From ebc33ad9475100eca48a7db4d76fd8316f30c6dc Mon Sep 17 00:00:00 2001 From: Roland Walker Date: Tue, 11 Aug 2026 06:27:04 -0400 Subject: [PATCH] optionally guide auth before boundary tunnel Optionally, based on ~/.myclirc settings, give the user a chance to perform a guided re-authentication to boundary before attempting to establish a tunnel. Otherwise, establishing a tunnel simply fails when the user is not authenticated. Requires moving $BOUNDARY_ADDR into the common process environment. This may work less well with some authentication methods, but was tested with OIDC. Incidentally remove some repetition of the word Boundary from error messages. --- mycli/boundary_tunnel.py | 122 +++++++- mycli/client_connection.py | 4 + mycli/myclirc | 12 + test/myclirc | 12 + test/pytests/test_boundary_tunnel.py | 389 ++++++++++++++++++++++++- test/pytests/test_client_connection.py | 31 +- 6 files changed, 547 insertions(+), 23 deletions(-) diff --git a/mycli/boundary_tunnel.py b/mycli/boundary_tunnel.py index d16dbfcd0..f70762b59 100644 --- a/mycli/boundary_tunnel.py +++ b/mycli/boundary_tunnel.py @@ -1,13 +1,19 @@ from __future__ import annotations +from collections.abc import Iterator +from contextlib import ExitStack, contextmanager import datetime import json import os import shlex import socket import subprocess +import sys import threading import time +from typing import IO + +from mycli.compat import WIN class BoundaryTunnelError(RuntimeError): @@ -20,6 +26,32 @@ def _find_free_local_port() -> int: return int(sock.getsockname()[1]) +@contextmanager +def _authentication_terminal() -> Iterator[tuple[IO[str], IO[str]]]: + if sys.stdin.isatty() and sys.stderr.isatty(): + yield sys.stdin, sys.stderr + return + + with ExitStack() as stack: + try: + if WIN: + terminal_input = stack.enter_context(open('CONIN$', 'r', encoding='utf-8')) + terminal_output = stack.enter_context(open('CONOUT$', 'w', encoding='utf-8')) + else: + terminal_input = terminal_output = stack.enter_context(open('/dev/tty', 'r+', encoding='utf-8')) + except OSError as exc: + raise BoundaryTunnelError('Unable to open a terminal for Boundary authentication.') from exc + yield terminal_input, terminal_output + + +def _prompt_for_authentication(terminal_input: IO[str], terminal_output: IO[str], prompt: str) -> str: + print(prompt, file=terminal_output, end='', flush=True) + response = terminal_input.readline() + if not response: + raise BoundaryTunnelError('Unable to read a response from the terminal.') + return response.rstrip('\r\n') + + class BoundaryTunnel: def __init__( self, @@ -29,6 +61,8 @@ def __init__( address: str | None = None, auth_method_id: str | None = None, boundary_options: str | None = None, + boundary_test_command: str | None = None, + boundary_auth_command: str | None = None, local_port: int | None = None, ready_timeout: float = 30.0, ) -> None: @@ -37,6 +71,8 @@ def __init__( self.address = address self.auth_method_id = auth_method_id self.boundary_options = boundary_options + self.boundary_test_command = boundary_test_command + self.boundary_auth_command = boundary_auth_command self.local_host = '127.0.0.1' self.local_port = local_port or _find_free_local_port() self.ready_timeout = ready_timeout @@ -68,6 +104,7 @@ def command(self) -> list[str]: return command def start(self, *, show_expiration_warning: bool = True) -> None: + self._authenticate_if_needed() self._thread = threading.Thread(target=self._run, name='mycli-boundary-tunnel', daemon=True) self._thread.start() deadline = time.monotonic() + self.ready_timeout @@ -78,7 +115,7 @@ def start(self, *, show_expiration_warning: bool = True) -> None: time.sleep(0.05) else: self.close() - raise BoundaryTunnelError('Timed out waiting for Boundary tunnel process to start.') + raise BoundaryTunnelError('Timed out waiting for tunnel process to start.') while time.monotonic() < deadline: self._raise_if_failed() @@ -87,18 +124,18 @@ def start(self, *, show_expiration_warning: bool = True) -> None: time.sleep(0.05) else: self.close() - raise BoundaryTunnelError('Timed out waiting for Boundary tunnel process output.') + raise BoundaryTunnelError('Timed out waiting for tunnel process output.') connection_details = json.loads(self.stdout) if 'status_code' in connection_details: - raise BoundaryTunnelError(f'Boundary tunnel CLI raised status code {connection_details["status_code"]}.') + raise BoundaryTunnelError(f'Tunnel CLI raised status code {connection_details["status_code"]}.') try: self.username = connection_details['credentials'][0]['secret']['decoded']['username'] self.password = connection_details['credentials'][0]['secret']['decoded']['password'] except (IndexError, KeyError): - raise BoundaryTunnelError('Boundary tunnel CLI did not return credentials.') from None + raise BoundaryTunnelError('Tunnel CLI did not return credentials.') from None expiry_raw = connection_details['expiration'] expiry_utc = datetime.datetime.strptime(expiry_raw, '%Y-%m-%dT%H:%M:%S.%f%z') @@ -112,15 +149,15 @@ def start(self, *, show_expiration_warning: bool = True) -> None: return time.sleep(0.05) self.close() - raise BoundaryTunnelError('Timed out waiting for Boundary tunnel to become ready.') + raise BoundaryTunnelError('Timed out waiting for tunnel to become ready.') def _raise_if_failed(self) -> None: if not self._failed.is_set(): return self.close() if self._startup_error is not None: - raise BoundaryTunnelError(f'Unable to start Boundary tunnel process: {self._startup_error}') from self._startup_error - raise BoundaryTunnelError('Boundary tunnel process exited before it was ready.') + raise BoundaryTunnelError(f'Unable to start tunnel process: {self._startup_error}') from self._startup_error + raise BoundaryTunnelError('Tunnel process exited before it was ready.') def _read_stdout(self) -> None: process = self.process @@ -129,12 +166,77 @@ def _read_stdout(self) -> None: self.stdout = process.stdout.readline().decode('utf-8') def _environment(self) -> dict[str, str] | None: - if not self.auth_method_id: - return None environment = os.environ.copy() - environment['BOUNDARY_AUTH_METHOD_ID'] = self.auth_method_id + if self.auth_method_id: + environment['BOUNDARY_AUTH_METHOD_ID'] = self.auth_method_id + if self.address: + environment['BOUNDARY_ADDR'] = self.address return environment + def _authenticate_if_needed(self) -> None: + if not self.boundary_test_command or not self.boundary_auth_command: + return + + test_command = self._parse_authentication_command(self.boundary_test_command, 'test') + try: + completed_process = subprocess.run( + test_command, + check=False, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + env=self._environment(), + ) + except OSError as exc: + raise BoundaryTunnelError(f'Unable to run test command: {exc}') from exc + if completed_process.returncode == 0: + return + + with _authentication_terminal() as (terminal_input, terminal_output): + yn = _prompt_for_authentication( + terminal_input, + terminal_output, + 'Authenticate with Boundary before connecting? [Yn] ', + ).lower() + if yn not in ('y', ''): + raise BoundaryTunnelError('Not authenticated.') + + auth_command = self._parse_authentication_command(self.boundary_auth_command, 'authentication') + try: + completed_process = subprocess.run( + auth_command, + check=False, + stdin=terminal_input, + stdout=terminal_output, + stderr=terminal_output, + env=self._environment(), + ) + except OSError as exc: + raise BoundaryTunnelError(f'Unable to run authentication command: {exc}') from exc + if completed_process.returncode != 0: + raise BoundaryTunnelError(f'Authentication command exited with status {completed_process.returncode}.') + + _prompt_for_authentication( + terminal_input, + terminal_output, + 'Press return to continue after authenticating: ', + ) + + @staticmethod + def _parse_authentication_command(command: str, name: str) -> list[str]: + try: + arguments = shlex.split(command, posix=not WIN) + except ValueError as exc: + raise BoundaryTunnelError(f'Unable to parse {name} command: {exc}') from exc + if WIN: + arguments = [ + argument[1:-1] if len(argument) >= 2 and argument[0] == argument[-1] and argument[0] in ('"', "'") else argument + for argument in arguments + ] + if not arguments: + raise BoundaryTunnelError(f'{name} command is empty.') + return arguments + def close(self) -> None: process = self.process if process is not None and process.poll() is None: diff --git a/mycli/client_connection.py b/mycli/client_connection.py index 16d3c4e05..900c9d0e7 100644 --- a/mycli/client_connection.py +++ b/mycli/client_connection.py @@ -136,6 +136,8 @@ def connect( boundary_address = self.config.get('boundary_beta', {}).get('address') or None boundary_auth_method_id = self.config.get('boundary_beta', {}).get('auth_method_id') or None boundary_options = self.config.get('boundary_beta', {}).get('boundary_options') or None + boundary_test_command = self.config.get('boundary_beta', {}).get('boundary_test_command') or None + boundary_auth_command = self.config.get('boundary_beta', {}).get('boundary_auth_command') or None try: self.boundary_tunnel = BoundaryTunnel( target_id=boundary_target_id, @@ -143,6 +145,8 @@ def connect( address=boundary_address, auth_method_id=boundary_auth_method_id, boundary_options=boundary_options, + boundary_test_command=boundary_test_command, + boundary_auth_command=boundary_auth_command, ) self.boundary_tunnel.start(show_expiration_warning=self.verbosity >= 0) if self.verbosity >= 0: diff --git a/mycli/myclirc b/mycli/myclirc index e1545849a..4c750e794 100644 --- a/mycli/myclirc +++ b/mycli/myclirc @@ -435,6 +435,18 @@ auth_method_id = # Additional options passed to the "boundary connect" command. boundary_options = +# Command used to test whether Boundary authentication is valid. +# If empty, establishing the tunnel will fail when not authenticated. +# If set, boundary_auth_command must also be set. +# Example: boundary targets read -id ttcp_zzzzzzzzzz +boundary_test_command = + +# Command used to authenticate when boundary_test_command exits nonzero. +# If empty, establishing the tunnel will fail when not authenticated. +# If set, boundary_test_command must also be set. +# Example: boundary authenticate oidc +boundary_auth_command = + # Custom colors for the completion menu, toolbar, etc, with actual support # depending on the terminal, and the property being set. # Colors: #ffffff, bg:#ffffff, border:#ffffff. diff --git a/test/myclirc b/test/myclirc index cbaf13a80..a4bcd270d 100644 --- a/test/myclirc +++ b/test/myclirc @@ -435,6 +435,18 @@ auth_method_id = # Additional options passed to the "boundary connect" command. boundary_options = +# Command used to test whether Boundary authentication is valid. +# If empty, establishing the tunnel will fail when not authenticated. +# If set, boundary_auth_command must also be set. +# Example: boundary targets read -id ttcp_zzzzzzzzzz +boundary_test_command = + +# Command used to authenticate when boundary_test_command exits nonzero. +# If empty, establishing the tunnel will fail when not authenticated. +# If set, boundary_test_command must also be set. +# Example: boundary authenticate oidc +boundary_auth_command = + # Custom colors for the completion menu, toolbar, etc, with actual support # depending on the terminal, and the property being set. # Colors: #ffffff, bg:#ffffff, border:#ffffff. diff --git a/test/pytests/test_boundary_tunnel.py b/test/pytests/test_boundary_tunnel.py index fcb7cc646..0aaed81b4 100644 --- a/test/pytests/test_boundary_tunnel.py +++ b/test/pytests/test_boundary_tunnel.py @@ -1,9 +1,12 @@ from __future__ import annotations +from contextlib import nullcontext +from io import StringIO import os import socket import subprocess import threading +from types import SimpleNamespace from typing import Any, cast import pytest @@ -72,7 +75,10 @@ def test_boundary_tunnel_command_splits_options_before_generated_flags() -> None def test_boundary_tunnel_environment_uses_parent_default(auth_method_id: str | None) -> None: tunnel = BoundaryTunnel(target_id='ttcp_123', auth_method_id=auth_method_id) - assert tunnel._environment() is None + environment = tunnel._environment() + + assert environment is not None + assert environment == dict(os.environ) def test_boundary_tunnel_environment_sets_configured_auth_method(monkeypatch: pytest.MonkeyPatch) -> None: @@ -86,6 +92,347 @@ def test_boundary_tunnel_environment_sets_configured_auth_method(monkeypatch: py assert os.environ['BOUNDARY_AUTH_METHOD_ID'] == 'ampw_parent' +def test_boundary_tunnel_environment_sets_configured_address(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv('BOUNDARY_ADDR', 'https://parent.example.com') + tunnel = BoundaryTunnel(target_id='ttcp_123', address='https://config.example.com') + + environment = tunnel._environment() + + assert environment is not None + assert environment['BOUNDARY_ADDR'] == 'https://config.example.com' + assert os.environ['BOUNDARY_ADDR'] == 'https://parent.example.com' + + +@pytest.mark.parametrize( + ('test_command', 'auth_command'), + [ + (None, 'boundary authenticate'), + ('boundary authenticate status', None), + ('', 'boundary authenticate'), + ('boundary authenticate status', ''), + ], +) +def test_boundary_tunnel_authentication_requires_both_commands( + monkeypatch: pytest.MonkeyPatch, + test_command: str | None, + auth_command: str | None, +) -> None: + monkeypatch.setattr(boundary_tunnel.subprocess, 'run', lambda *_args, **_kwargs: pytest.fail('unexpected command')) + tunnel = BoundaryTunnel( + target_id='ttcp_123', + boundary_test_command=test_command, + boundary_auth_command=auth_command, + local_port=4406, + ) + + tunnel._authenticate_if_needed() + + +def test_boundary_tunnel_authentication_skips_auth_when_test_succeeds(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[tuple[list[str], dict[str, Any]]] = [] + + def fake_run(command: list[str], **kwargs: Any) -> SimpleNamespace: + calls.append((command, kwargs)) + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(boundary_tunnel.subprocess, 'run', fake_run) + tunnel = BoundaryTunnel( + target_id='ttcp_123', + auth_method_id='ampw_123', + boundary_test_command='boundary authenticate status -name "my auth"', + boundary_auth_command='boundary authenticate password', + local_port=4406, + ) + + tunnel._authenticate_if_needed() + + assert calls == [ + ( + ['boundary', 'authenticate', 'status', '-name', 'my auth'], + { + 'check': False, + 'stdin': subprocess.DEVNULL, + 'stdout': subprocess.DEVNULL, + 'stderr': subprocess.DEVNULL, + 'env': {**os.environ, 'BOUNDARY_AUTH_METHOD_ID': 'ampw_123'}, + }, + ) + ] + + +def test_boundary_tunnel_authentication_runs_auth_after_failed_test(monkeypatch: pytest.MonkeyPatch) -> None: + class FakeTerminal(StringIO): + def __init__(self, responses: str) -> None: + super().__init__(responses) + self.output = StringIO() + + def write(self, value: str) -> int: + return self.output.write(value) + + def flush(self) -> None: + self.output.flush() + + def close(self) -> None: + pass + + calls: list[tuple[list[str], dict[str, Any]]] = [] + return_codes = iter([1, 0]) + sql_input = StringIO('select 1;\n') + redirected_error = StringIO() + terminal = FakeTerminal('\n\n') + opened_paths: list[tuple[str, str, str]] = [] + + def fake_run(command: list[str], **kwargs: Any) -> SimpleNamespace: + calls.append((command, kwargs)) + return SimpleNamespace(returncode=next(return_codes)) + + def fake_open(path: str, mode: str, *, encoding: str) -> FakeTerminal: + opened_paths.append((path, mode, encoding)) + return terminal + + monkeypatch.setattr(boundary_tunnel.subprocess, 'run', fake_run) + monkeypatch.setattr(boundary_tunnel, 'WIN', False) + monkeypatch.setattr(boundary_tunnel.sys, 'stdin', sql_input) + monkeypatch.setattr(boundary_tunnel.sys, 'stderr', redirected_error) + monkeypatch.setattr(boundary_tunnel, 'open', fake_open, raising=False) + tunnel = BoundaryTunnel( + target_id='ttcp_123', + boundary_test_command='boundary authenticate status', + boundary_auth_command='boundary authenticate password -login-name "Jane Doe"', + local_port=4406, + ) + + tunnel._authenticate_if_needed() + + assert calls == [ + ( + ['boundary', 'authenticate', 'status'], + { + 'check': False, + 'stdin': subprocess.DEVNULL, + 'stdout': subprocess.DEVNULL, + 'stderr': subprocess.DEVNULL, + 'env': {**os.environ}, + }, + ), + ( + ['boundary', 'authenticate', 'password', '-login-name', 'Jane Doe'], + { + 'check': False, + 'stdin': terminal, + 'stdout': terminal, + 'stderr': terminal, + 'env': {**os.environ}, + }, + ), + ] + assert opened_paths == [('/dev/tty', 'r+', 'utf-8')] + assert sql_input.read() == 'select 1;\n' + assert redirected_error.getvalue() == '' + assert terminal.output.getvalue() == ( + 'Authenticate with Boundary before connecting? [Yn] Press return to continue after authenticating: ' + ) + + +def test_boundary_tunnel_authentication_uses_tty_streams(monkeypatch: pytest.MonkeyPatch) -> None: + terminal_input = StringIO('response\n') + terminal_output = StringIO() + monkeypatch.setattr(terminal_input, 'isatty', lambda: True) + monkeypatch.setattr(terminal_output, 'isatty', lambda: True) + monkeypatch.setattr(boundary_tunnel.sys, 'stdin', terminal_input) + monkeypatch.setattr(boundary_tunnel.sys, 'stderr', terminal_output) + + with boundary_tunnel._authentication_terminal() as (authentication_input, authentication_output): + assert authentication_input is terminal_input + assert authentication_output is terminal_output + + +def test_boundary_tunnel_authentication_opens_windows_console(monkeypatch: pytest.MonkeyPatch) -> None: + terminal_input = StringIO('response\n') + terminal_output = StringIO() + opened_paths: list[tuple[str, str, str]] = [] + + def fake_open(path: str, mode: str, *, encoding: str) -> StringIO: + opened_paths.append((path, mode, encoding)) + return terminal_input if path == 'CONIN$' else terminal_output + + monkeypatch.setattr(boundary_tunnel, 'WIN', True) + monkeypatch.setattr(boundary_tunnel.sys, 'stdin', StringIO()) + monkeypatch.setattr(boundary_tunnel.sys, 'stderr', StringIO()) + monkeypatch.setattr(boundary_tunnel, 'open', fake_open, raising=False) + + with boundary_tunnel._authentication_terminal() as (authentication_input, authentication_output): + assert authentication_input is terminal_input + assert authentication_output is terminal_output + + assert opened_paths == [ + ('CONIN$', 'r', 'utf-8'), + ('CONOUT$', 'w', 'utf-8'), + ] + + +def test_boundary_tunnel_authentication_reports_missing_terminal(monkeypatch: pytest.MonkeyPatch) -> None: + sql_input = StringIO('select 1;\n') + + def fail_open(*_args: Any, **_kwargs: Any) -> None: + raise OSError('no terminal') + + monkeypatch.setattr(boundary_tunnel.sys, 'stdin', sql_input) + monkeypatch.setattr(boundary_tunnel.sys, 'stderr', StringIO()) + monkeypatch.setattr(boundary_tunnel, 'open', fail_open, raising=False) + + with pytest.raises(BoundaryTunnelError, match='Unable to open a terminal') as excinfo: + with boundary_tunnel._authentication_terminal(): + pass + + assert isinstance(excinfo.value.__cause__, OSError) + assert sql_input.read() == 'select 1;\n' + + +def test_boundary_tunnel_authentication_reports_terminal_eof() -> None: + with pytest.raises(BoundaryTunnelError, match='Unable to read a response from the terminal'): + boundary_tunnel._prompt_for_authentication(StringIO(), StringIO(), 'Prompt: ') + + +def test_boundary_tunnel_authentication_reports_declined_auth(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + boundary_tunnel.subprocess, + 'run', + lambda *_args, **_kwargs: SimpleNamespace(returncode=1), + ) + monkeypatch.setattr( + boundary_tunnel, + '_authentication_terminal', + lambda: nullcontext((StringIO('n\n'), StringIO())), + ) + tunnel = BoundaryTunnel( + target_id='ttcp_123', + boundary_test_command='boundary authenticate status', + boundary_auth_command='boundary authenticate password', + local_port=4406, + ) + + with pytest.raises(BoundaryTunnelError, match='Not authenticated'): + tunnel._authenticate_if_needed() + + +@pytest.mark.parametrize( + ('command', 'expected'), + [ + ( + r'C:\boundary\boundary.exe authenticate status', + [r'C:\boundary\boundary.exe', 'authenticate', 'status'], + ), + ( + r'"C:\Program Files\Boundary\boundary.exe" authenticate -name "Jane Doe"', + [r'C:\Program Files\Boundary\boundary.exe', 'authenticate', '-name', 'Jane Doe'], + ), + ], +) +def test_boundary_tunnel_authentication_parses_windows_commands( + monkeypatch: pytest.MonkeyPatch, + command: str, + expected: list[str], +) -> None: + monkeypatch.setattr(boundary_tunnel, 'WIN', True) + + assert BoundaryTunnel._parse_authentication_command(command, 'test') == expected + + +def test_boundary_tunnel_authentication_reports_failed_auth(monkeypatch: pytest.MonkeyPatch) -> None: + return_codes = iter([1, 2]) + monkeypatch.setattr( + boundary_tunnel.subprocess, + 'run', + lambda *_args, **_kwargs: SimpleNamespace(returncode=next(return_codes)), + ) + monkeypatch.setattr( + boundary_tunnel, + '_authentication_terminal', + lambda: nullcontext((StringIO('\n'), StringIO())), + ) + tunnel = BoundaryTunnel( + target_id='ttcp_123', + boundary_test_command='boundary authenticate status', + boundary_auth_command='boundary authenticate password', + local_port=4406, + ) + + with pytest.raises(BoundaryTunnelError, match='Authentication command exited with status 2'): + tunnel._authenticate_if_needed() + + +@pytest.mark.parametrize( + ('test_command', 'auth_command', 'error_match'), + [ + ('"unterminated', 'boundary authenticate', 'Unable to parse test command'), + (' ', 'boundary authenticate', 'test command is empty'), + ('boundary test', '"unterminated', 'Unable to parse authentication command'), + ('boundary test', ' ', 'authentication command is empty'), + ], +) +def test_boundary_tunnel_authentication_reports_invalid_commands( + monkeypatch: pytest.MonkeyPatch, + test_command: str, + auth_command: str, + error_match: str, +) -> None: + monkeypatch.setattr(boundary_tunnel.subprocess, 'run', lambda *_args, **_kwargs: SimpleNamespace(returncode=1)) + monkeypatch.setattr( + boundary_tunnel, + '_authentication_terminal', + lambda: nullcontext((StringIO('\n'), StringIO())), + ) + tunnel = BoundaryTunnel( + target_id='ttcp_123', + boundary_test_command=test_command, + boundary_auth_command=auth_command, + local_port=4406, + ) + + with pytest.raises(BoundaryTunnelError, match=error_match): + tunnel._authenticate_if_needed() + + +@pytest.mark.parametrize( + ('return_codes', 'error_match'), + [ + ([], 'Unable to run test command: command failed'), + ([1], 'Unable to run authentication command: command failed'), + ], +) +def test_boundary_tunnel_authentication_reports_process_errors( + monkeypatch: pytest.MonkeyPatch, + return_codes: list[int], + error_match: str, +) -> None: + remaining_codes = iter(return_codes) + + def fake_run(*_args: Any, **_kwargs: Any) -> SimpleNamespace: + try: + return SimpleNamespace(returncode=next(remaining_codes)) + except StopIteration: + raise OSError('command failed') from None + + monkeypatch.setattr(boundary_tunnel.subprocess, 'run', fake_run) + monkeypatch.setattr( + boundary_tunnel, + '_authentication_terminal', + lambda: nullcontext((StringIO('\n'), StringIO())), + ) + tunnel = BoundaryTunnel( + target_id='ttcp_123', + boundary_test_command='boundary authenticate status', + boundary_auth_command='boundary authenticate password', + local_port=4406, + ) + + with pytest.raises(BoundaryTunnelError, match=error_match) as excinfo: + tunnel._authenticate_if_needed() + + assert isinstance(excinfo.value.__cause__, OSError) + + def test_boundary_tunnel_allocates_local_port(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(boundary_tunnel, '_find_free_local_port', lambda: 4406) @@ -141,6 +488,28 @@ def fake_read_stdout() -> None: assert tunnel.expiry is not None +def test_boundary_tunnel_start_authenticates_before_starting_worker(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[str] = [] + tunnel = BoundaryTunnel(target_id='ttcp_123', local_port=4406) + tunnel.stdout = CONNECTION_DETAILS + + def fake_authenticate() -> None: + calls.append('authenticate') + + def fake_run() -> None: + calls.append('run') + tunnel._started.set() + tunnel._output_ready.set() + + monkeypatch.setattr(tunnel, '_authenticate_if_needed', fake_authenticate) + monkeypatch.setattr(tunnel, '_run', fake_run) + monkeypatch.setattr(tunnel, '_is_listening', lambda: True) + + tunnel.start() + + assert calls == ['authenticate', 'run'] + + def test_boundary_tunnel_start_waits_for_process_to_start(monkeypatch: pytest.MonkeyPatch) -> None: sleeps: list[float] = [] tunnel = BoundaryTunnel(target_id='ttcp_123', local_port=4406) @@ -170,7 +539,7 @@ def fake_run() -> None: monkeypatch.setattr(tunnel, '_run', fake_run) - with pytest.raises(BoundaryTunnelError, match='Boundary tunnel CLI raised status code 403'): + with pytest.raises(BoundaryTunnelError, match='Tunnel CLI raised status code 403'): tunnel.start() @@ -191,7 +560,7 @@ def fake_run() -> None: monkeypatch.setattr(tunnel, '_run', fake_run) - with pytest.raises(BoundaryTunnelError, match='Boundary tunnel CLI did not return credentials'): + with pytest.raises(BoundaryTunnelError, match='Tunnel CLI did not return credentials'): tunnel.start() @@ -254,7 +623,7 @@ def fake_read_stdout() -> None: monkeypatch.setattr(tunnel, '_run', fake_run) monkeypatch.setattr(tunnel, '_read_stdout', fake_read_stdout) - with pytest.raises(BoundaryTunnelError, match='Unable to start Boundary tunnel process: boundary failed') as excinfo: + with pytest.raises(BoundaryTunnelError, match='Unable to start tunnel process: boundary failed') as excinfo: tunnel.start() assert isinstance(excinfo.value.__cause__, OSError) @@ -268,7 +637,7 @@ def fail_popen(*_args: Any, **_kwargs: Any) -> None: tunnel = BoundaryTunnel(target_id='ttcp_123', local_port=4406) monkeypatch.setattr(tunnel, '_is_listening', lambda: False) - with pytest.raises(BoundaryTunnelError, match='Unable to start Boundary tunnel process: missing boundary') as excinfo: + with pytest.raises(BoundaryTunnelError, match='Unable to start tunnel process: missing boundary') as excinfo: tunnel.start() assert isinstance(excinfo.value.__cause__, FileNotFoundError) @@ -277,7 +646,7 @@ def fail_popen(*_args: Any, **_kwargs: Any) -> None: def test_boundary_tunnel_start_reports_invalid_options() -> None: tunnel = BoundaryTunnel(target_id='ttcp_123', boundary_options='"unterminated', local_port=4406) - with pytest.raises(BoundaryTunnelError, match='Unable to start Boundary tunnel process: No closing quotation') as excinfo: + with pytest.raises(BoundaryTunnelError, match='Unable to start tunnel process: No closing quotation') as excinfo: tunnel.start() assert isinstance(excinfo.value.__cause__, ValueError) @@ -287,7 +656,7 @@ def test_boundary_tunnel_start_reports_process_start_timeout(monkeypatch: pytest tunnel = BoundaryTunnel(target_id='ttcp_123', local_port=4406, ready_timeout=0) monkeypatch.setattr(tunnel, '_run', lambda: None) - with pytest.raises(BoundaryTunnelError, match='Timed out waiting for Boundary tunnel process to start'): + with pytest.raises(BoundaryTunnelError, match='Timed out waiting for tunnel process to start'): tunnel.start() @@ -318,7 +687,7 @@ def wait(self, timeout: float | None = None) -> int: monkeypatch.setattr(boundary_tunnel.subprocess, 'Popen', lambda *_args, **_kwargs: FakeProcess()) tunnel = BoundaryTunnel(target_id='ttcp_123', local_port=4406, ready_timeout=0.1) - with pytest.raises(BoundaryTunnelError, match='Timed out waiting for Boundary tunnel process output'): + with pytest.raises(BoundaryTunnelError, match='Timed out waiting for tunnel process output'): tunnel.start() assert calls[:2] == ['read', 'terminate'] @@ -338,7 +707,7 @@ def fake_run() -> None: monotonic_values = iter([0.0, 0.0, 0.0, 31.0]) monkeypatch.setattr(boundary_tunnel.time, 'monotonic', lambda: next(monotonic_values)) - with pytest.raises(BoundaryTunnelError, match='Timed out waiting for Boundary tunnel'): + with pytest.raises(BoundaryTunnelError, match='Timed out waiting for tunnel'): tunnel.start() @@ -411,7 +780,7 @@ def fake_popen(command: list[str], **kwargs: Any) -> FakeProcess: 'stdout': subprocess.PIPE, 'stderr': subprocess.DEVNULL, 'start_new_session': True, - 'env': None, + 'env': {**os.environ}, }, ) ] diff --git a/test/pytests/test_client_connection.py b/test/pytests/test_client_connection.py index bcc3464e6..e149dcb5d 100644 --- a/test/pytests/test_client_connection.py +++ b/test/pytests/test_client_connection.py @@ -716,6 +716,8 @@ def __init__( address: str | None, auth_method_id: str | None, boundary_options: str | None, + boundary_test_command: str | None, + boundary_auth_command: str | None, ) -> None: tunnel_calls.append({ 'target_id': target_id, @@ -723,6 +725,8 @@ def __init__( 'address': address, 'auth_method_id': auth_method_id, 'boundary_options': boundary_options, + 'boundary_test_command': boundary_test_command, + 'boundary_auth_command': boundary_auth_command, }) def start(self, *, show_expiration_warning: bool) -> None: @@ -741,6 +745,8 @@ def close(self) -> None: 'address': 'https://boundary.example.com', 'auth_method_id': 'ampw_123', 'boundary_options': '-token env://BOUNDARY_TOKEN', + 'boundary_test_command': 'boundary authenticate status', + 'boundary_auth_command': 'boundary authenticate password', }, 'connection': {}, }, @@ -762,6 +768,8 @@ def close(self) -> None: 'address': 'https://boundary.example.com', 'auth_method_id': 'ampw_123', 'boundary_options': '-token env://BOUNDARY_TOKEN', + 'boundary_test_command': 'boundary authenticate status', + 'boundary_auth_command': 'boundary authenticate password', } ] assert warning_calls == [expected_warning] @@ -804,6 +812,8 @@ def __init__( address: str | None, auth_method_id: str | None, boundary_options: str | None, + boundary_test_command: str | None, + boundary_auth_command: str | None, ) -> None: pass @@ -834,7 +844,7 @@ def close(self) -> None: def test_connect_boundary_tunnel_uses_default_config(monkeypatch: pytest.MonkeyPatch) -> None: - tunnel_calls: list[tuple[str, str | None, str | None, str | None]] = [] + tunnel_calls: list[tuple[str, str | None, str | None, str | None, str | None, str | None]] = [] warning_calls: list[bool] = [] class FakeTunnel: @@ -852,8 +862,17 @@ def __init__( address: str | None, auth_method_id: str | None, boundary_options: str | None, + boundary_test_command: str | None, + boundary_auth_command: str | None, ) -> None: - tunnel_calls.append((boundary_executable, address, auth_method_id, boundary_options)) + tunnel_calls.append(( + boundary_executable, + address, + auth_method_id, + boundary_options, + boundary_test_command, + boundary_auth_command, + )) def start(self, *, show_expiration_warning: bool) -> None: warning_calls.append(show_expiration_warning) @@ -866,7 +885,7 @@ def close(self) -> None: client.connect(host='db.internal', boundary_target_id='ttcp_123') - assert tunnel_calls == [('boundary', None, None, None)] + assert tunnel_calls == [('boundary', None, None, None, None, None)] assert warning_calls == [True] @@ -887,6 +906,8 @@ def __init__( address: str | None, auth_method_id: str | None, boundary_options: str | None, + boundary_test_command: str | None, + boundary_auth_command: str | None, ) -> None: pass @@ -930,6 +951,8 @@ def __init__( address: str | None, auth_method_id: str | None, boundary_options: str | None, + boundary_test_command: str | None, + boundary_auth_command: str | None, ) -> None: pass @@ -967,6 +990,8 @@ def __init__( address: str | None, auth_method_id: str | None, boundary_options: str | None, + boundary_test_command: str | None, + boundary_auth_command: str | None, ) -> None: pass