google.cloud/plugins/connection/iap.py
2025-09-04 18:28:04 -07:00

777 lines
25 KiB
Python

# Copyright (c) 2025 Red Hat
# GNU General Public License v3.0+ https://www.gnu.org/licenses/gpl-3.0.txt
from __future__ import annotations
# I had to duplicate (almost?) all of the documentation found in the
# ansible.plugins.connection.ssh plugin, due to how ansible-doc and ansible-test sanity
# work, they look at the lexical structure of the code. I had initially done:
# 1. load ssh.py DOCUMENTATION string into a yaml object
# 2. modify the yaml object to change defaults / add my options
# 3. set this plugin's DOCUMENTATION to the yaml.dump() of the modified object
# but that doesn't work with how the AST evaluation is done.
# here are the changes from upstream:
# 1. Changed default private_key_file default to ~/.ssh/google_compute_engine
# 2. Make host_key_checking default to False
# 3. Added known_hosts_file option pointing to ~/.ssh/google_compute_known_hosts
DOCUMENTATION = """
name: iap
short_description: connect via SSH through Google Cloud's Identity Aware Proxy (IAP)
description:
- This connection plugin behaves almost like the stock SSH plugin, but it creates
a new IAP process per host in the inventory so connections are tunneled through
it.
author:
- Jorge A Gallegos (@thekad)
notes:
- This plugin requires you to have configured gcloud authentication prior to using
it. You can change the active configuration used, but the plugin won't auth
for you.
- This plugin is mostly a wrapper to the ``ssh`` CLI utility and the exact behavior
of the options depends on this tool. This means that the documentation provided
here is subject to be overridden by the CLI tool itself.
- Many options default to V(None) here but that only means we do not override the
SSH tool's defaults and/or configuration. For example, if you specify the port
in this plugin it will override any C(Port) entry in your C(.ssh/config).
- The ssh CLI tool uses return code 255 as a 'connection error', this can conflict
with commands/tools that also return 255 as an error code and will look like an
'unreachable' condition or 'connection error' to this plugin.
extends_documentation_fragment:
- connection_pipelining
options:
gcloud_executable:
description:
- Path to the gcloud executable, defaults to whatever is found in the PATH
environment variable.
type: string
vars:
- name: ansible_gcloud_executable
ini:
- section: gcloud
key: executable
gcloud_configuration:
description:
- If set, points to non-standard gcloud configuration.
type: string
vars:
- name: ansible_gcloud_configuration
ini:
- section: gcloud
key: configuration
env:
- name: CLOUDSDK_ACTIVE_CONFIG_NAME
gcloud_project:
description:
- The Google Cloud project ID to use for this invocation.
- If omitted, then the current active project is assumed.
type: string
vars:
- name: ansible_gcloud_project
ini:
- section: gcloud
key: project
env:
- name: CLOUDSDK_CORE_PROJECT
gcloud_account:
description:
- Google cloud account to use for invocation.
type: string
vars:
- name: ansible_gcloud_account
ini:
- section: gcloud
key: account
env:
- name: CLOUDSDK_CORE_ACCOUNT
gcloud_zone:
description:
- The Google Cloud zone to use for the instance(s).
type: string
vars:
- name: ansible_gcloud_zone
ini:
- section: gcloud
key: zone
env:
- name: CLOUDSDK_COMPUTE_ZONE
gcloud_access_token_file:
description:
- A file to read the access token from.
- The credentials of the active account (if exists) will be ignored.
type: string
vars:
- name: ansible_access_token_file
ini:
- section: gcloud
key: access_token_file
env:
- name: CLOUDSDK_AUTH_ACCESS_TOKEN_FILE
host:
description: Google Cloud instance name to connect to.
default: inventory_hostname
type: string
vars:
- name: inventory_hostname
- name: ansible_host
- name: ansible_gcloud_host
known_hosts_file:
description: Path to the UserKnownHosts file storing SSH fingerprints. Defaults
to the same file used by `gcloud compute ssh`
type: string
default: ~/.ssh/google_compute_known_hosts
ini:
- section: ssh_connection
key: known_hosts_file
vars:
- name: ansible_known_hosts_file
- name: ansible_ssh_known_hosts_file
host_key_checking:
description: Determines if SSH should reject or not a connection after checking
host keys.
default: false
type: boolean
ini:
- section: defaults
key: host_key_checking
- section: ssh_connection
key: host_key_checking
env:
- name: ANSIBLE_HOST_KEY_CHECKING
- name: ANSIBLE_SSH_HOST_KEY_CHECKING
vars:
- name: ansible_host_key_checking
- name: ansible_ssh_host_key_checking
password:
description:
- Authentication password for the O(remote_user). Can be supplied as CLI option.
type: string
vars:
- name: ansible_password
- name: ansible_ssh_pass
- name: ansible_ssh_password
password_mechanism:
description: Mechanism to use for handling ssh password prompt
type: string
default: ssh_askpass
choices:
- ssh_askpass
- sshpass
- disable
env:
- name: ANSIBLE_SSH_PASSWORD_MECHANISM
ini:
- section: ssh_connection
key: password_mechanism
vars:
- name: ansible_ssh_password_mechanism
sshpass_prompt:
description:
- Password prompt that C(sshpass)/C(SSH_ASKPASS) should search for.
- Supported by sshpass 1.06 and up when O(password_mechanism) set to V(sshpass).
- Defaults to C(Enter PIN for) when pkcs11_provider is set.
- Defaults to C(assword) when O(password_mechanism) set to V(ssh_askpass).
default: ''
type: string
ini:
- section: ssh_connection
key: 'sshpass_prompt'
env:
- name: ANSIBLE_SSHPASS_PROMPT
vars:
- name: ansible_sshpass_prompt
ssh_args:
description: arguments to pass to all ssh cli tools.
type: string
default: '-C -o controlmaster=auto -o controlpersist=60s'
ini:
- section: ssh_connection
key: ssh_args
env:
- name: ansible_ssh_args
vars:
- name: ansible_ssh_args
ssh_common_args:
description: common extra args for all ssh cli tools.
type: string
default: ''
ini:
- section: ssh_connection
key: ssh_common_args
env:
- name: ANSIBLE_SSH_COMMON_ARGS
vars:
- name: ansible_ssh_common_args
cli:
- name: ssh_common_args
ssh_executable:
description:
- This defines the location of the SSH binary. It defaults to V(ssh) which will
use the first SSH binary available in $PATH.
- This option is usually not required, it might be useful when access to system
SSH is restricted, or when using SSH wrappers to connect to remote hosts.
type: string
default: ssh
ini:
- key: ssh_executable
section: ssh_connection
env:
- name: ANSIBLE_SSH_EXECUTABLE
vars:
- name: ansible_ssh_executable
sftp_executable:
description:
- This defines the location of the sftp binary. It defaults to V(sftp) which
will use the first binary available in $PATH.
type: string
default: sftp
ini:
- key: sftp_executable
section: ssh_connection
env:
- name: ANSIBLE_SFTP_EXECUTABLE
vars:
- name: ansible_sftp_executable
scp_executable:
description:
- This defines the location of the scp binary. It defaults to V(scp) which will
use the first binary available in $PATH.
type: string
default: scp
ini:
- section: ssh_connection
key: scp_executable
env:
- name: ANSIBLE_SCP_EXECUTABLE
vars:
- name: ansible_scp_executable
scp_extra_args:
description: Extra exclusive to the C(scp) CLI
type: string
default: ''
ini:
- section: ssh_connection
key: scp_extra_args
env:
- name: ANSIBLE_SCP_EXTRA_ARGS
vars:
- name: ansible_scp_extra_args
cli:
- name: scp_extra_args
sftp_extra_args:
description: Extra exclusive to the C(sftp) CLI
type: string
default: ''
ini:
- section: ssh_connection
key: sftp_extra_args
vars:
- name: ansible_sftp_extra_args
env:
- name: ANSIBLE_SFTP_EXTRA_ARGS
cli:
- name: sftp_extra_args
ssh_extra_args:
description: Extra exclusive to the SSH CLI.
type: string
default: ''
vars:
- name: ansible_ssh_extra_args
env:
- name: ANSIBLE_SSH_EXTRA_ARGS
ini:
- section: ssh_connection
key: ssh_extra_args
cli:
- name: ssh_extra_args
reconnection_retries:
description:
- Number of attempts to connect.
- Ansible retries connections only if it gets an SSH error with a return code
of 255.
- Any errors with return codes other than 255 indicate an issue with program
execution.
type: integer
default: 0
env:
- name: ANSIBLE_SSH_RETRIES
ini:
- section: connection
key: retries
- section: ssh_connection
key: retries
vars:
- name: ansible_ssh_retries
port:
description: Remote port to connect to.
type: int
ini:
- section: defaults
key: remote_port
env:
- name: ANSIBLE_REMOTE_PORT
vars:
- name: ansible_port
- name: ansible_ssh_port
keyword:
- name: port
remote_user:
description:
- User name with which to login to the remote server, normally set by the
remote_user keyword.
- If no user is supplied, Ansible will let the SSH client binary choose the
user as it normally.
type: string
ini:
- section: defaults
key: remote_user
env:
- name: ANSIBLE_REMOTE_USER
vars:
- name: ansible_user
- name: ansible_ssh_user
cli:
- name: user
keyword:
- name: remote_user
private_key_file:
description:
- Path to private key file to use for authentication.
type: string
default: '~/.ssh/google_compute_engine'
ini:
- section: defaults
key: private_key_file
- section: gcloud
key: private_key_file
env:
- name: ANSIBLE_PRIVATE_KEY_FILE
vars:
- name: ansible_private_key_file
- name: ansible_ssh_private_key_file
- name: ansible_gcloud_private_key_file
cli:
- name: private_key_file
option: '--private-key'
private_key:
description:
- Private key contents in PEM format. Requires the C(SSH_AGENT) configuration
to be enabled.
type: string
env:
- name: ANSIBLE_PRIVATE_KEY
vars:
- name: ansible_private_key
- name: ansible_ssh_private_key
private_key_passphrase:
description:
- Private key passphrase, dependent on O(private_key).
- This does NOT have any effect when used with O(private_key_file).
type: string
env:
- name: ANSIBLE_PRIVATE_KEY_PASSPHRASE
vars:
- name: ansible_private_key_passphrase
- name: ansible_ssh_private_key_passphrase
control_path:
description:
- This is the location to save SSH's ControlPath sockets, it uses SSH's variable
substitution.
- Be aware that this setting is ignored if C(-o ControlPath) is set in ssh args.
type: string
env:
- name: ANSIBLE_SSH_CONTROL_PATH
ini:
- section: ssh_connection
key: control_path
vars:
- name: ansible_control_path
control_path_dir:
default: ~/.ansible/cp
description:
- This sets the directory to use for ssh control path if the control path
setting is null.
- Also, provides the ``%(directory)s`` variable for the control path setting.
type: string
env:
- name: ANSIBLE_SSH_CONTROL_PATH_DIR
ini:
- section: ssh_connection
key: control_path_dir
vars:
- name: ansible_control_path_dir
sftp_batch_mode:
description:
- When set to C(True), sftp will be run in batch mode, allowing detection of
transfer errors.
- When set to C(False), sftp will not be run in batch mode, preventing detection
of transfer errors.
type: bool
default: true
env:
- name: ANSIBLE_SFTP_BATCH_MODE
ini:
- section: ssh_connection
key: sftp_batch_mode
vars:
- name: ansible_sftp_batch_mode
ssh_transfer_method:
description: Preferred method to use when transferring files over ssh
type: string
default: smart
choices:
sftp: This is the most reliable way to copy things with SSH.
scp: Deprecated in OpenSSH. For OpenSSH >=9.0 you must add an additional option
to enable scp C(scp_extra_args="-O").
piped: Creates an SSH pipe with C(dd) on either side to copy the data.
smart: Tries each method in order (sftp > scp > piped), until one succeeds or
they all fail.
env:
- name: ANSIBLE_SSH_TRANSFER_METHOD
ini:
- section: ssh_connection
key: transfer_method
vars:
- name: ansible_ssh_transfer_method
use_tty:
description: add -tt to ssh commands to force tty allocation.
type: bool
default: true
env:
- name: ANSIBLE_SSH_USETTY
ini:
- section: ssh_connection
key: usetty
vars:
- name: ansible_ssh_use_tty
timeout:
description:
- This is the default amount of time we will wait while establishing an SSH
connection.
- It also controls how long we can wait to access reading the connection once
established (select on the socket).
type: integer
default: 10
env:
- name: ANSIBLE_TIMEOUT
- name: ANSIBLE_SSH_TIMEOUT
ini:
- section: defaults
key: timeout
- section: ssh_connection
key: timeout
vars:
- name: ansible_ssh_timeout
cli:
- name: timeout
pkcs11_provider:
description:
- PKCS11 SmartCard provider such as opensc, e.g. /usr/local/lib/opensc-pkcs11.so
type: string
default: ''
env:
- name: ANSIBLE_PKCS11_PROVIDER
ini:
- section: ssh_connection
key: pkcs11_provider
vars:
- name: ansible_ssh_pkcs11_provider
verbosity:
description:
- Requested verbosity level for the SSH CLI.
default: 0
type: int
env:
- name: ANSIBLE_SSH_VERBOSITY
ini:
- section: ssh_connection
key: verbosity
vars:
- name: ansible_ssh_verbosity
"""
import os
import re
import pty
import shlex
import select
import shutil
import subprocess
import threading
import time
import tempfile
import typing as T
from os import path as ospath
from ansible.plugins.connection import ssh as sshconn
from ansible import errors
from ansible.utils import display
D = display.Display()
DEFAULT_GCLOUD: T.Optional[str] = shutil.which("gcloud")
DEFAULT_SSH_PORT: int = 22
PORT_REGEX = re.compile(r"\d+")
class IAP:
host: str
local_port: int
remote_port: int
master_fd: int
up: bool = False
process: T.Optional[subprocess.Popen] = None
thread: T.Optional[threading.Thread] = None
ready: threading.Event = threading.Event()
output: T.List[str] = []
def __init__(
self,
gcloud_bin: str,
host: str,
remote_port: int,
project: T.Optional[str],
account: T.Optional[str],
zone: T.Optional[str],
config: T.Optional[str] = None,
token_file: T.Optional[str] = None,
) -> None:
self.host = host
self.remote_port = remote_port
cmd: T.List[str] = [
gcloud_bin,
"compute",
"start-iap-tunnel",
host,
str(self.remote_port),
]
if config is not None:
cmd.extend(
[
"--configuration",
shlex.quote(ospath.realpath(ospath.expanduser(config.strip()))),
]
)
if project is not None:
cmd.extend(
[
"--project",
shlex.quote(project.strip()),
]
)
if account is not None:
cmd.extend(
[
"--account",
shlex.quote(account.strip()),
]
)
if zone is not None:
cmd.extend(
[
"--zone",
shlex.quote(zone.strip()),
]
)
if token_file is not None:
cmd.extend(
[
"--access-token-file",
shlex.quote(token_file.strip()),
]
)
D.vvv(f"IAP: CMD {' '.join(cmd)}", host=self.host)
try:
# start-iap-tunnel prints 2 lines:
# - Picking local unused port [$PORT].
# - Testing if tunnel connection works.
# and only when the terminal is a pty, a 3rd line:
# - Listening on port [$PORT].
# The last line only displayed after the tunnel has been tested,
# that's why we use a PTY for the subprocess
self.master_fd, slave_fd = pty.openpty()
self.process = subprocess.Popen(
cmd, stdout=slave_fd, stderr=slave_fd, text=True, close_fds=True
)
os.close(slave_fd)
self.thread = threading.Thread(target=self._monitor, daemon=True)
self.thread.start()
D.vvvvv("started IAP thread", host=self.host)
except Exception as e:
self.process = None
raise Exception from e
def _monitor(self) -> None:
"""Monitor the thread handling the IAP subprocess until it is 'up'"""
while self.process is not None and self.process.poll() is None:
# pylint: disable=disallowed-name
rlist, _, _ = select.select([self.master_fd], [], [], 0.1)
if rlist is not None:
try:
output = os.read(self.master_fd, 1024).decode("utf-8")
if output:
for line in output.splitlines():
self.output.append(line)
if line.startswith("Listening on port"):
m = PORT_REGEX.search(line)
if m is not None:
self.local_port = int(m.group())
self.up = True
D.vvv(
f"IAP: LOCAL PORT {self.local_port}",
host=self.host,
)
except OSError: # pty is closed
break
if self.up: # no need to monitor if already up
break
if not self.ready.is_set():
self.ready.set()
os.close(self.master_fd)
def terminate(self) -> None:
"""Gracefully terminate the IAP subprocess"""
D.vvv("IAP: STOPPING TUNNEL", host=self.host)
if self.process is not None and self.process.poll() is None:
try:
self.process.terminate()
self.process.wait(timeout=5) # wait up to 5 seconds to terminate IAP
except subprocess.TimeoutExpired:
self.process.kill()
D.vvvvv("terminated/killed IAP", host=self.host)
if self.thread is not None and self.thread.is_alive():
self.thread.join(timeout=1) # joining thread back should be quick
class Connection(sshconn.Connection):
"""
This is pretty much the same as the upstream ssh plugin, just overloads
the connection handling to start/stop the IAP tunnel with gcloud as appropriate
"""
iaps: dict[str, IAP] = {}
lock: threading.Lock = threading.Lock()
gcloud_executable: T.Optional[str] = None
ssh_config: str
transport = "gcloud-iap" # type: ignore[override]
def __init__(self, *args: T.Any, **kwargs: T.Any) -> None:
super(Connection, self).__init__(*args, **kwargs)
# If the gcloud binary isn't found/configured, bail out immediately
exec: T.Optional[str] = self.get_option("gcloud_executable")
if exec is None:
self.gcloud_executable = DEFAULT_GCLOUD
else:
self.gcloud_executable = exec
if self.gcloud_executable is None:
raise errors.AnsiblePluginError(
"Plugin Error: no gcloud binary found in $PATH and "
"no executable defined in ansible config"
)
def _connect(self) -> Connection:
"""Upstream ssh is empty, overload with the stuff starting the IAP tunnel"""
host: T.Optional[str] = self.get_option("host")
project: T.Optional[str] = self.get_option("gcloud_project")
account: T.Optional[str] = self.get_option("gcloud_account")
zone: T.Optional[str] = self.get_option("gcloud_zone")
token_file: T.Optional[str] = self.get_option("gcloud_access_token_file")
config: T.Optional[str] = self.get_option("gcloud_configuration")
port: T.Optional[int] = self.get_option("port")
timeout: T.Optional[int] = self.get_option("timeout")
# this shouldn't happen, but still.
if host is None:
raise errors.AnsibleAssertionError("No host defined")
with self.lock:
if host not in self.iaps:
self.iaps[host] = IAP(
str(self.gcloud_executable),
host=host,
remote_port=int(port or DEFAULT_SSH_PORT),
project=project,
zone=zone,
account=account,
config=config,
token_file=token_file,
)
success = self.iaps[host].ready.wait(timeout=timeout)
is_up: bool = False
for _ in range(3): # pylint: disable=disallowed-name
is_up = self.iaps[host].up
if success and is_up:
D.vvv("IAP: TUNNEL IS UP", host=host)
is_up = True
break
else:
time.sleep(0.5)
if not is_up:
D.vvv("IAP: TUNNEL FAILURE", host=host)
for line in self.iaps[host].output:
D.vvvvv(line, host=host)
raise errors.AnsibleRuntimeError("Failure when starting IAP tunnel")
# override port with the random IAP port
self.set_option("port", self.iaps[host].local_port)
# read path to the supplied known hosts file
ukhf: str = ospath.abspath(
ospath.expanduser(str(self.get_option("known_hosts_file")))
)
# have to trick SSH to connect to localhost instead of the instances
fd, self.ssh_config = tempfile.mkstemp(
suffix="ssh_config", prefix="ansible_gcloud", text=True
)
with open(fd, "w") as fp:
fp.write("Host *\n")
fp.write(" HostName localhost\n") # trick
fp.write(" HostKeyAlias {}\n".format(host)) # avoid multiple entries
fp.write(" UserKnownHostsFile {}\n".format(ukhf)) # as defined in opts
# prepend our generated ssh config to all ssh_args if not already present
if self.ssh_config not in str(self.get_option("ssh_args")):
self.set_option(
"ssh_args", f"-F {self.ssh_config} " + str(self.get_option("ssh_args"))
)
self._connected = True
return self
def close(self) -> None:
"""
Upstream only marks the connection as closed, we have to terminate
all IAP tunnels as well
"""
# Terminate IAP
with self.lock:
for iap in self.iaps.values():
iap.terminate()
self.iaps.clear()
# remove ssh config
os.unlink(self.ssh_config)
self._connected = False