From 3414d72c075e519a610c8bf7420082cec621f7c0 Mon Sep 17 00:00:00 2001 From: msawczynk Date: Wed, 25 Feb 2026 12:26:41 +0000 Subject: [PATCH 1/8] feat(pam tunnel): add --foreground flag for non-interactive tunnel persistence Co-authored-by: Cursor --- .../commands/tunnel_and_connections.py | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/keepercommander/commands/tunnel_and_connections.py b/keepercommander/commands/tunnel_and_connections.py index 48cd8b2dd..481567e4c 100644 --- a/keepercommander/commands/tunnel_and_connections.py +++ b/keepercommander/commands/tunnel_and_connections.py @@ -12,7 +12,9 @@ import argparse import logging import os +import signal import sys +import threading from keeper_secrets_manager_core.utils import bytes_to_base64, base64_to_bytes from .base import Command, GroupCommand, dump_report_data, RecordMixin @@ -490,6 +492,11 @@ class PAMTunnelStartCommand(Command): pam_cmd_parser.add_argument('--no-trickle-ice', '-nti', required=False, dest='no_trickle_ice', action='store_true', help='Disable trickle ICE for WebRTC connections. By default, trickle ICE is enabled ' 'for real-time candidate exchange.') + pam_cmd_parser.add_argument('--foreground', '-fg', required=False, dest='foreground', action='store_true', + help='Keep the tunnel running in the foreground, blocking until ' + 'SIGTERM/SIGINT/Ctrl+C is received. Use this flag when running ' + 'tunnels from scripts, systemd services, or any non-interactive ' + 'context where the process would otherwise exit immediately.') def get_parser(self): return PAMTunnelStartCommand.pam_cmd_parser @@ -636,8 +643,34 @@ def execute(self, params, **kwargs): result = start_rust_tunnel(params, record_uid, gateway_uid, host, port, seed, target_host, target_port, socks, trickle_ice, record.title, allow_supply_host=allow_supply_host) if result and result.get("success"): - # The helper will show endpoint table when local socket is actually listening - pass + if kwargs.get('foreground', False): + fg_shutdown = threading.Event() + + def _fg_signal_handler(signum, _frame): + sig_name = signal.Signals(signum).name if hasattr(signal, 'Signals') else str(signum) + print(f"\n{bcolors.WARNING}Received {sig_name}, stopping tunnel...{bcolors.ENDC}") + fg_shutdown.set() + + prev_sigterm = signal.signal(signal.SIGTERM, _fg_signal_handler) + prev_sigint = signal.signal(signal.SIGINT, _fg_signal_handler) + + print(f"\n{bcolors.OKGREEN}Tunnel running in foreground mode.{bcolors.ENDC}") + print(f"{bcolors.OKGREEN}Press Ctrl+C or send SIGTERM (kill {os.getpid()}) to stop.{bcolors.ENDC}\n") + + try: + fg_shutdown.wait() + except KeyboardInterrupt: + pass + finally: + signal.signal(signal.SIGTERM, prev_sigterm) + signal.signal(signal.SIGINT, prev_sigint) + print(f"\n{bcolors.OKBLUE}Stopping tunnel for record {record_uid}...{bcolors.ENDC}") + try: + stop_cmd = PAMTunnelStopCommand() + stop_cmd.execute(params, uid=record_uid) + print(f"{bcolors.OKGREEN}Tunnel stopped.{bcolors.ENDC}") + except Exception as fg_err: + logging.warning("Error stopping tunnel during foreground shutdown: %s", fg_err) else: # Print failure message error_msg = result.get("error", "Unknown error") if result else "Failed to start tunnel" From 57a87c2ba408b59201ebfb49429c732f6fe49959 Mon Sep 17 00:00:00 2001 From: msawczynk Date: Fri, 27 Feb 2026 12:38:27 +0000 Subject: [PATCH 2/8] fix(pam tunnel): resolve 4 foreground issues -- direct close_tube, interactive shell detection, --pid-file, improved status output Made-with: Cursor --- .../commands/tunnel_and_connections.py | 89 +++++++++++++------ 1 file changed, 63 insertions(+), 26 deletions(-) diff --git a/keepercommander/commands/tunnel_and_connections.py b/keepercommander/commands/tunnel_and_connections.py index 481567e4c..1fb0c4413 100644 --- a/keepercommander/commands/tunnel_and_connections.py +++ b/keepercommander/commands/tunnel_and_connections.py @@ -21,7 +21,7 @@ from .tunnel.port_forward.TunnelGraph import TunnelDAG from .tunnel.port_forward.tunnel_helpers import find_open_port, get_config_uid, get_keeper_tokens, \ get_or_create_tube_registry, get_gateway_uid_from_record, resolve_record, resolve_pam_config, resolve_folder, \ - remove_field, start_rust_tunnel, get_tunnel_session, CloseConnectionReasons, create_rust_webrtc_settings + remove_field, start_rust_tunnel, get_tunnel_session, unregister_tunnel_session, CloseConnectionReasons, create_rust_webrtc_settings from .. import api, vault, record_management from ..display import bcolors from ..error import CommandError @@ -497,6 +497,10 @@ class PAMTunnelStartCommand(Command): 'SIGTERM/SIGINT/Ctrl+C is received. Use this flag when running ' 'tunnels from scripts, systemd services, or any non-interactive ' 'context where the process would otherwise exit immediately.') + pam_cmd_parser.add_argument('--pid-file', required=False, dest='pid_file', action='store', + help='Write the process PID to a file when using --foreground. ' + 'Enables stopping the tunnel from another terminal via ' + 'kill -SIGTERM $(cat ). The file is removed on shutdown.') def get_parser(self): return PAMTunnelStartCommand.pam_cmd_parser @@ -644,33 +648,66 @@ def execute(self, params, **kwargs): if result and result.get("success"): if kwargs.get('foreground', False): - fg_shutdown = threading.Event() - - def _fg_signal_handler(signum, _frame): - sig_name = signal.Signals(signum).name if hasattr(signal, 'Signals') else str(signum) - print(f"\n{bcolors.WARNING}Received {sig_name}, stopping tunnel...{bcolors.ENDC}") - fg_shutdown.set() - - prev_sigterm = signal.signal(signal.SIGTERM, _fg_signal_handler) - prev_sigint = signal.signal(signal.SIGINT, _fg_signal_handler) - - print(f"\n{bcolors.OKGREEN}Tunnel running in foreground mode.{bcolors.ENDC}") - print(f"{bcolors.OKGREEN}Press Ctrl+C or send SIGTERM (kill {os.getpid()}) to stop.{bcolors.ENDC}\n") + if not params.batch_mode: + print(f"\n{bcolors.OKBLUE}Note: --foreground is not needed inside the interactive shell.{bcolors.ENDC}") + print(f"{bcolors.OKBLUE}The tunnel is already running and will persist until you exit the shell.{bcolors.ENDC}") + print(f"{bcolors.OKBLUE}Use 'pam tunnel list' to see active tunnels, 'pam tunnel stop' to stop them.{bcolors.ENDC}\n") + else: + fg_tube_id = result.get("tube_id") + fg_tube_registry = result.get("tube_registry") + fg_shutdown = threading.Event() + pid_file = kwargs.get('pid_file') + + def _fg_signal_handler(signum, _frame): + sig_name = signal.Signals(signum).name if hasattr(signal, 'Signals') else str(signum) + print(f"\n{bcolors.WARNING}Received {sig_name}, stopping tunnel...{bcolors.ENDC}") + fg_shutdown.set() + + prev_sigterm = signal.signal(signal.SIGTERM, _fg_signal_handler) + prev_sigint = signal.signal(signal.SIGINT, _fg_signal_handler) + + if pid_file: + try: + with open(pid_file, 'w') as pf: + pf.write(str(os.getpid())) + except Exception as e: + logging.warning("Could not write PID file '%s': %s", pid_file, e) + pid_file = None + + print(f"\n{bcolors.OKGREEN}Tunnel running in foreground mode{bcolors.ENDC}") + print(f" Record: {record_uid}") + if fg_tube_id: + print(f" Tube ID: {fg_tube_id}") + print(f" Listening: {host}:{port}") + print(f" PID: {os.getpid()}") + if pid_file: + print(f" PID file: {pid_file}") + print(f"\n{bcolors.OKGREEN}To stop: kill -SIGTERM {os.getpid()} (or Ctrl+C){bcolors.ENDC}\n") - try: - fg_shutdown.wait() - except KeyboardInterrupt: - pass - finally: - signal.signal(signal.SIGTERM, prev_sigterm) - signal.signal(signal.SIGINT, prev_sigint) - print(f"\n{bcolors.OKBLUE}Stopping tunnel for record {record_uid}...{bcolors.ENDC}") try: - stop_cmd = PAMTunnelStopCommand() - stop_cmd.execute(params, uid=record_uid) - print(f"{bcolors.OKGREEN}Tunnel stopped.{bcolors.ENDC}") - except Exception as fg_err: - logging.warning("Error stopping tunnel during foreground shutdown: %s", fg_err) + fg_shutdown.wait() + except KeyboardInterrupt: + pass + finally: + signal.signal(signal.SIGTERM, prev_sigterm) + signal.signal(signal.SIGINT, prev_sigint) + print(f"\n{bcolors.OKBLUE}Stopping tunnel {fg_tube_id or record_uid}...{bcolors.ENDC}") + try: + if fg_tube_registry and fg_tube_id: + fg_tube_registry.close_tube(fg_tube_id, reason=CloseConnectionReasons.Normal) + unregister_tunnel_session(fg_tube_id) + else: + stop_cmd = PAMTunnelStopCommand() + stop_cmd.execute(params, uid=record_uid) + print(f"{bcolors.OKGREEN}Tunnel stopped.{bcolors.ENDC}") + except Exception as fg_err: + logging.warning("Error stopping tunnel during foreground shutdown: %s", fg_err) + finally: + if pid_file: + try: + os.remove(pid_file) + except OSError: + pass else: # Print failure message error_msg = result.get("error", "Unknown error") if result else "Failed to start tunnel" From e54473ab8c599c87a789bc6f07c663d1e3ec6ed5 Mon Sep 17 00:00:00 2001 From: msawczynk Date: Fri, 27 Feb 2026 13:04:56 +0000 Subject: [PATCH 3/8] feat(pam tunnel): add --run flag, --timeout, connection readiness, batch mode fix - Add --run / -R flag: start tunnel, wait for connection, execute command, stop tunnel, exit with commands exit code - Add --timeout flag (default 30s) for WebRTC connection wait - Wait for tunnel connection in --foreground before printing banner and writing PID file - Raise CommandError instead of input() when --target-host/--target-port are missing in batch mode (fixes script hang) - Import wait_for_tunnel_connection and subprocess Made-with: Cursor --- .../commands/tunnel_and_connections.py | 85 ++++++++++++++++++- 1 file changed, 82 insertions(+), 3 deletions(-) diff --git a/keepercommander/commands/tunnel_and_connections.py b/keepercommander/commands/tunnel_and_connections.py index 1fb0c4413..2c8392e9b 100644 --- a/keepercommander/commands/tunnel_and_connections.py +++ b/keepercommander/commands/tunnel_and_connections.py @@ -13,6 +13,7 @@ import logging import os import signal +import subprocess import sys import threading from keeper_secrets_manager_core.utils import bytes_to_base64, base64_to_bytes @@ -21,7 +22,8 @@ from .tunnel.port_forward.TunnelGraph import TunnelDAG from .tunnel.port_forward.tunnel_helpers import find_open_port, get_config_uid, get_keeper_tokens, \ get_or_create_tube_registry, get_gateway_uid_from_record, resolve_record, resolve_pam_config, resolve_folder, \ - remove_field, start_rust_tunnel, get_tunnel_session, unregister_tunnel_session, CloseConnectionReasons, create_rust_webrtc_settings + remove_field, start_rust_tunnel, get_tunnel_session, unregister_tunnel_session, CloseConnectionReasons, \ + wait_for_tunnel_connection, create_rust_webrtc_settings from .. import api, vault, record_management from ..display import bcolors from ..error import CommandError @@ -501,6 +503,15 @@ class PAMTunnelStartCommand(Command): help='Write the process PID to a file when using --foreground. ' 'Enables stopping the tunnel from another terminal via ' 'kill -SIGTERM $(cat ). The file is removed on shutdown.') + pam_cmd_parser.add_argument('--run', '-R', required=False, dest='run_command', action='store', + help='Start the tunnel, wait for it to be ready, execute the ' + 'specified command, then stop the tunnel and exit with the ' + "command's exit code. " + "Example: --run 'pg_dump -h localhost -p 5432 mydb > backup.sql'") + pam_cmd_parser.add_argument('--timeout', required=False, dest='connect_timeout', action='store', + type=int, default=30, + help='Seconds to wait for the tunnel to connect before giving up ' + '(used with --foreground and --run). Default: 30') def get_parser(self): return PAMTunnelStartCommand.pam_cmd_parser @@ -569,8 +580,12 @@ def execute(self, params, **kwargs): target_host = kwargs.get('target_host') target_port = kwargs.get('target_port') - # If not provided via command line, prompt interactively + # If not provided via command line, prompt interactively (or error in batch mode) if not target_host: + if params.batch_mode: + raise CommandError('tunnel start', + 'Target host is required in non-interactive mode. ' + 'Use --target-host --target-port ') print(f"{bcolors.WARNING}This resource requires you to supply the target host and port.{bcolors.ENDC}") try: target_host = input(f"{bcolors.OKBLUE}Enter target hostname or IP address: {bcolors.ENDC}").strip() @@ -582,6 +597,10 @@ def execute(self, params, **kwargs): return if not target_port: + if params.batch_mode: + raise CommandError('tunnel start', + 'Target port is required in non-interactive mode. ' + 'Use --target-host --target-port ') try: target_port_str = input(f"{bcolors.OKBLUE}Enter target port number: {bcolors.ENDC}").strip() if not target_port_str: @@ -647,7 +666,51 @@ def execute(self, params, **kwargs): result = start_rust_tunnel(params, record_uid, gateway_uid, host, port, seed, target_host, target_port, socks, trickle_ice, record.title, allow_supply_host=allow_supply_host) if result and result.get("success"): - if kwargs.get('foreground', False): + run_command = kwargs.get('run_command') + connect_timeout = kwargs.get('connect_timeout', 30) + + if run_command: + run_tube_id = result.get("tube_id") + run_tube_registry = result.get("tube_registry") + + print(f"{bcolors.OKBLUE}Waiting for tunnel to connect (timeout: {connect_timeout}s)...{bcolors.ENDC}") + conn_status = wait_for_tunnel_connection(result, timeout=connect_timeout, show_progress=False) + + if not conn_status.get("connected"): + err = conn_status.get("error", "Connection failed") + print(f"{bcolors.FAIL}Tunnel did not connect: {err}{bcolors.ENDC}") + if run_tube_registry and run_tube_id: + try: + run_tube_registry.close_tube(run_tube_id, reason=CloseConnectionReasons.Normal) + unregister_tunnel_session(run_tube_id) + except Exception: + pass + return + + print(f"{bcolors.OKGREEN}Tunnel ready{bcolors.ENDC} {host}:{port} -> {target_host}:{target_port}") + print(f"{bcolors.OKBLUE}Running:{bcolors.ENDC} {run_command}\n") + + try: + proc = subprocess.run(run_command, shell=True) + cmd_exit = proc.returncode + except KeyboardInterrupt: + cmd_exit = 130 + except Exception as run_err: + logging.warning("Error running command: %s", run_err) + cmd_exit = 1 + finally: + print(f"\n{bcolors.OKBLUE}Stopping tunnel {run_tube_id or record_uid}...{bcolors.ENDC}") + try: + if run_tube_registry and run_tube_id: + run_tube_registry.close_tube(run_tube_id, reason=CloseConnectionReasons.Normal) + unregister_tunnel_session(run_tube_id) + print(f"{bcolors.OKGREEN}Tunnel stopped.{bcolors.ENDC}") + except Exception as stop_err: + logging.warning("Error stopping tunnel: %s", stop_err) + + sys.exit(cmd_exit) + + elif kwargs.get('foreground', False): if not params.batch_mode: print(f"\n{bcolors.OKBLUE}Note: --foreground is not needed inside the interactive shell.{bcolors.ENDC}") print(f"{bcolors.OKBLUE}The tunnel is already running and will persist until you exit the shell.{bcolors.ENDC}") @@ -666,6 +729,22 @@ def _fg_signal_handler(signum, _frame): prev_sigterm = signal.signal(signal.SIGTERM, _fg_signal_handler) prev_sigint = signal.signal(signal.SIGINT, _fg_signal_handler) + print(f"{bcolors.OKBLUE}Waiting for tunnel to connect (timeout: {connect_timeout}s)...{bcolors.ENDC}") + conn_status = wait_for_tunnel_connection(result, timeout=connect_timeout, show_progress=False) + + if not conn_status.get("connected"): + signal.signal(signal.SIGTERM, prev_sigterm) + signal.signal(signal.SIGINT, prev_sigint) + err = conn_status.get("error", "Connection failed") + print(f"{bcolors.FAIL}Tunnel did not connect: {err}{bcolors.ENDC}") + if fg_tube_registry and fg_tube_id: + try: + fg_tube_registry.close_tube(fg_tube_id, reason=CloseConnectionReasons.Normal) + unregister_tunnel_session(fg_tube_id) + except Exception: + pass + return + if pid_file: try: with open(pid_file, 'w') as pf: From 96673c99755fe9acef4aaa9bff5e1f83faeed852 Mon Sep 17 00:00:00 2001 From: msawczynk Date: Fri, 27 Feb 2026 15:15:33 +0000 Subject: [PATCH 4/8] feat(pam tunnel): add --background flag for daemonized tunnels, tunnel list note - Add --background / -bg: fork before tunnel start, child daemonizes (setsid), starts tunnel, signals parent via pipe when connected, then blocks. Parent prints readiness info and returns. Linux/macOS only. - Add tunnel list limitation note to --foreground and --background banners - Addresses user feedback: multiple commands need --background, not --run Made-with: Cursor --- .../commands/tunnel_and_connections.py | 128 +++++++++++++++++- 1 file changed, 125 insertions(+), 3 deletions(-) diff --git a/keepercommander/commands/tunnel_and_connections.py b/keepercommander/commands/tunnel_and_connections.py index 2c8392e9b..463a678a9 100644 --- a/keepercommander/commands/tunnel_and_connections.py +++ b/keepercommander/commands/tunnel_and_connections.py @@ -511,7 +511,13 @@ class PAMTunnelStartCommand(Command): pam_cmd_parser.add_argument('--timeout', required=False, dest='connect_timeout', action='store', type=int, default=30, help='Seconds to wait for the tunnel to connect before giving up ' - '(used with --foreground and --run). Default: 30') + '(used with --foreground, --background, and --run). Default: 30') + pam_cmd_parser.add_argument('--background', '-bg', required=False, dest='background', action='store_true', + help='Start the tunnel, wait for connection readiness, then ' + 'daemonize and return control to the caller. The tunnel ' + 'continues running in the background. Use --pid-file to ' + 'write the daemon PID for later shutdown via ' + 'kill -SIGTERM $(cat ). Linux/macOS only.') def get_parser(self): return PAMTunnelStartCommand.pam_cmd_parser @@ -663,13 +669,127 @@ def execute(self, params, **kwargs): # Use Rust WebRTC implementation with configurable trickle ICE trickle_ice = not no_trickle_ice + + # --background: fork before starting tunnel so Rust threads are + # created only in the child process (threads don't survive fork). + _bg_pipe_w = None + background = kwargs.get('background', False) + if background: + if not params.batch_mode: + print(f"\n{bcolors.OKBLUE}Note: --background is not needed inside the interactive shell.{bcolors.ENDC}") + print(f"{bcolors.OKBLUE}The tunnel is already running and will persist until you exit the shell.{bcolors.ENDC}") + print(f"{bcolors.OKBLUE}Use 'pam tunnel list' to see active tunnels, 'pam tunnel stop' to stop them.{bcolors.ENDC}\n") + return + if os.name == 'nt': + raise CommandError('tunnel start', + '--background is not supported on Windows. ' + 'Use --foreground in a background job (Start-Process).') + r_fd, w_fd = os.pipe() + child_pid = os.fork() + if child_pid > 0: + os.close(w_fd) + try: + data = b"" + while True: + chunk = os.read(r_fd, 4096) + if not chunk: + break + data += chunk + finally: + os.close(r_fd) + msg = data.decode('utf-8', errors='replace') + if msg.startswith("READY\n"): + print(msg[6:], end='') + else: + print(f"{bcolors.FAIL}{msg}{bcolors.ENDC}") + return + else: + os.close(r_fd) + os.setsid() + _bg_pipe_w = w_fd + result = start_rust_tunnel(params, record_uid, gateway_uid, host, port, seed, target_host, target_port, socks, trickle_ice, record.title, allow_supply_host=allow_supply_host) + if _bg_pipe_w is not None and (not result or not result.get("success")): + error_msg = result.get("error", "Unknown error") if result else "Failed to start tunnel" + os.write(_bg_pipe_w, f"Background tunnel failed: {error_msg}".encode()) + os.close(_bg_pipe_w) + os._exit(1) + if result and result.get("success"): run_command = kwargs.get('run_command') connect_timeout = kwargs.get('connect_timeout', 30) - if run_command: + if _bg_pipe_w is not None: + bg_tube_id = result.get("tube_id") + bg_tube_registry = result.get("tube_registry") + bg_shutdown = threading.Event() + pid_file = kwargs.get('pid_file') + + conn_status = wait_for_tunnel_connection(result, timeout=connect_timeout, show_progress=False) + + if not conn_status.get("connected"): + err = conn_status.get("error", "Connection failed") + os.write(_bg_pipe_w, f"Tunnel did not connect: {err}".encode()) + os.close(_bg_pipe_w) + if bg_tube_registry and bg_tube_id: + try: + bg_tube_registry.close_tube(bg_tube_id, reason=CloseConnectionReasons.Normal) + unregister_tunnel_session(bg_tube_id) + except Exception: + pass + os._exit(1) + + if pid_file: + try: + with open(pid_file, 'w') as pf: + pf.write(str(os.getpid())) + except Exception as e: + logging.warning("Could not write PID file '%s': %s", pid_file, e) + pid_file = None + + info = [] + info.append(f"{bcolors.OKGREEN}Tunnel running in background{bcolors.ENDC}") + info.append(f" Record: {record_uid}") + if bg_tube_id: + info.append(f" Tube ID: {bg_tube_id}") + info.append(f" Listening: {host}:{port}") + info.append(f" Daemon PID: {os.getpid()}") + if pid_file: + info.append(f" PID file: {pid_file}") + info.append(f"\n{bcolors.OKGREEN}To stop: kill -SIGTERM {os.getpid()}{bcolors.ENDC}") + if pid_file: + info.append(f" or: kill -SIGTERM $(cat {pid_file})") + info.append(f"\n{bcolors.WARNING}Note: This tunnel is managed by a background process (PID {os.getpid()}).{bcolors.ENDC}") + info.append(f"{bcolors.WARNING}'pam tunnel list' from other Commander sessions cannot see it.{bcolors.ENDC}") + + os.write(_bg_pipe_w, ("READY\n" + "\n".join(info) + "\n").encode()) + os.close(_bg_pipe_w) + + def _bg_signal_handler(signum, _frame): + bg_shutdown.set() + signal.signal(signal.SIGTERM, _bg_signal_handler) + signal.signal(signal.SIGINT, _bg_signal_handler) + + try: + bg_shutdown.wait() + except Exception: + pass + finally: + try: + if bg_tube_registry and bg_tube_id: + bg_tube_registry.close_tube(bg_tube_id, reason=CloseConnectionReasons.Normal) + unregister_tunnel_session(bg_tube_id) + except Exception: + pass + if pid_file: + try: + os.remove(pid_file) + except OSError: + pass + os._exit(0) + + elif run_command: run_tube_id = result.get("tube_id") run_tube_registry = result.get("tube_registry") @@ -761,7 +881,9 @@ def _fg_signal_handler(signum, _frame): print(f" PID: {os.getpid()}") if pid_file: print(f" PID file: {pid_file}") - print(f"\n{bcolors.OKGREEN}To stop: kill -SIGTERM {os.getpid()} (or Ctrl+C){bcolors.ENDC}\n") + print(f"\n{bcolors.OKGREEN}To stop: kill -SIGTERM {os.getpid()} (or Ctrl+C){bcolors.ENDC}") + print(f"\n{bcolors.WARNING}Note: This tunnel is only visible in this process.{bcolors.ENDC}") + print(f"{bcolors.WARNING}'pam tunnel list' from other Commander sessions cannot see it.{bcolors.ENDC}\n") try: fg_shutdown.wait() From 0c70d81b6c45aabdc952e13298a6cc76d9d0e9db Mon Sep 17 00:00:00 2001 From: msawczynk Date: Fri, 27 Feb 2026 15:27:34 +0000 Subject: [PATCH 5/8] feat(pam tunnel): file-based tunnel registry for cross-process tunnel list/stop - Add file-based tunnel registry (~/.keeper/tunnel-sessions/.json) - Each foreground/background/run tunnel registers on connect, unregisters on cleanup - pam tunnel list now shows tunnels from ALL Commander processes - pam tunnel stop falls back to file registry, sends SIGTERM to owning process - pam tunnel stop --all also stops cross-process tunnels - Stale entries (dead PIDs) are auto-cleaned on list Made-with: Cursor --- .../commands/tunnel_and_connections.py | 272 +++++++++++++----- 1 file changed, 198 insertions(+), 74 deletions(-) diff --git a/keepercommander/commands/tunnel_and_connections.py b/keepercommander/commands/tunnel_and_connections.py index 463a678a9..5eac85f72 100644 --- a/keepercommander/commands/tunnel_and_connections.py +++ b/keepercommander/commands/tunnel_and_connections.py @@ -10,12 +10,14 @@ # import argparse +import json import logging import os import signal import subprocess import sys import threading +import time from keeper_secrets_manager_core.utils import bytes_to_base64, base64_to_bytes from .base import Command, GroupCommand, dump_report_data, RecordMixin @@ -31,6 +33,101 @@ from ..subfolder import find_folders from ..utils import value_to_boolean + +# --------------------------------------------------------------------------- +# File-based tunnel registry +# +# Each foreground/background/run tunnel writes a JSON metadata file to +# ~/.keeper/tunnel-sessions/.json. This lets `pam tunnel list` and +# `pam tunnel stop` discover tunnels across Commander processes. +# --------------------------------------------------------------------------- + +def _tunnel_registry_dir(): + """Return (and create) the tunnel session registry directory.""" + base = os.path.join(os.path.expanduser('~'), '.keeper', 'tunnel-sessions') + os.makedirs(base, exist_ok=True) + return base + + +def _register_tunnel(pid, record_uid, tube_id, host, port, + target_host=None, target_port=None, mode='foreground', + record_title=None): + """Write a JSON file for an active tunnel so other processes can see it.""" + path = os.path.join(_tunnel_registry_dir(), f'{pid}.json') + data = { + 'pid': pid, + 'record_uid': record_uid, + 'tube_id': tube_id, + 'host': host, + 'port': port, + 'target_host': target_host, + 'target_port': target_port, + 'mode': mode, + 'record_title': record_title, + 'started': time.strftime('%Y-%m-%d %H:%M:%S'), + } + try: + with open(path, 'w') as f: + json.dump(data, f) + except Exception as exc: + logging.debug("Could not write tunnel registry file %s: %s", path, exc) + + +def _unregister_tunnel(pid=None): + """Remove the registry file for a tunnel (defaults to current PID).""" + pid = pid or os.getpid() + path = os.path.join(_tunnel_registry_dir(), f'{pid}.json') + try: + os.remove(path) + except OSError: + pass + + +def _is_pid_alive(pid): + """Check whether a process with the given PID is still running.""" + if os.name == 'nt': + import ctypes + kernel32 = ctypes.windll.kernel32 + handle = kernel32.OpenProcess(0x100000, False, pid) # SYNCHRONIZE + if handle: + kernel32.CloseHandle(handle) + return True + return False + else: + try: + os.kill(pid, 0) + return True + except OSError: + return False + + +def _list_registered_tunnels(): + """Read all registry files, verify PID liveness, clean stale entries. + + Returns a list of dicts for tunnels whose owning process is still alive. + """ + reg_dir = _tunnel_registry_dir() + result = [] + for fname in os.listdir(reg_dir): + if not fname.endswith('.json'): + continue + fpath = os.path.join(reg_dir, fname) + try: + with open(fpath) as f: + data = json.load(f) + pid = data.get('pid') + if pid and _is_pid_alive(pid): + result.append(data) + else: + os.remove(fpath) + except Exception: + try: + os.remove(fpath) + except OSError: + pass + return result + + # Group Commands class PAMTunnelCommand(GroupCommand): @@ -70,73 +167,65 @@ def get_parser(self): return PAMTunnelListCommand.pam_cmd_parser def execute(self, params, **kwargs): - # Try to get active tunnels from Rust PyTubeRegistry - # Logger initialization is handled by get_or_create_tube_registry() - tube_registry = get_or_create_tube_registry(params) - if tube_registry: - if not tube_registry.has_active_tubes(): - logging.warning(f"{bcolors.OKBLUE}No Tunnels running{bcolors.ENDC}") - return - - table = [] - headers = ['Record', 'Remote Target', 'Local Address', 'Tunnel ID', 'Conversation ID', 'Status'] + table = [] + headers = ['Record', 'Remote Target', 'Local Address', 'Tunnel ID', 'Conversation ID', 'Status'] - # Get all tube IDs + # In-process tunnels from the Rust PyTubeRegistry + tube_registry = get_or_create_tube_registry(params) + in_process_tube_ids = set() + if tube_registry and tube_registry.has_active_tubes(): tube_ids = tube_registry.all_tube_ids() - for tube_id in tube_ids: - # Get conversation IDs for this tube + in_process_tube_ids.add(tube_id) conversation_ids = tube_registry.get_conversation_ids_by_tube_id(tube_id) - - # Get tunnel session for detailed info tunnel_session = get_tunnel_session(tube_id) - # Record title record_title = tunnel_session.record_title if tunnel_session and tunnel_session.record_title else f"{bcolors.WARNING}unknown{bcolors.ENDC}" - # Remote target if tunnel_session and tunnel_session.target_host and tunnel_session.target_port: remote_target = f"{tunnel_session.target_host}:{tunnel_session.target_port}" else: remote_target = f"{bcolors.WARNING}unknown{bcolors.ENDC}" - # Local listening address if tunnel_session and tunnel_session.host and tunnel_session.port: local_addr = f"{bcolors.OKGREEN}{tunnel_session.host}:{tunnel_session.port}{bcolors.ENDC}" else: local_addr = f"{bcolors.WARNING}unknown{bcolors.ENDC}" - # Tunnel ID (tube_id) - this is what's needed for stopping - tunnel_id = tube_id - - # Conversation ID - WebRTC signaling identifier conv_id = conversation_ids[0] if conversation_ids else (tunnel_session.conversation_id if tunnel_session else 'none') - # Connection state try: state = tube_registry.get_connection_state(tube_id) status_color = f"{bcolors.OKGREEN}" if state.lower() == "connected" else f"{bcolors.WARNING}" status = f"{status_color}{state}{bcolors.ENDC}" - except: + except Exception: status = f"{bcolors.WARNING}unknown{bcolors.ENDC}" - row = [ - record_title, - remote_target, - local_addr, - tunnel_id, - conv_id, - status, - ] - table.append(row) - - dump_report_data(table, headers, fmt='table', filename="", row_number=False, column_width=None) - else: - # Rust WebRTC library is required for tunnel operations - print(f"{bcolors.FAIL}This command requires the Rust WebRTC library (keeper_pam_webrtc_rs).{bcolors.ENDC}") - print(f"{bcolors.OKBLUE}Please ensure the keeper_pam_webrtc_rs module is installed and available.{bcolors.ENDC}") + table.append([record_title, remote_target, local_addr, tube_id, conv_id, status]) + + # Cross-process tunnels from the file-based registry + for entry in _list_registered_tunnels(): + if entry.get('tube_id') in in_process_tube_ids: + continue + pid = entry.get('pid') + rec = entry.get('record_title') or entry.get('record_uid', '?') + th = entry.get('target_host') + tp = entry.get('target_port') + remote = f"{th}:{tp}" if th and tp else f"{bcolors.WARNING}n/a{bcolors.ENDC}" + h = entry.get('host', '127.0.0.1') + p = entry.get('port', '?') + local = f"{bcolors.OKGREEN}{h}:{p}{bcolors.ENDC}" + tid = entry.get('tube_id', '') + mode = entry.get('mode', '?') + status = f"{bcolors.OKGREEN}{mode} (PID {pid}){bcolors.ENDC}" + table.append([rec, remote, local, tid, '', status]) + + if not table: + logging.warning(f"{bcolors.OKBLUE}No Tunnels running{bcolors.ENDC}") return + dump_report_data(table, headers, fmt='table', filename="", row_number=False, column_width=None) + class PAMTunnelStopCommand(Command): pam_cmd_parser = argparse.ArgumentParser(prog='pam tunnel stop') @@ -191,6 +280,23 @@ def execute(self, params, **kwargs): if tube_id: matching_tubes = [tube_id] + # Fall back to file-based registry (cross-process tunnels) + if not matching_tubes: + for entry in _list_registered_tunnels(): + if uid in (entry.get('tube_id', ''), entry.get('record_uid', ''), + entry.get('record_title', '')): + pid = entry.get('pid') + if pid and _is_pid_alive(pid): + try: + os.kill(pid, signal.SIGTERM) + print(f"{bcolors.OKGREEN}Sent SIGTERM to tunnel process " + f"(PID {pid}, {entry.get('mode', '?')} mode){bcolors.ENDC}") + except OSError as e: + print(f"{bcolors.FAIL}Failed to signal PID {pid}: {e}{bcolors.ENDC}") + else: + _unregister_tunnel(pid) + return + if not matching_tubes: raise CommandError('tunnel stop', f"No active tunnels found matching '{uid}'") @@ -221,43 +327,49 @@ def execute(self, params, **kwargs): raise CommandError('tunnel stop', f"Failed to stop any tunnels matching '{uid}'") def _stop_all_tunnels(self, params): - """Stop all active tunnels""" - tube_registry = get_or_create_tube_registry(params) - if not tube_registry: - raise CommandError('tunnel stop', 'This command requires the Rust WebRTC library') + """Stop all active tunnels (in-process and cross-process).""" + stopped_count = 0 + failed_count = 0 - # Get all active tunnel IDs - all_tube_ids = tube_registry.all_tube_ids() - - if not all_tube_ids: + # In-process tunnels + tube_registry = get_or_create_tube_registry(params) + if tube_registry: + all_tube_ids = tube_registry.all_tube_ids() + if all_tube_ids: + print(f"{bcolors.WARNING}Stopping {len(all_tube_ids)} in-process tunnel(s):{bcolors.ENDC}") + for tube_id in all_tube_ids: + try: + tube_registry.close_tube(tube_id, reason=CloseConnectionReasons.Normal) + print(f" {bcolors.OKGREEN}Stopped: {tube_id}{bcolors.ENDC}") + stopped_count += 1 + except Exception as e: + print(f" {bcolors.FAIL}Failed: {tube_id}: {e}{bcolors.ENDC}") + failed_count += 1 + + # Cross-process tunnels from file registry + registered = _list_registered_tunnels() + if registered: + print(f"{bcolors.WARNING}Stopping {len(registered)} external tunnel(s):{bcolors.ENDC}") + for entry in registered: + pid = entry.get('pid') + try: + os.kill(pid, signal.SIGTERM) + print(f" {bcolors.OKGREEN}Sent SIGTERM to PID {pid} " + f"({entry.get('mode', '?')} mode, {entry.get('host')}:{entry.get('port')}){bcolors.ENDC}") + stopped_count += 1 + except OSError as e: + print(f" {bcolors.FAIL}Failed to signal PID {pid}: {e}{bcolors.ENDC}") + failed_count += 1 + _unregister_tunnel(pid) + + if stopped_count == 0 and failed_count == 0: print(f"{bcolors.WARNING}No active tunnels to stop.{bcolors.ENDC}") return - # Confirm with user - print(f"{bcolors.WARNING}About to stop {len(all_tube_ids)} active tunnel(s):{bcolors.ENDC}") - for tube_id in all_tube_ids: - print(f" - {tube_id}") - - # Stop all tunnels - stopped_count = 0 - failed_count = 0 - for tube_id in all_tube_ids: - try: - tube_registry.close_tube(tube_id, reason=CloseConnectionReasons.Normal) - print(f"{bcolors.OKGREEN}Stopped tunnel: {tube_id}{bcolors.ENDC}") - stopped_count += 1 - except Exception as e: - print(f"{bcolors.FAIL}Failed to stop tunnel {tube_id}: {e}{bcolors.ENDC}") - failed_count += 1 - - # Summary if stopped_count > 0: print(f"\n{bcolors.OKGREEN}Successfully stopped {stopped_count} tunnel(s).{bcolors.ENDC}") if failed_count > 0: print(f"{bcolors.FAIL}Failed to stop {failed_count} tunnel(s).{bcolors.ENDC}") - - if stopped_count == 0: - raise CommandError('tunnel stop', 'Failed to stop any tunnels') class PAMTunnelEditCommand(Command): @@ -748,6 +860,10 @@ def execute(self, params, **kwargs): logging.warning("Could not write PID file '%s': %s", pid_file, e) pid_file = None + _register_tunnel(os.getpid(), record_uid, bg_tube_id, host, port, + target_host, target_port, mode='background', + record_title=record.title if record else None) + info = [] info.append(f"{bcolors.OKGREEN}Tunnel running in background{bcolors.ENDC}") info.append(f" Record: {record_uid}") @@ -757,11 +873,10 @@ def execute(self, params, **kwargs): info.append(f" Daemon PID: {os.getpid()}") if pid_file: info.append(f" PID file: {pid_file}") - info.append(f"\n{bcolors.OKGREEN}To stop: kill -SIGTERM {os.getpid()}{bcolors.ENDC}") + info.append(f"\n{bcolors.OKGREEN}To stop: pam tunnel stop {record_uid} or kill -SIGTERM {os.getpid()}{bcolors.ENDC}") if pid_file: info.append(f" or: kill -SIGTERM $(cat {pid_file})") - info.append(f"\n{bcolors.WARNING}Note: This tunnel is managed by a background process (PID {os.getpid()}).{bcolors.ENDC}") - info.append(f"{bcolors.WARNING}'pam tunnel list' from other Commander sessions cannot see it.{bcolors.ENDC}") + info.append(f"{bcolors.OKBLUE}Use 'pam tunnel list' from any Commander session to see this tunnel.{bcolors.ENDC}") os.write(_bg_pipe_w, ("READY\n" + "\n".join(info) + "\n").encode()) os.close(_bg_pipe_w) @@ -776,6 +891,7 @@ def _bg_signal_handler(signum, _frame): except Exception: pass finally: + _unregister_tunnel() try: if bg_tube_registry and bg_tube_id: bg_tube_registry.close_tube(bg_tube_id, reason=CloseConnectionReasons.Normal) @@ -807,6 +923,10 @@ def _bg_signal_handler(signum, _frame): pass return + _register_tunnel(os.getpid(), record_uid, run_tube_id, host, port, + target_host, target_port, mode='run', + record_title=record.title if record else None) + print(f"{bcolors.OKGREEN}Tunnel ready{bcolors.ENDC} {host}:{port} -> {target_host}:{target_port}") print(f"{bcolors.OKBLUE}Running:{bcolors.ENDC} {run_command}\n") @@ -819,6 +939,7 @@ def _bg_signal_handler(signum, _frame): logging.warning("Error running command: %s", run_err) cmd_exit = 1 finally: + _unregister_tunnel() print(f"\n{bcolors.OKBLUE}Stopping tunnel {run_tube_id or record_uid}...{bcolors.ENDC}") try: if run_tube_registry and run_tube_id: @@ -873,6 +994,10 @@ def _fg_signal_handler(signum, _frame): logging.warning("Could not write PID file '%s': %s", pid_file, e) pid_file = None + _register_tunnel(os.getpid(), record_uid, fg_tube_id, host, port, + target_host, target_port, mode='foreground', + record_title=record.title if record else None) + print(f"\n{bcolors.OKGREEN}Tunnel running in foreground mode{bcolors.ENDC}") print(f" Record: {record_uid}") if fg_tube_id: @@ -881,15 +1006,14 @@ def _fg_signal_handler(signum, _frame): print(f" PID: {os.getpid()}") if pid_file: print(f" PID file: {pid_file}") - print(f"\n{bcolors.OKGREEN}To stop: kill -SIGTERM {os.getpid()} (or Ctrl+C){bcolors.ENDC}") - print(f"\n{bcolors.WARNING}Note: This tunnel is only visible in this process.{bcolors.ENDC}") - print(f"{bcolors.WARNING}'pam tunnel list' from other Commander sessions cannot see it.{bcolors.ENDC}\n") + print(f"\n{bcolors.OKGREEN}To stop: kill -SIGTERM {os.getpid()} (or Ctrl+C) or pam tunnel stop {record_uid}{bcolors.ENDC}\n") try: fg_shutdown.wait() except KeyboardInterrupt: pass finally: + _unregister_tunnel() signal.signal(signal.SIGTERM, prev_sigterm) signal.signal(signal.SIGINT, prev_sigint) print(f"\n{bcolors.OKBLUE}Stopping tunnel {fg_tube_id or record_uid}...{bcolors.ENDC}") From aeec7fc8320320ab5ab6ee37fc1f655f746ab4b0 Mon Sep 17 00:00:00 2001 From: msawczynk Date: Fri, 27 Feb 2026 15:39:38 +0000 Subject: [PATCH 6/8] fix(pam tunnel): replace fork-based --background with subprocess approach The os.fork() approach did not work reliably on Linux because the Rust WebRTC library's internal state (threads, FFI objects, network connections) does not survive a fork. Replaced with subprocess.Popen(start_new_session=True) that launches a separate keeper process with --foreground, then polls the file-based tunnel registry for readiness. This also makes --background work on Windows (no os.fork required). Made-with: Cursor --- .../commands/tunnel_and_connections.py | 192 +++++++----------- 1 file changed, 79 insertions(+), 113 deletions(-) diff --git a/keepercommander/commands/tunnel_and_connections.py b/keepercommander/commands/tunnel_and_connections.py index 5eac85f72..f8a32097a 100644 --- a/keepercommander/commands/tunnel_and_connections.py +++ b/keepercommander/commands/tunnel_and_connections.py @@ -625,11 +625,11 @@ class PAMTunnelStartCommand(Command): help='Seconds to wait for the tunnel to connect before giving up ' '(used with --foreground, --background, and --run). Default: 30') pam_cmd_parser.add_argument('--background', '-bg', required=False, dest='background', action='store_true', - help='Start the tunnel, wait for connection readiness, then ' - 'daemonize and return control to the caller. The tunnel ' - 'continues running in the background. Use --pid-file to ' - 'write the daemon PID for later shutdown via ' - 'kill -SIGTERM $(cat ). Linux/macOS only.') + help='Start the tunnel in a background process, wait for ' + 'connection readiness, then return control to the caller. ' + 'The tunnel continues running independently. Use --pid-file ' + 'to write the daemon PID for later shutdown. Use ' + "'pam tunnel list' / 'pam tunnel stop' from any session.") def get_parser(self): return PAMTunnelStartCommand.pam_cmd_parser @@ -782,9 +782,8 @@ def execute(self, params, **kwargs): # Use Rust WebRTC implementation with configurable trickle ICE trickle_ice = not no_trickle_ice - # --background: fork before starting tunnel so Rust threads are - # created only in the child process (threads don't survive fork). - _bg_pipe_w = None + # --background: launch a separate Commander process with --foreground, + # then poll the file-based tunnel registry for readiness. background = kwargs.get('background', False) if background: if not params.batch_mode: @@ -792,120 +791,87 @@ def execute(self, params, **kwargs): print(f"{bcolors.OKBLUE}The tunnel is already running and will persist until you exit the shell.{bcolors.ENDC}") print(f"{bcolors.OKBLUE}Use 'pam tunnel list' to see active tunnels, 'pam tunnel stop' to stop them.{bcolors.ENDC}\n") return - if os.name == 'nt': - raise CommandError('tunnel start', - '--background is not supported on Windows. ' - 'Use --foreground in a background job (Start-Process).') - r_fd, w_fd = os.pipe() - child_pid = os.fork() - if child_pid > 0: - os.close(w_fd) + + connect_timeout = kwargs.get('connect_timeout', 30) + pid_file = kwargs.get('pid_file') + + bg_cmd = [sys.executable, '-m', 'keepercommander'] + if params.config_filename: + bg_cmd.extend(['--config', os.path.abspath(params.config_filename)]) + + tunnel_parts = ['pam', 'tunnel', 'start', record_uid, + '--port', str(port), '--foreground', + '--timeout', str(connect_timeout)] + if host and host != '127.0.0.1': + tunnel_parts.extend(['--host', host]) + if target_host: + tunnel_parts.extend(['--target-host', str(target_host)]) + if target_port: + tunnel_parts.extend(['--target-port', str(target_port)]) + if pid_file: + tunnel_parts.extend(['--pid-file', pid_file]) + if no_trickle_ice: + tunnel_parts.append('--no-trickle-ice') + bg_cmd.append(' '.join(tunnel_parts)) + + print(f"{bcolors.OKBLUE}Starting tunnel in background...{bcolors.ENDC}") + try: + bg_proc = subprocess.Popen( + bg_cmd, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + except Exception as e: + raise CommandError('tunnel start', f'Failed to launch background process: {e}') + + bg_deadline = time.time() + connect_timeout + 20 + bg_info = None + while time.time() < bg_deadline: + if bg_proc.poll() is not None: + print(f"{bcolors.FAIL}Background tunnel process exited " + f"(code {bg_proc.returncode}){bcolors.ENDC}") + return + for entry in _list_registered_tunnels(): + if entry.get('pid') == bg_proc.pid and entry.get('record_uid') == record_uid: + bg_info = entry + break + if bg_info: + break + time.sleep(1) + + if not bg_info: + print(f"{bcolors.FAIL}Tunnel did not become ready within the timeout{bcolors.ENDC}") try: - data = b"" - while True: - chunk = os.read(r_fd, 4096) - if not chunk: - break - data += chunk - finally: - os.close(r_fd) - msg = data.decode('utf-8', errors='replace') - if msg.startswith("READY\n"): - print(msg[6:], end='') - else: - print(f"{bcolors.FAIL}{msg}{bcolors.ENDC}") + bg_proc.terminate() + except Exception: + pass return - else: - os.close(r_fd) - os.setsid() - _bg_pipe_w = w_fd + + print(f"\n{bcolors.OKGREEN}Tunnel running in background{bcolors.ENDC}") + print(f" Record: {bg_info.get('record_title') or record_uid}") + if bg_info.get('tube_id'): + print(f" Tube ID: {bg_info['tube_id']}") + print(f" Listening: {host}:{port}") + print(f" Daemon PID: {bg_proc.pid}") + if pid_file: + print(f" PID file: {pid_file}") + print(f"\n{bcolors.OKGREEN}To stop: pam tunnel stop {record_uid} or " + f"kill -SIGTERM {bg_proc.pid}{bcolors.ENDC}") + if pid_file: + print(f" or: kill -SIGTERM $(cat {pid_file})") + print(f"{bcolors.OKBLUE}Use 'pam tunnel list' from any Commander session " + f"to see this tunnel.{bcolors.ENDC}") + return result = start_rust_tunnel(params, record_uid, gateway_uid, host, port, seed, target_host, target_port, socks, trickle_ice, record.title, allow_supply_host=allow_supply_host) - - if _bg_pipe_w is not None and (not result or not result.get("success")): - error_msg = result.get("error", "Unknown error") if result else "Failed to start tunnel" - os.write(_bg_pipe_w, f"Background tunnel failed: {error_msg}".encode()) - os.close(_bg_pipe_w) - os._exit(1) if result and result.get("success"): run_command = kwargs.get('run_command') connect_timeout = kwargs.get('connect_timeout', 30) - if _bg_pipe_w is not None: - bg_tube_id = result.get("tube_id") - bg_tube_registry = result.get("tube_registry") - bg_shutdown = threading.Event() - pid_file = kwargs.get('pid_file') - - conn_status = wait_for_tunnel_connection(result, timeout=connect_timeout, show_progress=False) - - if not conn_status.get("connected"): - err = conn_status.get("error", "Connection failed") - os.write(_bg_pipe_w, f"Tunnel did not connect: {err}".encode()) - os.close(_bg_pipe_w) - if bg_tube_registry and bg_tube_id: - try: - bg_tube_registry.close_tube(bg_tube_id, reason=CloseConnectionReasons.Normal) - unregister_tunnel_session(bg_tube_id) - except Exception: - pass - os._exit(1) - - if pid_file: - try: - with open(pid_file, 'w') as pf: - pf.write(str(os.getpid())) - except Exception as e: - logging.warning("Could not write PID file '%s': %s", pid_file, e) - pid_file = None - - _register_tunnel(os.getpid(), record_uid, bg_tube_id, host, port, - target_host, target_port, mode='background', - record_title=record.title if record else None) - - info = [] - info.append(f"{bcolors.OKGREEN}Tunnel running in background{bcolors.ENDC}") - info.append(f" Record: {record_uid}") - if bg_tube_id: - info.append(f" Tube ID: {bg_tube_id}") - info.append(f" Listening: {host}:{port}") - info.append(f" Daemon PID: {os.getpid()}") - if pid_file: - info.append(f" PID file: {pid_file}") - info.append(f"\n{bcolors.OKGREEN}To stop: pam tunnel stop {record_uid} or kill -SIGTERM {os.getpid()}{bcolors.ENDC}") - if pid_file: - info.append(f" or: kill -SIGTERM $(cat {pid_file})") - info.append(f"{bcolors.OKBLUE}Use 'pam tunnel list' from any Commander session to see this tunnel.{bcolors.ENDC}") - - os.write(_bg_pipe_w, ("READY\n" + "\n".join(info) + "\n").encode()) - os.close(_bg_pipe_w) - - def _bg_signal_handler(signum, _frame): - bg_shutdown.set() - signal.signal(signal.SIGTERM, _bg_signal_handler) - signal.signal(signal.SIGINT, _bg_signal_handler) - - try: - bg_shutdown.wait() - except Exception: - pass - finally: - _unregister_tunnel() - try: - if bg_tube_registry and bg_tube_id: - bg_tube_registry.close_tube(bg_tube_id, reason=CloseConnectionReasons.Normal) - unregister_tunnel_session(bg_tube_id) - except Exception: - pass - if pid_file: - try: - os.remove(pid_file) - except OSError: - pass - os._exit(0) - - elif run_command: + if run_command: run_tube_id = result.get("tube_id") run_tube_registry = result.get("tube_registry") From cbaaf90306e131d8dc253dc4744ecf1af69d5360 Mon Sep 17 00:00:00 2001 From: msawczynk Date: Fri, 27 Feb 2026 15:52:45 +0000 Subject: [PATCH 7/8] refactor(pam tunnel): robustness improvements across all tunnel modes - Atomic registry writes (temp + rename) prevent corruption during concurrent reads - Mutual exclusivity check for --foreground/--background/--run flags - SIGHUP handler for --foreground so terminal closure triggers clean shutdown - Capture stderr from --background subprocess for actionable error messages - Pass params.server to --background subprocess for non-default Keeper servers - Reduce --background polling interval from 1s to 0.5s for better responsiveness - Initialize cmd_exit before try block in --run to prevent NameError edge case - Extract _stop_tunnel_process() for cross-platform process termination - Debug logging for corrupt registry files Made-with: Cursor --- .../commands/tunnel_and_connections.py | 85 ++++++++++++++----- 1 file changed, 66 insertions(+), 19 deletions(-) diff --git a/keepercommander/commands/tunnel_and_connections.py b/keepercommander/commands/tunnel_and_connections.py index f8a32097a..be7840eec 100644 --- a/keepercommander/commands/tunnel_and_connections.py +++ b/keepercommander/commands/tunnel_and_connections.py @@ -52,8 +52,12 @@ def _tunnel_registry_dir(): def _register_tunnel(pid, record_uid, tube_id, host, port, target_host=None, target_port=None, mode='foreground', record_title=None): - """Write a JSON file for an active tunnel so other processes can see it.""" - path = os.path.join(_tunnel_registry_dir(), f'{pid}.json') + """Write a JSON file for an active tunnel so other processes can see it. + + Uses atomic write (temp file + rename) so readers never see partial data. + """ + reg_dir = _tunnel_registry_dir() + path = os.path.join(reg_dir, f'{pid}.json') data = { 'pid': pid, 'record_uid': record_uid, @@ -66,11 +70,17 @@ def _register_tunnel(pid, record_uid, tube_id, host, port, 'record_title': record_title, 'started': time.strftime('%Y-%m-%d %H:%M:%S'), } + tmp_path = path + '.tmp' try: - with open(path, 'w') as f: + with open(tmp_path, 'w') as f: json.dump(data, f) + os.replace(tmp_path, path) except Exception as exc: logging.debug("Could not write tunnel registry file %s: %s", path, exc) + try: + os.remove(tmp_path) + except OSError: + pass def _unregister_tunnel(pid=None): @@ -101,6 +111,19 @@ def _is_pid_alive(pid): return False +def _stop_tunnel_process(pid): + """Send a termination signal to a tunnel process. + + On Unix, sends SIGTERM for graceful shutdown. + On Windows, calls TerminateProcess (Python maps SIGTERM to it). + """ + try: + os.kill(pid, signal.SIGTERM) + return True + except OSError: + return False + + def _list_registered_tunnels(): """Read all registry files, verify PID liveness, clean stale entries. @@ -120,7 +143,8 @@ def _list_registered_tunnels(): result.append(data) else: os.remove(fpath) - except Exception: + except Exception as exc: + logging.debug("Removing corrupt tunnel registry file %s: %s", fpath, exc) try: os.remove(fpath) except OSError: @@ -287,12 +311,11 @@ def execute(self, params, **kwargs): entry.get('record_title', '')): pid = entry.get('pid') if pid and _is_pid_alive(pid): - try: - os.kill(pid, signal.SIGTERM) - print(f"{bcolors.OKGREEN}Sent SIGTERM to tunnel process " + if _stop_tunnel_process(pid): + print(f"{bcolors.OKGREEN}Sent stop signal to tunnel process " f"(PID {pid}, {entry.get('mode', '?')} mode){bcolors.ENDC}") - except OSError as e: - print(f"{bcolors.FAIL}Failed to signal PID {pid}: {e}{bcolors.ENDC}") + else: + print(f"{bcolors.FAIL}Failed to signal PID {pid}{bcolors.ENDC}") else: _unregister_tunnel(pid) return @@ -352,13 +375,12 @@ def _stop_all_tunnels(self, params): print(f"{bcolors.WARNING}Stopping {len(registered)} external tunnel(s):{bcolors.ENDC}") for entry in registered: pid = entry.get('pid') - try: - os.kill(pid, signal.SIGTERM) - print(f" {bcolors.OKGREEN}Sent SIGTERM to PID {pid} " + if _stop_tunnel_process(pid): + print(f" {bcolors.OKGREEN}Sent stop signal to PID {pid} " f"({entry.get('mode', '?')} mode, {entry.get('host')}:{entry.get('port')}){bcolors.ENDC}") stopped_count += 1 - except OSError as e: - print(f" {bcolors.FAIL}Failed to signal PID {pid}: {e}{bcolors.ENDC}") + else: + print(f" {bcolors.FAIL}Failed to signal PID {pid}{bcolors.ENDC}") failed_count += 1 _unregister_tunnel(pid) @@ -782,9 +804,18 @@ def execute(self, params, **kwargs): # Use Rust WebRTC implementation with configurable trickle ICE trickle_ice = not no_trickle_ice + # Validate mutual exclusivity of mode flags + background = kwargs.get('background', False) + foreground = kwargs.get('foreground', False) + run_command = kwargs.get('run_command') + mode_flags = sum(bool(f) for f in [background, foreground, run_command]) + if mode_flags > 1: + raise CommandError('tunnel start', + '--foreground, --background, and --run are mutually exclusive. ' + 'Use only one at a time.') + # --background: launch a separate Commander process with --foreground, # then poll the file-based tunnel registry for readiness. - background = kwargs.get('background', False) if background: if not params.batch_mode: print(f"\n{bcolors.OKBLUE}Note: --background is not needed inside the interactive shell.{bcolors.ENDC}") @@ -798,6 +829,8 @@ def execute(self, params, **kwargs): bg_cmd = [sys.executable, '-m', 'keepercommander'] if params.config_filename: bg_cmd.extend(['--config', os.path.abspath(params.config_filename)]) + if hasattr(params, 'server') and params.server: + bg_cmd.extend(['--server', params.server]) tunnel_parts = ['pam', 'tunnel', 'start', record_uid, '--port', str(port), '--foreground', @@ -820,7 +853,7 @@ def execute(self, params, **kwargs): bg_cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, + stderr=subprocess.PIPE, start_new_session=True, ) except Exception as e: @@ -830,8 +863,15 @@ def execute(self, params, **kwargs): bg_info = None while time.time() < bg_deadline: if bg_proc.poll() is not None: + stderr_output = '' + try: + stderr_output = bg_proc.stderr.read().decode('utf-8', errors='replace').strip() + except Exception: + pass print(f"{bcolors.FAIL}Background tunnel process exited " f"(code {bg_proc.returncode}){bcolors.ENDC}") + if stderr_output: + print(f"{bcolors.FAIL}{stderr_output}{bcolors.ENDC}") return for entry in _list_registered_tunnels(): if entry.get('pid') == bg_proc.pid and entry.get('record_uid') == record_uid: @@ -839,7 +879,7 @@ def execute(self, params, **kwargs): break if bg_info: break - time.sleep(1) + time.sleep(0.5) if not bg_info: print(f"{bcolors.FAIL}Tunnel did not become ready within the timeout{bcolors.ENDC}") @@ -868,7 +908,6 @@ def execute(self, params, **kwargs): result = start_rust_tunnel(params, record_uid, gateway_uid, host, port, seed, target_host, target_port, socks, trickle_ice, record.title, allow_supply_host=allow_supply_host) if result and result.get("success"): - run_command = kwargs.get('run_command') connect_timeout = kwargs.get('connect_timeout', 30) if run_command: @@ -896,6 +935,7 @@ def execute(self, params, **kwargs): print(f"{bcolors.OKGREEN}Tunnel ready{bcolors.ENDC} {host}:{port} -> {target_host}:{target_port}") print(f"{bcolors.OKBLUE}Running:{bcolors.ENDC} {run_command}\n") + cmd_exit = 1 try: proc = subprocess.run(run_command, shell=True) cmd_exit = proc.returncode @@ -917,7 +957,7 @@ def execute(self, params, **kwargs): sys.exit(cmd_exit) - elif kwargs.get('foreground', False): + elif foreground: if not params.batch_mode: print(f"\n{bcolors.OKBLUE}Note: --foreground is not needed inside the interactive shell.{bcolors.ENDC}") print(f"{bcolors.OKBLUE}The tunnel is already running and will persist until you exit the shell.{bcolors.ENDC}") @@ -935,6 +975,9 @@ def _fg_signal_handler(signum, _frame): prev_sigterm = signal.signal(signal.SIGTERM, _fg_signal_handler) prev_sigint = signal.signal(signal.SIGINT, _fg_signal_handler) + prev_sighup = None + if hasattr(signal, 'SIGHUP'): + prev_sighup = signal.signal(signal.SIGHUP, _fg_signal_handler) print(f"{bcolors.OKBLUE}Waiting for tunnel to connect (timeout: {connect_timeout}s)...{bcolors.ENDC}") conn_status = wait_for_tunnel_connection(result, timeout=connect_timeout, show_progress=False) @@ -942,6 +985,8 @@ def _fg_signal_handler(signum, _frame): if not conn_status.get("connected"): signal.signal(signal.SIGTERM, prev_sigterm) signal.signal(signal.SIGINT, prev_sigint) + if prev_sighup is not None: + signal.signal(signal.SIGHUP, prev_sighup) err = conn_status.get("error", "Connection failed") print(f"{bcolors.FAIL}Tunnel did not connect: {err}{bcolors.ENDC}") if fg_tube_registry and fg_tube_id: @@ -982,6 +1027,8 @@ def _fg_signal_handler(signum, _frame): _unregister_tunnel() signal.signal(signal.SIGTERM, prev_sigterm) signal.signal(signal.SIGINT, prev_sigint) + if prev_sighup is not None: + signal.signal(signal.SIGHUP, prev_sighup) print(f"\n{bcolors.OKBLUE}Stopping tunnel {fg_tube_id or record_uid}...{bcolors.ENDC}") try: if fg_tube_registry and fg_tube_id: From 41b664bcb6aadd0d68bc897e75799fa6f57d1901 Mon Sep 17 00:00:00 2001 From: msawczynk Date: Fri, 6 Mar 2026 09:36:36 +0000 Subject: [PATCH 8/8] fix(pam tunnel): move tunnel registry to temp dir (survives credential removal) Move the file-based tunnel registry from ~/.keeper/tunnel-sessions/ to /keeper-tunnel-sessions/ so that deleting or replacing Keeper credentials no longer orphans running tunnel metadata. The system temp directory is cleared on reboot, matching the tunnel lifecycle. On Unix the directory is created with mode 0700 for isolation. Made-with: Cursor --- .../commands/tunnel_and_connections.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/keepercommander/commands/tunnel_and_connections.py b/keepercommander/commands/tunnel_and_connections.py index be7840eec..8e6925464 100644 --- a/keepercommander/commands/tunnel_and_connections.py +++ b/keepercommander/commands/tunnel_and_connections.py @@ -16,6 +16,7 @@ import signal import subprocess import sys +import tempfile import threading import time from keeper_secrets_manager_core.utils import bytes_to_base64, base64_to_bytes @@ -38,14 +39,24 @@ # File-based tunnel registry # # Each foreground/background/run tunnel writes a JSON metadata file to -# ~/.keeper/tunnel-sessions/.json. This lets `pam tunnel list` and -# `pam tunnel stop` discover tunnels across Commander processes. +# /keeper-tunnel-sessions/.json. This lets `pam tunnel list` +# and `pam tunnel stop` discover tunnels across Commander processes. +# +# The registry lives in the system temp directory rather than ~/.keeper/ +# so that it survives credential removal/replacement. Temp directories +# are cleared on reboot, which matches tunnel lifecycle (tunnels don't +# survive reboots). # --------------------------------------------------------------------------- def _tunnel_registry_dir(): """Return (and create) the tunnel session registry directory.""" - base = os.path.join(os.path.expanduser('~'), '.keeper', 'tunnel-sessions') + base = os.path.join(tempfile.gettempdir(), 'keeper-tunnel-sessions') os.makedirs(base, exist_ok=True) + if os.name != 'nt': + try: + os.chmod(base, 0o700) + except OSError: + pass return base