533 lines
20 KiB
Python

"""Connection backend implementation file."""
from vpn_manager.service.connections.base import ConnectionBase, ConfigSpec
from typing import TypedDict, Required
from enum import IntEnum, StrEnum
from asyncio import (
create_subprocess_exec,
get_running_loop,
sleep,
timeout,
CancelledError,
)
from asyncio.subprocess import Process, PIPE, DEVNULL
from dbus_fast import Variant
from sys import platform
import requests
from vpn_manager.utils import unwrap_variant
from .insecure_tls import TLSAdapter
import logging
import ssl
from . import errors
from .auth_flows import AuthFlows
import base64
import re
import psutil
import signal
from datetime import timedelta
from vpn_manager.service.inhibitor import Inhibitor
class LoginTarget(StrEnum):
"""
Login target enum.
Has two methods for selecting the target, using the GlobalProtect
client's names, and the more technical names that specify
"""
GATEWAY = "gateway"
PORTAL = "portal"
SSL = "ssl"
IPSEC = "ipsec"
class Options(TypedDict, total=False):
"""Options type definition for GlobalProtect VPNs."""
hostname: Required[str]
verify_certificate: bool
login_target: Required[LoginTarget]
allow_insecure_crypto: bool
spoof_clientos: str
use_default_browser: bool
auth_cache_timeout: int
# If set, will attempt to open the in-browser request using
# a Firefox "Open external links in a container" plugin URI,
# using the container name set here.
firefox_browser_container: str
class Auth(TypedDict):
"""Auth type definition for GlobalProtect VPNs."""
username: str
cookie: str
class GlobalProtectConnection(
ConnectionBase,
connection_type="com.paloaltonetworks.GlobalProtect",
):
"""GlobalProtect VPN connection backend."""
_proc: Process | None = None
_pid: int | None = None
_disconnecting: bool = False
PLATFORM_TO_CLIENTOS = {
"linux": "Linux",
"darwin": "Mac",
"win32": "Windows",
"cygwin": "Windows",
}
logger = logging.getLogger(f"{__name__}.GlobalProtectConnection")
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self._inhibitor = Inhibitor(
Inhibitor.What.sleep,
"vpn-manager",
"Disconnect from VPN before sleep",
"delay",
)
async def inhibitor_disconnect():
await self.disconnect(timeout_sec=2)
self._inhibitor.on_sleep_or_shutdown(inhibitor_disconnect)
def get_auth_cache_timeout(self, options: dict[str, Variant]) -> timedelta | None:
"""Get the timeout for the authdata cache."""
timeout = options.get("auth_cache_timeout")
if timeout is None:
return None
return timedelta(seconds=timeout.value)
async def _prelogin(self, options: dict[str, Variant]):
"""Perform prelogin request."""
self.logger.debug("Begin preauth with options:", options)
## Extract options from vardict
if "spoof_clientos" in options:
clientos = options["spoof_clientos"].value
else:
clientos = self.PLATFORM_TO_CLIENTOS.get(platform)
if clientos is None:
raise errors.PlatformError(
f"Platform {platform} is not supported. Specify a client OS to spoof in the connection options."
)
verify = unwrap_variant(options.get("verify_certificate", True))
if not verify:
# Disabling certificate verification is extremely dangerous and should only be done if you KNOW WHAT YOU ARE DOING
self.logger.warn("Certificate verification disabled! This is dangerous!")
if options.get("login_target").value in {LoginTarget.GATEWAY, LoginTarget.SSL}:
url = f"{options.get('hostname').value}/ssl-vpn/prelogin.esp"
else:
url = f"{options.get('hostname').value}/global-protect/prelogin.esp"
allow_insecure_crypto = unwrap_variant(
options.get("allow_insecure_crypto", False)
)
use_default_browser = unwrap_variant(options.get("use_default_browser", True))
## Configure session
session = requests.Session()
# User-agent must be set to 'PAN GlobalProtect' or the subsequent SAML request will fail.
session.headers["User-Agent"] = "PAN GlobalProtect"
if allow_insecure_crypto:
# Insecure crypto is dangerous, log a warning
self.logger.warn(
"Using insecure crypto for prelogin request! This is dangerous!"
)
session.mount("https://", TLSAdapter(verify=verify))
data = {
"ipv6-support": "yes",
"clientos": clientos,
"clientVer": 4100, # This needs to be hardcoded apparently.
"tmp": "tmp", # No idea what this is for, but other GP OpenConnect wrappers set it so we do too
"cas-support": "yes",
"default-browser": (
1 if use_default_browser else 0
), # This will influence which auth flow we ultimately use, but the server may reject our request to use the default browser.
}
try:
res = session.post(url, verify=verify, data=data)
except Exception as ex:
# Get the root exception
rootex = ex
while True:
if isinstance(rootex, ssl.SSLError):
break
elif not rootex.__cause__ and not rootex.__context__:
break
rootex = rootex.__cause__ or rootex.__context__
if isinstance(rootex, ssl.CertificateError):
# Raise a D-Bus certificate error
self.logger.error(f"SSL certificate error: {rootex}")
raise errors.CertificateError(str(rootex))
elif isinstance(rootex, ssl.SSLError):
# Raise a D-Bus SSL error
self.logger.error(f"Generic SSL error: {rootex}")
raise errors.SSLError(str(rootex))
else:
# Raise a D-Bus generic prelogin failure
raise errors.PreloginFailure(f"An unexpected error occurred: {rootex}")
self.logger.debug("Got XML response:", res.text)
# Pass XML parsing off to agent for security reasons
result = await self._manager.request_credentials(
AuthFlows.GLOBALPROTECT_PARSE_PRELOGIN, Variant("s", res.text)
)
if result.signature != "a{ss}":
# Make sure that the agent is well-behaved and is returning a valid response for this request.
raise errors.PreloginFailure(
"Received unexpected response from agent. Expected strdict (a{ss}), got "
f"{result.signature}"
)
self.logger.debug("Got parsed result", result.value)
return result.value
async def authenticate(self, options: dict[str, Variant]) -> Variant:
f"""{super().authenticate.__doc__}"""
self.logger.info("Starting GlobalProtect authentication.")
preauth: dict[str, str] = await self._prelogin(options)
if "saml-auth-method" not in preauth or "saml-request" not in preauth:
raise errors.AuthFlowUnsupported(
"Server requested unsupported authentication flow. We currently only support SAML auth for GlobalProtect."
)
auth_flow = (
AuthFlows.GLOBALPROTECT_SAML_DEFAULT_BROWSER
if preauth.get("saml-default-browser") == "yes"
and preauth.get("saml-auth-method") == "REDIRECT"
else AuthFlows.GLOBALPROTECT_SAML_INTEGRATED
)
if (
auth_flow == AuthFlows.GLOBALPROTECT_SAML_DEFAULT_BROWSER
and "firefox_browser_container" in options
):
auth_result = await self._manager.request_credentials(
auth_flow,
Variant(
"(ss)",
(
base64.b64decode(preauth.get("saml-request")).decode("utf-8"),
options["firefox_browser_container"].value,
),
),
)
else:
auth_result = await self._manager.request_credentials(
auth_flow,
Variant(
"s", base64.b64decode(preauth.get("saml-request")).decode("utf-8")
),
)
if auth_result.signature != "a{ss}":
# Make sure the agent is well-behaved and is returning a valid response for this request.
raise errors.AuthenticationFailure(
"Received unexpected auth data. Expected strdict (a{ss}), got "
f"{auth_result.signature}"
)
if auth_result.value.get("saml-auth-status") != "1":
raise errors.AuthenticationFailure(
"Server indicates authentication failure"
)
# Guard to make sure the attributes we use to authenticate to the VPN are actually available.
if "prelogin-cookie" not in auth_result.value:
raise errors.InvalidResponse("Did not receive cookie from server")
if "saml-username" not in auth_result.value:
raise errors.InvalidResponse("Did not receive SAML username from server")
return auth_result
async def connect(self, options: dict[str, Variant], auth: Variant):
f"""{super().connect.__doc__}"""
if self._proc and self._proc.is_running():
self.logger.error(
"Cowardly refusing to connect to VPN while already connected."
)
raise errors.ConnectionFailure(
"Cowardly refusing to connect to VPN while already connected."
)
# Take logind inhibitor
try:
await self._inhibitor.acquire()
except:
self.logger.warning(
f"Unable to acquire sleep delay inhibitor. Networking will be broken on resume from suspend."
)
# Create subprocess
proc = await create_subprocess_exec(
"/usr/bin/openconnect",
"--protocol=gp",
f"--user={auth.value.get('saml-username')}",
"--useragent=PAN GlobalProtect",
f"--usergroup={options.get('login_target', Variant('s', LoginTarget.PORTAL)).value in {LoginTarget.PORTAL, LoginTarget.IPSEC} and 'portal' or 'gateway'}:prelogin-cookie",
"--passwd-on-stdin",
options.get("hostname").value,
"-b",
stdin=PIPE,
stdout=PIPE,
stderr=PIPE,
)
self.logger.debug(f"Initial process PID: {proc.pid}")
# Send prelogin cookie
proc.stdin.write(auth.value.get("prelogin-cookie").encode())
proc.stdin.close()
# Create logger for openconnect
openconnect_logger = logging.getLogger(
f"{__name__}.GlobalProtectConnection.openconnect"
)
# Spawn task to bridge stderr to log
self._stderr_task = get_running_loop().create_task(
self._bridge_output(proc.stderr, openconnect_logger.warning)
)
# Bridge stdout to log and also search for PID of forked process, with a timeout of 10 seconds after which we assume a connection failure and terminate the process.
self._pid = None
try:
async with timeout(10):
async for line in proc.stdout:
decoded = line.decode("utf-8").strip()
if decoded == "":
continue
openconnect_logger.debug(decoded)
if match_ := re.match(
r"Continuing in background; pid ([0-9]+)", decoded
):
self._pid = int(match_.group(1))
break
except TimeoutError:
self.logger.error("Connection timed out")
proc.kill()
await proc.wait()
raise errors.ConnectionFailure("Connection timed out")
if self._pid is None:
# Never received PID of child, if we get here then openconnect exited without reporting a PID, indicating a failure to connect.
self.logger.error("VPN failed to connect")
raise errors.ConnectionFailure()
self.logger.debug(f"Got PID for openconnect: {self._pid}")
# Spawn task to bridge remainder of stdout to log
self._stdout_task = get_running_loop().create_task(
self._bridge_output(proc.stdout, openconnect_logger.debug)
)
self.logger.info("Connection established")
self.logger.debug(f"openconnect PID: {self._pid}")
self._proc = psutil.Process(self._pid)
self._wait_task = get_running_loop().create_task(self._wait_for_exit())
# Create a task that will wait for the process to exit so we don't leave zombies around
# (for some reason, proc.wait() waits until the streams are all closed as well :/)
self._proc_wait_task = get_running_loop().create_task(proc.wait())
async def _bridge_output(self, stream, processor):
async for line in stream:
decoded = line.decode("utf-8").strip()
if decoded == "":
# Don't log a blank line
continue
processor(line.decode("utf-8").strip())
self.logger.debug(f"Stream {stream} closed")
async def _wait_for_exit(self):
"""Wait for self._proc to exit."""
try:
while self._proc.is_running():
# Check process every 5 seconds
# I'd prefer a proper-async way to do this, but i guess this will work.
self.logger.debug("openconnect process is running")
await sleep(5)
self.logger.debug("openconnect has exited")
if not self._disconnecting:
# Only call on_disconnected if the disconnect was asynchronous
self._connection.on_disconnected()
self._disconnecting = False
except CancelledError:
self.logger.debug("exit wait canceled.")
finally:
self._inhibitor.release()
async def disconnect(self, *, timeout_sec: int = 10):
f"""{super().disconnect.__doc__}"""
if self._proc is None:
self.logger.warn(
"Cowardly refusing to disconnect from a VPN that is not connected"
)
return
if self._wait_task is not None:
self._wait_task.cancel()
self._disconnecting = True
try:
self._proc.terminate()
except:
# Process doesn't exist, we are in the state we want so... :shrug: this is fine
return
# If the process takes longer than 10 seconds to exit, kill it sith WIGKILL
try:
async with timeout(timeout_sec):
await self._proc_wait_task
except TimeoutError:
self._proc.kill()
async def temporary_disconnect(self):
f"""{super().temporary_disconnect.__doc__}"""
if self._proc is None:
self.logger.warn(
"Cowardly refusing to disconnect from a VPN that is not connected"
)
return
if self._wait_task is not None:
self._wait_task.cancel()
self._disconnecting = True
self._proc.send_signal(signal.SIGHUP)
await self._proc_wait_task
async def reconnect(self):
f"""{super().reconnect.__doc__}"""
if self._proc is None:
self.logger.warn(
"Cowardly refusing to atomically reconnect to a VPN that is not connected"
)
return
self._proc.send_signal(signal.SIGUSR2)
@classmethod
def validate_options(cls, options: dict[str, Variant]):
# options must contain hostname and login target.
if "hostname" not in options:
raise errors.InvalidOptions.missing("hostname")
if "login_target" not in options:
raise errors.InvalidOptions.missing(
"login_target", details="What login endpoint to use."
)
# validate types
if sig := options["hostname"].signature != "s":
raise errors.InvalidOptions.invalid_type("hostname", "s", sig)
if sig := options["login_target"].signature != "s":
raise errors.InvalidOptions.invalid_type("login_target", "s", sig)
if (
sig := options.get("verify_certificate", Variant("b", True)).signature
!= "b"
):
raise errors.InvalidOptions.invalid_type("verify_certifcate", "b", sig)
if (
sig := options.get("allow_insecure_crypto", Variant("b", True)).signature
!= "b"
):
raise errors.InvalidOptions.invalid_type("verify_certificate", "b", sig)
if sig := options.get("spoof_clientos", Variant("s", "")).signature != "s":
raise errors.InvalidOptions.invalid_type("spoof_clientos", "s", sig)
if (
sig := options.get("use_default_browser", Variant("b", True)).signature
!= "b"
):
raise errors.InvalidOptions.invalid_type("use_default_browser", "b", sig)
if (
sig := options.get("auth_cache_timeout", Variant("u", True)).signature
!= "u"
):
raise errors.InvalidOptions.invalid_type("auth_cache_timeout", "u", sig)
if (
sig := options.get("firefox_browser_container", Variant("s", "")).signature
!= "s"
):
raise errors.InvalidOptions.invalid_type("use_default_browser", "s", sig)
@classmethod
def restore_options(cls, options: dict) -> dict[str, Variant]:
result = {}
cls.put_value(result, "s", options, "hostname")
cls.put_value(result, "s", options, "login_target")
cls.put_value(result, "s", options, "spoof_clientos")
cls.put_value(result, "b", options, "verify_certificate")
cls.put_value(result, "b", options, "allow_insecure_crypto")
cls.put_value(result, "b", options, "use_default_browser")
cls.put_value(result, "u", options, "auth_cache_timeout")
cls.put_value(result, "s", options, "firefox_browser_container")
return result
@classmethod
def get_config_spec(cls):
"""See parent."""
return cls._build_config_spec(
ConfigSpec(
name="hostname",
signature="s",
description="Hostname of the VPN server",
required=True,
),
ConfigSpec(
name="login_target",
signature="s",
description="What kind of connection to create (IPSec or SSL)",
choices=[
LoginTarget.PORTAL,
LoginTarget.GATEWAY,
LoginTarget.SSL,
LoginTarget.IPSEC,
],
default=LoginTarget.IPSEC,
),
ConfigSpec(
name="spoof_clientos",
signature="s",
description="Custom OS identifier to use instead of autodetected OS",
),
ConfigSpec(
name="verify_certificate",
signature="b",
description="Whether to verify the certificate provided by the server. DISABLING THIS IS DANGEROUS!!!",
default=True,
),
ConfigSpec(
name="allow_insecure_crypto",
signature="b",
description="Whether to allow older, insecure TLS versions when connecting to the server. ENABLING THIS IS DANGEROUS!!!",
default=False,
),
ConfigSpec(
name="use_default_browser",
signature="b",
description="Whether to request use of the default browser when authenticating. The server may not allow this.",
default=True,
),
ConfigSpec(
name="auth_cache_timeout",
signature="u",
description="The timeout on caching of authentication results for reconnecting after unexpected or temporary disconnects. This should never be longer than the session timeout. Leaving empty disables caching entirely.",
),
ConfigSpec(
name="firefox_browser_container",
signature="s",
description="Specify a browser container to open the authentication page in. Requires Firefox and the 'Open external links in container' plugin",
),
)