diff --git a/keepercli-package/requirements.txt b/keepercli-package/requirements.txt index 8ff4ffbc..020d515e 100644 --- a/keepercli-package/requirements.txt +++ b/keepercli-package/requirements.txt @@ -4,6 +4,9 @@ pyperclip tabulate asciitree colorama +keeper_pam_webrtc_rs>=2.1.18 +websockets +keeper-secrets-manager-core>=16.6.0 cbor2; sys_platform == "darwin" and python_version>='3.10' pyobjc-framework-LocalAuthentication; sys_platform == "darwin" and python_version>='3.10' winrt-runtime; sys_platform == "win32" diff --git a/keepercli-package/setup.cfg b/keepercli-package/setup.cfg index 877a1315..53b52397 100644 --- a/keepercli-package/setup.cfg +++ b/keepercli-package/setup.cfg @@ -31,6 +31,9 @@ install_requires = tabulate asciitree colorama + keeper_pam_webrtc_rs>=2.1.18 + websockets + keeper-secrets-manager-core>=16.6.0 cbor2; sys_platform == "darwin" and python_version>='3.10' pyobjc-framework-LocalAuthentication; sys_platform == "darwin" and python_version>='3.10' winrt-runtime; sys_platform == "win32" diff --git a/keepercli-package/src/keepercli/commands/base.py b/keepercli-package/src/keepercli/commands/base.py index 925ae48c..67b2b28b 100644 --- a/keepercli-package/src/keepercli/commands/base.py +++ b/keepercli-package/src/keepercli/commands/base.py @@ -27,9 +27,15 @@ class CommandError(errors.KeeperError): - def __init__(self, message): - super().__init__(message) - self.command = '' + def __init__(self, message_or_command, message=None): + # Keepercli style: CommandError('message') + # Commander style: CommandError('command', 'message') + if message is None: + super().__init__(message_or_command) + self.command = '' + else: + super().__init__(message) + self.command = message_or_command or '' def __str__(self): if self.command: diff --git a/keepercli-package/src/keepercli/commands/pam/debug/__init__.py b/keepercli-package/src/keepercli/commands/pam/debug/__init__.py index d6f6ac91..0abeb8ad 100644 --- a/keepercli-package/src/keepercli/commands/pam/debug/__init__.py +++ b/keepercli-package/src/keepercli/commands/pam/debug/__init__.py @@ -11,7 +11,7 @@ def get_connection(context: KeeperParams) -> ConnectionBase: if value_to_boolean(os.environ.get("USE_LOCAL_DAG", False)) is False: from keepersdk.helpers.keeper_dag.connection.commander import Connection as CommanderConnection - return CommanderConnection(context=context) + return CommanderConnection(vault=context.vault) else: from keepersdk.helpers.keeper_dag.connection.local import Connection as LocalConnection return LocalConnection() \ No newline at end of file diff --git a/keepercli-package/src/keepercli/commands/pam/keeper_pam.py b/keepercli-package/src/keepercli/commands/pam/keeper_pam.py index cd99959b..3098c083 100644 --- a/keepercli-package/src/keepercli/commands/pam/keeper_pam.py +++ b/keepercli-package/src/keepercli/commands/pam/keeper_pam.py @@ -10,6 +10,7 @@ from .pam_rotation import PAMCreateRecordRotationCommand, PAMListRecordRotationCommand, PAMRouterGetRotationInfo, PAMRouterScriptCommand from .pam_connection import PAMConnectionEditCommand from .pam_rbi import PAMRbiEditCommand +from .pam_launch.launch import PAMLaunchCommand from .. import enterprise_utils from .. import base from ... import api @@ -83,6 +84,7 @@ def __init__(self): self.register_command(PAMRotationCommand(), 'rotation', 'r') self.register_command(PAMConnectionCommand(), 'connection', 'n') self.register_command(PAMRbiCommand(), 'rbi', 'b') + self.register_command(PAMLaunchCommand(), 'launch', 'l') class PAMGatewayCommand(base.GroupCommand): diff --git a/keepercli-package/src/keepercli/commands/pam/pam_connection.py b/keepercli-package/src/keepercli/commands/pam/pam_connection.py index 5a3dc9d6..56cca896 100644 --- a/keepercli-package/src/keepercli/commands/pam/pam_connection.py +++ b/keepercli-package/src/keepercli/commands/pam/pam_connection.py @@ -1,14 +1,30 @@ import os import argparse +from typing import Optional from keepersdk import utils from keepersdk.helpers.keeper_dag import dag_utils +from keepersdk.helpers.keeper_dag.constants import ( + PAM_CONFIGURATIONS, + PAM_DATABASE, + PAM_DIRECTORY, + PAM_MACHINE, + PAM_RESOURCES, + PAM_USER, +) from keepersdk.helpers.tunnel.tunnel_graph import TunnelDAG from keepersdk.helpers.tunnel.tunnel_utils import get_keeper_tokens, get_config_uid -from keepersdk.vault import record_management, vault_record +from keepersdk.vault import ( + nsf_management, + record_management, + vault_extensions, + vault_online, + vault_record, +) from .. import base from ... import api +from ...helpers import record_utils from ...params import KeeperParams @@ -18,6 +34,98 @@ protocols = ['', 'http', 'kubernetes', 'mysql', 'postgresql', 'rdp', 'sql-server', 'ssh', 'telnet', 'vnc'] choices = ['on', 'off', 'default'] +# Resource + RBI + PAM configs (reuse keeper_dag constants where they exist). +_PAM_CONNECTION_RECORD_TYPES = (*PAM_RESOURCES, 'pamRemoteBrowser', *PAM_CONFIGURATIONS) +_PAM_SEED_RECORD_TYPES = (PAM_DATABASE, PAM_DIRECTORY, PAM_MACHINE, 'pamRemoteBrowser') +_PAM_RESOURCE_USER_LINK_TYPES = (PAM_DATABASE, PAM_DIRECTORY, PAM_MACHINE) + + +def _resolve_nsf_record_uid(vault: vault_online.VaultOnline, identifier: str) -> Optional[str]: + """Resolve an NSF record UID from a UID or exact title.""" + if not vault.nsf_data or not identifier: + return None + if vault.nsf_data.get_record(identifier): + return identifier + try: + return nsf_management.resolve_nsf_record_uid(vault, identifier) + except nsf_management.NsfError: + return None + + +def _load_nsf_typed_record( + vault: vault_online.VaultOnline, record_uid: str) -> Optional[vault_record.TypedRecord]: + """Load an NSF record as TypedRecord (with record_key when available).""" + if not vault.nsf_data or not record_uid or not vault.nsf_data.get_record(record_uid): + return None + try: + meta = nsf_management.load_nsf_record_metadata(vault, record_uid) + except nsf_management.NsfError: + return None + typed = vault_record.TypedRecord() + typed.record_uid = record_uid + typed.load_record_data({ + 'type': meta.get('type') or '', + 'title': meta.get('title') or record_uid, + 'notes': meta.get('notes') or '', + 'fields': meta.get('fields') or [], + 'custom': meta.get('custom') or [], + }) + entry = vault.nsf_data.get_record(record_uid) + if entry and entry.record_key: + typed.record_key = entry.record_key + return typed + + +def _is_nsf_record(vault: vault_online.VaultOnline, record_uid: str) -> bool: + return bool(record_uid and vault.nsf_data and vault.nsf_data.get_record(record_uid)) + + +def _load_typed_record( + vault: vault_online.VaultOnline, + context: KeeperParams, + identifier: str, +) -> Optional[vault_record.TypedRecord]: + """Load a TypedRecord from classic vault or NSF by UID/path/title.""" + if not identifier: + return None + loaded = vault.vault_data.load_record(identifier) + if isinstance(loaded, vault_record.TypedRecord): + return loaded + record_info = record_utils.try_resolve_single_record(identifier, context) + if record_info: + loaded = vault.vault_data.load_record(record_info.record_uid) + if isinstance(loaded, vault_record.TypedRecord): + return loaded + nsf_uid = _resolve_nsf_record_uid(vault, identifier) + if nsf_uid: + return _load_nsf_typed_record(vault, nsf_uid) + return None + + +def _save_typed_record(vault: vault_online.VaultOnline, record: vault_record.TypedRecord) -> None: + """Persist typed-record body changes via NSF or classic update.""" + if _is_nsf_record(vault, record.record_uid): + schema = vault.vault_data.get_record_type_by_name(record.record_type) + record_data = vault_extensions.extract_typed_record_data(record, schema) + try: + nsf_management.update_nsf_record( + vault, + record.record_uid, + title=record.title, + record_type=record.record_type, + record_data=record_data, + request_sync=True, + ) + except nsf_management.NsfError as err: + raise base.CommandError(str(err)) from err + else: + record_management.update_record(vault, record) + vault.sync_down() + + +def _is_pam_config_record(record: vault_record.TypedRecord) -> bool: + return record.record_type in PAM_CONFIGURATIONS + class PAMConnectionEditCommand(base.ArgparseCommand): @@ -75,29 +183,36 @@ def execute(self, context: KeeperParams, **kwargs): record_name = kwargs.get('record') if not record_name: raise base.CommandError(f'Record parameter is required.') - record = vault.vault_data.load_record(record_name) + record = _load_typed_record(vault, context, record_name) if not record: raise base.CommandError(f'Record \"{record_name}\" not found.') - if not isinstance(record, vault_record.TypedRecord): - raise base.CommandError(f'Record \"{record_name}\" can not be edited.') - - config_name = kwargs.get('config', None) - cfg_rec = vault.vault_data.load_record(config_name) - if not cfg_rec and record.version == 6: - cfg_rec = record - config_uid = cfg_rec.record_uid if cfg_rec else None record_uid = record.record_uid record_type = record.record_type - if record_type not in ("pamMachine pamDatabase pamDirectory pamNetworkConfiguration pamAwsConfiguration " - "pamRemoteBrowser pamAzureConfiguration").split(): + if record_type not in _PAM_CONNECTION_RECORD_TYPES: raise base.CommandError(f"This record's type is not supported for connections. " f"Connections are only supported on pamMachine, pamDatabase, pamDirectory, " f"pamRemoteBrowser, pamNetworkConfiguration pamAwsConfiguration, and " f"pamAzureConfiguration records") encrypted_session_token, encrypted_transmission_key, transmission_key = get_keeper_tokens(vault) - if record_type in "pamNetworkConfiguration pamAwsConfiguration pamAzureConfiguration".split(): + + config_name = kwargs.get('config', None) + cfg_rec = _load_typed_record(vault, context, config_name) if config_name else None + if not cfg_rec and _is_pam_config_record(record): + cfg_rec = record + + # For resource records, fall back to the PAM config already linked in the DAG. + existing_config_uid = None + if not _is_pam_config_record(record): + existing_config_uid = get_config_uid( + vault, encrypted_session_token, encrypted_transmission_key, record_uid) + existing_config_uid = str(existing_config_uid) if existing_config_uid else '' + if not cfg_rec and existing_config_uid: + cfg_rec = _load_typed_record(vault, context, existing_config_uid) + config_uid = cfg_rec.record_uid if cfg_rec else None + + if record_type in PAM_CONFIGURATIONS: tdag = TunnelDAG(vault, encrypted_session_token, encrypted_transmission_key, record_uid, is_config=True, transmission_key=transmission_key) tdag.edit_tunneling_config(connections=_connections, session_recording=_recording, typescript_recording=_typescript_recording) @@ -111,7 +226,7 @@ def execute(self, context: KeeperParams, **kwargs): base64_seed = utils.base64_url_encode(seed) record_seed = vault_record.TypedField.create_field('trafficEncryptionSeed', base64_seed, required=False) - record_types_with_seed = ("pamDatabase", "pamDirectory", "pamMachine", "pamRemoteBrowser") + record_types_with_seed = _PAM_SEED_RECORD_TYPES if traffic_encryption_key: traffic_encryption_key.value = [base64_seed] elif record.record_type in record_types_with_seed: @@ -183,8 +298,7 @@ def execute(self, context: KeeperParams, **kwargs): logger.debug(f'Unexpected value for --key-events {key_events} (ignored)') if dirty: - record_management.update_record(vault, record) - vault.sync_down() + _save_typed_record(vault, record) traffic_encryption_key = record.get_typed_field('trafficEncryptionSeed') if not traffic_encryption_key: @@ -192,23 +306,33 @@ def execute(self, context: KeeperParams, **kwargs): f"Please make sure you have edit rights to record {record_uid}") dirty = False - existing_config_uid = get_config_uid(vault, encrypted_session_token, encrypted_transmission_key, record_uid) + if not config_uid: + raise base.CommandError( + "No PAM Configuration UID set. " + "This must be set or supplied for connections to work. " + "Pass --config [ConfigUID] (see `pam config list`)." + ) tdag = TunnelDAG(vault, encrypted_session_token, encrypted_transmission_key, config_uid, - transmission_key=transmission_key) - old_dag = TunnelDAG(vault, encrypted_session_token, encrypted_transmission_key, existing_config_uid, - transmission_key=transmission_key) + is_config=True, transmission_key=transmission_key) - if config_uid and existing_config_uid != config_uid: + if existing_config_uid and existing_config_uid != config_uid: + old_dag = TunnelDAG( + vault, encrypted_session_token, encrypted_transmission_key, existing_config_uid, + is_config=True, transmission_key=transmission_key, + ) old_dag.remove_from_dag(record_uid) tdag.link_resource_to_config(record_uid) + elif not tdag.is_tunneling_config_set_up(record_uid): + tdag.link_resource_to_config(record_uid) - if tdag is None or not tdag.linking_dag.has_graph: - raise base.CommandError(f"No PAM Configuration UID set. " - f"This must be set or supplied for connections to work. This can be done by adding " - f"' --config [ConfigUID] " - f" The ConfigUID can be found by running " - f"'pam config list'") + if not tdag.linking_dag.has_graph: + raise base.CommandError( + f"No PAM Configuration DAG found for {config_uid}. " + "Initialize tunnel settings on the config first, e.g.\n" + f" pam connection edit {config_uid} --connections on " + "--connections-recording on" + ) if not tdag.check_tunneling_enabled_config(enable_connections=_connections, enable_session_recording=_recording, @@ -255,20 +379,21 @@ def execute(self, context: KeeperParams, **kwargs): typescript_recording=kwargs.get('typescriptrecording', None)) admin_name = kwargs.get('admin') - adm_rec = vault.vault_data.load_record(admin_name) - admin_uid = adm_rec.record_uid if adm_rec else None - if admin_uid and record_type in ("pamDatabase", "pamDirectory", "pamMachine"): - tdag.link_user_to_resource(admin_uid, record_uid, is_admin=True, belongs_to=True) + if admin_name: + adm_rec = _load_typed_record(vault, context, admin_name) + admin_uid = adm_rec.record_uid if adm_rec else None + if admin_uid and record_type in _PAM_RESOURCE_USER_LINK_TYPES: + tdag.link_user_to_resource(admin_uid, record_uid, is_admin=True, belongs_to=True) launch_user_name = kwargs.get('launch_user') if launch_user_name: - launch_rec = vault.vault_data.load_record(launch_user_name) + launch_rec = _load_typed_record(vault, context, launch_user_name) if not launch_rec: raise base.CommandError(f'Launch user record "{launch_user_name}" not found.') - if not isinstance(launch_rec, vault_record.TypedRecord) or launch_rec.record_type != 'pamUser': + if launch_rec.record_type != PAM_USER: raise base.CommandError(f'Launch user record must be a pamUser record type.') launch_uid = launch_rec.record_uid - if record_type in ("pamDatabase", "pamDirectory", "pamMachine"): + if record_type in _PAM_RESOURCE_USER_LINK_TYPES: tdag.clear_launch_credential_for_resource(record_uid, exclude_user_uid=launch_uid) tdag.link_user_to_resource(launch_uid, record_uid, is_admin=True, belongs_to=True) tdag.upgrade_resource_meta_to_v1(record_uid) diff --git a/keepercli-package/src/keepercli/commands/pam/pam_dto.py b/keepercli-package/src/keepercli/commands/pam/pam_dto.py index 1638102c..9681ee9d 100644 --- a/keepercli-package/src/keepercli/commands/pam/pam_dto.py +++ b/keepercli-package/src/keepercli/commands/pam/pam_dto.py @@ -156,3 +156,13 @@ def __init__(self, inputs: GatewayActionDiscoverRuleValidateInputs, conversation def toJSON(self): return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True, indent=4) + +class GatewayActionWebRTCSession(GatewayAction): + + def __init__(self, inputs: dict, conversation_id=None, message_id=None): + super().__init__('webrtc-session', inputs=inputs, conversation_id=conversation_id, + message_id=message_id, is_scheduled=False) + + def toJSON(self): + return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True, indent=4) + diff --git a/keepercli-package/src/keepercli/commands/pam/pam_import/__init__.py b/keepercli-package/src/keepercli/commands/pam/pam_import/__init__.py new file mode 100644 index 00000000..caf701f3 --- /dev/null +++ b/keepercli-package/src/keepercli/commands/pam/pam_import/__init__.py @@ -0,0 +1,3 @@ +from .base import ConnectionProtocol + +__all__ = ['ConnectionProtocol'] diff --git a/keepercli-package/src/keepercli/commands/pam/pam_import/base.py b/keepercli-package/src/keepercli/commands/pam/pam_import/base.py new file mode 100644 index 00000000..05936ea2 --- /dev/null +++ b/keepercli-package/src/keepercli/commands/pam/pam_import/base.py @@ -0,0 +1,31 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' Dict[str, Any]: + """Vault-compatible default KeeperAI settings (``emptyKeeperAISettings`` in dag-pam-link.ts).""" + empty_allow_deny = {'allow': [], 'deny': []} + return { + 'version': AI_SETTINGS_VERSION, + 'riskLevels': { + 'critical': {'aiSessionTerminate': False, 'tags': dict(empty_allow_deny)}, + 'high': {'aiSessionTerminate': False, 'tags': dict(empty_allow_deny)}, + 'medium': {'aiSessionTerminate': False, 'tags': dict(empty_allow_deny)}, + 'low': {'aiSessionTerminate': False, 'tags': {'allow': []}}, + }, + } + + +def is_default_keeper_ai_settings(settings: Optional[Dict[str, Any]]) -> bool: + """True when settings are absent or match the vault empty/default template.""" + if not settings: + return True + risk_levels = settings.get('riskLevels') + if not isinstance(risk_levels, dict) or not risk_levels: + return True + for level, level_data in risk_levels.items(): + if not isinstance(level_data, dict): + return False + if level_data.get('aiSessionTerminate', False): + return False + tags = level_data.get('tags') + if not isinstance(tags, dict): + continue + if tags.get('allow'): + return False + if level != 'low' and tags.get('deny'): + return False + return True + + +def _find_highest_path_edge(vertex, head_uid: str, dag_path: str): + """Return the highest-version edge for a self-loop DATA path (any edge type).""" + best = None + best_version = -1 + for edge in vertex.edges or []: + if not edge or edge.head_uid != head_uid or edge.path != dag_path: + continue + if edge.version > best_version: + best_version = edge.version + best = edge + return best + + +def list_resource_data_edges( + params: KeeperParams, + resource_uid: str, + config_uid: Optional[str] = None +) -> List[Dict[str, Any]]: + """ + List all DATA edges on a resource vertex to inspect available paths. + + This is useful for discovering what settings are stored in the DAG, + such as 'ai_settings', 'jit_settings', etc. + + Args: + params: KeeperParams instance + resource_uid: UID of the PAM resource + config_uid: Optional PAM config UID. If not provided, will be looked up. + + Returns: + List of dictionaries containing edge information: + [{"path": "ai_settings", "version": 0, "active": True, "is_encrypted": True}, ...] + """ + try: + # Get the record to access the record key + record = vault.KeeperRecord.load(params, resource_uid) + if not record: + logging.warning(f"Record {resource_uid} not found") + return [] + + # Get record key for decryption + record_key = None + if resource_uid in params.record_cache: + record_data = params.record_cache[resource_uid] + record_key = record_data.get('record_key_unencrypted') + + if not record_key: + logging.warning(f"Record key not available for {resource_uid}") + return [] + + # Get config UID if not provided + if not config_uid: + encrypted_session_token, encrypted_transmission_key, transmission_key = get_keeper_tokens(params) + config_uid = get_config_uid(params, encrypted_session_token, encrypted_transmission_key, resource_uid) + if not config_uid: + config_uid = resource_uid + + # Create DAG connection + encrypted_session_token, encrypted_transmission_key, transmission_key = get_keeper_tokens(params) + + # Create a dummy record for DAG + dag_record = PasswordRecord() + dag_record.record_uid = config_uid + dag_record.record_key = record_key # Use record key to decrypt keychain + + conn = Connection( + vault=params.vault, + encrypted_transmission_key=encrypted_transmission_key, + encrypted_session_token=encrypted_session_token, + transmission_key=transmission_key, + use_read_protobuf=False, + use_write_protobuf=False + ) + + # Load the DAG + linking_dag = DAG( + conn=conn, + record=dag_record, + graph_id=PamGraphId.PAM.value + ) + try: + linking_dag.load() + except DAGPathException as e: + logging.debug(f"Skipping DATA edge listing for {resource_uid}: ambiguous DAG path ({e})") + return [] + + # Get the resource vertex + resource_vertex = linking_dag.get_vertex(resource_uid) + if not resource_vertex: + logging.warning(f"Resource vertex {resource_uid} not found in DAG") + return [] + + # Collect all DATA edges + data_edges = [] + for edge in resource_vertex.edges: + if edge and edge.edge_type == EdgeType.DATA: + data_edges.append({ + "path": edge.path, + "version": edge.version, + "active": edge.active, + "is_encrypted": edge.is_encrypted, + "has_content": edge.content is not None + }) + + return data_edges + + except Exception as e: + logging.error(f"Error listing DATA edges for {resource_uid}: {e}", exc_info=True) + return [] + + +def get_resource_settings( + params: KeeperParams, + resource_uid: str, + dag_path: str, + config_uid: Optional[str] = None, + quiet_if_missing_vertex: bool = False, +) -> Optional[Dict[str, Any]]: + """ + Generic function to retrieve settings from a DAG DATA edge with the specified path for a resource. + + The settings are stored as encrypted JSON in a DATA edge on the resource vertex + with the specified path. This function: + 1. Loads the DAG for the resource + 2. Finds the DATA edge with the specified path + 3. Decrypts the content using the record key + 4. Parses the JSON to return the settings structure + + Args: + params: KeeperParams instance + resource_uid: UID of the PAM resource (pamMachine, pamDatabase, pamDirectory, pamRemoteBrowser) + dag_path: Path of the DATA edge (e.g., 'ai_settings', 'jit_settings') + config_uid: Optional PAM config UID. If not provided, will be looked up. + quiet_if_missing_vertex: Log at debug instead of warning when the resource + vertex is absent (expected before first link to a PAM Configuration). + + Returns: + Dictionary containing settings if found, None otherwise. + """ + try: + # Get the record to access the record key + record = vault.KeeperRecord.load(params, resource_uid) + if not record: + logging.warning(f"Record {resource_uid} not found") + return None + + # Get record key for decryption + record_key = None + if resource_uid in params.record_cache: + record_data = params.record_cache[resource_uid] + record_key = record_data.get('record_key_unencrypted') + + if not record_key: + logging.warning(f"Record key not available for {resource_uid}") + return None + + # Get config UID if not provided + if not config_uid: + encrypted_session_token, encrypted_transmission_key, transmission_key = get_keeper_tokens(params) + config_uid = get_config_uid(params, encrypted_session_token, encrypted_transmission_key, resource_uid) + if not config_uid: + config_uid = resource_uid + + # Create DAG connection + encrypted_session_token, encrypted_transmission_key, transmission_key = get_keeper_tokens(params) + + # Create a dummy record for DAG (uses config UID as record UID) + dag_record = PasswordRecord() + dag_record.record_uid = config_uid + dag_record.record_key = record_key # Use record key to decrypt keychain + + conn = Connection( + vault=params.vault, + encrypted_transmission_key=encrypted_transmission_key, + encrypted_session_token=encrypted_session_token, + transmission_key=transmission_key, + use_read_protobuf=False, + use_write_protobuf=False + ) + + # Load the DAG + linking_dag = DAG( + conn=conn, + record=dag_record, + graph_id=PamGraphId.PAM.value + ) + try: + linking_dag.load() + except DAGPathException as e: + # The graph has duplicate/ambiguous vertices for this path. Treat it + # as "no settings available" rather than aborting the caller (e.g. + # pam launch); there is nothing we can read for this resource. + logging.debug(f"Skipping {dag_path} for {resource_uid}: ambiguous DAG path ({e})") + return None + + # Get the resource vertex + resource_vertex = linking_dag.get_vertex(resource_uid) + if not resource_vertex: + log = logging.debug if quiet_if_missing_vertex else logging.warning + log(f"Resource vertex {resource_uid} not found in DAG") + return None + + # Highest-version edge for this path; DELETION means "no settings" (vault GSE_DELETION). + settings_edge = _find_highest_path_edge(resource_vertex, resource_uid, dag_path) + if settings_edge is None or settings_edge.edge_type == EdgeType.DELETION: + logging.debug(f"No active '{dag_path}' DATA edge for resource {resource_uid}") + return None + if settings_edge.edge_type != EdgeType.DATA: + logging.debug(f"Latest '{dag_path}' edge is not DATA for resource {resource_uid}") + return None + + # Get the content from the edge + edge_content = settings_edge.content + if not edge_content: + logging.debug(f"'{dag_path}' edge has no content for resource {resource_uid}") + return None + + # Check if content appears to be encrypted (binary data that's not valid UTF-8) + # Even if is_encrypted is False, inactive edges might not have been decrypted + is_encrypted_content = settings_edge.is_encrypted + if not is_encrypted_content and isinstance(edge_content, bytes): + # Try to detect if it's encrypted by attempting to decode + # Encrypted content will fail UTF-8 decoding + try: + test_decode = edge_content.decode('utf-8') + # If decode succeeds, check if it looks like JSON (starts with { or [) + if not (test_decode.strip().startswith('{') or test_decode.strip().startswith('[')): + # Doesn't look like JSON, might be encrypted + is_encrypted_content = True + except (UnicodeDecodeError, AttributeError): + # Decode failed - likely encrypted binary data + is_encrypted_content = True + + # Check if edge is still encrypted + if is_encrypted_content: + # Content is encrypted - need to decrypt manually using record key + if not isinstance(edge_content, (bytes, str)): + logging.warning(f"Unexpected encrypted content type: {type(edge_content)}") + return None + + # Convert to bytes if it's a string (base64 encoded) + if isinstance(edge_content, str): + try: + edge_content = base64.urlsafe_b64decode(edge_content + '==') + except Exception as e: + logging.warning(f"Failed to decode base64 content: {e}") + return None + + # Decrypt using AES-GCM + try: + if len(edge_content) < 12: + logging.warning(f"Encrypted content too short: {len(edge_content)} bytes") + return None + + aesgcm = AESGCM(record_key) + nonce = edge_content[:12] + ciphertext = edge_content[12:] + decrypted_bytes = aesgcm.decrypt(nonce, ciphertext, None) + except Exception as e: + logging.warning(f"Failed to decrypt {dag_path} content: {e}") + return None + + # Parse JSON + try: + settings = json.loads(decrypted_bytes.decode('utf-8')) + return settings + except Exception as e: + logging.warning(f"Failed to parse {dag_path} JSON: {e}") + return None + else: + # Content is already decrypted by DAG + if isinstance(edge_content, dict): + return edge_content + + if isinstance(edge_content, str): + try: + return json.loads(edge_content) + except Exception as e: + logging.warning(f"Failed to parse already-decrypted content: {e}") + return None + + if isinstance(edge_content, bytes): + # Try to decode as UTF-8 JSON + try: + decoded_str = edge_content.decode('utf-8') + return json.loads(decoded_str) + except UnicodeDecodeError: + # If UTF-8 decode fails, it might still be encrypted + # Try decrypting it + try: + if len(edge_content) >= 12: + aesgcm = AESGCM(record_key) + nonce = edge_content[:12] + ciphertext = edge_content[12:] + decrypted_bytes = aesgcm.decrypt(nonce, ciphertext, None) + return json.loads(decrypted_bytes.decode('utf-8')) + except Exception as decrypt_error: + logging.warning(f"Content appears encrypted but decryption failed: {decrypt_error}") + return None + except Exception as e: + logging.warning(f"Failed to decode bytes content: {e}") + return None + + logging.warning(f"Unexpected decrypted content type: {type(edge_content)}") + return None + + except Exception as e: + logging.error(f"Error retrieving {dag_path} settings for {resource_uid}: {e}", exc_info=True) + return None + + +def get_resource_jit_settings( + params: KeeperParams, + resource_uid: str, + config_uid: Optional[str] = None +) -> Optional[Dict[str, Any]]: + """ + Retrieve JIT settings from the DAG DATA edge with path 'jit_settings' for a resource. + + This function checks if JIT settings are stored in a DAG DATA edge similar to AI settings. + The settings are expected to be stored as encrypted JSON in a DATA edge on the resource vertex + with path 'jit_settings'. + + Args: + params: KeeperParams instance + resource_uid: UID of the PAM resource (pamMachine, pamDatabase, pamDirectory, pamRemoteBrowser) + config_uid: Optional PAM config UID. If not provided, will be looked up. + + Returns: + Dictionary containing JIT settings if found, None otherwise. + """ + return get_resource_settings(params, resource_uid, 'jit_settings', config_uid) + + +def get_resource_keeper_ai_settings( + params: KeeperParams, + resource_uid: str, + config_uid: Optional[str] = None, + quiet_if_missing_vertex: bool = False, +) -> Optional[Dict[str, Any]]: + """ + Retrieve KeeperAI settings from the DAG DATA edge with path 'ai_settings' for a resource. + + The settings are stored as encrypted JSON in a DATA edge on the resource vertex + with path 'ai_settings'. This function: + 1. Loads the DAG for the resource + 2. Finds the DATA edge with path 'ai_settings' + 3. Decrypts the content using the record key + 4. Parses the JSON to return the KeeperAISettings structure + + Args: + params: KeeperParams instance + resource_uid: UID of the PAM resource (pamMachine, pamDatabase, pamDirectory, pamRemoteBrowser) + config_uid: Optional PAM config UID. If not provided, will be looked up. + + Returns: + Dictionary containing KeeperAI settings with structure: + { + "version": "v1.0.0", + "riskLevels": { + "critical": { + "aiSessionTerminate": bool, + "tags": { + "allow": [...], + "deny": [...] + } + }, + "high": {...}, + "medium": {...}, + "low": { + "aiSessionTerminate": bool, + "tags": { + "allow": [...] + } + } + } + } + Returns None if settings not found or error occurred. + """ + return get_resource_settings( + params, resource_uid, 'ai_settings', config_uid, + quiet_if_missing_vertex=quiet_if_missing_vertex, + ) + + +def set_resource_keeper_ai_settings( + params: KeeperParams, + resource_uid: str, + settings: Dict[str, Any], + config_uid: Optional[str] = None +) -> bool: + """ + Save KeeperAI settings on a PAM resource. + + Primary path: POSTs the encrypted settings to krouter's + `/api/user/configure_resource` (Layer-B, permission-checked). krouter + validates caller access then writes the `ai_settings` DAG DATA edge on the + resource server-side. + + Fallback (env var `KEEPER_DAG_LB_FALLBACK`, default OFF / strict mode): on + `RRC_NOT_ALLOWED*` from krouter, fall back to the legacy direct + DAG-write path (`_set_resource_keeper_ai_settings_legacy`). Gateway then + enforces at runtime. Default (unset/`0`) propagates denials; set to `1` to opt + into fallback. + + Args: + params: KeeperParams instance + resource_uid: UID of the PAM resource (pamMachine, pamDatabase, pamDirectory, pamRemoteBrowser) + settings: Dictionary containing KeeperAI settings to save + config_uid: Optional PAM config UID. If not provided, will be looked up. + + Returns: + True if settings were saved successfully, False otherwise. + """ + # Common setup — needed by both the new and legacy paths. + common = _resolve_resource_settings_inputs(params, resource_uid, settings, config_uid) + if common is None: + return False + record_key, resolved_config_uid = common + + if not settings: + logging.debug(f"KeeperAI settings empty for {resource_uid}, skipping save") + return False + + encrypted_content = encrypt_aes(json.dumps(settings).encode(), record_key) + + # krouter's configure_resource only writes a settings edge when it loads the + # resource's existing edges (loopEdges), which it does only for requests that + # carry meta/jit/connection (UserRest.kt). A keeperAiSettings-only request + # leaves loopEdges null and the ai_settings write is silently dropped. The Web + # Vault avoids this by always sending meta alongside the AI settings, so mirror + # that: include the resource's current meta in the same request. When the + # resource is not in the DAG yet (first link), bootstrap v1 meta instead. + from ..tunnel.port_forward.TunnelGraph import build_resource_meta_v1 + + current_meta = get_resource_settings( + params, resource_uid, 'meta', resolved_config_uid, quiet_if_missing_vertex=True) + if not isinstance(current_meta, dict): + current_meta = build_resource_meta_v1({}, False) + meta_bytes = json.dumps(current_meta).encode() + + # Primary: Layer-B configure_resource (permission-checked). + from ...helpers.router_utils import router_configure_resource, get_router_url + host = get_router_url(params) + endpoint = 'configure_resource' + if not is_layer_b_feature_disabled(host, endpoint): + rq = pam_pb2.PAMResourceConfig( + recordUid=url_safe_str_to_bytes(resource_uid), + networkUid=url_safe_str_to_bytes(resolved_config_uid), + keeperAiSettings=encrypted_content, + meta=meta_bytes, + ) + try: + router_configure_resource(params, rq) + logging.debug(f"Saved KeeperAI settings via configure_resource for {resource_uid}") + return True + except Exception as err: + if not should_fallback_on_layer_b_error(err, host=host, endpoint=endpoint): + logging.error(f"configure_resource failed (no fallback): {err}", exc_info=True) + return False + logging.warning( + f"configure_resource denied/unavailable for {resource_uid}; falling back to legacy " + f"DAG-write (KEEPER_DAG_LB_FALLBACK enabled): {err}" + ) + + # Fallback: legacy direct DAG-write path. + return _set_resource_keeper_ai_settings_legacy( + params, resource_uid, settings, resolved_config_uid, record_key, encrypted_content + ) + + +def _resolve_resource_settings_inputs( + params: KeeperParams, + resource_uid: str, + settings: Dict[str, Any], + config_uid: Optional[str], +): + """Validate inputs and resolve record_key + config_uid. Returns (record_key, config_uid) or None on failure. + + Shared by the new and legacy paths so behavior on bad input (missing record, + bad settings dict, etc.) stays identical. + """ + record = vault.KeeperRecord.load(params, resource_uid) + if not record: + logging.warning(f"Record {resource_uid} not found") + return None + + record_key = None + if resource_uid in params.record_cache: + record_data = params.record_cache[resource_uid] + record_key = record_data.get('record_key_unencrypted') + if not record_key: + logging.warning(f"Record key not available for {resource_uid}") + return None + + if not isinstance(settings, dict): + logging.warning("Settings must be a dictionary") + return None + + if not config_uid: + encrypted_session_token, encrypted_transmission_key, _ = get_keeper_tokens(params) + config_uid = get_config_uid(params, encrypted_session_token, encrypted_transmission_key, resource_uid) + if not config_uid: + config_uid = resource_uid + + return record_key, config_uid + + +def _set_resource_keeper_ai_settings_legacy( + params: KeeperParams, + resource_uid: str, + settings: Dict[str, Any], + config_uid: str, + record_key: bytes, + encrypted_content: bytes, +) -> bool: + """Legacy direct DAG-write path. Used as fallback when configure_resource is denied. + + Loads the resource's DAG, deactivates any prior `ai_settings` edge, writes a new + one with the pre-encrypted content, and saves. Pre-Phase-2 behavior, preserved verbatim. + """ + try: + encrypted_session_token, encrypted_transmission_key, transmission_key = get_keeper_tokens(params) + + dag_record = PasswordRecord() + dag_record.record_uid = config_uid + dag_record.record_key = record_key + + conn = Connection( + vault=params.vault, + encrypted_transmission_key=encrypted_transmission_key, + encrypted_session_token=encrypted_session_token, + transmission_key=transmission_key, + use_read_protobuf=False, + use_write_protobuf=False, + ) + + linking_dag = DAG( + conn=conn, + record=dag_record, + graph_id=PamGraphId.PAM.value, + decrypt=True, + ) + linking_dag.load() + + resource_vertex = linking_dag.get_vertex(resource_uid) + if not resource_vertex: + logging.warning(f"Resource vertex {resource_uid} not found in DAG") + return False + + # Find and deactivate existing 'ai_settings' edge for proper versioning + for edge in resource_vertex.edges: + if edge and (edge.edge_type == EdgeType.DATA and + edge.path == 'ai_settings' and + edge.active): + edge.active = False + logging.debug(f"Deactivated existing 'ai_settings' edge (version {edge.version})") + break + + resource_vertex.add_data( + content=encrypted_content, + path='ai_settings', + needs_encryption=False, + is_encrypted=True, + modified=True, + ) + linking_dag.save() + + logging.debug(f"Saved KeeperAI settings via legacy DAG-write for {resource_uid}") + return True + except Exception as e: + logging.error(f"Error saving KeeperAI settings (legacy path) for {resource_uid}: {e}", exc_info=True) + return False + + +def _delete_resource_data_edge_legacy( + params: KeeperParams, + resource_uid: str, + config_uid: str, + record_key: bytes, + dag_path: str, +) -> Optional[bool]: + """Delete a path-scoped self-loop DATA edge via GSE_DELETION (vault ``createDeletionEvent``). + + Returns: + True if a DELETION edge was written, + None if the path was already absent, + False on error. + """ + try: + encrypted_session_token, encrypted_transmission_key, transmission_key = get_keeper_tokens(params) + + dag_record = PasswordRecord() + dag_record.record_uid = config_uid + dag_record.record_key = record_key + + conn = Connection( + vault=params.vault, + encrypted_transmission_key=encrypted_transmission_key, + encrypted_session_token=encrypted_session_token, + transmission_key=transmission_key, + use_read_protobuf=False, + use_write_protobuf=False, + ) + + linking_dag = DAG( + conn=conn, + record=dag_record, + graph_id=PamGraphId.PAM.value, + decrypt=True, + ) + linking_dag.load() + + resource_vertex = linking_dag.get_vertex(resource_uid) + if not resource_vertex: + logging.debug(f"No resource vertex {resource_uid} in DAG; '{dag_path}' already absent") + return None + + highest = _find_highest_path_edge(resource_vertex, resource_uid, dag_path) + if highest is None or highest.edge_type == EdgeType.DELETION: + logging.debug(f"'{dag_path}' already deleted for resource {resource_uid}") + return None + + for edge in resource_vertex.edges or []: + if (edge and edge.edge_type == EdgeType.DATA and edge.path == dag_path + and edge.head_uid == resource_uid and edge.active): + edge.active = False + + resource_vertex.belongs_to( + resource_vertex, + EdgeType.DELETION, + path=dag_path, + ) + linking_dag.save() + + logging.debug(f"Deleted '{dag_path}' via DELETION edge for resource {resource_uid}") + return True + except Exception as e: + logging.error(f"Error deleting '{dag_path}' for {resource_uid}: {e}", exc_info=True) + return False + + +def set_resource_jit_settings( + params: KeeperParams, + resource_uid: str, + settings: Dict[str, Any], + config_uid: Optional[str] = None, + allow_empty: bool = False +) -> bool: + """ + Save JIT settings on a PAM resource. + + Primary path: POSTs the encrypted settings to krouter's + `/api/user/configure_resource` (Layer-B, permission-checked). Fallback + behavior matches `set_resource_keeper_ai_settings` — see its docstring + and `KEEPER_DAG_LB_FALLBACK`. Same shape, `jit_settings` instead of + `ai_settings`. + """ + # Empty-settings short-circuit retains the legacy semantics. + if not isinstance(settings, dict): + logging.debug(f"JIT settings invalid for {resource_uid}, skipping") + return False + if not settings and not allow_empty: + logging.debug(f"JIT settings empty for {resource_uid}, skipping") + return False + + common = _resolve_resource_settings_inputs(params, resource_uid, settings, config_uid) + if common is None: + return False + record_key, resolved_config_uid = common + + encrypted_content = encrypt_aes(json.dumps(settings).encode(), record_key) + + # Primary: Layer-B configure_resource (permission-checked). + from ...helpers.router_utils import router_configure_resource, get_router_url + host = get_router_url(params) + endpoint = 'configure_resource' + if not is_layer_b_feature_disabled(host, endpoint): + rq = pam_pb2.PAMResourceConfig( + recordUid=url_safe_str_to_bytes(resource_uid), + networkUid=url_safe_str_to_bytes(resolved_config_uid), + jitSettings=encrypted_content, + ) + try: + router_configure_resource(params, rq) + logging.debug(f"Saved JIT settings via configure_resource for {resource_uid}") + return True + except Exception as err: + if not should_fallback_on_layer_b_error(err, host=host, endpoint=endpoint): + logging.error(f"configure_resource failed (no fallback): {err}", exc_info=True) + return False + logging.warning( + f"configure_resource denied/unavailable for {resource_uid}; falling back to legacy " + f"DAG-write (KEEPER_DAG_LB_FALLBACK enabled): {err}" + ) + + return _set_resource_jit_settings_legacy( + params, resource_uid, resolved_config_uid, record_key, encrypted_content + ) + + +def _set_resource_jit_settings_legacy( + params: KeeperParams, + resource_uid: str, + config_uid: str, + record_key: bytes, + encrypted_content: bytes, +) -> bool: + """Legacy direct DAG-write path for JIT settings. See AI-settings analog for shape.""" + try: + encrypted_session_token, encrypted_transmission_key, transmission_key = get_keeper_tokens(params) + + dag_record = PasswordRecord() + dag_record.record_uid = config_uid + dag_record.record_key = record_key + + conn = Connection( + vault=params.vault, + encrypted_transmission_key=encrypted_transmission_key, + encrypted_session_token=encrypted_session_token, + transmission_key=transmission_key, + use_read_protobuf=False, + use_write_protobuf=False, + ) + + linking_dag = DAG( + conn=conn, + record=dag_record, + graph_id=PamGraphId.PAM.value, + decrypt=True, + ) + linking_dag.load() + + resource_vertex = linking_dag.get_vertex(resource_uid) + if not resource_vertex: + logging.warning(f"Resource vertex {resource_uid} not found in DAG") + return False + + for edge in resource_vertex.edges: + if edge and (edge.edge_type == EdgeType.DATA and + edge.path == 'jit_settings' and + edge.active): + edge.active = False + logging.debug(f"Deactivated existing 'jit_settings' edge (version {edge.version})") + break + + resource_vertex.add_data( + content=encrypted_content, + path='jit_settings', + needs_encryption=False, + is_encrypted=True, + modified=True, + ) + linking_dag.save() + + logging.debug(f"Saved JIT settings via legacy DAG-write for {resource_uid}") + return True + except Exception as e: + logging.error(f"Error saving JIT settings (legacy path) for {resource_uid}: {e}", exc_info=True) + return False + + +def refresh_meta_to_latest( + params: KeeperParams, + resource_uid: str, + config_uid: Optional[str] = None +) -> bool: + """ + Re-add the meta DATA edge with the same content so meta becomes the latest + (highest version) and appears on top. Call after writing jit_settings and/or + ai_settings. + """ + try: + record = vault.KeeperRecord.load(params, resource_uid) + if not record: + return False + record_key = None + if resource_uid in params.record_cache: + record_data = params.record_cache[resource_uid] + record_key = record_data.get('record_key_unencrypted') + if not record_key: + return False + if not config_uid: + encrypted_session_token, encrypted_transmission_key, transmission_key = get_keeper_tokens(params) + config_uid = get_config_uid(params, encrypted_session_token, encrypted_transmission_key, resource_uid) + if not config_uid: + config_uid = resource_uid + encrypted_session_token, encrypted_transmission_key, transmission_key = get_keeper_tokens(params) + dag_record = PasswordRecord() + dag_record.record_uid = config_uid + dag_record.record_key = record_key + conn = Connection( + vault=params.vault, + encrypted_transmission_key=encrypted_transmission_key, + encrypted_session_token=encrypted_session_token, + transmission_key=transmission_key, + use_read_protobuf=False, + use_write_protobuf=False + ) + linking_dag = DAG( + conn=conn, + record=dag_record, + graph_id=PamGraphId.PAM.value, + decrypt=True + ) + linking_dag.load() + resource_vertex = linking_dag.get_vertex(resource_uid) + if not resource_vertex: + return False + meta_edges = [e for e in (resource_vertex.edges or []) + if e and getattr(e, 'edge_type', None) == EdgeType.DATA + and getattr(e, 'path', None) == 'meta'] + if not meta_edges: + return False + best = max(meta_edges, key=lambda e: getattr(e, 'version', -1)) + try: + content = best.content_as_dict + except Exception: + return False + if content is None: + return False + resource_vertex.add_data(content=content, path='meta', needs_encryption=False) + linking_dag.save() + return True + except Exception as e: + logging.debug(f"refresh_meta_to_latest for {resource_uid}: {e}") + return False + + +def refresh_link_to_config_to_latest( + params: KeeperParams, + resource_uid: str, + config_uid: Optional[str] = None +) -> bool: + """ + Dummy update to the LINK edge (resource -> PAM config) so LINK is the latest + edge to config, not KEY. JIT/AI add a KEY edge for encryption; in a normal + record the visible link to PAM config is LINK with path empty and content {}. + Call after writing jit_settings and/or ai_settings. + """ + try: + record = vault.KeeperRecord.load(params, resource_uid) + if not record: + return False + record_key = None + if resource_uid in params.record_cache: + record_data = params.record_cache[resource_uid] + record_key = record_data.get('record_key_unencrypted') + if not record_key: + return False + if not config_uid: + encrypted_session_token, encrypted_transmission_key, transmission_key = get_keeper_tokens(params) + config_uid = get_config_uid(params, encrypted_session_token, encrypted_transmission_key, resource_uid) + if not config_uid: + config_uid = resource_uid + encrypted_session_token, encrypted_transmission_key, transmission_key = get_keeper_tokens(params) + dag_record = PasswordRecord() + dag_record.record_uid = config_uid + dag_record.record_key = record_key + conn = Connection( + vault=params.vault, + encrypted_transmission_key=encrypted_transmission_key, + encrypted_session_token=encrypted_session_token, + transmission_key=transmission_key, + use_read_protobuf=False, + use_write_protobuf=False + ) + linking_dag = DAG( + conn=conn, + record=dag_record, + graph_id=PamGraphId.PAM.value, + decrypt=True + ) + linking_dag.load() + resource_vertex = linking_dag.get_vertex(resource_uid) + config_vertex = linking_dag.get_vertex(config_uid) + if not resource_vertex or not config_vertex: + return False + # Re-add LINK (path empty, content {}) so it becomes latest, above KEY added by JIT/AI + resource_vertex.belongs_to(config_vertex, EdgeType.LINK, path=None, content={}) + linking_dag.save() + return True + except Exception as e: + logging.debug(f"refresh_link_to_config_to_latest for {resource_uid}: {e}") + return False + + +def _get_audit_user_id(params: KeeperParams) -> str: + if getattr(params, 'account_uid_bytes', None): + return utils.base64_url_encode(params.account_uid_bytes) + return getattr(params, 'user', '') or '' + + +def _make_tag_entry(tag: str, action: str, user_id: str) -> Dict[str, Any]: + return { + 'tag': tag, + 'auditLog': [{ + 'date': utils.current_milli_time(), + 'userId': user_id, + 'action': action, + }], + } + + +def _parse_tag_name(tag_item: Any) -> str: + if isinstance(tag_item, dict): + return str(tag_item.get('tag', '')).strip() + return str(tag_item).strip() + + +def _get_tag_list(level_data: Dict[str, Any], list_name: str) -> List[Dict[str, Any]]: + tags = level_data.setdefault('tags', {}) + entries = tags.get(list_name) + if not isinstance(entries, list): + entries = [] + tags[list_name] = entries + return entries + + +def parse_ai_setting_spec(spec: str) -> tuple: + """ + Parse CLI spec ``LEVEL``, ``LEVEL.SETTING``, or ``LEVEL.SETTING=VALUE``. + + Returns (level, setting_or_none, value_or_none). ``value_or_none`` is None when + ``=`` is absent (unset-without-value forms). + """ + if not spec or not str(spec).strip(): + raise ValueError('empty AI setting spec') + + text = str(spec).strip() + level_part, sep, value_part = text.partition('=') + if sep and value_part == '': + raise ValueError( + f'invalid AI setting spec (missing value): {spec}. ' + f'To remove a setting, use --unset|-u (e.g. -u high.terminate or -u high.allow=chmod).' + ) + + if '.' in level_part: + level_name, setting_name = level_part.split('.', 1) + else: + level_name, setting_name = level_part, None + + level_name = level_name.strip().lower() + if level_name not in AI_RISK_LEVELS: + raise ValueError(f'invalid risk level "{level_name}" (expected: {", ".join(AI_RISK_LEVELS)})') + + if setting_name is not None: + setting_name = setting_name.strip().lower() + if setting_name not in ('terminate', 'allow', 'deny'): + raise ValueError(f'invalid setting "{setting_name}" (expected: terminate, allow, deny)') + if level_name == 'low' and setting_name == 'deny': + raise ValueError('deny is not supported for the low risk level') + + value = value_part if sep else None + return level_name, setting_name, value + + +def dedupe_ai_cli_option_specs( + specs: Optional[List[str]], + option_label: str, +) -> tuple: + """Return unique specs in first-seen order and warnings for duplicate CLI options. + + ``option_label`` is shown in warnings (e.g. ``--set/-s``). + """ + if not specs: + return [], [] + + order: List[str] = [] + counts: Dict[str, int] = {} + for spec in specs: + counts[spec] = counts.get(spec, 0) + 1 + if spec not in order: + order.append(spec) + + warnings = [ + f'duplicate {option_label} ignored: {spec} ({counts[spec]}x)' + for spec in order + if counts[spec] > 1 + ] + return order, warnings + + +def _is_full_ai_setting_spec(spec: str) -> bool: + """True when spec is ``LEVEL.SETTING=VALUE`` (value present after ``=``).""" + _, setting, value = parse_ai_setting_spec(spec) + return setting is not None and value is not None + + +def _reconcile_set_unset_specs( + unset_specs: Optional[List[str]], + set_specs: Optional[List[str]], +) -> tuple: + """Drop ``--unset`` specs mirrored by a ``--set`` on the same ``LEVEL.SETTING=VALUE``. + + Only applies when both sides use the full ``level.setting=value`` form. Partial + unsets (e.g. ``-u high.allow`` or ``-u high.terminate``) are not reconciled. + """ + unset_list = list(unset_specs or []) + set_list = list(set_specs or []) + warnings: List[str] = [] + if not unset_list or not set_list: + return unset_list, set_list, warnings + + parsed_sets = [parse_ai_setting_spec(spec) for spec in set_list] + drop_unset_specs = set() + + for u_spec in unset_list: + if not _is_full_ai_setting_spec(u_spec): + continue + u_level, u_setting, _u_value = parse_ai_setting_spec(u_spec) + + for set_spec, (s_level, s_setting, _s_value) in zip(set_list, parsed_sets): + if u_level != s_level or u_setting != s_setting: + continue + if not _is_full_ai_setting_spec(set_spec): + continue + drop_unset_specs.add(u_spec) + if u_spec == set_spec: + warnings.append(f'--set/-s overrides --unset/-u: {u_spec}') + else: + warnings.append( + f'--set/-s overrides --unset/-u: {u_spec} (mirrors -s {set_spec})' + ) + break + + return [spec for spec in unset_list if spec not in drop_unset_specs], set_list, warnings + + +_LOW_TERMINATE_WARNING = 'risk level low.terminate always defaults to false.' + + +def _parse_terminate_value(value: str) -> bool: + normalized = str(value).strip().casefold() + if normalized == 'true': + return True + if normalized == 'false': + return False + raise ValueError(f'invalid terminate value "{value}" (expected true or false)') + + +def _existing_terminate_value(risk_levels: Dict[str, Any], level: str) -> Optional[bool]: + level_data = risk_levels.get(level) + if not isinstance(level_data, dict): + return None + value = level_data.get('aiSessionTerminate') + if value is None: + return None + return bool(value) + + +def _ensure_ai_settings_dict(existing: Optional[Dict[str, Any]]) -> Dict[str, Any]: + settings = dict(existing) if isinstance(existing, dict) else {} + settings['version'] = settings.get('version') or AI_SETTINGS_VERSION + risk_levels = settings.get('riskLevels') + if not isinstance(risk_levels, dict): + risk_levels = {} + settings['riskLevels'] = risk_levels + return settings + + +def _ensure_level_dict(risk_levels: Dict[str, Any], level: str) -> Dict[str, Any]: + level_data = risk_levels.get(level) + if not isinstance(level_data, dict): + level_data = {} + risk_levels[level] = level_data + return level_data + + +def _prune_level(level_data: Dict[str, Any]) -> bool: + tags = level_data.get('tags') + if isinstance(tags, dict): + for key in list(tags.keys()): + entries = tags.get(key) + if not entries: + tags.pop(key, None) + if not tags: + level_data.pop('tags', None) + return not level_data + + +def _level_is_only_default_low(level_data: Dict[str, Any]) -> bool: + if not isinstance(level_data, dict): + return True + tags = level_data.get('tags') + has_tags = isinstance(tags, dict) and any(tags.get(k) for k in tags) + if has_tags: + return False + terminate = level_data.get('aiSessionTerminate') + return terminate is None or terminate is False + + +def _normalize_ai_settings_for_save(settings: Dict[str, Any]) -> Dict[str, Any]: + risk_levels = settings.get('riskLevels') + if not isinstance(risk_levels, dict): + return {} + + for level in list(risk_levels.keys()): + if level not in AI_RISK_LEVELS: + risk_levels.pop(level, None) + continue + if _prune_level(risk_levels[level]): + risk_levels.pop(level, None) + + low_data = risk_levels.get('low') + if isinstance(low_data, dict): + low_data['aiSessionTerminate'] = False + tags = low_data.get('tags') + if isinstance(tags, dict): + tags.pop('deny', None) + if not tags: + low_data.pop('tags', None) + if _prune_level(low_data) or _level_is_only_default_low(low_data): + risk_levels.pop('low', None) + + if not risk_levels: + return {} + + return { + 'version': settings.get('version', AI_SETTINGS_VERSION), + 'riskLevels': risk_levels, + } + + +def apply_ai_setting_changes( + existing: Optional[Dict[str, Any]], + set_specs: Optional[List[str]], + unset_specs: Optional[List[str]], + params: KeeperParams, +) -> tuple: + """Merge CLI --set/--unset operations into KeeperAI DAG settings. + + Returns ``(settings_dict, warnings)`` where ``warnings`` is a list of user-facing + messages (e.g. silent conversions applied during save). + """ + settings = _ensure_ai_settings_dict(existing) + risk_levels = settings['riskLevels'] + user_id = _get_audit_user_id(params) + warnings: List[str] = [] + + unset_specs, set_specs, reconcile_warnings = _reconcile_set_unset_specs(unset_specs, set_specs) + warnings.extend(reconcile_warnings) + + for spec in unset_specs or []: + level, setting, value = parse_ai_setting_spec(spec) + if setting is None: + risk_levels.pop(level, None) + continue + + level_data = risk_levels.get(level) + if not isinstance(level_data, dict): + continue + + if setting == 'terminate': + level_data.pop('aiSessionTerminate', None) + else: + tags = level_data.get('tags') + if not isinstance(tags, dict): + continue + entries = tags.get(setting) + if not isinstance(entries, list): + continue + if value is None: + tags.pop(setting, None) + else: + tags[setting] = [e for e in entries if _parse_tag_name(e) != value] + if not tags.get(setting): + tags.pop(setting, None) + if not tags: + level_data.pop('tags', None) + + if _prune_level(level_data): + risk_levels.pop(level, None) + + for spec in set_specs or []: + level, setting, value = parse_ai_setting_spec(spec) + if setting is None: + raise ValueError(f'--set requires LEVEL.SETTING=VALUE (got "{spec}")') + if value is None: + raise ValueError(f'--set requires a value (got "{spec}")') + + level_data = _ensure_level_dict(risk_levels, level) + if setting == 'terminate': + terminate_value = _parse_terminate_value(value) + effective_value = False if level == 'low' else terminate_value + if level == 'low' and terminate_value: + if _LOW_TERMINATE_WARNING not in warnings: + warnings.append(_LOW_TERMINATE_WARNING) + if _existing_terminate_value(risk_levels, level) == effective_value: + continue + level_data['aiSessionTerminate'] = effective_value + continue + + tag_value = value.strip() + if not tag_value: + raise ValueError(f'--set tag value cannot be empty (got "{spec}")') + + entries = _get_tag_list(level_data, setting) + if not any(_parse_tag_name(e) == tag_value for e in entries): + action = 'added_to_allow' if setting == 'allow' else 'added_to_deny' + entries.append(_make_tag_entry(tag_value, action, user_id)) + + return _normalize_ai_settings_for_save(settings), warnings + + +def remove_resource_keeper_ai_settings( + params: KeeperParams, + resource_uid: str, + config_uid: Optional[str] = None +) -> Optional[bool]: + """Remove the ``ai_settings`` DATA edge (GSE_DELETION), restoring pre-AI DAG state. + + Web Vault treats a missing ``ai_settings`` edge as ``emptyKeeperAISettings`` in + memory. Writing ``{}`` or the empty template leaves a DATA edge and can break WV; + ``configure_resource`` does not clear ``keeperAiSettings`` when omitted — use graph-sync + DELETION like ``DagOperations.createDeletionEvent`` + ``dagPamLinkAddData``. + + Returns: + True if a DELETION edge was written, + None if ``ai_settings`` was already absent, + False on error. + """ + common = _resolve_resource_settings_inputs(params, resource_uid, {}, config_uid) + if common is None: + return False + record_key, resolved_config_uid = common + + return _delete_resource_data_edge_legacy( + params, resource_uid, resolved_config_uid, record_key, 'ai_settings') + + +def print_keeper_ai_settings(params: KeeperParams, resource_uid: str, config_uid: Optional[str] = None): + """ + Print KeeperAI settings in a human-readable format. + + Args: + params: KeeperParams instance + resource_uid: UID of the PAM resource + config_uid: Optional PAM config UID + """ + settings = get_resource_keeper_ai_settings(params, resource_uid, config_uid) + + if is_default_keeper_ai_settings(settings): + print(f"{bcolors.WARNING}No KeeperAI settings found for resource {resource_uid}{bcolors.ENDC}") + return + + print(f"\n{bcolors.OKBLUE}KeeperAI Settings for Resource: {resource_uid}{bcolors.ENDC}") + print(f"Version: {settings.get('version', 'unknown')}") + print(f"\n{bcolors.OKGREEN}Risk Level Configurations:{bcolors.ENDC}") + + risk_levels = settings.get('riskLevels', {}) + risk_level_order = ['critical', 'high', 'medium', 'low'] + + for level in risk_level_order: + level_data = risk_levels.get(level) + if not level_data: + continue + + terminate = level_data.get('aiSessionTerminate', False) + tags = level_data.get('tags', {}) + allow_tags = tags.get('allow', []) + deny_tags = tags.get('deny', []) if level != 'low' else [] + + level_color = { + 'critical': bcolors.FAIL, + 'high': bcolors.WARNING, + 'medium': bcolors.OKBLUE, + 'low': bcolors.OKGREEN + }.get(level, bcolors.ENDC) + + print(f"\n {level_color}{level.upper()}{bcolors.ENDC}:") + print(f" Terminate Session: {bcolors.OKGREEN if terminate else bcolors.WARNING}{terminate}{bcolors.ENDC}") + + if allow_tags: + print(f" Allow Tags ({len(allow_tags)}):") + for tag_item in allow_tags: + tag_name = tag_item.get('tag', '') if isinstance(tag_item, dict) else str(tag_item) + print(f" - {tag_name}") + + if deny_tags: + print(f" Deny Tags ({len(deny_tags)}):") + for tag_item in deny_tags: + tag_name = tag_item.get('tag', '') if isinstance(tag_item, dict) else str(tag_item) + print(f" - {tag_name}") + + print() + + +def inspect_resource_in_graph( + params: KeeperParams, + resource_uid: str, + config_uid: Optional[str] = None, + show_raw_content: bool = False +) -> Dict[str, Any]: + """ + Inspect all graph edges and vertices referencing a record UID. + Returns edges (tail->head), vertices (UIDs), and DATA edges grouped by path with all versions. + + Args: + params: KeeperParams instance + resource_uid: UID of the PAM resource + config_uid: Optional PAM config UID + show_raw_content: If True, load DAG with decrypt=False and include raw stored content + (encrypted bytes) in data_by_path. Use this to see what's actually stored without + auto-decrypt skewing the picture. + + Returns: + { + "edges": [{"type": str, "tail": str, "head": str, "path": str|None, "version": int, "active": bool}, ...], + "vertices": [uid, ...], + "data_by_path": {"path_name": [{"version": int, "active": bool, "has_content": bool, + "raw_content_len"?: int, "raw_content_preview"?: str}, ...], ...} + } + """ + result: Dict[str, Any] = {"edges": [], "vertices": [], "data_by_path": {}} + try: + vault.KeeperRecord.load(params, resource_uid) + record_key = params.record_cache.get(resource_uid, {}).get('record_key_unencrypted') + if not record_key: + logging.warning(f"Record key not available for {resource_uid}") + return result + + if not config_uid: + encrypted_session_token, encrypted_transmission_key, transmission_key = get_keeper_tokens(params) + config_uid = get_config_uid(params, encrypted_session_token, encrypted_transmission_key, resource_uid) + if not config_uid: + config_uid = resource_uid + + vault.KeeperRecord.load(params, config_uid) + config_record_key = params.record_cache.get(config_uid, {}).get('record_key_unencrypted') + if not config_record_key: + logging.warning(f"Config record key not available for {config_uid}") + return result + + encrypted_session_token, encrypted_transmission_key, transmission_key = get_keeper_tokens(params) + dag_record = PasswordRecord() + dag_record.record_uid = config_uid + dag_record.record_key = config_record_key + + conn = Connection( + vault=params.vault, + encrypted_transmission_key=encrypted_transmission_key, + encrypted_session_token=encrypted_session_token, + transmission_key=transmission_key, + use_read_protobuf=False, + use_write_protobuf=False + ) + linking_dag = DAG( + conn=conn, + record=dag_record, + graph_id=PamGraphId.PAM.value, + decrypt=not show_raw_content + ) + linking_dag.load() + + # 1) All edges referencing record_uid (tail==ruid or head_uid==ruid) + edge_records = [] + vertex_uids = {resource_uid} + + for vertex in linking_dag.all_vertices: + tail_uid = vertex.uid + for edge in (vertex.edges or []): + if not edge: + continue + head_uid = edge.head_uid + if tail_uid != resource_uid and head_uid != resource_uid: + continue + vertex_uids.add(tail_uid) + vertex_uids.add(head_uid) + edge_records.append({ + "type": edge.edge_type.value if hasattr(edge.edge_type, 'value') else str(edge.edge_type), + "tail": tail_uid, + "head": head_uid, + "path": edge.path, + "version": getattr(edge, 'version', 0), + "active": getattr(edge, 'active', True), + }) + if edge.edge_type == EdgeType.DATA: + path_key = edge.path or "(no path)" + if path_key not in result["data_by_path"]: + result["data_by_path"][path_key] = [] + entry = { + "version": getattr(edge, 'version', 0), + "active": getattr(edge, 'active', True), + "has_content": edge.content is not None, + } + if show_raw_content and edge.content is not None: + raw = edge.content + if isinstance(raw, bytes): + entry["raw_content_len"] = len(raw) + # First 64 bytes as hex for encrypted blob preview + entry["raw_content_preview"] = raw[:64].hex() + else: + s = str(raw) + entry["raw_content_len"] = len(s) + entry["raw_content_preview"] = s[:128] + ("..." if len(s) > 128 else "") + result["data_by_path"][path_key].append(entry) + + result["edges"] = edge_records + result["vertices"] = sorted(vertex_uids) + return result + + except Exception as e: + logging.error(f"Error inspecting graph for {resource_uid}: {e}", exc_info=True) + result["error"] = str(e) + return result + + +def get_resource_domain_dir_uid( + params: KeeperParams, + resource_uid: str, + config_uid: Optional[str] = None +) -> Optional[str]: + """ + Return the pamDirectory UID linked to the resource via a LINK edge with path 'domain'. + Returns None if no such link exists. + """ + try: + record = vault.KeeperRecord.load(params, resource_uid) + if not record: + return None + + record_key = None + if resource_uid in params.record_cache: + record_key = params.record_cache[resource_uid].get('record_key_unencrypted') + if not record_key: + return None + + if not config_uid: + encrypted_session_token, encrypted_transmission_key, transmission_key = get_keeper_tokens(params) + config_uid = get_config_uid(params, encrypted_session_token, encrypted_transmission_key, resource_uid) + if not config_uid: + config_uid = resource_uid + + encrypted_session_token, encrypted_transmission_key, transmission_key = get_keeper_tokens(params) + dag_record = PasswordRecord() + dag_record.record_uid = config_uid + dag_record.record_key = record_key + + conn = Connection( + vault=params.vault, + encrypted_transmission_key=encrypted_transmission_key, + encrypted_session_token=encrypted_session_token, + transmission_key=transmission_key, + use_read_protobuf=False, + use_write_protobuf=False + ) + linking_dag = DAG( + conn=conn, + record=dag_record, + graph_id=PamGraphId.PAM.value + ) + linking_dag.load() + + resource_vertex = linking_dag.get_vertex(resource_uid) + if not resource_vertex: + return None + + for edge in resource_vertex.edges: + if (edge and edge.edge_type == EdgeType.LINK and + edge.path == 'domain' and edge.active): + return edge.head_uid + + return None + + except Exception as e: + logging.error(f"Error getting domain dir UID for {resource_uid}: {e}", exc_info=True) + return None + + +def set_resource_domain_dir( + params: KeeperParams, + resource_uid: str, + dir_uid: str, + config_uid: Optional[str] = None +) -> bool: + """ + Add or replace the LINK edge from resource to pamDirectory. + + Primary path: POSTs a `PAMResourceConfig` with `domainUid` set to krouter's + `/api/user/configure_resource`. krouter handles "replace existing domain link" + server-side (so the legacy disconnect-old-first dance is no longer needed). + + Fallback: same `KEEPER_DAG_LB_FALLBACK` policy as the AI/JIT helpers (see + `set_resource_keeper_ai_settings`). + """ + common = _resolve_resource_settings_inputs(params, resource_uid, {}, config_uid) + if common is None: + return False + record_key, resolved_config_uid = common + + # Primary: Layer-B configure_resource (permission-checked). + from ...helpers.router_utils import router_configure_resource, get_router_url + host = get_router_url(params) + endpoint = 'configure_resource' + if not is_layer_b_feature_disabled(host, endpoint): + rq = pam_pb2.PAMResourceConfig( + recordUid=url_safe_str_to_bytes(resource_uid), + networkUid=url_safe_str_to_bytes(resolved_config_uid), + domainUid=url_safe_str_to_bytes(dir_uid), + ) + try: + router_configure_resource(params, rq) + logging.debug(f"Set domain dir link {resource_uid} -> {dir_uid} via configure_resource") + return True + except Exception as err: + if not should_fallback_on_layer_b_error(err, host=host, endpoint=endpoint): + logging.error(f"configure_resource failed (no fallback): {err}", exc_info=True) + return False + logging.warning( + f"configure_resource denied/unavailable for {resource_uid}; falling back to legacy " + f"DAG-write (KEEPER_DAG_LB_FALLBACK enabled): {err}" + ) + + return _set_resource_domain_dir_legacy( + params, resource_uid, dir_uid, resolved_config_uid, record_key + ) + + +def _set_resource_domain_dir_legacy( + params: KeeperParams, + resource_uid: str, + dir_uid: str, + config_uid: str, + record_key: bytes, +) -> bool: + """Legacy direct DAG-write path: disconnect-old-then-link. Used as fallback only.""" + try: + encrypted_session_token, encrypted_transmission_key, transmission_key = get_keeper_tokens(params) + dag_record = PasswordRecord() + dag_record.record_uid = config_uid + dag_record.record_key = record_key + + conn = Connection( + vault=params.vault, + encrypted_transmission_key=encrypted_transmission_key, + encrypted_session_token=encrypted_session_token, + transmission_key=transmission_key, + use_read_protobuf=False, + use_write_protobuf=False, + ) + linking_dag = DAG( + conn=conn, + record=dag_record, + graph_id=PamGraphId.PAM.value, + decrypt=True, + ) + linking_dag.load() + + resource_vertex = linking_dag.get_vertex(resource_uid) + if not resource_vertex: + logging.warning(f"Resource vertex {resource_uid} not found in DAG") + return False + + # If a domain LINK to a different pamDirectory exists, disconnect it first. + old_dir_uid = None + for edge in resource_vertex.edges: + if (edge and edge.edge_type == EdgeType.LINK and + edge.path == 'domain' and edge.active): + old_dir_uid = edge.head_uid + break + if old_dir_uid and old_dir_uid != dir_uid: + old_dir_vertex = linking_dag.get_vertex(old_dir_uid) + if old_dir_vertex: + resource_vertex.disconnect_from(old_dir_vertex) + logging.debug(f"Disconnected old domain LINK edge to {old_dir_uid}") + + dir_vertex = linking_dag.get_vertex(dir_uid) + if not dir_vertex: + logging.warning(f"Directory vertex {dir_uid} not found in DAG") + return False + + resource_vertex.belongs_to(dir_vertex, EdgeType.LINK, path="domain", content={}) + linking_dag.save() + + logging.debug(f"Set domain dir link {resource_uid} -> {dir_uid} via legacy DAG-write") + return True + except Exception as e: + logging.error(f"Error setting domain dir (legacy path) for {resource_uid}: {e}", exc_info=True) + return False + + +def remove_resource_jit_settings( + params: KeeperParams, + resource_uid: str, + config_uid: Optional[str] = None +) -> bool: + """ + Remove JIT settings by overwriting the 'jit_settings' DATA edge with an empty dict. + + Implementation note: DATA edges in the DAG library use `active` as a versioning + marker (auto-managed by add_data when superseding), not a visibility toggle — + get_resource_settings reads the highest-version edge regardless of `active`, + and EdgeType.DELETION self-loops are not path-scoped in the library's lookup + logic. Writing an empty {} via the same set_resource_jit_settings path that + creation uses gives a clean, reliable removal: the new edge becomes the + highest version, and _do_show treats {} as 'No JIT settings configured'. + """ + if not set_resource_jit_settings(params, resource_uid, {}, config_uid, allow_empty=True): + return False + logging.debug(f"Cleared jit_settings for resource {resource_uid}") + return True diff --git a/keepercli-package/src/keepercli/commands/pam/pam_launch/__init__.py b/keepercli-package/src/keepercli/commands/pam/pam_launch/__init__.py new file mode 100644 index 00000000..94f1e776 --- /dev/null +++ b/keepercli-package/src/keepercli/commands/pam/pam_launch/__init__.py @@ -0,0 +1,17 @@ +from __future__ import annotations +from keepersdk.helpers.keeper_dag.dag_utils import value_to_boolean +import os +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ....params import KeeperParams + from keepersdk.helpers.keeper_dag.connection import ConnectionBase + + +def get_connection(params: KeeperParams) -> ConnectionBase: + if value_to_boolean(os.environ.get("USE_LOCAL_DAG", False)) is False: + from keepersdk.helpers.keeper_dag.connection.commander import Connection as CommanderConnection + return CommanderConnection(vault=params.vault) + else: + from keepersdk.helpers.keeper_dag.connection.local import Connection as LocalConnection + return LocalConnection() diff --git a/keepercli-package/src/keepercli/commands/pam/pam_launch/connect_spinner.py b/keepercli-package/src/keepercli/commands/pam/pam_launch/connect_spinner.py new file mode 100644 index 00000000..1e5a4396 --- /dev/null +++ b/keepercli-package/src/keepercli/commands/pam/pam_launch/connect_spinner.py @@ -0,0 +1,69 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' None: + self.message = message + self.running = False + self.thread: threading.Thread | None = None + self._last_visible_len = 0 + + def _animate(self) -> None: + idx = 0 + while self.running: + try: + frame = self.FRAMES[idx % len(self.FRAMES)] + message = self.message or '' + visible_len = len(message) + 2 + pad = max(0, self._last_visible_len - visible_len) + sys.stdout.write(f'\r{Fore.CYAN}{frame}{Fore.RESET} {message}' + (' ' * pad)) + sys.stdout.flush() + self._last_visible_len = visible_len + pad + idx += 1 + except Exception: + logging.getLogger(__name__).debug('PamLaunchSpinner frame skipped', exc_info=True) + time.sleep(0.08) + clear_len = max(self._last_visible_len, len(self.message or '') + 2) + sys.stdout.write('\r' + ' ' * clear_len + '\r') + sys.stdout.flush() + self._last_visible_len = 0 + + def start(self) -> None: + self.running = True + self.thread = threading.Thread(target=self._animate, daemon=True) + self.thread.start() + + def stop(self) -> None: + self.running = False + if self.thread: + self.thread.join(timeout=0.5) diff --git a/keepercli-package/src/keepercli/commands/pam/pam_launch/connect_timing.py b/keepercli-package/src/keepercli/commands/pam/pam_launch/connect_timing.py new file mode 100644 index 00000000..6e5bf6e6 --- /dev/null +++ b/keepercli-package/src/keepercli/commands/pam/pam_launch/connect_timing.py @@ -0,0 +1,206 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' bool: + """True when PAM_CONNECT_TIMING=1 or the module logger is at DEBUG.""" + if os.environ.get(_TIMING_FORCE_ENV, '').strip().lower() in ('1', 'true', 'yes', 'on'): + return True + return _LOG.isEnabledFor(logging.DEBUG) + + +# --- Connect-phase delay helpers ------------------------------------------- +# +# The pam launch flow has several historically fixed sleeps whose durations +# only matter in the first-launch / slow-network case. Defaults here are tuned +# for the fast path; each env var below lets operators restore the legacy +# (conservative) values without a code roll. + +_WEBSOCKET_BACKEND_DELAY_ENV = 'WEBSOCKET_BACKEND_DELAY' +_WEBSOCKET_BACKEND_DELAY_FAST_DEFAULT = 0.30 # seconds — fast path default +_WEBSOCKET_BACKEND_DELAY_LEGACY_ENV = 'WEBSOCKET_BACKEND_DELAY_LEGACY' +_WEBSOCKET_BACKEND_DELAY_LEGACY_DEFAULT = 2.0 # seconds — adaptive fallback cap + +_PAM_PRE_OFFER_SEC_ENV = 'PAM_PRE_OFFER_SEC' +_PAM_PRE_OFFER_FAST_DEFAULT = 0.0 # seconds — merged into backend_delay +_PAM_PRE_OFFER_LEGACY_ENV = 'PAM_PRE_OFFER_LEGACY' # 1/true/yes → force legacy 1.0s + +_PAM_OFFER_RETRY_EXTRA_SEC_ENV = 'PAM_OFFER_RETRY_EXTRA_SEC' +_PAM_OFFER_RETRY_EXTRA_DEFAULT = 1.25 # seconds — retry backoff + +_PAM_OPEN_CONNECTION_DELAY_ENV = 'PAM_OPEN_CONNECTION_DELAY' +_PAM_OPEN_CONNECTION_DELAY_FAST_DEFAULT = 0.05 # seconds — safety margin + # (retry loop handles slow DataChannel) + +_PAM_WEBRTC_POLL_MS_ENV = 'PAM_WEBRTC_POLL_MS' +_PAM_WEBRTC_POLL_MS_DEFAULT = 25 # milliseconds — poll granularity + + +def _env_float(name: str, default: float) -> float: + """Read a float env var; return ``default`` when unset, empty, or unparseable.""" + raw = os.environ.get(name) + if raw is None: + return default + raw = str(raw).strip() + if raw == '': + return default + try: + return float(raw) + except (TypeError, ValueError): + return default + + +def _env_truthy(name: str) -> bool: + return os.environ.get(name, '').strip().lower() in ('1', 'true', 'yes', 'on') + + +def websocket_backend_delay_sec() -> float: + """Sleep after WebSocket connects and before POSTing the offer (router/backend + registration window). + + Set ``WEBSOCKET_BACKEND_DELAY`` to override. Default is 0.30s for the fast + path; the legacy value was 2.0s. Combined with the retry path, a single + unlucky launch still caps at the legacy total (see + ``websocket_backend_delay_legacy_sec``). + """ + return _env_float(_WEBSOCKET_BACKEND_DELAY_ENV, _WEBSOCKET_BACKEND_DELAY_FAST_DEFAULT) + + +def websocket_backend_delay_legacy_sec() -> float: + """Upper bound for the adaptive backend-delay catch-up on a first-attempt + offer failure. On retry the code sleeps up to + ``max(0, legacy - fast_default)`` more so the cumulative wait matches the + pre-change 2.0s behavior for the unlucky cold-router case. + """ + return _env_float(_WEBSOCKET_BACKEND_DELAY_LEGACY_ENV, _WEBSOCKET_BACKEND_DELAY_LEGACY_DEFAULT) + + +def pre_offer_delay_sec() -> float: + """Extra sleep between the backend-delay wait and the offer HTTP POST. + + Default 0.0 (the previous hardcoded 1.0s sleep was redundant — the + backend-delay wait already serves the same purpose). Set + ``PAM_PRE_OFFER_LEGACY=1`` to force the legacy 1.0s, or ``PAM_PRE_OFFER_SEC`` + for a custom value. + """ + if _env_truthy(_PAM_PRE_OFFER_LEGACY_ENV): + return max(1.0, _env_float(_PAM_PRE_OFFER_SEC_ENV, 1.0)) + return _env_float(_PAM_PRE_OFFER_SEC_ENV, _PAM_PRE_OFFER_FAST_DEFAULT) + + +def offer_retry_extra_delay_sec() -> float: + """Base delay before a retry of the gateway offer HTTP POST.""" + return _env_float(_PAM_OFFER_RETRY_EXTRA_SEC_ENV, _PAM_OFFER_RETRY_EXTRA_DEFAULT) + + +def open_connection_delay_sec() -> float: + """Sleep between ``webrtc_data_plane_connected`` and sending ``OpenConnection``. + + Historically 0.2s; reduced to 0.05s because the caller's retry loop with + exponential backoff already handles the "DataChannel not yet open" case. + Set ``PAM_OPEN_CONNECTION_DELAY`` to restore a larger safety margin. + """ + return _env_float(_PAM_OPEN_CONNECTION_DELAY_ENV, _PAM_OPEN_CONNECTION_DELAY_FAST_DEFAULT) + + +def webrtc_connection_poll_sec() -> float: + """Poll tick (seconds) for the ``tube_registry.get_connection_state`` loop + that waits for the WebRTC data plane to reach ``connected``. + + Default 25ms (previously 100ms). Set ``PAM_WEBRTC_POLL_MS`` to override. + """ + ms = _env_float(_PAM_WEBRTC_POLL_MS_ENV, _PAM_WEBRTC_POLL_MS_DEFAULT) + return max(0.001, ms / 1000.0) + + +_PAM_WEBRTC_CONNECT_TIMEOUT_ENV = 'PAM_WEBRTC_CONNECT_TIMEOUT_SEC' +_PAM_WEBRTC_CONNECT_TIMEOUT_DEFAULT = 24.0 # seconds — see note below + + +def webrtc_connect_timeout_sec() -> float: + """Maximum wall-clock to wait for the WebRTC data plane to reach + ``connected`` after ``OpenConnection`` is sent. + + Default 24s — observed ICE completion sometimes lands a few seconds + past the prior 16s bound (notably on TURN-relay fallback paths), so + the client was aborting just before the connection would have come + up. JIT ephemeral accounts need more time to create and connect, so + the extra headroom also covers gateway-side account provisioning + before the data plane comes up. 24s keeps us clearly above the + gateway/guacd-side 15s connect timeout while absorbing that jitter. + If ICE really is stuck (state staying at ``Connecting`` / tube_status + ``connecting`` indefinitely) we still fail and the user can re-run — + the retry typically succeeds on a fresh ICE gathering pass. Set + ``PAM_WEBRTC_CONNECT_TIMEOUT_SEC`` to override for targeted diagnostics. + """ + return _env_float(_PAM_WEBRTC_CONNECT_TIMEOUT_ENV, _PAM_WEBRTC_CONNECT_TIMEOUT_DEFAULT) + + +class PamConnectTiming: + """Monotonic checkpoints for ``pam launch`` / tunnel open (debug or PAM_CONNECT_TIMING=1). + + Usage: + tc = PamConnectTiming('pam-launch:webrtc-tunnel') + tc.checkpoint('enter') + ... + tc.checkpoint('relay_creds_ok') + ... + tc.summary('done') + """ + + __slots__ = ('_label', '_t0', '_last') + + def __init__(self, label: str = 'pam-launch') -> None: + self._label = label + self._t0 = time.perf_counter() + self._last = self._t0 + + def checkpoint(self, phase: str, *, log: Optional[bool] = None) -> None: + do_log = connect_timing_log_enabled() if log is None else log + now = time.perf_counter() + step_ms = (now - self._last) * 1000.0 + total_ms = (now - self._t0) * 1000.0 + self._last = now + if not do_log: + return + # Always emit at DEBUG. Commander's ``debug --file`` handler installs an + # explicit ``record.levelno != INFO`` filter (cli.py::setup_file_logging) + # to keep user-facing INFO prints out of the debug log — which ate our + # timing lines when PAM_CONNECT_TIMING=1 previously bumped them to INFO. + # DEBUG passes that filter and surfaces cleanly whenever debug mode is on. + _LOG.log( + logging.DEBUG, + '%s | %-44s | +%.1f ms (step) | %.1f ms (total)', + self._label, + phase, + step_ms, + total_ms, + ) + + def summary(self, phase: str = 'done') -> None: + """Log one line with total elapsed (e.g. at end of tunnel open or command).""" + if not connect_timing_log_enabled(): + return + total_ms = (time.perf_counter() - self._t0) * 1000.0 + _LOG.log(logging.DEBUG, '%s | %-44s | TOTAL %.1f ms', self._label, phase, total_ms) diff --git a/keepercli-package/src/keepercli/commands/pam/pam_launch/crlf_merge_delay.py b/keepercli-package/src/keepercli/commands/pam/pam_launch/crlf_merge_delay.py new file mode 100644 index 00000000..fe60efa8 --- /dev/null +++ b/keepercli-package/src/keepercli/commands/pam/pam_launch/crlf_merge_delay.py @@ -0,0 +1,53 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ (R) +# | ' int: + """ + Whole milliseconds after a lone ``\\r`` before a partner ``\\n`` may be dropped + (split CRLF across reads). + + Parsed from :data:`PAM_LAUNCH_CRLF_MERGE_DELAY_MS_ENV` (integer). Values below + :data:`MIN_CRLF_MERGE_DELAY_MS` or above :data:`MAX_CRLF_MERGE_DELAY_MS` are clamped. + Invalid or missing values use :data:`DEFAULT_CRLF_MERGE_DELAY_MS`. + """ + raw = os.environ.get(PAM_LAUNCH_CRLF_MERGE_DELAY_MS_ENV) + if raw is None or not str(raw).strip(): + return DEFAULT_CRLF_MERGE_DELAY_MS + s = str(raw).strip() + try: + v = int(s, 10) + except ValueError: + return DEFAULT_CRLF_MERGE_DELAY_MS + if v < 0: + return DEFAULT_CRLF_MERGE_DELAY_MS + if v < MIN_CRLF_MERGE_DELAY_MS: + return MIN_CRLF_MERGE_DELAY_MS + if v > MAX_CRLF_MERGE_DELAY_MS: + return MAX_CRLF_MERGE_DELAY_MS + return v + + +def pam_launch_crlf_merge_delay_sec() -> float: + """Seconds for ``time.monotonic()`` deadlines (internal use).""" + return pam_launch_crlf_merge_delay_ms() / 1000.0 diff --git a/keepercli-package/src/keepercli/commands/pam/pam_launch/guac_cli/__init__.py b/keepercli-package/src/keepercli/commands/pam/pam_launch/guac_cli/__init__.py new file mode 100644 index 00000000..316062e4 --- /dev/null +++ b/keepercli-package/src/keepercli/commands/pam/pam_launch/guac_cli/__init__.py @@ -0,0 +1,66 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' callback mapping + + def register_handler(self, opcode: str, callback: Callable[[GuacInstruction], None]): + """Register a handler for a specific opcode""" + self.handlers[opcode] = callback + + def feed(self, data: bytes) -> List[GuacInstruction]: + """ + Feed raw bytes from the data channel and parse instructions. + + Args: + data: Raw bytes from Guacamole server + + Returns: + List of parsed instructions + """ + try: + # Guacamole protocol is UTF-8 text + text = data.decode('utf-8') + self.buffer += text + except UnicodeDecodeError as e: + logging.warning(f"Failed to decode Guacamole data: {e}") + return [] + + instructions = [] + + # Parse complete instructions (terminated by semicolon) + while ';' in self.buffer: + idx = self.buffer.index(';') + instruction_text = self.buffer[:idx] + self.buffer = self.buffer[idx + 1:] + + # Parse the instruction + instruction = self._parse_instruction(instruction_text) + if instruction: + instructions.append(instruction) + + # Call registered handler if exists + if instruction.opcode in self.handlers: + try: + self.handlers[instruction.opcode](instruction) + except Exception as e: + logging.error(f"Error in handler for {instruction.opcode}: {e}") + + return instructions + + def _parse_instruction(self, text: str) -> Optional[GuacInstruction]: + """ + Parse a single instruction from text format. + + Format: length.value,length.value,... + Example: "4.sync,8.12345678" -> sync instruction with timestamp + + Args: + text: Raw instruction text (without semicolon) + + Returns: + Parsed GuacInstruction or None if parsing fails + """ + if not text: + return None + + try: + elements = [] + remaining = text + + while remaining: + # Find the length prefix + if '.' not in remaining: + break + + dot_idx = remaining.index('.') + length_str = remaining[:dot_idx] + + try: + length = int(length_str) + except ValueError: + logging.warning(f"Invalid length prefix in Guacamole instruction: {length_str}") + return None + + # Extract the value + value_start = dot_idx + 1 + value_end = value_start + length + + if value_end > len(remaining): + logging.warning(f"Truncated Guacamole instruction: expected {length} bytes") + return None + + value = remaining[value_start:value_end] + elements.append(value) + + # Move to next element + remaining = remaining[value_end:] + if remaining.startswith(','): + remaining = remaining[1:] + + if not elements: + return None + + # First element is the opcode + opcode = elements[0] + args = elements[1:] + + return GuacInstruction(opcode, args) + + except Exception as e: + logging.error(f"Error parsing Guacamole instruction '{text}': {e}") + return None + + def encode_instruction(self, opcode: str, *args) -> bytes: + """ + Encode a Guacamole instruction to send to the server. + + Args: + opcode: Instruction opcode + *args: Instruction arguments + + Returns: + Encoded instruction as bytes + """ + elements = [opcode] + list(args) + encoded_parts = [] + + for element in elements: + element_str = str(element) + encoded_parts.append(f"{len(element_str)}.{element_str}") + + instruction = ','.join(encoded_parts) + ';' + return instruction.encode('utf-8') + + def encode_key(self, keycode: int, pressed: bool) -> bytes: + """ + Encode a keyboard event. + + Args: + keycode: X11 keysym value + pressed: True for press, False for release + + Returns: + Encoded key instruction + """ + return self.encode_instruction('key', str(keycode), '1' if pressed else '0') + + def encode_mouse(self, x: int, y: int, button_mask: int) -> bytes: + """ + Encode a mouse event. + + Args: + x: X coordinate + y: Y coordinate + button_mask: Bitmask of pressed buttons + + Returns: + Encoded mouse instruction + """ + return self.encode_instruction('mouse', str(x), str(y), str(button_mask)) + + def encode_size(self, width: int, height: int) -> bytes: + """ + Encode a terminal size change. + + Args: + width: Terminal width in characters + height: Terminal height in characters + + Returns: + Encoded size instruction + """ + # Size instruction: size,layer,width,height + # Layer 0 is the root layer + return self.encode_instruction('size', '0', str(width), str(height)) + + def encode_clipboard(self, mimetype: str, data: str) -> bytes: + """ + Encode clipboard data. + + Args: + mimetype: MIME type (typically "text/plain") + data: Clipboard text + + Returns: + Encoded clipboard instruction + """ + return self.encode_instruction('clipboard', mimetype, data) + + def encode_sync(self, timestamp: str) -> bytes: + """ + Encode a sync acknowledgment. + + Args: + timestamp: Timestamp from server's sync instruction + + Returns: + Encoded sync instruction + """ + return self.encode_instruction('sync', timestamp) + + +# X11 keysym mappings for common keys +# Reference: https://www.x.org/releases/X11R7.7/doc/xproto/x11protocol.html#keysym_encoding +class X11Keysym: + """X11 keysym values for common keyboard keys""" + + # Control keys + BACKSPACE = 0xFF08 + TAB = 0xFF09 + RETURN = 0xFF0D + ESCAPE = 0xFF1B + DELETE = 0xFFFF + INSERT = 0xFF63 + + # Cursor movement + HOME = 0xFF50 + LEFT = 0xFF51 + UP = 0xFF52 + RIGHT = 0xFF53 + DOWN = 0xFF54 + PAGE_UP = 0xFF55 + PAGE_DOWN = 0xFF56 + END = 0xFF57 + + # Function keys + F1 = 0xFFBE + F2 = 0xFFBF + F3 = 0xFFC0 + F4 = 0xFFC1 + F5 = 0xFFC2 + F6 = 0xFFC3 + F7 = 0xFFC4 + F8 = 0xFFC5 + F9 = 0xFFC6 + F10 = 0xFFC7 + F11 = 0xFFC8 + F12 = 0xFFC9 + + # Modifiers + SHIFT_L = 0xFFE1 + SHIFT_R = 0xFFE2 + CONTROL_L = 0xFFE3 + CONTROL_R = 0xFFE4 + CAPS_LOCK = 0xFFE5 + META_L = 0xFFE7 + META_R = 0xFFE8 + ALT_L = 0xFFE9 + ALT_R = 0xFFEA + + # ASCII / Latin-1 (U+0000–U+00FF): keysym equals code point (legacy X11 Latin-1 keysyms). + # Unicode outside that range: Guacamole / X11 use U+01000000 | codepoint (see Guacamole + # Keyboard.js keysym_from_unicode; X11 keysym Unicode extension). + _GUAC_UNICODE_KEYSYM_OFFSET = 0x01000000 + + @staticmethod + def keysym_from_unicode_codepoint(codepoint: int) -> int: + """ + Map a Unicode scalar to the X11 keysym value sent on the Guacamole ``key`` instruction. + + Code points U+0000-U+00FF use the direct keysym (ASCII + ISO 8859-1). U+0100 and + above (Cyrillic, Greek, CJK, emoji, etc.) use ``0x01000000 | codepoint``. + """ + if codepoint < 0 or codepoint > 0x10FFFF: + return 0 + if codepoint <= 0xFF: + return codepoint + return X11Keysym._GUAC_UNICODE_KEYSYM_OFFSET | codepoint + + @staticmethod + def from_char(ch: str) -> int: + """Convert a single character to X11 keysym (same rules as keysym_from_unicode_codepoint).""" + if len(ch) == 1: + return X11Keysym.keysym_from_unicode_codepoint(ord(ch)) + return 0 + diff --git a/keepercli-package/src/keepercli/commands/pam/pam_launch/guac_cli/input.py b/keepercli-package/src/keepercli/commands/pam/pam_launch/guac_cli/input.py new file mode 100644 index 00000000..9b071551 --- /dev/null +++ b/keepercli-package/src/keepercli/commands/pam/pam_launch/guac_cli/input.py @@ -0,0 +1,643 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' 0: + self._send_key(X11Keysym.keysym_from_unicode_codepoint(code)) + + def _read_escape_sequence(self) -> Optional[str]: + """Read an ANSI escape sequence from stdin after the leading ESC.""" + seq = '' + for _ in range(8): + ch = self.stdin_reader.read_char(timeout=0.05) + if not ch: + break + seq += ch + if ch.isalpha() or ch == '~': + break + return seq if seq else None + + def _escape_to_keysym(self, seq: str) -> Optional[int]: + """Map an ANSI escape sequence (without leading ESC) to an X11 keysym.""" + mappings = { + '[A': X11Keysym.UP, + '[B': X11Keysym.DOWN, + '[C': X11Keysym.RIGHT, + '[D': X11Keysym.LEFT, + '[H': X11Keysym.HOME, + '[F': X11Keysym.END, + '[1~': X11Keysym.HOME, + '[2~': X11Keysym.INSERT, + '[3~': X11Keysym.DELETE, + '[4~': X11Keysym.END, + '[5~': X11Keysym.PAGE_UP, + '[6~': X11Keysym.PAGE_DOWN, + 'OP': X11Keysym.F1, + 'OQ': X11Keysym.F2, + 'OR': X11Keysym.F3, + 'OS': X11Keysym.F4, + '[15~': X11Keysym.F5, + '[17~': X11Keysym.F6, + '[18~': X11Keysym.F7, + '[19~': X11Keysym.F8, + '[20~': X11Keysym.F9, + '[21~': X11Keysym.F10, + '[23~': X11Keysym.F11, + '[24~': X11Keysym.F12, + } + # Strip modifier suffix (e.g. [1;5A → [A) + if seq.startswith('[1;') and len(seq) >= 4: + final = seq[-1] + base = {'A': '[A', 'B': '[B', 'C': '[C', 'D': '[D'}.get(final) + if base: + return mappings.get(base) + return mappings.get(seq) + + def _control_char_to_keysym(self, ch: str) -> Optional[int]: + """Map a control character (code < 32) to an X11 keysym.""" + code = ord(ch) + if code == 8: return X11Keysym.BACKSPACE + if code == 9: return X11Keysym.TAB + if code == 10: return X11Keysym.RETURN + if code == 13: return X11Keysym.RETURN + if code == 27: return X11Keysym.ESCAPE + # Ctrl+A … Ctrl+Z and other control codes: send as the raw code value. + # guacd maps ETX (3), EOT (4), etc. correctly for SSH/terminal use. + return code + + def _send_key(self, keysym: int): + """Emit a key press followed by a key release.""" + self.key_callback(keysym, True) + self.key_callback(keysym, False) + + def _send_modifier_chord(self, modifiers: list[int], main_keysym: int) -> None: + """Press modifiers, press+release main key, release modifiers (remote TTY paste).""" + for m in modifiers: + self.key_callback(m, True) + self.key_callback(main_keysym, True) + self.key_callback(main_keysym, False) + for m in reversed(modifiers): + self.key_callback(m, False) + + def _send_ctrl_v_chord(self) -> None: + self._send_modifier_chord([X11Keysym.CONTROL_L], ord('v')) + + def _send_ctrl_shift_v_chord(self) -> None: + self._send_modifier_chord([X11Keysym.CONTROL_L, X11Keysym.SHIFT_L], ord('v')) + + def _send_shift_insert_chord(self) -> None: + self._send_modifier_chord([X11Keysym.SHIFT_L], X11Keysym.INSERT) + + def _send_ctrl_insert_chord(self) -> None: + self._send_modifier_chord([X11Keysym.CONTROL_L], X11Keysym.INSERT) + + +# Unix/macOS stdin reader + +class UnixStdinReader: + """Unix/macOS stdin reader with raw mode via termios.""" + + def __init__(self): + self.old_settings = None + + def set_raw_mode(self): + try: + import termios, tty, time + sys.stdout.flush() + sys.stderr.flush() + self.old_settings = termios.tcgetattr(sys.stdin.fileno()) + tty.setraw(sys.stdin.fileno()) + time.sleep(0.01) + sys.stdout.flush() + sys.stderr.flush() + except Exception as exc: + logging.warning(f'Failed to set raw mode: {exc}') + + def restore(self): + if self.old_settings: + try: + import termios + _fd = sys.stdin.fileno() + termios.tcsetattr(_fd, termios.TCSADRAIN, self.old_settings) + stash_stdin_termios_from_stdin() + except Exception as exc: + logging.warning(f'Failed to restore terminal: {exc}') + self.old_settings = None + + def read_char(self, timeout: Optional[float] = None) -> Optional[str]: + if timeout is not None: + import select + ready, _, _ = select.select([sys.stdin], [], [], timeout) + if not ready: + return None + try: + # Use os.read for exactly-1-byte fd-level reads so that select() + # and the actual read operate at the same kernel-buffer level. + # sys.stdin.read(1) goes through Python's 8192-byte BufferedReader + # which can consume the entire escape sequence (\x1b[A) in one + # os.read() call, leaving the fd empty and breaking subsequent + # select() calls in _read_escape_sequence. + raw = os.read(sys.stdin.fileno(), 1) + return raw.decode('utf-8', 'replace') if raw else None + except Exception: + return None + + +# Windows stdin reader (ReadConsoleInput-based) + +# VT100 / xterm escape sequences for Windows VK codes. +# These are queued as individual chars so the existing _escape_to_keysym +# mapping in InputHandler works unchanged. +_VK_TO_ESC_SEQ: dict = { + 0x26: '\x1b[A', # VK_UP + 0x28: '\x1b[B', # VK_DOWN + 0x27: '\x1b[C', # VK_RIGHT + 0x25: '\x1b[D', # VK_LEFT + 0x24: '\x1b[H', # VK_HOME + 0x23: '\x1b[F', # VK_END + 0x2D: '\x1b[2~', # VK_INSERT + 0x2E: '\x1b[3~', # VK_DELETE + 0x21: '\x1b[5~', # VK_PRIOR (Page Up) + 0x22: '\x1b[6~', # VK_NEXT (Page Down) + 0x70: '\x1bOP', # VK_F1 + 0x71: '\x1bOQ', # VK_F2 + 0x72: '\x1bOR', # VK_F3 + 0x73: '\x1bOS', # VK_F4 + 0x74: '\x1b[15~', # VK_F5 + 0x75: '\x1b[17~', # VK_F6 + 0x76: '\x1b[18~', # VK_F7 + 0x77: '\x1b[19~', # VK_F8 + 0x78: '\x1b[20~', # VK_F9 + 0x79: '\x1b[21~', # VK_F10 + 0x7A: '\x1b[23~', # VK_F11 + 0x7B: '\x1b[24~', # VK_F12 +} + +_VK_INSERT = 0x2D +_VK_V = 0x56 +_SHIFT_PRESSED = 0x0010 +_LEFT_CTRL = 0x0008 +_RIGHT_CTRL = 0x0004 +_CTRL_PRESSED = _LEFT_CTRL | _RIGHT_CTRL +_KEY_EVENT = 0x0001 +_STD_INPUT_HANDLE = -10 + + +class WindowsStdinReader: + """ + Windows console reader using ReadConsoleInputW for full modifier awareness. + + Paste chords are translated to sentinels so _process_input can route to + PasteOrchestrator or, when PAM disablePaste, to key chords (Ctrl+V, etc.). + + Navigation / function keys are translated to VT100 escape sequences and + queued one character at a time; InputHandler._read_escape_sequence drains + the queue transparently. + """ + + def __init__(self, should_continue: Optional[Callable[[], bool]] = None): + self._should_continue = should_continue + self._queue: collections.deque = collections.deque() + self._hstdin = None + self._input_record_type = None + self._ready = False + self._init_win32() + + def _init_win32(self): + """Set up ctypes structures for ReadConsoleInputW.""" + try: + import ctypes + from ctypes import wintypes + + class _KeyEventRecord(ctypes.Structure): + _fields_ = [ + ('bKeyDown', wintypes.BOOL), + ('wRepeatCount', wintypes.WORD), + ('wVirtualKeyCode', wintypes.WORD), + ('wVirtualScanCode', wintypes.WORD), + ('uChar', wintypes.WCHAR), + ('dwControlKeyState', wintypes.DWORD), + ] + + class _EventUnion(ctypes.Union): + _fields_ = [ + ('KeyEvent', _KeyEventRecord), + ('_pad', ctypes.c_byte * 20), + ] + + class _InputRecord(ctypes.Structure): + _fields_ = [ + ('EventType', wintypes.WORD), + ('Event', _EventUnion), + ] + + self._InputRecord = _InputRecord + self._wintypes = wintypes + self._ctypes = ctypes + kernel32 = ctypes.windll.kernel32 + self._hstdin = kernel32.GetStdHandle(_STD_INPUT_HANDLE) + self._ReadConsoleInputW = kernel32.ReadConsoleInputW + self._WaitForSingleObject = kernel32.WaitForSingleObject + self._ready = True + except Exception as exc: + logging.warning(f'WindowsStdinReader: Win32 init failed, falling back to msvcrt: {exc}') + self._ready = False + + def set_raw_mode(self): + import time + sys.stdout.flush() + sys.stderr.flush() + time.sleep(0.01) + sys.stdout.flush() + sys.stderr.flush() + # Ctrl+C as input (not SIGINT) so CtrlCCoordinator can handle double-tap. + self._win_saved_console_mode = win_stdin_disable_ctrl_c_process_input() + + def restore(self): + win_stdin_restore_console_mode(self._win_saved_console_mode) + self._win_saved_console_mode = None + + def read_char(self, timeout: Optional[float] = None) -> Optional[str]: + # Drain queued chars first (from previously decoded escape sequences). + if self._queue: + return self._queue.popleft() + + if self._ready: + return self._read_via_console_input(timeout) + return self._read_via_msvcrt(timeout) + + def _read_via_console_input(self, timeout: Optional[float]) -> Optional[str]: + """Read one logical key event, emitting VT100 sequences for nav keys.""" + ctypes = self._ctypes + wintypes = self._wintypes + + while True: + if timeout is not None: + wait_ms = int(timeout * 1000) + result = self._WaitForSingleObject(self._hstdin, wait_ms) + if result != 0: # WAIT_OBJECT_0 = 0 + return None + + if self._should_continue is not None and not self._should_continue(): + return None + + record = self._InputRecord() + n_read = wintypes.DWORD(0) + ok = self._ReadConsoleInputW( + self._hstdin, + ctypes.byref(record), + 1, + ctypes.byref(n_read), + ) + if not ok or n_read.value == 0: + return None + + if record.EventType != _KEY_EVENT: + continue + + key = record.Event.KeyEvent + if not key.bKeyDown: + continue # ignore key-up events + + vk = key.wVirtualKeyCode + ctrl = key.dwControlKeyState & _CTRL_PRESSED + shift = key.dwControlKeyState & _SHIFT_PRESSED + + # Ctrl+Shift+V + if vk == _VK_V and ctrl and shift: + return _CHORD_CTRL_SHIFT_V + + # Ctrl+V (plain) — some consoles omit uChar; match before uChar path + if vk == _VK_V and ctrl and not shift: + return _PASTE_BYTE + + # Shift+Insert + if vk == _VK_INSERT and shift and not ctrl: + return _CHORD_SHIFT_INSERT + + # Ctrl+Insert (some terminals) + if vk == _VK_INSERT and ctrl and not shift: + return _CHORD_CTRL_INSERT + + # Navigation / function keys → VT100 ESC sequence (queued) + if vk in _VK_TO_ESC_SEQ: + seq = _VK_TO_ESC_SEQ[vk] + for c in seq[1:]: + self._queue.append(c) + return seq[0] # '\x1b' — rest drained by _read_escape_sequence + + # Regular character from uChar (includes Ctrl+letter control codes) + ch = key.uChar + if ch and ord(ch) > 0: + return ch + + # Modifier-only or unhandled VK — loop for next event + + def _read_via_msvcrt(self, timeout: Optional[float]) -> Optional[str]: + """Fallback when ReadConsoleInput init failed.""" + try: + import msvcrt, time + start = time.time() + while True: + if timeout is not None and (time.time() - start) >= timeout: + return None + if self._should_continue is not None and not self._should_continue(): + return None + if msvcrt.kbhit(): + ch = msvcrt.getch() + # Extended key prefix — read second byte immediately + if ch in (b'\xe0', b'\x00'): + scan = msvcrt.getch() + seq = self._win_scan_to_esc(scan[0] if scan else 0) + if seq: + for c in seq[1:]: + self._queue.append(c) + return seq[0] + return None + return ch.decode('utf-8', errors='replace') + time.sleep(0.01) + except Exception as exc: + logging.error(f'msvcrt read error: {exc}') + return None + + @staticmethod + def _win_scan_to_esc(scan: int) -> Optional[str]: + """Map a Windows extended-key scan code to a VT100 escape sequence.""" + table = { + 0x48: '\x1b[A', # Up + 0x50: '\x1b[B', # Down + 0x4D: '\x1b[C', # Right + 0x4B: '\x1b[D', # Left + 0x47: '\x1b[H', # Home + 0x4F: '\x1b[F', # End + 0x52: '\x1b[2~', # Insert + 0x53: '\x1b[3~', # Delete + 0x49: '\x1b[5~', # Page Up + 0x51: '\x1b[6~', # Page Down + 0x3B: '\x1bOP', # F1 + 0x3C: '\x1bOQ', # F2 + 0x3D: '\x1bOR', # F3 + 0x3E: '\x1bOS', # F4 + 0x3F: '\x1b[15~', # F5 + 0x40: '\x1b[17~', # F6 + 0x41: '\x1b[18~', # F7 + 0x42: '\x1b[19~', # F8 + 0x43: '\x1b[20~', # F9 + 0x44: '\x1b[21~', # F10 + 0x85: '\x1b[23~', # F11 + 0x86: '\x1b[24~', # F12 + } + return table.get(scan) diff --git a/keepercli-package/src/keepercli/commands/pam/pam_launch/guac_cli/instructions.py b/keepercli-package/src/keepercli/commands/pam/pam_launch/guac_cli/instructions.py new file mode 100644 index 00000000..27e53d2d --- /dev/null +++ b/keepercli-package/src/keepercli/commands/pam/pam_launch/guac_cli/instructions.py @@ -0,0 +1,773 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' bytes: + """ + Map CRLF to LF across Guacamole STDOUT **blob** boundaries. + + A single per-blob CRLF→LF replace misses a carriage return at the end of one blob + and a line feed at the start of the next; the local TTY then sees two motion + operations (common symptom: double vertical step in mysql-style prompts). + """ + data = carry_cell[0] + decoded + carry_cell[0] = b'' + out = bytearray() + i, n = 0, len(data) + while i < n: + if data[i] == 0x0D: + if i + 1 < n and data[i + 1] == 0x0A: + out.append(0x0A) + i += 2 + elif i + 1 >= n: + carry_cell[0] = b'\r' + i += 1 + else: + out.append(0x0D) + i += 1 + else: + out.append(data[i]) + i += 1 + return bytes(out) + + +def _collapse_adjacent_lf_pairs(data: bytes) -> bytes: + """ + One left-to-right pass: each adjacent ``\\n\\n`` becomes a single ``\\n``. + + This only merges **pairs** (e.g. four consecutive LFs become two, six become three). + It does **not** repeatedly collapse until a single LF (which would erase intentional + blank lines from a long run in one blob). + """ + if not data or b'\n\n' not in data: + return data + out = bytearray() + i, n = 0, len(data) + while i < n: + if i + 1 < n and data[i] == 0x0A and data[i + 1] == 0x0A: + out.append(0x0A) + i += 2 + else: + out.append(data[i]) + i += 1 + return bytes(out) + + +def _drop_one_oob_lf_pair(data: bytes) -> bytes: + """ + Remove at most one leading and at most one trailing ``\\n\\n`` pair (each -> single ``\\n``). + + ``_collapse_adjacent_lf_pairs`` already handles runs in the middle; mysql result blobs + sometimes end with an extra ``\\r\\n\\r\\n`` / ``\\n\\n`` before the next prompt (see + ``show databases``) where the duplicate is not a byte-identical repeat of the prior blob. + """ + d = data + if d.startswith(b'\n\n'): + d = d[1:] + if len(d) >= 2 and d.endswith(b'\n\n'): + d = d[:-1] + return d + + +# Guacamole sometimes delivers the same STDOUT blob twice (darwin/mysql); gaps can exceed +# 120ms (seen ~175ms). Suppress only the **second** of each identical pair (then allow the third). +_IDENTICAL_STDOUT_BLOB_PAIR_MAX_S = 0.75 +_IDENTICAL_STDOUT_BLOB_PAIR_MAX_LEN = 512 + + +def _identical_stdout_blob_pair_anchorable(to_write: bytes) -> bool: + """Single-byte keystroke blobs must not replace the dedupe anchor (breaks prompt pair logic).""" + return len(to_write) >= 8 or (b'\n' in to_write or b'\r' in to_write) + + +def _identical_stdout_blob_pair_should_skip(to_write: bytes, state: List[Any]) -> bool: + if not to_write or len(to_write) > _IDENTICAL_STDOUT_BLOB_PAIR_MAX_LEN: + return False + now = time.monotonic() + last_b, last_t, already_skipped = state[0], state[1], state[2] + if last_b is None or to_write != last_b: + return False + if (now - last_t) >= _IDENTICAL_STDOUT_BLOB_PAIR_MAX_S: + return False + if already_skipped: + return False + state[2] = True + return True + + +def _identical_stdout_blob_pair_note_emitted(to_write: bytes, state: List[Any]) -> None: + now = time.monotonic() + lb, _lt, sk = state[0], state[1], state[2] + if _identical_stdout_blob_pair_anchorable(to_write): + state[:] = [to_write, now, False] + else: + # Slide time only; keep anchor so a duplicate prompt line still matches after 1-byte blobs. + state[:] = [lb, now, False] + + +def is_stdout_pipe_stream_name(name: str) -> bool: + """True if Guacamole named pipe is the terminal STDOUT stream (case/whitespace tolerant).""" + if not name: + return False + return str(name).strip().casefold() == 'stdout' + + +def is_stdin_pipe_stream_name(name: str) -> bool: + """True if this named pipe is the client→server STDIN stream (do not treat as terminal output).""" + if not name: + return False + return str(name).strip().casefold() == 'stdin' + + +def _pipe_looks_like_terminal_stdout(mimetype: str, name: str) -> bool: + """ + Heuristic when guacr/gateway uses a non-STDOUT pipe name for TTY bytes (e.g. PAM clipboard flags). + Require text/* and exclude STDIN. Only used when no stdout stream is tracked yet. + """ + if is_stdin_pipe_stream_name(name): + return False + mt = (mimetype or '').strip().lower() + return mt == 'text/plain' or mt.startswith('text/') + + +# Handler type: receives list of string arguments +InstructionHandler = Callable[[List[str]], None] + +# Callback type for sending ack responses +AckCallback = Callable[[str, str, str], None] # (stream_index, message, code) + + +# ============================================================================= +# Instruction Handlers +# ============================================================================= + +def handle_sync(args: List[str]) -> None: + """ + Handle sync instruction - Frame synchronization. + + This is critical - the server waits for sync acknowledgment. + Note: The actual sync response is sent by the caller (GuacamoleHandler). + + Args: + args: [timestamp] or [timestamp, frames] + """ + timestamp = args[0] if args else "?" + frames = args[1] if len(args) > 1 else "0" + logging.debug(f"[SYNC] timestamp={timestamp}, frames={frames}") + + +def handle_name(args: List[str]) -> None: + """ + Handle name instruction - Connection name/title. + + Args: + args: [name] + """ + name = args[0] if args else "?" + logging.debug(f"[NAME] {name}") + + +def handle_size(args: List[str]) -> None: + """ + Handle size instruction - Screen/terminal size. + + Size can have different formats: + - size,layer,width,height (3 args) + - size,layer,width,height,dpi (4 args) + - size,width,height (2 args - client sending to server) + """ + if len(args) == 2: + width, height = args + logging.debug(f"[SIZE] {width}x{height}") + elif len(args) >= 3: + layer, width, height = args[0], args[1], args[2] + dpi = args[3] if len(args) > 3 else str(default_handshake_dpi()) + logging.debug(f"[SIZE] layer={layer}, {width}x{height} @ {dpi}dpi") + else: + logging.debug(f"[SIZE] {args}") + + +def handle_png(args: List[str]) -> None: + """ + Handle png instruction - PNG image data. + + Args: + args: [channel, layer, x, y, ...data_args] + """ + if len(args) < 4: + logging.debug(f"[PNG] {args}") + return + + channel, layer, x, y = args[0], args[1], args[2], args[3] + data_args = args[4:] + + if data_args: + data = data_args[0] + if len(data) > 16: + try: + hex_preview = data[:16].encode('utf-8').hex() + except: + hex_preview = str(data[:16]) + logging.debug(f"[PNG] channel={channel}, layer={layer}, pos=({x},{y}), data=[{hex_preview}...] ({len(data)} chars)") + else: + logging.debug(f"[PNG] channel={channel}, layer={layer}, pos=({x},{y}), data={data}") + else: + logging.debug(f"[PNG] channel={channel}, layer={layer}, pos=({x},{y})") + + +def handle_jpeg(args: List[str]) -> None: + """ + Handle jpeg instruction - JPEG image data. + + Args: + args: [channel, layer, x, y, ...data_args] + """ + if len(args) < 4: + logging.debug(f"[JPEG] {args}") + return + + channel, layer, x, y = args[0], args[1], args[2], args[3] + data_args = args[4:] + + if data_args: + data = data_args[0] + if len(data) > 16: + try: + hex_preview = data[:16].encode('utf-8').hex() + except: + hex_preview = str(data[:16]) + logging.debug(f"[JPEG] channel={channel}, layer={layer}, pos=({x},{y}), data=[{hex_preview}...] ({len(data)} chars)") + else: + logging.debug(f"[JPEG] channel={channel}, layer={layer}, pos=({x},{y}), data={data}") + else: + logging.debug(f"[JPEG] channel={channel}, layer={layer}, pos=({x},{y})") + + +def handle_img(args: List[str]) -> None: + """ + Handle img instruction - Streamed image data. + + Args: + args: [stream, channel, layer, mimetype, x, y] + """ + if len(args) >= 6: + stream, channel, layer, mimetype, x, y = args[0], args[1], args[2], args[3], args[4], args[5] + logging.debug(f"[IMG] stream={stream}, channel={channel}, layer={layer}, type={mimetype}, pos=({x},{y})") + else: + logging.debug(f"[IMG] {args}") + + +def handle_cursor(args: List[str]) -> None: + """ + Handle cursor instruction - Cursor position/image. + + Args: + args: [x, y, ...additional params] + """ + if len(args) >= 2: + x, y = args[0], args[1] + extra = args[2:] if len(args) > 2 else [] + if extra: + logging.debug(f"[CURSOR] hotspot=({x},{y}), args={extra}") + else: + logging.debug(f"[CURSOR] pos=({x},{y})") + else: + logging.debug(f"[CURSOR] {args}") + + +def handle_move(args: List[str]) -> None: + """ + Handle move instruction - Move layer. + + Args: + args: [layer, parent, x, y, z] + """ + if len(args) >= 5: + layer, parent, x, y, z = args[0], args[1], args[2], args[3], args[4] + logging.debug(f"[MOVE] layer={layer}, parent={parent}, pos=({x},{y}), z={z}") + else: + logging.debug(f"[MOVE] {args}") + + +def handle_rect(args: List[str]) -> None: + """ + Handle rect instruction - Draw rectangle path. + + Args: + args: [layer, x, y, width, height] + """ + if len(args) >= 5: + layer, x, y, width, height = args[0], args[1], args[2], args[3], args[4] + logging.debug(f"[RECT] layer={layer}, rect=({x},{y},{width},{height})") + else: + logging.debug(f"[RECT] {args}") + + +def handle_cfill(args: List[str]) -> None: + """ + Handle cfill instruction - Fill with color. + + Args: + args: [channel, layer, r, g, b, a] + """ + if len(args) >= 6: + channel, layer, r, g, b, a = args[0], args[1], args[2], args[3], args[4], args[5] + logging.debug(f"[CFILL] channel={channel}, layer={layer}, color=rgba({r},{g},{b},{a})") + else: + logging.debug(f"[CFILL] {args}") + + +def handle_copy(args: List[str]) -> None: + """ + Handle copy instruction - Copy rectangle between layers. + + Args: + args: [src_layer, src_x, src_y, width, height, channel, dst_layer, dst_x, dst_y] + """ + if len(args) >= 9: + src_layer, src_x, src_y, width, height = args[0], args[1], args[2], args[3], args[4] + channel, dst_layer, dst_x, dst_y = args[5], args[6], args[7], args[8] + logging.debug(f"[COPY] from layer={src_layer} ({src_x},{src_y},{width},{height}) to layer={dst_layer} ({dst_x},{dst_y})") + else: + logging.debug(f"[COPY] {args}") + + +def handle_clipboard(args: List[str]) -> None: + """ + Handle clipboard instruction - Clipboard data stream. + + Args: + args: [stream, mimetype] + """ + if len(args) >= 2: + stream, mimetype = args[0], args[1] + logging.debug(f"[CLIPBOARD] stream={stream}, type={mimetype}") + else: + logging.debug(f"[CLIPBOARD] {args}") + + +def handle_ack(args: List[str]) -> None: + """ + Handle ack instruction - Acknowledgment. + + Args: + args: [stream, message, code] + """ + if len(args) >= 3: + stream, message, code = args[0], args[1], args[2] + logging.debug(f"[ACK] stream={stream}, message='{message}', code={code}") + else: + logging.debug(f"[ACK] {args}") + + +def handle_error(args: List[str]) -> None: + """ + Handle error instruction - Error message from server. + + Args: + args: [message, code] + """ + if len(args) >= 2: + message, code = args[0], args[1] + logging.error(f"[ERROR] code={code}, message='{message}'") + else: + logging.error(f"[ERROR] {args}") + + +def handle_disconnect(args: List[str]) -> None: + """ + Handle disconnect instruction - Server disconnecting. + + Args: + args: Optional disconnect parameters + """ + logging.debug(f"[DISCONNECT] {args if args else ''}") + + +def handle_mouse(args: List[str]) -> None: + """ + Handle mouse instruction - Mouse position (server-side cursor). + + Args: + args: [x, y] + """ + # Don't print mouse movements to avoid spam + if len(args) >= 2: + x, y = args[0], args[1] + logging.debug(f"MOUSE: ({x},{y})") + + +def handle_blob(args: List[str]) -> None: + """ + Handle blob instruction - Binary blob data for stream. + + Args: + args: [stream, data] + """ + if len(args) >= 2: + stream, data = args[0], args[1] + data_preview = data[:16] if len(data) > 16 else data + logging.debug(f"[BLOB] stream={stream}, data=[{data_preview}...] ({len(data)} chars)") + else: + logging.debug(f"[BLOB] {args}") + + +def handle_end(args: List[str]) -> None: + """ + Handle end instruction - End of stream. + + Args: + args: [stream] + """ + stream = args[0] if args else "?" + logging.debug(f"[END] stream={stream}") + + +def handle_pipe(args: List[str]) -> None: + """ + Handle pipe instruction - Named pipe stream. + + For SSH/TTY sessions, the server sends pipe with name "STDOUT" to + indicate terminal output will follow via blob instructions. + + Args: + args: [stream_index, mimetype, name] + """ + if len(args) >= 3: + stream, mimetype, name = args[0], args[1], args[2] + logging.debug(f"[PIPE] stream={stream}, type={mimetype}, name={name}") + else: + logging.debug(f"[PIPE] {args}") + + +def handle_args(args: List[str]) -> None: + """ + Handle args instruction - Server requests connection parameters. + + This is CRITICAL - guacd sends this after receiving 'select' to ask what + parameters are needed for the connection. + + Note: The actual handshake response is sent by the caller (GuacamoleHandler). + + Args: + args: List of parameter names that guacd expects + """ + logging.debug(f"[ARGS] Server requesting parameters: {list(args)}") + + +def handle_ready(args: List[str]) -> None: + """ + Handle ready instruction - Server confirms connection is ready. + + This is sent by guacd after processing 'connect' instruction. + + Args: + args: [connection_id] + """ + connection_id = args[0] if args else None + logging.debug(f"[READY] Connection ready (id: {connection_id})") + + +def handle_unknown(opcode: str, args: List[str]) -> None: + """ + Handle any unrecognized instruction - default handler. + + Args: + opcode: Instruction opcode + args: Instruction arguments + """ + # Truncate long arguments for display + arg_preview = [] + for arg in args: + arg_str = str(arg) + if len(arg_str) > 32: + arg_preview.append(arg_str[:32] + "...") + else: + arg_preview.append(arg_str) + + logging.debug(f"[{opcode.upper()}] {arg_preview}") + + +# ============================================================================= +# Instruction Router +# ============================================================================= + +# Map of opcode -> handler function +_INSTRUCTION_HANDLERS: Dict[str, InstructionHandler] = { + # Critical instructions + 'sync': handle_sync, + 'name': handle_name, + 'size': handle_size, + + # Image instructions + 'png': handle_png, + 'jpeg': handle_jpeg, + 'img': handle_img, + + # Display instructions + 'cursor': handle_cursor, + 'move': handle_move, + 'rect': handle_rect, + 'cfill': handle_cfill, + 'copy': handle_copy, + + # I/O instructions + 'clipboard': handle_clipboard, + 'pipe': handle_pipe, + 'blob': handle_blob, + 'end': handle_end, + 'ack': handle_ack, + + # Control instructions + 'error': handle_error, + 'disconnect': handle_disconnect, + + # Connection handshake (CRITICAL) + 'args': handle_args, + 'ready': handle_ready, + + # Mouse (logged but not printed) + 'mouse': handle_mouse, +} + + +def create_instruction_router( + custom_handlers: Optional[Dict[str, InstructionHandler]] = None, + send_ack_callback: Optional[AckCallback] = None, + stdout_stream_tracker: Optional[Any] = None, + *, + normalize_stdout_crlf: bool = False, +) -> Callable[[str, List[str]], None]: + """ + Create an instruction router callback for use with Parser.oninstruction. + + The router dispatches instructions to the appropriate handler based on opcode. + Custom handlers can override the default handlers. + + For plaintext SSH/TTY streams, the router can track STDOUT pipes and decode + blob data to sys.stdout. This requires: + - send_ack_callback: Function to send ack responses + - stdout_stream_tracker: Object with `stdout_stream_index` attribute for tracking + + Args: + custom_handlers: Optional dict of opcode -> handler to override defaults. + send_ack_callback: Optional callback(stream, message, code) to send ack. + stdout_stream_tracker: Optional object with `stdout_stream_index` attribute. + When set, pipe/blob/end for STDOUT streams will be handled specially: + - pipe with name "STDOUT" stores stream index and sends ack + - blob with matching stream decodes base64 to stdout and sends ack + - end with matching stream clears tracking + normalize_stdout_crlf: When True (``pam launch -n``), replace CRLF with LF in decoded STDOUT + blobs only (terminal output), including **across** blob boundaries; then collapse + adjacent ``\\n\\n`` to ``\\n`` **one pair at a time** per pass (see + :func:`_collapse_adjacent_lf_pairs`). Does not alter stdin or other streams. + + Returns: + A callback function with signature (opcode: str, args: List[str]) -> None + suitable for assigning to Parser.oninstruction. + + Example: + from guacamole import Parser + from guac_cli.instructions import create_instruction_router + + parser = Parser() + parser.oninstruction = create_instruction_router() + parser.receive("4.sync,10.1234567890;") + + Example with STDOUT handling: + class StreamTracker: + stdout_stream_index = -1 + + tracker = StreamTracker() + parser.oninstruction = create_instruction_router( + send_ack_callback=lambda s, m, c: send_ack(s, m, c), + stdout_stream_tracker=tracker, + ) + """ + # Merge default handlers with custom handlers + handlers = _INSTRUCTION_HANDLERS.copy() + if custom_handlers: + handlers.update(custom_handlers) + + # Per-router STDOUT CRLF tail when normalizing (see _streaming_crlf_to_lf). + _stdout_crlf_carry: List[bytes] = [b''] + # Previous STDOUT write ended with LF — if next chunk starts with LF, drop one (pairwise). + _stdout_prev_emitted_ends_lf: List[bool] = [False] + # [last_emitted_bytes|None, last_emit_mono, skipped_one_duplicate_of_last] + _stdout_identical_pair: List[Any] = [None, 0.0, False] + + def router(opcode: str, args: List[str]) -> None: + """Route instruction to appropriate handler.""" + + # Special handling for pipe/blob/end when STDOUT tracking is enabled + if stdout_stream_tracker is not None and send_ack_callback is not None: + + # Handle pipe - track STDOUT stream + if opcode == 'pipe' and len(args) >= 3: + stream_index, mimetype, name = args[0], args[1], args[2] + _note = getattr(stdout_stream_tracker, 'note_guac_pipe_instruction', None) + if callable(_note): + _note() + use_as_stdout = is_stdout_pipe_stream_name(name) + if ( + not use_as_stdout + and stdout_stream_tracker.stdout_stream_index == -1 + and _pipe_looks_like_terminal_stdout(mimetype, name) + ): + use_as_stdout = True + logging.debug( + 'CLI: using pipe name=%r mimetype=%r as terminal STDOUT (fallback)', + name, + mimetype, + ) + + if use_as_stdout: + _stdout_crlf_carry[0] = b'' + _stdout_prev_emitted_ends_lf[0] = False + _stdout_identical_pair[:] = [None, 0.0, False] + stdout_stream_tracker.stdout_stream_index = int(stream_index) + send_ack_callback(stream_index, 'OK', '0') + evt = getattr(stdout_stream_tracker, 'stdout_pipe_opened', None) + if evt is not None and hasattr(evt, 'set'): + evt.set() + logging.debug('Terminal output pipe on stream %s (name=%r)', stream_index, name) + # Still call original handler for diagnostics + handler = handlers.get(opcode) + if handler: + try: + handler(args) + except Exception as e: + logging.error(f"Error in pipe handler: {e}") + return + + # Handle blob - decode STDOUT data to sys.stdout + elif opcode == 'blob' and len(args) >= 2: + stream_index = int(args[0]) + if stream_index == stdout_stream_tracker.stdout_stream_index: + # Decode base64 and write to stdout + try: + decoded = base64.b64decode(args[1]) + if normalize_stdout_crlf: + decoded = _streaming_crlf_to_lf(decoded, _stdout_crlf_carry) + if _stdout_prev_emitted_ends_lf[0] and decoded.startswith(b'\n'): + decoded = decoded[1:] + decoded = _collapse_adjacent_lf_pairs(decoded) + decoded = _drop_one_oob_lf_pair(decoded) + if decoded and _identical_stdout_blob_pair_should_skip( + decoded, _stdout_identical_pair + ): + send_ack_callback(args[0], 'OK', '0') + return + # Try buffer.write for binary output, fall back to str for compatibility + if decoded: + if hasattr(sys.stdout, 'buffer'): + sys.stdout.buffer.write(decoded) + else: + sys.stdout.write(decoded.decode('utf-8', errors='replace')) + sys.stdout.flush() + if normalize_stdout_crlf: + _stdout_prev_emitted_ends_lf[0] = decoded.endswith(b'\n') + _identical_stdout_blob_pair_note_emitted( + decoded, _stdout_identical_pair + ) + send_ack_callback(args[0], 'OK', '0') + except Exception as e: + logging.error(f"Error decoding STDOUT blob: {e}") + return + # Inbound Guacamole clipboard stream (server → client) + clip_blob = getattr(stdout_stream_tracker, 'handle_remote_clipboard_blob', None) + if clip_blob is not None: + if cast(Callable[[str, str], bool], clip_blob)(args[0], args[1]): + return + # Non-STDOUT blob falls through to default handler + + # Handle end - clear STDOUT tracking + elif opcode == 'end' and len(args) >= 1: + stream_index = int(args[0]) + if stream_index == stdout_stream_tracker.stdout_stream_index: + _stdout_prev_emitted_ends_lf[0] = False + _stdout_identical_pair[:] = [None, 0.0, False] + if normalize_stdout_crlf and _stdout_crlf_carry[0]: + tail = _stdout_crlf_carry[0] + _stdout_crlf_carry[0] = b'' + try: + if hasattr(sys.stdout, 'buffer'): + sys.stdout.buffer.write(tail) + else: + sys.stdout.write(tail.decode('utf-8', errors='replace')) + sys.stdout.flush() + except Exception as exc: + logging.debug('STDOUT CRLF carry flush at stream end: %s', exc) + stdout_stream_tracker.stdout_stream_index = -1 + logging.debug(f"STDOUT stream {stream_index} ended") + # Still call original handler for diagnostics + handler = handlers.get(opcode) + if handler: + try: + handler(args) + except Exception as e: + logging.error(f"Error in end handler: {e}") + return + clip_end = getattr(stdout_stream_tracker, 'handle_remote_clipboard_end', None) + if clip_end is not None: + if cast(Callable[[str], bool], clip_end)(args[0]): + return + + # Default routing + handler = handlers.get(opcode) + if handler: + try: + handler(args) + except Exception as e: + logging.error(f"Error handling instruction {opcode}: {e}", exc_info=True) + else: + handle_unknown(opcode, args) + + return router + + +def get_default_handlers() -> Dict[str, InstructionHandler]: + """ + Get a copy of the default instruction handlers. + + Returns: + Dict mapping opcode to handler function. + """ + return _INSTRUCTION_HANDLERS.copy() diff --git a/keepercli-package/src/keepercli/commands/pam/pam_launch/guac_cli/renderer.py b/keepercli-package/src/keepercli/commands/pam/pam_launch/guac_cli/renderer.py new file mode 100644 index 00000000..af21bcb6 --- /dev/null +++ b/keepercli-package/src/keepercli/commands/pam/pam_launch/guac_cli/renderer.py @@ -0,0 +1,362 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' = 2: + try: + x = int(args[0]) + y = int(args[1]) + self.cursor_x = min(max(0, x), self.width - 1) + self.cursor_y = min(max(0, y), self.height - 1) + except ValueError: + pass + + def _handle_text(self, args: list): + """ + Handle text drawing. + + Args format: [layer, x, y, text] + """ + if len(args) >= 4: + try: + # layer = args[0] # Ignore layer for now + x = int(args[1]) + y = int(args[2]) + text = args[3] + + # Draw text at position + self._draw_text(x, y, text) + + except (ValueError, IndexError) as e: + logging.debug(f"Error in text instruction: {e}") + + def _draw_text(self, x: int, y: int, text: str): + """Draw text at specified position in screen buffer""" + if y < 0 or y >= self.height: + return + + for i, ch in enumerate(text): + col = x + i + if col >= 0 and col < self.width: + self.screen[y][col] = ch + self.attrs[y][col] = self._encode_attrs() + + def _encode_attrs(self) -> int: + """Encode current attributes to a single integer""" + attr = 0 + if self.current_bold: + attr |= 1 + if self.current_underline: + attr |= 2 + attr |= (self.current_fg & 0xF) << 4 + attr |= (self.current_bg & 0xF) << 8 + return attr + + def _handle_rect(self, args: list): + """Handle rectangle drawing (fill with color)""" + if len(args) >= 5: + try: + # layer = args[0] + x = int(args[1]) + y = int(args[2]) + w = int(args[3]) + h = int(args[4]) + + # Fill rectangle with spaces + for row in range(y, min(y + h, self.height)): + for col in range(x, min(x + w, self.width)): + if row >= 0 and col >= 0: + self.screen[row][col] = ' ' + self.attrs[row][col] = self._encode_attrs() + + except (ValueError, IndexError): + pass + + def _handle_cfill(self, args: list): + """Handle color fill""" + if len(args) >= 4: + try: + # Parse color (r, g, b, a) + # For simplicity, map to nearest ANSI color + r = int(args[0]) + g = int(args[1]) + b = int(args[2]) + # a = int(args[3]) # alpha + + # Map RGB to ANSI color (0-15) + self.current_bg = self._rgb_to_ansi(r, g, b) + + except (ValueError, IndexError): + pass + + def _rgb_to_ansi(self, r: int, g: int, b: int) -> int: + """Map RGB (0-255) to ANSI color code (0-15)""" + # Simple mapping to 8 basic colors + if r < 128 and g < 128 and b < 128: + return 0 # Black + elif r > 200 and g > 200 and b > 200: + return 7 # White + elif r > 128: + return 1 # Red + elif g > 128: + return 2 # Green + elif b > 128: + return 4 # Blue + else: + return 7 # Default white + + def _handle_copy(self, args: list): + """Handle copy operation (copy screen region)""" + if len(args) >= 7: + try: + # src_layer = args[0] + src_x = int(args[1]) + src_y = int(args[2]) + w = int(args[3]) + h = int(args[4]) + # dst_layer = args[5] + dst_x = int(args[6]) + dst_y = int(args[7]) + + # Copy region + for row in range(h): + for col in range(w): + src_row = src_y + row + src_col = src_x + col + dst_row = dst_y + row + dst_col = dst_x + col + + if (0 <= src_row < self.height and 0 <= src_col < self.width and + 0 <= dst_row < self.height and 0 <= dst_col < self.width): + self.screen[dst_row][dst_col] = self.screen[src_row][src_col] + self.attrs[dst_row][dst_col] = self.attrs[src_row][src_col] + + except (ValueError, IndexError): + pass + + def _handle_size(self, args: list): + """Handle terminal size change""" + if len(args) >= 3: + try: + # layer = args[0] + new_width = int(args[1]) + new_height = int(args[2]) + + if new_width > 0 and new_height > 0: + self.resize(new_width, new_height) + + except (ValueError, IndexError): + pass + + def _handle_move(self, args: list): + """Handle layer move (not relevant for text terminal)""" + pass + + def _handle_sync(self, args: list): + """Handle sync instruction - refresh the display""" + self.refresh() + + def _handle_error(self, args: list): + """Handle error message from server""" + if args: + error_msg = args[0] + logging.error(f"Guacamole server error: {error_msg}") + sys.stderr.write(f"\nServer error: {error_msg}\n") + sys.stderr.flush() + + def resize(self, new_width: int, new_height: int): + """ + Resize the terminal buffer. + + Args: + new_width: New width in characters + new_height: New height in characters + """ + # Create new buffers + new_screen = [[' ' for _ in range(new_width)] for _ in range(new_height)] + new_attrs = [[0 for _ in range(new_width)] for _ in range(new_height)] + + # Copy old content + for y in range(min(self.height, new_height)): + for x in range(min(self.width, new_width)): + new_screen[y][x] = self.screen[y][x] + new_attrs[y][x] = self.attrs[y][x] + + self.width = new_width + self.height = new_height + self.screen = new_screen + self.attrs = new_attrs + + # Clamp cursor position + self.cursor_x = min(self.cursor_x, new_width - 1) + self.cursor_y = min(self.cursor_y, new_height - 1) + + # Clear and redraw + sys.stdout.write('\033[2J') # Clear screen + self.refresh() + + def refresh(self): + """Refresh the entire display from the screen buffer""" + if not self.raw_mode: + return + + # Move cursor to home + sys.stdout.write('\033[H') + + # Render each line + for y in range(self.height): + line = ''.join(self.screen[y]) + sys.stdout.write(line) + if y < self.height - 1: + sys.stdout.write('\n') + + # Move cursor to current position + sys.stdout.write(f'\033[{self.cursor_y + 1};{self.cursor_x + 1}H') + sys.stdout.flush() + + def get_size(self) -> Tuple[int, int]: + """ + Get current terminal size. + + Returns: + Tuple of (width, height) in characters + """ + try: + # Try to get actual terminal size + import shutil + size = shutil.get_terminal_size(fallback=(80, 24)) + return (size.columns, size.lines) + except: + return (self.width, self.height) + + def clear(self): + """Clear the screen""" + self.screen = [[' ' for _ in range(self.width)] for _ in range(self.height)] + self.attrs = [[0 for _ in range(self.width)] for _ in range(self.height)] + if self.raw_mode: + sys.stdout.write('\033[2J') + sys.stdout.write('\033[H') + sys.stdout.flush() + diff --git a/keepercli-package/src/keepercli/commands/pam/pam_launch/guac_cli/session_input.py b/keepercli-package/src/keepercli/commands/pam/pam_launch/guac_cli/session_input.py new file mode 100644 index 00000000..39dcbeab --- /dev/null +++ b/keepercli-package/src/keepercli/commands/pam/pam_launch/guac_cli/session_input.py @@ -0,0 +1,137 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' ,text/plain; + blob,,; + end,; + Never falls back to send_stdin for paste. If disablePaste is set the + chord is silently ignored (early warning is printed at session start). +""" + +from __future__ import annotations + +import logging +import time +from typing import Callable, Optional + +# Fixed Ctrl+C double-tap window (plan: 400 ms, within the 300–500 ms band). +CTRL_C_WINDOW: float = 0.4 + + +class CtrlCCoordinator: + """ + Double-tap Ctrl+C coordinator shared by InputHandler and StdinHandler. + + Args: + remote_interrupt_fn: Called on the *first* tap (or any tap outside the + window) to forward the interrupt to the remote session. + • Key mode : send_key(keysym=3, pressed) x 2 (press + release) + • Pipe mode : send_stdin(b'\\x03') + local_exit_fn: Called on the *second* tap inside the window to end the + local pam-launch session (sets shutdown_requested=True). + """ + + def __init__( + self, + remote_interrupt_fn: Callable[[], None], + local_exit_fn: Callable[[], None], + ) -> None: + self._remote_interrupt = remote_interrupt_fn + self._local_exit = local_exit_fn + self._last_ctrl_c: Optional[float] = None + + def handle(self) -> None: + """Call whenever Ctrl+C (byte 0x03) is detected in the input stream.""" + now = time.monotonic() + if ( + self._last_ctrl_c is not None + and (now - self._last_ctrl_c) <= CTRL_C_WINDOW + ): + # Second tap inside window → local exit + self._last_ctrl_c = None + print('\r\nExiting session...', flush=True) + self._local_exit() + else: + # First tap (or outside window) → remote interrupt only + self._last_ctrl_c = now + self._remote_interrupt() + + +class PasteOrchestrator: + """ + OS-clipboard → remote Guacamole clipboard stream. + + GuacamoleClipboard.setRemoteClipboard: + client.createClipboardStream(mimetype) → clipboard instruction + writer.sendText(data) → blob instruction + writer.sendEnd() → end instruction + + Args: + send_clipboard_fn: Callable(text: str) that formats and sends the + three-instruction clipboard stream to the gateway. Should be + GuacamoleHandler.send_clipboard_stream. + disable_paste: When True the chord is a silent no-op (warning already + printed at session start by launch.py execute()). + """ + + def __init__( + self, + send_clipboard_fn: Callable[[str], None], + disable_paste: bool = False, + ) -> None: + self._send_clipboard = send_clipboard_fn + self._disable_paste = disable_paste + + def paste(self) -> None: + """Trigger a clipboard paste to the remote session.""" + if self._disable_paste: + return + + try: + import pyperclip # type: ignore[import] + text = pyperclip.paste() + except ImportError: + msg = ( + 'Paste unavailable: pyperclip is not installed. ' + 'Run: pip install pyperclip' + ) + logging.warning(msg) + print(f'\r\n{msg}', flush=True) + return + except Exception as exc: + msg = f'Could not read clipboard: {exc}' + logging.warning(msg) + print(f'\r\n{msg}', flush=True) + return + + if not text: + return + + try: + self._send_clipboard(text) + logging.debug('Paste: %d chars sent via Guacamole clipboard stream', len(text)) + except Exception as exc: + msg = f'Failed to send clipboard to remote: {exc}' + logging.warning(msg) + print(f'\r\n{msg}', flush=True) diff --git a/keepercli-package/src/keepercli/commands/pam/pam_launch/guac_cli/stdin_handler.py b/keepercli-package/src/keepercli/commands/pam/pam_launch/guac_cli/stdin_handler.py new file mode 100644 index 00000000..b47a386c --- /dev/null +++ b/keepercli-package/src/keepercli/commands/pam/pam_launch/guac_cli/stdin_handler.py @@ -0,0 +1,688 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ (R) +# | ' - Send base64-encoded keyboard input +- end,0 - Close the stream + +Each chunk of stdin input is sent as a complete pipe/blob/end sequence. + +Platform support: +- Unix/Linux: Uses termios for raw mode, select for non-blocking reads +- macOS: Uses same approach as Unix (termios + select) +- Windows: Uses msvcrt for console input +""" + +from __future__ import annotations +import logging +import sys +import threading +import time +from typing import Callable, Optional + +from .session_input import CtrlCCoordinator, PasteOrchestrator +from .win_console_input import ( + win_stdin_disable_ctrl_c_process_input, + win_stdin_restore_console_mode, +) + +from ..crlf_merge_delay import pam_launch_crlf_merge_delay_sec +from ..terminal_reset import stash_stdin_termios_from_stdin + + +class StdinHandler: + """ + Handles stdin input for plaintext SSH/TTY sessions (--stdin / pipe mode). + + Reads raw stdin in non-buffered mode and sends typed bytes via + stdin_callback (pipe/blob/end pattern, matching kcm-cli). + + Escape sequences (arrow keys, function keys) are converted to X11 key + events via key_callback when provided. + + Paste chords (Ctrl+V byte 0x16, Shift+Insert ESC[2~) and Ctrl+C double-tap + are handled via the shared CtrlCCoordinator / PasteOrchestrator helpers so + behaviour is identical to key-event mode (InputHandler). + """ + + def __init__( + self, + stdin_callback: Callable[[bytes], None], + key_callback: Optional[Callable[[int, bool], None]] = None, + ctrl_c_coordinator: Optional[CtrlCCoordinator] = None, + paste_orchestrator: Optional[PasteOrchestrator] = None, + ): + """ + Args: + stdin_callback: Sends typed bytes via GuacamoleHandler.send_stdin(). + key_callback: Sends key events via GuacamoleHandler.send_key(). + Required for escape-sequence → keysym conversion. + ctrl_c_coordinator: Shared double-tap Ctrl+C handler. + paste_orchestrator: Shared paste handler (clipboard → Guac stream). + """ + self.stdin_callback = stdin_callback + self.key_callback = key_callback + self.ctrl_c_coordinator = ctrl_c_coordinator + self.paste_orchestrator = paste_orchestrator + self.running = False + self.thread: Optional[threading.Thread] = None + self.raw_mode_active = False + self._escape_buffer = b'' # Buffer for escape sequences + # After a lone ``\\r`` -> ``\\n``, the next read may be the partner ``\\n`` (split ``\\r\\n``). + # Only drop that LF if it arrives within a few ms; a second Enter's LF is usually later. + self._suppress_lf_deadline: Optional[float] = None + + # Platform-specific stdin reader + self._stdin_reader = self._get_stdin_reader() + + def _get_stdin_reader(self): + """Get platform-specific stdin reader.""" + if sys.platform == 'win32': + return _WindowsStdinReader() + elif sys.platform == 'darwin': + return _MacOSStdinReader() + else: + return _UnixStdinReader() + + def start(self): + """Start reading stdin in a background thread.""" + if self.running: + return + + self.running = True + self._stdin_reader.set_raw_mode() + self.raw_mode_active = True + + self.thread = threading.Thread(target=self._input_loop, daemon=True) + self.thread.start() + logging.debug("StdinHandler started") + + def stop(self): + """Stop reading stdin and restore terminal.""" + self.running = False + # Flush any pending escape sequence + if self._escape_buffer: + # If we have just ESC (0x1B) with no following bytes, treat as standalone ESC key + if len(self._escape_buffer) == 1 and self._escape_buffer[0] == 0x1B: + if self.key_callback: + self._send_key(0xFF1B) # X11Keysym.ESCAPE + else: + self.stdin_callback(self._escape_buffer) + else: + # Incomplete escape sequence - send as regular data + logging.debug(f"Flushing incomplete escape sequence: {self._escape_buffer}") + self.stdin_callback(self._escape_buffer) + self._escape_buffer = b'' + if self.raw_mode_active: + self._stdin_reader.restore() + self.raw_mode_active = False + if self.thread: + # Don't wait too long - stdin.read() might be blocking + self.thread.join(timeout=0.5) + logging.debug("StdinHandler stopped") + + def _input_loop(self): + """Main stdin reading loop.""" + import time + last_escape_time = None + + while self.running: + try: + # Read available data (non-blocking with short timeout) + data = self._stdin_reader.read(timeout=0.1) + if data: + self._process_input(data) + # Reset escape timer if we got data + last_escape_time = None + elif self._escape_buffer and len(self._escape_buffer) == 1 and self._escape_buffer[0] == 0x1B: + # We have a standalone ESC in buffer with no more data + # Wait a short time to see if more bytes arrive (escape sequence) + if last_escape_time is None: + last_escape_time = time.time() + elif time.time() - last_escape_time > 0.05: # 50ms timeout + # No more bytes after 50ms - treat as standalone ESC key + logging.debug("Standalone ESC key (timeout)") + self._send_key(0xFF1B) # X11Keysym.ESCAPE + self._escape_buffer = b'' + last_escape_time = None + except Exception as e: + if self.running: # Only log if not shutting down + logging.error(f"Error in stdin loop: {e}") + break + + def _process_input(self, data: bytes): + """ + Process input data, detecting escape sequences and converting them to key events. + + Args: + data: Raw bytes from stdin + """ + if not self.key_callback: + # No key callback - send everything as stdin (original behavior) + self.stdin_callback(data) + return + + # Combine any pending escape buffer with new data + if self._escape_buffer: + data = self._escape_buffer + data + self._escape_buffer = b'' + + # Process data byte by byte to detect escape sequences + i = 0 + while i < len(data): + byte = data[i] + + # Check if we're in an escape sequence + if self._escape_buffer: + self._escape_buffer += bytes([byte]) + keysym = self._detect_escape_sequence() + if keysym is not None: + # Found a complete escape sequence. + # INSERT (ESC[2~) is a paste chord in both modes. + logging.debug(f"Detected escape sequence: {self._escape_buffer.hex()} -> keysym 0x{keysym:04X}") + if keysym == 0xFF63 and self.paste_orchestrator: # INSERT → paste + self.paste_orchestrator.paste() + else: + self._send_key(keysym) + self._escape_buffer = b'' + i += 1 + continue + elif len(self._escape_buffer) > 10: + # Escape sequence too long - treat as regular data + logging.warning(f"Invalid escape sequence (too long): {self._escape_buffer.hex()}") + self.stdin_callback(self._escape_buffer) + self._escape_buffer = b'' + i += 1 + continue + else: + # Still waiting for more bytes in escape sequence + # Check if we've reached the end of current data + if i == len(data) - 1: + # Last byte, might need more - keep in buffer for next read + # But if we only have ESC (0x1B) and no more data, treat as standalone ESC + if len(self._escape_buffer) == 1 and self._escape_buffer[0] == 0x1B: + logging.debug("Standalone ESC key (no more data available)") + self._send_key(0xFF1B) # X11Keysym.ESCAPE + self._escape_buffer = b'' + i += 1 + continue + break + i += 1 + continue + + # Check for start of escape sequence + # Unix/Linux/macOS: ESC = 0x1B + # Windows: Extended key = 0xE0 or 0x00 + if byte == 0x1B: + # Start of potential Unix-style escape sequence + # Check if there are more bytes immediately available + if i < len(data) - 1: + # More bytes available - might be an escape sequence + self._escape_buffer = bytes([byte]) + i += 1 + # Continue processing to see if sequence completes in this read + continue + else: + # This is the last byte - could be standalone ESC or start of sequence + # For now, treat as standalone ESC key (user can press ESC twice if needed) + # If it's part of a sequence, the next read will handle it + logging.debug("Standalone ESC key detected") + self._send_key(0xFF1B) # X11Keysym.ESCAPE + i += 1 + continue + elif byte == 0xE0 or byte == 0x00: + # Windows extended key sequence (0xE0 or 0x00 followed by scan code) + self._escape_buffer = bytes([byte]) + i += 1 + # Check if we can read more bytes immediately + if i < len(data): + # Continue processing to see if sequence completes in this read + continue + else: + # End of data, wait for next read + break + + # Regular character - send as stdin. + # Normalize line endings: send \n for Enter so remote sees one newline (avoids double + # newlines when terminal sends \r or \r\n and remote echoes + app sends newline). + if byte == 0x0D: # \r (Enter on some terminals) + self.stdin_callback(b'\n') + if i + 1 < len(data) and data[i + 1] == 0x0A: # skip trailing \n in \r\n + i += 1 + self._suppress_lf_deadline = None + else: + self._suppress_lf_deadline = time.monotonic() + pam_launch_crlf_merge_delay_sec() + elif byte == 0x0A: + dline = self._suppress_lf_deadline + if ( + dline is not None + and time.monotonic() <= dline + and len(data) == 1 + ): + self._suppress_lf_deadline = None + i += 1 + continue + self._suppress_lf_deadline = None + self.stdin_callback(b'\n') + i += 1 + continue + elif byte == 0x03: # Ctrl+C — double-tap coordinator + if self.ctrl_c_coordinator: + self.ctrl_c_coordinator.handle() + else: + self.stdin_callback(bytes([byte])) + elif byte == 0x16: # Ctrl+V — paste chord + if self.paste_orchestrator: + self.paste_orchestrator.paste() + else: + self.stdin_callback(bytes([byte])) + elif byte < 32 and byte != 0x1B: # Other control chars as-is + self.stdin_callback(bytes([byte])) + elif byte >= 32: # Printable character + self.stdin_callback(bytes([byte])) + else: + self.stdin_callback(bytes([byte])) + i += 1 + + def _detect_escape_sequence(self) -> Optional[int]: + """ + Detect if the escape buffer contains a known escape sequence. + + Returns: + X11 keysym if sequence is recognized and complete, None if incomplete or unknown + """ + if not self._escape_buffer or len(self._escape_buffer) < 2: + return None + + # Check for Windows extended key sequences (0xE0 or 0x00 prefix) + if len(self._escape_buffer) == 2 and (self._escape_buffer[0] == 0xE0 or self._escape_buffer[0] == 0x00): + # Windows console extended key code + scan_code = self._escape_buffer[1] + + # Windows scan codes for arrow keys + if scan_code == 0x48: # 'H' = Up arrow + return 0xFF52 # UP + elif scan_code == 0x50: # 'P' = Down arrow + return 0xFF54 # DOWN + elif scan_code == 0x4D: # 'M' = Right arrow + return 0xFF53 # RIGHT + elif scan_code == 0x4B: # 'K' = Left arrow + return 0xFF51 # LEFT + elif scan_code == 0x47: # Home + return 0xFF50 # HOME + elif scan_code == 0x4F: # End + return 0xFF57 # END + elif scan_code == 0x49: # Page Up + return 0xFF55 # PAGE_UP + elif scan_code == 0x51: # Page Down + return 0xFF56 # PAGE_DOWN + elif scan_code == 0x52: # Insert + return 0xFF63 # INSERT + elif scan_code == 0x53: # Delete + return 0xFFFF # DELETE + # Function keys F1-F10 (Windows scan codes) + elif scan_code == 0x3B: # F1 + return 0xFFBE # F1 + elif scan_code == 0x3C: # F2 + return 0xFFBF # F2 + elif scan_code == 0x3D: # F3 + return 0xFFC0 # F3 + elif scan_code == 0x3E: # F4 + return 0xFFC1 # F4 + elif scan_code == 0x3F: # F5 + return 0xFFC2 # F5 + elif scan_code == 0x40: # F6 + return 0xFFC3 # F6 + elif scan_code == 0x41: # F7 + return 0xFFC4 # F7 + elif scan_code == 0x42: # F8 + return 0xFFC5 # F8 + elif scan_code == 0x43: # F9 + return 0xFFC6 # F9 + elif scan_code == 0x44: # F10 + return 0xFFC7 # F10 + elif scan_code == 0x85: # F11 + return 0xFFC8 # F11 + elif scan_code == 0x86: # F12 + return 0xFFC9 # F12 + else: + return None + + # Unix/Linux/macOS VT100/xterm escape sequences + # Convert to string for pattern matching + try: + seq = self._escape_buffer[1:].decode('ascii', errors='ignore') + except Exception: + return None + + # Arrow keys and navigation (VT100/xterm style - universal on Linux/macOS) + # These sequences are standard across all Unix-like terminals + if seq == '[A': + return 0xFF52 # UP + elif seq == '[B': + return 0xFF54 # DOWN + elif seq == '[C': + return 0xFF53 # RIGHT + elif seq == '[D': + return 0xFF51 # LEFT + elif seq == '[H': + return 0xFF50 # HOME + elif seq == '[F': + return 0xFF57 # END + # Some terminals send arrow keys with modifiers (e.g., [1;2A for Shift+Up, [1;5A for Ctrl+Up) + # We ignore the modifier part and just use the base key + elif seq.startswith('[1;') and len(seq) >= 4: + # Extract the final character (A, B, C, D for arrows) + final_char = seq[-1] + if final_char == 'A': + return 0xFF52 # UP + elif final_char == 'B': + return 0xFF54 # DOWN + elif final_char == 'C': + return 0xFF53 # RIGHT + elif final_char == 'D': + return 0xFF51 # LEFT + + # Function keys (VT100/xterm style - single character after ESC) + elif seq == 'OP': + return 0xFFBE # F1 + elif seq == 'OQ': + return 0xFFBF # F2 + elif seq == 'OR': + return 0xFFC0 # F3 + elif seq == 'OS': + return 0xFFC1 # F4 + + # Function keys (xterm style - with tilde) + elif seq == '[11~': + return 0xFFBE # F1 + elif seq == '[12~': + return 0xFFBF # F2 + elif seq == '[13~': + return 0xFFC0 # F3 + elif seq == '[14~': + return 0xFFC1 # F4 + elif seq == '[15~': + return 0xFFC2 # F5 + elif seq == '[17~': + return 0xFFC3 # F6 + elif seq == '[18~': + return 0xFFC4 # F7 + elif seq == '[19~': + return 0xFFC5 # F8 + elif seq == '[20~': + return 0xFFC6 # F9 + elif seq == '[21~': + return 0xFFC7 # F10 + elif seq == '[23~': + return 0xFFC8 # F11 + elif seq == '[24~': + return 0xFFC9 # F12 + + # Other special keys + elif seq == '[1~': + return 0xFF50 # HOME + elif seq == '[2~': + return 0xFF63 # INSERT + elif seq == '[3~': + return 0xFFFF # DELETE + elif seq == '[4~': + return 0xFF57 # END + elif seq == '[5~': + return 0xFF55 # PAGE_UP + elif seq == '[6~': + return 0xFF56 # PAGE_DOWN + + # Check if sequence might be incomplete (common patterns that need more bytes) + # If it starts with '[' and doesn't end with '~' or a letter, might need more + if seq.startswith('[') and len(seq) >= 2: + # Check if it looks like it might be complete (ends with letter or ~) + if seq[-1].isalpha() or seq[-1] == '~': + # Might be complete but not recognized - return None to continue waiting + # This handles edge cases where we might have partial sequences + pass + + # Not a recognized sequence yet - might need more bytes + return None + + def _send_key(self, keysym: int): + """ + Send a key press and release event. + + Args: + keysym: X11 keysym value + """ + if self.key_callback: + # Send key press + self.key_callback(keysym, True) + # Send key release + self.key_callback(keysym, False) + + +class _UnixStdinReader: + """Unix/Linux stdin reader with raw mode support using termios.""" + + def __init__(self): + self.old_settings = None + + def set_raw_mode(self): + """Set terminal to raw mode (non-buffered, non-echoing).""" + try: + import termios + import tty + import time + + # Flush stdout before changing terminal attributes to ensure all output is complete + sys.stdout.flush() + sys.stderr.flush() + + self.old_settings = termios.tcgetattr(sys.stdin.fileno()) + tty.setraw(sys.stdin.fileno()) + + # Small delay to allow terminal to process the attribute change + # This helps prevent visual glitches where lines appear to be deleted + time.sleep(0.01) # 10ms delay + + # Flush again after setting raw mode + sys.stdout.flush() + sys.stderr.flush() + except Exception as e: + logging.warning(f"Failed to set raw mode: {e}") + + def restore(self): + """Restore terminal to normal mode.""" + if self.old_settings: + try: + import termios + termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, self.old_settings) + stash_stdin_termios_from_stdin() + except Exception as e: + logging.warning(f"Failed to restore terminal: {e}") + self.old_settings = None + + def read(self, timeout: Optional[float] = None) -> Optional[bytes]: + """ + Read available data from stdin. + + Args: + timeout: Read timeout in seconds (None = blocking) + + Returns: + Bytes data or None if timeout/no data + """ + import select + + if timeout: + ready, _, _ = select.select([sys.stdin], [], [], timeout) + if not ready: + return None + + try: + # Read what's available (up to 4KB) + data = sys.stdin.buffer.read1(4096) + return data if data else None + except Exception: + return None + + +class _MacOSStdinReader: + """ + macOS stdin reader with raw mode support. + + macOS uses the same POSIX termios approach as Linux, but may have + slight differences in terminal handling. This class provides + macOS-specific optimizations if needed. + """ + + def __init__(self): + self.old_settings = None + + def set_raw_mode(self): + """Set terminal to raw mode (non-buffered, non-echoing).""" + try: + import termios + import tty + import time + + # Flush stdout before changing terminal attributes to ensure all output is complete + sys.stdout.flush() + sys.stderr.flush() + + self.old_settings = termios.tcgetattr(sys.stdin.fileno()) + # Use setraw for macOS - same as Linux + tty.setraw(sys.stdin.fileno()) + + # Small delay to allow terminal to process the attribute change + # This helps prevent visual glitches where lines appear to be deleted + time.sleep(0.01) # 10ms delay + + # Flush again after setting raw mode + sys.stdout.flush() + sys.stderr.flush() + except Exception as e: + logging.warning(f"Failed to set raw mode on macOS: {e}") + + def restore(self): + """Restore terminal to normal mode.""" + if self.old_settings: + try: + import termios + termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, self.old_settings) + stash_stdin_termios_from_stdin() + except Exception as e: + logging.warning(f"Failed to restore terminal on macOS: {e}") + self.old_settings = None + + def read(self, timeout: Optional[float] = None) -> Optional[bytes]: + """ + Read available data from stdin on macOS. + + Args: + timeout: Read timeout in seconds (None = blocking) + + Returns: + Bytes data or None if timeout/no data + """ + import select + + if timeout: + # Use select for non-blocking check + ready, _, _ = select.select([sys.stdin], [], [], timeout) + if not ready: + return None + + try: + # Read what's available (up to 4KB) + # Note: On macOS, read1() may not be available on all Python versions, + # so we use os.read() as fallback + import os + fd = sys.stdin.fileno() + data = os.read(fd, 4096) + return data if data else None + except Exception: + return None + + +class _WindowsStdinReader: + """Windows stdin reader using msvcrt for console input.""" + + def __init__(self): + self._win_saved_console_mode: Optional[int] = None + + def set_raw_mode(self): + """Set console to raw mode on Windows.""" + try: + import time + + # Flush stdout before changing console mode to ensure all output is complete + sys.stdout.flush() + sys.stderr.flush() + + # Small delay to allow console to process any pending output + time.sleep(0.01) # 10ms delay + + sys.stdout.flush() + sys.stderr.flush() + # Ctrl+C as input (not SIGINT) so CtrlCCoordinator can handle double-tap. + self._win_saved_console_mode = win_stdin_disable_ctrl_c_process_input() + except Exception as e: + logging.warning(f"Failed to set raw mode on Windows: {e}") + + def restore(self): + win_stdin_restore_console_mode(self._win_saved_console_mode) + self._win_saved_console_mode = None + + def read(self, timeout: Optional[float] = None) -> Optional[bytes]: + """ + Read available data from stdin on Windows. + + Args: + timeout: Read timeout in seconds (None = blocking) + + Returns: + Bytes data or None if timeout/no data + """ + try: + import msvcrt + import time + + result = b'' + start = time.time() + + # Collect all available characters + while True: + if timeout and (time.time() - start >= timeout): + break + + if msvcrt.kbhit(): + ch = msvcrt.getch() + result += ch + # Continue collecting if more chars available immediately + continue + elif result: + # Have data, return it + break + else: + # No data yet, brief sleep to avoid busy-wait + time.sleep(0.01) + + return result if result else None + + except Exception as e: + logging.error(f"Error reading from stdin on Windows: {e}") + return None diff --git a/keepercli-package/src/keepercli/commands/pam/pam_launch/guac_cli/win_console_input.py b/keepercli-package/src/keepercli/commands/pam/pam_launch/guac_cli/win_console_input.py new file mode 100644 index 00000000..d2925708 --- /dev/null +++ b/keepercli-package/src/keepercli/commands/pam/pam_launch/guac_cli/win_console_input.py @@ -0,0 +1,97 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' Optional[int]: + """ + Set stdin console handle to raw mode for ReadConsoleInputW: + - Clear ENABLE_PROCESSED_INPUT so Ctrl+C is read as 0x03, not SIGINT. + - Clear ENABLE_LINE_INPUT + ENABLE_ECHO_INPUT to suppress the console + host's visual echo, preventing duplicate characters when the remote + SSH session also echoes typed input. + + Returns the previous mode for win_stdin_restore_console_mode, or None if not + Windows, not a console, or the API failed. + """ + if sys.platform != 'win32': + return None + try: + import ctypes + from ctypes import wintypes + + kernel32 = ctypes.windll.kernel32 + h = kernel32.GetStdHandle(_STD_INPUT_HANDLE) + mode = wintypes.DWORD() + if not kernel32.GetConsoleMode(h, ctypes.byref(mode)): + return None + old = int(mode.value) + new = old & ~_RAW_MODE_CLEAR + if new == old: + return old + if not kernel32.SetConsoleMode(h, new): + logging.debug('SetConsoleMode(raw mode) failed') + return None + return old + except Exception as exc: + logging.debug('win_stdin_disable_ctrl_c_process_input: %s', exc) + return None + + +def win_stdin_restore_console_mode(old_mode: Optional[int]) -> None: + """Restore stdin console mode from win_stdin_disable_ctrl_c_process_input.""" + if old_mode is None or sys.platform != 'win32': + return + try: + import ctypes + from ctypes import wintypes + + kernel32 = ctypes.windll.kernel32 + h = kernel32.GetStdHandle(_STD_INPUT_HANDLE) + if not kernel32.SetConsoleMode(h, old_mode): + logging.debug('SetConsoleMode(restore) failed') + except Exception as exc: + logging.debug('win_stdin_restore_console_mode: %s', exc) diff --git a/keepercli-package/src/keepercli/commands/pam/pam_launch/guacamole/__init__.py b/keepercli-package/src/keepercli/commands/pam/pam_launch/guacamole/__init__.py new file mode 100644 index 00000000..c55ffb66 --- /dev/null +++ b/keepercli-package/src/keepercli/commands/pam/pam_launch/guacamole/__init__.py @@ -0,0 +1,85 @@ +""" +Guacamole Protocol Library for Python. + +A reusable implementation of the Apache Guacamole protocol, ported from +guacamole-common-js. This library provides protocol parsing, event handling, +and client functionality for building Guacamole-based applications. + +Example usage: + from guacamole import Parser, Client, Status + + # Parse incoming instructions + parser = Parser() + parser.oninstruction = lambda opcode, args: print(f"{opcode}: {args}") + parser.receive("4.sync,10.1234567890;") + + # Create instruction strings + instruction = Parser.to_instruction(["key", "65", "1"]) + + # Build a client with a tunnel + class MyTunnel(Tunnel): + # ... implementation + pass + + client = Client(my_tunnel) + client.onstatechange = lambda state: print(f"State: {state}") + client.connect() +""" + +# Exceptions +from .exceptions import ( + GuacamoleError, + InvalidInstructionError, + ProtocolError, + TunnelError, + ClientError, +) + +# Parser +from .parser import Parser, code_point_count, to_instruction + +# Event system +from .event import Event, EventTarget + +# Status +from .status import Status, StatusCode + +# Integer pool +from .integer_pool import IntegerPool + +# Tunnel +from .tunnel import Tunnel, TunnelState + +# Client +from .client import Client, ClientState, ClientMessage, InputStream, OutputStream + + +__all__ = [ + # Exceptions + "GuacamoleError", + "InvalidInstructionError", + "ProtocolError", + "TunnelError", + "ClientError", + # Parser + "Parser", + "code_point_count", + "to_instruction", + # Event system + "Event", + "EventTarget", + # Status + "Status", + "StatusCode", + # Integer pool + "IntegerPool", + # Tunnel + "Tunnel", + "TunnelState", + # Client + "Client", + "ClientState", + "ClientMessage", + "InputStream", + "OutputStream", +] diff --git a/keepercli-package/src/keepercli/commands/pam/pam_launch/guacamole/client.py b/keepercli-package/src/keepercli/commands/pam/pam_launch/guacamole/client.py new file mode 100644 index 00000000..62aa60b6 --- /dev/null +++ b/keepercli-package/src/keepercli/commands/pam/pam_launch/guacamole/client.py @@ -0,0 +1,620 @@ +""" +Guacamole protocol client. + +This module provides the Client class for handling Guacamole protocol +communication. This is a terminal-focused implementation that handles +the instruction routing and state management needed for SSH/RDP/VNC +terminal sessions. + +Note: GUI-related handlers (png, jpeg, img, rect, cfill, copy, move, cursor, +video, audio, etc.) are not implemented in this port as they require a +graphical display layer. This implementation focuses on: +- Connection state management +- Keep-alive/sync handling +- Keyboard and mouse input +- Clipboard support +- Stream management +- Error handling +""" + +import time +from enum import IntEnum +from typing import Any, Callable, Dict, List, Optional + +from .integer_pool import IntegerPool +from .parser import Parser +from .status import Status, StatusCode +from .tunnel import Tunnel, TunnelState + + +class ClientState(IntEnum): + """ + All possible Guacamole client states. + + Attributes: + IDLE: The client is idle, with no active connection. + CONNECTING: The client is in the process of establishing a connection. + WAITING: The client is waiting on further information or a remote + server to establish the connection. + CONNECTED: The client is actively connected to a remote server. + DISCONNECTING: The client is in the process of disconnecting. + DISCONNECTED: The client has completed disconnection. + """ + IDLE = 0 + CONNECTING = 1 + WAITING = 2 + CONNECTED = 3 + DISCONNECTING = 4 + DISCONNECTED = 5 + + +class ClientMessage(IntEnum): + """ + Possible messages that can be sent by the server. + + Attributes: + USER_JOINED: A user has joined the connection. + USER_LEFT: A user has left the connection. + """ + USER_JOINED = 0x0001 + USER_LEFT = 0x0002 + + +class InputStream: + """ + Guacamole input stream for receiving data from the server. + + Attributes: + client: The client that owns this stream. + index: The index of this stream. + onblob: Callback for received blob data. + onend: Callback when stream ends. + """ + + def __init__(self, client: 'Client', index: int): + """ + Initialize a new InputStream. + + Args: + client: The client that owns this stream. + index: The index of this stream. + """ + self.client = client + self.index = index + self.onblob: Optional[Callable[[str], None]] = None + self.onend: Optional[Callable[[], None]] = None + + +class OutputStream: + """ + Guacamole output stream for sending data to the server. + + Attributes: + client: The client that owns this stream. + index: The index of this stream. + onack: Callback for acknowledgement of sent data. + """ + + def __init__(self, client: 'Client', index: int): + """ + Initialize a new OutputStream. + + Args: + client: The client that owns this stream. + index: The index of this stream. + """ + self.client = client + self.index = index + self.onack: Optional[Callable[[Status], None]] = None + + +class Client: + """ + Guacamole protocol client for terminal-focused applications. + + Given a Tunnel, automatically handles incoming and outgoing Guacamole + instructions via the provided tunnel. This implementation focuses on + terminal operations (keyboard, mouse, clipboard) rather than graphical + display rendering. + + Attributes: + tunnel: The tunnel used for communication. + State: Alias for ClientState enum. + Message: Alias for ClientMessage enum. + + Callbacks: + onstatechange: Called when client state changes. + onerror: Called when an error occurs. + onname: Called when connection name is received. + onsync: Called when sync instruction is received. + onclipboard: Called when clipboard data is available. + onfile: Called when a file transfer starts. + onpipe: Called when a named pipe is created. + onargv: Called when argument value is received. + onrequired: Called when additional parameters are required. + onjoin: Called when a user joins. + onleave: Called when a user leaves. + onmsg: Called for general messages. + + Example: + client = Client(tunnel) + client.onstatechange = lambda state: print(f"State: {state}") + client.connect("hostname=example.com") + """ + + # Expose enums as class attributes + State = ClientState + Message = ClientMessage + + # Keep-alive ping frequency in milliseconds + KEEP_ALIVE_FREQUENCY = 5000 + + def __init__(self, tunnel: Tunnel): + """ + Initialize a new Client. + + Args: + tunnel: The tunnel to use for communication. + """ + self.tunnel = tunnel + self._state = ClientState.IDLE + self._current_timestamp = 0 + self._last_sent_keepalive = 0 + self._keepalive_timeout: Optional[float] = None + + # Stream management + self._stream_indices = IntegerPool() + self._streams: Dict[int, InputStream] = {} + self._output_streams: Dict[int, OutputStream] = {} + + # Callbacks + self.onstatechange: Optional[Callable[[ClientState], None]] = None + self.onerror: Optional[Callable[[Status], None]] = None + self.onname: Optional[Callable[[str], None]] = None + self.onsync: Optional[Callable[[int, int], None]] = None + self.onclipboard: Optional[Callable[[InputStream, str], None]] = None + self.onfile: Optional[Callable[[InputStream, str, str], None]] = None + self.onpipe: Optional[Callable[[InputStream, str, str], None]] = None + self.onargv: Optional[Callable[[InputStream, str, str], None]] = None + self.onrequired: Optional[Callable[[List[str]], None]] = None + self.onjoin: Optional[Callable[[str, str], None]] = None + self.onleave: Optional[Callable[[str, str], None]] = None + self.onmsg: Optional[Callable[[int, List[str]], Optional[bool]]] = None + + # Set up instruction handlers + self._instruction_handlers: Dict[str, Callable[[List[str]], None]] = { + 'ack': self._handle_ack, + 'argv': self._handle_argv, + 'blob': self._handle_blob, + 'clipboard': self._handle_clipboard, + 'disconnect': self._handle_disconnect, + 'end': self._handle_end, + 'error': self._handle_error, + 'file': self._handle_file, + 'msg': self._handle_msg, + 'name': self._handle_name, + 'nop': self._handle_nop, + 'pipe': self._handle_pipe, + 'required': self._handle_required, + 'sync': self._handle_sync, + } + + # Wire up tunnel instruction handler + tunnel.oninstruction = self._on_instruction + + @property + def state(self) -> ClientState: + """Get the current client state.""" + return self._state + + def _set_state(self, state: ClientState) -> None: + """ + Set the client state, firing onstatechange if changed. + + Args: + state: The new client state. + """ + if state != self._state: + self._state = state + if self.onstatechange: + self.onstatechange(state) + + def _is_connected(self) -> bool: + """Return whether the client is connected or waiting.""" + return self._state in (ClientState.CONNECTED, ClientState.WAITING) + + def _on_instruction(self, opcode: str, parameters: List[str]) -> None: + """ + Handle a received instruction. + + Args: + opcode: The instruction opcode. + parameters: The instruction parameters. + """ + handler = self._instruction_handlers.get(opcode) + if handler: + handler(parameters) + + # Schedule next keep-alive on any network activity + self._schedule_keepalive() + + # ========================================================================== + # Instruction Handlers + # ========================================================================== + + def _handle_ack(self, parameters: List[str]) -> None: + """Handle ack instruction.""" + stream_index = int(parameters[0]) + reason = parameters[1] + code = int(parameters[2]) + + stream = self._output_streams.get(stream_index) + if stream: + if stream.onack: + stream.onack(Status(code, reason)) + + # If error code, invalidate stream + if code >= 0x0100 and self._output_streams.get(stream_index) is stream: + self._stream_indices.free(stream_index) + del self._output_streams[stream_index] + + def _handle_argv(self, parameters: List[str]) -> None: + """Handle argv instruction (argument value stream).""" + stream_index = int(parameters[0]) + mimetype = parameters[1] + name = parameters[2] + + if self.onargv: + stream = InputStream(self, stream_index) + self._streams[stream_index] = stream + self.onargv(stream, mimetype, name) + else: + self.send_ack(stream_index, "Receiving argument values unsupported", 0x0100) + + def _handle_blob(self, parameters: List[str]) -> None: + """Handle blob instruction (stream data).""" + stream_index = int(parameters[0]) + data = parameters[1] + + stream = self._streams.get(stream_index) + if stream and stream.onblob: + stream.onblob(data) + + def _handle_clipboard(self, parameters: List[str]) -> None: + """Handle clipboard instruction.""" + stream_index = int(parameters[0]) + mimetype = parameters[1] + + if self.onclipboard: + stream = InputStream(self, stream_index) + self._streams[stream_index] = stream + self.onclipboard(stream, mimetype) + else: + self.send_ack(stream_index, "Clipboard unsupported", 0x0100) + + def _handle_disconnect(self, parameters: List[str]) -> None: + """Handle disconnect instruction.""" + self.disconnect() + + def _handle_end(self, parameters: List[str]) -> None: + """Handle end instruction (stream end).""" + stream_index = int(parameters[0]) + + stream = self._streams.get(stream_index) + if stream: + if stream.onend: + stream.onend() + del self._streams[stream_index] + + def _handle_error(self, parameters: List[str]) -> None: + """Handle error instruction.""" + reason = parameters[0] + code = int(parameters[1]) + + if self.onerror: + self.onerror(Status(code, reason)) + + self.disconnect() + + def _handle_file(self, parameters: List[str]) -> None: + """Handle file instruction (file transfer).""" + stream_index = int(parameters[0]) + mimetype = parameters[1] + filename = parameters[2] + + if self.onfile: + stream = InputStream(self, stream_index) + self._streams[stream_index] = stream + self.onfile(stream, mimetype, filename) + else: + self.send_ack(stream_index, "File transfer unsupported", 0x0100) + + def _handle_msg(self, parameters: List[str]) -> None: + """Handle msg instruction (general message).""" + msgid = int(parameters[0]) + + # Fire general message handler first + allow_default = True + if self.onmsg: + result = self.onmsg(msgid, parameters[1:]) + if result is not None: + allow_default = result + + # Fire specific convenience events if allowed + if allow_default: + if msgid == ClientMessage.USER_JOINED: + user_id = parameters[1] + username = parameters[2] + if self.onjoin: + self.onjoin(user_id, username) + elif msgid == ClientMessage.USER_LEFT: + user_id = parameters[1] + username = parameters[2] + if self.onleave: + self.onleave(user_id, username) + + def _handle_name(self, parameters: List[str]) -> None: + """Handle name instruction (connection name).""" + if self.onname: + self.onname(parameters[0]) + + def _handle_nop(self, parameters: List[str]) -> None: + """Handle nop instruction (no operation / keep-alive).""" + # No operation needed - just confirms connection is alive + pass + + def _handle_pipe(self, parameters: List[str]) -> None: + """Handle pipe instruction (named pipe).""" + stream_index = int(parameters[0]) + mimetype = parameters[1] + name = parameters[2] + + if self.onpipe: + stream = InputStream(self, stream_index) + self._streams[stream_index] = stream + self.onpipe(stream, mimetype, name) + else: + self.send_ack(stream_index, "Named pipes unsupported", 0x0100) + + def _handle_required(self, parameters: List[str]) -> None: + """Handle required instruction (additional parameters needed).""" + if self.onrequired: + self.onrequired(parameters) + + def _handle_sync(self, parameters: List[str]) -> None: + """Handle sync instruction.""" + timestamp = int(parameters[0]) + frames = int(parameters[1]) if len(parameters) > 1 else 0 + + # Send sync response + if timestamp != self._current_timestamp: + self.tunnel.send_message("sync", timestamp) + self._current_timestamp = timestamp + + # Transition from WAITING to CONNECTED on first sync + if self._state == ClientState.WAITING: + self._set_state(ClientState.CONNECTED) + + # Fire callback + if self.onsync: + self.onsync(timestamp, frames) + + # ========================================================================== + # Keep-alive Management + # ========================================================================== + + def _send_keepalive(self) -> None: + """Send a keep-alive nop instruction.""" + self.tunnel.send_message('nop') + self._last_sent_keepalive = time.time() * 1000 + + def _schedule_keepalive(self) -> None: + """Schedule the next keep-alive ping.""" + current_time = time.time() * 1000 + keepalive_delay = max( + self._last_sent_keepalive + self.KEEP_ALIVE_FREQUENCY - current_time, + 0 + ) + + if keepalive_delay <= 0: + self._send_keepalive() + else: + # In async environments, this would schedule a timeout + # For sync usage, keep-alive is sent on next network activity + self._keepalive_timeout = current_time + keepalive_delay + + def _stop_keepalive(self) -> None: + """Stop sending keep-alive pings.""" + self._keepalive_timeout = None + + # ========================================================================== + # Public API - Sending + # ========================================================================== + + def send_key_event(self, pressed: bool, keysym: int) -> None: + """ + Send a key event to the server. + + Args: + pressed: True if key is pressed, False if released. + keysym: The X11 keysym of the key. + """ + if not self._is_connected(): + return + self.tunnel.send_message("key", keysym, 1 if pressed else 0) + + def send_mouse_state(self, x: int, y: int, button_mask: int) -> None: + """ + Send a mouse state to the server. + + Args: + x: X coordinate of the mouse. + y: Y coordinate of the mouse. + button_mask: Bitmask of pressed buttons (1=left, 2=middle, 4=right, + 8=scroll-up, 16=scroll-down). + """ + if not self._is_connected(): + return + self.tunnel.send_message("mouse", x, y, button_mask) + + def send_size(self, width: int, height: int, dpi: Optional[int] = None) -> None: + """ + Send the current screen size to the server. + + Args: + width: Screen width in pixels. + height: Screen height in pixels. + dpi: Optional display DPI; when set, sent as a third ``size`` argument + (same shape as the Guacamole handshake ``size`` instruction). + """ + if not self._is_connected(): + return + if dpi is None: + self.tunnel.send_message("size", width, height) + else: + self.tunnel.send_message("size", width, height, dpi) + + def send_ack(self, stream_index: int, message: str, code: int) -> None: + """ + Acknowledge receipt of data on a stream. + + Args: + stream_index: The index of the stream. + message: Human-readable status message. + code: Status code (0 for success). + """ + if not self._is_connected(): + return + self.tunnel.send_message("ack", stream_index, message, code) + + def send_blob(self, stream_index: int, data: str) -> None: + """ + Send blob data on a stream. + + Args: + stream_index: The index of the stream. + data: Base64-encoded data to send. + """ + if not self._is_connected(): + return + self.tunnel.send_message("blob", stream_index, data) + + def end_stream(self, stream_index: int) -> None: + """ + Mark a stream as complete. + + Args: + stream_index: The index of the stream to end. + """ + if not self._is_connected(): + return + + self.tunnel.send_message("end", stream_index) + + # Free stream index + if stream_index in self._output_streams: + self._stream_indices.free(stream_index) + del self._output_streams[stream_index] + + # ========================================================================== + # Public API - Stream Management + # ========================================================================== + + def create_output_stream(self) -> OutputStream: + """ + Create a new output stream. + + Returns: + A new OutputStream with an allocated index. + """ + index = self._stream_indices.next() + stream = OutputStream(self, index) + self._output_streams[index] = stream + return stream + + def create_clipboard_stream(self, mimetype: str) -> OutputStream: + """ + Create a clipboard stream for sending clipboard data. + + Args: + mimetype: The mimetype of the clipboard data. + + Returns: + An output stream for sending clipboard data. + """ + stream = self.create_output_stream() + self.tunnel.send_message("clipboard", stream.index, mimetype) + return stream + + def create_file_stream(self, mimetype: str, filename: str) -> OutputStream: + """ + Create a file stream for sending a file. + + Args: + mimetype: The mimetype of the file. + filename: The name of the file. + + Returns: + An output stream for sending file data. + """ + stream = self.create_output_stream() + self.tunnel.send_message("file", stream.index, mimetype, filename) + return stream + + def create_pipe_stream(self, mimetype: str, name: str) -> OutputStream: + """ + Create a named pipe stream. + + Args: + mimetype: The mimetype of the data. + name: The name of the pipe. + + Returns: + An output stream for the pipe. + """ + stream = self.create_output_stream() + self.tunnel.send_message("pipe", stream.index, mimetype, name) + return stream + + # ========================================================================== + # Public API - Connection Management + # ========================================================================== + + def connect(self, data: Optional[str] = None) -> None: + """ + Connect to the Guacamole server. + + Args: + data: Arbitrary connection data to send during handshake. + + Raises: + Status: If an error occurs during connection. + """ + self._set_state(ClientState.CONNECTING) + + try: + self.tunnel.connect(data) + except Exception as e: + self._set_state(ClientState.IDLE) + raise + + # Start keep-alive pings + self._schedule_keepalive() + + self._set_state(ClientState.WAITING) + + def disconnect(self) -> None: + """Disconnect from the Guacamole server.""" + if self._state in (ClientState.DISCONNECTED, ClientState.DISCONNECTING): + return + + self._set_state(ClientState.DISCONNECTING) + + # Stop keep-alive + self._stop_keepalive() + + # Send disconnect and close tunnel + self.tunnel.send_message("disconnect") + self.tunnel.disconnect() + + self._set_state(ClientState.DISCONNECTED) diff --git a/keepercli-package/src/keepercli/commands/pam/pam_launch/guacamole/event.py b/keepercli-package/src/keepercli/commands/pam/pam_launch/guacamole/event.py new file mode 100644 index 00000000..b5509d37 --- /dev/null +++ b/keepercli-package/src/keepercli/commands/pam/pam_launch/guacamole/event.py @@ -0,0 +1,179 @@ +""" +Guacamole event system. + +This module provides the Event and EventTarget classes for implementing +event-driven architecture in Guacamole applications. +""" + +import time +from typing import Any, Callable, Dict, List, Optional + + +class Event: + """ + An arbitrary event that can be dispatched by an EventTarget. + + This class serves as the base for more specific event types. Each event + has a type name and timestamp. + + Attributes: + type: The unique name of this event type. + timestamp: Timestamp in seconds when this event was created. + + Example: + event = Event("connection_state_changed") + print(f"Event type: {event.type}") + print(f"Age: {event.get_age()} seconds") + """ + + def __init__(self, event_type: str): + """ + Initialize a new Event. + + Args: + event_type: The unique name of this event type. + """ + self.type: str = event_type + self.timestamp: float = time.time() + + def get_age(self) -> float: + """ + Return the number of seconds elapsed since this event was created. + + Returns: + The age of this event in seconds. + """ + return time.time() - self.timestamp + + def invoke_legacy_handler(self, target: 'EventTarget') -> None: + """ + Invoke the legacy event handler associated with this event. + + This method is called automatically by EventTarget.dispatch() and + provides backward compatibility with single-handler patterns like + "onmousedown" or "onkeyup". + + Subclasses should override this method to invoke the appropriate + legacy handler on the target. + + Args: + target: The EventTarget that emitted this event. + """ + # Default implementation does nothing + pass + + +class EventTarget: + """ + An object that can dispatch Event objects to registered listeners. + + Listeners registered with on() are automatically invoked based on the + event type when dispatch() is called. This class is typically subclassed + by objects that need to emit events. + + Example: + target = EventTarget() + + def on_state_change(event, source): + print(f"State changed: {event.type}") + + target.on("state_change", on_state_change) + target.dispatch(Event("state_change")) + """ + + # Type alias for listener callbacks + Listener = Callable[['Event', 'EventTarget'], None] + + def __init__(self): + """Initialize a new EventTarget.""" + self._listeners: Dict[str, List[EventTarget.Listener]] = {} + + def on(self, event_type: str, listener: 'EventTarget.Listener') -> None: + """ + Register a listener for events of the given type. + + Args: + event_type: The unique name of the event type to listen for. + listener: The callback function to invoke when an event of this + type is dispatched. The function receives the Event object + and the dispatching EventTarget. + """ + if event_type not in self._listeners: + self._listeners[event_type] = [] + self._listeners[event_type].append(listener) + + def on_each(self, types: List[str], listener: 'EventTarget.Listener') -> None: + """ + Register a listener for multiple event types. + + This is equivalent to calling on() for each type in the list. + + Args: + types: List of event type names to listen for. + listener: The callback function to invoke for any of these events. + """ + for event_type in types: + self.on(event_type, listener) + + def off(self, event_type: str, listener: 'EventTarget.Listener') -> bool: + """ + Unregister a previously registered listener. + + If the same listener was registered multiple times, only the first + occurrence is removed. + + Args: + event_type: The event type the listener was registered for. + listener: The listener function to remove. + + Returns: + True if the listener was found and removed, False otherwise. + """ + if event_type not in self._listeners: + return False + + listeners = self._listeners[event_type] + for i, registered_listener in enumerate(listeners): + if registered_listener is listener: + listeners.pop(i) + return True + + return False + + def off_each(self, types: List[str], listener: 'EventTarget.Listener') -> bool: + """ + Unregister a listener from multiple event types. + + This is equivalent to calling off() for each type in the list. + + Args: + types: List of event type names to unregister from. + listener: The listener function to remove. + + Returns: + True if the listener was removed from at least one event type. + """ + changed = False + for event_type in types: + if self.off(event_type, listener): + changed = True + return changed + + def dispatch(self, event: Event) -> None: + """ + Dispatch an event to all registered listeners. + + First invokes the legacy handler (if the event supports it), then + invokes all listeners registered for this event type. + + Args: + event: The event to dispatch. + """ + # Invoke legacy handler for backward compatibility + event.invoke_legacy_handler(self) + + # Invoke all registered listeners + listeners = self._listeners.get(event.type) + if listeners: + for listener in listeners: + listener(event, self) diff --git a/keepercli-package/src/keepercli/commands/pam/pam_launch/guacamole/exceptions.py b/keepercli-package/src/keepercli/commands/pam/pam_launch/guacamole/exceptions.py new file mode 100644 index 00000000..a1da065a --- /dev/null +++ b/keepercli-package/src/keepercli/commands/pam/pam_launch/guacamole/exceptions.py @@ -0,0 +1,85 @@ +""" +Custom exceptions for the Guacamole protocol library. + +This module defines the exception hierarchy for all Guacamole-related errors. +""" + +from typing import Optional + + +class GuacamoleError(Exception): + """Base exception for all Guacamole-related errors.""" + + def __init__(self, message: str, code: Optional[int] = None): + """ + Initialize a GuacamoleError. + + Args: + message: Human-readable error description. + code: Optional Guacamole status code. + """ + super().__init__(message) + self.message = message + self.code = code + + def __str__(self) -> str: + if self.code is not None: + return f"[{self.code}] {self.message}" + return self.message + + +class InvalidInstructionError(GuacamoleError): + """Raised when a malformed Guacamole instruction is encountered.""" + + def __init__(self, message: str, instruction: Optional[str] = None): + """ + Initialize an InvalidInstructionError. + + Args: + message: Description of why the instruction is invalid. + instruction: The malformed instruction data, if available. + """ + super().__init__(message) + self.instruction = instruction + + +class ProtocolError(GuacamoleError): + """Raised when a protocol-level error occurs.""" + + def __init__(self, message: str, code: Optional[int] = None): + """ + Initialize a ProtocolError. + + Args: + message: Description of the protocol error. + code: Optional Guacamole status code. + """ + super().__init__(message, code) + + +class TunnelError(GuacamoleError): + """Raised when a tunnel-related error occurs.""" + + def __init__(self, message: str, code: Optional[int] = None): + """ + Initialize a TunnelError. + + Args: + message: Description of the tunnel error. + code: Optional Guacamole status code. + """ + super().__init__(message, code) + + +class ClientError(GuacamoleError): + """Raised when a client-related error occurs.""" + + def __init__(self, message: str, code: Optional[int] = None): + """ + Initialize a ClientError. + + Args: + message: Description of the client error. + code: Optional Guacamole status code. + """ + super().__init__(message, code) diff --git a/keepercli-package/src/keepercli/commands/pam/pam_launch/guacamole/integer_pool.py b/keepercli-package/src/keepercli/commands/pam/pam_launch/guacamole/integer_pool.py new file mode 100644 index 00000000..ca0afca4 --- /dev/null +++ b/keepercli-package/src/keepercli/commands/pam/pam_launch/guacamole/integer_pool.py @@ -0,0 +1,71 @@ +""" +Integer pool for reusing stream indices. + +This module provides the IntegerPool class for efficiently managing +integer indices that can be acquired and released. +""" + +from typing import List + + +class IntegerPool: + """ + Integer pool that returns consistently increasing integers while in use, + and previously-used integers when possible. + + This is used by the Guacamole client for managing stream indices, + allowing freed indices to be reused to conserve the index space. + + Example: + pool = IntegerPool() + idx1 = pool.next() # Returns 0 + idx2 = pool.next() # Returns 1 + pool.free(idx1) # Release index 0 + idx3 = pool.next() # Returns 0 (reused) + idx4 = pool.next() # Returns 2 + + Attributes: + next_int: The next integer to return if no freed integers are available. + """ + + def __init__(self): + """Initialize a new IntegerPool.""" + self._pool: List[int] = [] + self.next_int: int = 0 + + def next(self) -> int: + """ + Return the next available integer from the pool. + + If previously freed integers exist, one of those is returned. + Otherwise, a new integer is allocated and returned. + + Returns: + The next available integer. + """ + if self._pool: + return self._pool.pop(0) + result = self.next_int + self.next_int += 1 + return result + + def free(self, integer: int) -> None: + """ + Free the given integer, allowing it to be reused. + + Args: + integer: The integer to free. + """ + self._pool.append(integer) + + def __contains__(self, integer: int) -> bool: + """ + Check if an integer is currently in the free pool. + + Args: + integer: The integer to check. + + Returns: + True if the integer is in the free pool, False otherwise. + """ + return integer in self._pool diff --git a/keepercli-package/src/keepercli/commands/pam/pam_launch/guacamole/parser.py b/keepercli-package/src/keepercli/commands/pam/pam_launch/guacamole/parser.py new file mode 100644 index 00000000..e785eeb8 --- /dev/null +++ b/keepercli-package/src/keepercli/commands/pam/pam_launch/guacamole/parser.py @@ -0,0 +1,313 @@ +""" +Guacamole protocol parser. + +This module provides the Parser class for parsing Guacamole protocol instructions +from incoming data streams. It handles the length-prefixed element format and +properly counts Unicode codepoints. + +The Guacamole protocol uses instructions in the format: + LENGTH.VALUE,LENGTH.VALUE,...,LENGTH.VALUE; + +Where LENGTH is the number of Unicode codepoints in VALUE. +""" + +import re +from typing import Any, Callable, List, Optional + +from .exceptions import InvalidInstructionError + + +# Regex pattern for detecting UTF-16 surrogate pairs +# In Python strings (which are UTF-32 internally), surrogate pairs appear +# as two separate characters when the string came from UTF-16 encoding +_SURROGATE_PAIR_PATTERN = re.compile(r'[\uD800-\uDBFF][\uDC00-\uDFFF]') + +# Minimum codepoint that requires a surrogate pair in UTF-16 +_MIN_CODEPOINT_REQUIRES_SURROGATE = 0x10000 + +# Range checks for surrogates +_HIGH_SURROGATE_MIN = 0xD800 +_HIGH_SURROGATE_MAX = 0xDBFF +_LOW_SURROGATE_MIN = 0xDC00 +_LOW_SURROGATE_MAX = 0xDFFF + + +def _is_high_surrogate(char_code: int) -> bool: + """Check if a character code is a high surrogate.""" + return _HIGH_SURROGATE_MIN <= char_code <= _HIGH_SURROGATE_MAX + + +def _is_low_surrogate(char_code: int) -> bool: + """Check if a character code is a low surrogate.""" + return _LOW_SURROGATE_MIN <= char_code <= _LOW_SURROGATE_MAX + + +class Parser: + """ + Simple Guacamole protocol parser that invokes an oninstruction callback when + full instructions are available from data received via receive(). + + The parser handles the Guacamole wire protocol format where each element + is prefixed with its length in Unicode codepoints, followed by a period, + then the element value, and finally a terminator (',' for more elements + or ';' for end of instruction). + + Example: + parser = Parser() + parser.oninstruction = lambda opcode, args: print(f"{opcode}: {args}") + parser.receive("4.sync,10.1234567890;") + # Output: sync: ['1234567890'] + + Attributes: + oninstruction: Callback invoked when a complete instruction is parsed. + Signature: (opcode: str, parameters: List[str]) -> None + """ + + # Number of parsed characters before truncating the buffer to conserve memory + BUFFER_TRUNCATION_THRESHOLD = 4096 + + def __init__(self): + """Initialize a new Parser instance.""" + # Current buffer of received data + self._buffer: str = '' + + # Buffer of all received, complete elements for current instruction + self._element_buffer: List[str] = [] + + # Character offset of current element's terminator (-1 if not yet known) + self._element_end: int = -1 + + # Character offset where parser should start looking for next element + self._start_index: int = 0 + + # Declared length of current element in Unicode codepoints + self._element_codepoints: int = 0 + + # Callback for completed instructions + self.oninstruction: Optional[Callable[[str, List[str]], None]] = None + + def receive(self, packet: str, is_buffer: bool = False) -> None: + """ + Append instruction data to the internal buffer and execute all + completed instructions at the beginning of the buffer. + + Args: + packet: The instruction data to receive. + is_buffer: If True, the packet is treated as an external buffer + that grows continuously. The packet MUST always start with + the data provided to the previous call. If False (default), + only new data should be provided and previously-received + data will be buffered automatically. + + Raises: + InvalidInstructionError: If a malformed instruction is encountered. + """ + if is_buffer: + self._buffer = packet + else: + # Truncate buffer as necessary to conserve memory + if (self._start_index > self.BUFFER_TRUNCATION_THRESHOLD and + self._element_end >= self._start_index): + self._buffer = self._buffer[self._start_index:] + # Reset parse positions relative to truncation + self._element_end -= self._start_index + self._start_index = 0 + + # Append data to buffer only if there is outstanding data. + # Otherwise, parse the received buffer as-is for efficiency. + if self._buffer: + self._buffer += packet + else: + self._buffer = packet + + # Parse while search is within currently received data + while self._element_end < len(self._buffer): + + # If we are waiting for element data + if self._element_end >= self._start_index: + + # Count codepoints in the expected element substring + codepoints = code_point_count( + self._buffer, + self._start_index, + self._element_end + ) + + # If we don't have enough codepoints yet, adjust element_end + # This handles characters that are represented as surrogate pairs + if codepoints < self._element_codepoints: + self._element_end += self._element_codepoints - codepoints + continue + + # If element_end points to a character that's part of a surrogate pair, + # we need to adjust. Two cases: + # 1. element_end-1 is HIGH surrogate and element_end is LOW surrogate + # (we're about to split a pair, need to include the LOW) + # 2. element_end-1 is >= 0x10000 (combined supplementary char in Python, + # though this is rare since Python usually keeps surrogates separate) + if (self._element_codepoints and + self._element_end > 0 and + self._element_end < len(self._buffer)): + last_char_index = self._element_end - 1 + if last_char_index >= self._start_index: + last_char_code = ord(self._buffer[last_char_index]) + term_char_code = ord(self._buffer[self._element_end]) + + # Case 1: Last char is HIGH surrogate, terminator pos is LOW surrogate + # This means we're about to cut a surrogate pair in half + if _is_high_surrogate(last_char_code) and _is_low_surrogate(term_char_code): + self._element_end += 1 + continue + + # Case 2: Character >= 0x10000 (combined supplementary char) + if last_char_code >= _MIN_CODEPOINT_REQUIRES_SURROGATE: + self._element_end += 1 + continue + + # We now have enough data for the element - parse it + element = self._buffer[self._start_index:self._element_end] + + # Get terminator character + if self._element_end < len(self._buffer): + terminator = self._buffer[self._element_end] + else: + # Need more data + break + + # Add element to array + self._element_buffer.append(element) + + # If last element (semicolon terminator), handle instruction + if terminator == ';': + # Get opcode (first element) + opcode = self._element_buffer.pop(0) + + # Call instruction handler + if self.oninstruction is not None: + self.oninstruction(opcode, self._element_buffer) + + # Clear elements for next instruction + self._element_buffer = [] + + # Immediately truncate buffer if fully parsed + if not is_buffer and self._element_end + 1 == len(self._buffer): + self._element_end = -1 + self._buffer = '' + + elif terminator != ',': + raise InvalidInstructionError( + 'Element terminator of instruction was not ";" nor ",".', + instruction=self._buffer[:self._element_end + 1] + ) + + # Start searching for length at character after terminator + self._start_index = self._element_end + 1 + + # Search for end of length (the period) + length_end = self._buffer.find('.', self._start_index) + if length_end != -1: + # Parse length + length_str = self._buffer[self._element_end + 1:length_end] + try: + self._element_codepoints = int(length_str) + except ValueError: + raise InvalidInstructionError( + 'Non-numeric character in element length.', + instruction=length_str + ) + + # Calculate start of element value + self._start_index = length_end + 1 + + # Calculate location of element terminator + self._element_end = self._start_index + self._element_codepoints + + else: + # No period yet, continue search when more data is received + self._start_index = len(self._buffer) + break + + +def code_point_count(s: str, start: int = 0, end: Optional[int] = None) -> int: + """ + Return the number of Unicode codepoints in the given string or substring. + + In Python, strings are stored as proper Unicode (UTF-32 internally), so + len() already gives the codepoint count. However, this function also handles + edge cases where surrogate characters might appear in strings that originated + from UTF-16 encoding. + + Unlike JavaScript's string.length which counts UTF-16 code units (where + surrogate pairs count as 2), this function counts actual Unicode codepoints. + + Args: + s: The string to inspect. + start: The starting index (default 0). + end: The ending index (exclusive). If None, counts to end of string. + + Returns: + The number of Unicode codepoints in the specified portion of the string. + + Example: + >>> code_point_count("hello") + 5 + >>> code_point_count("test string", 0, 4) + 4 + """ + # Extract substring + substring = s[start:end] + + # In Python, len() gives codepoint count for normal strings. + # However, if the string contains unpaired surrogates (from malformed UTF-16), + # we need to handle surrogate pairs that are stored as two characters. + # Find proper surrogate pairs (high surrogate followed by low surrogate) + surrogate_pairs = _SURROGATE_PAIR_PATTERN.findall(substring) + + # Each surrogate pair represents a single codepoint but is stored as + # two characters in Python when originating from UTF-16 data. + # Subtract the number of pairs to get the actual codepoint count. + return len(substring) - len(surrogate_pairs) + + +def to_instruction(elements: List[Any]) -> str: + """ + Convert a list of values into a properly formatted Guacamole instruction. + + Each element is converted to a string and prefixed with its length in + Unicode codepoints, followed by a period. Elements are separated by + commas, and the instruction ends with a semicolon. + + Args: + elements: The values to encode as instruction elements. Must have at + least one element (the opcode). Each element will be converted + to a string. + + Returns: + A complete Guacamole instruction string. + + Example: + >>> to_instruction(["key", "65", "1"]) + '3.key,2.65,1.1;' + >>> to_instruction(["sync", "1234567890"]) + '4.sync,10.1234567890;' + """ + if not elements: + raise ValueError("Instruction must have at least one element (opcode)") + + def to_element(value: Any) -> str: + """Convert a value to a length-prefixed element string.""" + s = str(value) + length = code_point_count(s) + return f"{length}.{s}" + + # Build instruction: first element, then comma-separated remaining elements + instruction = to_element(elements[0]) + for element in elements[1:]: + instruction += ',' + to_element(element) + + return instruction + ';' + + +# Expose functions at module level for convenience +Parser.code_point_count = staticmethod(code_point_count) +Parser.to_instruction = staticmethod(to_instruction) diff --git a/keepercli-package/src/keepercli/commands/pam/pam_launch/guacamole/status.py b/keepercli-package/src/keepercli/commands/pam/pam_launch/guacamole/status.py new file mode 100644 index 00000000..9917eee4 --- /dev/null +++ b/keepercli-package/src/keepercli/commands/pam/pam_launch/guacamole/status.py @@ -0,0 +1,157 @@ +""" +Guacamole status codes and Status class. + +This module provides the Status class and StatusCode enum for representing +Guacamole protocol status codes and associated messages. +""" + +from enum import IntEnum +from typing import Optional + + +class StatusCode(IntEnum): + """ + Enumeration of all Guacamole protocol status codes. + + Status codes are divided into ranges: + - 0x0000-0x00FF: Success/informational + - 0x0100-0x01FF: Unsupported operations + - 0x0200-0x02FF: Server errors + - 0x0300-0x03FF: Client errors + """ + + # Success + SUCCESS = 0x0000 + + # Unsupported + UNSUPPORTED = 0x0100 + + # Server errors + SERVER_ERROR = 0x0200 + SERVER_BUSY = 0x0201 + UPSTREAM_TIMEOUT = 0x0202 + UPSTREAM_ERROR = 0x0203 + RESOURCE_NOT_FOUND = 0x0204 + RESOURCE_CONFLICT = 0x0205 + RESOURCE_CLOSED = 0x0206 + UPSTREAM_NOT_FOUND = 0x0207 + UPSTREAM_UNAVAILABLE = 0x0208 + SESSION_CONFLICT = 0x0209 + SESSION_TIMEOUT = 0x020A + SESSION_CLOSED = 0x020B + + # Client errors + CLIENT_BAD_REQUEST = 0x0300 + CLIENT_UNAUTHORIZED = 0x0301 + CLIENT_FORBIDDEN = 0x0303 + CLIENT_TIMEOUT = 0x0308 + CLIENT_OVERRUN = 0x030D + CLIENT_BAD_TYPE = 0x030F + CLIENT_TOO_MANY = 0x031D + + @classmethod + def from_http_code(cls, http_status: int) -> 'StatusCode': + """ + Return the Guacamole status code that most closely represents + the given HTTP status code. + + Args: + http_status: The HTTP status code to translate. + + Returns: + The corresponding Guacamole status code. + """ + http_to_guac = { + 400: cls.CLIENT_BAD_REQUEST, + 403: cls.CLIENT_FORBIDDEN, + 404: cls.RESOURCE_NOT_FOUND, + 429: cls.CLIENT_TOO_MANY, + 503: cls.SERVER_BUSY, + } + return http_to_guac.get(http_status, cls.SERVER_ERROR) + + @classmethod + def from_websocket_code(cls, ws_code: int) -> 'StatusCode': + """ + Return the Guacamole status code that most closely represents + the given WebSocket close code. + + Args: + ws_code: The WebSocket status code to translate. + + Returns: + The corresponding Guacamole status code. + """ + # Successful disconnect + if ws_code == 1000: # Normal Closure + return cls.SUCCESS + + # Server not reachable + if ws_code in (1006, 1015): # Abnormal Closure, TLS Handshake + return cls.UPSTREAM_NOT_FOUND + + # Server busy/unavailable + if ws_code in (1001, 1012, 1013, 1014): # Going Away, Service Restart, Try Again, Bad Gateway + return cls.UPSTREAM_UNAVAILABLE + + return cls.SERVER_ERROR + + +class Status: + """ + A Guacamole status consisting of a status code and optional message. + + The status code is defined by the protocol, while the message is an + optional human-readable description, typically for debugging. + + Attributes: + code: The Guacamole status code. + message: Optional human-readable message. + + Example: + status = Status(StatusCode.SUCCESS, "Connection established") + if status.is_error(): + print(f"Error: {status.message}") + """ + + def __init__(self, code: int, message: Optional[str] = None): + """ + Initialize a new Status. + + Args: + code: The Guacamole status code (can be int or StatusCode). + message: Optional human-readable message. + """ + self.code: int = int(code) + self.message: Optional[str] = message + + def is_error(self) -> bool: + """ + Return whether this status represents an error. + + Returns: + True if this is an error status, False otherwise. + """ + return self.code < 0 or self.code > 0x00FF + + def __repr__(self) -> str: + """Return a string representation of this status.""" + try: + code_name = StatusCode(self.code).name + except ValueError: + code_name = f"UNKNOWN({self.code})" + + if self.message: + return f"Status({code_name}, {self.message!r})" + return f"Status({code_name})" + + def __str__(self) -> str: + """Return a human-readable string for this status.""" + try: + code_name = StatusCode(self.code).name + except ValueError: + code_name = f"Code {self.code}" + + if self.message: + return f"{code_name}: {self.message}" + return code_name diff --git a/keepercli-package/src/keepercli/commands/pam/pam_launch/guacamole/tunnel.py b/keepercli-package/src/keepercli/commands/pam/pam_launch/guacamole/tunnel.py new file mode 100644 index 00000000..c55fbb79 --- /dev/null +++ b/keepercli-package/src/keepercli/commands/pam/pam_launch/guacamole/tunnel.py @@ -0,0 +1,156 @@ +""" +Guacamole tunnel abstract base class. + +This module provides the abstract Tunnel class that defines the interface +for Guacamole protocol communication channels. Concrete implementations +should handle the actual transport mechanism (WebSocket, HTTP, WebRTC, etc.). +""" + +from abc import ABC, abstractmethod +from enum import IntEnum +from typing import Any, Callable, List, Optional + +from .status import Status + + +class TunnelState(IntEnum): + """ + All possible tunnel states. + + Attributes: + CONNECTING: A connection is pending. It is not yet known whether + connection was successful. + OPEN: Connection was successful, and data is being received. + CLOSED: The connection is closed. Connection may not have been + successful, the tunnel may have been explicitly closed by + either side, or an error may have occurred. + UNSTABLE: The connection is open, but communication appears to be + disrupted, and the connection may close as a result. + """ + CONNECTING = 0 + OPEN = 1 + CLOSED = 2 + UNSTABLE = 3 + + +class Tunnel(ABC): + """ + Abstract base class for Guacamole protocol tunnels. + + This class defines the interface for sending and receiving Guacamole + protocol instructions over a communication channel. Concrete implementations + should handle the specific transport mechanism. + + Attributes: + state: The current state of this tunnel. + uuid: The UUID uniquely identifying this tunnel, or None if not yet known. + receive_timeout: Maximum time (ms) to wait for data before closing. + unstable_threshold: Time (ms) before connection is considered unstable. + oninstruction: Callback for received instructions. + onstatechange: Callback for state changes. + onerror: Callback for errors. + onuuid: Callback when UUID becomes known. + + Example: + class MyTunnel(Tunnel): + def connect(self, data=None): + # Implementation + pass + + def disconnect(self): + # Implementation + pass + + def send_message(self, *elements): + # Implementation + pass + """ + + # Internal data opcode used by tunnel implementations + INTERNAL_DATA_OPCODE = '' + + def __init__(self): + """Initialize a new Tunnel instance.""" + self._state: TunnelState = TunnelState.CLOSED + self.uuid: Optional[str] = None + self.receive_timeout: int = 15000 + self.unstable_threshold: int = 1500 + + # Callbacks + self.oninstruction: Optional[Callable[[str, List[str]], None]] = None + self.onstatechange: Optional[Callable[[TunnelState], None]] = None + self.onerror: Optional[Callable[[Status], None]] = None + self.onuuid: Optional[Callable[[str], None]] = None + + @property + def state(self) -> TunnelState: + """Get the current tunnel state.""" + return self._state + + @state.setter + def state(self, value: TunnelState) -> None: + """Set the tunnel state (use set_state() for callback notification).""" + self._state = value + + def set_state(self, state: TunnelState) -> None: + """ + Change the tunnel state, firing onstatechange if the state changes. + + Args: + state: The new state of this tunnel. + """ + if state != self._state: + self._state = state + if self.onstatechange: + self.onstatechange(state) + + def set_uuid(self, uuid: str) -> None: + """ + Set the tunnel UUID, firing onuuid callback. + + Args: + uuid: The unique identifier for this tunnel. + """ + self.uuid = uuid + if self.onuuid: + self.onuuid(uuid) + + def is_connected(self) -> bool: + """ + Return whether this tunnel is currently connected. + + Returns: + True if the tunnel is in OPEN or UNSTABLE state, False otherwise. + """ + return self._state in (TunnelState.OPEN, TunnelState.UNSTABLE) + + @abstractmethod + def connect(self, data: Optional[str] = None) -> None: + """ + Connect to the tunnel with the given optional data. + + The data is typically used for authentication. The format of data + accepted is up to the tunnel implementation. + + Args: + data: Optional data to send during connection (e.g., auth tokens). + """ + pass + + @abstractmethod + def disconnect(self) -> None: + """Disconnect from the tunnel.""" + pass + + @abstractmethod + def send_message(self, *elements: Any) -> None: + """ + Send a message through the tunnel. + + All messages are guaranteed to be received in the order sent. + + Args: + *elements: The elements of the message to send. These will be + formatted as a Guacamole instruction. + """ + pass diff --git a/keepercli-package/src/keepercli/commands/pam/pam_launch/launch.py b/keepercli-package/src/keepercli/commands/pam/pam_launch/launch.py new file mode 100644 index 00000000..bd70bc17 --- /dev/null +++ b/keepercli-package/src/keepercli/commands/pam/pam_launch/launch.py @@ -0,0 +1,2235 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' bool: + """True if a PAM connection clipboard flag is enabled; coerces JSON/string booleans.""" + if isinstance(v, bool): + return v + if v is None: + return False + b = value_to_boolean(v) + return b is True + + +def _pam_connection_font_size_int(raw: Any) -> Optional[int]: + """Parse pamSettings.connection.fontSize to int, or None if unset or not parseable as an integer size.""" + if raw is None: + return None + if isinstance(raw, bool): + return None + if isinstance(raw, int): + return raw + if isinstance(raw, float): + return int(raw) if raw.is_integer() else None + if isinstance(raw, str): + s = raw.strip() + if not s: + return None + try: + return int(s) + except ValueError: + return None + try: + return int(raw) + except (TypeError, ValueError): + return None + + +def _parse_host_port(value: str) -> Tuple[str, int]: + """ + Parse a 'host:port' or '[ipv6]:port' string into (host, port). + + Supported formats: + - IPv4 / hostname: 192.168.1.1:22 or server.example.com:3306 + - IPv6: [::1]:22 or [2001:db8::1]:443 + + Raises: + CommandError: if the format is invalid or the port is out of range. + """ + value = value.strip() + if value.startswith('['): + end_bracket = value.find(']') + if end_bracket == -1: + raise CommandError('pam launch', + f'Invalid host format {value!r}. Expected [ipv6]:port (e.g. [::1]:22).') + host = value[1:end_bracket] + rest = value[end_bracket + 1:] + if not rest.startswith(':'): + raise CommandError('pam launch', + f'Invalid host format {value!r}. Expected [ipv6]:port (e.g. [::1]:22).') + port_str = rest[1:] + elif ':' in value: + last_colon = value.rfind(':') + host = value[:last_colon] + port_str = value[last_colon + 1:] + else: + raise CommandError('pam launch', + f'Invalid host format {value!r}. Expected host:port (e.g. 192.168.1.1:22 or server.example.com:3306).') + try: + port = int(port_str) + except ValueError: + raise CommandError('pam launch', + f'Invalid port {port_str!r} in {value!r}. Port must be an integer 1-65535.') + _validate_host_port(host, port) + return host, port + + +def _validate_host_port(host: str, port: int) -> None: + """ + Validate host (non-empty, valid IPv4/IPv6 or hostname) and port (1-65535). + Raises CommandError if invalid. + """ + if not host: + raise CommandError('pam launch', 'Host cannot be empty.') + if not (1 <= port <= 65535): + raise CommandError('pam launch', f'Port {port} is out of range (valid range: 1-65535).') + # Attempt strict IP validation; if it raises ValueError the host is treated as a hostname + # (any non-empty hostname string is accepted — the gateway does the DNS resolution). + try: + ipaddress.ip_address(host) + except ValueError: + pass # Not an IP literal — treat as hostname, basic non-empty check above is sufficient + + +def _iter_record_fields(record: Any): + """Yield every TypedField from both record.fields and record.custom.""" + for field in list(getattr(record, 'fields', None) or []) + list(getattr(record, 'custom', None) or []): + yield field + + +def _get_host_port_from_record(record: Any) -> Tuple[Optional[str], Optional[int]]: + """ + Extract (hostName, port) from a record's pamHostname or host typed fields. + + Requires a non-empty hostName on exactly one such field. Port comes from + pamSettings.connection.port when the record is pamMachine/pamDirectory/pamDatabase + and that port is set (overrides the field's port); otherwise from the field's port. + + Raises CommandError if more than one qualifying host field is found (ambiguous). + + Returns: + Tuple of (host, port) where either may be None if none found. + """ + if not record: + return None, None + + pam_override_port = _pam_settings_connection_port(record) + candidates: list = [] + for field in _iter_record_fields(record): + if getattr(field, 'type', None) not in ('pamHostname', 'host'): + continue + value = field.get_default_value(dict) if hasattr(field, 'get_default_value') else {} + if not isinstance(value, dict): + continue + host = (value.get('hostName') or '').strip() + if not host: + continue + port_raw = pam_override_port if pam_override_port is not None else value.get('port') + if not port_raw: + continue + try: + p = int(port_raw) + except (ValueError, TypeError): + continue + if 1 <= p <= 65535: + candidates.append((host, p)) + + if len(candidates) > 1: + raise CommandError('pam launch', + f'Record has {len(candidates)} non-empty host/pamHostname fields with valid host and port ' + '(expected exactly one). Clear the extra field before launching.') + if not candidates: + return None, None + return candidates[0] + + +def _record_has_credentials(record: Any, params: Optional['KeeperParams'] = None) -> bool: + """ + Return True if the record has exactly one non-empty login field and at least one of: + - exactly one non-empty password field (fields[] and custom[]), or + - a usable SSH private key (same discovery as the launch path: keyPair, notes, custom fields, + attachments), when ``params`` is given so attachments can be resolved. + + Raises CommandError if multiple non-empty login or password fields are found (ambiguous). + """ + if not record: + return False + + def _count_nonempty(field_type: str) -> int: + count = 0 + for field in _iter_record_fields(record): + if getattr(field, 'type', None) == field_type: + val = field.get_default_value(str) if hasattr(field, 'get_default_value') else '' + if val: + count += 1 + return count + + login_count = _count_nonempty('login') + if login_count > 1: + raise CommandError('pam launch', + f'Record has {login_count} non-empty login fields (expected exactly one). ' + 'Clear the extra login field before launching.') + if login_count == 0: + return False + + password_count = _count_nonempty('password') + if password_count > 1: + raise CommandError('pam launch', + f'Record has {password_count} non-empty password fields (expected exactly one). ' + 'Clear the extra password field before launching.') + if password_count == 1: + return True + + # No password: SSH (and similar) may authenticate with a private key only. + if password_count == 0 and params is not None: + key_result = try_extract_private_key(params, record) + if key_result and key_result[0]: + return True + + return False + + +def _record_has_host_port(record: Any) -> bool: + """Return True if the record has exactly one non-empty host/pamHostname field with valid host and port.""" + host, port = _get_host_port_from_record(record) + return bool(host) and port is not None + + +# Exit codes for involuntary ``pam launch`` terminations. Distinct from the +# generic CommandError path (1) and normal exit (0) so scripts can branch. +# Avoid 0/1, sysexits.h (64-78), shell-reserved (126-127), signal range (128+). +EXIT_CODE_AI_TERMINATED = 40 +EXIT_CODE_ADMIN_TERMINATED = 41 + + +def _print_close_reason_notice( + reason: Optional[str], + *, + pending_exit_code: Optional[int], + session_established: bool = False, +) -> Optional[int]: + """Show a user-facing notice for an involuntary remote close. + + Stays silent for ``normal`` / ``client`` (user-initiated). Called from the + inner ``finally`` of ``_start_cli_session`` after the input handler has + stopped and the local terminal is back in cooked mode — the message is the + last thing the user sees before returning to Commander, so no acknowledge + prompt is needed. + + ``session_established`` reflects whether the guac session was live (≥1 sync / + data flowing) at the moment of close. A guacd-initiated disconnect — the user + typing ``exit`` / ``logout`` — is tagged ``GuacdError`` (code 14) by the + gateway, and ``server_disconnect`` when guacd sends an explicit ``disconnect`` + opcode. Neither is an error once the session was running, so both are treated + as a normal close to avoid the misleading "Session ended (guacd_error)." line + on a clean logout. A ``guacd_error`` that arrives *before* the session went + live is a genuine connect-time fault and is still surfaced. + """ + if reason == 'server_disconnect' or (reason == 'guacd_error' and session_established): + reason = 'normal' + + if not reason or reason in ('normal', 'client'): + return pending_exit_code + + is_stdout_tty = sys.stdout.isatty() + + def _color(text: str, color: str) -> str: + if not is_stdout_tty: + return text + return f'{color}{Style.BRIGHT}{text}{Style.RESET_ALL}' + + if reason == 'ai_closed': + print() + print(_color('Session terminated by KeeperAI.', Fore.RED), flush=True) + print('Critical activity was detected during this session.', flush=True) + print('Contact your administrator to unlock this record.', flush=True) + return EXIT_CODE_AI_TERMINATED + + if reason == 'admin_closed': + print() + print(_color('Session terminated by administrator.', Fore.YELLOW), flush=True) + return EXIT_CODE_ADMIN_TERMINATED + + print(f'\nSession ended ({reason}).', flush=True) + return pending_exit_code + + +VALID_PAM_RECORD_TYPES = {'pamDatabase', 'pamDirectory', 'pamMachine'} + + +def is_launchable(params, record_uid): + """Return ``(eligible, protocol)`` for a `pam launch` candidate. + + Eligibility: TypedRecord v3, ``record_type`` in :data:`VALID_PAM_RECORD_TYPES`, + and detected protocol in :data:`ALL_TERMINAL`. Reads only cached vault data — + safe to call from a TUI render path. + """ + try: + record = vault.KeeperRecord.load(params, record_uid) + if not isinstance(record, vault.TypedRecord): + return False, None + if record.version != 3: + return False, None + if record.record_type not in VALID_PAM_RECORD_TYPES: + return False, None + except Exception as e: + logging.debug("is_launchable: cannot load %s: %s", record_uid, e) + return False, None + try: + protocol = detect_protocol(params, record_uid) + except Exception as e: + logging.debug("is_launchable: detect_protocol failed for %s: %s", record_uid, e) + return False, None + if protocol not in ALL_TERMINAL: + return False, None + return True, protocol + + +def get_launch_info(params, record_uid): + """Return a summary dict describing how this record will launch, or ``None``. + + Reads only cached vault data — safe to call from a TUI render path. + + Keys: + protocol — terminal protocol (e.g. 'ssh', 'mysql') + host — hostname from pamHostname/host field, or None + port — port (int) from pamHostname/host field, or None + allow_supply_host — bool: user may supply host at launch time + allow_supply_user — bool: user may supply credential at launch time + credential_uid — first userRecords[] entry, or None + credential_title — title of the credential record (if cached), else None + """ + ok, protocol = is_launchable(params, record_uid) + if not ok: + return None + try: + record = vault.KeeperRecord.load(params, record_uid) + except Exception: + return None + host, port = _get_host_port_from_record(record) + allow_supply_host = False + allow_supply_user = False + credential_uid = None + pam_settings_field = record.get_typed_field('pamSettings') + if pam_settings_field: + pam_settings_value = pam_settings_field.get_default_value(dict) or {} + allow_supply_host = bool(pam_settings_value.get('allowSupplyHost')) + connection = pam_settings_value.get('connection') or {} + if isinstance(connection, dict): + allow_supply_user = bool(connection.get('allowSupplyUser')) + user_records = connection.get('userRecords') or [] + if user_records: + credential_uid = user_records[0] + credential_title = None + if credential_uid: + try: + cred = vault.KeeperRecord.load(params, credential_uid) + if cred is not None: + credential_title = getattr(cred, 'title', None) + except Exception: + credential_title = None + return { + 'protocol': protocol, + 'host': host, + 'port': port, + 'allow_supply_host': allow_supply_host, + 'allow_supply_user': allow_supply_user, + 'credential_uid': credential_uid, + 'credential_title': credential_title, + } + + +class PAMLaunchCommand(ArgparseCommand): + """PAM Launch command to launch a connection to a PAM resource""" + + VALID_PAM_RECORD_TYPES = VALID_PAM_RECORD_TYPES + + def __init__(self): + parser = argparse.ArgumentParser(prog='pam launch', description='Launch a connection to a PAM resource') + PAMLaunchCommand.add_arguments_to_parser(parser) + super().__init__(parser) + + @staticmethod + def add_arguments_to_parser(parser: argparse.ArgumentParser): + parser.add_argument('record', type=str, action='store', + help='PAM resource to launch — record UID, path, exact title, ' + 'or any substring of the title or a host/pamHostname field. ' + 'Multiple matches prompt for selection on a TTY.') + 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.') + parser.add_argument('--credential', '-cr', required=False, dest='launch_credential', type=str, + help='Record (UID, path, or title) for launch credentials') + parser.add_argument('--host', '-H', required=False, dest='custom_host', type=str, + help='Host and port in format host:port (e.g. -H=192.168.1.1:22 or -H=[::1]:22 for IPv6). ' + 'Requires allowSupplyHost. Mutually exclusive with --host-record.') + parser.add_argument('--host-record', '-hr', required=False, dest='host_record', type=str, + help='Record (UID, path, or title) with a host or pamHostname field containing hostName and port. ' + 'Requires allowSupplyHost. Mutually exclusive with --host.') + parser.add_argument('--stdin', required=False, dest='use_stdin', action='store_true', + help='Send typed input via stdin pipe bytes (pipe/blob/end, kcm-cli style) instead of ' + 'the default Guacamole key-event mode. Paste and Ctrl+C double-tap behave the ' + 'same in both modes.') + parser.add_argument('--normalize-crlf', '-n', required=False, dest='normalize_crlf', action='store_true', + help='Normalize decoded Guacamole STDOUT: CRLF to LF and downstream LF cleanup. ' + 'Use when you see double new lines from the remote. ' + 'By default we keep raw CR/LF on STDOUT (lower overhead). ' + 'Alternatively, tune sending double newlines to the remote with environment ' + f'variable {PAM_LAUNCH_CRLF_MERGE_DELAY_MS_ENV}: [{MIN_CRLF_MERGE_DELAY_MS}..{MAX_CRLF_MERGE_DELAY_MS}] ms ' + 'which controls local Enter coalescing (split CRLF across reads).') + parser.add_argument('--scale', '-s', required=False, dest='scale', type=int, default=None, + help='Scale pixel width/height by this percentage (e.g. 50 = half canvas, 200 = double). ' + 'Range: [40-400]. Helps when fullscreen TUI programs show garbled layout.') + parser.add_argument('--reason', '-r', required=False, dest='workflow_reason', type=str, + help='Justification text for workflow access request. Used when the record\'s ' + 'workflow requires a reason; non-interactive equivalent of the inline prompt.') + parser.add_argument('--ticket', '-tk', required=False, dest='workflow_ticket', type=str, + help='External ticket / reference number for workflow access request. Used when ' + 'the record\'s workflow requires a ticket; non-interactive equivalent of the inline prompt.') + parser.add_argument('--auto-checkout', '-aco', required=False, dest='workflow_auto_checkout', + action='store_true', + help='Auto-confirm workflow check-out when the record is approved but not yet ' + 'checked out (skips the interactive Y/n prompt). The lease is released ' + 'automatically when the launch session ends.') + parser.add_argument('--wait', '-w', required=False, dest='workflow_wait', + action='store_true', + help='When the workflow is waiting on approval, poll until approved ' + '(or --wait-timeout elapses) instead of exiting immediately.') + parser.add_argument('--wait-timeout', '-wt', required=False, dest='workflow_wait_timeout', + type=int, default=600, + help='Maximum seconds to poll for approval when --wait is set (default: 600).') + + def _is_valid_pam_record(self, params: KeeperParams, record_uid: str) -> bool: + """ + Check if a record is a valid PAM record type. + + Args: + params: KeeperParams instance + record_uid: Record UID to check + + Returns: + True if record is a valid PAM type (version 3 TypedRecord with PAM type), False otherwise + """ + try: + record = vault.KeeperRecord.load(params, record_uid) + if not isinstance(record, vault.TypedRecord): + return False + if record.version != 3: + return False + return record.record_type in self.VALID_PAM_RECORD_TYPES + except Exception as e: + logging.debug(f"Error checking record type for {record_uid}: {e}") + return False + + def find_record(self, params: KeeperParams, record_token: str) -> Optional[str]: + """ + Find a record by UID, path, or title. + + Args: + params: KeeperParams instance + record_token: Record identifier (UID, path, or title) + + Returns: + Record UID if found, None otherwise + """ + if not record_token: + return None + + record_token = record_token.strip() + + # Step 1: Try UID lookup + uid_pattern = re.compile(r'^[A-Za-z0-9_-]{22}$') + if uid_pattern.match(record_token): + if record_token in params.record_cache: + logging.debug(f"Found record by UID: {record_token}") + return record_token + if params.vault is not None: + if params.vault.vault_data.get_record(record_token): + logging.debug(f"Found record by vault UID: {record_token}") + return record_token + if params.vault.nsf_data and params.vault.nsf_data.get_record(record_token): + logging.debug(f"Found record by NSF UID: {record_token}") + return record_token + + # Step 2: Try path lookup + record_uid = self._find_by_path(params, record_token) + if record_uid: + return record_uid + + # Step 3: Try full title match + record_uid = self._find_by_title(params, record_token) + if record_uid: + return record_uid + + # Step 4: Substring fallback — title + host/pamHostname fields across + # PAM records. Catches cases like `pam launch prod-server` (multiple + # title matches) and `pam launch db.example.com` (hostname lookup). + record_uid = self._find_by_substring(params, record_token) + if record_uid: + return record_uid + + return None + + def _find_by_path(self, params: KeeperParams, path: str) -> Optional[str]: + """ + Find record by path resolution. + + If exactly one record matches (any type), returns its UID. If two or more + match, filters to PAM types only: returns the single PAM UID if one, + else logs error (no PAM types vs multiple PAM matches) and returns None. + + Returns: + Record UID if found, None otherwise + """ + rs = try_resolve_path(params, path) + if rs is None: + return None + + folder, name = rs + if folder is None or name is None: + return None + + folder_uid = folder.folder_uid or '' + if folder_uid not in params.subfolder_record_cache: + return None + + # All records in folder with matching title (any type) + all_matched = [] + for uid in params.subfolder_record_cache[folder_uid]: + r = api.get_record(params, uid) + if r and r.title and r.title.lower() == name.lower(): + all_matched.append(uid) + + if len(all_matched) == 1: + logging.debug(f"Found record by path: {path} -> {all_matched[0]}") + return all_matched[0] + + if len(all_matched) >= 2: + pam_matched = [uid for uid in all_matched if self._is_valid_pam_record(params, uid)] + if len(pam_matched) == 1: + logging.debug(f"Found record by path: {path} -> {pam_matched[0]} (1 PAM among {len(all_matched)} matches)") + return pam_matched[0] + if len(pam_matched) == 0: + logging.debug( + 'path %r matches %d record(s) but none are PAM types', + path, len(all_matched), + ) + return None + logging.debug( + 'path %r matches %d PAM records — falling through to substring search', + path, len(pam_matched), + ) + return None + + return None + + def _find_by_title(self, params: KeeperParams, title: str) -> Optional[str]: + """ + Find record by exact title match. + + If exactly one record matches (any type), returns its UID. If two or more + match, filters to PAM types only: returns the single PAM UID if one, + else logs error (no PAM types vs multiple PAM matches) and returns None. + + Returns: + Record UID if found, None otherwise + """ + all_matched = [] + for record_uid in params.record_cache: + record = vault.KeeperRecord.load(params, record_uid) + if record and record.title and record.title.lower() == title.lower(): + all_matched.append(record_uid) + + if len(all_matched) == 1: + logging.debug(f"Found record by title: {title} -> {all_matched[0]}") + return all_matched[0] + + if len(all_matched) >= 2: + pam_matched = [uid for uid in all_matched if self._is_valid_pam_record(params, uid)] + if len(pam_matched) == 1: + logging.debug(f"Found record by title: {title} -> {pam_matched[0]} (1 PAM among {len(all_matched)} matches)") + return pam_matched[0] + if len(pam_matched) == 0: + logging.debug( + 'title %r matches %d record(s) but none are PAM types', + title, len(all_matched), + ) + return None + logging.debug( + 'title %r matches %d PAM records — falling through to substring search', + title, len(pam_matched), + ) + return None + + return None + + def _find_by_substring(self, params: KeeperParams, token: str) -> Optional[str]: + """Substring fallback for ``find_record`` — case-insensitive contains + match across PAM record titles and any ``host`` / ``pamHostname`` field. + + Limited to ``VALID_PAM_RECORD_TYPES`` (pamMachine, pamDirectory, + pamDatabase). Connections-enabled / config gating is intentionally not + checked here — that's expensive (DAG fetch per record) and the + downstream gates in ``execute()`` reject inappropriate records anyway. + + Returns None if no candidates match. Returns the unique UID if exactly + one matches. With multiple matches, prompts on a TTY or prints the + list and returns None on a non-TTY. + """ + token_lower = token.lower() + # candidate tuple: (uid, title, [(hostName, port), ...]) + candidates: list = [] + for record_uid in params.record_cache: + try: + record = vault.KeeperRecord.load(params, record_uid) + except Exception: + continue + if not isinstance(record, vault.TypedRecord) or record.version != 3: + continue + if record.record_type not in self.VALID_PAM_RECORD_TYPES: + continue + title = record.title or '' + hosts: list = [] + for field in _iter_record_fields(record): + if getattr(field, 'type', None) not in ('pamHostname', 'host'): + continue + value = field.get_default_value(dict) if hasattr(field, 'get_default_value') else {} + if not isinstance(value, dict): + continue + host_name = (value.get('hostName') or '').strip() + if not host_name: + continue + hosts.append((host_name, value.get('port'))) + + if token_lower in title.lower() or any(token_lower in h.lower() for (h, _) in hosts): + candidates.append((record_uid, title, hosts)) + + if not candidates: + return None + + if len(candidates) == 1: + uid, title, _ = candidates[0] + logging.debug('substring %r -> %s (%s)', token, uid, title) + return uid + + return self._pick_candidate(candidates, token) + + @staticmethod + def _pick_candidate(candidates: list, token: str) -> Optional[str]: + """Render a numbered list of candidates and prompt for selection. + + On non-TTY stdin, prints the list once and returns None — caller + surfaces ``Record not found`` so the user knows to be more specific. + """ + title_w = max(len(c[1]) for c in candidates) + print(f'\n{len(candidates)} matching resources:', flush=True) + for idx, (uid, title, hosts) in enumerate(candidates, 1): + host_str = '' + if hosts: + host_str = ', '.join(f'{h}:{p}' if p else h for (h, p) in hosts) + host_str = f' ({host_str})' + print(f' {idx:>2}. {uid} {title:<{title_w}}{host_str}', flush=True) + + if not sys.stdin.isatty(): + logging.error( + 'pam launch: %d matches for %r — re-run with a UID, full path, ' + 'or a more specific token.', len(candidates), token, + ) + return None + + while True: + try: + answer = input('Specify the resource: ').strip() + except (EOFError, KeyboardInterrupt): + print() + return None + if not answer: + return None + try: + n = int(answer) + except ValueError: + print( + f'Invalid selection {answer!r}. Enter a number 1..{len(candidates)} ' + 'or press Enter to cancel.', + flush=True, + ) + continue + if not (1 <= n <= len(candidates)): + print(f'Selection out of range. Enter 1..{len(candidates)}.', flush=True) + continue + return candidates[n - 1][0] + + def find_gateway( + self, + params: KeeperParams, + record_uid: str, + tdag: Optional[Any] = None, + ) -> Optional[Dict]: + """ + Find the gateway associated with a PAM record. + + Args: + params: KeeperParams instance + record_uid: Record UID to find gateway for (must be pre-validated as PAM type) + tdag: Optional pre-built TunnelDAG. When provided, the config UID is + read from ``tdag.record.record_uid`` instead of fetched again via + ``get_config_uid_from_record`` — avoids the extra + ``/api/user/get_leafs`` roundtrip. + + Returns: + Dictionary with gateway information including: + - gateway_uid: Gateway UID (str) + - gateway_name: Gateway name (str) + - config_uid: PAM configuration UID (str) + - gateway_proto: Gateway protobuf object (pam_pb2.PAMController) + Returns None if no gateway found + + Raises: + CommandError: If gateway configuration issues exist + """ + # Get the gateway UID from the record + # Note: Record type validation happens in find_record() + if tdag is not None: + config_uid = tdag.record.record_uid + gateway_uid = self._gateway_uid_from_config(params, config_uid) if config_uid else '' + else: + gateway_uid = get_gateway_uid_from_record(params, vault, record_uid) + config_uid = None # resolved below when tdag is absent + + if not gateway_uid: + raise CommandError('pam launch', f'No gateway found for record {record_uid}. ') + + logging.debug(f"Found gateway UID for record: {gateway_uid}") + + # Get all gateways to find the matching one. This is the strict + # enterprise-wide list filtered by the user's gateway-visibility + # permissions; it can return empty / miss the gateway for KSM-app + # members who are not the app owner, even when the user has access to + # the PAM Configuration record itself. + all_gateways = get_all_gateways(params.vault) + + # Find the gateway by UID + gateway_uid_bytes = url_safe_str_to_bytes(gateway_uid) + gateway_proto = next((g for g in all_gateways if g.controllerUid == gateway_uid_bytes), None) + + # Fallback: web vault uses the per-config endpoint + # `pam/get_configuration_controller` rather than enumerating + # `pam/get_controllers`. That endpoint resolves the gateway for THIS + # config without requiring enterprise-wide gateway visibility, so + # non-Owner KSM-app members can still launch through gateways tied to + # configs they have access to. Mirrors WV's LaunchButton.tsx -> + # getControllerUidForConfigUid -> get-controller-for-config-uid.ts -> + # api-pam.ts (`pam/get_configuration_controller`). + if not gateway_proto: + # Resolve config_uid early if we don't have it yet — it's needed + # for the per-config lookup. This is the same lookup that runs + # below for the return value, just hoisted up for the fallback. + if config_uid is None: + try: + config_uid = get_config_uid_from_record(params, vault, record_uid) + except Exception as e: + logging.debug('find_gateway: config_uid resolution for fallback failed: %s', e) + if config_uid: + try: + from keepersdk.helpers.config_utils import configuration_controller_get + controller = configuration_controller_get( + params.vault, url_safe_str_to_bytes(config_uid), + ) + if controller and controller.controllerUid == gateway_uid_bytes: + gateway_proto = controller + logging.debug( + 'find_gateway: resolved gateway via ' + 'pam/get_configuration_controller fallback' + ) + except Exception as e: + logging.debug( + 'find_gateway: pam/get_configuration_controller ' + 'fallback failed: %s', e, + ) + + if not gateway_proto: + raise CommandError('pam launch', f'Gateway {gateway_uid} not found in available gateways.') + + gateway_name = gateway_proto.controllerName if gateway_proto else 'Unknown' + logging.debug(f"Found gateway: {gateway_name} ({gateway_uid})") + + # Get the configuration UID (already resolved from tdag when present + # OR by the fallback above) + if config_uid is None: + config_uid = get_config_uid_from_record(params, vault, record_uid) + + return { + 'gateway_uid': gateway_uid, + 'gateway_name': gateway_name, + 'config_uid': config_uid, + 'gateway_proto': gateway_proto + } + + @staticmethod + def _gateway_uid_from_config(params: KeeperParams, pam_config_uid: str) -> str: + """Resolve the controller (gateway) UID from a PAM configuration UID. + + Mirrors the second half of + ``tunnel_helpers.get_gateway_uid_from_record`` — read ``controllerUid`` + from the config record's ``pamResources`` field, falling back to the + ``pam/get_configuration_controller`` API when the local record is + missing the field. + """ + gateway_uid = '' + record = vault.KeeperRecord.load(params, pam_config_uid) + if record is not None: + field = record.get_typed_field('pamResources') + value = field.get_default_value(dict) if field is not None else None + if value: + gateway_uid = value.get('controllerUid', '') or '' + + if not gateway_uid: + try: + from keepersdk.helpers.config_utils import configuration_controller_get + from keepersdk import utils + config_uid_bytes = url_safe_str_to_bytes(pam_config_uid) + controller = configuration_controller_get(params.vault, config_uid_bytes) + if controller and controller.controllerUid: + gateway_uid = utils.base64_url_encode(controller.controllerUid) + except Exception as e: + logging.debug('_gateway_uid_from_config: fallback failed: %s', e) + + return gateway_uid + + def execute(self, context: KeeperParams, **kwargs): + from ....compat.commander_params import ensure_commander_compat + ensure_commander_compat() + params = context + """ + Execute the PAM launch command + + Args: + params: KeeperParams instance containing session state + **kwargs: Command arguments including 'record' (record path or UID) + """ + # Grand-total timer: from command entry through handoff to the interactive + # loop. Summary fires in _start_cli_session just before input_handler.start(). + # Per-phase blocks (pam-launch:execute / :terminal_connection / :webrtc-tunnel / + # :cli_session) nest inside and log their own totals — no double-counting. + _total_tc = PamConnectTiming('pam-launch:total') + + # Pre-phase timer: covers all work done in execute() before the terminal + # connection handoff. Summary fires at pre_terminal_connection below. + _exec_tc = PamConnectTiming('pam-launch:execute') + _exec_tc.checkpoint('execute_start') + + # Save original root logger level and set to ERROR if not in DEBUG mode + root_logger = logging.getLogger() + original_level = root_logger.level + + if root_logger.getEffectiveLevel() > logging.DEBUG: + root_logger.setLevel(logging.ERROR) + + try: + # TODO: Add JIT - note that allowSupplyHost overrides all other supply modes. + # When a PAM record has allowSupplyHost, allowSupplyUser, and JIT settings all enabled, + # the Web Vault (and this CLI) treat allowSupplyHost as the active mode and ignore the + # other two. Any validation logic below must reflect this precedence: if allowSupplyHost + # is True, treat the record as "host+credential supply" mode regardless of the other flags. + + record_token = kwargs.get('record') + + if not record_token: + logging.error("Record path or UID is required") + return + + # Find the record + record_uid = self.find_record(params, record_token) + + if not record_uid: + raise CommandError('pam launch', f'Record not found: {record_token}') + + logging.debug(f"Found record: {record_uid}") + + record = vault.KeeperRecord.load(params, record_uid) + if not isinstance(record, vault.TypedRecord): + raise CommandError('pam launch', f'Record {record_uid} is not a TypedRecord') + + # Per-user enforcement gate (matches web vault getAllowConnections, + # pam-enforcement-selectors.ts:39-40). Bail before any further work + # when the user's enterprise enforcement disallows PAM connections. + try: + from ...workflow.helpers import is_pam_action_allowed_by_enforcement + if not is_pam_action_allowed_by_enforcement( + params, 'allow_launch_pam_on_cloud_connection'): + logging.error( + "pam launch aborted: PAM connections are not allowed by " + "your enterprise enforcement (allow_launch_pam_on_cloud_connection).", + ) + return + except ImportError: + pass + + # PAM-config gate (matches web vault GuacConnectBanner.tsx:37-45): + # bail before the workflow gate / lease auto-checkout when the PAM + # configuration disables connections for this record. + try: + from ...workflow.helpers import is_pam_config_action_allowed_for_record + if not is_pam_config_action_allowed_for_record(params, record_uid, 'connections'): + logging.error( + "pam launch aborted: connections are disabled by the PAM " + "configuration for record %s.", record_uid, + ) + return + except ImportError: + pass + + workflow_expires_on_ms = 0 + workflow_flow_uid = None + workflow_started_by_launch = False + try: + from ...workflow import check_workflow_for_launch + gate = check_workflow_for_launch( + params, record_uid, + reason=kwargs.get('workflow_reason'), + ticket=kwargs.get('workflow_ticket'), + auto_checkout=bool(kwargs.get('workflow_auto_checkout')), + wait=bool(kwargs.get('workflow_wait')), + wait_timeout=int(kwargs.get('workflow_wait_timeout') or 600), + ) + if not gate.allowed: + # Orchestrator (`check_workflow_for_launch`) already prints + # the user-facing reason for any block_reason it can identify + # (no_workflow / needs_action / waiting / ready_to_start / + # outside_time_window / MFA cancel / submit-failed / etc.), + # so bail silently here instead of stacking a generic + # catch-all on top. Mirrors `pam tunnel start`. + return + if gate.two_factor_value: + kwargs['two_factor_value'] = gate.two_factor_value + workflow_expires_on_ms = gate.expires_on_ms + workflow_flow_uid = gate.flow_uid + workflow_started_by_launch = gate.started_by_launch + except ImportError: + pass + + if not self._is_valid_pam_record(params, record_uid): + record_type = getattr(record, 'record_type', type(record).__name__) + raise CommandError('pam launch',f'Record {record_uid} of type "{record_type}" is not a machine record type (pamMachine, pamDirectory, pamDatabase)') + _exec_tc.checkpoint('record_loaded') + + # Only terminal protocols are supported (SSH, Telnet, Kubernetes, databases). + protocol = detect_protocol(params, record_uid) + if protocol not in ALL_TERMINAL: + logging.error( + "pam launch only supports terminal protocols (ssh, telnet, kubernetes, mysql, postgresql, sql-server). " + "Protocol %r is not supported; use Web Vault for RDP/VNC/RBI etc.", + protocol, + ) + return + _exec_tc.checkpoint('protocol_detected_top') + + # Optimistic launch cache: the pre-phase (TunnelDAG build + + # find_gateway + online probe) resolves to values that rarely + # change between launches of the same record — DAG-linked + # launch credential UID, gateway UID, config UID. If we have a + # cached entry, use it immediately and spawn a background + # refresh so the next launch sees fresh data if anything moved. + # See keepercommander/commands/pam_launch/launch_cache.py for + # the cache contract. + _cache_entry = launch_cache.get(record_uid) + _launch_tdag = None # populated only on cache miss + _cached_gateway_info: Optional[Dict[str, Any]] = None + + if _cache_entry is not None: + # CACHE HIT: skip DAG build + find_gateway + online probe + dag_linked_uid = _cache_entry.get('dag_linked_uid') + _cached_gateway_info = { + 'gateway_uid': _cache_entry['gateway_uid'], + 'gateway_name': _cache_entry['gateway_name'], + 'config_uid': _cache_entry['config_uid'], + # gateway_proto is only used internally by find_gateway + # to derive gateway_name; nothing downstream reads it. + 'gateway_proto': None, + } + _exec_tc.checkpoint('launch_cache_hit') + + # Kick off a background refresh so the NEXT launch sees + # fresh values if anything changed (credential rotation, + # gateway reassignment). The fetch_fn does the full DAG + # build + find_gateway inline; it must not raise. + def _refresh_fetch(_params=params, _record_uid=record_uid, _self=self): + try: + _enc_s, _enc_t, _tk = get_keeper_tokens(_params) + _tdag = TunnelDAG( + _params.vault, _enc_s, _enc_t, _record_uid, transmission_key=_tk, + ) + _dag_uid = _get_launch_credential_uid(_params, _record_uid, tdag=_tdag) + _gw = _self.find_gateway(_params, _record_uid, tdag=_tdag) + if not _gw: + return None + return { + 'dag_linked_uid': _dag_uid, + 'config_uid': _gw.get('config_uid'), + 'gateway_uid': _gw['gateway_uid'], + 'gateway_name': _gw.get('gateway_name') or 'Unknown', + } + except Exception: + return None + launch_cache.spawn_refresh(record_uid, _refresh_fetch) + else: + # CACHE MISS: build TunnelDAG once and reuse it for both + # _get_launch_credential_uid and find_gateway. Values are + # written to the cache after find_gateway succeeds below. + try: + _enc_session_token, _enc_transmission_key, _transmission_key = get_keeper_tokens(params) + _launch_tdag = TunnelDAG( + params.vault, + _enc_session_token, + _enc_transmission_key, + record_uid, + transmission_key=_transmission_key, + ) + except Exception as _e: + logging.debug('Failed to build TunnelDAG up front: %s — falling back to per-call lookups', _e) + _launch_tdag = None + _exec_tc.checkpoint('dag_built') + + # Get DAG-linked credential UID (shared with downstream + # extract_terminal_settings so it doesn't re-resolve). + dag_linked_uid = _get_launch_credential_uid(params, record_uid, tdag=_launch_tdag) + _exec_tc.checkpoint('dag_linked_uid_resolved') + if not dag_linked_uid: + # Fallback: first entry in pamSettings.connection.userRecords + _psf = record.get_typed_field('pamSettings') + if _psf: + _psv = _psf.get_default_value(dict) + if _psv: + _conn = _psv.get('connection', {}) + if isinstance(_conn, dict): + _ur = _conn.get('userRecords', []) + if _ur: + dag_linked_uid = _ur[0] + + # Read allowSupply flags and other connection settings from pamSettings + pam_settings_field = record.get_typed_field('pamSettings') + allow_supply_user = False + allow_supply_host = False + pam_connection_font_size: Any = None + if pam_settings_field: + pam_settings_value = pam_settings_field.get_default_value(dict) + if pam_settings_value: + allow_supply_host = pam_settings_value.get('allowSupplyHost', False) + connection = pam_settings_value.get('connection', {}) + if isinstance(connection, dict): + allow_supply_user = connection.get('allowSupplyUser', False) + pam_connection_font_size = connection.get('fontSize') + if _pam_connection_clipboard_bool(connection.get('readOnly')): + raise CommandError( + 'pam launch', + f'Record {record_uid} has connection.readOnly:true. That setting is only ' + 'meaningful when joining an existing session; starting a new session from ' + 'Commander is not a read-only join flow. Set readOnly:false on the record ' + 'for CLI launch, or use Web Vault when joining a session read-only.', + ) + if connection.get('disableCopy'): + raise CommandError( + 'pam launch', + f'Record {record_uid} has disableCopy:true and KCM blocks terminal STDOUT ' + 'in that configuration, so Commander cannot run a CLI session. ' + 'Use Web Vault or another client, or set disableCopy:false on the record ' + 'if CLI terminal access is required.', + ) + if _pam_connection_clipboard_bool(connection.get('disablePaste')): + raise CommandError( + 'pam launch', + f'Record {record_uid} has disablePaste:true and a terminal session cannot ' + 'reliably distinguish pasted text from normal keystrokes, so Commander ' + 'cannot run a CLI session. Use Web Vault or another client, or set ' + 'disablePaste:false on the record if CLI terminal access is required.', + ) + # If we want to ignore disablePaste - uncomment below (still need --stdin check) + # disablePaste is incompatible with --stdin (pipe mode) + # if connection.get('disablePaste') and kwargs.get('use_stdin'): + # raise CommandError( + # 'pam launch', + # f'Record {record_uid} has disablePaste:true and KCM does not expose the ' + # 'STDIN pipe that --stdin requires. Run pam launch without --stdin to use ' + # 'default key-event driven input mode, or set disablePaste:false on the record.', + # ) + # Do not remove: + # There's no reliable way to detect pasted text from typed input alone + # so either disable this command or ignore disablePaste i.e. allow paste. + # - Paste is impossible to detect/block in CLI mode - all characters are received/read as console input. + # - Capturing raw key events is platform dependent and usually require admin access. + # - Python modules like keyboard/pyinput capture global keys (not per window/terminal) + # and often need admin/root privileges to work (driver level event capture). + # - Burst detection is unreliable and eating typed characters during auto-repeat/fast typing. + + # Get record host/port for fallback validation + hostname_on_record, port_on_record = _get_host_port_from_record(record) + + # --- Resolve --credential option --- + launch_credential = kwargs.get('launch_credential') + launch_credential_uid = None + if launch_credential: + # Reject early — before record resolution — when neither supply flag permits it. + # (With host options the flag requirement is checked later; here we only gate the + # case where -cr alone requires at least one supply flag to be meaningful.) + if not allow_supply_user and not allow_supply_host: + raise CommandError('pam launch', + '--credential requires allowSupplyUser or allowSupplyHost to be enabled on the record.') + launch_credential_uid = self.find_record(params, launch_credential) + if not launch_credential_uid: + raise CommandError('pam launch', f'Credential record not found: {launch_credential}') + + # --- Parse --host / --host-record (mutually exclusive) --- + raw_custom_host = kwargs.get('custom_host') + host_record_token = kwargs.get('host_record') + custom_host = None + custom_port = None + + # All -H/-hr checks happen BEFORE any record resolution to give the right error first. + + # -H and -hr are mutually exclusive (conflicting options prevent execution). + if raw_custom_host and host_record_token: + raise CommandError('pam launch', + 'Cannot use both --host and --host-record. Use one to specify the target host.') + + # Options conflict: -H/-hr require -cr (Web Vault: host and credentials supplied together). + if (raw_custom_host or host_record_token) and not launch_credential: + raise CommandError('pam launch', + '--host / --host-record requires --credential (-cr) to also be provided. ' + 'When allowSupplyHost is enabled, credentials and host must be supplied together.') + + # allowSupplyHost must be enabled to use -H/-hr at all. + if (raw_custom_host or host_record_token) and not allow_supply_host: + raise CommandError('pam launch', + '--host / --host-record requires allowSupplyHost to be enabled on the record. ' + '(Web Vault: Record > Allow shared users to select their own host and credential)') + + if raw_custom_host: + custom_host, custom_port = _parse_host_port(raw_custom_host) + kwargs['custom_host'] = custom_host + kwargs['custom_port'] = custom_port + logging.debug(f"Parsed --host: {custom_host}:{custom_port}") + + if host_record_token: + host_record_uid = self.find_record(params, host_record_token) + if not host_record_uid: + raise CommandError('pam launch', f'Host record not found: {host_record_token}') + host_record = vault.KeeperRecord.load(params, host_record_uid) + if not host_record: + raise CommandError('pam launch', f'Could not load host record: {host_record_uid}') + custom_host, custom_port = _get_host_port_from_record(host_record) + if not custom_host: + raise CommandError('pam launch', + f'Record {host_record_token} has no hostname. ' + 'It must have a host or pamHostname field with hostName.') + if custom_port is None: + raise CommandError('pam launch', + f'Record {host_record_token} has no valid port (1-65535). ' + 'It must have a host or pamHostname field with a port.') + kwargs['custom_host'] = custom_host + kwargs['custom_port'] = custom_port + logging.debug(f"Using host from record {host_record_uid}: {custom_host}:{custom_port}") + + has_cli_host = custom_host is not None + has_cli_cred = launch_credential_uid is not None + + # --credential record with no host options that matches DAG-linked -> treat as no --credential + if has_cli_cred and not has_cli_host and launch_credential_uid == dag_linked_uid: + logging.warning( + '--credential %s matches linked Launch Credential; treating as if no --credential provided', + launch_credential, + ) + launch_credential_uid = None + has_cli_cred = False + + # --host / --host-record require allowSupplyHost + if has_cli_host and not allow_supply_host: + raise CommandError('pam launch', + '--host / --host-record requires allowSupplyHost to be enabled on the record. ' + '(Web Vault: Record > Allow shared users to select their own host and credential)') + + if has_cli_cred: + # with host options -> allowSupplyHost; without -> allowSupplyUser or allowSupplyHost + if has_cli_host: + if not allow_supply_host: + raise CommandError('pam launch', + '--credential with --host/--host-record requires allowSupplyHost to be enabled.') + else: + if not allow_supply_user and not allow_supply_host: + raise CommandError('pam launch', + '--credential requires allowSupplyUser or allowSupplyHost to be enabled on the record.') + + # Strictly validate --credential record has login and password + cred_record = vault.KeeperRecord.load(params, launch_credential_uid) + if not cred_record: + raise CommandError('pam launch', f'Credential record not found: {launch_credential_uid}') + if not _record_has_credentials(cred_record, params): + raise CommandError('pam launch', + f'Credential record {launch_credential_uid} must have non-empty login and ' + 'password, or login with an SSH private key.') + + if allow_supply_host: + # allowSupplyHost mode: host comes from -H/-hr (CLI) or from the --credential record. + if has_cli_host: + # -H/-hr provided: CLI host wins. Warn if --credential also has a host. + if _record_has_host_port(cred_record): + _cr_host, _ = _get_host_port_from_record(cred_record) + logging.warning( + '--host / --host-record (%s:%s) overrides host %r from --credential record %s; ' + 'the credential record host will be ignored.', + custom_host, custom_port, _cr_host, launch_credential_uid, + ) + else: + # no -H/-hr -> --credential record must supply host:port. + if not _record_has_host_port(cred_record): + raise CommandError('pam launch', + f'Credential record {launch_credential_uid} must have a non-empty host and port ' + 'when allowSupplyHost is enabled and no --host or --host-record is provided.') + cred_host, cred_port = _get_host_port_from_record(cred_record) + custom_host = cred_host + custom_port = cred_port + kwargs['custom_host'] = custom_host + kwargs['custom_port'] = custom_port + logging.debug(f"Using host from --credential record: {custom_host}:{custom_port}") + + else: + # allowSupplyUser mode: only login + password come from --credential. + # Any host/pamHostname on the --credential record is intentionally ignored; + # host and port always come from the PAM machine/connection record. + if _record_has_host_port(cred_record): + _cr_host, _ = _get_host_port_from_record(cred_record) + logging.warning( + 'allowSupplyUser mode: host %r in --credential record %s is ignored; ' + 'host and port will come from the PAM machine record.', + _cr_host, launch_credential_uid, + ) + + kwargs['launch_credential_uid'] = launch_credential_uid + logging.debug(f"Using --credential: {launch_credential_uid}") + + else: + # No --credential: validate that the record itself provides what's needed + if not has_cli_host: + # No CLI host -> must come from the PAM launch record + if not hostname_on_record: + if allow_supply_host and sys.stdin.isatty() and sys.stdout.isatty(): + # Interactive prompt instead of erroring out — covers the common + # `pam launch ` case for records with allowSupplyHost and no + # static hostname (e.g. SuperShell launching from the TUI). + prompt_text = ( + f'{Fore.CYAN}Enter host for launch ' + f'{Fore.WHITE}(format: host:port, e.g. 192.168.1.1:22 or ' + f'server.example.com:3306, [::1]:22 for IPv6){Fore.RESET}\n' + f'{Fore.CYAN}Host:port: {Fore.RESET}' + ) + custom_host = None + custom_port = None + for _ in range(3): + try: + user_input = input(prompt_text).strip() + except (EOFError, KeyboardInterrupt): + logging.info('Canceled') + return + if not user_input: + logging.info('Canceled') + return + try: + custom_host, custom_port = _parse_host_port(user_input) + break + except CommandError as e: + print(f'{Fore.RED}{e}{Fore.RESET}', file=sys.stderr) + custom_host = None + custom_port = None + if custom_host is None: + logging.error('Too many invalid host entries; aborting launch.') + return + kwargs['custom_host'] = custom_host + kwargs['custom_port'] = custom_port + has_cli_host = True + logging.info( + 'Using interactively supplied host: %s:%s', custom_host, custom_port + ) + elif allow_supply_host: + raise CommandError('pam launch', + 'allowSupplyHost is enabled but no hostname on record. ' + 'Use --host, --host-record, or --credential with a host:port to specify.') + else: + raise CommandError('pam launch', + f'No hostname configured for record {record_uid}.') + + # No CLI options at all -> validate DAG-linked credential has login + password or SSH key + if dag_linked_uid: + dag_cred_record = vault.KeeperRecord.load(params, dag_linked_uid) + if dag_cred_record and not _record_has_credentials(dag_cred_record, params): + raise CommandError('pam launch', + f'Linked credential record {dag_linked_uid} has no usable auth ' + '(need login and password, or login and SSH private key). ' + 'Configure valid credentials or use --credential to override.') + elif not allow_supply_user and not allow_supply_host: + raise CommandError('pam launch', + f'No credentials configured for record {record_uid}. ' + 'Configure a linked credential or enable allowSupplyUser/allowSupplyHost.') + + # Gateway resolution — cache hit reuses the cached entry, cache + # miss calls find_gateway and populates the cache on success. + if _cached_gateway_info is not None: + gateway_info = _cached_gateway_info + logging.debug( + f"Launch cache hit: reusing {gateway_info['gateway_name']} " + f"({gateway_info['gateway_uid']}) — background refresh in-flight" + ) + else: + # Cache miss — resolve fresh (reuse _launch_tdag to skip a get_leafs roundtrip). + gateway_info = self.find_gateway(params, record_uid, tdag=_launch_tdag) + + if not gateway_info: + raise CommandError('pam launch', f'No gateway found for record {record_uid}') + + logging.debug(f"Found gateway: {gateway_info['gateway_name']} ({gateway_info['gateway_uid']})") + logging.debug(f"Configuration: {gateway_info['config_uid']}") + _exec_tc.checkpoint('find_gateway_ok') + + # Populate the launch cache now that DAG + gateway are both resolved + # for this record. Future launches in this session hit the cache. + launch_cache.put(record_uid, { + 'dag_linked_uid': dag_linked_uid, + 'config_uid': gateway_info.get('config_uid'), + 'gateway_uid': gateway_info['gateway_uid'], + 'gateway_name': gateway_info.get('gateway_name') or 'Unknown', + }) + + # Optionally check if Gateway appears online; if not, log warning and try anyway. + # Runs on cache hit too so the offline warning is not skipped. + try: + connected_gateways = router_get_connected_gateways(params.vault) + if connected_gateways and connected_gateways.controllers: + connected_gateway_uids = [x.controllerUid for x in connected_gateways.controllers] + gateway_uid_bytes = url_safe_str_to_bytes(gateway_info['gateway_uid']) + if gateway_uid_bytes not in connected_gateway_uids: + # Root logger is ERROR when not DEBUG; use logging.error so this is visible. + logging.error( + 'Gateway "%s" (%s) seems offline - trying to connect anyway.', + gateway_info['gateway_name'], + gateway_info['gateway_uid'], + ) + else: + logging.debug("✓ Gateway is online and connected") + else: + logging.error('Gateway seems offline - trying to connect anyway.') + except Exception as e: + logging.debug('Could not verify gateway status: %s. Continuing...', e) + _exec_tc.checkpoint('gateway_online_verified') + + if pam_connection_font_size is not None and str(pam_connection_font_size).strip() != '': + fs_int = _pam_connection_font_size_int(pam_connection_font_size) + if fs_int != 12: + fs_disp = fs_disp = str(fs_int) if fs_int is not None else str(pam_connection_font_size).strip() + logging.warning( + 'Record %s sets connection.fontSize=%s (session overrides with 12); session recordings ' + 'may look different from this Commander terminal session.', + record_uid, + fs_disp, + ) + print( + f'Warning: connection.fontSize={fs_disp} is ignored here; this session uses font size 12 ', + ) + + # Banner + spinner: only after pamSettings record gates (readOnly, disableCopy, disablePaste, etc.), + # credential/host validation, gateway resolution, optional online probe, and fontSize discrepancy. + _debug_connect_ui = bool(getattr(params, 'debug', False)) or logging.getLogger().isEnabledFor( + logging.DEBUG + ) + pre_connect_spinner: Optional[PamLaunchSpinner] = None + _banner_name_connect = (getattr(record, 'title', None) or record_token or record_uid or '').strip() or 'PAM resource' + if not _debug_connect_ui: + print(f'Launching connection to {_banner_name_connect}...', flush=True) + pre_connect_spinner = PamLaunchSpinner('[ Establishing secure session… ]') + pre_connect_spinner.start() + + # Pass the resolved DAG UID through so extract_terminal_settings does not + # rebuild the DAG. kwargs carries both the ConnectAs-relevant + # launch_credential_uid (possibly CLI-overridden) and the authoritative + # dag_linked_uid used only for DAG-comparison logic. + kwargs['dag_linked_uid'] = dag_linked_uid + _exec_tc.checkpoint('pre_terminal_connection') + _exec_tc.summary('execute_pre_interactive') + + # Launch terminal connection + try: + result = launch_terminal_connection(params, record_uid, gateway_info, **kwargs) + except BaseException: + if pre_connect_spinner is not None and getattr( + pre_connect_spinner, 'running', False + ): + pre_connect_spinner.stop() + raise + + if result.get('success'): + logging.debug("Terminal connection launched successfully") + logging.debug(f"Protocol: {result.get('protocol')}") + + # Warn early: clipboard policy + gateway risk before the terminal session starts. + _clip = result.get('settings', {}).get('clipboard', {}) + if _clip.get('disablePaste') or _clip.get('disableCopy'): + print('Warning: This record disables clipboard copy and/or paste in PAM settings.') + logging.debug( + '\nWarning: This record disables clipboard copy and/or paste in PAM. ' + 'Commander enforces that in the client; guacd also receives disable-paste/' + 'disable-copy when the record requires it. Some gateways never emit a ' + 'Guacamole terminal `pipe` in that configuration — `pam launch` may show no ' + 'shell output. Workarounds: use Web Vault for this machine, temporarily allow ' + 'clipboard on the record for CLI (enable-pipe is still requested in the offer).\n' + ) + if _clip.get('disablePaste'): + logging.debug( + 'disablePaste: local clipboard paste is off; paste chords send key events ' + '(remote session clipboard / TTY paste).\n' + ) + if _clip.get('disableCopy'): + logging.debug('Copy is disabled (disableCopy): remote text will not be placed on the local clipboard.\n') + + # Always start interactive CLI session + # Pass launch_credential_uid to know if ConnectAs payload is needed + _scale = kwargs.get('scale') + if isinstance(_scale, int): + if _scale < 40 or _scale > 400: + if pre_connect_spinner is not None: + pre_connect_spinner.stop() + raise CommandError('pam launch', + f'--scale must be between 40 and 400 (got {_scale})') + _banner_title = getattr(record, 'title', None) or record_token or record_uid + try: + self._start_cli_session( + result, + params, + kwargs.get('launch_credential_uid'), + use_stdin=kwargs.get('use_stdin', False), + cli_scale=_scale, + connect_banner_title=_banner_title, + pre_connect_spinner=pre_connect_spinner, + preserve_crlf=not bool(kwargs.get('normalize_crlf')), + pam_total_tc=_total_tc, + workflow_expires_on_ms=workflow_expires_on_ms, + workflow_flow_uid=workflow_flow_uid, + workflow_started_by_launch=workflow_started_by_launch, + ) + except BaseException: + if pre_connect_spinner is not None and getattr( + pre_connect_spinner, 'running', False + ): + pre_connect_spinner.stop() + raise + else: + if pre_connect_spinner is not None: + pre_connect_spinner.stop() + error_msg = result.get('error', 'Unknown error') + raise CommandError('pam launch', f'Failed to launch connection: {error_msg}') + finally: + # Restore original root logger level + root_logger.setLevel(original_level) + + def _start_cli_session( + self, + tunnel_result: Dict[str, Any], + params: KeeperParams, + launch_credential_uid: Optional[str] = None, + use_stdin: bool = False, + cli_scale: Optional[int] = None, + connect_banner_title: Optional[str] = None, + pre_connect_spinner: Optional[PamLaunchSpinner] = None, + preserve_crlf: bool = True, + pam_total_tc: Optional[PamConnectTiming] = None, + workflow_expires_on_ms: int = 0, + workflow_flow_uid: Optional[bytes] = None, + workflow_started_by_launch: bool = False, + ): + """ + Start CLI session using PythonHandler protocol mode. + + In PythonHandler mode: + - Python initiates connection via tube_registry.open_handler_connection() + - Rust forwards OpenConnection to Gateway and handles Ping/Pong heartbeat + - Gateway starts guacd and connects to target + - Python receives Guacamole protocol data via callback + - Python sends Guacamole responses back via tube_registry.send_handler_data() + + Flow: + 1. Wait for WebRTC connection to be established + 2. Send OpenConnection to Gateway (conn_no=1) + 3. Gateway starts guacd, sends 'args' instruction + 4. Python responds with 'connect', 'size', 'audio', 'image' + 5. guacd sends 'ready', terminal session begins + + Input modes + ----------- + Default (key-event): + InputHandler maps every keystroke to a Guacamole `key` instruction + (press + release), matching Web Vault behaviour. + --stdin (pipe mode): + StdinHandler sends typed bytes via the pipe/blob/end STDIN stream, + matching kcm-cli behaviour. + Both modes share the same paste (Ctrl+V / Shift+Insert → Vault clipboard + stream) and Ctrl+C double-tap (400 ms → local exit) logic. + + Args: + tunnel_result: Result from launch_terminal_connection + params: KeeperParams instance + launch_credential_uid: Optional UID resolved from CLI --credential; + triggers ConnectAs payload when set. + use_stdin: When True use StdinHandler (pipe/byte mode) instead of + the default InputHandler (key-event mode). + connect_banner_title: Record title (or fallback) for the pre-session banner and spinner. + pre_connect_spinner: If set, an already-started PamLaunchSpinner from execute() after record checks + (banner printed there); do not create a second spinner or duplicate the launching line. + preserve_crlf: When True (default), STDOUT keeps raw CRLF; False when ``pam launch -n`` / ``--normalize-crlf``. + """ + # Non-interactive stdin guard: key-event mode requires a real TTY. + # --stdin (pipe mode) is fine with redirected stdin, but key mode is not — + # tty.setraw() will raise and character-at-a-time mapping makes no sense + # for piped/scripted input. + if not use_stdin and not sys.stdin.isatty(): + if pre_connect_spinner is not None: + pre_connect_spinner.stop() + raise CommandError( + 'pam launch', + 'Interactive (key-event) mode requires a TTY. ' + 'stdin is not a terminal — scripted/piped input is not supported. ' + 'If you need to drive pam launch non-interactively use --stdin.', + ) + shutdown_requested = False + lease_expired = False + # Latest close reason from the rust webrtc layer (snake_case name from + # PyCloseConnectionReason). Set asynchronously by _on_session_disconnect + # below; consumed in the inner finally to print a user-facing notice. + closure_reason: Optional[str] = None + # Whether the guac session was live (≥1 sync) at the moment of remote + # close. Captured in _on_session_disconnect; lets the notice treat a + # guacd_error after an established session as a normal logout. + session_established_at_close: bool = False + # Distinct exit code for involuntary terminations (KeeperAI, admin). + # Raised as SystemExit at the end of the method so the inner/outer + # finally cleanup blocks run first. + pending_exit_code: Optional[int] = None + + def signal_handler_fn(signum, frame): + nonlocal shutdown_requested + shutdown_requested = True + logging.warning("\n\n* Interrupt received - shutting down...") + + original_handler = signal.signal(signal.SIGINT, signal_handler_fn) + + # Workflow lease expiry: schedule teardown at expiresOn matching + # the web vault (immediate teardown, no grace period, no reconnect). + # The "Access expired" line is printed AFTER terminal reset in finally + # so the message survives reset_local_terminal_after_pam_session(). + # On expiry we close the tube; the connection-closed cleanup path then + # stops the websocket and unregisters the tunnel session. + lease_timer = None + if workflow_expires_on_ms and workflow_expires_on_ms > 0: + seconds_until_expiry = (workflow_expires_on_ms / 1000.0) - time.time() + _lease_tube_id = tunnel_result['tunnel'].get('tube_id') + _lease_tube_registry = tunnel_result['tunnel'].get('tube_registry') + + def _on_lease_expired(): + nonlocal shutdown_requested, lease_expired + lease_expired = True + shutdown_requested = True + if _lease_tube_id and _lease_tube_registry is not None: + try: + _lease_tube_registry.close_tube( + _lease_tube_id, + reason=CloseConnectionReasons.AdminClosed, + ) + except Exception as e: + logging.debug( + f"[lease-expiry launch tube={_lease_tube_id[:8]}] " + f"close_tube failed: {e}" + ) + + if seconds_until_expiry <= 0: + # Already expired at session start: run the close path + # immediately so cleanup goes through the same flow as a + # mid-session expiry. + _on_lease_expired() + else: + import threading as _threading + lease_timer = _threading.Timer(seconds_until_expiry, _on_lease_expired) + lease_timer.daemon = True + lease_timer.start() + + rust_log_token = None + try: + rust_log_token = enter_pam_launch_terminal_rust_logging() + tube_id = tunnel_result['tunnel'].get('tube_id') + if not tube_id: + raise CommandError('pam launch', 'No tube ID in tunnel result') + + tube_registry = tunnel_result['tunnel'].get('tube_registry') + if not tube_registry: + raise CommandError('pam launch', 'No tube registry in tunnel result') + + python_handler = tunnel_result['tunnel'].get('python_handler') + if not python_handler: + raise CommandError('pam launch', 'No python_handler in tunnel result - ensure Rust module supports PythonHandler mode') + + # Capture remote close reason from the rust webrtc layer so the + # finally block below can show a reason-specific notice (KeeperAI, + # admin, etc.). Runs on the rust callback thread — do not print + # here; terminal is still in raw mode. + def _on_session_disconnect(reason: str) -> None: + nonlocal closure_reason, shutdown_requested, session_established_at_close + closure_reason = reason + shutdown_requested = True + # Snapshot whether data was flowing so the notice can tell a clean + # guacd-initiated logout from a real connect-time guacd fault. + try: + session_established_at_close = bool(python_handler.is_data_flowing()) + except Exception: + pass + + python_handler.on_disconnect = _on_session_disconnect + + conversation_id = tunnel_result['tunnel'].get('conversation_id') + + logging.debug(f"Starting PythonHandler CLI session for tube {tube_id}") + + # Display connection banner + logging.debug(f"\n{'-' * 60}") + logging.debug(f"CLI Terminal Mode - PythonHandler") + logging.debug(f"Protocol: {tunnel_result['protocol']}") + logging.debug(f"Target: {tunnel_result['settings']['hostname']}:{tunnel_result['settings']['port']}") + logging.debug(f"Tube ID: {tube_id}") + logging.debug(f"{'-' * 60}") + logging.debug("Python sends: OpenConnection (initiates guacd session)") + logging.debug("Rust handles: Ping/Pong heartbeat, message routing") + logging.debug("Python receives: Guacamole protocol data via callback") + logging.debug(f"{'=' * 60}\n") + + # Banner + spinner: starts in execute() after pamSettings gates and fontSize warning; continues here + # through WebRTC/OpenConnection. Stops before the terminal-height newline clear. Skip when debug logging + # is on — concurrent log lines break the animation. + _debug_connect_ui = bool(getattr(params, 'debug', False)) or logging.getLogger().isEnabledFor( + logging.DEBUG + ) + _connect_spinner: Optional[PamLaunchSpinner] = pre_connect_spinner + if _connect_spinner is None and not _debug_connect_ui: + _banner_name = (connect_banner_title or '').strip() or 'PAM resource' + print(f'Launching connection to {_banner_name}...', flush=True) + _connect_spinner = PamLaunchSpinner('[ Establishing secure session… ]') + _connect_spinner.start() + try: + _cli_tc = PamConnectTiming('pam-launch:cli_session') + _cli_tc.checkpoint('cli_session_try_enter') + # Start the Python handler + python_handler.start() + _cli_tc.checkpoint('python_handler_start_done') + + # Wait for WebRTC connection to be established. + # Poll tick defaults to 25ms (was 100ms) — cheap FFI call, + # tightens P99 handoff latency. Set PAM_WEBRTC_POLL_MS to override. + # Timeout defaults to 30s (was 15s) — accommodates TURN-relay + # fallback and failed first-pair retries. Set + # PAM_WEBRTC_CONNECT_TIMEOUT_SEC to override. + logging.debug("Waiting for WebRTC connection...") + max_wait = webrtc_connect_timeout_sec() + start_time = time.time() + connected = False + poll_tick = webrtc_connection_poll_sec() + _last_state = None # kept for diagnostics when we time out + + while time.time() - start_time < max_wait: + try: + state = tube_registry.get_connection_state(tube_id) + _last_state = state + if state and state.lower() == 'connected': + logging.debug(f"✓ WebRTC connection established: {state}") + connected = True + break + except Exception as e: + logging.debug(f"Checking connection state: {e}") + time.sleep(poll_tick) + + if not connected: + # Capture tube_status too — it distinguishes "ICE still + # gathering" vs "data channel never opened", which is the + # usual question when a timeout surfaces in QA. + _tube_status = None + try: + if hasattr(tube_registry, 'get_tube_status'): + _tube_status = tube_registry.get_tube_status(tube_id) + except Exception as _e: + logging.debug(f"Could not read tube_status on timeout: {_e}") + # Stop the spinner first so the error does not print on the + # same line as the spinner animation ("[ Establishing secure + # session… ]pam launch: ..."). + if _connect_spinner is not None and getattr(_connect_spinner, 'running', False): + try: + _connect_spinner.stop() + except Exception: + pass + logging.error( + 'pam launch: WebRTC connection not established within %.1fs ' + '(last connection_state=%r, tube_status=%r). ICE negotiation ' + 'stalled — this is usually transient; please re-run the command. ' + 'Set PAM_WEBRTC_CONNECT_TIMEOUT_SEC= to change the timeout.', + max_wait, _last_state, _tube_status, + ) + raise CommandError('pam launch', "WebRTC connection not established within timeout") + _cli_tc.checkpoint('webrtc_data_plane_connected') + + # Wait for DataChannel to be ready and Gateway to wire the session. + # connection state "connected" can precede DataChannel readiness; Gateway also needs + # time to associate the WebRTC connection with the channel and prepare guacd. + # Default 0.05s — a small safety margin on top of the open_handler_connection + # retry loop below (exponential backoff already handles slow DataChannel). + # Set PAM_OPEN_CONNECTION_DELAY=2.0 to restore the legacy safety wait. + open_conn_delay = open_connection_delay_sec() + if open_conn_delay > 0: + time.sleep(open_conn_delay) + _cli_tc.checkpoint('open_connection_delay_done') + + # Send OpenConnection to Gateway to initiate guacd session + # This is critical - without it, Gateway doesn't start guacd and no Guacamole traffic flows + # Retry with exponential backoff if DataChannel isn't ready yet + logging.debug(f"Sending OpenConnection to Gateway (conn_no=1, conversation_id={conversation_id})") + + # Build ConnectAs payload when cliUserOverride is set — this covers both: + # (a) explicit -cr that differs from DAG-linked, and + # (b) implicit userRecords[0] fallback (no DAG link, allowSupply* enabled, no -cr given). + # In case (b) launch_credential_uid is None; use userRecordUid from settings instead. + connect_as_payload = None + gateway_uid = tunnel_result['tunnel'].get('gateway_uid') + _tunnel_settings = tunnel_result.get('settings', {}) + cli_user_override = _tunnel_settings.get('cliUserOverride', False) + effective_credential_uid = launch_credential_uid or ( + _tunnel_settings.get('userRecordUid') if cli_user_override else None + ) + + # Remote keeper-pam-webrtc-rs version: from tunnel (non-streaming) or session (streaming) + remote_webrtc_version = tunnel_result['tunnel'].get('remote_webrtc_version') + if remote_webrtc_version is None: + sess = get_tunnel_session(tube_id) + remote_webrtc_version = getattr(sess, 'remote_webrtc_version', None) if sess else None + + connect_as_supported = _version_at_least(remote_webrtc_version, CONNECT_AS_MIN_VERSION) + + if cli_user_override and effective_credential_uid and gateway_uid: + # When using userRecords[0] fallback, include explanation in CommandError if ConnectAs fails + connect_as_fallback_msg = '' + if launch_credential_uid is None: + connect_as_fallback_msg = ( + f'Using credential from userRecords[0] ({effective_credential_uid}) as ConnectAs fallback because ' + 'no launch credential on record; ConnectAs is enabled but no --credential was given. ' + ) + if not connect_as_supported: + raise CommandError( + 'pam launch', + connect_as_fallback_msg + + f'ConnectAs (--credential) requires Gateway with keeper-pam-webrtc-rs >= {CONNECT_AS_MIN_VERSION}. ' + f'Remote version: {remote_webrtc_version or "unknown"}. ' + 'Please upgrade the Gateway to use --credential.' + ) + logging.debug(f"Building ConnectAs payload for credential: {effective_credential_uid}") + gateway_public_key = _retrieve_gateway_public_key(params, gateway_uid) + if gateway_public_key: + connect_as_payload = _build_connect_as_payload(params, effective_credential_uid, gateway_public_key) + if connect_as_payload: + logging.debug(f"ConnectAs payload built: {len(connect_as_payload)} bytes") + else: + logging.warning("Failed to build ConnectAs payload - credentials may not be passed to gateway") + else: + logging.warning("Could not retrieve gateway public key - credentials may not be passed to gateway") + + max_retries = 5 + retry_delay = 0.1 + last_error = None + + for attempt in range(max_retries): + try: + # Pass ConnectAs payload when user supplied credentials via -cr (matches vault behavior) + tube_registry.open_handler_connection( + conversation_id, 1, connect_as_payload + ) + logging.debug("✓ OpenConnection sent successfully") + _cli_tc.checkpoint('open_connection_sent_ok') + break + except Exception as e: + last_error = e + error_str = str(e).lower() + # Check if error is DataChannel-related + if "datachannel" in error_str or "not opened" in error_str: + if attempt < max_retries - 1: + wait_time = retry_delay * (2 ** attempt) # Exponential backoff + logging.debug(f"DataChannel not ready, retrying in {wait_time:.2f}s (attempt {attempt + 1}/{max_retries})") + time.sleep(wait_time) + continue + # For other errors or final attempt, raise immediately + logging.error(f"Failed to send OpenConnection: {e}") + raise CommandError('pam launch', f"Failed to send OpenConnection: {e}") + else: + # All retries exhausted + logging.error(f"Failed to send OpenConnection after {max_retries} attempts: {last_error}") + raise CommandError('pam launch', f"Failed to send OpenConnection after {max_retries} attempts: {last_error}") + finally: + if _connect_spinner is not None: + _connect_spinner.stop() + + # Wait for Guacamole ready (after spinner cleared; blank lines scroll the banner away) + print("Waiting for Guacamole connection...", flush=True) + + # Clear screen by printing terminal height worth of newlines. + # This prevents raw mode from overwriting existing screen lines. + # Keep in sync: terminal_reset uses max(current rows, this) at exit. + pam_session_start_rows = None + terminal_height = 24 + try: + terminal_size = shutil.get_terminal_size() + terminal_height = terminal_size.lines + except Exception: + terminal_height = 24 + pam_session_start_rows = terminal_height + print("\n" * terminal_height, end='', flush=True) + + guac_ready_timeout = 10.0 # Reduced from 30s - sync triggers readiness quickly + + guac_ready_result = python_handler.wait_for_ready(guac_ready_timeout) + _cli_tc.checkpoint( + 'guacamole_wait_for_ready_ok' if guac_ready_result else 'guacamole_wait_for_ready_timeout' + ) + _cli_tc.summary('cli_session_pre_interactive') + if guac_ready_result: + logging.debug("* Guacamole connection ready!") + logging.debug( + 'Terminal session active. Ctrl+C → remote interrupt; double Ctrl+C (<400 ms) to exit.', + ) + # Handshake ``size`` may have been sent while the local console was still + # changing during WebRTC/backend wait (before ``pre_offer_sync`` patches the + # handler). Push the current grid as a runtime ``size`` once data is flowing. + if is_interactive_tty(): + try: + _pr_raw = get_terminal_size_pixels() + if isinstance(cli_scale, int) and cli_scale > 0 and cli_scale != 100: + _pr = scale_screen_info( + _pr_raw["columns"], _pr_raw["rows"], cli_scale + ) + else: + _pr = _pr_raw + python_handler.send_size( + _pr['pixel_width'], + _pr['pixel_height'], + _pr['dpi'], + ) + logging.debug( + 'Post-ready Guacamole size sync: %sx%s -> %sx%spx @ %sdpi%s', + _pr['columns'], + _pr['rows'], + _pr['pixel_width'], + _pr['pixel_height'], + _pr['dpi'], + f' (--scale {cli_scale}%)' if cli_scale else '', + ) + except Exception as _e: + logging.debug('Post-ready size sync skipped: %s', _e) + + # Correct font-size to 12 via argv — GW may have applied the record's + # fontSize during the upstream handshake before our connect instruction. + # font-size=12 is required for pixel metrics to match the 96-DPI cell model. + _record_font_size = tunnel_result.get('settings', {}).get('terminal', {}).get('fontSize') + if _record_font_size and str(_record_font_size) != '12': + try: + python_handler.send_argv('font-size', '12') + logging.debug('Post-ready argv: font-size corrected from %s to 12', _record_font_size) + except Exception as _e: + logging.debug('Post-ready argv font-size skipped: %s', _e) + else: + logging.warning(f"Guacamole did not report ready within {guac_ready_timeout}s") + logging.warning("Terminal may still work if data is flowing.") + + # Check for STDOUT pipe support (feature detection) + # This warns the user if CLI pipe mode is not supported by the gateway + _clipboard_pol = tunnel_result.get('settings', {}).get('clipboard', {}) + _pam_clipboard = ( + _pam_connection_clipboard_bool(_clipboard_pol.get('disablePaste')) + or _pam_connection_clipboard_bool(_clipboard_pol.get('disableCopy')) + ) + python_handler.check_stdout_pipe_support( + timeout=45.0 if _pam_clipboard else 10.0, + pam_clipboard_record_policy=_pam_clipboard, + ) + + # Shared input coordinators: + # Both InputHandler (key mode) and StdinHandler (--stdin mode) use + # the same CtrlCCoordinator and PasteOrchestrator so paste and + # Ctrl+C double-tap behave identically in both modes. + + def _request_exit(): + nonlocal shutdown_requested + shutdown_requested = True + + disable_paste = _pam_connection_clipboard_bool(_clipboard_pol.get('disablePaste')) + + if use_stdin: + # Pipe mode: remote interrupt = raw Ctrl+C byte via send_stdin + ctrl_c_coord = CtrlCCoordinator( + remote_interrupt_fn=lambda: python_handler.send_stdin(b'\x03'), + local_exit_fn=_request_exit, + ) + else: + # Key-event mode: remote interrupt = send_key(ETX) press+release + def _remote_key_ctrl_c() -> None: + python_handler.send_key(3, True) + python_handler.send_key(3, False) + + ctrl_c_coord = CtrlCCoordinator( + remote_interrupt_fn=_remote_key_ctrl_c, + local_exit_fn=_request_exit, + ) + + # No PasteOrchestrator when PAM disables paste — only Guacamole key chords (no pyperclip). + paste_orch: Optional[PasteOrchestrator] = None + if not disable_paste: + paste_orch = PasteOrchestrator( + send_clipboard_fn=python_handler.send_clipboard_stream, + disable_paste=False, + ) + + # Build the appropriate input handler: + if use_stdin: + input_handler = StdinHandler( + stdin_callback=lambda data: python_handler.send_stdin(data), + key_callback=lambda keysym, pressed: python_handler.send_key(keysym, pressed), + ctrl_c_coordinator=ctrl_c_coord, + paste_orchestrator=paste_orch, + ) + logging.debug('Input mode: --stdin (pipe/blob/end, StdinHandler)') + else: + input_handler = InputHandler( + key_callback=lambda keysym, pressed: python_handler.send_key(keysym, pressed), + ctrl_c_coordinator=ctrl_c_coord, + paste_orchestrator=paste_orch, + disable_paste=disable_paste, + ) + logging.debug('Input mode: key-event (InputHandler, default)') + + # Grand-total stop point: we're about to hand control to input_handler.start() + # and enter the interactive loop. Everything after this is session runtime, + # not launch time. Fires after check_stdout_pipe_support + coordinator setup so + # the total reflects the *user-visible* time-to-prompt, not just guac-ready. + if pam_total_tc is not None: + pam_total_tc.summary('ready_for_prompt') + + # Main event loop with input handler + try: + # Start input handler (runs in background thread) + input_handler.start() + # Ctrl+C → byte 0x03 in the input thread (CtrlCCoordinator). Windows clears + # ENABLE_PROCESSED_INPUT in the stdin readers so the key is delivered; SIG_IGN had + # blocked ReadConsoleInput/msvcrt from seeing Ctrl+C on Win11. + mode_label = '--stdin (pipe)' if use_stdin else 'key-event (default)' + logging.debug( + 'Input handler started [mode=%s]. ' + 'Ctrl+C → remote interrupt; press Ctrl+C twice quickly (<400 ms) to exit. ' + 'Paste chords → Guacamole clipboard stream, or key events when disablePaste.', + mode_label, + ) + + # --- Terminal resize tracking --- + # Resize polling is skipped entirely in non-interactive (piped) + # environments where get_terminal_size() returns a dummy value. + _resize_enabled = is_interactive_tty() + # Poll cols/rows cheaply every N iterations; a timestamp guard + # ensures correctness if the loop sleep interval ever changes. + _RESIZE_POLL_EVERY = 3 # iterations (~0.3 s at 0.1 s/iter) + _RESIZE_POLL_INTERVAL = 0.3 # seconds - authoritative threshold + _RESIZE_DEBOUNCE = 0.25 # seconds - max send rate during drag + _resize_poll_counter = 0 + _last_resize_poll_time = 0.0 + _last_resize_send_time = 0.0 + # Track the last *sent* size; only updated when we actually send. + # This keeps re-detecting the change each poll during rapid resize + # so the final resting size is always dispatched. + _last_sent_cols = 0 + _last_sent_rows = 0 + + if _resize_enabled: + try: + _init_ts = shutil.get_terminal_size() + _last_sent_cols = _init_ts.columns + _last_sent_rows = _init_ts.lines + except Exception: + _resize_enabled = False + logging.debug("Could not query initial terminal size - resize polling disabled") + + elapsed = 0 + while not shutdown_requested and python_handler.running: + # Check if tube/connection is closed + try: + state = tube_registry.get_connection_state(tube_id) + if state and state.lower() in ('closed', 'disconnected', 'failed'): + logging.debug(f"Tube/connection closed (state: {state}) - exiting") + # Do not set python_handler.running = False here: the input thread would + # still read stdin and send_key/send_stdin would drop bytes (first key + # "eaten" after return to Commander). Stopping order is input_handler + # first in finally, then python_handler.stop() clears running. + shutdown_requested = True + break + except Exception: + # If we can't check state, continue (tube might be closing) + pass + time.sleep(0.1) + elapsed += 0.1 + # SIGINT may set shutdown_requested during sleep; exit before resize/status work. + if shutdown_requested or not python_handler.running: + break + + # --- Resize polling (Phase 1: cheap cols/rows check) --- + # Check every _RESIZE_POLL_EVERY iterations AND at least + # _RESIZE_POLL_INTERVAL seconds since the last poll, so the + # check stays correct if the loop sleep ever changes. + if _resize_enabled: + _resize_poll_counter += 1 + _now = time.time() + if ( + _resize_poll_counter % _RESIZE_POLL_EVERY == 0 + and _now - _last_resize_poll_time >= _RESIZE_POLL_INTERVAL + ): + _last_resize_poll_time = _now + try: + _cur_ts = shutil.get_terminal_size() + _cur_cols = _cur_ts.columns + _cur_rows = _cur_ts.lines + except Exception: + _cur_cols, _cur_rows = _last_sent_cols, _last_sent_rows + + # Send only when cols or rows change; pixel values are derived + # from the grid via kcm_cli_approximate_pixels in get_terminal_size_pixels. + if (_cur_cols, _cur_rows) != (_last_sent_cols, _last_sent_rows): + # Phase 2: size changed - apply debounce then + # fetch exact pixels and send. + if _now - _last_resize_send_time >= _RESIZE_DEBOUNCE: + if shutdown_requested or not python_handler.running: + break + try: + if isinstance(cli_scale, int) and cli_scale > 0 and cli_scale != 100: + _si = scale_screen_info( + _cur_cols, _cur_rows, cli_scale + ) + else: + _si = get_terminal_size_pixels( + _cur_cols, _cur_rows + ) + python_handler.send_size( + _si['pixel_width'], + _si['pixel_height'], + _si['dpi'], + ) + # Track what get_terminal_size_pixels actually used + # (it re-queries internally), not just the poll value. + # If the inner query saw a transient size, the next + # poll will detect the mismatch and send a correction. + _last_sent_cols = _si['columns'] + _last_sent_rows = _si['rows'] + _last_resize_send_time = _now + logging.debug( + f"Terminal resized: {_si['columns']}x{_si['rows']} cols/rows " + f"-> {_si['pixel_width']}x{_si['pixel_height']}px @ {_si['dpi']}dpi " + ) + except Exception as _e: + logging.debug(f"Failed to send resize: {_e}") + # else: debounce active - last sent * not updated + # so the change is re-detected on the next eligible poll. + + # Status indicator every 30 seconds + if elapsed % 30.0 < 0.1 and elapsed > 0.1: + rx = python_handler.messages_received + tx = python_handler.messages_sent + syncs = python_handler.sync_count + logging.debug(f"[{int(elapsed)}s] Session active (rx={rx}, tx={tx}, syncs={syncs})") + + except KeyboardInterrupt: + shutdown_requested = True + logging.debug("\n\nExiting CLI terminal mode...") + + except Exception as e: + shutdown_requested = True + logging.debug(f"CLI session loop ended abnormally: {e}") + raise + + finally: + # Stop input handler first (restores terminal) + logging.debug("Stopping input handler...") + try: + input_handler.stop() + except Exception as e: + logging.debug(f"Error stopping input handler: {e}") + + # Fullscreen TUIs (nano, mcedit, etc.) may leave alternate screen / + # cursor modes in the outer terminal; restore after raw mode is back. + try: + reset_local_terminal_after_pam_session( + session_start_rows=pam_session_start_rows, + ) + except Exception as e: + logging.debug(f"Terminal reset after pam session: {e}") + + # Print lease-expiry notice AFTER terminal reset so the user sees + # it on a clean line (the reset can wipe pre-reset stderr writes). + if lease_expired: + print('\nAccess expired — session terminated by workflow lease.', flush=True) + + # Reason-specific notice for involuntary closures from the + # gateway / rust webrtc layer (KeeperAI, admin, transport errors). + # Stays silent for normal/client-initiated closes. + pending_exit_code = _print_close_reason_notice( + closure_reason, + pending_exit_code=pending_exit_code, + session_established=session_established_at_close, + ) + + # Cleanup - check if connection is already closed to avoid deadlock + logging.debug("Stopping Python handler...") + try: + # Check if tube is already closed - if so, skip sending disconnect + try: + state = tube_registry.get_connection_state(tube_id) + skip_disconnect = state and state.lower() in ('closed', 'disconnected', 'failed') + except Exception: + skip_disconnect = False + + python_handler.stop(skip_disconnect=skip_disconnect) + except Exception as e: + logging.debug(f"Error stopping Python handler: {e}") + + # Close the tube (Rust handles CloseConnection automatically) + logging.debug("Closing WebRTC tunnel...") + try: + tube_registry.close_tube(tube_id) + logging.debug(f"Closed tube: {tube_id}") + except Exception as e: + logging.debug(f"Error closing tube: {e}") + + # Clean up registrations + try: + unregister_tunnel_session(tube_id) + if conversation_id: + unregister_conversation_key(conversation_id) + except Exception as e: + logging.debug(f"Error unregistering: {e}") + + logging.info("CLI session ended - cleanup complete") + + except CommandError: + raise + except Exception as e: + logging.error(f"Error in PythonHandler CLI session: {e}") + raise CommandError('pam launch', f'Failed to start CLI session: {e}') + finally: + if lease_timer is not None: + try: + lease_timer.cancel() + except Exception: + pass + # Auto check-in: release the workflow lease only if THIS launch + # triggered the checkout (so a pre-existing checkout isn't released) + # and the lease hasn't already expired (server already ended it). + if (workflow_started_by_launch and not lease_expired + and workflow_flow_uid): + try: + from ...workflow.helpers import ProtobufRefBuilder + from ....helpers.router_utils import _post_request_to_router_for_params as _post_request_to_router + flow_ref = ProtobufRefBuilder.workflow_ref(workflow_flow_uid) + _post_request_to_router(params, 'end_workflow', rq_proto=flow_ref) + logging.debug("Auto check-in: released workflow lease.") + except Exception as e: + logging.debug("Auto check-in failed: %s", e) + exit_pam_launch_terminal_rust_logging(rust_log_token) + signal.signal(signal.SIGINT, original_handler) + + # Surface a distinct OS exit code (KeeperAI=40, admin=41) only when + # Commander is running in batch / scripted mode (e.g. `keeper pam + # launch UID` from a shell). In the interactive shell (`keeper shell` + # -> `pam launch UID`) the user expects to land back at the Keeper> + # prompt — a SystemExit would tear the whole shell down. Raised after + # both finally blocks so cleanup is already complete. + if pending_exit_code is not None and getattr(params, 'batch_mode', False): + raise SystemExit(pending_exit_code) diff --git a/keepercli-package/src/keepercli/commands/pam/pam_launch/launch_cache.py b/keepercli-package/src/keepercli/commands/pam/pam_launch/launch_cache.py new file mode 100644 index 00000000..df39208b --- /dev/null +++ b/keepercli-package/src/keepercli/commands/pam/pam_launch/launch_cache.py @@ -0,0 +1,145 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' Optional[Dict[str, Any]]: + """Return a shallow copy of the cache entry for ``record_uid``, or None. + + Returns a copy so callers can safely mutate the dict (e.g. add + ``gateway_proto=None``) without disturbing the shared cache state. + """ + with _CACHE_LOCK: + entry = _CACHE.get(record_uid) + if entry is None: + return None + return dict(entry) + + +def put(record_uid: str, entry: Dict[str, Any]) -> None: + """Insert / overwrite the cache entry for ``record_uid``. + + ``entry`` must carry ``dag_linked_uid`` (may be None), ``config_uid``, + ``gateway_uid``, and ``gateway_name``. ``timestamp`` is set here. + """ + with _CACHE_LOCK: + stored = dict(entry) + stored['timestamp'] = time.time() + _CACHE[record_uid] = stored + + +def invalidate(record_uid: str) -> None: + """Drop the cache entry for ``record_uid`` (e.g. after a launch failure + that clearly indicates stale cache).""" + with _CACHE_LOCK: + _CACHE.pop(record_uid, None) + + +def _entries_differ(a: Dict[str, Any], b: Dict[str, Any]) -> bool: + """True if any of the load-bearing fields differ between two entries.""" + return any(a.get(k) != b.get(k) for k in _CACHE_VALUE_KEYS) + + +def spawn_refresh(record_uid: str, fetch_fn: Callable[[], Optional[Dict[str, Any]]]) -> None: + """Spawn a daemon thread that calls ``fetch_fn()`` and, if it returns a + new entry dict, updates the cache for the NEXT launch. + + ``fetch_fn`` should perform the full fresh resolution (TunnelDAG build + + find_gateway) and return a dict with ``dag_linked_uid`` / ``config_uid`` + / ``gateway_uid`` / ``gateway_name``, or None if resolution failed (e.g. + transient network error). A single in-flight refresh per record is + enforced — concurrent launches on the same record share one refresh. + """ + with _CACHE_LOCK: + if _REFRESHING.get(record_uid): + _LOG.debug('pam-launch cache: refresh already in-flight for %s, skipping', record_uid) + return + _REFRESHING[record_uid] = True + + def _run_refresh() -> None: + try: + fresh = fetch_fn() + if not fresh: + _LOG.debug('pam-launch cache: refresh returned no entry for %s', record_uid) + return + old = get(record_uid) + put(record_uid, fresh) + if old is not None and _entries_differ(old, fresh): + _LOG.info( + 'pam-launch cache: refreshed %s — values changed ' + '(dag_linked_uid=%s→%s, gateway_uid=%s→%s)', + record_uid, + old.get('dag_linked_uid'), fresh.get('dag_linked_uid'), + old.get('gateway_uid'), fresh.get('gateway_uid'), + ) + else: + _LOG.debug('pam-launch cache: refreshed %s (no change)', record_uid) + except Exception as e: + # Background refresh must never crash the main launch. Log and move on. + _LOG.debug('pam-launch cache: refresh failed for %s: %s', record_uid, e) + finally: + with _CACHE_LOCK: + _REFRESHING.pop(record_uid, None) + + t = threading.Thread( + target=_run_refresh, + daemon=True, + name=f'pam-launch-cache-refresh-{record_uid[:8] if record_uid else "?"}', + ) + t.start() diff --git a/keepercli-package/src/keepercli/commands/pam/pam_launch/python_handler.py b/keepercli-package/src/keepercli/commands/pam/pam_launch/python_handler.py new file mode 100644 index 00000000..19554fcd --- /dev/null +++ b/keepercli-package/src/keepercli/commands/pam/pam_launch/python_handler.py @@ -0,0 +1,1197 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' , then blob/end + self.remote_clipboard_stream_index: int = -1 + self._remote_clipboard_mimetype: Optional[str] = None + self._remote_clipboard_acc = bytearray() + + # Feature detection for CLI pipe mode + # STDOUT pipe: if the server supports plaintext SSH/TTY mode, it sends a STDOUT pipe + # STDIN pipe: when we try to send input, the server should ack successfully + self.stdout_pipe_opened = threading.Event() # Set when STDOUT pipe is received + self.stdin_pipe_failed = False # Set if STDIN pipe ack fails + self.stdin_stream_index: int = 0 # Stream index we use for STDIN + self.pending_stdin_ack = False # True when waiting for STDIN ack + + # Create instruction router with custom handlers for our needs + # Pass self as stdout_stream_tracker so router can decode STDOUT blobs + self.parser.oninstruction = create_instruction_router( + custom_handlers={ + 'args': self._on_args, + 'sync': self._on_sync, + 'ready': self._on_ready, + 'disconnect': self._on_guac_disconnect, + 'error': self._on_error, + 'ack': self._on_ack, # Custom ack handler for STDIN failure detection + 'pipe': self._on_pipe, # Custom pipe handler for STDOUT detection + 'clipboard': self._on_remote_clipboard_instruction, + }, + send_ack_callback=self._send_ack, + stdout_stream_tracker=self, + normalize_stdout_crlf=bool(self.connection_settings.get('normalize_crlf', False)), + ) + + # State + self.running = False + self.last_sync_timestamp: Optional[str] = None + + # Connection readiness states (matching JS client behavior) + # JS client transitions WAITING -> CONNECTED on first sync, NOT on 'ready' + # We track both for compatibility: + # - handshake_complete: Set when we receive 'ready' instruction (informational) + # - data_flowing: Set when we receive first 'sync' instruction (TRUE readiness) + self.handshake_complete = threading.Event() # 'ready' received (custom extension) + self.data_flowing = threading.Event() # First 'sync' received (protocol standard) + + # For backwards compatibility, connection_ready = data_flowing + # This matches JS client behavior where sync = ready + self.connection_ready = self.data_flowing + + self.guac_connection_id: Optional[str] = None + self.sync_count = 0 # Track number of syncs received + + # Statistics + self.messages_received = 0 + self.bytes_received = 0 + self.messages_sent = 0 + self.bytes_sent = 0 + + # Clipboard stream counter — starts at 200 to avoid collision with + # image streams (1–99) and named pipe streams (100–101). + self._clipboard_stream_index: int = 200 + # argv stream counter — starts at 300 to avoid collision with clipboard streams. + self._argv_stream_index: int = 300 + + # Count `pipe` opcodes from guacd (diagnostics when STDOUT never binds). + self._guac_pipe_instruction_count: int = 0 + + def note_guac_pipe_instruction(self) -> None: + """Incremented by the instruction router for each well-formed pipe from guacd.""" + self._guac_pipe_instruction_count += 1 + + def start(self): + """Start the handler.""" + if self.running: + return + self.running = True + logging.debug(f"GuacamoleHandler started (conversation_id={self.conversation_id})") + + def stop(self, skip_disconnect: bool = False): + """ + Stop the handler and optionally send disconnect to guacd. + + Args: + skip_disconnect: If True, skip sending disconnect instruction. + Use this when connection is already closed to avoid deadlock. + """ + if not self.running: + return + + self.running = False + + # Send graceful disconnect to guacd (unless connection already closed) + if not skip_disconnect: + try: + disconnect_instruction = self._format_instruction('disconnect') + self._send_to_gateway(disconnect_instruction) + logging.debug("Sent disconnect instruction to guacd") + except Exception as e: + # Don't warn if connection is already closed - this is expected + if "closed" not in str(e).lower() and "disconnected" not in str(e).lower(): + logging.warning(f"Failed to send disconnect instruction: {e}") + + logging.debug( + f"GuacamoleHandler stopped (conversation_id={self.conversation_id}, " + f"rx={self.messages_received}, tx={self.messages_sent})" + ) + + def handle_events(self, events: List[Dict[str, Any]]): + """ + Handle a batch of events from Rust PythonHandler. + + This is called by the Rust handler task with a list of events. + Events are batched for GIL efficiency (up to 10 messages per batch). + + Args: + events: List of event dicts, each with: + - type: "connection_opened" | "data" | "connection_closed" + - conn_no: Connection number (int) + - conversation_id: Conversation ID (str) + - payload: Bytes data (for "data" events) + - reason: Close reason code (for "connection_closed" events) + """ + for event in events: + try: + self._handle_single_event(event) + except Exception as e: + logging.error(f"Error handling event: {e}", exc_info=True) + + def _handle_single_event(self, event: Dict[str, Any]): + """Handle a single event from Rust.""" + event_type = event.get('type') + conn_no = event.get('conn_no', 1) + + if event_type == 'connection_opened': + self._on_connection_opened(conn_no) + elif event_type == 'data': + payload = event.get('payload', b'') + self._on_data(conn_no, payload) + elif event_type == 'connection_closed': + reason = event.get('reason', 0) + self._on_connection_closed(conn_no, reason) + else: + logging.warning(f"Unknown event type: {event_type}") + + def _on_connection_opened(self, conn_no: int): + """ + Handle connection_opened event from Rust. + + This is sent when the Gateway has acknowledged the OpenConnection + request and the virtual connection is now established. + + Flow: + 1. Python calls tube_registry.open_handler_connection(conversation_id, conn_no) + 2. Rust sends OpenConnection control frame to Gateway via WebRTC + 3. Gateway receives OpenConnection, starts guacd, connects to target + 4. Gateway sends ConnectionOpened back to Rust + 5. Rust notifies Python via this callback + 6. Gateway may begin streaming Guacamole **server→client** display ops + (size/move/pipe/img/…). The initial **args** handshake from guacd is + often completed **upstream** (gateway↔guacd) and never forwarded here, + so ``_on_args`` may never run. + """ + logging.debug(f"✓ Connection opened: conn_no={conn_no}") + self.conn_no = conn_no + + # Guacamole protocol continues on the data channel; handshake may already + # be complete on the gateway side (no 'args' instruction to Python). + + def _on_data(self, conn_no: int, payload: bytes): + """ + Handle data event from Rust. + + This contains pure Guacamole protocol data (no channel prefix). + The Rust layer has already stripped the frame header. + """ + if not payload: + return + + self.messages_received += 1 + self.bytes_received += len(payload) + + try: + # Decode Guacamole instructions + instructions_str = payload.decode('utf-8') + + # Log received data for debugging + if logging.getLogger().isEnabledFor(logging.DEBUG): + preview = instructions_str[:100] + logging.debug( + f"<<< GUACD DATA: {len(payload)} bytes, preview: {preview}" + f"{'...' if len(instructions_str) > 100 else ''}" + ) + + # Parse and dispatch instructions + self.parser.receive(instructions_str) + + except UnicodeDecodeError: + logging.debug(f"Binary data received ({len(payload)} bytes): {payload[:32].hex()}...") + + def _on_connection_closed(self, conn_no: int, reason: int): + """ + Handle connection_closed event from Rust. + + This is sent when the gateway/guacd closes the connection. + """ + reason_name = self._close_reason_name(reason) + logging.debug(f"Connection closed: conn_no={conn_no}, reason={reason} ({reason_name})") + + # Stop without sending disconnect (connection already closed) + self.stop(skip_disconnect=True) + + if self.on_disconnect: + try: + self.on_disconnect(reason_name) + except Exception as e: + logging.error(f"Error in disconnect callback: {e}") + + def _on_args(self, args: List[str]) -> None: + """ + Handle args instruction from guacd (via Gateway). + + **When this runs:** Only if the gateway **forwards** guacd's ``args`` + instruction on the PythonHandler data channel. Many PAM deployments + complete select/args/size/connect **between gateway and guacd** and only + then stream display + STDOUT to Python — in that mode this handler is + **never** called. + + When it does run, respond with 'size', 'audio', 'video', 'image', then + 'connect' (guacr-guacd order) so DPI in ``size`` precedes ``connect``. + + Guacamole handshake sequence: + 1. Gateway sends 'select ' to guacd + 2. guacd responds with 'args' (list of required params) + 3. We respond with 'size', 'audio', 'video', 'image', 'connect' (matches guacd client / guacr-guacd) + 4. guacd responds with 'ready' + + Args: + args: Parameter names that guacd expects (first is version, rest are params) + """ + if self.handshake_sent: + logging.debug(f"Ignoring duplicate 'args' instruction (handshake already sent)") + return + + logging.debug(f"✓ Received 'args' from guacd: {list(args)}") + + try: + # Build and send the handshake response + self._send_handshake_response(list(args)) + self.handshake_sent = True + logging.debug("✓ Guacamole handshake sent (size,audio,video,image,connect)") + except Exception as e: + logging.error(f"Error sending handshake response: {e}", exc_info=True) + + def _send_handshake_response(self, args_list: List[str]): + """ + Send the complete Guacamole handshake response. + + Args: + args_list: List of parameter names from guacd's 'args' instruction + """ + settings = self.connection_settings + + # Get terminal dimensions (default to standard CLI size) + width = settings.get('width', 800) + height = settings.get('height', 600) + # Default must match ``screen_info['dpi']`` / pixel mode (``default_handshake_dpi()``), + # or Cairo cell metrics diverge from client pixel sizing (wrong $COLUMNS / TUI layout). + dpi = settings.get('dpi', default_handshake_dpi()) + + # Get guacd parameters (hostname, port, username, password, etc.) + guacd_params = settings.get('guacd_params', {}) + + # Build connect args: first arg is version (from guacd), rest are param values + connect_args = [] + + # First arg from guacd is the version requirement + if args_list: + version = args_list[0] if args_list[0] else "VERSION_1_5_0" + connect_args.append(version) + + # For each remaining parameter guacd requested, provide the value + for param_name in args_list[1:]: + # Normalize param name for lookup (remove hyphens/underscores, lowercase) + normalized = param_name.replace('-', '').replace('_', '').lower() + + # Look up in guacd_params with various key formats + value = "" + for key in [param_name, normalized, param_name.replace('-', '_'), param_name.replace('_', '-')]: + if key in guacd_params: + value = str(guacd_params[key]) + break + # Also try lowercase version + if key.lower() in guacd_params: + value = str(guacd_params[key.lower()]) + break + + connect_args.append(value) + + # Guacd expects the same order as guacr-guacd / JS client: size (with DPI), then + # audio/video/image, then connect — not connect before size. + size_instruction = self._format_instruction('size', width, height, dpi) + self._send_to_gateway(size_instruction) + logging.debug( + f"Sent 'size' (handshake): {width}x{height} @ {dpi}dpi, " + f"cols/rows:{settings.get('columns')}x{settings.get('rows')}" + ) + + audio_mimetypes = settings.get('audio_mimetypes', []) + audio_instruction = self._format_instruction('audio', *audio_mimetypes) + self._send_to_gateway(audio_instruction) + logging.debug(f"Sent 'audio': {audio_mimetypes}") + + video_mimetypes = settings.get('video_mimetypes', []) + video_instruction = self._format_instruction('video', *video_mimetypes) + self._send_to_gateway(video_instruction) + logging.debug(f"Sent 'video': {video_mimetypes}") + + image_mimetypes = settings.get('image_mimetypes', ['image/png', 'image/jpeg', 'image/webp']) + image_instruction = self._format_instruction('image', *image_mimetypes) + self._send_to_gateway(image_instruction) + logging.debug(f"Sent 'image': {image_mimetypes}") + + connect_instruction = self._format_instruction('connect', *connect_args) + self._send_to_gateway(connect_instruction) + logging.debug(f"Sent 'connect' with {len(connect_args)} args") + + def _on_sync(self, args: List[str]) -> None: + """ + Handle sync instruction from guacd. + + Guacamole requires sync acknowledgments to maintain connection. + + IMPORTANT: The JS client uses the first sync as the TRUE readiness signal, + transitioning from WAITING to CONNECTED state. We follow the same pattern. + This is more reliable than waiting for 'ready' (which is a custom extension). + + Args: + args: [timestamp] or [timestamp, frames] + """ + timestamp = args[0] if args else "0" + frames = args[1] if len(args) > 1 else "0" + + self.last_sync_timestamp = timestamp + self.sync_count += 1 + + # First sync = TRUE connection ready (matches JS client behavior) + # JS Client.js line 1679: if (currentState === WAITING) setState(CONNECTED) + if self.sync_count == 1: + self.data_flowing.set() + logging.debug(f"* First sync received - connection ready (timestamp={timestamp})") + + # Call on_ready callback if not already called by 'ready' instruction + if self.on_ready and not self.handshake_complete.is_set(): + try: + self.on_ready() + except Exception as e: + logging.error(f"Error in ready callback: {e}") + + # Log but don't spam + logging.debug(f"SYNC #{self.sync_count}: timestamp={timestamp}, frames={frames}") + + # Send sync acknowledgment back to guacd + try: + ack = self._format_instruction('sync', timestamp) + self._send_to_gateway(ack) + except Exception as e: + logging.error(f"Error sending sync ack: {e}") + + def _on_ready(self, args: List[str]) -> None: + """ + Handle ready instruction from guacd. + + This indicates the Guacamole handshake is complete. + NOTE: This is a custom extension - the JS client doesn't have a 'ready' handler. + The TRUE readiness signal is the first 'sync' instruction. + + Args: + args: [connection_id] + """ + connection_id = args[0] if args else "" + self.guac_connection_id = connection_id + self.handshake_complete.set() + + # Also signal data_flowing for compatibility (in case ready comes before sync) + # This ensures wait_for_ready() returns true on either signal + self.data_flowing.set() + + logging.debug(f"✓ Guacamole ready! Connection established: connection_id={connection_id}") + + if self.on_ready: + try: + self.on_ready() + except Exception as e: + logging.error(f"Error in ready callback: {e}") + + def _on_guac_disconnect(self, args: List[str]) -> None: + """Handle disconnect instruction from guacd.""" + logging.debug(f"Server sent disconnect instruction (args: {args})") + + # Stop without sending disconnect (server already disconnected) + self.stop(skip_disconnect=True) + + if self.on_disconnect: + try: + self.on_disconnect("server_disconnect") + except Exception as e: + logging.error(f"Error in disconnect callback: {e}") + + def _on_error(self, args: List[str]) -> None: + """Handle error instruction from guacd.""" + message = args[0] if args else "Unknown error" + code = args[1] if len(args) > 1 else "0" + + logging.error(f"Guacamole error {code}: {message}") + + def _on_pipe(self, args: List[str]) -> None: + """ + Handle pipe instruction - track STDOUT pipe opening for feature detection. + + When the server supports plaintext SSH/TTY mode, it sends a pipe with name "STDOUT". + If this pipe never opens, the feature is not supported by the gateway/connection. + + Note: The instruction router handles STDOUT ack, blob decode, and stdout_pipe_opened + when the pipe is treated as terminal output (STDOUT name or text/* fallback). + + Args: + args: [stream_index, mimetype, name] + """ + if len(args) >= 3: + stream_index, mimetype, name = args[0], args[1], args[2] + logging.debug(f"[PIPE] stream={stream_index}, type={mimetype}, name={name}") + else: + logging.debug(f"[PIPE] {args}") + + def _on_ack(self, args: List[str]) -> None: + """ + Handle ack instruction - detect STDIN pipe failures. + + When we try to send input via STDIN pipe, the server should ack successfully. + If the ack has a non-zero code, the STDIN pipe feature is not supported. + + Args: + args: [stream_index, message, code] + """ + if len(args) >= 3: + stream_index, message, code = args[0], args[1], args[2] + logging.debug(f"[ACK] stream={stream_index}, message='{message}', code={code}") + + # Check if this is an ack for our STDIN stream + if self.pending_stdin_ack and int(stream_index) == self.stdin_stream_index: + self.pending_stdin_ack = False + if code != '0': + # Non-zero code means STDIN pipe failed + self.stdin_pipe_failed = True + logging.warning( + f"STDIN pipe failed (stream={stream_index}, code={code}, message='{message}'). " + f"CLI input mode may not be supported by this connection." + ) + else: + logging.debug(f"[ACK] {args}") + + def _on_remote_clipboard_instruction(self, args: List[str]) -> None: + """Open inbound clipboard stream from guacd; ack and prepare for blob/end.""" + if len(args) < 2: + logging.debug(f"[CLIPBOARD] unexpected args: {args}") + return + stream, mimetype = args[0], args[1] + try: + idx = int(stream) + except ValueError: + logging.debug(f"[CLIPBOARD] bad stream index: {stream!r}") + return + self.remote_clipboard_stream_index = idx + self._remote_clipboard_mimetype = mimetype + self._remote_clipboard_acc.clear() + try: + self._send_ack(stream, 'OK', '0') + except Exception as e: + logging.error(f"Clipboard stream ack failed: {e}") + logging.debug(f"[CLIPBOARD] inbound stream={stream}, type={mimetype}") + + def handle_remote_clipboard_blob(self, stream_index: str, b64_data: str) -> bool: + """ + Called by the instruction router for blob instructions. + Returns True if this blob belonged to the active inbound clipboard stream. + """ + if self.remote_clipboard_stream_index < 0: + return False + try: + if int(stream_index) != self.remote_clipboard_stream_index: + return False + except ValueError: + return False + try: + if not self._clipboard_disable_copy: + self._remote_clipboard_acc.extend(base64.b64decode(b64_data)) + self._send_ack(stream_index, 'OK', '0') + except Exception as e: + logging.error(f"Inbound clipboard blob error: {e}") + try: + self._send_ack(stream_index, 'OK', '0') + except Exception: + pass + return True + + def handle_remote_clipboard_end(self, stream_index: str) -> bool: + """ + Called by the instruction router for end instructions. + Returns True if this ended the active inbound clipboard stream. + """ + if self.remote_clipboard_stream_index < 0: + return False + try: + if int(stream_index) != self.remote_clipboard_stream_index: + return False + except ValueError: + return False + try: + if ( + not self._clipboard_disable_copy + and self._remote_clipboard_acc + and (self._remote_clipboard_mimetype or '').lower().startswith('text/') + ): + text = self._remote_clipboard_acc.decode('utf-8', errors='replace') + try: + import pyperclip # type: ignore[import] + + pyperclip.copy(text) + except ImportError: + logging.warning( + 'Remote clipboard data received but pyperclip is not installed; ' + 'cannot copy to the local clipboard. Run: pip install pyperclip' + ) + except Exception as e: + logging.warning(f'Could not copy remote clipboard to local OS: {e}') + else: + logging.debug( + 'Remote clipboard copied to local OS (%d chars)', len(text) + ) + finally: + self.remote_clipboard_stream_index = -1 + self._remote_clipboard_mimetype = None + self._remote_clipboard_acc.clear() + return True + + def _format_instruction(self, *elements) -> bytes: + """Format elements into a Guacamole instruction.""" + # Use the new guacamole module's to_instruction function + # It takes a list, returns str, we encode to bytes + instruction_str = to_instruction(list(elements)) + return instruction_str.encode('utf-8') + + def _send_ack(self, stream_index: str, message: str, code: str): + """ + Send ack instruction for stream acknowledgment. + + Used by the instruction router to acknowledge pipe/blob instructions. + + Args: + stream_index: Stream index (as string) + message: Acknowledgment message (usually "OK") + code: Status code (usually "0" for success) + """ + try: + instruction = self._format_instruction('ack', stream_index, message, code) + self._send_to_gateway(instruction) + logging.debug(f"ACK sent: stream={stream_index}, message={message}, code={code}") + except Exception as e: + logging.error(f"Error sending ack: {e}") + + def _send_to_gateway(self, data: bytes): + """ + Send Guacamole data back to gateway via Rust. + + Uses send_handler_data() which routes through the WebRTC channel. + """ + if isinstance(data, str): + data = data.encode('utf-8') + + try: + self.tube_registry.send_handler_data( + self.conversation_id, + self.conn_no, + data + ) + self.messages_sent += 1 + self.bytes_sent += len(data) + logging.debug(f">>> GUACD SEND: {len(data)} bytes") + except Exception as e: + logging.error(f"Failed to send data to gateway: {e}") + raise + + def send_stdin(self, data: bytes): + """ + Send stdin data to guacd using the pipe/blob/end pattern. + + This is the preferred method for plaintext SSH/TTY streams. + It matches the kcm-cli implementation: + - pipe,0,text/plain,STDIN (open stream) + - blob,0, (send data) + - end,0 (close stream) + + Only sends if session is active (running and data flowing). + + Args: + data: Raw bytes to send as stdin (e.g., keyboard input) + """ + # Guard: only send during active session + if not self.running: + logging.debug("Ignoring stdin - handler not running") + return + if not self.data_flowing.is_set(): + logging.debug("Ignoring stdin - connection not ready") + return + + # Check if STDIN pipe previously failed + if self.stdin_pipe_failed: + logging.debug("Ignoring stdin - STDIN pipe not supported") + return + + try: + # Use stream index 0 for STDIN (matching kcm-cli) + stream_index = '0' + self.stdin_stream_index = int(stream_index) + + # Track that we're waiting for ack (for failure detection) + self.pending_stdin_ack = True + + # Send pipe instruction to open STDIN stream + pipe_instruction = self._format_instruction('pipe', stream_index, 'text/plain', 'STDIN') + self._send_to_gateway(pipe_instruction) + + # Send blob with base64-encoded data + data_base64 = base64.b64encode(data).decode('ascii') + blob_instruction = self._format_instruction('blob', stream_index, data_base64) + self._send_to_gateway(blob_instruction) + + # Send end to close the stream + end_instruction = self._format_instruction('end', stream_index) + self._send_to_gateway(end_instruction) + + # Log for debugging + if logging.getLogger().isEnabledFor(logging.DEBUG): + preview = data[:20].decode('utf-8', errors='replace') if len(data) <= 20 else data[:20].decode('utf-8', errors='replace') + '...' + logging.debug(f"STDIN: sent {len(data)} bytes: {repr(preview)}") + + except Exception as e: + logging.error(f"Error sending stdin: {e}") + + def check_stdout_pipe_support( + self, + timeout: float = 10.0, + *, + pam_clipboard_record_policy: bool = False, + ) -> bool: + """ + Check if STDOUT pipe is supported with a timeout. + + This should be called after connection is established (after first sync). + If the STDOUT pipe doesn't open within the timeout, warns the user that + CLI pipe mode may not be supported. + + Args: + timeout: Seconds to wait for STDOUT pipe (default 10.0) + pam_clipboard_record_policy: PAM record disables copy/paste; gateway may still + force guacd clipboard off after the Commander handshake (no STDOUT pipe). + + Returns: + True if STDOUT pipe opened, False if timeout expired + """ + if self.stdout_pipe_opened.wait(timeout): + logging.debug("STDOUT pipe support confirmed") + return True + else: + logging.warning( + f"STDOUT pipe did not open within {timeout}s. " + f"CLI pipe mode may not be supported by this gateway/connection." + ) + n_pipe = self._guac_pipe_instruction_count + print( + "\nNo STDOUT stream has been received since the connection was opened. " + "This may indicate the gateway/guacd does not support CLI mode. " + "You can continue waiting, or press Ctrl+C to cancel." + ) + if pam_clipboard_record_policy: + if n_pipe == 0: + logging.error( + "This record disables clipboard copy or paste in PAM. Some KCM builds may " + "omit the terminal pipe entirely. Commander requests enable-pipe in the offer " + "- if pipes still never appear use Web Vault or temporarily allow " + "clipboard on the record for CLI sesions.\n" + ) + else: + print( + f"\nThis record disables PAM clipboard; guacd sent {n_pipe} pipe instruction(s) " + "but none were accepted as terminal STDOUT (unexpected).\n" + ) + return False + + def is_stdin_supported(self) -> bool: + """ + Check if STDIN pipe is supported. + + Returns: + True if STDIN pipe has not failed, False if it failed + """ + return not self.stdin_pipe_failed + + def send_key(self, keysym: int, pressed: bool): + """ + Send a key event to guacd using X11 keysym. + + NOTE: For plaintext SSH/TTY streams, use send_stdin() instead. + This method is for graphical protocols (RDP, VNC) that use X11 keysyms. + + Only sends if session is active (running and data flowing). + + Args: + keysym: X11 keysym value + pressed: True for press, False for release + """ + # Guard: only send keys during active session + if not self.running: + logging.debug(f"Ignoring key event - handler not running") + return + if not self.data_flowing.is_set(): + logging.debug(f"Ignoring key event - connection not ready") + return + + try: + instruction = self._format_instruction('key', keysym, 1 if pressed else 0) + self._send_to_gateway(instruction) + # Log key events for debugging (only press, not release to reduce spam) + if pressed: + # Show printable chars, hex for control/special keys + if 32 <= keysym < 127: + logging.debug(f"KEY: '{chr(keysym)}' (0x{keysym:04X})") + else: + logging.debug(f"KEY: 0x{keysym:04X} (special)") + except Exception as e: + logging.error(f"Error sending key event: {e}") + + def send_mouse(self, x: int, y: int, buttons: int = 0): + """ + Send a mouse event to guacd. + + Only sends if session is active (running and data flowing). + + Args: + x: X coordinate + y: Y coordinate + buttons: Button mask + """ + if not self.running or not self.data_flowing.is_set(): + return + + try: + instruction = self._format_instruction('mouse', x, y, buttons) + self._send_to_gateway(instruction) + except Exception as e: + logging.error(f"Error sending mouse event: {e}") + + def send_size(self, width: int, height: int, dpi: Optional[int] = None): + """ + Send terminal size to guacd (runtime resize). + + Sends ``size`` with width, height, and DPI (three arguments), matching the + handshake instruction shape and the same DPI as handshake (typically + ``default_handshake_dpi()`` for the active pixel mode) so guacd can + keep Cairo cell metrics aligned on every resize where supported. + + Only sends if session is active (running and data flowing). + + Args: + width: Width in pixels + height: Height in pixels + dpi: Display DPI for font rasterisation (defaults to ``default_handshake_dpi()``) + """ + if not self.running or not self.data_flowing.is_set(): + return + + if dpi is None: + dpi = default_handshake_dpi() + + try: + instruction = self._format_instruction('size', width, height, dpi) + self._send_to_gateway(instruction) + except Exception as e: + logging.error(f"Error sending size: {e}") + + def send_clipboard_stream(self, text: str) -> None: + """ + Send clipboard text using the Web Vault-equivalent stream protocol. + + Mirrors GuacamoleClipboard.setRemoteClipboard: + createClipboardStream(mimetype) → clipboard instruction + StringWriter.sendText(data) → blob instruction (base64) + StringWriter.sendEnd() → end instruction + + Wire format: + clipboard,,text/plain; + blob,,; + end,; + + Never uses send_stdin for clipboard data. + + Args: + text: Clipboard text to send + """ + if not self.running or not self.data_flowing.is_set(): + return + + try: + stream_id = str(self._clipboard_stream_index) + self._clipboard_stream_index += 1 + + data_b64 = base64.b64encode(text.encode('utf-8')).decode('ascii') + + self._send_to_gateway( + self._format_instruction('clipboard', stream_id, 'text/plain') + ) + self._send_to_gateway( + self._format_instruction('blob', stream_id, data_b64) + ) + self._send_to_gateway( + self._format_instruction('end', stream_id) + ) + + logging.debug( + 'Clipboard stream sent: %d chars, stream_id=%s', len(text), stream_id + ) + except Exception as exc: + logging.error(f"Error sending clipboard stream: {exc}") + + def send_argv(self, name: str, value: str) -> None: + """ + Change a guacd connection parameter at runtime via the argv stream protocol. + + Wire format (mirrors Guacamole JS client argv channel): + argv,,text/plain,; + blob,,; + end,; + + Args: + name: Parameter name (e.g. 'font-size', 'color-scheme'). + value: New parameter value as a string. + """ + if not self.running or not self.data_flowing.is_set(): + return + try: + stream_id = str(self._argv_stream_index) + self._argv_stream_index += 1 + value_b64 = base64.b64encode(value.encode('utf-8')).decode('ascii') + self._send_to_gateway( + self._format_instruction('argv', stream_id, 'text/plain', name) + ) + self._send_to_gateway( + self._format_instruction('blob', stream_id, value_b64) + ) + self._send_to_gateway( + self._format_instruction('end', stream_id) + ) + logging.debug('argv sent: %s=%r stream_id=%s', name, value, stream_id) + except Exception as exc: + logging.error('Error sending argv %s: %s', name, exc) + + def wait_for_ready(self, timeout: float = 10.0) -> bool: + """ + Wait for the Guacamole connection to be ready. + + Connection is considered ready when: + - First 'sync' instruction is received (matches JS client behavior), OR + - 'ready' instruction is received (custom extension) + + The JS Guacamole client (guacamole-common-js) considers the connection + CONNECTED when the first sync is received, not when 'ready' is received. + We follow the same pattern for reliability. + + Args: + timeout: Maximum seconds to wait (default: 10.0, was 30.0) + Handshake typically completes in <500ms on normal networks. + + Returns: + True if ready (sync or ready received), False if timeout + """ + import time + start = time.time() + + result = self.connection_ready.wait(timeout) + + elapsed = time.time() - start + if result: + logging.debug(f"Connection ready after {elapsed:.3f}s") + else: + # Provide diagnostic info on timeout + logging.warning( + f"Timeout after {elapsed:.1f}s waiting for ready - " + f"received {self.messages_received} messages ({self.bytes_received} bytes), " + f"syncs={self.sync_count}, handshake_sent={self.handshake_sent}" + ) + + return result + + def is_data_flowing(self) -> bool: + """ + Check if data is flowing (sync messages being received). + + Returns: + True if at least one sync has been received + """ + return self.sync_count > 0 + + @staticmethod + def _close_reason_name(reason: int) -> str: + """Convert close reason code to snake_case name. + + Mirrors ``PyCloseConnectionReason`` in + ``keeper-pam-webrtc-rs/src/python/enums.rs``. Code 3 is + intentionally absent in the rust enum. + """ + reasons = { + 0: "normal", + 1: "error", + 2: "timeout", + 4: "server_refuse", + 5: "client", + 6: "unknown", + 7: "invalid_instruction", + 8: "guacd_refuse", + 9: "connection_lost", + 10: "connection_failed", + 11: "tunnel_closed", + 12: "admin_closed", + 13: "error_recording", + 14: "guacd_error", + 15: "ai_closed", + 16: "address_resolution_failed", + 17: "decryption_failed", + 18: "configuration_error", + 19: "protocol_error", + 20: "upstream_closed", + } + return reasons.get(reason, f"code_{reason}") + + def __enter__(self): + """Context manager entry.""" + self.start() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit.""" + self.stop() + return False + + +def create_handler_callback(handler: GuacamoleHandler) -> Callable[[List[Dict]], None]: + """ + Create a callback function for the Rust PythonHandler. + + This wraps the handler's handle_events method in a function + that can be passed to the Rust create_tube() call. + + Args: + handler: GuacamoleHandler instance + + Returns: + Callback function that accepts a list of event dicts + """ + def callback(events: List[Dict[str, Any]]): + handler.handle_events(events) + + return callback + + +def create_python_handler( + tube_registry, + conversation_id: str, + conn_no: int = 1, + connection_settings: Optional[Dict[str, Any]] = None, + on_ready: Optional[Callable[[], None]] = None, + on_disconnect: Optional[Callable[[str], None]] = None, +) -> tuple: + """ + Create a PythonHandler callback and handler for Guacamole CLI. + + This is the main entry point for setting up PythonHandler mode. + It creates both the handler instance and the callback function + that should be passed to tube_registry.create_tube(). + + Args: + tube_registry: PyTubeRegistry instance + conversation_id: Conversation/channel ID + conn_no: Connection number (default: 1) + connection_settings: Connection parameters for Guacamole handshake: + - protocol: Protocol type (ssh, telnet, mysql, etc.) + - hostname: Target hostname + - port: Target port + - width: Terminal width in pixels + - height: Terminal height in pixels + - dpi: Display DPI (default ``default_handshake_dpi()``) + - audio_mimetypes: List of supported audio types + - image_mimetypes: List of supported image types + - guacd_params: Dict of guacd connection parameters + on_ready: Optional callback when Guacamole connection is ready + on_disconnect: Optional callback when connection closes + + Returns: + Tuple of (callback_function, handler_instance) + + Example: + callback, handler = create_python_handler( + tube_registry, + conversation_id, + connection_settings={ + 'protocol': 'ssh', + 'width': 800, + 'height': 600, + 'dpi': 192, + 'guacd_params': { + 'hostname': '192.168.1.100', + 'port': '22', + 'username': 'admin', + 'password': 'secret', + } + }, + on_ready=lambda: print("Connected!"), + on_disconnect=lambda reason: print(f"Disconnected: {reason}") + ) + + # Pass callback to create_tube + result = tube_registry.create_tube( + conversation_id=conversation_id, + settings={...}, + handler_callback=callback, + ... + ) + + # Start handler + handler.start() + + # Wait for connection + if handler.wait_for_ready(timeout=30): + print("Guacamole session ready!") + """ + handler = GuacamoleHandler( + tube_registry=tube_registry, + conversation_id=conversation_id, + conn_no=conn_no, + connection_settings=connection_settings, + on_ready=on_ready, + on_disconnect=on_disconnect, + ) + + callback = create_handler_callback(handler) + + return callback, handler diff --git a/keepercli-package/src/keepercli/commands/pam/pam_launch/rust_log_filter.py b/keepercli-package/src/keepercli/commands/pam/pam_launch/rust_log_filter.py new file mode 100644 index 00000000..54f75d8f --- /dev/null +++ b/keepercli-package/src/keepercli/commands/pam/pam_launch/rust_log_filter.py @@ -0,0 +1,287 @@ +""" +Rust/webrtc log filtering for pam launch terminal session only. + +Downgrades Rust/webrtc/turn log messages to DEBUG so they only appear when --debug is on, +and only while the pam launch CLI terminal session is active. +""" + +import logging +import threading + + +# Patterns for known leak messages from turn-0.11.0's relay-conn task. +# The webrtc-rs ICE agent does not synchronously cancel its TURN refresh task +# on PeerConnection.close(); the task survives indefinitely and re-fires every +# few minutes (TURN permission lifetime ~5 min, refresh at ~3/4 of that). Each +# iteration logs: +# "fail to refresh permissions: CreatePermission error response (error 400: Bad Request)" +# "refresh permissions failed" +# from turn-0.11.0/src/client/relay_conn.rs:528 / :618. +# +# Until the upstream leak is fixed, suppress these messages permanently — they +# are post-close stragglers from a deallocated TURN allocation and have no +# diagnostic value to the user. +_TURN_REFRESH_LEAK_PATTERNS = ( + 'fail to refresh permissions', + 'refresh permissions failed', +) + + +class _PermanentTurnLeakFilter(logging.Filter): + """Always drop the known turn-rs refresh-permission leak messages. + + Installed once at module import time on the root logger, never removed. + Independent of the session-scoped _RustWebrtcToDebugFilter — that one + flips with --debug; this one is an upstream-bug workaround that should + fire regardless of debug state. + """ + + def filter(self, record: logging.LogRecord) -> bool: + try: + msg = record.getMessage() + except Exception: + return True + for needle in _TURN_REFRESH_LEAK_PATTERNS: + if needle in msg: + return False + return True + + +# Loggers known to emit the leak. Both dot- and colon-separated names cover +# the Rust→Python bridge formats. ``turn`` and ``turn.client`` cover any +# parent that records may originate from depending on rust-log target style. +_TURN_LEAK_LOGGER_NAMES = ( + 'turn', + 'turn.client', + 'turn.client.relay_conn', + 'turn::client::relay_conn', +) + +_PERMANENT_TURN_FILTER = _PermanentTurnLeakFilter() + + +def _install_permanent_turn_filter(): + """Attach the content filter to the known leaky loggers AND root. + + Python's logger filters fire only at the originating logger (filters do + NOT re-check during propagation up the hierarchy via callHandlers), so we + must attach to the actual emitting logger names rather than relying on + root.addFilter alone. Idempotent — safe to call again. + """ + for name in _TURN_LEAK_LOGGER_NAMES: + log = logging.getLogger(name) + if _PERMANENT_TURN_FILTER not in log.filters: + log.addFilter(_PERMANENT_TURN_FILTER) + # Also attach to root in case the Rust→Python bridge ever logs directly to + # root (cheap belt-and-braces). + root = logging.getLogger() + if _PERMANENT_TURN_FILTER not in root.filters: + root.addFilter(_PERMANENT_TURN_FILTER) + + +_install_permanent_turn_filter() + + +def _rust_webrtc_logger_name(name: str) -> bool: + """True if logger name is from Rust/webrtc/turn so we treat its messages as DEBUG-only.""" + if not name: + return False + # Normalize so we match both '.' and '::' (Rust may use either when passed to Python) + n = (name or '').replace('::', '.') + return ( + n.startswith('keeper_pam_webrtc_rs') + or n.startswith('webrtc') + or n.startswith('turn') + or n.startswith('stun') + or 'relay_conn' in n # turn crate submodule + ) + + +class _RustWebrtcToDebugFilter(logging.Filter): + """ + Filter for Rust/webrtc/turn log records. + When not in debug mode: suppress entirely (return False) so no handler can emit them. + When in debug mode: allow (return True); downgrading to DEBUG is redundant but harmless. + """ + + def filter(self, record: logging.LogRecord) -> bool: + if not _rust_webrtc_logger_name(record.name): + return True + # Only show these messages when debug is enabled (root or effective level is DEBUG) + if logging.getLogger().getEffectiveLevel() <= logging.DEBUG: + return True + return False # suppress when not in debug + + +class _RustAwareLogger(logging.Logger): + """ + Logger that forces Rust/webrtc/turn loggers to have no handlers and propagate to root, + and applies the downgrade filter at the logger so messages are DEBUG-only. + Used so loggers created *after* enter_ (e.g. by turn crate on first use) are still suppressed. + """ + + def __init__(self, name, level=logging.NOTSET): + super().__init__(name, level) + if _rust_webrtc_logger_name(name): + self.setLevel(logging.DEBUG) + self.propagate = True + self.handlers.clear() + self.addFilter(_RustWebrtcToDebugFilter()) + + +_WEBRTC_CRATE_NAMES = [ + 'webrtc', 'webrtc_ice', 'webrtc_mdns', 'webrtc_dtls', + 'webrtc_sctp', 'turn', 'stun', 'webrtc_ice.agent.agent_internal', + 'webrtc_ice.agent.agent_gather', 'webrtc_ice.mdns', + 'webrtc_mdns.conn', 'webrtc.peer_connection', 'turn.client', + 'turn.client.relay_conn', # turn crate submodule that emits "fail to refresh permissions..." +] + + +def enter_pam_launch_terminal_rust_logging(): + """ + Apply Rust/webrtc log filtering only during pam launch terminal session. + Downgrades Rust/webrtc/turn messages to DEBUG so they only show with --debug. + Returns a token to pass to exit_pam_launch_terminal_rust_logging() on exit. + """ + global _ACTIVE_SESSION_COUNT + with _ACTIVE_SESSION_LOCK: + _ACTIVE_SESSION_COUNT += 1 + + root = logging.getLogger() + flt = _RustWebrtcToDebugFilter() + root.addFilter(flt) + + # Use custom Logger class so any Rust/webrtc logger created later (e.g. turn crate) + # gets no handlers and propagates to root, where our filter downgrades to DEBUG. + _original_logger_class = logging.getLoggerClass() + logging.setLoggerClass(_RustAwareLogger) + + saved = [] + downgrade_filter = _RustWebrtcToDebugFilter() + for name in list(logging.Logger.manager.loggerDict.keys()): + if not isinstance(name, str) or not _rust_webrtc_logger_name(name): + continue + log = logging.getLogger(name) + # Only save if it's a real Logger with state we can restore (not our custom class yet) + if not isinstance(log, _RustAwareLogger): + saved.append((name, log.level, log.propagate, list(log.handlers))) + log.setLevel(logging.DEBUG) + log.propagate = True + log.handlers.clear() + if downgrade_filter not in log.filters: + log.addFilter(downgrade_filter) + for crate_name in _WEBRTC_CRATE_NAMES: + log = logging.getLogger(crate_name) + if not isinstance(log, _RustAwareLogger): + saved.append((crate_name, log.level, log.propagate, list(log.handlers))) + log.setLevel(logging.DEBUG) + log.propagate = True + log.handlers.clear() + if downgrade_filter not in log.filters: + log.addFilter(downgrade_filter) + + return (flt, saved, _original_logger_class) + + +# Grace period (seconds) between pam-launch session exit and actually removing +# the Rust/webrtc log filter. The Rust tube shutdown runs on its own runtime +# threads and can emit a final log record AFTER Python's session-exit path has +# returned control to the REPL — e.g. ``webrtc-sctp stream N not found`` when +# the channel is torn down, or TURN ``fail to refresh permissions`` warnings +# from the relay-conn task as it observes the deallocated allocation. +# +# The window must outlive both the tube close + teardown cascade (~3 s) and a +# brief TURN refresh-task latency after the PeerConnection drop cascade. +_DEFAULT_RUST_LOG_FILTER_GRACE_SEC = 4 + +# Refcount of active pam-launch sessions that have rust-log filtering installed. +# Incremented in enter_*, decremented at the END of the grace timer in +# _do_exit_rust_logging. The restore work (removing class-level filters, +# restoring pre-session logger state) is only performed when this drops to 0, +# so a second `pam launch` started during the grace window of a prior one is +# not silently de-filtered when the prior session's timer fires. +_ACTIVE_SESSION_COUNT = 0 +_ACTIVE_SESSION_LOCK = threading.Lock() + + +def _do_exit_rust_logging(token): + """Actual restoration — runs on the grace-period timer thread.""" + if not token: + return + flt, saved = token[0], token[1] + original_logger_class = token[2] if len(token) > 2 else logging.Logger + + # Always remove THIS session's filter instance from root so per-token + # filters don't pile up. The bulk class-based cleanup below only runs when + # we are the last active session. + root = logging.getLogger() + try: + root.removeFilter(flt) + except Exception: + pass + + global _ACTIVE_SESSION_COUNT + with _ACTIVE_SESSION_LOCK: + _ACTIVE_SESSION_COUNT = max(0, _ACTIVE_SESSION_COUNT - 1) + last_session = _ACTIVE_SESSION_COUNT == 0 + if not last_session: + # Another pam launch session is still active (or in its own grace + # window); leave the class-level filter and saved state alone so its + # filtering keeps working. We already removed our specific instance + # from root above. + logging.debug( + "rust_log_filter: skipping restore, %d session(s) still active", + _ACTIVE_SESSION_COUNT, + ) + return + + logging.setLoggerClass(original_logger_class) + # Remove downgrade filter from all Rust/webrtc loggers (we may have added the shared + # filter to existing loggers, and _RustAwareLogger instances have their own filter) + for name in list(logging.Logger.manager.loggerDict.keys()): + if not isinstance(name, str) or not _rust_webrtc_logger_name(name): + continue + log = logging.getLogger(name) + for f in list(log.filters): + if isinstance(f, _RustWebrtcToDebugFilter): + try: + log.removeFilter(f) + except ValueError: + pass + for crate_name in _WEBRTC_CRATE_NAMES: + log = logging.getLogger(crate_name) + for f in list(log.filters): + if isinstance(f, _RustWebrtcToDebugFilter): + try: + log.removeFilter(f) + except ValueError: + pass + for name, level, propagate, handlers in saved: + log = logging.getLogger(name) + log.setLevel(level) + log.propagate = propagate + for h in handlers: + log.addHandler(h) + + +def exit_pam_launch_terminal_rust_logging(token, grace_sec=_DEFAULT_RUST_LOG_FILTER_GRACE_SEC): + """Restore Rust/webrtc logger state after pam launch terminal session. + + The filter is removed after ``grace_sec`` seconds (default + ``_DEFAULT_RUST_LOG_FILTER_GRACE_SEC``) so that + late records from the Rust runtime (e.g. ``webrtc-sctp`` stream teardown + messages that arrive just after session exit) are still caught by the + filter and do not leak to the console in front of the subsequent + ``My Vault>`` prompt. Pass ``grace_sec=0`` to restore immediately. + """ + if not token: + return + if grace_sec <= 0: + _do_exit_rust_logging(token) + return + # Daemon thread so Commander can exit cleanly even during grace. + timer = threading.Timer(grace_sec, _do_exit_rust_logging, args=(token,)) + timer.daemon = True + timer.name = 'pam-launch-rust-log-filter-release' + timer.start() diff --git a/keepercli-package/src/keepercli/commands/pam/pam_launch/terminal_connection.py b/keepercli-package/src/keepercli/commands/pam/pam_launch/terminal_connection.py new file mode 100644 index 00000000..6e09020b --- /dev/null +++ b/keepercli-package/src/keepercli/commands/pam/pam_launch/terminal_connection.py @@ -0,0 +1,2228 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' Optional[str]: + """ + Ensure the SDP offer advertises the same max-message-size attribute as Web Vault. + + Args: + sdp_offer: Original SDP offer string returned by the WebRTC module. + + Returns: + SDP string containing the MAX_MESSAGE_SIZE_LINE (added if it was missing). + """ + if not sdp_offer or MAX_MESSAGE_SIZE_LINE in sdp_offer: + return sdp_offer + + newline = "\r\n" if "\r\n" in sdp_offer else "\n" + insert_location = None + lower_offer = sdp_offer.lower() + + # Prefer to inject directly after the SCTP port attribute to mimic Web Vault ordering. + sctp_idx = lower_offer.find("a=sctp-port:") + if sctp_idx != -1: + after_sctp = sdp_offer.find(newline, sctp_idx) + if after_sctp == -1: + insert_location = len(sdp_offer) + else: + insert_location = after_sctp + len(newline) + else: + # If the SCTP line is missing, try to inject immediately after the datachannel media line. + media_idx = lower_offer.find("m=application") + if media_idx != -1: + after_media = sdp_offer.find(newline, media_idx) + if after_media == -1: + insert_location = len(sdp_offer) + else: + insert_location = after_media + len(newline) + + if insert_location is None: + # Append at the end, keeping the existing newline style and ensuring we end with one blank line. + suffix = "" if sdp_offer.endswith(newline) else newline + updated_offer = f"{sdp_offer}{suffix}{MAX_MESSAGE_SIZE_LINE}{newline}" + else: + updated_offer = ( + sdp_offer[:insert_location] + + f"{MAX_MESSAGE_SIZE_LINE}{newline}" + + sdp_offer[insert_location:] + ) + + logging.debug("Injected `%s` into SDP offer to match Web Vault behavior", MAX_MESSAGE_SIZE_LINE) + return updated_offer + + +def _notify_gateway_connection_close(params, router_token, terminated=True): + """ + Notify the gateway/router that a WebRTC session should be closed. + + This mirrors the gateway's own POST to /api/device/connect_state so that + stale tubes are cleaned up when Commander aborts before a session fully starts. + + Note: gateway_cookies parameter was removed in commit 338a9fda as router + affinity is now handled server-side. + """ + if not router_token: + logging.debug("Skipping connection_close notification - router_token missing") + return + + try: + router_url = get_router_url(params) + payload = { + "token": router_token, + "type": "connection_close", + } + if terminated is not None: + payload["terminated"] = terminated + + response = requests.post( + f"{router_url}/api/device/connect_state", + json=payload, + verify=params.ssl_verify, + timeout=10, + ) + if response.status_code >= 400: + logging.warning( + "Gateway connection_close notification failed (%s): %s", + response.status_code, + response.text, + ) + else: + logging.debug("Sent connection_close notification for router token") + except Exception as notify_err: + logging.debug(f"Failed to notify gateway about connection_close: {notify_err}") + + +def detect_protocol(params: KeeperParams, record_uid: str) -> Optional[str]: + """ + Detect the connection protocol from a PAM record. + + All machine types (pamMachine, pamDirectory, pamDatabase) allow any connection + type (ssh, telnet, rdp, vnc, kubernetes, mysql, etc.). Extraction follows: + first connection.protocol; for pamDatabase only, if still undetermined then + connection.databaseType, then infer from port. + + Args: + params: KeeperParams instance + record_uid: Record UID + + Returns: + Protocol string (ex. ssh, telnet, rdp, mysql, etc.) or None if + not present/undetermined. If connection.protocol is set to a value that + matches a ConnectionProtocol enum, returns that canonical value; + otherwise returns the raw string (lowercased). + """ + record = vault.KeeperRecord.load(params, record_uid) + if not isinstance(record, vault.TypedRecord): + return None + + record_type = record.record_type + if record_type not in ('pamMachine', 'pamDirectory', 'pamDatabase'): + return None + + # Map lowercase protocol string to canonical ConnectionProtocol.value + _protocol_values = {p.value.lower(): p.value for p in ConnectionProtocol} + + pam_settings = record.get_typed_field('pamSettings') + if not pam_settings: + return None + + settings_value = pam_settings.get_default_value(dict) + if not settings_value: + return None + + connection = settings_value.get('connection') or {} + if not isinstance(connection, dict): + return None + + # 1) Try connection.protocol (same for all record types) + protocol_field = (connection.get('protocol') or '').strip() + if protocol_field: + protocol_lower = protocol_field.lower() + return _protocol_values.get(protocol_lower, protocol_lower) + + # 2) For pamDatabase only: connection.databaseType, then infer from port + if record_type == 'pamDatabase': + db_type = (connection.get('databaseType') or '').lower() + if 'mysql' in db_type: + return ConnectionProtocol.MYSQL.value + if 'postgres' in db_type or 'postgresql' in db_type: + return ConnectionProtocol.POSTGRESQL.value + if 'sql server' in db_type or 'sqlserver' in db_type or 'mssql' in db_type: + return ConnectionProtocol.SQLSERVER.value + + hostname_field = record.get_typed_field('pamHostname') + if hostname_field: + host_value = hostname_field.get_default_value(dict) + if host_value and host_value.get('port') is not None: + try: + port_int = int(host_value['port']) + except (TypeError, ValueError): + port_int = None + if port_int == 3306: + return ConnectionProtocol.MYSQL.value + if port_int == 5432: + return ConnectionProtocol.POSTGRESQL.value + if port_int == 1433: + return ConnectionProtocol.SQLSERVER.value + + return None + + +_PAM_TYPES_WITH_CONNECTION_PORT = ['pamMachine', 'pamDatabase', 'pamDirectory'] + + +def _pam_settings_connection_port(record: Any) -> Optional[int]: + """ + For PAM machine record types only, return a valid pamSettings.connection.port if set. + """ + if getattr(record, 'record_type', None) not in _PAM_TYPES_WITH_CONNECTION_PORT: + return None + if not hasattr(record, 'get_typed_field'): + return None + psf = record.get_typed_field('pamSettings') + if not psf or not hasattr(psf, 'get_default_value'): + return None + pam_val = psf.get_default_value(dict) + if not isinstance(pam_val, dict): + return None + connection = pam_val.get('connection') + if not isinstance(connection, dict): + return None + conn_port = connection.get('port') + if conn_port is None or conn_port == '': + return None + try: + p = int(conn_port) + except (ValueError, TypeError): + return None + if 1 <= p <= 65535: + return p + return None + + +def extract_terminal_settings( + params: KeeperParams, + record_uid: str, + protocol: str, + launch_credential_uid: Optional[str] = None, + custom_host: Optional[str] = None, + custom_port: Optional[int] = None, + dag_linked_uid: Any = _DAG_UID_UNSET, +) -> Dict[str, Any]: + """ + Extract terminal connection settings from a PAM record. + + Args: + params: KeeperParams instance + record_uid: Record UID + protocol: Protocol type (from detect_protocol) + launch_credential_uid: Optional override for userRecordUid (from --credential CLI param) + custom_host: Optional override for hostname (from --host/--host-record/--credential CLI param) + custom_port: Optional override for port (from --host/--host-record/--credential CLI param) + + Returns: + Dictionary containing terminal settings: + - hostname: Target hostname + - port: Target port + - clipboard: {disableCopy: bool, disablePaste: bool} + - terminal: {colorScheme: str, fontSize: str} + - recording: {includeKeys: bool} + - protocol_specific: Protocol-specific settings dict + - allowSupplyUser: bool - User can supply credentials on-the-fly + - allowSupplyHost: bool - User can supply host on-the-fly (forces userSupplied credential type) + - userRecordUid: str or None - UID of linked pamUser record for credentials + + Raises: + CommandError: If required fields are missing + """ + record = vault.KeeperRecord.load(params, record_uid) + if not isinstance(record, vault.TypedRecord): + raise CommandError('pam launch', f'Record {record_uid} is not a TypedRecord') + + # Resolve DAG-linked launch credential UID once; the pamSettings block and the + # later CLI-override comparison both need the same value. Pam launch passes + # a pre-resolved value via the kwarg so the 2–3 HTTP round-trips that build + # a TunnelDAG only happen once per command instead of per call site. + if dag_linked_uid is _DAG_UID_UNSET: + dag_linked_uid = _get_launch_credential_uid(params, record_uid) + + settings = { + 'hostname': None, + 'port': None, + 'clipboard': {'disableCopy': False, 'disablePaste': False}, + 'terminal': {'colorScheme': 'gray-black', 'fontSize': '12'}, + 'recording': {'includeKeys': False}, + 'protocol_specific': {}, + 'allowSupplyUser': False, + 'allowSupplyHost': False, + 'userRecordUid': None, + # JIT: mirrors web vault jitSettings.createEphemeral on the PAM resource graph. + # When True the gateway must receive credentialType='ephemeral'; the gateway + # re-reads jit_settings from the DAG and creates a temp account on the target. + 'createEphemeral': False, + } + + # Extract hostname and port from record - enforce single non-empty host/pamHostname field. + # Host requires non-empty hostName; port is pamSettings.connection.port (PAM types only) + # when set, else the field's port — same precedence as launch._get_host_port_from_record. + _pam_override_port = _pam_settings_connection_port(record) + _host_candidates = [] + for _f in list(getattr(record, 'fields', None) or []) + list(getattr(record, 'custom', None) or []): + if getattr(_f, 'type', None) in ('pamHostname', 'host'): + _hv = _f.get_default_value(dict) if hasattr(_f, 'get_default_value') else {} + _hn = ((_hv.get('hostName') or '').strip()) if isinstance(_hv, dict) else '' + if not _hn: + continue + _pr = _pam_override_port if _pam_override_port is not None else ( + _hv.get('port') if isinstance(_hv, dict) else None + ) + if not _pr: + continue + try: + _pp = int(_pr) + if 1 <= _pp <= 65535: + _host_candidates.append((_hn, _pp, _hv)) + except (ValueError, TypeError): + pass + if len(_host_candidates) > 1: + raise CommandError('pam launch', + f'Record {record_uid} has {len(_host_candidates)} non-empty host/pamHostname fields ' + '(expected exactly one). Clear the extra field before launching.') + _record_host, _record_port_val, _host_value = _host_candidates[0] if _host_candidates else (None, None, {}) + + settings['hostname'] = _record_host + + # CLI --host overrides record hostname (allowSupplyHost validated in launch.py) + if custom_host: + settings['hostname'] = custom_host + logging.debug(f"Using custom host override: {custom_host}") + + # Port precedence: CLI (custom_port) > record (pamSettings.connection.port overrides host field + # on PAM types, else field port) > pamSettings.connection.port when record port still unset > + # protocol DEFAULT. pamSettings fallback runs in the pamSettings block below. + if custom_port is not None: + settings['port'] = custom_port + elif _record_port_val is not None: + settings['port'] = _record_port_val + # else: remains None until pamSettings fallback or DEFAULT below + + # Extract PAM settings + pam_settings_field = record.get_typed_field('pamSettings') + if pam_settings_field: + pam_settings_value = pam_settings_field.get_default_value(dict) + if pam_settings_value: + connection = pam_settings_value.get('connection', {}) + if isinstance(connection, dict): + # Clipboard settings + settings['clipboard']['disableCopy'] = connection.get('disableCopy', False) + settings['clipboard']['disablePaste'] = connection.get('disablePaste', False) + + # Terminal display settings + color_scheme = connection.get('colorScheme') + if color_scheme: + settings['terminal']['colorScheme'] = color_scheme + + font_size = connection.get('fontSize') + if font_size: + settings['terminal']['fontSize'] = str(font_size) + + # Recording settings + settings['recording']['includeKeys'] = connection.get('recordingIncludeKeys', False) + + # allowSupplyUser is inside connection + settings['allowSupplyUser'] = connection.get('allowSupplyUser', False) + + # Extract linked pamUser record UID from pamSettings (may be overridden by CLI later) + # When both admin and launch credentials exist, we must use launch credential. + # dag_linked_uid was resolved once at the top of the function. + if dag_linked_uid: + settings['userRecordUid'] = dag_linked_uid + logging.debug(f"Using launch credential from DAG: {settings['userRecordUid']}") + elif not launch_credential_uid: + # No DAG-linked credential and no -cr given. + # If allowSupply* is enabled, use pamSettings.connection.userRecords[0] as + # implicit credential and warn so the user can be explicit via -cr. + user_records = connection.get('userRecords', []) + if user_records and len(user_records) > 0: + fallback_uid = user_records[0] + settings['userRecordUid'] = fallback_uid + allow_supply_host_flag = pam_settings_value.get('allowSupplyHost', False) + allow_supply_user_flag = connection.get('allowSupplyUser', False) + if allow_supply_host_flag or allow_supply_user_flag: + logging.warning( + 'Record %s: allowSupply* is enabled but no DAG-linked launch credential ' + 'was found; using pamSettings.connection.userRecords[0] (%s) as credential. ' + 'Pass --credential (-cr %s) to be explicit.', + record_uid, fallback_uid, fallback_uid, + ) + settings['_fallbackCredential'] = True + else: + logging.debug(f"Using userRecordUid from pamSettings: {fallback_uid}") + + # pamSettings.connection.port when CLI and host-derived port are still absent + if settings['port'] is None: + conn_port = connection.get('port') + if conn_port: + try: + settings['port'] = int(conn_port) + except (ValueError, TypeError): + pass + + # Protocol-specific settings + if protocol == ConnectionProtocol.SSH.value: + settings['protocol_specific'] = _extract_ssh_settings(connection) + elif protocol == ConnectionProtocol.TELNET.value: + settings['protocol_specific'] = _extract_telnet_settings(connection) + elif protocol == ConnectionProtocol.KUBERNETES.value: + settings['protocol_specific'] = _extract_kubernetes_settings(connection) + elif protocol in DATABASE: + settings['protocol_specific'] = _extract_database_settings(connection) + + # allowSupplyHost is at top level of pamSettings value, not inside connection + settings['allowSupplyHost'] = pam_settings_value.get('allowSupplyHost', False) + + # JIT settings live on the PAM resource DAG as an encrypted DATA edge ('jit_settings'). + # If createEphemeral is true, the gateway requires credentialType='ephemeral' and will + # reject 'linked'/'userSupplied'. Failure to read jit_settings is non-fatal — it just + # means the record is not JIT-configured, which is the normal path. + try: + jit = get_resource_jit_settings(params, record_uid) + if jit and jit.get('createEphemeral'): + settings['createEphemeral'] = True + logging.debug( + f"Record {record_uid} has jit_settings.createEphemeral=true; " + "launch will use 'ephemeral' credential type" + ) + except Exception as e: + logging.debug(f"Could not read jit_settings for {record_uid}: {e}") + + # Final port fallback to protocol default + if settings['port'] is None: + settings['port'] = DEFAULT_PORTS.get(protocol, 22) + + # CLI overrides: check if --credential provides a DIFFERENT user than DAG-linked. + # dag_linked_uid is the once-resolved DAG value from the top of the function — + # distinct from settings['userRecordUid'] which may have been set from the + # userRecords[0] fallback (not DAG-linked) and must not be used for this comparison. + if launch_credential_uid: + if launch_credential_uid == dag_linked_uid: + # CLI --credential matches DAG-linked credential - treat as if no --credential was provided + # so gateway uses normal 'linked' flow + logging.debug(f"CLI --credential matches DAG-linked credential {dag_linked_uid} - using normal 'linked' flow") + settings['cliUserOverride'] = False + else: + # CLI --credential provides a different user - this is a real override + settings['userRecordUid'] = launch_credential_uid + settings['cliUserOverride'] = True + logging.debug(f"CLI --credential overrides DAG credential: {launch_credential_uid} (was {dag_linked_uid})") + elif settings.pop('_fallbackCredential', False): + # userRecords[0] fallback with allowSupply* - treat as implicit -cr: + # gateway may not have it in DAG, so use userSupplied + ConnectAs payload + settings['cliUserOverride'] = True + logging.debug(f"Implicit credential from userRecords[0] fallback: {settings.get('userRecordUid')} - treating as userSupplied") + else: + settings['cliUserOverride'] = False + + # Final validation: hostname must be present for connection to succeed + # Note: userRecordUid is optional - if not present, _build_guacamole_connection_settings() + # will fall back to credentials from the pamMachine record directly + if not settings['hostname']: + if settings['allowSupplyHost']: + raise CommandError('pam launch', + f'Hostname not found in record {record_uid}. Use --host to specify one.') + else: + raise CommandError('pam launch', + f'Hostname not found in record {record_uid} and allowSupplyHost is not enabled.') + + return settings + + +def _extract_ssh_settings(connection: Dict[str, Any]) -> Dict[str, Any]: + """Extract SSH-specific settings from pamSettings.connection (record JSON).""" + sftp = connection.get('sftp') or {} + return { + 'publicHostKey': connection.get('hostKey', ''), + 'executeCommand': connection.get('command', ''), + 'sftpEnabled': bool(sftp.get('enableSftp', False)), + 'sftpRootDirectory': sftp.get('sftpRootDirectory', ''), + } + + +def _extract_telnet_settings(connection: Dict[str, Any]) -> Dict[str, Any]: + """Extract Telnet-specific settings from pamSettings.connection (record JSON).""" + return { + 'usernameRegex': connection.get('usernameRegex', ''), + 'passwordRegex': connection.get('passwordRegex', ''), + 'loginSuccessRegex': connection.get('loginSuccessRegex', ''), + 'loginFailureRegex': connection.get('loginFailureRegex', ''), + } + + +def _extract_kubernetes_settings(connection: Dict[str, Any]) -> Dict[str, Any]: + """Extract Kubernetes-specific settings from pamSettings.connection (record JSON).""" + return { + 'namespace': connection.get('namespace', 'default'), + 'pod': connection.get('pod', ''), + 'container': connection.get('container', ''), + 'useSSL': connection.get('useSSL', False), + 'ignoreServerCertificate': connection.get('ignoreCert', False), + 'caCertificate': connection.get('caCert', ''), + 'clientCertificate': connection.get('clientCert', ''), + 'clientKey': connection.get('clientKey', ''), + } + + +def _extract_database_settings(connection: Dict[str, Any]) -> Dict[str, Any]: + """Extract database-specific settings from pamSettings.connection (record JSON).""" + return { + 'defaultDatabase': connection.get('database', ''), + 'disableCsvExport': connection.get('disableCsvExport', False), + 'disableCsvImport': connection.get('disableCsvImport', False), + } + + +def create_connection_context(params: KeeperParams, + record_uid: str, + gateway_uid: str, + protocol: str, + settings: Dict[str, Any], + connect_as: Optional[str] = None) -> Dict[str, Any]: + """ + Build connection context for WebRTC tunnel. + + Args: + params: KeeperParams instance + record_uid: Record UID + gateway_uid: Gateway UID + protocol: Protocol type + settings: Terminal settings from extract_terminal_settings + connect_as: Optional username to connect as (overrides record) + + Returns: + Connection context dictionary ready for tunnel opening + """ + context = { + 'protocol': protocol, + 'recordUid': record_uid, + 'controllerUid': gateway_uid, + 'targetHost': { + 'hostname': settings['hostname'], + 'port': settings['port'] + }, + 'clipboard': settings['clipboard'], + 'terminal': settings['terminal'], + 'recording': settings['recording'], + 'connectAs': connect_as, + 'conversationType': str(protocol).lower(), + # Credential supply flags + 'allowSupplyUser': settings.get('allowSupplyUser', False), + 'allowSupplyHost': settings.get('allowSupplyHost', False), + # Linked pamUser record UID for credential extraction + 'userRecordUid': settings.get('userRecordUid'), + # True only when --credential was provided via CLI and differs from the DAG-linked record. + # Required by the offer-building path to distinguish "flag enabled but nothing supplied" + # from "flag enabled and user actually provided credentials". + 'cliUserOverride': settings.get('cliUserOverride', False), + # JIT: resource is configured for ephemeral/JIT accounts. + 'createEphemeral': settings.get('createEphemeral', False), + } + + # Add protocol-specific settings + if protocol == ConnectionProtocol.SSH.value: + context['ssh'] = settings['protocol_specific'] + elif protocol == ConnectionProtocol.TELNET.value: + context['telnet'] = settings['protocol_specific'] + elif protocol == ConnectionProtocol.KUBERNETES.value: + context['kubernetes'] = settings['protocol_specific'] + elif protocol in DATABASE: + context['database'] = settings['protocol_specific'] + context['database']['type'] = protocol + + return context + + +def _get_launch_credential_uid( + params: 'KeeperParams', + record_uid: str, + tdag: Optional['TunnelDAG'] = None, +) -> Optional[str]: + """ + Find the launch credential UID for a PAM record using the DAG. + + When a pamMachine record has both administrative credentials and launch credentials, + we need to use the launch credential (marked with is_launch_credential=True in DAG). + This function queries the DAG to find the correct credential. + + Args: + params: KeeperParams instance + record_uid: UID of the pamMachine record + tdag: Optional pre-built TunnelDAG to reuse. When provided, skips the + expensive ``TunnelDAG(...)`` construction (which issues 2–3 HTTP + round-trips). Used by ``pam launch`` to avoid resolving the same + DAG three times per command invocation. + + Returns: + UID of the launch credential pamUser record, or None if not found + """ + try: + if tdag is None: + encrypted_session_token, encrypted_transmission_key, transmission_key = get_keeper_tokens(params) + tdag = TunnelDAG(params.vault, encrypted_session_token, encrypted_transmission_key, record_uid, + transmission_key=transmission_key) + + if not tdag.linking_dag.has_graph: + logging.debug(f"No DAG graph loaded for record {record_uid}") + return None + + record_vertex = tdag.linking_dag.get_vertex(record_uid) + if record_vertex is None: + logging.debug(f"Record vertex not found in DAG for {record_uid}") + return None + + # Find the credential explicitly marked as is_launch_credential=True in DAG + launch_credential = None + + for user_vertex in record_vertex.has_vertices(EdgeType.ACL): + acl_edge = user_vertex.get_edge(record_vertex, EdgeType.ACL) + if acl_edge: + try: + content = acl_edge.content_as_dict or {} + if content.get('is_launch_credential', False) and launch_credential is None: + launch_credential = user_vertex.uid + logging.debug(f"Found launch credential via DAG: {launch_credential}") + except Exception as e: + logging.debug(f"Error parsing ACL edge content: {e}") + + if launch_credential: + logging.debug(f"Using launch credential from DAG: {launch_credential}") + return launch_credential + + logging.debug(f"No explicit launch credential (is_launch_credential=True) in DAG for {record_uid}") + return None + + except Exception as e: + logging.debug(f"Error accessing DAG for launch credential: {e}") + return None + + +# ECIES info string for ConnectAs payload encryption +# Must match the gateway's expected value +CONNECT_AS_ECIES_INFO = b'KEEPER_CONNECT_AS_ECIES_SECP256R1_HKDF_SHA256' + + +def _ecies_encrypt_with_hkdf( + plaintext: bytes, + recipient_public_key: bytes, + info: bytes = CONNECT_AS_ECIES_INFO +) -> bytes: + """ + Encrypt data using ECIES with HKDF key derivation. + + This implements ECIES (Elliptic Curve Integrated Encryption Scheme) using: + - SECP256R1 (P-256) curve for ECDH key exchange + - HKDF-SHA256 for key derivation with the provided info string + - AES-256-GCM for symmetric encryption + + Args: + plaintext: Data to encrypt + recipient_public_key: 65-byte uncompressed public key of recipient + info: HKDF info/context string (default: CONNECT_AS_ECIES_INFO) + + Returns: + Encrypted payload: [ephemeral_pubkey (65)] + [nonce (12)] + [ciphertext + auth_tag] + """ + # Generate ephemeral key pair + ephemeral_private_key = ec.generate_private_key(ec.SECP256R1(), default_backend()) + ephemeral_public_key = ephemeral_private_key.public_key() + + # Serialize ephemeral public key (65 bytes uncompressed) + ephemeral_public_key_bytes = ephemeral_public_key.public_bytes( + encoding=serialization.Encoding.X962, + format=serialization.PublicFormat.UncompressedPoint + ) + + # Load recipient's public key from bytes + recipient_key = ec.EllipticCurvePublicKey.from_encoded_point( + ec.SECP256R1(), + recipient_public_key + ) + + # Perform ECDH to get shared secret + shared_secret = ephemeral_private_key.exchange(ec.ECDH(), recipient_key) + + # Derive encryption key using HKDF + hkdf = HKDF( + algorithm=hashes.SHA256(), + length=32, # AES-256 key + salt=None, + info=info, + backend=default_backend() + ) + encryption_key = hkdf.derive(shared_secret) + + # Generate random nonce for AES-GCM + nonce = os.urandom(12) + + # Encrypt with AES-256-GCM + aesgcm = AESGCM(encryption_key) + ciphertext = aesgcm.encrypt(nonce, plaintext, None) + + # Return: [ephemeral_pubkey (65)] + [nonce (12)] + [ciphertext + auth_tag] + return ephemeral_public_key_bytes + nonce + ciphertext + + +def _build_connect_as_payload( + params: 'KeeperParams', + user_record_uid: str, + gateway_public_key: bytes +) -> Optional[bytes]: + """ + Build encrypted ConnectAs payload for credential passing to gateway. + + The ConnectAs payload contains user credentials from a pamUser record, + encrypted using ECIES with HKDF. This allows the gateway to receive + credentials via the OpenConnection message instead of looking them up in DAG. + + Args: + params: KeeperParams instance + user_record_uid: UID of the pamUser record containing credentials + gateway_public_key: 65-byte public key of the gateway for ECIES encryption + + Returns: + Encrypted payload in format expected by keeper-pam-webrtc-rs Gateway: + [ephemeral_pubkey (65)] + [nonce (12)] + [ciphertext + auth_tag] = 185 bytes. + Returns None if credentials cannot be extracted or encryption fails. + """ + if not user_record_uid or not gateway_public_key: + return None + + try: + # Extract credentials from pamUser record + creds = _extract_user_record_credentials(params, user_record_uid) + + # Build ConnectAs user data structure (matches webvault's ConnectAsUser) + connect_as_user = {} + if creds.get('username'): + connect_as_user['username'] = creds['username'] + if creds.get('password'): + connect_as_user['password'] = creds['password'] + if creds.get('private_key'): + connect_as_user['private_key'] = creds['private_key'] + if creds.get('passphrase'): + connect_as_user['passphrase'] = creds['passphrase'] + + # The payload structure matches webvault: {"user": {...}} + payload_dict = {'user': connect_as_user} + payload_json = json.dumps(payload_dict).encode('utf-8') + + # keeper-pam-webrtc-rs protocol.rs expects: + # [encrypted_data_len: 4 bytes] + [PK(65)] + [Nonce(12)] + [Encrypted(encrypted_data_len)] + # Encrypted = ciphertext + auth_tag(16). Ciphertext len = plaintext len. + # So plaintext must be >= 92 bytes to produce 108-byte encrypted portion. + # Use space padding (not null) so decrypted JSON parses correctly. + min_plaintext_len = 92 + if len(payload_json) < min_plaintext_len: + payload_json = payload_json + b' ' * (min_plaintext_len - len(payload_json)) + + logging.debug(f"ConnectAs payload: username={'(set)' if connect_as_user.get('username') else '(empty)'}, " + f"password={'(set)' if connect_as_user.get('password') else '(empty)'}, " + f"private_key={'(set)' if connect_as_user.get('private_key') else '(empty)'}") + + # Encrypt with ECIES+HKDF + ecies_encrypted = _ecies_encrypt_with_hkdf(payload_json, gateway_public_key) + + # protocol.rs reads: connect_as_payload_len = get_u32(), then + # required_crypto_block_len = 65 + 12 + connect_as_payload_len + # The length is of the ENCRYPTED portion only (ciphertext+auth_tag) = 108 + encrypted_data_len = len(ecies_encrypted) - 65 - 12 # ciphertext + auth_tag + length_bytes = encrypted_data_len.to_bytes(4, byteorder='big') + connect_as_payload = length_bytes + ecies_encrypted + + logging.debug(f"Built ConnectAs payload: total_len={len(connect_as_payload)}, encrypted_data_len={encrypted_data_len}") + + return connect_as_payload + + except Exception as e: + logging.error(f"Failed to build ConnectAs payload: {e}") + return None + + +def _retrieve_gateway_public_key( + params: 'KeeperParams', + gateway_uid: str +) -> Optional[bytes]: + """ + Retrieve the public key for a gateway. + + This function calls the vault/get_ksm_public_keys API to retrieve the + gateway's public key needed for ECIES encryption of ConnectAs payloads. + + Args: + params: KeeperParams instance + gateway_uid: UID of the gateway + + Returns: + 65-byte uncompressed public key, or None if not found + """ + try: + gateway_uid_bytes = url_safe_str_to_bytes(gateway_uid) + get_ksm_pubkeys_rq = GetKsmPublicKeysRequest() + get_ksm_pubkeys_rq.controllerUids.append(gateway_uid_bytes) + get_ksm_pubkeys_rs = api.communicate_rest( + params, get_ksm_pubkeys_rq, 'vault/get_ksm_public_keys', + rs_type=GetKsmPublicKeysResponse + ) + + if len(get_ksm_pubkeys_rs.keyResponses) == 0: + logging.warning(f"No public key found for gateway {gateway_uid}") + return None + + gateway_public_key_bytes = get_ksm_pubkeys_rs.keyResponses[0].publicKey + logging.debug(f"Retrieved gateway public key: {len(gateway_public_key_bytes)} bytes") + return gateway_public_key_bytes + + except Exception as e: + logging.error(f"Error retrieving gateway public key: {e}") + return None + + +def _get_single_str_field(record: Any, field_type: str) -> str: + """ + Return the value of the single non-empty typed field matching field_type. + + Enforces exactly one non-empty field across both record.fields[] and record.custom[]. + Raises CommandError if multiple non-empty fields of that type are found. + Returns '' if none are found. + """ + nonempty_values = [] + for field in list(getattr(record, 'fields', None) or []) + list(getattr(record, 'custom', None) or []): + if getattr(field, 'type', None) == field_type: + val = field.get_default_value(str) if hasattr(field, 'get_default_value') else '' + if val: + nonempty_values.append(val) + if len(nonempty_values) > 1: + raise CommandError('pam launch', + f'Record has {len(nonempty_values)} non-empty {field_type!r} fields ' + '(expected exactly one). Clear the extra field before launching.') + return nonempty_values[0] if nonempty_values else '' + + +def _extract_user_record_credentials( + params: 'KeeperParams', + user_record_uid: str +) -> Dict[str, Any]: + """ + Extract credentials from a linked pamUser record. + + This function extracts username, password, private key, and passphrase from + a pamUser record. For SSH connections, the private key is extracted using + try_extract_private_key() which checks keyPair fields, notes, custom fields, + and attachments. The password field serves as the passphrase for encrypted + private keys. + + Args: + params: KeeperParams instance + user_record_uid: UID of the linked pamUser record + + Returns: + Dictionary containing: + - username: Login username (str) + - password: Password (str) + - private_key: PEM-encoded private key if found (str or None) + - passphrase: Passphrase for encrypted private key (str or None) + """ + result = { + 'username': '', + 'password': '', + 'private_key': None, + 'passphrase': None, + } + + # Load the pamUser record (classic vault or NSF; accept keepersdk + compat TypedRecord) + from keepersdk.vault import vault_record as _vr + user_record = vault.KeeperRecord.load(params, user_record_uid) + if not isinstance(user_record, _vr.TypedRecord): + logging.warning(f"User record {user_record_uid} is not a TypedRecord") + return result + + # Extract username - enforce single non-empty login field across fields[] + custom[] + result['username'] = _get_single_str_field(user_record, 'login') + + # Extract password - enforce single non-empty password field across fields[] + custom[] + result['password'] = _get_single_str_field(user_record, 'password') + + # Extract private key using try_extract_private_key() + # This function checks: keyPair field, notes, custom fields (text, multiline, secret, note), and attachments + key_result = try_extract_private_key(params, user_record) + if key_result: + private_key, passphrase = key_result + result['private_key'] = private_key + # The password field serves as the passphrase for encrypted private keys + # If try_extract_private_key returned a passphrase (from password field), use it + # Otherwise, use the password we already extracted + result['passphrase'] = passphrase if passphrase else (result['password'] if result['password'] else None) + + logging.debug( + f"Extracted credentials from pamUser {user_record_uid}: " + f"username={'(set)' if result['username'] else '(empty)'}, " + f"password={'(set)' if result['password'] else '(empty)'}, " + f"private_key={'(set)' if result['private_key'] else '(empty)'}" + ) + + return result + + +def _build_guacamole_connection_settings( + params: 'KeeperParams', + record_uid: str, + protocol: str, + settings: Dict[str, Any], + context: Dict[str, Any], + screen_info: Dict[str, int], + user_record_uid: Optional[str] = None, + credential_type: str = 'linked', + normalize_crlf: bool = False, +) -> Dict[str, Any]: + """ + Build connection settings for Guacamole handshake in PythonHandler mode. + + When guacd sends 'args' instruction requesting connection parameters, + we respond with 'connect' containing these values. + + Credential handling follows gateway behavior: + - If credential_type='linked' and user_record_uid is provided, extract credentials + from the linked pamUser record (username, password, private key) + - If credential_type='userSupplied', leave credentials empty (user provides on-the-fly) + - SSH authentication precedence: private key is tried first, then password + (standard SSH behavior handled by guacd) + + Args: + params: KeeperParams instance + record_uid: Record UID (pamMachine record) + protocol: Protocol type (ssh, telnet, mysql, etc.) + settings: Terminal settings from extract_terminal_settings() + context: Connection context from create_connection_context() + screen_info: Screen dimensions dict + user_record_uid: Optional UID of linked pamUser record for credentials + credential_type: Credential type ('linked', 'userSupplied', 'ephemeral') + normalize_crlf: When True, map CRLF to LF on decoded STDOUT blobs and run downstream LF cleanup + (``pam launch --normalize-crlf`` / ``-n``). Default False keeps raw CR/LF (CLI default). + + Returns: + Dictionary with connection settings for GuacamoleHandler + """ + username = '' + password = '' + private_key = None + passphrase = None + + # Determine how to get credentials based on credential_type + # Note: Even for 'userSupplied', if we have user_record_uid (from CLI --credential), extract credentials + # because guacd_params go directly to guacd via our connect instruction + if credential_type == 'ephemeral': + # JIT: gateway creates the target account and injects creds after guacd handshake. + # Do NOT pull creds from the pamMachine record — they'd be wrong and misleading. + logging.debug("Using ephemeral credential type - leaving handshake credentials empty for gateway to inject") + elif credential_type == 'userSupplied' and not user_record_uid: + # True user-supplied: no credentials provided at all + # Note: user may not be able to provide via guacamole prompt since STDIN/STDOUT not open yet + logging.debug("Using userSupplied credential type with no pamUser - leaving credentials empty") + elif user_record_uid: + # Extract credentials from linked pamUser record + user_creds = _extract_user_record_credentials(params, user_record_uid) + username = user_creds['username'] + password = user_creds['password'] + private_key = user_creds['private_key'] + passphrase = user_creds['passphrase'] + logging.debug(f"Using credentials from linked pamUser record: {user_record_uid}") + else: + # Fallback: Get credentials from the pamMachine record directly + # (backward compatibility for records without linked pamUser) + # Enforces single non-empty login/password field across fields[] + custom[]. + record = vault.KeeperRecord.load(params, record_uid) + if isinstance(record, vault.TypedRecord): + username = _get_single_str_field(record, 'login') + password = _get_single_str_field(record, 'password') + logging.debug("Using credentials from pamMachine record (no linked pamUser)") + + # Build guacd parameters dictionary + # These map to guacd's expected parameter names + # The 'protocol' field is required for guacd to know which backend to use + guacd_protocol = str(protocol).lower() + guacd_params = { + 'protocol': guacd_protocol, # Required: tells guacd which protocol handler to use + 'hostname': settings.get('hostname', ''), + 'port': str(settings.get('port', '')), + 'username': username, + 'password': password, + } + + # Add private key for SSH protocol if available + # SSH authentication precedence: guacd/SSH tries private key first, then password + # Both can be present simultaneously - this matches gateway behavior + if protocol == ConnectionProtocol.SSH.value and private_key: + guacd_params['private-key'] = private_key + if passphrase: + guacd_params['passphrase'] = passphrase + logging.debug("Added private-key to guacd_params for SSH authentication") + + # Add protocol-specific parameters + protocol_specific = settings.get('protocol_specific', {}) + + if protocol == ConnectionProtocol.SSH.value: + # SSH-specific params + if protocol_specific.get('publicHostKey'): + guacd_params['host-key'] = protocol_specific['publicHostKey'] + if protocol_specific.get('executeCommand'): + guacd_params['command'] = protocol_specific['executeCommand'] + # Enable SFTP if configured + if protocol_specific.get('sftpEnabled'): + guacd_params['enable-sftp'] = 'true' + if protocol_specific.get('sftpRootDirectory'): + guacd_params['sftp-root-directory'] = protocol_specific['sftpRootDirectory'] + + elif protocol == ConnectionProtocol.TELNET.value: + # Telnet-specific params + if protocol_specific.get('usernameRegex'): + guacd_params['username-regex'] = protocol_specific['usernameRegex'] + if protocol_specific.get('passwordRegex'): + guacd_params['password-regex'] = protocol_specific['passwordRegex'] + if protocol_specific.get('loginSuccessRegex'): + guacd_params['login-success-regex'] = protocol_specific['loginSuccessRegex'] + if protocol_specific.get('loginFailureRegex'): + guacd_params['login-failure-regex'] = protocol_specific['loginFailureRegex'] + + elif protocol == ConnectionProtocol.KUBERNETES.value: + # Kubernetes-specific params + if protocol_specific.get('namespace'): + guacd_params['namespace'] = protocol_specific['namespace'] + if protocol_specific.get('pod'): + guacd_params['pod'] = protocol_specific['pod'] + if protocol_specific.get('container'): + guacd_params['container'] = protocol_specific['container'] + if protocol_specific.get('caCertificate'): + guacd_params['ca-cert'] = protocol_specific['caCertificate'] + if protocol_specific.get('clientCertificate'): + guacd_params['client-cert'] = protocol_specific['clientCertificate'] + if protocol_specific.get('clientKey'): + guacd_params['client-key'] = protocol_specific['clientKey'] + if protocol_specific.get('ignoreServerCertificate'): + guacd_params['ignore-cert'] = 'true' + if protocol_specific.get('useSSL'): + guacd_params['use-ssl'] = 'true' + + elif protocol in DATABASE: + # Database-specific params + if protocol_specific.get('defaultDatabase'): + guacd_params['database'] = protocol_specific['defaultDatabase'] + if protocol_specific.get('disableCsvExport'): + guacd_params['disable-csv-export'] = 'true' + if protocol_specific.get('disableCsvImport'): + guacd_params['disable-csv-import'] = 'true' + + # CLI mode: named pipe for terminal STDOUT (guacr terminal handlers; not graphical RDP/VNC) + guacd_params['enable-pipe'] = 'true' + + # Terminal display settings + terminal_settings = settings.get('terminal', {}) + if terminal_settings.get('colorScheme'): + guacd_params['color-scheme'] = terminal_settings['colorScheme'] + _record_font_size = terminal_settings.get('fontSize') + if _record_font_size and str(_record_font_size) != '12': + logging.debug( + "Record font-size %r is not supported for terminal sessions " + "(pixel metrics are calibrated for font-size 12); converting to font-size 12.", + _record_font_size, + ) + guacd_params['font-size'] = '12' + + # PAM clipboard → guacd: only pass disable-* when the record sets them (guacd "true" = on). + _pam_clip = settings.get('clipboard') or {} + if _pam_clip.get('disablePaste'): + guacd_params['disable-paste'] = 'true' + if _pam_clip.get('disableCopy'): + guacd_params['disable-copy'] = 'true' + + # Terminal dimensions and DPI must be in guacd_params so the 'connect' instruction + # carries them to guacd. Without these, guacd initialises its font metrics at its + # built-in default DPI (96), giving char_width ≈ 10 px. The kcm pixel formula uses + # char_width = 19 px (calibrated for DPI 192), so a missing DPI in 'connect' causes + # guacd to compute ~2× too many PTY columns from the pixel width we send. + guacd_params['width'] = str(screen_info.get('pixel_width', 800)) + guacd_params['height'] = str(screen_info.get('pixel_height', 600)) + guacd_params['dpi'] = str(screen_info.get('dpi', GUACAMOLE_HANDSHAKE_DPI)) + + # Build final connection settings + connection_settings = { + 'protocol': protocol, + 'hostname': settings.get('hostname', ''), + 'port': settings.get('port', 22), + 'width': screen_info.get('pixel_width', 800), + 'height': screen_info.get('pixel_height', 600), + # DPI comes from screen_info (192 for KCM mode, 96 for guacd/scale mode) — also + # carried via guacd_params['dpi'] so the 'connect' instruction sets guacd's font + # metrics to the correct DPI from the start. + 'dpi': screen_info.get('dpi', GUACAMOLE_HANDSHAKE_DPI), + 'guacd_params': guacd_params, + # Supported mimetypes for terminal sessions + 'audio_mimetypes': [], # No audio for terminal + 'video_mimetypes': [], # No video for terminal + 'image_mimetypes': ['image/png', 'image/jpeg', 'image/webp'], + # PAM clipboard policy (also in guacd_params as disable-* only when record disables) + 'clipboard': dict(settings.get('clipboard') or {}), + # CLI-only: GuacamoleHandler / instruction router (not sent to guacd) + 'normalize_crlf': bool(normalize_crlf), + } + + logging.debug(f"Built Guacamole connection settings for {protocol}: " + f"hostname={settings.get('hostname')}, port={settings.get('port')}, " + f"width={connection_settings['width']}x{connection_settings['height']}") + + return connection_settings + + +def _open_terminal_webrtc_tunnel(params: KeeperParams, + record_uid: str, + gateway_uid: str, + protocol: str, + settings: Dict[str, Any], + context: Dict[str, Any], + **kwargs) -> Dict[str, Any]: + """ + Open a WebRTC tunnel for terminal/Guacamole connection. + + This function adapts start_rust_tunnel for terminal protocols by: + - Using the protocol-specific conversation type + - Not requiring local socket listening (Guacamole renders server-side) + - Setting up for text/image streaming only (no audio/video) + + Args: + params: KeeperParams instance + record_uid: Record UID + gateway_uid: Gateway UID + protocol: Protocol type (ssh, telnet, etc.) + settings: Terminal settings + context: Connection context + + Returns: + Dictionary with tunnel information: + - success: bool + - tube_id: str + - conversation_id: str + - tube_registry: PyTubeRegistry + - signal_handler: TunnelSignalHandler + - websocket_thread: Thread + - error: error message if failed + """ + logging.debug(f"{bcolors.HIGHINTENSITYWHITE}Establishing {protocol.upper()} terminal connection via WebRTC...{bcolors.ENDC}") + screen_info = DEFAULT_SCREEN_INFO + + try: + _pam_tc = PamConnectTiming('pam-launch:webrtc-tunnel') + _pam_tc.checkpoint('enter') + router_token = None + + # Get encryption seed from record + record = vault.KeeperRecord.load(params, record_uid) + if not isinstance(record, vault.TypedRecord): + return {"success": False, "error": "Invalid record type"} + + # Get traffic encryption seed + seed_field = record.get_typed_field('trafficEncryptionSeed') + if seed_field: + seed = seed_field.get_default_value() + if isinstance(seed, str): + seed = base64_to_bytes(seed) + else: + # Generate a random seed if not present + seed = secrets.token_bytes(32) + logging.debug("No trafficEncryptionSeed found, using generated seed") + + # Generate 128-bit (16-byte) random nonce + nonce = os.urandom(MAIN_NONCE_LENGTH) + + # Derive the encryption key using HKDF + hkdf = HKDF( + algorithm=hashes.SHA256(), + length=SYMMETRIC_KEY_LENGTH, # 256-bit key + salt=nonce, + info=b"KEEPER_TUNNEL_ENCRYPT_AES_GCM_128", + backend=default_backend() + ).derive(seed) + symmetric_key = AESGCM(hkdf) + + # Get tube registry (Rust WebRTC library) + tube_registry = get_or_create_tube_registry(params) + if not tube_registry: + return {"success": False, "error": "Rust WebRTC library (keeper_pam_webrtc_rs) not available"} + + # For terminal connections, we act as client (not server mode) + tube_registry.set_server_mode(False) + + # Generate conversation ID + conversation_id_original = GatewayAction.generate_conversation_id() + conversation_id_bytes = url_safe_str_to_bytes(conversation_id_original) + conversation_id = base64.b64encode(conversation_id_bytes).decode('utf-8') + + logging.debug(f"Generated conversation_id_original: {conversation_id_original}") + logging.debug(f"Base64 encoded conversation_id: {conversation_id}") + + base64_nonce = bytes_to_base64(nonce) + + # Get relay server configuration + relay_url = get_relay_host(params.server) + + response = router_get_relay_access_creds(params=params, expire_sec=60000000) + if response is None: + return {"success": False, "error": "Failed to get relay access credentials"} + _pam_tc.checkpoint('relay_creds_ok') + + # Create WebRTC settings for terminal (no local socket needed) + webrtc_settings = { + "turn_only": False, + "relay_url": relay_url, + "stun_url": f"stun:{relay_url}:3478", + "turn_url": f"turn:{relay_url}:3478", + "turn_username": response.username, + "turn_password": response.password, + "conversationType": context['conversationType'], # ssh, telnet, kubernetes, mysql, etc. + "local_listen_addr": "", # No local socket for terminal + "target_host": settings['hostname'], + "target_port": settings['port'], + "socks_mode": False, # Terminal connections don't use SOCKS + "control_channel_label": "control", # Ensure WebRTC data channel label matches gateway expectation + "callback_token": bytes_to_base64(nonce) + } + + # Debug: Log settings to verify control_channel_label is present + logging.debug(f"WebRTC settings before create_tube: {json.dumps(webrtc_settings, default=str)}") + + # Register the encryption key in the global conversation store + register_conversation_key(conversation_id, symmetric_key) + # Create a temporary tunnel session + temp_tube_id = str(uuid.uuid4()) + + # Pre-create tunnel session to buffer early ICE candidates + conversation_type = context.get('conversationType', protocol) + + tunnel_session = TunnelSession( + tube_id=temp_tube_id, + conversation_id=conversation_id, + gateway_uid=gateway_uid, + symmetric_key=symmetric_key, + offer_sent=False, + host=None, # No local host for terminal + port=None # No local port for terminal + ) + + # Register the temporary session + register_tunnel_session(temp_tube_id, tunnel_session) + + # Determine trickle ICE setting from kwargs + no_trickle_ice = kwargs.get('no_trickle_ice', False) + trickle_ice = not no_trickle_ice + + # For trickle ICE, use shared tokens and bind_to_controller for ALB stickiness (same worker for WebSocket + POST) + router_tokens = None + http_session = None + cookie_header = None + if trickle_ice: + router_tokens = get_keeper_tokens(params) + http_session = requests.Session() + krouter_host = get_router_url(params) + try: + bind_url = krouter_host + "/api/user/bind_to_controller/" + gateway_uid + http_session.get(bind_url, verify=params.ssl_verify, timeout=10) + except Exception as e: + logging.debug("bind_to_controller GET failed (continuing): %s", e) + if http_session.cookies: + cookie_header = "; ".join(f"{c.name}={c.value}" for c in http_session.cookies) + logging.debug("Bound to controller for ALB stickiness (WebSocket and streaming HTTP will use same backend)") + + # Create signal handler for Rust events + signal_handler = TunnelSignalHandler( + params=params, + record_uid=record_uid, + gateway_uid=gateway_uid, + symmetric_key=symmetric_key, + base64_nonce=base64_nonce, + conversation_id=conversation_id, + tube_registry=tube_registry, + tube_id=temp_tube_id, + trickle_ice=trickle_ice, + router_tokens=router_tokens, + http_session=http_session + ) + + # Store signal handler reference + tunnel_session.signal_handler = signal_handler # type: ignore[assignment] + + # Start the dedicated WebSocket listener *before* ``create_tube``. The Rust + # tube creation takes ~500ms; running the WebSocket TLS handshake / router + # registration concurrently with it saves most of that window. The listener + # only reads the ``conversation_id`` from tunnel_session; the tube_id is + # used for the thread name and log context only (updated in-place after + # ``create_tube`` returns). No message will arrive before the gateway has + # received our offer, so there is no race between early listener start and + # the tube-id being rewritten from the temp UUID to the real one. + websocket_thread = start_websocket_listener( + params, tube_registry, timeout=300, gateway_uid=gateway_uid, + tunnel_session=tunnel_session, + router_tokens=router_tokens, + cookie_header=cookie_header, + ) + _pam_tc.checkpoint('websocket_listener_started_early') + + logging.debug(f"{bcolors.OKBLUE}Creating WebRTC offer for {protocol} connection...{bcolors.ENDC}") + if trickle_ice: + logging.debug("Using trickle ICE for real-time candidate exchange") + else: + logging.debug("Trickle ICE disabled - using standard ICE") + + # Check if PythonHandler mode is requested + use_python_handler = kwargs.get('use_python_handler', True) # Default to True for new mode + python_handler = None + handler_callback = None + + if use_python_handler: + logging.debug("Using PythonHandler mode - Rust handles control frames automatically") + + # Set conversationType to "python_handler" to enable PythonHandler protocol mode in Rust + # The actual protocol (ssh, telnet, etc.) is passed via guacd_params["protocol"] + # IMPORTANT: Only update webrtc_settings - gateway needs the actual protocol type (ssh, telnet, etc.) + # The gateway validates conversationType against valid protocol types, not "python_handler" + webrtc_settings["conversationType"] = "python_handler" + # Keep context['conversationType'] as the actual protocol (ssh, telnet, etc.) for gateway + # Do NOT change context["conversationType"] - gateway needs the real protocol type + logging.debug(f"Set webrtc_settings conversationType to 'python_handler' (gateway will receive: {context['conversationType']})") + + # Determine credential type based on JIT config and allowSupply* flags. + # Mirrors the gateway-side decision in the main offer path below. + # Precedence: ephemeral (JIT) > linked > userSupplied > None. + allow_supply_host = context.get('allowSupplyHost', False) + allow_supply_user = context.get('allowSupplyUser', False) + user_record_uid = context.get('userRecordUid') + create_ephemeral = context.get('createEphemeral', False) + + # credential_type is None when using pamMachine credentials directly (backward compatible) + credential_type = None + if create_ephemeral: + # JIT: gateway will inject ephemeral creds at session start. Leave the + # guacd handshake creds empty — _build_guacamole_connection_settings + # handles the empty-credential path for ephemeral. + credential_type = 'ephemeral' + user_record_uid = None + logging.debug("Using 'ephemeral' credential type (JIT) for python_handler") + elif user_record_uid: + # Linked user present (from CLI --credential or record) - use linked credentials + credential_type = 'linked' + logging.debug(f"Using 'linked' credential type with userRecordUid: {user_record_uid}") + elif allow_supply_host or allow_supply_user: + # No credentials provided but supply flags enabled - user must provide interactively + credential_type = 'userSupplied' + logging.debug("No credentials provided, allowSupply enabled - using 'userSupplied' credential type") + else: + # No linked user, no supply flags - use pamMachine credentials directly + logging.debug("No linked user or supply flags - using pamMachine credentials directly") + + # Fresh TTY metrics for the Python handshake — do not use ``screen_info`` from + # function start (DEFAULT_SCREEN_INFO snapshot); that can be import-time or stale. + _scale = kwargs.get('scale') + if isinstance(_scale, int) and _scale > 0 and _scale != 100: + _ts = shutil.get_terminal_size(fallback=(DEFAULT_TERMINAL_COLUMNS, DEFAULT_TERMINAL_ROWS)) + screen_info = scale_screen_info(_ts.columns, _ts.lines, _scale) + logging.debug( + "--scale %s%%: guacd-96 base, grid %sx%s → %sx%spx @ %sdpi", + _scale, + screen_info["columns"], + screen_info["rows"], + screen_info["pixel_width"], + screen_info["pixel_height"], + screen_info["dpi"], + ) + else: + try: + screen_info = get_terminal_size_pixels() + except Exception: + logging.debug("Falling back to default terminal size for PythonHandler connection_settings") + screen_info = _build_screen_info(DEFAULT_TERMINAL_COLUMNS, DEFAULT_TERMINAL_ROWS) + + # Build connection settings for Guacamole handshake + # These are used when guacd sends 'args' instruction + connection_settings = _build_guacamole_connection_settings( + params=params, + record_uid=record_uid, + protocol=protocol, + settings=settings, + context=context, + screen_info=screen_info, + user_record_uid=user_record_uid, + credential_type=credential_type, + normalize_crlf=bool(kwargs.get('normalize_crlf')), + ) + + # Create the handler and callback + handler_callback, python_handler = create_python_handler( + tube_registry=tube_registry, + conversation_id=conversation_id, + conn_no=1, + connection_settings=connection_settings, + ) + + logging.debug(f"Created PythonHandler for conversation {conversation_id}") + + # Create the tube to get the WebRTC offer + offer = tube_registry.create_tube( + conversation_id=conversation_id, + settings=webrtc_settings, + trickle_ice=trickle_ice, + callback_token=webrtc_settings["callback_token"], + ksm_config="", + krelay_server=relay_url, + client_version="Commander-Python-Terminal", + offer=None, # Let Rust create the offer + signal_callback=signal_handler.signal_from_rust, + handler_callback=handler_callback, # PythonHandler callback (None if not using) + ) + + if not offer or 'tube_id' not in offer or 'offer' not in offer: + error_msg = "Failed to create tube" + if offer: + error_msg = offer.get('error', error_msg) + # Clean up temporary session on failure + unregister_tunnel_session(temp_tube_id) + unregister_conversation_key(conversation_id) + return {"success": False, "error": error_msg} + + commander_tube_id = offer['tube_id'] + logging.debug(f"Created tube with ID: {commander_tube_id}") + logging.debug(f"Conversation ID for this tube: {conversation_id_original}") + logging.debug(f"Data channel will be named: {conversation_id}") + _pam_tc.checkpoint('create_tube_ok') + + # Update signal handler and tunnel session with real tube ID + signal_handler.tube_id = commander_tube_id + tunnel_session.tube_id = commander_tube_id + + # Unregister temporary session and register with real tube ID + unregister_tunnel_session(temp_tube_id) + register_tunnel_session(commander_tube_id, tunnel_session) + + logging.debug(f"Registered encryption key for conversation: {conversation_id}") + logging.debug(f"Expecting WebSocket responses for conversation ID: {conversation_id}") + + # (WebSocket listener already started above, before create_tube.) + + # Wait for WebSocket to be ready before sending offer (same as pam tunnel start). + # Use event.wait() when available so we proceed as soon as ready; fallback to short sleep. + max_wait = 15.0 + # Router/gateway need a moment to register the conversation after the + # WebSocket handshake. Default 0.30s; on first-offer failure we top up + # with the delta to the legacy 2.0s before retrying (adaptive fallback). + backend_delay = websocket_backend_delay_sec() + if trickle_ice: + if tunnel_session.websocket_ready_event: + logging.debug(f"Waiting for dedicated WebSocket to connect (max {max_wait}s)...") + websocket_ready = tunnel_session.websocket_ready_event.wait(timeout=max_wait) + if not websocket_ready: + logging.error(f"Dedicated WebSocket did not become ready within {max_wait}s") + signal_handler.cleanup() + unregister_tunnel_session(commander_tube_id) + return {"success": False, "error": "WebSocket connection timeout"} + logging.debug("Dedicated WebSocket connection established and ready for streaming") + logging.debug(f"Waiting {backend_delay}s for backend to register conversation...") + time.sleep(backend_delay) + _pam_tc.checkpoint('websocket_ready_backend_delay_done') + else: + logging.warning("No WebSocket ready event for tunnel, using backend delay %.1fs", backend_delay) + time.sleep(backend_delay) + _pam_tc.checkpoint('websocket_no_event_backend_delay_done') + else: + # Non-trickle ICE: SDP answer comes via the HTTP offer response body + # (handled further below in the non-streaming branch) and ICE candidates + # are carried inside the offer SDP itself, so there is no streamed + # conversation to register on the router/gateway side. The WebSocket + # listener keeps running in the background for async signaling + # (disconnect / state changes) but the main thread does not need to + # block on it. Saves ~backend_delay + ~WS-TLS-handshake before the + # offer POST (~700ms on a typical launch). + logging.debug("Non-trickle ICE: skipping WebSocket-ready wait and backend_delay") + _pam_tc.checkpoint('non_trickle_skip_backend_delay') + + # Send offer to gateway via HTTP POST + logging.debug(f"{bcolors.OKBLUE}Sending {protocol} connection offer to gateway...{bcolors.ENDC}") + + # Prepare the offer data with terminal-specific parameters + # Match webvault format: host, size, audio, video, image (for guacd configuration) + # These parameters are needed by Gateway to configure guacd BEFORE OpenConnection + # Get terminal size for Guacamole size parameter (offer payload). + # get_terminal_size_pixels() queries the terminal internally and uses + # platform-specific APIs (Windows: GetCurrentConsoleFontEx; Unix: + # TIOCGWINSZ) to obtain exact pixel dimensions before falling back to + # the fixed cell-size estimate. + _scale = kwargs.get('scale') + if isinstance(_scale, int) and _scale > 0 and _scale != 100: + _ts = shutil.get_terminal_size(fallback=(DEFAULT_TERMINAL_COLUMNS, DEFAULT_TERMINAL_ROWS)) + screen_info = scale_screen_info(_ts.columns, _ts.lines, _scale) + logging.debug( + "--scale %s%% (offer): guacd-96 base, grid %sx%s → %sx%spx @ %sdpi", + _scale, + screen_info["columns"], + screen_info["rows"], + screen_info["pixel_width"], + screen_info["pixel_height"], + screen_info["dpi"], + ) + else: + try: + screen_info = get_terminal_size_pixels() + except Exception: + logging.debug("Falling back to default terminal size for offer payload") + screen_info = _build_screen_info(DEFAULT_TERMINAL_COLUMNS, DEFAULT_TERMINAL_ROWS) + logging.debug( + f"Using terminal metrics columns={screen_info['columns']} rows={screen_info['rows']} -> " + f"{screen_info['pixel_width']}x{screen_info['pixel_height']}px @ {screen_info['dpi']}dpi" + ) + + # Offer payload and Guacamole ``size`` handshake must agree. The handler was created + # earlier; refresh its stored width/height/dpi so a slightly later ``args``/handshake + # matches what we send in the connection offer (avoids PTY geometry vs. local TTY drift). + if python_handler is not None: + python_handler.connection_settings['width'] = screen_info['pixel_width'] + python_handler.connection_settings['height'] = screen_info['pixel_height'] + python_handler.connection_settings['dpi'] = screen_info['dpi'] + # Keep connect-instruction lookup in sync with top-level handshake size/DPI. + _gp_sync = python_handler.connection_settings.setdefault('guacd_params', {}) + _gp_sync['width'] = str(screen_info['pixel_width']) + _gp_sync['height'] = str(screen_info['pixel_height']) + _gp_sync['dpi'] = str(screen_info['dpi']) + + offer_payload = offer.get("offer") + decoded_offer_bytes = None + decoded_offer_text = None + use_re_encoded_offer = False + + if isinstance(offer_payload, str): + try: + # Offers coming from the Rust module are base64-encoded SDP blobs. + decoded_offer_bytes = base64.b64decode(offer_payload, validate=True) + decoded_offer_text = decoded_offer_bytes.decode('utf-8') + use_re_encoded_offer = True + except Exception: + decoded_offer_text = offer_payload + elif isinstance(offer_payload, bytes): + decoded_offer_text = offer_payload.decode('utf-8', errors='ignore') + + if decoded_offer_text is None: + decoded_offer_text = offer_payload + + offer_sdp = _ensure_max_message_size_attribute(decoded_offer_text) + + if offer_sdp is None: + offer_payload = offer.get("offer") + elif use_re_encoded_offer: + offer_payload = base64.b64encode(offer_sdp.encode('utf-8')).decode('utf-8') + else: + offer_payload = offer_sdp + + # Gateway may configure guacd from this map before Python's `connect`. + offer_guacd_params: Dict[str, Any] = {'enable-pipe': 'true'} + _offer_clip = settings.get('clipboard') or {} + if use_python_handler: + _cs_gp = connection_settings.get('guacd_params') or {} + for _k, _v in _cs_gp.items(): + if _k in ('disable-paste', 'disable-copy'): + continue + offer_guacd_params[_k] = _v + if _offer_clip.get('disablePaste'): + offer_guacd_params['disable-paste'] = 'true' + if _offer_clip.get('disableCopy'): + offer_guacd_params['disable-copy'] = 'true' + + _offer_disable_copy = bool(_offer_clip.get('disableCopy')) + _offer_disable_paste = bool(_offer_clip.get('disablePaste')) + + offer_data = { + "offer": offer_payload, + "audio": ["audio/L8", "audio/L16"], # Supported audio codecs + "video": [], # Supported video codecs - None for terminal + # [width, height, dpi] — matches screen_info / pixel mode (e.g. 96 guacd, 192 kcm) + "size": [screen_info['pixel_width'], screen_info['pixel_height'], screen_info['dpi']], + "image": ["image/jpeg", "image/png", "image/webp"], # Supported image formats + # CRITICAL: Gateway needs 'host' to configure guacd connection + "host": { + "hostName": settings['hostname'], + "port": settings['port'] + }, + # enable-pipe + optional disable-paste/disable-copy from PAM (see offer_guacd_params) + "guacd_params": offer_guacd_params, + "terminalSettings": { + "disableCopy": _offer_disable_copy, + "disablePaste": _offer_disable_paste, + }, + # Alternate shape (PAM record uses connection.clipboard) + "clipboard": { + "disableCopy": _offer_disable_copy, + "disablePaste": _offer_disable_paste, + }, + # these are not sent by webvault during open connection for terminal connections + # "protocol": protocol, + # "terminalSettings": { + # "colorScheme": settings['terminal']['colorScheme'], + # "fontSize": settings['terminal']['fontSize'], + # } + } + + # TODO: Add protocol-specific settings to offer + # if 'protocol_specific' in settings and settings['protocol_specific']: + # offer_data["protocolSettings"] = settings['protocol_specific'] + + logging.debug(f"Sending initial offer with connection parameters: {json.dumps(offer_data, indent=2)}") + data_bytes = string_to_bytes(json.dumps(offer_data)) + encrypted_data = tunnel_encrypt(symmetric_key, data_bytes) + + # Get userRecordUid and credential flags from context (extracted in extract_terminal_settings) + user_record_uid = context.get('userRecordUid') + allow_supply_host = context.get('allowSupplyHost', False) + allow_supply_user = context.get('allowSupplyUser', False) + create_ephemeral = context.get('createEphemeral', False) + + # Determine credential type for gateway inputs + # Gateway credential types (matches web vault useLaunchHandlers.ts): + # - 'ephemeral': JIT — gateway creates a temp account on the target from its own + # decrypted jit_settings (client cannot override). Required when + # jit_settings.createEphemeral=true on the PAM resource graph. + # - 'linked': Look up credential in DAG (for records with DAG-linked pamUser) + # - 'userSupplied': Skip DAG lookup, credentials from ConnectAs (-cr) or user prompt + # - None: Use pamMachine credentials directly + # Precedence: ephemeral (JIT) wins over everything because the gateway enforces + # it when createEphemeral is set; sending anything else results in a gateway + # rejection. We warn if the user also passed -cr so they know it was ignored. + credential_type_for_gateway = None + cli_user_override = context.get('cliUserOverride', False) + if create_ephemeral: + credential_type_for_gateway = 'ephemeral' + if cli_user_override: + logging.warning( + "Record %s has JIT (createEphemeral=true); --credential override " + "is ignored because the gateway creates an ephemeral account.", + record_uid, + ) + # userRecordUid is meaningless for ephemeral — the gateway ignores it and + # generates its own username. Clear it so we don't accidentally send one. + user_record_uid = None + logging.debug("Using 'ephemeral' credential type for gateway (JIT)") + elif cli_user_override: + # User explicitly supplied a different credential via -cr. + # The -cr record is NOT DAG-linked to this machine so 'linked' would fail; + # credentials arrive via the ConnectAs payload (built in launch.py after tunnel opens). + # NOTE: -H/-hr are not accepted without -cr (legacy, to match Web Vault behaviour), + # so cli_user_override=True is the only reliable signal that the user supplied something. + credential_type_for_gateway = 'userSupplied' + logging.debug("CLI credential override active - using 'userSupplied' for gateway") + elif user_record_uid: + # DAG-linked pamUser (no CLI override) - gateway looks up credentials via DAG + credential_type_for_gateway = 'linked' + logging.debug(f"Using 'linked' credential type for gateway with userRecordUid: {user_record_uid}") + else: + logging.debug(f"No linked pamUser for record {record_uid} - using pamMachine credentials directly") + + # Formerly a fixed ``time.sleep(1)`` — now 0.0 by default because the + # preceding backend_delay already covers router registration. Set + # PAM_PRE_OFFER_LEGACY=1 (or PAM_PRE_OFFER_SEC=) to restore. + _pre_offer = pre_offer_delay_sec() + _pam_tc.checkpoint('pre_offer_sleep_start') + if _pre_offer > 0: + time.sleep(_pre_offer) + _pam_tc.checkpoint('pre_offer_sleep_done') + + # Send offer via HTTP POST - two paths: streaming vs non-streaming + try: + # Build inputs dict - matching working session format + inputs = { + "recordUid": record_uid, + 'kind': 'start', + 'base64Nonce': base64_nonce, + 'conversationType': context['conversationType'], + "data": encrypted_data, + "trickleICE": trickle_ice, # Set trickle ICE flag + } + + # Add credential type and userRecordUid based on mode + if credential_type_for_gateway == 'ephemeral': + # JIT: gateway re-reads jit_settings from its own DAG and creates a temp + # account. Do NOT send userRecordUid (meaningless) or allowSupplyUser + # (gateway would interpret as userSupplied and reject ephemeral). + inputs['credentialType'] = 'ephemeral' + elif credential_type_for_gateway == 'linked' and user_record_uid: + inputs['credentialType'] = 'linked' + inputs['userRecordUid'] = user_record_uid + elif credential_type_for_gateway == 'userSupplied': + inputs['credentialType'] = 'userSupplied' + # For userSupplied, set allow_supply_user flag in connect_as_settings + # This matches gateway behavior (line 1203 in tunnel_vault_record.py) + inputs['allowSupplyUser'] = True + logging.debug("Using userSupplied credential type - user will provide credentials") + # else: no credentialType - gateway uses pamMachine credentials directly (backward compatible) + + # Add 2FA value if workflow requires MFA + two_factor_value = kwargs.get('two_factor_value') + if two_factor_value: + inputs['twoFactorValue'] = two_factor_value + + # Router token is no longer extracted from cookies (removed in commit 338a9fda) + # Router affinity is now handled server-side + + # Generate messageId from conversationId (replace + with -, / with _) + message_id = GatewayAction.conversation_id_to_message_id(conversation_id_original) + logging.debug(f"Generated messageId: {message_id} from conversationId: {conversation_id_original}") + + # --- Gateway offer POST with retry + adaptive backend-delay fallback --- + # On a first-attempt failure that looks like a transient backend-not-ready + # condition (timeout, 502/503/504, controller_down, RRC timeout), sleep + # the retry base delay plus the delta between the fast default and the + # legacy ``WEBSOCKET_BACKEND_DELAY`` so the cumulative wait on the retry + # matches the pre-change behavior. Fast path stays fast; unlucky first + # try still gets the full safety window before a second attempt. + try: + _max_offer_attempts = max(1, int(os.environ.get('PAM_GATEWAY_OFFER_MAX_ATTEMPTS', '2'))) + except (TypeError, ValueError): + _max_offer_attempts = 2 + _offer_retry_extra = offer_retry_extra_delay_sec() + _offer_backend_catchup = max( + 0.0, + websocket_backend_delay_legacy_sec() - websocket_backend_delay_sec(), + ) + _offer_transient_patterns = ( + 'timeout', 'rrc_timeout', 'bad_state', 'connection', + '502', '503', '504', 'controller_down', + ) + + def _send_gateway_offer_with_retry(is_streaming, **extra_kwargs): + _resp = None + for _oa in range(_max_offer_attempts): + if _oa > 0: + if _offer_backend_catchup > 0 or _offer_retry_extra > 0: + _pam_tc.checkpoint('gateway_offer_backend_catchup_delay_start') + time.sleep(_offer_retry_extra + _offer_backend_catchup) + if _offer_backend_catchup > 0 or _offer_retry_extra > 0: + _pam_tc.checkpoint('gateway_offer_backend_catchup_delay_done') + _pam_tc.checkpoint( + 'gateway_offer_http_attempt_1' if _oa == 0 + else 'gateway_offer_http_attempt_{}'.format(_oa + 1) + ) + # Ephemeral/JIT sessions spend 30-90s on remote account creation on + # the gateway (see TunnelTimeouts.jit_account_creation in the gateway); + # the normal 30s offer timeout is too tight. Default 120s, overridable. + if credential_type_for_gateway == 'ephemeral': + try: + _gw_to = int(os.environ.get('PAM_GATEWAY_OFFER_TIMEOUT_EPHEMERAL_MS', '120000')) + except (TypeError, ValueError): + _gw_to = 120000 + else: + _gw_to = 30000 + try: + _resp = router_send_action_to_gateway( + params=params, + destination_gateway_uid_str=gateway_uid, + gateway_action=GatewayActionWebRTCSession( + conversation_id=conversation_id_original, + inputs=inputs, + message_id=message_id, + ), + message_type=pam_pb2.CMT_CONNECT, + is_streaming=is_streaming, + gateway_timeout=_gw_to, + **extra_kwargs, + ) + except requests.exceptions.RequestException as _re: + if _oa < _max_offer_attempts - 1: + logging.warning( + 'Gateway offer HTTP error (%s); retrying (attempt %s/%s)', + _re, _oa + 1, _max_offer_attempts, + ) + continue + raise + except Exception as _ge: + _em = str(_ge).lower() + if _oa < _max_offer_attempts - 1 and any( + _p in _em for _p in _offer_transient_patterns + ): + logging.warning( + 'Gateway offer transient failure (%s); retrying (attempt %s/%s)', + _ge, _oa + 1, _max_offer_attempts, + ) + continue + raise + if _resp is None and _oa < _max_offer_attempts - 1: + logging.warning( + 'Gateway offer returned no response; retrying (attempt %s/%s)', + _oa + 1, _max_offer_attempts, + ) + continue + break + _pam_tc.checkpoint('gateway_offer_http_done') + return _resp + + # Two paths: streaming vs non-streaming + if trickle_ice: + # Streaming path: Response will come via WebSocket (use same tokens and session as WebSocket for ALB stickiness) + offer_kwargs = {} + if router_tokens and len(router_tokens) >= 3: + offer_kwargs = { + "transmission_key": router_tokens[2], + "encrypted_transmission_key": router_tokens[1], + "encrypted_session_token": router_tokens[0], + } + if http_session is not None: + offer_kwargs["http_session"] = http_session + router_response = _send_gateway_offer_with_retry(is_streaming=True, **offer_kwargs) + + if router_response is None: + signal_handler.cleanup() + unregister_tunnel_session(commander_tube_id) + # Fail fast (Commander waits ~24s for WebRTC). Keep the same + # user-facing offline wording; avoid nested CommandError wrapping. + return { + "success": False, + "error": "This Gateway currently is not online.", + } + + logging.debug(f"{bcolors.OKGREEN}Offer sent to gateway (streaming mode){bcolors.ENDC}") + + # Mark offer as sent + signal_handler.offer_sent = True + tunnel_session.offer_sent = True + + # Send any buffered ICE candidates — one batched HTTP POST instead of N + # serial ``_send_ice_candidate_immediately`` calls. The gateway's + # ``add_ice_candidates_to_conversation_tunnel`` already iterates the + # ``candidates`` array internally and each ``add_ice_candidate`` PyO3 + # call is spawn-and-return, so a batch costs the server ~the same as + # a single candidate while collapsing ~N*500ms of client-side round + # trips into one. + if tunnel_session.buffered_ice_candidates: + logging.debug(f"Flushing {len(tunnel_session.buffered_ice_candidates)} buffered ICE candidates") + signal_handler._send_ice_candidates_batch( + tunnel_session.buffered_ice_candidates, commander_tube_id + ) + tunnel_session.buffered_ice_candidates.clear() + + logging.debug(f"{bcolors.OKGREEN}Terminal connection established for {protocol.upper()}{bcolors.ENDC}") + logging.debug(f"{bcolors.OKBLUE}Connection state: {bcolors.ENDC}gathering candidates...") + + _pam_tc.summary('webrtc_tunnel_open_ok_streaming') + return { + "success": True, + "tube_id": commander_tube_id, + "conversation_id": conversation_id, + "tube_registry": tube_registry, + "signal_handler": signal_handler, + "websocket_thread": websocket_thread, + "status": "connecting", + "screen_info": screen_info, + "python_handler": python_handler, # PythonHandler for simplified guac protocol + "use_python_handler": use_python_handler, + "user_record_uid": user_record_uid, # For ConnectAs payload + "gateway_uid": gateway_uid, # For ConnectAs payload + } + else: + # Non-streaming path: Handle response immediately + router_response = _send_gateway_offer_with_retry(is_streaming=False) + + if router_response is None: + signal_handler.cleanup() + unregister_tunnel_session(commander_tube_id) + return { + "success": False, + "error": "This Gateway currently is not online.", + } + + logging.debug(f"{bcolors.OKGREEN}Offer sent to gateway (non-streaming mode){bcolors.ENDC}") + logging.debug(f"Router response: {router_response}") + + # Must be defined before return below; only refined inside `if router_response`. + remote_webrtc_version = None + + # Handle immediate response + if router_response and router_response.get('response'): + response_dict = router_response['response'] + logging.debug(f"Received immediate response from gateway: {response_dict}") + response_payload = response_dict.get('payload') if isinstance(response_dict, dict) else "{}" + if isinstance(response_payload, str): + try: + response_payload = json.loads(response_payload) + except json.JSONDecodeError: + response_payload = {} + + # Check for errors in response + if not (response_payload.get('is_ok') or response_payload.get('isOk')): + error_msg = response_payload.get('error', 'Unknown error from gateway') + raise Exception(f"Gateway error: {error_msg} Payload: {response_payload}") + + # Decrypt and handle payload.data if present (contains SDP answer) + if response_payload.get('is_ok') and response_payload.get('data'): + data_field = response_payload.get('data', '') + + # Check if this is a plain text acknowledgment (not encrypted) + if isinstance(data_field, str) and ( + "ice candidate" in data_field.lower() or + "buffered" in data_field.lower() or + "connected" in data_field.lower() or + "disconnected" in data_field.lower() or + "error" in data_field.lower() or + data_field.endswith(conversation_id_original) + ): + logging.debug(f"Received plain text acknowledgment: {data_field}") + else: + # This is encrypted data - decrypt it + encrypted_data = data_field + if encrypted_data: + logging.debug(f"Found encrypted data in response, length: {len(encrypted_data)}") + try: + decrypted_data = tunnel_decrypt(symmetric_key, encrypted_data) + if decrypted_data: + data_text = bytes_to_string(decrypted_data).replace("'", '"') + logging.debug(f"Successfully decrypted data for {conversation_id_original}, length: {len(data_text)}") + + # Parse JSON; fallback to raw SDP if decrypted data is plain SDP + answer_sdp = None + data_json = None + try: + data_json = json.loads(data_text) + if isinstance(data_json, dict): + logging.debug(f"🔓 Decrypted payload type: {data_json.get('type', 'unknown')}, keys: {list(data_json.keys())}") + answer_sdp = data_json.get('answer') or data_json.get('sdp') + except (json.JSONDecodeError, TypeError): + if data_text.strip().startswith('v=') and 'm=' in data_text: + answer_sdp = data_text.strip() + logging.debug("Decrypted data appears to be raw SDP (not JSON), using as answer") + + if answer_sdp: + logging.debug(f"Found SDP answer in non-streaming response, sending to Rust for conversation: {conversation_id_original}") + remote_webrtc_version = set_remote_description_and_parse_version( + tube_registry, commander_tube_id, answer_sdp, is_answer=True + ) + + if hasattr(tunnel_session, "gateway_ready_event") and tunnel_session.gateway_ready_event is not None: + tunnel_session.gateway_ready_event.set() + logging.debug(f"{bcolors.OKBLUE}Connection state: {bcolors.ENDC}SDP answer received, connecting...") + + if tunnel_session.buffered_ice_candidates: + logging.debug(f"Sending {len(tunnel_session.buffered_ice_candidates)} buffered ICE candidates after answer") + signal_handler._send_ice_candidates_batch( + tunnel_session.buffered_ice_candidates, commander_tube_id + ) + tunnel_session.buffered_ice_candidates.clear() + elif isinstance(data_json, dict) and ("offer" in data_json or data_json.get("type") == "offer"): + logging.warning(f"Received ICE restart offer in non-streaming mode - this is unexpected") + else: + logging.warning(f"Decryption returned None for conversation {conversation_id_original}") + except Exception as e: + logging.error(f"Failed to decrypt data in non-streaming response: {e}") + logging.debug(f"Data content: {encrypted_data[:100]}...") + # Don't fail the connection if decryption fails - might be a plain text response + + # Mark offer as sent + signal_handler.offer_sent = True + tunnel_session.offer_sent = True + + # No ICE candidates to send in non-streaming mode (all candidates in SDP) + logging.debug(f"{bcolors.OKGREEN}Terminal connection established for {protocol.upper()}{bcolors.ENDC}") + logging.debug(f"{bcolors.OKBLUE}Connection state: {bcolors.ENDC}established (non-streaming mode)...") + + _pam_tc.summary('webrtc_tunnel_open_ok_non_streaming') + return { + "success": True, + "tube_id": commander_tube_id, + "conversation_id": conversation_id, + "tube_registry": tube_registry, + "signal_handler": signal_handler, + "websocket_thread": websocket_thread, + "status": "connected", + "router_response": router_response, + "screen_info": screen_info, + "python_handler": python_handler, # PythonHandler for simplified guac protocol + "use_python_handler": use_python_handler, + "user_record_uid": user_record_uid, # For ConnectAs payload + "gateway_uid": gateway_uid, # For ConnectAs payload + "remote_webrtc_version": remote_webrtc_version, # From SDP for ConnectAs capability + } + + except Exception as e: + # Stop dedicated WebSocket before Rust/tube cleanup so we do not process a late + # channel_closed after the CLI has already returned (avoids stray ERROR after prompt). + try: + if tunnel_session.websocket_stop_event and tunnel_session.websocket_thread: + tunnel_session.websocket_stop_event.set() + tunnel_session.websocket_thread.join(timeout=3.0) + except Exception: + logging.debug("Stopping WebSocket after HTTP offer failure", exc_info=True) + signal_handler.cleanup() + unregister_tunnel_session(commander_tube_id) + unregister_conversation_key(conversation_id) + _notify_gateway_connection_close(params, router_token) + return {"success": False, "error": f"Failed to send offer via HTTP: {e}"} + + except Exception as e: + logging.error(f"Error opening terminal WebRTC tunnel: {e}") + if 'conversation_id' in locals() and conversation_id: + unregister_conversation_key(conversation_id) + if 'signal_handler' in locals(): + signal_handler.cleanup() + return {"success": False, "error": f"Failed to establish tunnel: {e}"} + + +def launch_terminal_connection(params: KeeperParams, + record_uid: str, + gateway_info: Dict[str, Any], + connect_as: Optional[str] = None, + **kwargs) -> Dict[str, Any]: + """ + Launch a terminal connection for a PAM record. + + This is the main entry point for terminal connections. It: + 1. Detects the protocol + 2. Extracts settings + 3. Builds connection context + 4. Opens WebRTC tunnel + 5. Manages lifecycle + + Args: + params: KeeperParams instance + record_uid: Record UID + gateway_info: Gateway information from find_gateway + connect_as: Optional username to connect as + + Returns: + Dictionary with connection status: + - success: bool + - protocol: str + - context: connection context dict + - tunnel: tunnel result dict + - error: error message if failed + + Raises: + CommandError: If connection cannot be established + """ + try: + _launch_tc = PamConnectTiming('pam-launch:terminal_connection') + _launch_tc.checkpoint('enter') + # Step 1: Detect protocol + protocol = detect_protocol(params, record_uid) + if not protocol or protocol not in ALL_TERMINAL: + raise CommandError( + 'pam launch', + f'Protocol {protocol!r} is not supported for record {record_uid}. ' + 'Only terminal protocols (ssh, telnet, kubernetes, mysql, postgresql, sql-server) are supported.' + ) + + logging.debug(f"Detected protocol: {protocol}") + _launch_tc.checkpoint('protocol_detected') + + # Step 2: Extract settings (with optional CLI overrides). + # Forward the pre-resolved DAG launch credential UID when the caller supplied it + # (pam launch does, to collapse three DAG loads into one); otherwise + # extract_terminal_settings falls back to resolving it internally. + settings = extract_terminal_settings( + params, + record_uid, + protocol, + launch_credential_uid=kwargs.get('launch_credential_uid'), + custom_host=kwargs.get('custom_host'), + custom_port=kwargs.get('custom_port'), + dag_linked_uid=kwargs.get('dag_linked_uid', _DAG_UID_UNSET), + ) + logging.debug(f"Extracted settings: hostname={settings['hostname']}, port={settings['port']}") + _launch_tc.checkpoint('settings_extracted') + + # Step 3: Build connection context + context = create_connection_context( + params, + record_uid, + gateway_info['gateway_uid'], + protocol, + settings, + connect_as + ) + logging.debug(f"Built connection context for {protocol}") + _launch_tc.checkpoint('context_built') + + # Step 4: Open WebRTC tunnel + tunnel_result = _open_terminal_webrtc_tunnel( + params, + record_uid, + gateway_info['gateway_uid'], + protocol, + settings, + context, + **kwargs + ) + + if not tunnel_result.get('success'): + error_msg = tunnel_result.get('error', 'Unknown error') + # Offline gateway: surface Commander's wording without the WebRTC wrapper. + if 'not online' in str(error_msg).lower(): + raise CommandError('pam launch', error_msg) + raise CommandError('pam launch', f'Failed to open WebRTC tunnel: {error_msg}') + _launch_tc.checkpoint('webrtc_tunnel_opened') + + logging.debug(f"Terminal connection established for {protocol}") + logging.debug(f"Target: {settings['hostname']}:{settings['port']}") + logging.debug(f"Gateway: {gateway_info['gateway_name']} ({gateway_info['gateway_uid']})") + + _launch_tc.summary('terminal_connection_ok') + return { + 'success': True, + 'protocol': protocol, + 'context': context, + 'settings': settings, + 'gateway_info': gateway_info, + 'tunnel': { + **tunnel_result, + 'registry': tunnel_result.get('tube_registry') # Add registry for CLI mode + }, + 'message': f'Terminal connection established for {protocol}' + } + + except CommandError: + raise + except Exception as e: + logging.error(f"Error launching terminal connection: {e}") + raise CommandError('pam launch', f'Failed to launch terminal connection: {e}') diff --git a/keepercli-package/src/keepercli/commands/pam/pam_launch/terminal_reset.py b/keepercli-package/src/keepercli/commands/pam/pam_launch/terminal_reset.py new file mode 100644 index 00000000..a4d049b5 --- /dev/null +++ b/keepercli-package/src/keepercli/commands/pam/pam_launch/terminal_reset.py @@ -0,0 +1,343 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' List[Any]: + """``tcgetattr``/``tcsetattr`` list: copy top-level list and the ``cc`` sub-list.""" + a = list(attrs) + if len(a) >= 7 and isinstance(a[6], list): + a[6] = a[6][:] + return a + + +def stash_stdin_termios_attrs_for_post_reset(attrs: List[Any]) -> None: + """Remember stdin termios for :func:`_reapply_stashed_stdin_termios_attrs` (POSIX only).""" + global _stdin_termios_for_post_reset_reapply + try: + _stdin_termios_for_post_reset_reapply = copy.deepcopy(list(attrs)) + except Exception as exc: + logging.debug('stash stdin termios: deepcopy failed (%s), using shallow cc copy', exc) + try: + _stdin_termios_for_post_reset_reapply = _shallow_copy_termios_attrs(attrs) + except Exception as exc2: + logging.debug('stash stdin termios: shallow copy failed: %s', exc2) + _stdin_termios_for_post_reset_reapply = None + + +def stash_stdin_termios_from_stdin() -> None: + """ + Snapshot current stdin termios after a successful restore (read-back from kernel). + + Prefer this over :func:`stash_stdin_termios_attrs_for_post_reset` with pre-raw attrs so + the reapply path matches what the driver actually applied. + """ + global _stdin_termios_for_post_reset_reapply + if sys.platform == 'win32' or not sys.stdin.isatty(): + return + try: + import termios + + attrs = list(termios.tcgetattr(sys.stdin.fileno())) + try: + _stdin_termios_for_post_reset_reapply = copy.deepcopy(attrs) + except Exception as exc: + logging.debug('stash stdin termios from fd: deepcopy failed (%s), shallow cc', exc) + _stdin_termios_for_post_reset_reapply = _shallow_copy_termios_attrs(attrs) + except Exception as exc: + logging.debug('stash stdin termios from fd: %s', exc) + _stdin_termios_for_post_reset_reapply = None + + +def _reapply_stashed_stdin_termios_attrs() -> None: + """If a stash helper ran after restore, re-apply those attrs to stdin.""" + global _stdin_termios_for_post_reset_reapply + if _stdin_termios_for_post_reset_reapply is None: + return + if sys.platform == 'win32': + _stdin_termios_for_post_reset_reapply = None + return + try: + if not sys.stdin.isatty(): + return + import termios + + termios.tcsetattr( + sys.stdin.fileno(), + termios.TCSADRAIN, + _stdin_termios_for_post_reset_reapply, + ) + except Exception as exc: + logging.debug('reapply stashed stdin termios after pam reset: %s', exc) + finally: + _stdin_termios_for_post_reset_reapply = None + + +def _post_reset_line_count() -> int: + """Fresh terminal row count at call time (handles resize since session start).""" + try: + if sys.stdout.isatty(): + return max(1, shutil.get_terminal_size().lines) + except Exception as exc: + logging.debug('post-reset line count: %s', exc) + return _FALLBACK_TERMINAL_ROWS + + +def _padding_line_count(session_start_rows: int | None) -> int: + """ + Rows of newline padding: max(current size, session start size). + + Session start printed ``session_start_rows`` newlines; if the user shrinks the + window before exit, ``current`` alone would under-pad; ``max`` fixes that. + If the window grew, ``current`` is larger and dominates. + """ + current = _post_reset_line_count() + if session_start_rows is None: + return current + return max(current, max(1, int(session_start_rows))) + + +def _ansi_terminal_reset_string() -> str: + """VT sequences to undo common fullscreen TUI state (nano, vim, etc.).""" + return ( + # RIS (ESC c): full terminal reset—same class of fix as interactive `reset`. Do not remove: + # without it, macOS Terminal (and similar) can leave broken newline / line-spacing after + # pam launch exits, even when `stty -a` looks unchanged. + '\033c' + '\x1b[?1049l' # rmcup — exit alternate screen + '\x1b[?47l' # old secondary screen off (no-op on modern terminals) + '\x1b[r' # reset scroll region / margins (DECSTBM full screen) + '\x1b[?6l' # origin mode off — cursor addressing to full screen + # xterm mouse / SGR mouse (nano may enable) + '\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l' + '\x1b[?2004l' # bracketed paste off + '\x1b[?25h' # show cursor + '\x1b[0m' # SGR reset + '\x1b[?1l' # DECCKM off — normal cursor keys + '\x1b[?7h' # autowrap on + ) + + +def _post_reset_newlines(session_start_rows: int | None = None) -> str: + """Padding newlines: see ``_padding_line_count`` and launch.py pre-session clear.""" + n = _padding_line_count(session_start_rows) + if sys.platform == 'win32': + return '\r\n' * n + return '\n' * n + + +def _post_reset_clear_viewport() -> str: + """ + Full terminal reset via viewport clear. + + CSI 3 J + 2 J + H clears scrollback and the visible screen. That strips + remote TUI residue aggressively but loses all buffered scrollback in the + emulator — enable in :func:`reset_local_terminal_after_pam_session` only if + that trade-off is acceptable. + """ + return '\x1b[3J\x1b[2J\x1b[H' + + +def _windows_scroll_viewport_to_cursor() -> None: + """ + If the cursor row is outside the visible window, scroll the window so the + cursor is on-screen (typically at the bottom). Fixes ConPTY/Windows + Terminal leaving the viewport at the top while output continues below. + """ + try: + import ctypes + from ctypes import wintypes + + kernel32 = ctypes.windll.kernel32 + STD_OUTPUT_HANDLE = -11 + h = kernel32.GetStdHandle(STD_OUTPUT_HANDLE) + if not h or h == wintypes.HANDLE(-1).value: + return + + class COORD(ctypes.Structure): + _fields_ = [('X', wintypes.SHORT), ('Y', wintypes.SHORT)] + + class SMALL_RECT(ctypes.Structure): + _fields_ = [ + ('Left', wintypes.SHORT), + ('Top', wintypes.SHORT), + ('Right', wintypes.SHORT), + ('Bottom', wintypes.SHORT), + ] + + class CONSOLE_SCREEN_BUFFER_INFO(ctypes.Structure): + _fields_ = [ + ('dwSize', COORD), + ('dwCursorPosition', COORD), + ('wAttributes', wintypes.WORD), + ('srWindow', SMALL_RECT), + ('dwMaximumWindowSize', COORD), + ] + + info = CONSOLE_SCREEN_BUFFER_INFO() + if not kernel32.GetConsoleScreenBufferInfo(h, ctypes.byref(info)): + return + + cy = int(info.dwCursorPosition.Y) + top = int(info.srWindow.Top) + bottom = int(info.srWindow.Bottom) + win_h = bottom - top + 1 + buf_rows = int(info.dwSize.Y) + if win_h <= 0 or buf_rows <= 0: + return + + # Put the cursor on the bottom row of the viewport (normal shell UX). + # ConPTY sometimes leaves the window pinned while the cursor moves down. + max_top = max(0, buf_rows - win_h) + new_top = cy - win_h + 1 + if new_top < 0: + new_top = 0 + elif new_top > max_top: + new_top = max_top + + new_bottom = new_top + win_h - 1 + if new_bottom >= buf_rows: + new_bottom = buf_rows - 1 + new_top = max(0, new_bottom - win_h + 1) + + if new_top == top and new_bottom == bottom: + return + + sr = SMALL_RECT() + sr.Left = info.srWindow.Left + sr.Right = info.srWindow.Right + sr.Top = wintypes.SHORT(new_top) + sr.Bottom = wintypes.SHORT(new_bottom) + + if not kernel32.SetConsoleWindowInfo(h, True, ctypes.byref(sr)): + logging.debug( + 'SetConsoleWindowInfo failed: %s', kernel32.GetLastError() + ) + except Exception as exc: + logging.debug('Windows viewport scroll after pam session: %s', exc) + + +def _flush_stdin_queue_posix() -> None: + try: + if not sys.stdin.isatty(): + return + import termios + + termios.tcflush(sys.stdin.fileno(), termios.TCIFLUSH) + except Exception as exc: + logging.debug('tcflush stdin after pam session: %s', exc) + + +def _flush_stdin_queue_windows() -> None: + """Clear the console input queue (parity with POSIX tcflush TCIFLUSH).""" + try: + import ctypes + + kernel32 = ctypes.windll.kernel32 + STD_INPUT_HANDLE = -10 + h = kernel32.GetStdHandle(STD_INPUT_HANDLE) + # INVALID_HANDLE_VALUE (-1) is truthy in Python; still call Flush and rely on return. + if h == 0: + return + if not kernel32.FlushConsoleInputBuffer(h): + logging.debug('FlushConsoleInputBuffer failed: %s', kernel32.GetLastError()) + except Exception as exc: + logging.debug('FlushConsoleInputBuffer after pam session: %s', exc) + + +def reset_local_terminal_after_pam_session( + session_start_rows: int | None = None, +) -> None: + """ + Best-effort reset of the interactive terminal after pam launch CLI mode. + + Call only after InputHandler/StdinHandler.stop() has restored raw mode / + Windows console mode so stdin matches the outer shell again. + + Args: + session_start_rows: Row count used at session start for the pre-session + newline clear (``launch.py``). When set, padding uses + ``max(fresh get_terminal_size().lines, session_start_rows)``. + """ + if not sys.stdout.isatty(): + return + + try: + sys.stdout.write(_ansi_terminal_reset_string()) + + # Do not emit _post_reset_clear_viewport() here: it clears scrollback (CSI 3J) + # and was never meant to run on every exit; some terminals then behave oddly. + + sys.stdout.write(_post_reset_newlines(session_start_rows=session_start_rows)) + sys.stdout.flush() + except Exception as exc: + logging.debug('Terminal ANSI reset: %s', exc) + + # Queued input: POSIX tcflush; Windows FlushConsoleInputBuffer. + if sys.platform == 'win32': + _windows_scroll_viewport_to_cursor() + _flush_stdin_queue_windows() + else: + _flush_stdin_queue_posix() + + _reapply_stashed_stdin_termios_attrs() diff --git a/keepercli-package/src/keepercli/commands/pam/pam_launch/terminal_size.py b/keepercli-package/src/keepercli/commands/pam/pam_launch/terminal_size.py new file mode 100644 index 00000000..d23f6cbc --- /dev/null +++ b/keepercli-package/src/keepercli/commands/pam/pam_launch/terminal_size.py @@ -0,0 +1,709 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' Dict[str, int]: + """Return screen_info using guacd-96 base metrics scaled by *scale_pct* percent. + + Uses :func:`guacd_default_approximate_pixels` (DPI 96, 10×20 px chars) as + the base, then multiplies pixel_width and pixel_height by ``scale_pct / 100``. + Canonical ``pam launch --scale`` path: local console columns/rows with + ``dpi`` 96 aligned to guacd's default PTY pixel model. + + Example: scale_pct=80 → multiply base pixels by 0.80 (shrink) + scale_pct=120 → multiply base pixels by 1.20 (enlarge) + """ + base_w, base_h = guacd_default_approximate_pixels(columns, rows) + factor = scale_pct / 100.0 + return { + "columns": columns, + "rows": rows, + "pixel_width": max(1, int(base_w * factor)), + "pixel_height": max(1, int(base_h * factor)), + "dpi": _GUACD_DPI, + } + + +def _coerce_pixel_mode(mode_value: str) -> str: + """Normalize *mode_value* to ``kcm`` or ``guacd``; unknown/empty → :data:`DEFAULT_PIXEL_MODE`.""" + m = (mode_value or "").strip().lower() + if not m: + m = DEFAULT_PIXEL_MODE.strip().lower() + if m in (PIXEL_MODE_KCM, PIXEL_MODE_GUACD): + return m + return DEFAULT_PIXEL_MODE.strip().lower() + + +def approximate_pixels(columns: int, rows: int, pixel_mode: str = DEFAULT_PIXEL_MODE): + """Return (pixel_width, pixel_height) for the given pixel mode. + + Parameters + ---------- + pixel_mode: + ``'kcm'`` — kcm-cli/tty.js formula (DPI 192, char 19×38, with margin). + ``'guacd'`` — guacd defaults (DPI 96, char 10×20, scrollbar only). + Other values are treated as :data:`DEFAULT_PIXEL_MODE` (see :func:`_coerce_pixel_mode`). + """ + mode = _coerce_pixel_mode(pixel_mode) + if mode == PIXEL_MODE_GUACD: + return guacd_default_approximate_pixels(columns, rows) + return kcm_cli_approximate_pixels(columns, rows) + + +# --------------------------------------------------------------------------- +# Fallback helper (previously defined in terminal_connection.py) +# --------------------------------------------------------------------------- + +def _dpi_for_cell_fallback() -> int: + """DPI to embed when pixel dimensions come from cell estimates (same as Guacamole resize).""" + if sys.platform == 'win32': + return _get_dpi_windows() + if sys.platform == 'darwin': + return _get_dpi_macos() + return DEFAULT_SCREEN_DPI + +def _resolve_pixel_mode(pixel_mode: Optional[str] = None) -> str: + """Return the effective pixel mode, consulting env when *pixel_mode* is None. + + Unknown strings (including env typos) become :data:`DEFAULT_PIXEL_MODE`. + """ + if pixel_mode is not None: + return _coerce_pixel_mode(pixel_mode) + raw = os.environ.get("KEEPER_GUAC_PIXEL_MODE", DEFAULT_PIXEL_MODE) + return _coerce_pixel_mode(raw) + + +def _dpi_for_mode(pixel_mode: str) -> int: + """Return Guacamole handshake DPI for *pixel_mode*. + + Must stay aligned with :func:`approximate_pixels` cell metrics: + + - ``guacd`` → :data:`_GUACD_DPI` (96) + - ``kcm`` → :data:`_KCM_DPI` (192) + - Any other value → DPI for :data:`DEFAULT_PIXEL_MODE` (via :func:`_coerce_pixel_mode`). + """ + mode = _coerce_pixel_mode(pixel_mode) + if mode == PIXEL_MODE_GUACD: + return _GUACD_DPI + return _KCM_DPI + + +def default_handshake_dpi() -> int: + """DPI for Guacamole text-terminal handshake (matches ``screen_info['dpi']`` when mode is unset). + + Resolved from ``KEEPER_GUAC_PIXEL_MODE`` and :data:`DEFAULT_PIXEL_MODE` via + :func:`_dpi_for_mode`: **192** for ``kcm``, **96** for ``guacd``. + """ + return _dpi_for_mode(_resolve_pixel_mode(None)) + + +# Default for imports and ``settings.get('dpi', …)`` fallbacks; set at import time. +GUACAMOLE_HANDSHAKE_DPI = default_handshake_dpi() + + +def _build_screen_info(columns: int, rows: int, pixel_mode: Optional[str] = None) -> Dict[str, int]: + """Convert character columns/rows into pixel measurements for the Gateway. + + Uses the pixel mode to select the DPI and pixel formula: + - ``'kcm'`` — DPI 192, kcm-cli tty.js formula. + - ``'guacd'`` — DPI 96, guacd-native formula (no canvas margin). + + The active mode is resolved from the *pixel_mode* argument first, then the + ``KEEPER_GUAC_PIXEL_MODE`` environment variable, then :data:`DEFAULT_PIXEL_MODE`. + """ + mode = _resolve_pixel_mode(pixel_mode) + col_value = columns if isinstance(columns, int) and columns > 0 else DEFAULT_TERMINAL_COLUMNS + row_value = rows if isinstance(rows, int) and rows > 0 else DEFAULT_TERMINAL_ROWS + pixel_w, pixel_h = approximate_pixels(col_value, row_value, mode) + return { + "columns": col_value, + "rows": row_value, + "pixel_width": pixel_w, + "pixel_height": pixel_h, + "dpi": _dpi_for_mode(mode), + } + + +# --------------------------------------------------------------------------- +# Module-level caches +# --------------------------------------------------------------------------- + +# DPI cache: refreshed at most once per :data:`DPI_CACHE_TTL_SEC` when +# :func:`get_terminal_size_pixels` / resize runs need a DPI value. Not tied to +# "current window screen" on all platforms yet — see platform DPI helpers. +_dpi: Optional[int] = None +_dpi_cache_mono: Optional[float] = None # time.monotonic() when *_dpi* was stored + +# TIOCGWINSZ pixel support: None = untested, True = returns non-zero pixels, +# False = permanently disabled (returned all-zero pixel fields). When False, +# _get_pixels_unix() returns (0, 0) immediately without retrying the ioctl. +_tiocgwinsz_works: Optional[bool] = None + +# Interactive TTY flag, cached after first call. +_is_tty: Optional[bool] = None + + +# --------------------------------------------------------------------------- +# TTY detection +# --------------------------------------------------------------------------- + +def is_interactive_tty() -> bool: + """Return True if both stdin and stdout are connected to a real TTY. + + Cached after the first call. When running in a non-interactive environment + (piped I/O, CI, scripted launch) resize polling should be skipped entirely + to avoid spurious or meaningless size-change events. + """ + global _is_tty + if _is_tty is None: + try: + _is_tty = sys.stdin.isatty() and sys.stdout.isatty() + except Exception: + _is_tty = False + return bool(_is_tty) + + +# --------------------------------------------------------------------------- +# Platform DPI helpers +# --------------------------------------------------------------------------- + +def _invalidate_dpi_cache_if_stale() -> None: + """Clear module DPI cache when the TTL has expired.""" + global _dpi, _dpi_cache_mono + if _dpi is None: + return + if _dpi_cache_mono is None: + _dpi = None + return + if time.monotonic() - _dpi_cache_mono >= DPI_CACHE_TTL_SEC: + _dpi = None + _dpi_cache_mono = None + + +def _store_dpi(value: int) -> int: + """Store DPI and refresh cache timestamp.""" + global _dpi, _dpi_cache_mono + _dpi = int(value) + _dpi_cache_mono = time.monotonic() + return _dpi + + +def _get_dpi_windows() -> int: + """Return display DPI on Windows via ctypes, cached for the session. + + Tries GetDpiForSystem (shcore.dll, Windows 8.1+) first, then falls back + to GetDeviceCaps(LOGPIXELSX). Returns :data:`DEFAULT_SCREEN_DPI` on failure. + """ + _invalidate_dpi_cache_if_stale() + if _dpi is not None: + return _dpi + try: + # GetDpiForSystem - available on Windows 8.1+ via shcore.dll + try: + dpi = ctypes.windll.shcore.GetDpiForSystem() + if dpi and dpi > 0: + return _store_dpi(int(dpi)) + except Exception: + pass + # Fallback: GDI GetDeviceCaps(LOGPIXELSX) + LOGPIXELSX = 88 + hdc = ctypes.windll.user32.GetDC(0) + if hdc: + try: + dpi = ctypes.windll.gdi32.GetDeviceCaps(hdc, LOGPIXELSX) + if dpi and dpi > 0: + return _store_dpi(int(dpi)) + finally: + ctypes.windll.user32.ReleaseDC(0, hdc) + except Exception as e: + logging.debug(f"Could not query Windows DPI: {e}") + return _store_dpi(DEFAULT_SCREEN_DPI) + + +def _get_dpi_macos() -> int: + """Return approximate main-display DPI on macOS via CoreGraphics. + + Uses ``CGDisplayScreenSize`` (physical size in mm) and ``CGDisplayPixelsWide`` / + ``CGDisplayPixelsHigh`` to compute horizontal/vertical DPI and averages them. + This uses ``CGMainDisplayID()`` (system menu-bar display), not the screen that + holds the terminal window. Falls back to :data:`DEFAULT_SCREEN_DPI` on failure. + + Cached for :data:`DPI_CACHE_TTL_SEC` (then refreshed on next query). + """ + _invalidate_dpi_cache_if_stale() + if _dpi is not None: + return _dpi + try: + path = ctypes.util.find_library('CoreGraphics') + if not path: + return _store_dpi(DEFAULT_SCREEN_DPI) + cg = ctypes.CDLL(path) + CGDirectDisplayID = ctypes.c_uint32 + + cg.CGMainDisplayID.restype = CGDirectDisplayID + cg.CGMainDisplayID.argtypes = [] + + class CGSize(ctypes.Structure): + _fields_ = [('width', ctypes.c_double), ('height', ctypes.c_double)] + + cg.CGDisplayScreenSize.argtypes = [CGDirectDisplayID] + cg.CGDisplayScreenSize.restype = CGSize + + cg.CGDisplayPixelsWide.argtypes = [CGDirectDisplayID] + cg.CGDisplayPixelsWide.restype = ctypes.c_size_t + cg.CGDisplayPixelsHigh.argtypes = [CGDirectDisplayID] + cg.CGDisplayPixelsHigh.restype = ctypes.c_size_t + + main = cg.CGMainDisplayID() + size_mm = cg.CGDisplayScreenSize(main) + pw = float(cg.CGDisplayPixelsWide(main)) + ph = float(cg.CGDisplayPixelsHigh(main)) + + if size_mm.width > 0 and size_mm.height > 0 and pw > 0 and ph > 0: + dpi_x = pw / (size_mm.width / 25.4) + dpi_y = ph / (size_mm.height / 25.4) + dpi = int(round((dpi_x + dpi_y) / 2.0)) + if 72 <= dpi <= 600: + return _store_dpi(dpi) + except Exception as e: + logging.debug(f"Could not query macOS DPI: {e}") + return _store_dpi(DEFAULT_SCREEN_DPI) + + +def _try_linux_x11_xft_dpi() -> Optional[int]: + """Read Xft.dpi from the X11 resource database (``XGetDefault``). + + Works on X11 sessions and often under XWayland when ``DISPLAY`` is set. + Returns None if libX11 is unavailable, the display cannot be opened, or + Xft.dpi is unset. + """ + try: + lib_path = ctypes.util.find_library('X11') + if not lib_path: + return None + x11 = ctypes.CDLL(lib_path) + XOpenDisplay = x11.XOpenDisplay + XOpenDisplay.argtypes = [ctypes.c_char_p] + XOpenDisplay.restype = ctypes.c_void_p + XCloseDisplay = x11.XCloseDisplay + XCloseDisplay.argtypes = [ctypes.c_void_p] + XCloseDisplay.restype = ctypes.c_int + XGetDefault = x11.XGetDefault + XGetDefault.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_char_p] + XGetDefault.restype = ctypes.c_char_p + + dpy = XOpenDisplay(None) + if not dpy: + return None + try: + raw = XGetDefault(dpy, b'Xft', b'dpi') + if not raw: + return None + val = raw.decode('utf-8', errors='ignore').strip() + dpi_f = float(val) + dpi = int(round(dpi_f)) + if 72 <= dpi <= 600: + return dpi + finally: + XCloseDisplay(dpy) + except Exception as e: + logging.debug(f"Linux X11 Xft.dpi query failed: {e}") + return None + + +def _try_linux_gnome_text_scaling_dpi() -> Optional[int]: + """GNOME (and many Wayland) sessions: ``text-scaling-factor`` × :data:`DEFAULT_SCREEN_DPI`.""" + try: + if not shutil.which('gsettings'): + return None + r = subprocess.run( + [ + 'gsettings', + 'get', + 'org.gnome.desktop.interface', + 'text-scaling-factor', + ], + capture_output=True, + text=True, + timeout=0.5, + check=False, + ) + if r.returncode != 0 or not (r.stdout or '').strip(): + return None + line = (r.stdout or '').strip().strip("'\"") + factor = float(line) + if factor <= 0: + return None + dpi = int(round(DEFAULT_SCREEN_DPI * factor)) + if 72 <= dpi <= 600: + return dpi + except Exception as e: + logging.debug(f"Linux gsettings text-scaling query failed: {e}") + return None + + +def _try_linux_env_scale_dpi() -> Optional[int]: + """Derive effective DPI from common toolkit / Qt environment variables.""" + gdk = os.environ.get('GDK_SCALE') + if gdk: + try: + factor = float(gdk) + if factor > 0: + dpi = int(round(DEFAULT_SCREEN_DPI * factor)) + if 72 <= dpi <= 600: + return dpi + except ValueError: + pass + qt = os.environ.get('QT_SCALE_FACTOR') or os.environ.get('QT_SCREEN_SCALE_FACTORS') + if qt: + first = qt.split(';')[0].strip().split(',')[0].strip() + try: + factor = float(first) + if factor > 0: + dpi = int(round(DEFAULT_SCREEN_DPI * factor)) + if 72 <= dpi <= 600: + return dpi + except ValueError: + pass + return None + + +def _get_dpi_linux() -> int: + """Return display DPI on Linux without extra Python dependencies. + + Tries in order: + + 1. **X11** — ``Xft.dpi`` from the X resource database (``libX11``). + 2. **GNOME** — ``gsettings`` ``text-scaling-factor`` × :data:`DEFAULT_SCREEN_DPI`. + 3. **Environment** — ``GDK_SCALE`` or ``QT_SCALE_FACTOR`` / ``QT_SCREEN_SCALE_FACTORS`` + multiplied by :data:`DEFAULT_SCREEN_DPI`. + + Falls back to :data:`DEFAULT_SCREEN_DPI` when nothing applies (e.g. SSH + with no display, minimal containers, or non-GNOME Wayland without Xft). + """ + _invalidate_dpi_cache_if_stale() + if _dpi is not None: + return _dpi + for probe in ( + _try_linux_x11_xft_dpi, + _try_linux_gnome_text_scaling_dpi, + _try_linux_env_scale_dpi, + ): + found = probe() + if found is not None: + return _store_dpi(found) + return _store_dpi(DEFAULT_SCREEN_DPI) + + +def _get_dpi_unix() -> int: + """Return display DPI on Unix. + + On **macOS**, uses CoreGraphics physical screen size + pixel dimensions + (see :func:`_get_dpi_macos`). On **Linux**, uses :func:`_get_dpi_linux`. + Other Unix systems use :data:`DEFAULT_SCREEN_DPI`. + + Cached for :data:`DPI_CACHE_TTL_SEC` (then refreshed). + """ + _invalidate_dpi_cache_if_stale() + if _dpi is not None: + return _dpi + if sys.platform == 'darwin': + return _get_dpi_macos() + if sys.platform.startswith('linux'): + return _get_dpi_linux() + return _store_dpi(DEFAULT_SCREEN_DPI) + + +# --------------------------------------------------------------------------- +# Platform pixel-dimension helpers +# --------------------------------------------------------------------------- + +def _windows_console_font_cell() -> Optional[Tuple[int, int]]: + """Return console font cell (dwFontSize.X, dwFontSize.Y), or None if unavailable.""" + if sys.platform != 'win32': + return None + try: + STD_OUTPUT_HANDLE = -11 + handle = ctypes.windll.kernel32.GetStdHandle(STD_OUTPUT_HANDLE) + if not handle or handle == ctypes.wintypes.HANDLE(-1).value: + return None + + class COORD(ctypes.Structure): + _fields_ = [('X', ctypes.c_short), ('Y', ctypes.c_short)] + + class CONSOLE_FONT_INFOEX(ctypes.Structure): + _fields_ = [ + ('cbSize', ctypes.c_ulong), + ('nFont', ctypes.c_ulong), + ('dwFontSize', COORD), + ('FontFamily', ctypes.c_uint), + ('FontWeight', ctypes.c_uint), + ('FaceName', ctypes.c_wchar * 32), + ] + + font_info = CONSOLE_FONT_INFOEX() + font_info.cbSize = ctypes.sizeof(CONSOLE_FONT_INFOEX) + + if ctypes.windll.kernel32.GetCurrentConsoleFontEx(handle, False, ctypes.byref(font_info)): + fw = int(font_info.dwFontSize.X) + fh = int(font_info.dwFontSize.Y) + if fw > 0 and fh > 0: + return fw, fh + except Exception as e: + logging.debug(f"GetCurrentConsoleFontEx failed: {e}") + return None + + +def _get_pixels_windows(columns: int, rows: int): + """Return (pixel_width, pixel_height) on Windows via GetCurrentConsoleFontEx. + + Retrieves the console font glyph size in pixels (dwFontSize.X / .Y) and + multiplies by columns/rows to get the total terminal window pixel size. + Returns (0, 0) on any failure so the caller can fall back gracefully. + """ + cell = _windows_console_font_cell() + if not cell: + return 0, 0 + fw, fh = cell + return columns * fw, rows * fh + + +def _get_pixels_unix(columns: int, rows: int): + """Return (pixel_width, pixel_height) on Unix/macOS via TIOCGWINSZ. + + The kernel struct winsize includes ws_xpixel and ws_ypixel holding the + total terminal pixel dimensions. If those fields are zero on the first + attempt, the failure is cached permanently (_tiocgwinsz_works = False) + and subsequent calls return (0, 0) without retrying the ioctl. + """ + global _tiocgwinsz_works + if _tiocgwinsz_works is False: + return 0, 0 + if fcntl is None or termios is None: + return 0, 0 + try: + buf = struct.pack('HHHH', 0, 0, 0, 0) + result = fcntl.ioctl(sys.stdout.fileno(), termios.TIOCGWINSZ, buf) + # struct winsize layout: ws_row, ws_col, ws_xpixel, ws_ypixel + _ws_row, _ws_col, ws_xpixel, ws_ypixel = struct.unpack('HHHH', result) + if ws_xpixel > 0 and ws_ypixel > 0: + _tiocgwinsz_works = True + return ws_xpixel, ws_ypixel + # Pixel fields are zero - terminal emulator does not populate them. + _tiocgwinsz_works = False + return 0, 0 + except Exception as e: + logging.debug(f"TIOCGWINSZ failed: {e}") + _tiocgwinsz_works = False + return 0, 0 + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def get_terminal_size_pixels( + columns: Optional[int] = None, + rows: Optional[int] = None, + pixel_mode: Optional[str] = None, +) -> Dict[str, int]: + """Return terminal size in pixels and DPI for a Guacamole 'size' instruction. + + Always re-queries the terminal size internally via shutil.get_terminal_size + for maximum accuracy. The optional *columns* and *rows* arguments serve as + a fallback used only when the internal query fails. + + Parameters + ---------- + pixel_mode: + ``'kcm'`` — kcm-cli/tty.js formula, DPI 192, char 19×38 + margin. + ``'guacd'`` — guacd-native formula, DPI 96, char 10×20, scrollbar only. + ``None`` — resolved from ``KEEPER_GUAC_PIXEL_MODE`` env var, then + :data:`DEFAULT_PIXEL_MODE`. + + Returns + ------- + dict with keys: columns, rows, pixel_width, pixel_height, dpi + (same structure as _build_screen_info — drop-in compatible) + """ + mode = _resolve_pixel_mode(pixel_mode) + + # Resolve caller-supplied hints as fallback values + fallback_cols = columns if (isinstance(columns, int) and columns > 0) else DEFAULT_TERMINAL_COLUMNS + fallback_rows = rows if (isinstance(rows, int) and rows > 0) else DEFAULT_TERMINAL_ROWS + + # Always re-query for maximum accuracy; use hints only if query fails + try: + ts = shutil.get_terminal_size(fallback=(fallback_cols, fallback_rows)) + actual_cols = ts.columns + actual_rows = ts.lines + except Exception: + actual_cols = fallback_cols + actual_rows = fallback_rows + + pixel_w, pixel_h = approximate_pixels(actual_cols, actual_rows, mode) + return { + "columns": actual_cols, + "rows": actual_rows, + "pixel_width": pixel_w, + "pixel_height": pixel_h, + "dpi": _dpi_for_mode(mode), + } diff --git a/keepercli-package/src/keepercli/commands/pam/pam_rotation.py b/keepercli-package/src/keepercli/commands/pam/pam_rotation.py index ab83c7cf..48579292 100644 --- a/keepercli-package/src/keepercli/commands/pam/pam_rotation.py +++ b/keepercli-package/src/keepercli/commands/pam/pam_rotation.py @@ -40,6 +40,8 @@ 'pamDatabase', 'pamDirectory', 'pamMachine', 'pamUser', 'pamRemoteBrowser', ) +_PAM_SCRIPT_RECORD_TYPES = ('pamUser', 'pamDirectory') + def _resolve_nsf_record_uid(vault: vault_online.VaultOnline, identifier: str) -> Optional[str]: """Resolve an NSF record UID from a UID or exact title.""" @@ -94,6 +96,130 @@ def _load_pam_typed_record( return None +def _is_nsf_pam_record(vault: vault_online.VaultOnline, record_uid: str) -> bool: + return bool(record_uid and vault.nsf_data and vault.nsf_data.get_record(record_uid)) + + +def _save_pam_typed_record( + vault: vault_online.VaultOnline, record: vault_record.TypedRecord) -> None: + """Persist a PAM typed record via NSF or classic update (including file/script links).""" + _attach_record_key(vault, record) + if _is_nsf_pam_record(vault, record.record_uid): + nsf_management.update_nsf_typed_record(vault, record) + else: + record_management.update_record(vault, record) + vault.sync_requested = True + + +def _iter_nsf_pam_script_records( + vault: vault_online.VaultOnline, pattern: Optional[str] = None): + """Yield NSF pamUser/pamDirectory records optionally filtered by UID/title pattern.""" + if not vault.nsf_data: + return + pattern_cf = pattern.casefold() if pattern else None + for entry in vault.nsf_data.records(): + typed = _load_nsf_typed_record(vault, entry.record_uid) + if not typed or typed.record_type not in _PAM_SCRIPT_RECORD_TYPES: + continue + if pattern_cf: + title = (typed.title or '').casefold() + if typed.record_uid != pattern and pattern_cf not in title and not fnmatch.fnmatch(title, pattern_cf): + continue + yield typed + + +def _find_pam_script_records( + vault: vault_online.VaultOnline, pattern: Optional[str] = None): + """Find pamUser/pamDirectory TypedRecords in classic vault and NSF.""" + found = [] + seen = set() + for rec in vault.vault_data.find_records( + criteria=pattern, record_version=3, record_type=_PAM_SCRIPT_RECORD_TYPES): + loaded = vault.vault_data.load_record(rec.record_uid) + if not isinstance(loaded, vault_record.TypedRecord): + continue + found.append(loaded) + seen.add(loaded.record_uid) + + if pattern: + nsf_uid = _resolve_nsf_record_uid(vault, pattern) + if nsf_uid and nsf_uid not in seen: + typed = _load_nsf_typed_record(vault, nsf_uid) + if typed and typed.record_type in _PAM_SCRIPT_RECORD_TYPES: + found.append(typed) + seen.add(typed.record_uid) + else: + for typed in _iter_nsf_pam_script_records(vault, pattern): + if typed.record_uid not in seen: + found.append(typed) + seen.add(typed.record_uid) + else: + for typed in _iter_nsf_pam_script_records(vault): + if typed.record_uid not in seen: + found.append(typed) + seen.add(typed.record_uid) + return found + + +def _get_unique_pam_script_record( + vault: vault_online.VaultOnline, record_name: str) -> vault_record.TypedRecord: + """Resolve a single pamUser/pamDirectory for script commands (classic or NSF).""" + records = _find_pam_script_records(vault, record_name) + if len(records) == 0: + raise base.CommandError(f'Record "{record_name}" not found') + if len(records) > 1: + raise base.CommandError(f'Record "{record_name}" is not unique. Use record UID.') + return records[0] + + +def _load_script_file_record(vault: vault_online.VaultOnline, file_ref: str): + """Load a script file attachment record from classic vault or NSF.""" + if not file_ref: + return None + file_record = vault.vault_data.load_record(file_ref) + if file_record: + return file_record + return _load_nsf_typed_record(vault, file_ref) + + +def _find_script_value(vault, script_field, script_name): + """Find a script value by fileRef UID or file title/name.""" + if not script_field or not script_name: + return None + script_value = next( + (x for x in script_field.value if isinstance(x, dict) and x.get('fileRef') == script_name), + None) + if script_value is not None: + return script_value + s_name = script_name.casefold() + for x in script_field.value: + if not isinstance(x, dict): + continue + file_uid = x.get('fileRef') + file_record = _load_script_file_record(vault, file_uid) + if not file_record: + continue + if getattr(file_record, 'record_uid', None) == script_name: + return x + title = (getattr(file_record, 'title', None) or '').casefold() + name = (getattr(file_record, 'name', None) or '').casefold() + if title == s_name or name == s_name: + return x + return None + + +def _resolve_script_credential_uid(vault: vault_online.VaultOnline, ref: str) -> Optional[str]: + """Resolve a credential UID for script recordRef (classic or NSF).""" + if not ref: + return None + loaded = _load_pam_typed_record(vault, ref) + if loaded: + return loaded.record_uid + if vault.vault_data.get_record_key(ref) or vault.vault_data.load_record(ref): + return ref + return None + + def _iter_nsf_pam_configurations(vault: vault_online.VaultOnline): """Yield NSF PAM configuration TypedRecords.""" if not vault.nsf_data: @@ -1275,25 +1401,22 @@ def execute(self, context: KeeperParams, **kwargs): table = [] header = ['record_uid', 'title', 'record_type', 'script_uid', 'script_name', 'records', 'command'] - for rec in vault.vault_data.find_records(criteria=pattern, record_version=3, - record_type=('pamUser', 'pamDirectory')): - record = vault.vault_data.load_record(rec.record_uid) - if not isinstance(record, vault_record.TypedRecord): - continue + for record in _find_pam_script_records(vault, pattern): for field in (x for x in record.fields if x.type == 'script'): - value = field.get_default_value(dict) - if not value: - continue - file_ref = value.get('fileRef') - if not file_ref: - continue - file_record = vault.vault_data.load_record(file_ref) - if not file_record: - continue - records = value.get('recordRef') - command = value.get('command') - table.append([record.record_uid, record.title, record.record_type, file_record.record_uid, - file_record.title, records, command]) + for value in (field.value or []): + if not isinstance(value, dict): + continue + file_ref = value.get('fileRef') + if not file_ref: + continue + file_record = _load_script_file_record(vault, file_ref) + if not file_record: + continue + records = value.get('recordRef') + command = value.get('command') + table.append([record.record_uid, record.title, record.record_type, + getattr(file_record, 'record_uid', file_ref), + getattr(file_record, 'title', file_ref), records, command]) fmt = kwargs.get('format') if fmt != 'json': header = [report_utils.field_to_title(x) for x in header] @@ -1323,12 +1446,7 @@ def execute(self, context: KeeperParams, **kwargs): record_name = kwargs.get('record') if not record_name: raise base.CommandError('"record" argument is required') - records = list(vault.vault_data.find_records(criteria=record_name, record_version=3, record_type=('pamUser', 'pamDirectory'))) - if len(records) == 0: - raise base.CommandError(f'Record "{record_name}" not found') - if len(records) > 1: - raise base.CommandError(f'Record "{record_name}" is not unique. Use record UID.') - record = vault.vault_data.load_record(records[0].record_uid) + record = _get_unique_pam_script_record(vault, record_name) if not isinstance(record, vault_record.TypedRecord): raise base.CommandError(f'Record "{record.title}" is not a rotation record.') @@ -1361,14 +1479,14 @@ def execute(self, context: KeeperParams, **kwargs): record_refs = kwargs.get('add_credential') if isinstance(record_refs, list): for ref in record_refs: - if ref in vault.vault_data._records: - script_value['recordRef'].append(ref) + resolved = _resolve_script_credential_uid(vault, ref) + if resolved: + script_value['recordRef'].append(resolved) cmd = kwargs.get('script_command') if cmd: script_value['command'] = cmd - record_management.update_record(vault, record) - vault.sync_data = True + _save_pam_typed_record(vault, record) class PAMScriptEditCommand(base.ArgparseCommand): @@ -1401,31 +1519,14 @@ def execute(self, context: KeeperParams, **kwargs): if not script_name: raise base.CommandError('"script" argument is required') - records = list(vault.vault_data.find_records(criteria=record_name, record_version=3, record_type=('pamUser', 'pamDirectory'))) - if len(records) == 0: - raise base.CommandError(f'Record "{record_name}" not found') - if len(records) > 1: - raise base.CommandError(f'Record "{record_name}" is not unique. Use record UID.') - record = vault.vault_data.load_record(records[0].record_uid) + record = _get_unique_pam_script_record(vault, record_name) if not isinstance(record, vault_record.TypedRecord): raise base.CommandError(f'Record "{record.title}" is not a rotation record.') script_field = next((x for x in record.fields if x.type == 'script'), None) if script_field is None: raise base.CommandError(f'Record "{record.title}" has no rotation scripts.') - script_value = next((x for x in script_field.value if x.get('fileRef') == script_name), None) - if script_value is None: - s_name = script_name.casefold() - for x in script_field.value: - file_uid = x.get('fileRef') - file_record = vault.vault_data.load_record(file_uid) - if isinstance(file_record, vault_record.FileRecord): - if file_record.record_uid == s_name: - script_value = x - break - elif file_record.title.casefold() == s_name: - script_value = x - break + script_value = _find_script_value(vault, script_field, script_name) if not isinstance(script_value, dict): raise base.CommandError(f'Record "{record.title}" does not have script "{script_name}"') @@ -1437,11 +1538,17 @@ def execute(self, context: KeeperParams, **kwargs): refs.update(record_refs) remove_credential = kwargs.get('remove_credential') if isinstance(remove_credential, list) and remove_credential: - refs.difference_update(remove_credential) + for ref in remove_credential: + resolved = _resolve_script_credential_uid(vault, ref) or ref + refs.discard(resolved) + refs.discard(ref) modified = True add_credential = kwargs.get('add_credential') if isinstance(add_credential, list) and add_credential: - refs.update(add_credential) + for ref in add_credential: + resolved = _resolve_script_credential_uid(vault, ref) + if resolved: + refs.add(resolved) modified = True if modified: script_value['recordRef'] = list(refs) @@ -1453,8 +1560,7 @@ def execute(self, context: KeeperParams, **kwargs): if not modified: raise base.CommandError('Nothing to do') - record_management.update_record(vault, record) - vault.sync_data = True + _save_pam_typed_record(vault, record) class PAMScriptDeleteCommand(base.ArgparseCommand): @@ -1480,35 +1586,17 @@ def execute(self, context: KeeperParams, **kwargs): if not script_name: raise base.CommandError('"script" argument is required') - records = list(vault.vault_data.find_records(criteria=record_name, record_version=3, record_type=('pamUser', 'pamDirectory'))) - if len(records) == 0: - raise base.CommandError(f'Record "{record_name}" not found') - if len(records) > 1: - raise base.CommandError(f'Record "{record_name}" is not unique. Use record UID.') - record = vault.vault_data.load_record(records[0].record_uid) + record = _get_unique_pam_script_record(vault, record_name) if not isinstance(record, vault_record.TypedRecord): raise base.CommandError(f'Record "{record.title}" is not a rotation record.') script_field = next((x for x in record.fields if x.type == 'script'), None) if script_field is None: raise base.CommandError(f'Record "{record.title}" has no rotation scripts.') - script_value = next((x for x in script_field.value if x.get('fileRef') == script_name), None) - if script_value is None: - s_name = script_name.casefold() - for x in script_field.value: - file_uid = x.get('fileRef') - file_record = vault.vault_data.load_record(file_uid) - if isinstance(file_record, vault_record.FileRecord): - if file_record.record_uid == s_name: - script_value = x - break - elif file_record.title.casefold() == s_name: - script_value = x - break + script_value = _find_script_value(vault, script_field, script_name) if not isinstance(script_value, dict): raise base.CommandError(f'Record "{record.title}" does not have script "{script_name}"') script_field.value.remove(script_value) - record_management.update_record(vault, record) - vault.sync_data = True + _save_pam_typed_record(vault, record) diff --git a/keepercli-package/src/keepercli/commands/workflow/__init__.py b/keepercli-package/src/keepercli/commands/workflow/__init__.py new file mode 100644 index 00000000..d7930b1a --- /dev/null +++ b/keepercli-package/src/keepercli/commands/workflow/__init__.py @@ -0,0 +1,26 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' 0. ' + 'Duplicates are removed automatically.') + parser.add_argument('--format', dest='format', action='store', + choices=['table', 'json'], default='table', help='Output format') + + def get_parser(self): + return WorkflowCreateCommand.parser + + def execute(self, params: KeeperParams, **kwargs): + record_uid, record = RecordResolver.resolve(params, kwargs.get('record')) + RecordResolver.validate_workflow_record_type(record) + record_uid_bytes = utils.base64_url_decode(record_uid) + + # Pre-check for nicer UX; server is still authoritative on conflicts. + try: + existing = _post_request_to_router( + params, 'read_workflow_config', + rq_proto=ProtobufRefBuilder.record_ref(record_uid_bytes, record.title), + rs_type=workflow_pb2.WorkflowConfig, + ) + except Exception as e: + logging.debug('Pre-check read_workflow_config failed: %s', e) + existing = None + if existing: + raise CommandError( + '', + f'Workflow already configured for "{record.title}" ({record_uid}).\n' + f' Modify it: pam workflow update {record_uid} ...\n' + f' Inspect it: pam workflow read {record_uid}\n' + f' Remove and recreate: pam workflow delete {record_uid} && ' + f'pam workflow create {record_uid} ...' + ) + + approvals = kwargs.get('approvals_needed', 1) + if approvals < 0: + raise CommandError('', 'Approvals needed must be 0 or greater') + + approvers = list(dict.fromkeys( + a.strip() for a in (kwargs.get('approver') or []) if a and a.strip() + )) + + if approvals > 0 and not approvers: + raise CommandError( + '', + 'At least one --approver is required when --approvals-needed > 0. ' + 'Pass --approver for each approver, or use --approvals-needed 0 ' + 'for a workflow that does not need approval.' + ) + if approvers and approvals == 0: + logging.warning( + "--approver(s) supplied but --approvals-needed is 0 — approvers will " + "be recorded but no approval will ever be required." + ) + + parameters = workflow_pb2.WorkflowParameters() + parameters.resource.CopyFrom(ProtobufRefBuilder.record_ref(record_uid_bytes, record.title)) + parameters.approvalsNeeded = approvals + parameters.checkoutNeeded = kwargs.get('checkout', False) + parameters.startAccessOnApproval = kwargs.get('start_on_approval', False) + parameters.requireReason = kwargs.get('require_reason', False) + parameters.requireTicket = kwargs.get('require_ticket', False) + parameters.requireMFA = kwargs.get('require_mfa', False) + parameters.accessLength = WorkflowFormatter.parse_duration(kwargs.get('duration', '1d')) + + temporal_filter = WorkflowFormatter.build_temporal_filter( + kwargs.get('allowed_days'), kwargs.get('time_range'), + ) + if temporal_filter: + parameters.allowedTimes.CopyFrom(temporal_filter) + + try: + _post_request_to_router(params, 'create_workflow_config', rq_proto=parameters) + + approvers_added = [] + if approvers: + try: + _add_approvers_to_workflow( + params, record_uid, record.title, users=approvers, + ) + approvers_added = list(approvers) + except Exception as e: + logging.debug('Failed to add approvers: %s', e, exc_info=True) + print(f"\n{bcolors.WARNING}Workflow created, but failed to add " + f"approvers: {sanitize_router_error(e)}{bcolors.ENDC}") + print(f"Run: pam workflow add-approver {record_uid} " + f"{' '.join(f'--user {u}' for u in approvers)}") + + if kwargs.get('format') == 'json': + result = { + 'status': 'success', + 'record_uid': record_uid, + 'record_name': record.title, + 'workflow_config': { + 'approvals_needed': parameters.approvalsNeeded, + 'checkout_needed': parameters.checkoutNeeded, + 'require_reason': parameters.requireReason, + 'require_ticket': parameters.requireTicket, + 'require_mfa': parameters.requireMFA, + 'access_duration': WorkflowFormatter.format_duration(parameters.accessLength), + }, + 'approvers': approvers_added, + } + print(json.dumps(result, indent=2)) + else: + print(f"\n{bcolors.OKGREEN}Workflow created successfully{bcolors.ENDC}\n") + print(f"Record: {record.title} ({record_uid})") + print(f"Approvals needed: {parameters.approvalsNeeded}") + print(f"Check-in/out: {'Yes' if parameters.checkoutNeeded else 'No'}") + print(f"Duration: {WorkflowFormatter.format_duration(parameters.accessLength)}") + if parameters.requireReason: + print("Requires reason: Yes") + if parameters.requireTicket: + print("Requires ticket: Yes") + if parameters.requireMFA: + print("Requires MFA: Yes") + if approvers_added: + print(f"Approvers: {', '.join(approvers_added)}") + elif parameters.approvalsNeeded == 0: + pass # no approvers needed, no nag + else: + print(f"\n{bcolors.WARNING}Note: Add approvers with: " + f"pam workflow add-approver {record_uid} --user {bcolors.ENDC}") + print() + + except Exception as e: + raise CommandError('', f'Failed to create workflow: {sanitize_router_error(e)}') + + +class WorkflowReadCommand(Command): + parser = argparse.ArgumentParser( + prog='pam workflow read', + description='Read and display workflow configuration', + ) + parser.add_argument('record', help='Record UID or name') + parser.add_argument('--format', dest='format', action='store', + choices=['table', 'json'], default='table', help='Output format') + + def get_parser(self): + return WorkflowReadCommand.parser + + def execute(self, params: KeeperParams, **kwargs): + record_uid, record = RecordResolver.resolve(params, kwargs.get('record')) + record_uid_bytes = utils.base64_url_decode(record_uid) + ref = ProtobufRefBuilder.record_ref(record_uid_bytes, record.title) + + try: + response = _post_request_to_router( + params, 'read_workflow_config', + rq_proto=ref, rs_type=workflow_pb2.WorkflowConfig, + ) + + if not response: + if kwargs.get('format') == 'json': + print(json.dumps({'status': 'no_workflow', 'message': 'No workflow configured'}, indent=2)) + else: + print(f"\n{bcolors.WARNING}No workflow configured for this record{bcolors.ENDC}\n") + print(f"Record: {record.title} ({record_uid})") + print(f"\nTo create a workflow, run:") + print(f" pam workflow create {record_uid}") + print() + return + + if kwargs.get('format') == 'json': + self._print_json(params, response, record_uid) + else: + self._print_table(params, response, record_uid) + + except Exception as e: + raise CommandError('', f'Failed to read workflow: {sanitize_router_error(e)}') + + @staticmethod + def _print_json(params, response, record_uid): + result = { + 'record_uid': record_uid, + 'record_name': RecordResolver.resolve_name(params, response.parameters.resource), + 'parameters': { + 'approvals_needed': response.parameters.approvalsNeeded, + 'checkout_needed': response.parameters.checkoutNeeded, + 'start_access_on_approval': response.parameters.startAccessOnApproval, + 'require_reason': response.parameters.requireReason, + 'require_ticket': response.parameters.requireTicket, + 'require_mfa': response.parameters.requireMFA, + 'access_duration': WorkflowFormatter.format_duration(response.parameters.accessLength), + 'allowed_times': WorkflowFormatter.format_temporal_filter(response.parameters.allowedTimes), + }, + 'approvers': [], + } + + for approver in response.approvers: + approver_info = {'escalation': approver.escalation} + if approver.escalationAfterMs: + approver_info['escalation_after'] = WorkflowFormatter.format_duration(approver.escalationAfterMs) + if approver.HasField('user'): + approver_info['type'] = 'user' + approver_info['email'] = approver.user + elif approver.HasField('userId'): + approver_info['type'] = 'user_id' + approver_info['user_id'] = approver.userId + elif approver.HasField('teamUid'): + approver_info['type'] = 'team' + approver_info['team_uid'] = utils.base64_url_encode(approver.teamUid) + result['approvers'].append(approver_info) + + print(json.dumps(result, indent=2)) + + @staticmethod + def _print_table(params, response, record_uid): + print(f"\n{bcolors.OKBLUE}Workflow Configuration{bcolors.ENDC}\n") + print(f"Record: {RecordResolver.resolve_name(params, response.parameters.resource)}") + print(f"Record UID: {record_uid}") + + if response.createdOn: + created_date = datetime.fromtimestamp(response.createdOn / 1000) + print(f"Created: {created_date.strftime('%Y-%m-%d %H:%M:%S')}") + + p = response.parameters + print(f"\n{bcolors.BOLD}Access Parameters:{bcolors.ENDC}") + print(f" Approvals needed: {p.approvalsNeeded}") + print(f" Check-in/out required: {'Yes' if p.checkoutNeeded else 'No'}") + print(f" Access duration: {WorkflowFormatter.format_duration(p.accessLength)}") + print(f" Timer starts: {'On approval' if p.startAccessOnApproval else 'On check-out'}") + + print(f"\n{bcolors.BOLD}Requirements:{bcolors.ENDC}") + print(f" Reason required: {'Yes' if p.requireReason else 'No'}") + print(f" Ticket required: {'Yes' if p.requireTicket else 'No'}") + print(f" MFA required: {'Yes' if p.requireMFA else 'No'}") + + if p.HasField('allowedTimes') and p.allowedTimes: + at = p.allowedTimes + print(f"\n{bcolors.BOLD}Allowed Times:{bcolors.ENDC}") + if at.allowedDays: + day_names = [WorkflowFormatter.DAY_NAME_MAP.get(d, str(d)) for d in at.allowedDays] + print(f" Days: {', '.join(day_names)}") + if at.timeRanges: + for tr in at.timeRanges: + start_h, start_m = divmod(tr.startTime, 100) + end_h, end_m = divmod(tr.endTime, 100) + print(f" Time: {start_h:02d}:{start_m:02d} - {end_h:02d}:{end_m:02d}") + if at.timeZone: + print(f" Timezone: {at.timeZone}") + + if response.approvers: + print(f"\n{bcolors.BOLD}Approvers ({len(response.approvers)}):{bcolors.ENDC}") + for idx, approver in enumerate(response.approvers, 1): + esc_label = '' + if approver.escalation: + esc_label = ' (Escalation' + if approver.escalationAfterMs: + esc_label += f' after {WorkflowFormatter.format_duration(approver.escalationAfterMs)}' + esc_label += ')' + if approver.HasField('user'): + print(f" {idx}. User: {approver.user}{esc_label}") + elif approver.HasField('userId'): + print(f" {idx}. User: {RecordResolver.resolve_user(params, approver.userId)}{esc_label}") + elif approver.HasField('teamUid'): + team_uid = utils.base64_url_encode(approver.teamUid) + team_name = RecordResolver.resolve_team_name(params, team_uid) + team_display = f"{team_name} ({team_uid})" if team_name else team_uid + print(f" {idx}. Team: {team_display}{esc_label}") + else: + print(f"\n{bcolors.WARNING}No approvers configured{bcolors.ENDC}") + print(f"Add approvers with: pam workflow add-approver {record_uid} --user ") + + print() + + +class WorkflowUpdateCommand(Command): + parser = argparse.ArgumentParser( + prog='pam workflow update', + description='Update existing workflow configuration. ' + 'Only specified fields are changed; unspecified fields retain their current values.', + ) + parser.add_argument('record', help='Record UID or name with workflow to update') + parser.add_argument('-n', '--approvals-needed', type=int, help='Number of approvals required') + parser.add_argument('-co', '--checkout', type=lambda x: x.lower() == 'true', + help='Enable/disable check-in/check-out (true/false)') + parser.add_argument('-sa', '--start-on-approval', type=lambda x: x.lower() == 'true', + help='Start timer on approval vs check-out (true/false)') + parser.add_argument('-rr', '--require-reason', type=lambda x: x.lower() == 'true', + help='Require reason (true/false)') + parser.add_argument('-rt', '--require-ticket', type=lambda x: x.lower() == 'true', + help='Require ticket (true/false)') + parser.add_argument('-rm', '--require-mfa', type=lambda x: x.lower() == 'true', + help='Require MFA (true/false)') + parser.add_argument('-d', '--duration', type=str, help='Access duration (e.g., "2h", "30m", "1d")') + parser.add_argument('--allowed-days', type=str, + help='Comma-separated allowed days (e.g., "mon,tue,wed,thu,fri")') + parser.add_argument('--time-range', type=str, + help='Allowed time range in HH:MM-HH:MM format (e.g., "09:00-17:00")') + parser.add_argument('--format', dest='format', action='store', + choices=['table', 'json'], default='table', help='Output format') + + def get_parser(self): + return WorkflowUpdateCommand.parser + + def execute(self, params: KeeperParams, **kwargs): + record_uid, record = RecordResolver.resolve(params, kwargs.get('record')) + record_uid_bytes = utils.base64_url_decode(record_uid) + + try: + ref = ProtobufRefBuilder.record_ref(record_uid_bytes, record.title) + current_config = _post_request_to_router( + params, 'read_workflow_config', + rq_proto=ref, rs_type=workflow_pb2.WorkflowConfig, + ) + + if not current_config: + raise CommandError('', 'No workflow found for record. Create one first with "pam workflow create"') + + parameters = workflow_pb2.WorkflowParameters() + parameters.CopyFrom(current_config.parameters) + + updatable_fields = { + 'approvals_needed': 'approvalsNeeded', + 'checkout': 'checkoutNeeded', + 'start_on_approval': 'startAccessOnApproval', + 'require_reason': 'requireReason', + 'require_ticket': 'requireTicket', + 'require_mfa': 'requireMFA', + } + + if kwargs.get('approvals_needed') is not None and kwargs['approvals_needed'] < 0: + raise CommandError('', 'Approvals needed must be 0 or greater') + + updates_provided = False + for kwarg_key, proto_field in updatable_fields.items(): + if kwargs.get(kwarg_key) is not None: + setattr(parameters, proto_field, kwargs[kwarg_key]) + updates_provided = True + + if kwargs.get('duration') is not None: + parameters.accessLength = WorkflowFormatter.parse_duration(kwargs['duration']) + updates_provided = True + + temporal_filter = WorkflowFormatter.build_temporal_filter( + kwargs.get('allowed_days'), kwargs.get('time_range'), + ) + if temporal_filter: + parameters.allowedTimes.CopyFrom(temporal_filter) + updates_provided = True + + if not updates_provided: + raise CommandError( + '', 'No updates provided. Specify at least one option to update ' + '(e.g., --approvals-needed, --duration)', + ) + + _post_request_to_router(params, 'update_workflow_config', rq_proto=parameters) + + if kwargs.get('format') == 'json': + result = {'status': 'success', 'record_uid': record_uid, 'record_name': record.title} + print(json.dumps(result, indent=2)) + else: + print(f"\n{bcolors.OKGREEN}Workflow updated successfully{bcolors.ENDC}\n") + print(f"Record: {record.title} ({record_uid})") + print() + + except (CommandError, KeyboardInterrupt, SystemExit): + raise + except Exception as e: + raise CommandError('', f'Failed to update workflow: {sanitize_router_error(e)}') + + +class WorkflowDeleteCommand(Command): + parser = argparse.ArgumentParser( + prog='pam workflow delete', + description='Delete workflow configuration from a record', + ) + parser.add_argument('record', help='Record UID or name to remove workflow from') + parser.add_argument('--format', dest='format', action='store', + choices=['table', 'json'], default='table', help='Output format') + + def get_parser(self): + return WorkflowDeleteCommand.parser + + def execute(self, params: KeeperParams, **kwargs): + record_uid, record = RecordResolver.resolve(params, kwargs.get('record')) + record_uid_bytes = utils.base64_url_decode(record_uid) + ref = ProtobufRefBuilder.record_ref(record_uid_bytes, record.title) + + # Server-side delete is idempotent; verify config exists for accurate user feedback. + try: + existing = _post_request_to_router( + params, 'read_workflow_config', + rq_proto=ref, rs_type=workflow_pb2.WorkflowConfig, + ) + except Exception as e: + logging.debug('Pre-check read_workflow_config failed: %s', e) + existing = None + if not existing: + raise CommandError( + '', + f'No workflow configured for "{record.title}" ({record_uid}). Nothing to delete.' + ) + + try: + _post_request_to_router(params, 'delete_workflow_config', rq_proto=ref) + + if kwargs.get('format') == 'json': + result = {'status': 'success', 'record_uid': record_uid, 'record_name': record.title} + print(json.dumps(result, indent=2)) + else: + print(f"\n{bcolors.OKGREEN}Workflow deleted successfully{bcolors.ENDC}\n") + print(f"Record: {record.title} ({record_uid})") + print() + + except Exception as e: + raise CommandError('', f'Failed to delete workflow: {sanitize_router_error(e)}') + + +class WorkflowAddApproversCommand(Command): + parser = argparse.ArgumentParser( + prog='pam workflow add-approver', + description='Add approvers to a workflow', + ) + parser.add_argument('record', help='Record UID or name') + parser.add_argument('-u', '--user', action='append', + help='User email to add as approver (can specify multiple times)') + parser.add_argument('-t', '--team', action='append', + help='Team name or UID to add as approver (can specify multiple times)') + parser.add_argument('-e', '--escalation', action='store_true', help='Mark as escalation approver') + parser.add_argument('-ea', '--escalation-after', type=str, + help='Time before escalating to this approver (e.g., "30m", "1h", "2h"). ' + 'Only meaningful with --escalation') + parser.add_argument('--format', dest='format', action='store', + choices=['table', 'json'], default='table', help='Output format') + + def get_parser(self): + return WorkflowAddApproversCommand.parser + + def execute(self, params: KeeperParams, **kwargs): + users = list(dict.fromkeys( + u.strip() for u in (kwargs.get('user') or []) if u and u.strip() + )) + teams = list(dict.fromkeys( + t.strip() for t in (kwargs.get('team') or []) if t and t.strip() + )) + is_escalation = kwargs.get('escalation', False) + escalation_after = kwargs.get('escalation_after') + + if not users and not teams: + raise CommandError('', 'Must specify at least one --user or --team') + + if escalation_after and not is_escalation: + raise CommandError('', '--escalation-after requires --escalation flag') + + escalation_after_ms = 0 + if escalation_after: + escalation_after_ms = WorkflowFormatter.parse_duration(escalation_after) + + record_uid, record = RecordResolver.resolve(params, kwargs.get('record')) + + try: + _add_approvers_to_workflow( + params, record_uid, record.title, + users=users, teams=teams, + is_escalation=is_escalation, + escalation_after_ms=escalation_after_ms, + ) + + total = len(users) + len(teams) + if kwargs.get('format') == 'json': + result = { + 'status': 'success', + 'record_uid': record_uid, + 'record_name': record.title, + 'approvers_added': total, + 'escalation': is_escalation, + } + if escalation_after_ms: + result['escalation_after'] = WorkflowFormatter.format_duration(escalation_after_ms) + print(json.dumps(result, indent=2)) + else: + print(f"\n{bcolors.OKGREEN}Approvers added successfully{bcolors.ENDC}\n") + print(f"Record: {record.title} ({record_uid})") + print(f"Added {total} approver(s)") + if is_escalation: + esc_info = f" (after {WorkflowFormatter.format_duration(escalation_after_ms)})" if escalation_after_ms else '' + print(f"Type: Escalation approver{esc_info}") + print() + + except (CommandError, KeyboardInterrupt, SystemExit): + raise + except Exception as e: + raise CommandError('', f'Failed to add approvers: {sanitize_router_error(e)}') + + +class WorkflowDeleteApproversCommand(Command): + parser = argparse.ArgumentParser( + prog='pam workflow remove-approver', + description='Remove approvers from a workflow', + ) + parser.add_argument('record', help='Record UID or name') + parser.add_argument('-u', '--user', action='append', help='User email to remove as approver') + parser.add_argument('-t', '--team', action='append', help='Team name or UID to remove as approver') + parser.add_argument('--format', dest='format', action='store', + choices=['table', 'json'], default='table', help='Output format') + + def get_parser(self): + return WorkflowDeleteApproversCommand.parser + + def execute(self, params: KeeperParams, **kwargs): + users = kwargs.get('user') or [] + teams = kwargs.get('team') or [] + + if not users and not teams: + raise CommandError('', 'Must specify at least one --user or --team') + + record_uid, record = RecordResolver.resolve(params, kwargs.get('record')) + record_uid_bytes = utils.base64_url_decode(record_uid) + + config = workflow_pb2.WorkflowConfig() + config.parameters.resource.CopyFrom(ProtobufRefBuilder.record_ref(record_uid_bytes, record.title)) + + for user_email in users: + approver = workflow_pb2.WorkflowApprover() + approver.user = user_email + config.approvers.append(approver) + + for team_input in teams: + resolved_team_uid = RecordResolver.validate_team(params, team_input) + approver = workflow_pb2.WorkflowApprover() + approver.teamUid = utils.base64_url_decode(resolved_team_uid) + config.approvers.append(approver) + + try: + _post_request_to_router(params, 'delete_workflow_approvers', rq_proto=config) + + total = len(users) + len(teams) + if kwargs.get('format') == 'json': + result = { + 'status': 'success', + 'record_uid': record_uid, + 'record_name': record.title, + 'approvers_removed': total, + } + print(json.dumps(result, indent=2)) + else: + print(f"\n{bcolors.OKGREEN}Approvers removed successfully{bcolors.ENDC}\n") + print(f"Record: {record.title} ({record_uid})") + print(f"Removed {total} approver(s)") + print() + + except (CommandError, KeyboardInterrupt, SystemExit): + raise + except Exception as e: + raise CommandError('', f'Failed to remove approvers: {sanitize_router_error(e)}') diff --git a/keepercli-package/src/keepercli/commands/workflow/helpers.py b/keepercli-package/src/keepercli/commands/workflow/helpers.py new file mode 100644 index 00000000..237cc70a --- /dev/null +++ b/keepercli-package/src/keepercli/commands/workflow/helpers.py @@ -0,0 +1,615 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' str: + msg = str(error) + msg = _RESPONSE_CODE_RE.sub('', msg) + msg = _PROTO_DUMP_RE.sub('', msg) + msg = re.sub(r'\s+', ' ', msg).strip() + return msg or 'Unknown error' + + +def print_exempt_message(fmt='table'): + import json as _json + from ...compat.display import bcolors as _bc + if fmt == 'json': + print(_json.dumps({'status': 'exempt', 'message': 'Workflow not required'}, indent=2)) + else: + print(f"\n{_bc.WARNING}You are exempt from workflow restrictions on this record.{_bc.ENDC}") + print("As a record owner or approver, you can access this resource directly.\n") + + +def is_record_owner(params, record_uid): + if record_uid in getattr(params, 'record_owner_cache', {}): + owner_info = params.record_owner_cache[record_uid] + if getattr(owner_info, 'owner', False): + return True + return False + + +def is_on_approver_list(params, config): + """Check if current user is on the approver list (by email or team membership).""" + if not config or not config.approvers: + return False + + current_user = getattr(params, 'user', '') + team_cache = getattr(params, 'team_cache', {}) + + for approver in config.approvers: + if approver.user and approver.user.lower() == current_user.lower(): + return True + if approver.teamUid: + team_uid_b64 = utils.base64_url_encode(approver.teamUid) + if team_uid_b64 in team_cache: + return True + return False + + +def is_workflow_exempt(params, record_uid, config=None): + """Exempt = record owner OR on approver list. Pass `config` to skip a round-trip. + Transport failures fail closed (non-exempt).""" + if is_record_owner(params, record_uid): + return True + + if config is None: + from ...helpers.router_utils import _post_request_to_router + try: + ref = ProtobufRefBuilder.record_ref( + utils.base64_url_decode(record_uid), + '', + ) + config = _post_request_to_router( + params, 'read_workflow_config', + rq_proto=ref, rs_type=workflow_pb2.WorkflowConfig, + ) + except Exception as e: + import logging as _logging + _logging.debug( + 'is_workflow_exempt config read failed for %s: %s', record_uid, e, + ) + return False + + return is_on_approver_list(params, config) + + +def is_pam_action_allowed_by_enforcement(params: KeeperParams, enforcement_key: str) -> bool: + """Per-user enterprise enforcement gate. Mirrors web vault's PAM enforcement selectors. + Non-enterprise users and unexpected payload shapes fall back to allow (gateway gates). + Enterprise users with the key absent are denied (matches WV's `!!enforcements.`). + License is intentionally not checked here — gateway is the authoritative gate for that.""" + import logging as _logging + try: + enforcements = getattr(params, 'enforcements', None) + if not enforcements or not isinstance(enforcements, dict): + return True + booleans = enforcements.get('booleans') or [] + if not isinstance(booleans, list) or not booleans: + return True + for b in booleans: + if isinstance(b, dict) and b.get('key') == enforcement_key: + return bool(b.get('value')) + return False + except Exception as e: + _logging.debug('Enforcement check failed for %s: %s', enforcement_key, e) + return True + + +def is_pam_config_action_allowed_for_record(params: KeeperParams, record_uid: str, + action_key: str) -> bool: + """Best-effort PAM config allowedSettings (DAG) gate. + action_key: 'connections' (launch), 'tunneling' (port-forward), 'rotation'. + Returns True on any lookup failure; False only when explicitly disabled on the config.""" + import logging as _logging + try: + config_uid = None + try: + from ..pam.pam_launch import launch_cache + entry = launch_cache.get(record_uid) + if entry: + config_uid = entry.get('config_uid') + except Exception: + pass + + if not config_uid: + from ...compat import vault as _vault + from ...helpers.tunnel.tunnel_helpers import get_config_uid_from_record + try: + config_uid = get_config_uid_from_record(params, _vault, record_uid) + except CommandError: + return True + + if not config_uid: + return True + + from ...helpers import pam_config_utils as _pam_config_utils + allowed = pam_config_utils.pam_config_allowed_settings_json( + params, config_uid, + ) + return allowed.get(action_key) is not False + except Exception as e: + _logging.debug( + 'PAM config allowedSettings lookup failed for %s (action=%s): %s', + record_uid, action_key, e, + ) + return True + + +def is_gateway_online_for_record(params: KeeperParams, record_uid: str) -> Optional[bool]: + """Best-effort gateway online check. Returns None when undetermined (treat as 'proceed').""" + import logging as _logging + try: + from ..pam.pam_launch import launch_cache + entry = launch_cache.get(record_uid) + if not entry: + return None + gateway_uid_str = entry.get('gateway_uid') + if not gateway_uid_str: + return None + gateway_uid_bytes = utils.base64_url_decode(gateway_uid_str) + + from ...helpers.router_utils import router_get_connected_gateways + online = router_get_connected_gateways(params) + if not online or not online.controllers: + return False + return any(c.controllerUid == gateway_uid_bytes for c in online.controllers) + except Exception as e: + _logging.debug("Gateway online probe failed for %s: %s", record_uid, e) + return None + + +def start_workflow_for_record(params: KeeperParams, record_uid: str) -> None: + from ...helpers.router_utils import _post_request_to_router + record_uid_bytes = utils.base64_url_decode(record_uid) + record = vault.KeeperRecord.load(params, record_uid) + record_name = record.title if record else record_uid + + state = workflow_pb2.WorkflowState() + state.resource.CopyFrom(ProtobufRefBuilder.record_ref(record_uid_bytes, record_name)) + _post_request_to_router(params, 'start_workflow', rq_proto=state) + + +def submit_access_request(params: KeeperParams, record_uid: str, + reason: str = '', ticket: str = '') -> None: + """Send a workflow access request. Reason/ticket are encrypted with the record key.""" + from ...helpers.router_utils import _post_request_to_router + record_uid_bytes = utils.base64_url_decode(record_uid) + record = vault.KeeperRecord.load(params, record_uid) + record_name = record.title if record else record_uid + + record_key = None + if reason or ticket: + from ...nested_share_folder.common import get_record_key + record_key = get_record_key(params, record_uid, raise_on_missing=False) + if not record_key: + raise CommandError( + '', 'Record key not available — cannot encrypt reason/ticket. ' + 'You do not have sufficient access to this record to send encrypted parameters.', + ) + + access_request = workflow_pb2.WorkflowAccessRequest() + access_request.resource.CopyFrom(ProtobufRefBuilder.record_ref(record_uid_bytes, record_name)) + if reason: + reason_bytes = reason.encode('utf-8') if isinstance(reason, str) else reason + access_request.reason = crypto.encrypt_aes_v2(reason_bytes, record_key) + if ticket: + ticket_bytes = ticket.encode('utf-8') if isinstance(ticket, str) else ticket + access_request.ticket = crypto.encrypt_aes_v2(ticket_bytes, record_key) + + _post_request_to_router(params, 'request_workflow_access', rq_proto=access_request) + + +def prompt_for_reason_ticket(needs_reason: bool, needs_ticket: bool) -> Tuple[Optional[str], Optional[str]]: + """Prompt for reason/ticket. Returns (None, None) on cancel or empty required input.""" + from prompt_toolkit import prompt as pt_prompt + from ...compat.display import bcolors as _bc + + reason_value = None + ticket_value = None + try: + if needs_reason: + print(f"\n{_bc.OKBLUE}Workflow requires a justification.{_bc.ENDC}") + print('Type your reason. Press Esc then Enter to submit, or Ctrl+C to cancel.\n') + text = pt_prompt('Reason: ', multiline=True).strip() + if not text: + print(f"{_bc.WARNING}Reason is required — cancelled.{_bc.ENDC}") + return None, None + reason_value = text + if needs_ticket: + print(f"\n{_bc.OKBLUE}Workflow requires a ticket / reference number.{_bc.ENDC}") + text = pt_prompt('Ticket: ').strip() + if not text: + print(f"{_bc.WARNING}Ticket is required — cancelled.{_bc.ENDC}") + return None, None + ticket_value = text + except (KeyboardInterrupt, EOFError): + return None, None + return reason_value, ticket_value + + +class RecordResolver: + + WORKFLOW_RECORD_TYPES = {'pamCloudResource', 'pamMachine', 'pamDirectory', 'pamDatabase', 'pamRemoteBrowser'} + + @staticmethod + def resolve(params, record_input, allow_missing=False): + from ..pam.vault_target import resolve_pam_record + + if not record_input: + if allow_missing: + return None, None + raise CommandError('', 'Record is required') + + rec = resolve_pam_record(params, record_input) + if rec: + return rec.record_uid, rec + if allow_missing: + return None, None + raise CommandError('', f'Record "{record_input}" not found') + + @staticmethod + def validate_workflow_record_type(record): + if not isinstance(record, vault.TypedRecord): + raise CommandError('', 'Workflows are only supported on PAM records') + record_type = record.record_type or 'unknown' + if record_type not in RecordResolver.WORKFLOW_RECORD_TYPES: + supported = ', '.join(sorted(RecordResolver.WORKFLOW_RECORD_TYPES)) + raise CommandError( + '', + f'Record "{record.title}" is of type "{record_type}" which does not support workflows.\n' + f'Supported record types: {supported}' + ) + + @staticmethod + def get_uid_bytes(params: KeeperParams, record_uid: str) -> bytes: + from ..pam.vault_target import record_exists_in_vault + + uid_bytes = utils.base64_url_decode(record_uid) + if not record_exists_in_vault(params, record_uid): + raise CommandError('', f'Record {record_uid} not found') + return uid_bytes + + @staticmethod + def resolve_name(params, resource_ref) -> str: + if resource_ref.name: + return resource_ref.name + if resource_ref.value: + rec_uid = utils.base64_url_encode(resource_ref.value) + rec = vault.KeeperRecord.load(params, rec_uid) + if rec: + return rec.title + from ..pam.vault_target import get_vault_record_title_type + title, _ = get_vault_record_title_type(params, rec_uid) + return title if title and title != '[record inaccessible]' else '' + return '' + + @staticmethod + def format_label(params, resource_ref) -> str: + rec_uid = utils.base64_url_encode(resource_ref.value) if resource_ref.value else '' + rec_name = RecordResolver.resolve_name(params, resource_ref) + if rec_name and rec_name != rec_uid: + return f"{rec_name} ({rec_uid})" + return rec_uid or 'Unknown' + + @staticmethod + def resolve_user(params: KeeperParams, user_id: int) -> str: + if params.enterprise and 'users' in params.enterprise: + for u in params.enterprise['users']: + if u.get('enterprise_user_id') == user_id or u.get('user_id') == user_id: + return u.get('username', f'User ID {user_id}') + return f'User ID {user_id}' + + @staticmethod + def resolve_team_name(params: KeeperParams, team_uid: str) -> str: + team_data = params.team_cache.get(team_uid, {}) + name = team_data.get('name', '') + if name: + return name + if params.enterprise and 'teams' in params.enterprise: + for team in params.enterprise['teams']: + if team.get('team_uid', '') == team_uid: + return team.get('name', '') + return '' + + @staticmethod + def validate_team(params: KeeperParams, team_input: str) -> str: + if team_input in params.team_cache: + return team_input + for uid, team_data in params.team_cache.items(): + if team_data.get('name', '').casefold() == team_input.casefold(): + return uid + + if params.enterprise and 'teams' in params.enterprise: + for team in params.enterprise['teams']: + team_uid = team.get('team_uid', '') + if team_uid == team_input: + return team_uid + if team.get('name', '').casefold() == team_input.casefold(): + return team_uid + + raise CommandError('', f'Team "{team_input}" not found. Use a valid team UID or team name.') + + +class ProtobufRefBuilder: + + @staticmethod + def record_ref(record_uid_bytes: bytes, record_name: str = '') -> GraphSync_pb2.GraphSyncRef: + ref = GraphSync_pb2.GraphSyncRef() + ref.type = GraphSync_pb2.RFT_REC + ref.value = record_uid_bytes + if record_name: + ref.name = record_name + return ref + + @staticmethod + def workflow_ref(flow_uid_bytes: bytes) -> GraphSync_pb2.GraphSyncRef: + ref = GraphSync_pb2.GraphSyncRef() + ref.type = GraphSync_pb2.RFT_WORKFLOW + ref.value = flow_uid_bytes + return ref + + +class WorkflowFormatter: + + STAGE_MAP = { + workflow_pb2.WS_READY_TO_START: 'Ready to Start', + workflow_pb2.WS_STARTED: 'Started', + workflow_pb2.WS_NEEDS_ACTION: 'Needs Action', + workflow_pb2.WS_WAITING: 'Waiting', + } + + CONDITION_MAP = { + workflow_pb2.AC_APPROVAL: 'Approval Required', + workflow_pb2.AC_CHECKIN: 'Check-in Required', + workflow_pb2.AC_MFA: 'MFA Required', + workflow_pb2.AC_TIME: 'Time Restriction', + workflow_pb2.AC_REASON: 'Reason Required', + workflow_pb2.AC_TICKET: 'Ticket Required', + } + + DURATION_MULTIPLIERS = {'d': 86_400_000, 'h': 3_600_000, 'm': 60_000} + + DAY_PARSE_MAP = { + 'mon': workflow_pb2.MONDAY, 'monday': workflow_pb2.MONDAY, + 'tue': workflow_pb2.TUESDAY, 'tuesday': workflow_pb2.TUESDAY, + 'wed': workflow_pb2.WEDNESDAY, 'wednesday': workflow_pb2.WEDNESDAY, + 'thu': workflow_pb2.THURSDAY, 'thursday': workflow_pb2.THURSDAY, + 'fri': workflow_pb2.FRIDAY, 'friday': workflow_pb2.FRIDAY, + 'sat': workflow_pb2.SATURDAY, 'saturday': workflow_pb2.SATURDAY, + 'sun': workflow_pb2.SUNDAY, 'sunday': workflow_pb2.SUNDAY, + } + + DAY_NAME_MAP = { + workflow_pb2.MONDAY: 'Monday', + workflow_pb2.TUESDAY: 'Tuesday', + workflow_pb2.WEDNESDAY: 'Wednesday', + workflow_pb2.THURSDAY: 'Thursday', + workflow_pb2.FRIDAY: 'Friday', + workflow_pb2.SATURDAY: 'Saturday', + workflow_pb2.SUNDAY: 'Sunday', + } + + BLOCKING_CONDITIONS = {workflow_pb2.AC_TIME, workflow_pb2.AC_APPROVAL} + + @staticmethod + def format_stage(stage: int, status=None) -> str: + if stage == workflow_pb2.WS_READY_TO_START and status is not None: + if status.conditions: + has_blocking = any(c in WorkflowFormatter.BLOCKING_CONDITIONS for c in status.conditions) + if has_blocking: + return 'Waiting' + return 'Ready to Start' + if status.approvedBy and not status.startedOn: + return 'Ready to Start' + if not status.startedOn and not status.approvedBy: + return 'Needs Action' + return WorkflowFormatter.STAGE_MAP.get(stage, f'Unknown ({stage})') + + @staticmethod + def format_conditions(conditions: List[int]) -> str: + return ', '.join( + WorkflowFormatter.CONDITION_MAP.get(c, f'Unknown ({c})') + for c in conditions + ) + + @staticmethod + def parse_duration(duration_str: str) -> int: + duration_str = duration_str.lower().strip() + try: + for suffix, factor in WorkflowFormatter.DURATION_MULTIPLIERS.items(): + if duration_str.endswith(suffix): + value = int(duration_str[:-1]) + if value <= 0: + raise ValueError + return value * factor + value = int(duration_str) + if value <= 0: + raise ValueError + return value * 60_000 + except ValueError: + raise CommandError( + '', f'Invalid duration format: {duration_str}. ' + 'Use a positive value like "2h", "30m", or "1d"', + ) + + @staticmethod + def format_duration(milliseconds: int) -> str: + seconds = milliseconds // 1000 + minutes = seconds // 60 + hours = minutes // 60 + days = hours // 24 + + if days > 0: + return f"{days} day{'s' if days != 1 else ''}" + if hours > 0: + return f"{hours} hour{'s' if hours != 1 else ''}" + if minutes > 0: + return f"{minutes} minute{'s' if minutes != 1 else ''}" + return f"{seconds} second{'s' if seconds != 1 else ''}" + + @staticmethod + def build_temporal_filter(allowed_days_str, time_range_str): + if not allowed_days_str and not time_range_str: + return None + + temporal = workflow_pb2.TemporalAccessFilter() + + if allowed_days_str: + for day_token in allowed_days_str.split(','): + day_token = day_token.strip().lower() + day_enum = WorkflowFormatter.DAY_PARSE_MAP.get(day_token) + if day_enum is None: + valid = ', '.join(sorted({k for k in WorkflowFormatter.DAY_PARSE_MAP if len(k) == 3})) + raise CommandError('', f'Invalid day: "{day_token}". Valid: {valid}') + temporal.allowedDays.append(day_enum) + + if time_range_str: + if '-' not in time_range_str: + raise CommandError('', 'Time range must be in HH:MM-HH:MM format (e.g., "09:00-17:00")') + start_str, end_str = time_range_str.split('-', 1) + start_hhmm = WorkflowFormatter._parse_time_to_hhmm(start_str.strip()) + end_hhmm = WorkflowFormatter._parse_time_to_hhmm(end_str.strip()) + time_range = workflow_pb2.TimeOfDayRange() + time_range.startTime = start_hhmm + time_range.endTime = end_hhmm + temporal.timeRanges.append(time_range) + + temporal.timeZone = WorkflowFormatter._get_local_iana_timezone() + + return temporal + + @staticmethod + def _get_local_iana_timezone(): + """Detect local IANA timezone via TZ env var (override) or tzlocal (cross-platform).""" + import os + + tz = os.environ.get('TZ') + if tz and '/' in tz: + return tz + + try: + from tzlocal import get_localzone_name + except ImportError: + raise CommandError( + '', + 'Timezone detection requires the "tzlocal" package. ' + 'Install it with: pip install tzlocal\n' + 'Or set the TZ environment variable (e.g., TZ=Asia/Kolkata).' + ) + + try: + zone = get_localzone_name() + if zone: + return zone + except Exception as e: + import logging as _logging + _logging.debug('tzlocal lookup failed: %s', e) + + raise CommandError( + '', + 'Could not detect local IANA timezone. ' + 'Set the TZ environment variable (e.g., TZ=Asia/Kolkata).' + ) + + @staticmethod + def _parse_time_to_hhmm(time_str): + """Parse 'HH:MM' to HHMM integer (hours*100 + minutes), e.g. '09:00' -> 900, '17:30' -> 1730.""" + try: + parts = time_str.split(':') + h = int(parts[0]) + m = int(parts[1]) if len(parts) > 1 else 0 + if not (0 <= h <= 23 and 0 <= m <= 59): + raise ValueError + return h * 100 + m + except (ValueError, IndexError): + raise CommandError('', f'Invalid time format: "{time_str}". Use HH:MM (e.g., "09:00")') + + @staticmethod + def format_temporal_filter(at): + if not at: + return None + result = {} + if at.allowedDays: + result['allowed_days'] = [WorkflowFormatter.DAY_NAME_MAP.get(d, str(d)) for d in at.allowedDays] + if at.timeRanges: + ranges = [] + for tr in at.timeRanges: + sh, sm = divmod(tr.startTime, 100) + eh, em = divmod(tr.endTime, 100) + ranges.append(f"{sh:02d}:{sm:02d}-{eh:02d}:{em:02d}") + result['time_ranges'] = ranges + if at.timeZone: + result['timezone'] = at.timeZone + return result or None diff --git a/keepercli-package/src/keepercli/commands/workflow/mfa.py b/keepercli-package/src/keepercli/commands/workflow/mfa.py new file mode 100644 index 00000000..66e90d41 --- /dev/null +++ b/keepercli-package/src/keepercli/commands/workflow/mfa.py @@ -0,0 +1,747 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' dict: + if is_record_owner(self.params, self.record_uid): + return dict(self._DEFAULT_RESULT) + + config = self._read_workflow_config() + if config is _TRANSPORT_ERROR: + logging.debug( + 'read_workflow_config unavailable for %s; falling through to ' + 'allow (gateway will gate). Likely router without workflow API.', + self.record_uid, + ) + return dict(self._DEFAULT_RESULT) + if config is None: + return dict(self._DEFAULT_RESULT) + + if is_on_approver_list(self.params, config): + return dict(self._DEFAULT_RESULT) + + mfa_required = bool(config.parameters and config.parameters.requireMFA) + + if not self._check_allowed_times(config): + return self._blocked('outside_time_window') + + no_approvals = config.parameters and config.parameters.approvalsNeeded == 0 + workflow = self._find_active_workflow() + if workflow is _TRANSPORT_ERROR: + logging.debug( + 'get_user_access_state unavailable for %s; treating as no ' + 'active flow and allowing. Gateway will gate.', self.record_uid, + ) + return dict(self._DEFAULT_RESULT) + if workflow is None and no_approvals: + workflow = self._get_workflow_state_by_record() + if workflow is _TRANSPORT_ERROR: + logging.debug( + 'get_workflow_state unavailable for %s; allowing. Gateway ' + 'will gate.', self.record_uid, + ) + return dict(self._DEFAULT_RESULT) + if workflow is None: + requires_reason = bool(config.parameters and config.parameters.requireReason) + requires_ticket = bool(config.parameters and config.parameters.requireTicket) + approvals_needed = int(config.parameters.approvalsNeeded) if config.parameters else 0 + if no_approvals: + if not silent_actionable: + self._print_needs_start() + return self._blocked( + 'needs_start', + requires_reason=requires_reason, + requires_ticket=requires_ticket, + approvals_needed=approvals_needed, + ) + if not silent_actionable: + self._print_no_workflow() + return self._blocked( + 'no_workflow', + requires_reason=requires_reason, + requires_ticket=requires_ticket, + approvals_needed=approvals_needed, + ) + + return self._evaluate_stage(workflow, mfa_required, silent_actionable) + + def _blocked(self, reason: str, **extra) -> dict: + result = dict(self._BLOCKED_RESULT) + result['block_reason'] = reason + result.update(extra) + return result + + def _read_workflow_config(self): + ref = ProtobufRefBuilder.record_ref(self.record_uid_bytes, self.record_name) + try: + return _post_request_to_router( + self.params, 'read_workflow_config', + rq_proto=ref, rs_type=workflow_pb2.WorkflowConfig, + ) + except Exception as e: + logging.debug('Failed to read workflow config for %s: %s', self.record_uid, e) + return _TRANSPORT_ERROR + + def _find_active_workflow(self): + try: + user_state = _post_request_to_router( + self.params, 'get_user_access_state', + rs_type=workflow_pb2.UserAccessState, + ) + except Exception as e: + logging.debug('Failed to get user access state: %s', e) + return _TRANSPORT_ERROR + + if user_state and user_state.workflows: + for wf in user_state.workflows: + if wf.resource and wf.resource.value == self.record_uid_bytes: + return wf + return None + + def _check_allowed_times(self, config) -> bool: + if not config.parameters or not config.parameters.HasField('allowedTimes'): + return True + + at = config.parameters.allowedTimes + if not at.timeRanges and not at.allowedDays: + return True + + tz = None + if at.timeZone and ZoneInfo is not None: + try: + tz = ZoneInfo(at.timeZone) + except Exception: + logging.debug("Unknown timezone '%s'; falling back to local time", at.timeZone) + + now = datetime.datetime.now(tz) if tz else datetime.datetime.now().astimezone() + + # protobuf DayOfWeek: MONDAY=1..SUNDAY=7. Python weekday(): MONDAY=0..SUNDAY=6. + if at.allowedDays: + current_day = now.weekday() + 1 + if current_day not in at.allowedDays: + self._print_outside_time_window(at, now) + return False + + if at.timeRanges: + current_hhmm = now.hour * 100 + now.minute + in_range = False + for r in at.timeRanges: + if r.startTime <= r.endTime: + if r.startTime <= current_hhmm <= r.endTime: + in_range = True + break + else: + # range crosses midnight (e.g. 22:00-06:00) + if current_hhmm >= r.startTime or current_hhmm <= r.endTime: + in_range = True + break + if not in_range: + self._print_outside_time_window(at, now) + return False + + return True + + def _print_outside_time_window(self, at, now): + formatted = WorkflowFormatter.format_temporal_filter(at) or {} + print(f"\n{bcolors.WARNING}Workflow access is outside the allowed time window.{bcolors.ENDC}") + tz_label = at.timeZone or 'local' + print(f"Current time ({tz_label}): {now.strftime('%a %H:%M')}") + if formatted.get('allowed_days'): + print(f"Allowed days: {', '.join(formatted['allowed_days'])}") + if formatted.get('time_ranges'): + print(f"Allowed times: {', '.join(formatted['time_ranges'])}") + if formatted.get('timezone'): + print(f"Timezone: {formatted['timezone']}") + print() + + def _evaluate_stage(self, workflow, mfa_required: bool, + silent_actionable: bool = False) -> dict: + if not workflow.status: + self._print_no_workflow() + return self._blocked('no_status') + + stage = workflow.status.stage + + if stage == workflow_pb2.WS_STARTED: + return { + 'allowed': True, + 'require_mfa': mfa_required, + 'flow_uid': bytes(workflow.flowUid) if workflow.flowUid else None, + 'expires_on_ms': int(workflow.status.expiresOn) if workflow.status.expiresOn else 0, + 'block_reason': None, + 'pending_conditions': (), + } + + if stage == workflow_pb2.WS_READY_TO_START: + if not silent_actionable: + self._print_ready_to_start() + return self._blocked( + 'ready_to_start', + flow_uid=bytes(workflow.flowUid) if workflow.flowUid else None, + ) + + if stage == workflow_pb2.WS_WAITING: + conditions = tuple(workflow.status.conditions) if workflow.status.conditions else () + if not silent_actionable: + cond_str = WorkflowFormatter.format_conditions(conditions) if conditions else 'approval' + print(f"\n{bcolors.WARNING}Workflow access is pending: waiting for {cond_str}.{bcolors.ENDC}") + if workflow.status.checkedOutBy: + print(f"Record is currently checked out by: {workflow.status.checkedOutBy}") + print("Your request is being processed. Please wait for approval.\n") + return self._blocked( + 'waiting', + flow_uid=bytes(workflow.flowUid) if workflow.flowUid else None, + pending_conditions=conditions, + ) + + if stage == workflow_pb2.WS_NEEDS_ACTION: + conditions = tuple(workflow.status.conditions) if workflow.status.conditions else () + if not silent_actionable: + self._print_needs_action(conditions, workflow.flowUid) + return self._blocked( + 'needs_action', + flow_uid=bytes(workflow.flowUid) if workflow.flowUid else None, + pending_conditions=conditions, + ) + + self._print_no_workflow() + return self._blocked('no_workflow') + + def _print_ready_to_start(self): + print(f"\n{bcolors.WARNING}Workflow access approved but not yet checked out.{bcolors.ENDC}") + print(f"Run: {bcolors.OKBLUE}pam workflow start {self.record_uid}{bcolors.ENDC} to check out the record.\n") + + def _print_waiting(self, conditions, checked_out_by: str = ''): + cond_str = WorkflowFormatter.format_conditions(conditions) if conditions else 'approval' + print(f"\n{bcolors.WARNING}Workflow access is pending: waiting for {cond_str}.{bcolors.ENDC}") + if checked_out_by: + print(f"Record is currently checked out by: {checked_out_by}") + print("Your request is being processed. Please wait for approval.\n") + + def _print_needs_action(self, conditions, flow_uid_bytes): + print(f"\n{bcolors.WARNING}Workflow requires additional action before access is granted.{bcolors.ENDC}") + if conditions: + has_reason = workflow_pb2.AC_REASON in conditions + has_ticket = workflow_pb2.AC_TICKET in conditions + has_approval = workflow_pb2.AC_APPROVAL in conditions + if has_reason or has_ticket: + opts = [] + if has_reason: + opts.append('--reason ""') + if has_ticket: + opts.append('--ticket ""') + print(f"Run: {bcolors.OKBLUE}pam workflow request {self.record_uid} " + f"{' '.join(opts)}{bcolors.ENDC}") + elif has_approval: + print(f"Run: {bcolors.OKBLUE}pam workflow request {self.record_uid}{bcolors.ENDC} " + f"to request approval.") + else: + cond_str = WorkflowFormatter.format_conditions(conditions) + print(f"Pending conditions: {cond_str}") + elif flow_uid_bytes: + print(f"Run: {bcolors.OKBLUE}pam workflow state {self.record_name}{bcolors.ENDC} " + f"to see details.") + print() + + def _get_workflow_state_by_record(self): + try: + state_query = workflow_pb2.WorkflowState() + state_query.resource.CopyFrom( + ProtobufRefBuilder.record_ref(self.record_uid_bytes, self.record_name) + ) + return _post_request_to_router( + self.params, 'get_workflow_state', + rq_proto=state_query, rs_type=workflow_pb2.WorkflowState, + ) + except Exception as e: + logging.debug('Failed to get workflow state for %s: %s', self.record_uid, e) + return _TRANSPORT_ERROR + + def _print_transport_error(self, action: str): + print(f"\n{bcolors.FAIL}Unable to {action} — the server may be unavailable.{bcolors.ENDC}") + print("Access is blocked until workflow status can be verified. Please try again later.\n") + + def _print_no_workflow(self): + print(f"\n{bcolors.WARNING}This record is protected by a workflow.{bcolors.ENDC}") + print("You must request access before connecting.") + print(f"Run: {bcolors.OKBLUE}pam workflow request {self.record_uid}{bcolors.ENDC} to request access.\n") + + def _print_needs_start(self): + print(f"\n{bcolors.WARNING}This record is protected by a workflow.{bcolors.ENDC}") + print("No approvals required, but the record must be checked out first.") + print(f"Run: {bcolors.OKBLUE}pam workflow start {self.record_uid}{bcolors.ENDC} to check out the record.\n") + + +class WorkflowMfaPrompt: + + _NO_2FA_CONFIGURED = object() + + def __init__(self, params: KeeperParams): + self.params = params + + def prompt(self): + from ...proto import APIRequest_pb2 + from ... import api + + tfa_list = self._fetch_2fa_list(self.params, api, APIRequest_pb2) + if tfa_list is self._NO_2FA_CONFIGURED: + print(f"\n{bcolors.FAIL}This workflow requires 2FA verification{bcolors.ENDC}") + print( + "Your account does not have any 2FA methods configured. " + f"For available methods, run: {bcolors.OKBLUE}2fa add -h{bcolors.ENDC}\n" + ) + return None + if tfa_list is None: + try: + code = getpass.getpass('2FA required. Enter TOTP code: ').strip() + return code if code else None + except (KeyboardInterrupt, EOFError): + return None + + supported_types = { + APIRequest_pb2.TWO_FA_CT_TOTP: 'TOTP (Authenticator App)', + APIRequest_pb2.TWO_FA_CT_SMS: 'SMS Text Message', + APIRequest_pb2.TWO_FA_CT_DUO: 'DUO Security', + APIRequest_pb2.TWO_FA_CT_WEBAUTHN: 'Security Key', + APIRequest_pb2.TWO_FA_CT_DNA: 'Keeper DNA (Watch)', + } + + channels = [ch for ch in tfa_list.channels if ch.channelType in supported_types] + + if not channels: + print(f"{bcolors.FAIL}No supported 2FA methods found. Supported: TOTP, SMS, DUO, Security Key.{bcolors.ENDC}") + return None + + selected = self._select_channel(channels, supported_types) + if selected is None: + return None + + return self._dispatch(selected.channelType, APIRequest_pb2) + + @staticmethod + def _fetch_2fa_list(params, api, APIRequest_pb2): + try: + tfa_list = api.communicate_rest( + params, None, 'authentication/2fa_list', + rs_type=APIRequest_pb2.TwoFactorListResponse, + ) + except Exception: + return None + + if not tfa_list.channels: + return WorkflowMfaPrompt._NO_2FA_CONFIGURED + + return tfa_list + + @staticmethod + def _select_channel(channels, supported_types): + if len(channels) == 1: + return channels[0] + + print(f"\n{bcolors.OKBLUE}2FA required. Select authentication method:{bcolors.ENDC}") + for idx, ch in enumerate(channels, 1): + name = supported_types.get(ch.channelType, 'Unknown') + extra = f' ({ch.channelName})' if ch.channelName else '' + print(f" {idx}. {name}{extra}") + print(" q. Cancel") + + try: + answer = input('Selection: ').strip() + except (KeyboardInterrupt, EOFError): + return None + if answer.lower() == 'q': + return None + try: + idx = int(answer) - 1 + if 0 <= idx < len(channels): + return channels[idx] + except ValueError: + pass + + print(f"{bcolors.FAIL}Invalid selection.{bcolors.ENDC}") + return None + + def _dispatch(self, channel_type, APIRequest_pb2): + if channel_type == APIRequest_pb2.TWO_FA_CT_TOTP: + try: + code = getpass.getpass('Enter TOTP code: ').strip() + return code if code else None + except (KeyboardInterrupt, EOFError): + return None + + push_config = { + APIRequest_pb2.TWO_FA_CT_SMS: ( + APIRequest_pb2.TWO_FA_PUSH_SMS, + 'SMS sent.', 'SMS', + ), + APIRequest_pb2.TWO_FA_CT_DUO: ( + APIRequest_pb2.TWO_FA_PUSH_DUO_PUSH, + 'DUO push sent. Respond on your device, then enter the code.', 'DUO', + ), + APIRequest_pb2.TWO_FA_CT_DNA: ( + APIRequest_pb2.TWO_FA_PUSH_DNA, + 'Keeper DNA push sent. Approve on your watch, then enter the code.', 'DNA', + ), + } + + if channel_type in push_config: + push_type, sent_msg, label = push_config[channel_type] + return self._send_push_and_prompt(push_type, sent_msg, label) + + if channel_type == APIRequest_pb2.TWO_FA_CT_WEBAUTHN: + return self._handle_webauthn() + + return None + + def _send_push_and_prompt(self, push_type, sent_message, prompt_label): + try: + push_rq = router_pb2.Router2FASendPushRequest() + push_rq.pushType = push_type + _post_request_to_router(self.params, '2fa_send_push', rq_proto=push_rq) + print(f"{bcolors.OKGREEN}{sent_message}{bcolors.ENDC}") + except Exception: + print(f"{bcolors.FAIL}Failed to send {prompt_label} push. Please try again.{bcolors.ENDC}") + return None + + try: + code = getpass.getpass(f'Enter {prompt_label} code: ').strip() + return code if code else None + except (KeyboardInterrupt, EOFError): + return None + + def _handle_webauthn(self): + import json as _json + + try: + challenge_rq = router_pb2.Router2FAGetWebAuthnChallengeRequest() + challenge_rs = _post_request_to_router( + self.params, '2fa_get_webauthn_challenge', rq_proto=challenge_rq, + rs_type=router_pb2.Router2FAGetWebAuthnChallengeResponse, + ) + if not challenge_rs or not challenge_rs.challenge: + print(f"{bcolors.FAIL}Failed to get WebAuthn challenge from server.{bcolors.ENDC}") + return None + + challenge = _json.loads(challenge_rs.challenge) + + from ...yubikey.yubikey import yubikey_authenticate + response = yubikey_authenticate(challenge) + + if response: + signature = { + "id": response.id, + "rawId": utils.base64_url_encode(response.raw_id), + "response": { + "authenticatorData": utils.base64_url_encode(response.response.authenticator_data), + "clientDataJSON": response.response.client_data.b64, + "signature": utils.base64_url_encode(response.response.signature), + }, + "type": "public-key", + "clientExtensionResults": ( + dict(response.client_extension_results) + if response.client_extension_results else {} + ), + } + return _json.dumps(signature) + + print(f"{bcolors.FAIL}Security key authentication failed or was cancelled.{bcolors.ENDC}") + return None + + except ImportError: + from ...yubikey import display_fido2_warning + display_fido2_warning() + return None + except Exception: + print(f"{bcolors.FAIL}Security key authentication failed. Please try again.{bcolors.ENDC}") + return None + + +def check_workflow_access(params: KeeperParams, record_uid: str) -> dict: + return WorkflowAccessValidator(params, record_uid).validate() + + +_WAIT_POLL_INTERVAL_SECONDS = 8 + + +def _poll_until_state_change(validator: 'WorkflowAccessValidator', + timeout_seconds: int) -> Optional[dict]: + """Poll until state is no longer 'waiting' or timeout. Returns None on timeout/cancel/error.""" + import time as _time + deadline = _time.time() + max(timeout_seconds, _WAIT_POLL_INTERVAL_SECONDS) + print( + f"\n{bcolors.OKBLUE}Waiting for approval... " + f"(timeout: {timeout_seconds}s; press Ctrl+C to cancel){bcolors.ENDC}" + ) + try: + while _time.time() < deadline: + _time.sleep(_WAIT_POLL_INTERVAL_SECONDS) + r = validator.validate(silent_actionable=True) + block_reason = r.get('block_reason') + if r.get('allowed', True) or block_reason != 'waiting': + return r + print(f"{bcolors.WARNING}Approval not received within {timeout_seconds}s.{bcolors.ENDC}\n") + return None + except KeyboardInterrupt: + print(f"\n{bcolors.WARNING}Wait cancelled.{bcolors.ENDC}\n") + return None + + +def check_workflow_for_launch( + params: KeeperParams, + record_uid: str, + *, + reason: Optional[str] = None, + ticket: Optional[str] = None, + auto_checkout: bool = False, + wait: bool = False, + wait_timeout: int = 600, +) -> WorkflowGate: + """Pre-launch workflow gate: validate, auto-handle no_workflow/needs_action/ready_to_start, + prompt for MFA, and return flow UID + lease expiry. With `wait=True`, polls past 'waiting'.""" + validator = WorkflowAccessValidator(params, record_uid) + started_by_launch = False + handled_needs_action = False + handled_ready_to_start = False + handled_waiting = False + handled_no_workflow = False + + # no_workflow -> needs_action -> waiting -> ready_to_start -> started (max 5 transitions). + for _attempt in range(5): + result = validator.validate(silent_actionable=True) + if result.get('allowed', True): + break + block_reason = result.get('block_reason') + + if (block_reason in ('no_workflow', 'needs_start') + and not handled_no_workflow): + handled_no_workflow = True + requires_reason = bool(result.get('requires_reason')) + requires_ticket = bool(result.get('requires_ticket')) + approvals_needed = int(result.get('approvals_needed') or 0) + + final_reason = reason + final_ticket = ticket + + if requires_reason or requires_ticket: + prompt_reason = requires_reason and not final_reason + prompt_ticket = requires_ticket and not final_ticket + if prompt_reason or prompt_ticket: + p_reason, p_ticket = prompt_for_reason_ticket(prompt_reason, prompt_ticket) + if prompt_reason: + if p_reason is None: + if block_reason == 'needs_start': + validator._print_needs_start() + else: + validator._print_no_workflow() + return WorkflowGate(allowed=False) + final_reason = p_reason + if prompt_ticket: + if p_ticket is None: + if block_reason == 'needs_start': + validator._print_needs_start() + else: + validator._print_no_workflow() + return WorkflowGate(allowed=False) + final_ticket = p_ticket + + try: + submit_access_request( + params, record_uid, + reason=final_reason or '', + ticket=final_ticket or '', + ) + print(f"\n{bcolors.OKGREEN}Access request submitted.{bcolors.ENDC}\n") + except Exception as e: + logging.error("Failed to submit access request: %s", sanitize_router_error(e)) + return WorkflowGate(allowed=False) + continue + + if block_reason == 'needs_action' and not handled_needs_action: + handled_needs_action = True + conditions = result.get('pending_conditions') or () + needs_reason = workflow_pb2.AC_REASON in conditions + needs_ticket = workflow_pb2.AC_TICKET in conditions + needs_approval_only = ( + workflow_pb2.AC_APPROVAL in conditions + and not needs_reason and not needs_ticket + ) + + final_reason = reason + final_ticket = ticket + + if needs_reason or needs_ticket: + prompt_reason = needs_reason and not final_reason + prompt_ticket = needs_ticket and not final_ticket + if prompt_reason or prompt_ticket: + p_reason, p_ticket = prompt_for_reason_ticket(prompt_reason, prompt_ticket) + if prompt_reason: + if p_reason is None: + validator._print_needs_action(conditions, result.get('flow_uid')) + return WorkflowGate(allowed=False) + final_reason = p_reason + if prompt_ticket: + if p_ticket is None: + validator._print_needs_action(conditions, result.get('flow_uid')) + return WorkflowGate(allowed=False) + final_ticket = p_ticket + + if not (needs_reason or needs_ticket or needs_approval_only): + validator._print_needs_action(conditions, result.get('flow_uid')) + return WorkflowGate(allowed=False) + + try: + submit_access_request( + params, record_uid, + reason=final_reason or '', + ticket=final_ticket or '', + ) + print(f"\n{bcolors.OKGREEN}Access request submitted.{bcolors.ENDC}\n") + except Exception as e: + logging.error("Failed to submit access request: %s", sanitize_router_error(e)) + return WorkflowGate(allowed=False) + continue + + if block_reason == 'ready_to_start' and not handled_ready_to_start: + handled_ready_to_start = True + confirmed = auto_checkout + if not confirmed: + try: + answer = input( + f"\n{bcolors.OKBLUE}Workflow approved. Check out '{record_uid}' now? [Y/n]: {bcolors.ENDC}" + ).strip().lower() + except (KeyboardInterrupt, EOFError): + answer = 'n' + confirmed = answer in ('', 'y', 'yes') + if not confirmed: + validator._print_ready_to_start() + return WorkflowGate(allowed=False) + try: + start_workflow_for_record(params, record_uid) + print(f"{bcolors.OKGREEN}Checked out.{bcolors.ENDC}\n") + started_by_launch = True + except Exception as e: + logging.error("Failed to check out: %s", sanitize_router_error(e)) + return WorkflowGate(allowed=False) + continue + + if block_reason == 'waiting' and not handled_waiting and wait: + handled_waiting = True + polled = _poll_until_state_change(validator, wait_timeout) + if polled is None: + return WorkflowGate(allowed=False) + result = polled + if result.get('allowed', True): + break + continue + + # Fall-through for unhandled states; print explicit message so user always sees something. + if block_reason == 'needs_action': + validator._print_needs_action( + result.get('pending_conditions') or (), + result.get('flow_uid'), + ) + elif block_reason == 'ready_to_start': + validator._print_ready_to_start() + elif block_reason == 'waiting': + validator._print_waiting(result.get('pending_conditions') or ()) + elif block_reason == 'no_workflow': + validator._print_no_workflow() + elif block_reason == 'needs_start': + validator._print_needs_start() + return WorkflowGate(allowed=False) + + if not result.get('allowed', True): + return WorkflowGate(allowed=False) + + flow_uid = result.get('flow_uid') + expires_on_ms = int(result.get('expires_on_ms') or 0) + + two_factor_value = None + if result.get('require_mfa', False): + # Skip MFA prompt when gateway is known offline; launch surfaces its own error. + if is_gateway_online_for_record(params, record_uid) is False: + logging.debug("Skipping workflow MFA prompt — gateway is offline.") + else: + two_factor_value = WorkflowMfaPrompt(params).prompt() + if not two_factor_value: + return WorkflowGate(allowed=False) + + return WorkflowGate( + allowed=True, + two_factor_value=two_factor_value, + flow_uid=flow_uid, + expires_on_ms=expires_on_ms, + started_by_launch=started_by_launch, + ) + + +def check_workflow_and_prompt_2fa(params: KeeperParams, record_uid: str): + """Backward-compatible wrapper. Prefer check_workflow_for_launch (carries flow_uid + expiry).""" + gate = check_workflow_for_launch(params, record_uid) + return (gate.allowed, gate.two_factor_value) diff --git a/keepercli-package/src/keepercli/commands/workflow/registry.py b/keepercli-package/src/keepercli/commands/workflow/registry.py new file mode 100644 index 00000000..2bbe4a4c --- /dev/null +++ b/keepercli-package/src/keepercli/commands/workflow/registry.py @@ -0,0 +1,114 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' 0 else args.strip()).lower() if args else '' + resolved_verb = self._aliases.get(verb, verb) + + if resolved_verb in self._ADMIN_VERBS and not self._can_manage_workflows(params): + print( + f"\n{bcolors.WARNING}You do not have permission to manage workflow settings.{bcolors.ENDC}\n" + f"The '{bcolors.BOLD}{resolved_verb}{bcolors.ENDC}' command requires the " + f"'{bcolors.BOLD}Can manage workflow settings{bcolors.ENDC}' enforcement policy.\n" + f"Contact your Keeper administrator to enable this for your role.\n" + ) + return + + return super().execute_args(params, args, **kwargs) + + def print_help(self, **kwargs): + params = getattr(self, '_current_params', None) + is_admin = params and self._can_manage_workflows(params) + + print(f'{kwargs.get("command")} command [--options]') + table = [] + headers = ['Command', 'Description'] + for verb in self._commands.keys(): + if verb in self._ADMIN_VERBS and not is_admin: + continue + row = [verb, self._command_info.get(verb) or ''] + table.append(row) + print('') + dump_report_data(table, headers=headers) + print('') + + def __init__(self): + super(PAMWorkflowCommand, self).__init__() + + # Configuration (admin — requires 'Can manage workflow settings' enforcement) + self.register_command('create', WorkflowCreateCommand(), 'Create workflow configuration', 'c') + self.register_command('read', WorkflowReadCommand(), 'Read workflow configuration', 'r') + self.register_command('update', WorkflowUpdateCommand(), 'Update workflow configuration', 'u') + self.register_command('delete', WorkflowDeleteCommand(), 'Delete workflow configuration', 'd') + self.register_command('add-approver', WorkflowAddApproversCommand(), 'Add approvers', 'aa') + self.register_command('remove-approver', WorkflowDeleteApproversCommand(), 'Remove approvers', 'ra') + + # Approver actions + self.register_command('pending', WorkflowGetApprovalRequestsCommand(), 'Get pending approvals', 'p') + self.register_command('approve', WorkflowApproveCommand(), 'Approve access request', 'a') + self.register_command('deny', WorkflowDenyCommand(), 'Deny access request', 'dn') + + # Requester actions + self.register_command('request', WorkflowRequestAccessCommand(), 'Request or escalate access', 'rq') + self.register_command('start', WorkflowStartCommand(), 'Start workflow (check-out)', 's') + self.register_command('end', WorkflowEndCommand(), 'End workflow (check-in)', 'e') + + # State inspection + self.register_command('state', WorkflowGetStateCommand(), 'Get workflow state', 'st') + self.register_command('my-access', WorkflowGetUserAccessStateCommand(), 'Get my access state', 'ma') + + self.default_verb = 'state' diff --git a/keepercli-package/src/keepercli/commands/workflow/requester_commands.py b/keepercli-package/src/keepercli/commands/workflow/requester_commands.py new file mode 100644 index 00000000..700913da --- /dev/null +++ b/keepercli-package/src/keepercli/commands/workflow/requester_commands.py @@ -0,0 +1,357 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' str: + return datetime.fromtimestamp(ts_ms / 1000).strftime('%Y-%m-%d %H:%M:%S') + + +def _fmt_ts_or_empty(ts_ms: int) -> str: + return _ms_to_datetime_str(ts_ms) if ts_ms else '' + + +class WorkflowGetStateCommand(Command): + parser = argparse.ArgumentParser( + prog='pam workflow state', + description='Get workflow state for a record', + ) + parser.add_argument('record', help='Record UID or name') + parser.add_argument('--format', dest='format', action='store', + choices=['table', 'json'], default='table', help='Output format') + + def get_parser(self): + return WorkflowGetStateCommand.parser + + def execute(self, params: KeeperParams, **kwargs): + record_uid, record = RecordResolver.resolve(params, kwargs.get('record')) + if is_workflow_exempt(params, record_uid): + print_exempt_message(kwargs.get('format', 'table')) + return + + state = workflow_pb2.WorkflowState() + record_uid_bytes = utils.base64_url_decode(record_uid) + state.resource.CopyFrom(ProtobufRefBuilder.record_ref(record_uid_bytes, record.title)) + + try: + response = _post_request_to_router( + params, 'get_workflow_state', + rq_proto=state, rs_type=workflow_pb2.WorkflowState, + ) + + if response is None: + if kwargs.get('format') == 'json': + print(json.dumps({'status': 'no_workflow', 'message': 'No workflow found'}, indent=2)) + else: + print(f"\n{bcolors.WARNING}No workflow found for this record{bcolors.ENDC}\n") + return + + if kwargs.get('format') == 'json': + self._print_json(params, response) + else: + self._print_table(params, response) + + except Exception as e: + raise CommandError('', f'Failed to get workflow state: {sanitize_router_error(e)}') + + @staticmethod + def _print_json(params, response): + result = { + 'flow_uid': utils.base64_url_encode(response.flowUid) if response.flowUid else None, + 'record_uid': utils.base64_url_encode(response.resource.value), + 'record_name': RecordResolver.resolve_name(params, response.resource), + 'stage': WorkflowFormatter.format_stage(response.status.stage, response.status), + 'conditions': [WorkflowFormatter.format_conditions([c]) for c in response.status.conditions], + 'escalated': response.status.escalated, + 'checked_out_by': response.status.checkedOutBy or None, + 'can_force_checkin': response.status.canForceCheckIn, + 'started_on': response.status.startedOn or None, + 'expires_on': response.status.expiresOn or None, + 'approved_by': [ + { + 'user': a.user if a.user else RecordResolver.resolve_user(params, a.userId), + 'approved_on': a.approvedOn or None, + } + for a in response.status.approvedBy + ], + } + print(json.dumps(result, indent=2)) + + @staticmethod + def _print_table(params, response): + print(f"\n{bcolors.OKBLUE}Workflow State{bcolors.ENDC}\n") + print(f"Record: {RecordResolver.format_label(params, response.resource)}") + st = response.status + detail_lines = [ + f"Flow UID: {utils.base64_url_encode(response.flowUid)}" if response.flowUid else None, + f"Stage: {WorkflowFormatter.format_stage(st.stage, st)}", + f"Conditions: {WorkflowFormatter.format_conditions(st.conditions)}" if st.conditions else None, + f"Checked out by: {st.checkedOutBy}" if st.checkedOutBy else None, + "Force check-in: Available" if st.canForceCheckIn else None, + "Escalated: Yes" if st.escalated else None, + f"Started: {_ms_to_datetime_str(st.startedOn)}" if st.startedOn else None, + f"Expires: {_ms_to_datetime_str(st.expiresOn)}" if st.expiresOn else None, + ] + for line in detail_lines: + if line: + print(line) + if st.approvedBy: + print("Approved by:") + for a in st.approvedBy: + name = a.user if a.user else RecordResolver.resolve_user(params, a.userId) + suffix = f" at {_ms_to_datetime_str(a.approvedOn)}" if a.approvedOn else '' + print(f" - {name}{suffix}") + print() + + +class WorkflowGetUserAccessStateCommand(Command): + parser = argparse.ArgumentParser( + prog='pam workflow my-access', + description='Get all workflow states for current user', + ) + parser.add_argument('--format', dest='format', action='store', + choices=['table', 'json'], default='table', help='Output format') + + def get_parser(self): + return WorkflowGetUserAccessStateCommand.parser + + def execute(self, params: KeeperParams, **kwargs): + try: + response = _post_request_to_router( + params, 'get_user_access_state', + rs_type=workflow_pb2.UserAccessState, + ) + + if not response or not response.workflows: + if kwargs.get('format') == 'json': + print(json.dumps({'workflows': []}, indent=2)) + else: + print(f"\n{bcolors.WARNING}No active workflows{bcolors.ENDC}\n") + return + + if kwargs.get('format') == 'json': + self._print_json(params, response) + else: + self._print_table(params, response) + + except Exception as e: + raise CommandError('', f'Failed to get user access state: {sanitize_router_error(e)}') + + @staticmethod + def _print_json(params, response): + result = { + 'workflows': [ + { + 'flow_uid': utils.base64_url_encode(wf.flowUid), + 'record_uid': utils.base64_url_encode(wf.resource.value), + 'record_name': RecordResolver.resolve_name(params, wf.resource), + 'stage': WorkflowFormatter.format_stage(wf.status.stage, wf.status), + 'conditions': [WorkflowFormatter.format_conditions([c]) for c in wf.status.conditions], + 'escalated': wf.status.escalated, + 'checked_out_by': wf.status.checkedOutBy or None, + 'can_force_checkin': wf.status.canForceCheckIn, + 'started_on': wf.status.startedOn or None, + 'expires_on': wf.status.expiresOn or None, + 'approved_by': [ + { + 'user': a.user if a.user else RecordResolver.resolve_user(params, a.userId), + 'approved_on': a.approvedOn or None, + } + for a in wf.status.approvedBy + ], + } + for wf in response.workflows + ], + } + print(json.dumps(result, indent=2)) + + @staticmethod + def _print_table(params, response): + rows = [] + for wf in response.workflows: + stage = WorkflowFormatter.format_stage(wf.status.stage, wf.status) + record_name = RecordResolver.resolve_name(params, wf.resource) + record_uid = utils.base64_url_encode(wf.resource.value) if wf.resource.value else '' + flow_uid = utils.base64_url_encode(wf.flowUid) if wf.flowUid else '' + checked_out_by = wf.status.checkedOutBy or '' + started = _fmt_ts_or_empty(wf.status.startedOn) + expires = _fmt_ts_or_empty(wf.status.expiresOn) + approved_by = '' + if wf.status.approvedBy: + approved_names = [ + a.user if a.user else RecordResolver.resolve_user(params, a.userId) + for a in wf.status.approvedBy + ] + approved_by = '\n'.join(approved_names) + rows.append([stage, record_name, record_uid, flow_uid, checked_out_by, approved_by, started, expires]) + + headers = ['Stage', 'Record Name', 'Record UID', 'Flow UID', 'Checked Out By', 'Approved By', 'Started', 'Expires'] + print() + dump_report_data(rows, headers=headers) + print() diff --git a/keepercli-package/src/keepercli/compat/__init__.py b/keepercli-package/src/keepercli/compat/__init__.py new file mode 100644 index 00000000..4c6eb9ea --- /dev/null +++ b/keepercli-package/src/keepercli/compat/__init__.py @@ -0,0 +1,5 @@ +"""Commander API compatibility shims for pam launch port.""" + +from .commander_params import ensure_commander_compat + +__all__ = ['ensure_commander_compat'] diff --git a/keepercli-package/src/keepercli/compat/api.py b/keepercli-package/src/keepercli/compat/api.py new file mode 100644 index 00000000..16b06b73 --- /dev/null +++ b/keepercli-package/src/keepercli/compat/api.py @@ -0,0 +1,35 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' int: + if self._params.auth: + return self._params.auth.keeper_endpoint.server_key_id + return 7 + + @property + def server_base(self) -> str: + server = self._params.keeper_config.server or '' + if server.startswith('http'): + return server + return f'https://{server}' + + +_compat_installed = False + + +def ensure_commander_compat() -> None: + global _compat_installed + if _compat_installed: + return + _compat_installed = True + + KeeperParams.tube_registry = None # type: ignore[attr-defined] + + def _invalidate_caches(params: KeeperParams) -> None: + params._commander_record_cache = None # type: ignore[attr-defined] + params._commander_subfolder_record_cache = None # type: ignore[attr-defined] + params._commander_subfolder_cache = None # type: ignore[attr-defined] + params._commander_folder_cache = None # type: ignore[attr-defined] + + def _build_record_cache(params: KeeperParams) -> Dict[str, Dict[str, Any]]: + cache: Dict[str, Dict[str, Any]] = {} + if params.vault is None: + return cache + vd = params.vault.vault_data + for info in vd.records(): + uid = info.record_uid + record_key = vd.get_record_key(uid) + storage_record = vd.storage.records.get_entity(uid) + if not storage_record or not record_key: + continue + kr = vd.load_record(uid) + data_unencrypted = '{}' + extra_unencrypted = None + if kr: + if isinstance(kr, vault_record.PasswordRecord): + data, extra, _ = vault_extensions.extract_password_record(kr) + data_unencrypted = json.dumps(data) + if extra: + extra_unencrypted = json.dumps(extra) + elif isinstance(kr, vault_record.TypedRecord): + rt = vd.get_record_type_by_name(kr.record_type) + data = vault_extensions.extract_typed_record_data(kr, rt) + data_unencrypted = json.dumps(data) + cache[uid] = { + 'record_uid': uid, + 'version': info.version, + 'revision': info.revision, + 'record_key_unencrypted': record_key, + 'data_unencrypted': data_unencrypted, + 'extra_unencrypted': extra_unencrypted, + 'client_modified_time': getattr( + storage_record, 'modified_time', 0 + ), + 'shared': bool(getattr(storage_record, 'shared', False)), + } + + # Include NSF / Keeper Drive records so pam launch can resolve NSF UIDs. + nsf = getattr(params.vault, 'nsf_data', None) + if nsf is not None: + for entry in nsf.records(): + uid = entry.record_uid + if not uid or uid in cache: + continue + record_key = getattr(entry, 'record_key', None) or b'' + data_unencrypted = entry.decrypted_data or '{}' + cache[uid] = { + 'record_uid': uid, + 'version': getattr(entry, 'version', 3) or 3, + 'revision': getattr(entry, 'revision', 0) or 0, + 'record_key_unencrypted': record_key, + 'data_unencrypted': data_unencrypted, + 'extra_unencrypted': None, + 'client_modified_time': getattr(entry, 'client_modified_time', 0) or 0, + 'shared': bool(getattr(entry, 'shared', False)), + } + return cache + + def _build_subfolder_record_cache(params: KeeperParams) -> Dict[str, Set[str]]: + mapping: Dict[str, Set[str]] = {} + if params.vault is None: + return mapping + vd = params.vault.vault_data + + def walk(folder): + folder_uid = folder.folder_uid or '' + mapping.setdefault(folder_uid, set()).update(folder.records) + for sub_uid in folder.subfolders: + sub = vd.get_folder(sub_uid) + if sub: + walk(sub) + + walk(vd.root_folder) + return mapping + + def _build_subfolder_cache(params: KeeperParams) -> Dict[str, Dict[str, Any]]: + cache: Dict[str, Dict[str, Any]] = {} + if params.vault is None: + return cache + for folder in params.vault.vault_data.folders(): + cache[folder.folder_uid] = { + 'data_unencrypted': json.dumps({'name': folder.name or ''}), + } + root = params.vault.vault_data.root_folder + cache[root.folder_uid or ''] = { + 'data_unencrypted': json.dumps({'name': root.name or 'My Vault'}), + } + return cache + + def _build_folder_cache(params: KeeperParams) -> Dict[str, Any]: + cache: Dict[str, Any] = {} + if params.vault is None: + return cache + for folder in params.vault.vault_data.folders(): + cache[folder.folder_uid] = folder + cache[params.vault.vault_data.root_folder.folder_uid or ''] = params.vault.vault_data.root_folder + return cache + + @property + def batch_mode(self: KeeperParams) -> bool: + return bool(self.keeper_config.batch_mode) + + @property + def session_token(self: KeeperParams) -> Optional[str]: + if self.auth: + return utils.base64_url_encode(self.auth.auth_context.session_token) + return None + + @property + def server(self: KeeperParams) -> str: + return self.keeper_config.server or '' + + @property + def ssl_verify(self: KeeperParams) -> bool: + return bool(self.keeper_config.certificate_check) + + @property + def rest_context(self: KeeperParams) -> _RestContext: + return _RestContext(self) + + @property + def record_cache(self: KeeperParams) -> Dict[str, Dict[str, Any]]: + if getattr(self, '_commander_record_cache', None) is None: + self._commander_record_cache = _build_record_cache(self) + return self._commander_record_cache + + @property + def subfolder_record_cache(self: KeeperParams) -> Dict[str, Set[str]]: + if getattr(self, '_commander_subfolder_record_cache', None) is None: + self._commander_subfolder_record_cache = _build_subfolder_record_cache(self) + return self._commander_subfolder_record_cache + + @property + def subfolder_cache(self: KeeperParams) -> Dict[str, Dict[str, Any]]: + if getattr(self, '_commander_subfolder_cache', None) is None: + self._commander_subfolder_cache = _build_subfolder_cache(self) + return self._commander_subfolder_cache + + @property + def folder_cache(self: KeeperParams) -> Dict[str, Any]: + if getattr(self, '_commander_folder_cache', None) is None: + self._commander_folder_cache = _build_folder_cache(self) + return self._commander_folder_cache + + @property + def root_folder(self: KeeperParams): + if self.vault: + return self.vault.vault_data.root_folder + return None + + @property + def enforcements(self: KeeperParams): + return getattr(self, '_enforcements', None) + + @enforcements.setter + def enforcements(self: KeeperParams, value): + self._enforcements = value + + KeeperParams.batch_mode = batch_mode # type: ignore[assignment] + KeeperParams.session_token = session_token # type: ignore[assignment] + KeeperParams.server = server # type: ignore[assignment] + KeeperParams.ssl_verify = ssl_verify # type: ignore[assignment] + KeeperParams.rest_context = rest_context # type: ignore[assignment] + KeeperParams.record_cache = record_cache # type: ignore[assignment] + KeeperParams.subfolder_record_cache = subfolder_record_cache # type: ignore[assignment] + KeeperParams.subfolder_cache = subfolder_cache # type: ignore[assignment] + KeeperParams.folder_cache = folder_cache # type: ignore[assignment] + KeeperParams.root_folder = root_folder # type: ignore[assignment] + KeeperParams.enforcements = enforcements # type: ignore[assignment] + KeeperParams._invalidate_commander_caches = _invalidate_caches # type: ignore[attr-defined] diff --git a/keepercli-package/src/keepercli/compat/constants.py b/keepercli-package/src/keepercli/compat/constants.py new file mode 100644 index 00000000..3be8c241 --- /dev/null +++ b/keepercli-package/src/keepercli/compat/constants.py @@ -0,0 +1,32 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' str: + if server_hostname.startswith('dev.'): + return server_hostname[4:] + return server_hostname + + +def get_router_host(server_hostname: str) -> str: + krouter_url = os.getenv('KROUTER_URL') + if krouter_url: + return krouter_url.replace('https://', '').replace('http://', '').rstrip('/') + return f'connect.{get_keeper_host_for_services(server_hostname)}' + + +def get_relay_host(server_hostname: str) -> str: + krelay_url = os.getenv('KRELAY_URL') + if krelay_url: + return krelay_url.replace('https://', '').replace('http://', '').rstrip('/') + return f'krelay.{get_keeper_host_for_services(server_hostname)}' diff --git a/keepercli-package/src/keepercli/compat/display.py b/keepercli-package/src/keepercli/compat/display.py new file mode 100644 index 00000000..2bebe215 --- /dev/null +++ b/keepercli-package/src/keepercli/compat/display.py @@ -0,0 +1,25 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' Optional[str]: + if not folder_name: + return None + if folder_name in params.folder_cache: + return folder_name + rs = folder_utils.try_resolve_path(params, folder_name) + if rs is not None: + folder, record_name = rs + if folder and not record_name: + return folder.folder_uid or '' + return None diff --git a/keepercli-package/src/keepercli/compat/vault.py b/keepercli-package/src/keepercli/compat/vault.py new file mode 100644 index 00000000..59b88183 --- /dev/null +++ b/keepercli-package/src/keepercli/compat/vault.py @@ -0,0 +1,119 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' str: + return self.file_name or self.title + + @name.setter + def name(self, value: str) -> None: + self.file_name = value or '' + + +class ApplicationRecord(vault_record.KeeperRecord): + """Minimal KSM application record placeholder for Commander parity.""" + + def version(self) -> int: + return 5 + + def load_record_data(self, data: Dict[str, Any], extra=None) -> None: + self.title = data.get('title', '') + + +def _load_nsf_record(vault, record_uid: str) -> Optional[vault_record.KeeperRecord]: + """Load an NSF record as TypedRecord or FileRecord (version 4).""" + if not vault or not vault.nsf_data or not vault.nsf_data.get_record(record_uid): + return None + from keepersdk.vault import nsf_management + try: + meta = nsf_management.load_nsf_record_metadata(vault, record_uid) + except nsf_management.NsfError: + return None + entry = vault.nsf_data.get_record(record_uid) + version = int(meta.get('version') or (entry.version if entry else 0) or 0) + + if version == 4: + payload: Dict[str, Any] = {} + if entry is not None: + try: + payload = nsf_management._record_payload_from_entry(vault.nsf_data, entry) or {} + except Exception: + payload = {} + if not isinstance(payload, dict): + payload = {} + file_rec = FileRecord() + file_rec.record_uid = record_uid + if entry and getattr(entry, 'record_key', None): + setattr(file_rec, 'record_key', entry.record_key) + file_rec.load_record_data({ + 'title': payload.get('title') or meta.get('title') or record_uid, + 'name': payload.get('name') or payload.get('title') or meta.get('title') or record_uid, + 'type': payload.get('type') or meta.get('type') or '', + 'size': payload.get('size') if payload.get('size') is not None else ( + getattr(entry, 'file_size', None) if entry else None), + }) + return file_rec + + typed = TypedRecord() + typed.record_uid = record_uid + if entry and getattr(entry, 'record_key', None): + typed.record_key = entry.record_key + typed.load_record_data({ + 'type': meta.get('type') or '', + 'title': meta.get('title') or record_uid, + 'notes': meta.get('notes') or '', + 'fields': meta.get('fields') or [], + 'custom': meta.get('custom') or [], + }) + return typed + + +class KeeperRecord(vault_record.KeeperRecord): + @staticmethod + def load(params, rec: Union[str, Dict[str, Any]]) -> Optional[vault_record.KeeperRecord]: + if isinstance(rec, dict): + rec = rec.get('record_uid') + if not isinstance(rec, str) or not rec: + return None + if params.vault is None: + return None + loaded = params.vault.vault_data.load_record(rec) + if loaded: + return loaded + return _load_nsf_record(params.vault, rec) + + @staticmethod + def size_to_str(size): + return vault_record.KeeperRecord.size_to_str(size) if hasattr(vault_record.KeeperRecord, 'size_to_str') else str(size) diff --git a/keepercli-package/src/keepercli/helpers/pam_config_utils.py b/keepercli-package/src/keepercli/helpers/pam_config_utils.py new file mode 100644 index 00000000..9592f273 --- /dev/null +++ b/keepercli-package/src/keepercli/helpers/pam_config_utils.py @@ -0,0 +1,44 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' str: + """Standard base64 (not URL-safe) for router TransmissionKey / Authorization headers.""" + return base64.b64encode(data).decode('ascii') + + def _router_base_url(keeper_endpoint) -> str: """Full router base URL (scheme + host), matching keepersdk execute_router_rest.""" if 'ROUTER_URL' in os.environ: @@ -47,9 +53,80 @@ def router_get_connected_gateways(vault: vault_online.VaultOnline) -> Optional[p return None -def router_send_action_to_gateway(context: KeeperParams, gateway_action: GatewayAction, message_type, is_streaming, +def _router_base_url_from_params(params: KeeperParams) -> str: + if params.auth: + return _router_base_url(params.auth.keeper_endpoint) + if params.vault: + return _router_base_url(params.vault.keeper_auth.keeper_endpoint) + raise KeeperApiError('-1', 'Router URL unavailable: not authenticated') + + +def get_router_url(params: KeeperParams) -> str: + if os.getenv('KROUTER_URL'): + url = os.getenv('KROUTER_URL', '').rstrip('/') + if not url.startswith('http'): + url = f'https://{url}' + return url + if os.getenv('ROUTER_URL'): + return os.environ['ROUTER_URL'].rstrip('/') + if params.auth: + return _router_base_url(params.auth.keeper_endpoint) + if params.vault: + return _router_base_url(params.vault.keeper_auth.keeper_endpoint) + server = params.keeper_config.server or '' + from ..compat.constants import get_router_host + return f'https://{get_router_host(server)}' + + +def get_router_ws_url(params: KeeperParams) -> str: + router_url = get_router_url(params) + if router_url.startswith('https://'): + return 'wss://' + router_url[8:] + if router_url.startswith('http://'): + return 'ws://' + router_url[7:] + return router_url + + +def router_get_relay_access_creds(params: KeeperParams, expire_sec=None): + query_params = {'expire-sec': expire_sec} + vault = params.vault + if vault is None: + raise KeeperApiError('-1', 'Vault is not initialized') + return _post_request_to_router(vault, 'relay_access_creds', query_params=query_params, + rs_type=pam_pb2.RelayAccessCreds) + + +def get_dag_leafs(params: KeeperParams, encrypted_session_token, encrypted_transmission_key, record_id: str): + from keepersdk.helpers.tunnel.tunnel_utils import get_dag_leafs as _get_dag_leafs + if params.vault is None: + return None + return _get_dag_leafs(params.vault, encrypted_session_token, encrypted_transmission_key, record_id) + + +def _post_request_to_router_for_params(params: KeeperParams, path, rq_proto=None, rs_type=None, method='post', + raw_without_status_check_response=False, query_params=None, + transmission_key=None, encrypted_transmission_key=None, + encrypted_session_token=None): + if params.vault is None: + raise KeeperApiError('-1', 'Vault is not initialized') + return _post_request_to_router(params.vault, path, rq_proto=rq_proto, rs_type=rs_type, method=method, + raw_without_status_check_response=raw_without_status_check_response, + query_params=query_params, transmission_key=transmission_key, + encrypted_transmission_key=encrypted_transmission_key, + encrypted_session_token=encrypted_session_token) + + +def router_send_action_to_gateway(params: KeeperParams = None, context: KeeperParams = None, **kwargs): + """Commander-compatible wrapper: accepts ``params=`` or ``context=``.""" + ctx = params or context + if ctx is None: + raise ValueError('params or context is required') + return router_send_action_to_gateway_impl(ctx, **kwargs) + + +def router_send_action_to_gateway_impl(context: KeeperParams, gateway_action: GatewayAction, message_type, is_streaming, destination_gateway_uid_str=None, gateway_timeout=15000, transmission_key=None, - encrypted_transmission_key=None, encrypted_session_token=None): + encrypted_transmission_key=None, encrypted_session_token=None, http_session=None): krouter_url = _router_base_url(context.auth.keeper_endpoint) @@ -67,7 +144,8 @@ def router_send_action_to_gateway(context: KeeperParams, gateway_action: Gateway destination_gateway_uid_bytes = utils.base64_url_decode(destination_gateway_uid_str) if destination_gateway_uid_bytes not in router_enterprise_controllers_connected: - logging.warning(f"\tThis Gateway currently is not online.") + # Match Commander: print so the message is visible during the spinner. + print("\tThis Gateway currently is not online.") return else: if not router_enterprise_controllers_connected or len(router_enterprise_controllers_connected) == 0: @@ -109,7 +187,8 @@ def router_send_action_to_gateway(context: KeeperParams, gateway_action: Gateway transmission_key=transmission_key, rq_proto=rq, encrypted_transmission_key=encrypted_transmission_key, - encrypted_session_token=encrypted_session_token) + encrypted_session_token=encrypted_session_token, + http_session=http_session) rs_body = response.content @@ -153,7 +232,10 @@ def router_send_action_to_gateway(context: KeeperParams, gateway_action: Gateway def router_send_message_to_gateway(context: KeeperParams, transmission_key, rq_proto, - encrypted_transmission_key=None, encrypted_session_token=None): + encrypted_transmission_key=None, encrypted_session_token=None, + http_session=None): + """Send controller message to gateway. When http_session is provided (streaming/ALB affinity), + the request uses that session so cookies match the WebSocket connection.""" krouter_url = _router_base_url(context.auth.keeper_endpoint) @@ -172,16 +254,24 @@ def router_send_message_to_gateway(context: KeeperParams, transmission_key, rq_p if not encrypted_session_token: encrypted_session_token = crypto.encrypt_aes_v2(context.auth.auth_context.session_token, transmission_key) - rs = requests.post( - krouter_url + "/api/user/send_controller_message", - verify=VERIFY_SSL, - - headers={ - 'TransmissionKey': utils.base64_url_encode(encrypted_transmission_key), - 'Authorization': f'KeeperUser {utils.base64_url_encode(encrypted_session_token)}', - }, - data=encrypted_payload if rq_proto else None - ) + headers = { + 'TransmissionKey': _bytes_to_base64(encrypted_transmission_key), + 'Authorization': f'KeeperUser {_bytes_to_base64(encrypted_session_token)}', + } + if http_session is not None: + rs = http_session.post( + krouter_url + "/api/user/send_controller_message", + verify=VERIFY_SSL, + headers=headers, + data=encrypted_payload if rq_proto else None + ) + else: + rs = requests.post( + krouter_url + "/api/user/send_controller_message", + verify=VERIFY_SSL, + headers=headers, + data=encrypted_payload if rq_proto else None + ) if rs.status_code >= 300: raise Exception(str(rs.status_code) + ': error: ' + rs.reason + ', message: ' + rs.text) @@ -425,8 +515,8 @@ def _post_request_to_router(vault: vault_online.VaultOnline, path, rq_proto=None params=query_params, verify=VERIFY_SSL, headers={ - 'TransmissionKey': utils.base64_url_encode(encrypted_transmission_key), - 'Authorization': f'KeeperUser {utils.base64_url_encode(encrypted_session_token)}' + 'TransmissionKey': _bytes_to_base64(encrypted_transmission_key), + 'Authorization': f'KeeperUser {_bytes_to_base64(encrypted_session_token)}' }, data=encrypted_payload if rq_proto else None ) diff --git a/keepercli-package/src/keepercli/helpers/ssh_agent.py b/keepercli-package/src/keepercli/helpers/ssh_agent.py new file mode 100644 index 00000000..6da9114e --- /dev/null +++ b/keepercli-package/src/keepercli/helpers/ssh_agent.py @@ -0,0 +1,219 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' bool + header = header.rstrip('\r\n') + header = header.strip('-') + if header.startswith('BEGIN') and header.endswith('PRIVATE KEY'): + return True + return False + +Key_Prefix = ['id_'] +Key_Suffix = ['.key', '.pem'] +Key_Suffix_Exclude = ['.pub'] +def is_private_key_name(name): # type: (str) -> bool + if not name: + return False + if isinstance(name, str): + name = name.lower() + if any(True for x in Key_Suffix_Exclude if name.endswith(x)): + return False + if any(True for x in Key_Suffix if name.endswith(x)): + return True + if any(True for x in Key_Prefix if name.startswith(x)): + return True + + return False + +KEY_SIZE_MIN = 119 # Smallest possible size for ed25519 private key in PKCS#8 format +# PEM bodies for large RSA keys (8192+) exceed 4K; keep a generous cap for sanity. +KEY_SIZE_MAX = 4000 +PEM_TEXT_MAX = 256 * 1024 + + +def _normalize_typed_field_label(label): + # type: (Any) -> str + if not label or not isinstance(label, str): + return '' + return ''.join(c.lower() for c in label if c.isalnum()) + + +# Vault/PAM pamUser often stores PEM in a secret field labeled privatePEMKey (or similar). +_PEM_SECRET_FIELD_LABELS = frozenset({ + 'privatepemkey', + 'sshprivatekey', + 'sshkeypem', +}) + + +def _coerce_str_field_value(value): + # type: (Any) -> Optional[str] + if value is None: + return None + if isinstance(value, bytes): + try: + return value.decode('utf-8') + except Exception: + return None + if isinstance(value, str): + return value + return None + + +def is_valid_key_value(value): + return isinstance(value, str) and KEY_SIZE_MIN <= len(value) < KEY_SIZE_MAX + + +def _is_plausible_pem_private_key_blob(text): + # type: (Optional[str]) -> bool + text = _coerce_str_field_value(text) + if not text: + return False + text = text.strip() + if len(text) < KEY_SIZE_MIN or len(text) > PEM_TEXT_MAX: + return False + header, _, _ = text.partition('\n') + return bool(is_private_key(header)) + + +def is_valid_key_file(file): + try: + return KEY_SIZE_MIN <= file.size < PEM_TEXT_MAX + except: + return False + +def try_extract_private_key(params, record_or_uid): + # type: (KeeperParams, Union[str, vault.KeeperRecord]) -> Optional[Tuple[str, str]] + if isinstance(record_or_uid, _KeeperRecord): + record = record_or_uid + elif isinstance(record_or_uid, str): + record = vault.KeeperRecord.load(params, record_or_uid) + if not record: + return + else: + return + + private_key = '' + passphrase = '' + + # check keyPair field + if isinstance(record, _TypedRecord): + key_field = record.get_typed_field('keyPair') + if key_field: + key_pair = key_field.get_default_value(value_type=dict) + if key_pair: + private_key = key_pair.get('privateKey') + + # Explicit PEM secret fields (pamUser template: type secret, label privatePEMKey, etc.) + if not private_key and isinstance(record, _TypedRecord): + for fld in itertools.chain(record.fields, record.custom): + if _normalize_typed_field_label(getattr(fld, 'label', None)) not in _PEM_SECRET_FIELD_LABELS: + continue + candidate = _coerce_str_field_value(fld.get_default_value()) + if _is_plausible_pem_private_key_blob(candidate): + private_key = candidate.strip() + break + + # check notes field + if not private_key: + if isinstance(record, (_PasswordRecord, _TypedRecord)): + notes = getattr(record, 'notes', None) + if _is_plausible_pem_private_key_blob(notes): + private_key = notes.strip() + + # check typed fields / custom (text, multiline, secret, note) + if not private_key: + if isinstance(record, _TypedRecord): + for x in itertools.chain(record.fields, record.custom): + if x.type not in ('text', 'multiline', 'secret', 'note'): + continue + candidate = _coerce_str_field_value(x.get_default_value()) + if _is_plausible_pem_private_key_blob(candidate): + private_key = candidate.strip() + break + elif isinstance(record, _PasswordRecord): + for cf in record.custom: + if not cf.value: + continue + candidate = _coerce_str_field_value(cf.value[0] if isinstance(cf.value, list) and cf.value else cf.value) + if _is_plausible_pem_private_key_blob(candidate): + private_key = candidate.strip() + break + + # check for a single attachment + if not private_key: + download_rq = None + if isinstance(record, _TypedRecord): + file_refs = record.get_typed_field('fileRef') + if file_refs and isinstance(file_refs.value, list): + key_file_uids = [] + for file_uid in file_refs.value: + file_record = vault.KeeperRecord.load(params, file_uid) + if isinstance(file_record, _FileRecord): + names = [file_record.title] + file_name = getattr(file_record, 'name', None) or getattr(file_record, 'file_name', None) + if file_name and file_name != file_record.title: + names.append(file_name) + if any(True for x in names if is_private_key_name(x)): + if is_valid_key_file(file_record): + key_file_uids.append(file_uid) + if len(key_file_uids) == 1: + download_rq = next(attachment.prepare_attachment_download(params.vault, key_file_uids[0]), None) + elif isinstance(record, _PasswordRecord): + key_attachment_ids = [] + if record.attachments: + for atta in record.attachments: + names = [] + if atta.title: + names.append(atta.title) + if atta.name and atta.title != atta.name: + names.append(atta.name) + if any(True for x in names if is_private_key_name(x)): + if is_valid_key_file(atta): + key_attachment_ids.append(atta.id) + if len(key_attachment_ids) == 1: + download_rq = next(attachment.prepare_attachment_download(params.vault, record.record_uid, key_attachment_ids[0]), None) + if download_rq: + try: + with io.BytesIO() as b: + download_rq.download_to_stream(b) + text = b.getvalue().decode('ascii') + header, _, _ = text.partition('\n') + if is_private_key(header): + private_key = text + except: + pass + + if isinstance(record, _PasswordRecord): + passphrase = record.password + elif isinstance(record, _TypedRecord): + password_field = record.get_typed_field('password') + if password_field: + passphrase = password_field.get_default_value(str) + + if private_key: + return private_key, passphrase diff --git a/keepercli-package/src/keepercli/helpers/tunnel/__init__.py b/keepercli-package/src/keepercli/helpers/tunnel/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/keepercli-package/src/keepercli/helpers/tunnel/tunnel_helpers.py b/keepercli-package/src/keepercli/helpers/tunnel/tunnel_helpers.py new file mode 100644 index 00000000..82a8a4b8 --- /dev/null +++ b/keepercli-package/src/keepercli/helpers/tunnel/tunnel_helpers.py @@ -0,0 +1,2949 @@ +import base64 +import enum +import json +import logging +import re +import os +import threading +import secrets +import socket +import string +import sys +import time +import ssl +import asyncio +import requests + +from keeper_secrets_manager_core.utils import string_to_bytes, bytes_to_string, url_safe_str_to_bytes +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from keeper_secrets_manager_core.utils import bytes_to_base64, base64_to_bytes +from keepersdk.proto import pam_pb2 + +from ...compat.folder_mixin import FolderMixin +from keepersdk.helpers.config_utils import configuration_controller_get +from ...commands.pam.pam_dto import GatewayAction, GatewayActionWebRTCSession +from ..router_utils import router_get_relay_access_creds, get_dag_leafs, \ + get_router_ws_url, get_router_url, router_send_action_to_gateway +from ...compat.display import bcolors +from ...commands.base import CommandError +from ..folder_utils import try_resolve_path +from keepersdk import crypto, utils +from keepersdk.authentication import endpoint as rest_api +from ...compat import api +from ...compat.constants import get_relay_host + +# Import the websockets library for async WebSocket communication +# Support both websockets 15.0.1+ (asyncio) and legacy 11.0.3 (sync) versions +try: + # Try websockets 15.0.1+ asyncio implementation first + from websockets.asyncio.client import connect as websockets_connect + from websockets.exceptions import ConnectionClosed, InvalidURI, InvalidHandshake + WEBSOCKETS_VERSION = "asyncio" + WEBSOCKETS_AVAILABLE = True +except ImportError: + try: + # Fallback to websockets 11.0.3 legacy implementation + from websockets import connect as websockets_connect + from websockets.exceptions import ConnectionClosed, InvalidURI, InvalidHandshake + WEBSOCKETS_VERSION = "legacy" + WEBSOCKETS_AVAILABLE = True + except ImportError: + WEBSOCKETS_AVAILABLE = False + websockets_connect = None + ConnectionClosed = None + WEBSOCKETS_VERSION = None + print("websockets library not available - install with: pip install websockets", file=sys.stderr) + +# Regex for SDP attribute: a=keeper-webrtc:X.Y.Z (injected by keeper-pam-webrtc-rs) +_KEEPER_WEBRTC_VERSION_RE = re.compile(r"a=keeper-webrtc:(\S+)", re.IGNORECASE) + + +def parse_keeper_webrtc_version_from_sdp(sdp): + """ + Parse keeper-pam-webrtc-rs version from SDP attribute a=keeper-webrtc:X.Y.Z. + + The attribute is injected by the Rust module in both offer and answer. + Handles SDP that may be base64-encoded. + + Args: + sdp: SDP string (plain or base64-encoded). + + Returns: + Version string (e.g. "2.1.4") or None if not found. + """ + if not sdp or not isinstance(sdp, str): + return None + text = sdp + if "\n" not in sdp and "\r" not in sdp and len(sdp) > 20: + try: + decoded = base64.b64decode(sdp, validate=True) + text = decoded.decode("utf-8", errors="replace") + except Exception: + pass + m = _KEEPER_WEBRTC_VERSION_RE.search(text) + return m.group(1) if m else None + + +def set_remote_description_and_parse_version(tube_registry, tube_id, sdp, is_answer): + """ + Call tube_registry.set_remote_description and parse/store remote keeper-pam-webrtc + version when is_answer=True. Ensures version is always parsed regardless of which + code path delivered the SDP (WebSocket, HTTP, different JSON keys). + Returns the parsed version or None. + """ + tube_registry.set_remote_description(tube_id, sdp, is_answer=is_answer) + remote_ver = None + if is_answer: + remote_ver = parse_keeper_webrtc_version_from_sdp(sdp) + session = get_tunnel_session(tube_id) + if session and remote_ver: + session.remote_webrtc_version = remote_ver + logging.debug("Remote keeper-pam-webrtc version from SDP: %s", remote_ver) + return remote_ver + + +def _version_at_least(version, min_version): + """ + Compare semantic versions. Returns True if `version` >= `min_version`. + + `version` of None or unparseable is treated as unknown/old (False). + """ + if not version: + return False + + def parse(v): + parts = [] + for p in v.split(".")[:3]: + try: + parts.append(int(p)) + except ValueError: + parts.append(0) + while len(parts) < 3: + parts.append(0) + return tuple(parts[:3]) + + try: + return parse(version) >= parse(min_version) + except Exception: + return False + + +def print_above_keeper_prompt(msg): + """Print ``msg`` so the keeper-shell prompt redraws itself underneath it. + + Strategy: + 1. If a prompt-toolkit app is running, call ``app.renderer.erase()`` — + this writes the ANSI sequences to fully erase the prompt area + (which may span multiple lines), leaving a clean cursor. + 2. Print the message + newline so the cursor advances below. + 3. Call ``app.invalidate()`` (thread-safe) to schedule a fresh prompt + render at the new cursor position. + + Falls back to plain ``print`` if no app is running. Avoids + ``run_in_terminal`` (returns a coroutine that needs to be awaited on + the app's event loop; scheduling that from a Timer thread is + version-fragile and leaks un-awaited coroutines). + """ + app = None + try: + from prompt_toolkit.application.current import get_app_or_none + app = get_app_or_none() + if app is not None and app.is_running: + try: + app.renderer.erase() + except Exception: + pass + except Exception: + app = None + + sys.stdout.write(msg + '\n') + sys.stdout.flush() + + if app is not None: + try: + app.invalidate() + except Exception: + pass + + +# Constants +NONCE_LENGTH = 12 +MAIN_NONCE_LENGTH = 16 +SYMMETRIC_KEY_LENGTH = RANDOM_LENGTH = 32 +READ_TIMEOUT = 1.5 +KRELAY_URL = 'KRELAY_SERVER' +GATEWAY_TIMEOUT = int(os.getenv('GATEWAY_TIMEOUT')) if os.getenv('GATEWAY_TIMEOUT') else 30000 +# VERIFY_SSL applies to WebSocket SSL only; HTTP uses params.ssl_verify. +VERIFY_SSL = bool(os.environ.get("VERIFY_SSL", "TRUE") == "TRUE") + + +def _find_pam_config_records(params): + """Scan vault for PAM configuration records (version 6).""" + if params.vault is None: + return + for info in params.vault.vault_data.find_records(record_type='pamConfiguration', record_version=6): + kr = params.vault.vault_data.load_record(info.record_uid) + if kr is not None: + yield kr + +# ICE candidate buffering - store until SDP answer is received + +# Global conversation key management for multiple concurrent tunnels +_CONVERSATION_KEYS_LOCK = threading.Lock() +_GLOBAL_CONVERSATION_KEYS = {} # conversationId -> symmetric_key mapping + +# Global tunnel session management by tube_id +_TUNNEL_SESSIONS_LOCK = threading.Lock() +_GLOBAL_TUNNEL_SESSIONS = {} # tube_id -> TunnelSession mapping + +# NOTE: Global WebSocket variables removed! Each tunnel now has its own dedicated WebSocket. +# - _ACTIVE_WEBSOCKET_THREAD removed - each tunnel has tunnel_session.websocket_thread +# - _WEBSOCKET_READY_EVENT removed - each tunnel has tunnel_session.websocket_ready_event +# - _WEBSOCKET_THREAD_LOCK removed - no longer needed with dedicated WebSockets +# - _WEBSOCKET_REGISTRATION_LOCK removed - no contention with dedicated WebSockets + + +class CloseConnectionReason: + """ + Represents a structured close reason for WebRTC tunnel connections. + Provides categorization and backward compatibility with legacy outcome strings. + """ + + # Close reason codes with their properties + REASONS = { + 0: {"name": "Normal", "critical": False, "user_initiated": True, "retryable": False}, + 1: {"name": "Error", "critical": True, "user_initiated": False, "retryable": True}, + 2: {"name": "Timeout", "critical": False, "user_initiated": False, "retryable": True}, + 4: {"name": "ServerRefuse", "critical": True, "user_initiated": False, "retryable": True}, + 5: {"name": "Client", "critical": False, "user_initiated": True, "retryable": False}, + 6: {"name": "Unknown", "critical": False, "user_initiated": False, "retryable": False}, + 7: {"name": "InvalidInstruction", "critical": True, "user_initiated": False, "retryable": False}, + 8: {"name": "GuacdRefuse", "critical": True, "user_initiated": False, "retryable": True}, + 9: {"name": "ConnectionLost", "critical": False, "user_initiated": False, "retryable": True}, + 10: {"name": "ConnectionFailed", "critical": True, "user_initiated": False, "retryable": True}, + 11: {"name": "TunnelClosed", "critical": False, "user_initiated": True, "retryable": False}, + 12: {"name": "AdminClosed", "critical": False, "user_initiated": True, "retryable": False}, + 13: {"name": "ErrorRecording", "critical": True, "user_initiated": False, "retryable": False}, + 14: {"name": "GuacdError", "critical": True, "user_initiated": False, "retryable": False}, + 15: {"name": "AIClosed", "critical": False, "user_initiated": False, "retryable": False}, + 16: {"name": "AddressResolutionFailed", "critical": True, "user_initiated": False, "retryable": True}, + 17: {"name": "DecryptionFailed", "critical": True, "user_initiated": False, "retryable": False}, + 18: {"name": "ConfigurationError", "critical": True, "user_initiated": False, "retryable": False}, + 19: {"name": "ProtocolError", "critical": True, "user_initiated": False, "retryable": False}, + 20: {"name": "UpstreamClosed", "critical": False, "user_initiated": False, "retryable": True}, + } + + # Legacy outcome mapping for backward compatibility + LEGACY_OUTCOMES = { + "normal": 0, + "success": 0, + "completed": 0, + "tube_closed": 0, # User-initiated tube closure (pam tunnel stop) + "error": 1, + "failed": 1, + "failure": 1, + "timeout": 2, + "timed_out": 2, + "server_refuse": 4, + "server_refused": 4, + "client": 5, + "client_closed": 5, + "user_closed": 5, + "unknown": 6, + "invalid_instruction": 7, + "guacd_refuse": 8, + "connection_lost": 9, + "connection_failed": 10, + "tunnel_closed": 11, + "admin_closed": 12, + "error_recording": 13, + "recording_error": 13, + "guacd_error": 14, + "ai_closed": 15, + "address_resolution_failed": 16, + "dns_failed": 16, + "decryption_failed": 17, + "configuration_error": 18, + "config_error": 18, + "protocol_error": 19, + "upstream_closed": 20, + } + + def __init__(self, code, name=None): + self.code = code + self._reason_info = self.REASONS.get(code, self.REASONS[6]) # Default to Unknown + self.name = name or self._reason_info["name"] + + @classmethod + def from_code(cls, code): + """Create CloseConnectionReason from numeric code""" + if code in cls.REASONS: + return cls(code) + else: + logging.warning(f"Unknown close reason code: {code}, defaulting to Unknown") + return cls(6) # Unknown + + @classmethod + def from_legacy_outcome(cls, outcome): + """Create CloseConnectionReason from legacy outcome string""" + if not outcome or not isinstance(outcome, str): + return cls(6) # Unknown + + # Try direct mapping first + outcome_lower = outcome.lower().strip() + code = cls.LEGACY_OUTCOMES.get(outcome_lower) + + if code is not None: + return cls(code) + + # Try partial matching for common variations + for legacy_key, legacy_code in cls.LEGACY_OUTCOMES.items(): + if legacy_key in outcome_lower or outcome_lower in legacy_key: + return cls(legacy_code) + + # Default to Unknown + logging.warning(f"Unknown legacy outcome: '{outcome}', defaulting to Unknown") + return cls(6) + + def is_critical(self): + """Returns True if this is a critical failure requiring immediate attention""" + return self._reason_info["critical"] + + def is_user_initiated(self): + """Returns True if this was initiated by user action""" + return self._reason_info["user_initiated"] + + def is_retryable(self): + """Returns True if this failure is potentially retryable""" + return self._reason_info["retryable"] + +class TunnelSession: + """Container for tunnel session state organized by tube_id""" + def __init__(self, tube_id, conversation_id, gateway_uid, symmetric_key, + offer_sent=False, host=None, port=None, + record_title=None, record_uid=None, target_host=None, target_port=None): + self.tube_id = tube_id + self.conversation_id = conversation_id + self.gateway_uid = gateway_uid + self.symmetric_key = symmetric_key + self.offer_sent = offer_sent + self.host = host + self.port = port + self.record_title = record_title + self.record_uid = record_uid + self.target_host = target_host + self.target_port = target_port + self.buffered_ice_candidates = [] + self.creation_time = time.time() + self.last_activity = time.time() + # Per-tunnel WebSocket management + self.websocket_thread = None + self.websocket_ready_event = None + self.websocket_stop_event = None + # Optional attributes (set dynamically) + # Note: signal_handler is set after TunnelSignalHandler is created + self.signal_handler = None # type: ignore[assignment] + + def update_activity(self): + """Update last activity timestamp""" + self.last_activity = time.time() + +def register_tunnel_session(tube_id, session): + """Register a tunnel session by tube_id (thread-safe)""" + with _TUNNEL_SESSIONS_LOCK: + _GLOBAL_TUNNEL_SESSIONS[tube_id] = session + logging.debug(f"Registered tunnel session for tube: {tube_id}") + logging.debug(f"Total active tunnel sessions: {len(_GLOBAL_TUNNEL_SESSIONS)}") + +def get_tunnel_session(tube_id): + """Get a tunnel session by tube_id (thread-safe)""" + with _TUNNEL_SESSIONS_LOCK: + return _GLOBAL_TUNNEL_SESSIONS.get(tube_id) + +def unregister_tunnel_session(tube_id): + """Remove a tunnel session by tube_id (thread-safe)""" + with _TUNNEL_SESSIONS_LOCK: + if tube_id in _GLOBAL_TUNNEL_SESSIONS: + session = _GLOBAL_TUNNEL_SESSIONS[tube_id] + del _GLOBAL_TUNNEL_SESSIONS[tube_id] + logging.debug(f"Unregistered tunnel session for tube: {tube_id}") + logging.debug(f"Remaining active tunnel sessions: {len(_GLOBAL_TUNNEL_SESSIONS)}") + return session + return None + +def get_all_tunnel_sessions(): + """Get all active tunnel sessions (thread-safe)""" + with _TUNNEL_SESSIONS_LOCK: + return dict(_GLOBAL_TUNNEL_SESSIONS) + +def clear_all_tunnel_sessions(): + """Clear all tunnel sessions (thread-safe)""" + with _TUNNEL_SESSIONS_LOCK: + count = len(_GLOBAL_TUNNEL_SESSIONS) + _GLOBAL_TUNNEL_SESSIONS.clear() + logging.debug(f"Cleared {count} tunnel sessions") + +def register_conversation_key(conversation_id, symmetric_key): + """Register an encryption key for a conversation ID (thread-safe)""" + with _CONVERSATION_KEYS_LOCK: + _GLOBAL_CONVERSATION_KEYS[conversation_id] = symmetric_key + logging.debug(f"Registered conversation key for: {conversation_id}") + logging.debug(f"Total registered conversations: {len(_GLOBAL_CONVERSATION_KEYS)}") + +def unregister_conversation_key(conversation_id): + """Remove an encryption key for a conversation ID (thread-safe)""" + with _CONVERSATION_KEYS_LOCK: + if conversation_id in _GLOBAL_CONVERSATION_KEYS: + del _GLOBAL_CONVERSATION_KEYS[conversation_id] + logging.debug(f"Unregistered conversation key for: {conversation_id}") + logging.debug(f"Remaining registered conversations: {len(_GLOBAL_CONVERSATION_KEYS)}") + +def get_conversation_key(conversation_id): + """Get an encryption key for a conversation ID (thread-safe)""" + with _CONVERSATION_KEYS_LOCK: + return _GLOBAL_CONVERSATION_KEYS.get(conversation_id) + +def get_all_conversation_ids(): + """Get all registered conversation IDs (thread-safe)""" + with _CONVERSATION_KEYS_LOCK: + return list(_GLOBAL_CONVERSATION_KEYS.keys()) + +def clear_all_conversation_keys(): + """Clear all conversation keys (thread-safe)""" + with _CONVERSATION_KEYS_LOCK: + count = len(_GLOBAL_CONVERSATION_KEYS) + _GLOBAL_CONVERSATION_KEYS.clear() + logging.debug(f"Cleared {count} conversation keys") + +def get_conversation_status(): + """Get current conversation key status for debugging (thread-safe)""" + with _CONVERSATION_KEYS_LOCK: + active_conversations = len(_GLOBAL_CONVERSATION_KEYS) + conversation_ids = list(_GLOBAL_CONVERSATION_KEYS.keys()) + + # Get tunnel session info to count active WebSockets + with _TUNNEL_SESSIONS_LOCK: + active_websockets = sum(1 for session in _GLOBAL_TUNNEL_SESSIONS.values() + if session.websocket_thread and session.websocket_thread.is_alive()) + total_tunnels = len(_GLOBAL_TUNNEL_SESSIONS) + + return { + "active_conversations": active_conversations, + "conversation_ids": conversation_ids, + "active_websockets": active_websockets, + "total_tunnels": total_tunnels + } + + +# Tunnel helper functions +def _configure_rust_logger_levels(current_is_debug: bool, log_level: int): + """ + Configure Rust logger levels based on debug mode. + + Args: + current_is_debug: Whether debug mode is currently enabled + log_level: Current effective log level + """ + # Quick Fix: switch only between ERROR and DEBUG + # RCA: Commander has 2 modes only DEBUG and non-debug (default) + # yet all rust log messages are always printed incl. DEBUG messages when non-debug mode is set + + # Configure Rust logger level based on debug mode + if current_is_debug or log_level <= logging.DEBUG: + root_logger = logging.getLogger() + # Ensure root logger can handle DEBUG messages + if root_logger.level > logging.DEBUG: + root_logger.setLevel(logging.DEBUG) + + # CRITICAL: Ensure root logger has a handler + # pyo3_log sends Rust logs to Python loggers, but if loggers have no handlers, + # messages are lost even if propagate=True + if not root_logger.handlers: + # Add a console handler if none exists + console_handler = logging.StreamHandler(sys.stderr) + console_handler.setFormatter(logging.Formatter( + '%(levelname)s:%(name)s:%(message)s' + )) + console_handler.setLevel(logging.DEBUG) + root_logger.addHandler(console_handler) + + # Set up a custom logger factory that adds handlers to all Rust loggers + # This ensures handlers are added even if loggers are created dynamically + original_logger_class = logging.getLoggerClass() + + class RustLoggerHandler(logging.Logger): + """Custom logger that auto-adds handlers for Rust loggers""" + def __init__(self, name, level=logging.NOTSET): + super().__init__(name, level) + if name.startswith('keeper_pam_webrtc_rs'): + self.setLevel(logging.DEBUG) + self.propagate = False # Disable propagation to prevent duplicate logs + if not self.handlers: + handler = logging.StreamHandler(sys.stderr) + handler.setFormatter(logging.Formatter( + '%(levelname)s:%(name)s:%(message)s' + )) + handler.setLevel(logging.DEBUG) + self.addHandler(handler) + + # Temporarily set our custom logger class + logging.setLoggerClass(RustLoggerHandler) + + # Now set up all existing loggers (disable propagation to prevent duplicates) + for logger_name in list(logging.Logger.manager.loggerDict.keys()): + if isinstance(logger_name, str) and logger_name.startswith('keeper_pam_webrtc_rs'): + rust_logger = logging.getLogger(logger_name) + rust_logger.setLevel(logging.DEBUG) + rust_logger.propagate = False # Disable propagation to prevent duplicate logs + if not rust_logger.handlers: + handler = logging.StreamHandler(sys.stderr) + handler.setFormatter(logging.Formatter( + '%(levelname)s:%(name)s:%(message)s' + )) + handler.setLevel(logging.DEBUG) + rust_logger.addHandler(handler) + + # pyo3_log creates loggers based on Rust module paths + tube_registry_logger = logging.getLogger("keeper_pam_webrtc_rs.python.tube_registry_binding") + tube_registry_logger.setLevel(logging.DEBUG) + tube_registry_logger.propagate = False # Disable propagation to prevent duplicate logs + if not tube_registry_logger.handlers: + handler = logging.StreamHandler(sys.stderr) + handler.setFormatter(logging.Formatter( + '%(levelname)s:%(name)s:%(message)s' + )) + handler.setLevel(logging.DEBUG) + tube_registry_logger.addHandler(handler) + + # Restore original logger class + logging.setLoggerClass(original_logger_class) + + # Suppress noisy webrtc dependency logs even in debug mode (set to WARNING) + webrtc_crates = [ + 'webrtc', 'webrtc_ice', 'webrtc_mdns', 'webrtc_dtls', + 'webrtc_sctp', 'turn', 'stun', 'webrtc_ice.agent.agent_internal', + 'webrtc_ice.agent.agent_gather', 'webrtc_ice.mdns', + 'webrtc_mdns.conn', 'webrtc.peer_connection', 'turn.client', + 'turn.client.relay_conn', + ] + for crate_name in webrtc_crates: + crate_logger = logging.getLogger(crate_name) + crate_logger.setLevel(logging.WARNING) + crate_logger.propagate = False + if not crate_logger.handlers: + crate_logger.addHandler(logging.NullHandler()) + + # Suppress specific noisy keeper_pam_webrtc_rs sub-modules even in debug mode + # These log debug info at ERROR level, so we need to disable them entirely + noisy_keeper_loggers = [ + 'keeper_pam_webrtc_rs.channel.core', + 'keeper_pam_webrtc_rs.python.utils' + ] + for logger_name in noisy_keeper_loggers: + noisy_logger = logging.getLogger(logger_name) + noisy_logger.setLevel(logging.CRITICAL + 1) # Disable completely + noisy_logger.propagate = False + if not noisy_logger.handlers: + noisy_logger.addHandler(logging.NullHandler()) + + logging.debug(f"Rust loggers enabled at DEBUG level") + enabled_loggers = [name for name in logging.Logger.manager.loggerDict.keys() + if isinstance(name, str) and name.startswith('keeper_pam_webrtc_rs')] + logging.debug(f"Enabled Rust loggers: {enabled_loggers}") + else: + # Set to ERROR when not debugging + main_rust_logger = logging.getLogger("keeper_pam_webrtc_rs") + main_rust_logger.setLevel(logging.ERROR) + + # Also set all existing Rust sub-loggers to ERROR + for logger_name in list(logging.Logger.manager.loggerDict.keys()): + if isinstance(logger_name, str) and logger_name.startswith('keeper_pam_webrtc_rs'): + logger = logging.getLogger(logger_name) + logger.setLevel(logging.ERROR) + + # Completely suppress specific noisy keeper_pam_webrtc_rs sub-modules + # These log debug info at ERROR level, so we need to disable them entirely + suppress_completely = [ + 'keeper_pam_webrtc_rs.channel.core', + 'keeper_pam_webrtc_rs.python.utils' + ] + for logger_name in suppress_completely: + suppress_logger = logging.getLogger(logger_name) + suppress_logger.setLevel(logging.CRITICAL + 1) # Disable completely + suppress_logger.propagate = False + if not suppress_logger.handlers: + suppress_logger.addHandler(logging.NullHandler()) + + # Suppress noisy webrtc dependency logs when not debugging + webrtc_crates = [ + 'webrtc', 'webrtc_ice', 'webrtc_mdns', 'webrtc_dtls', + 'webrtc_sctp', 'turn', 'stun', 'webrtc_ice.agent.agent_internal', + 'webrtc_ice.agent.agent_gather', 'webrtc_ice.mdns', + 'webrtc_mdns.conn', 'webrtc.peer_connection', 'turn.client', + 'turn.client.relay_conn', + ] + for crate_name in webrtc_crates: + crate_logger = logging.getLogger(crate_name) + crate_logger.setLevel(logging.ERROR) + crate_logger.propagate = False + if not crate_logger.handlers: + crate_logger.addHandler(logging.NullHandler()) + + +def get_or_create_tube_registry(params): + """Get or create the tube registry instance, storing it on params for reuse""" + try: + from keeper_pam_webrtc_rs import PyTubeRegistry, initialize_logger + + # Initialize logger (Rust lib handles re-initialization gracefully) + # Match current debug state (not just initial --debug flag) + current_is_debug = logging.getLogger().level <= logging.DEBUG + log_level = logging.getLogger().getEffectiveLevel() + initialize_logger( + logger_name="keeper-pam-webrtc-rs", + verbose=current_is_debug, # Use current state, matches debug toggles + level=log_level + ) + + # Configure Rust logger levels based on debug mode + _configure_rust_logger_levels(current_is_debug, log_level) + + # Reuse existing registry or create new one + if not hasattr(params, 'tube_registry') or params.tube_registry is None: + params.tube_registry = PyTubeRegistry() + return params.tube_registry + except ImportError: + logging.error("Rust WebRTC library (keeper_pam_webrtc_rs) not available") + return None + except Exception as e: + logging.error(f"Failed to create tube registry: {e}") + return None + + +def cleanup_tube_registry(params): + """Clean up the tube registry and all active tubes""" + if hasattr(params, 'tube_registry') and params.tube_registry is not None: + try: + params.tube_registry.cleanup_all() + params.tube_registry = None + except Exception as e: + logging.warning(f"Error cleaning up tube registry: {e}") + + # Also clear all conversation keys when cleaning up everything + clear_all_conversation_keys() + + +class SocketNotConnectedException(Exception): + pass + + +class CloseConnectionReasons(enum.IntEnum): + Normal = 0 + Error = 1 + Timeout = 2 + ServerRefuse = 4 + Client = 5 + Unknown = 6 + InvalidInstruction = 7 + GuacdRefuse = 8 + ConnectionLost = 9 + ConnectionFailed = 10 + TunnelClosed = 11 + AdminClosed = 12 + ErrorRecording = 13 + GuacdError = 14 + AIClosed = 15 + AddressResolutionFailed = 16 + DecryptionFailed = 17 + ConfigurationError = 18 + ProtocolError = 19 + UpstreamClosed = 20 + + +class ConversationType(enum.Enum): + TUNNEL = "tunnel" + SSH = "ssh" + RDP = "rdp" + VNC = "vnc" + HTTP = "http" + KUBERNETES = "kubernetes" + TELNET = "telnet" + MYSQL = "mysql" + SQLSERVER = "sql-server" + POSTGRESQL = "postgresql" + + +def generate_random_bytes(pass_length=RANDOM_LENGTH): # type: (int) -> bytes + # Generate random bytes without worrying about character decoding + random_bytes = secrets.token_bytes(pass_length) + + # Filter out non-printable bytes using a list comprehension + printable_bytes = [byte for byte in random_bytes if + byte in string.printable.encode('utf-8') and byte not in b'\n\r'] + + # Convert the list of bytes back to bytes + filtered_bytes = bytes(printable_bytes) + if len(filtered_bytes) < pass_length: + # If the length of the filtered bytes is less than the requested length, call the function recursively + # to generate more bytes + return filtered_bytes + generate_random_bytes(pass_length - len(filtered_bytes)) + + return filtered_bytes + + +def find_open_port(tried_ports: list, start_port=49152, end_port=65535, preferred_port=None, host="127.0.0.1"): + """ + Find an open port in the range [start_port, end_port]. + The default range is from 49,152 to 65,535, which are the "ephemeral ports" or "dynamic ports." + :param tried_ports: A list of ports that have already been tried. + :param start_port: The starting port number. + :param end_port: The ending port number. + :param preferred_port: A preferred port to check first. + :param host: The host to check for open ports. + :return: An open port number or None if no port is found. + """ + if host is None: + host = '127.0.0.1' + if preferred_port and preferred_port not in tried_ports: + if is_port_open(host, preferred_port): + time.sleep(0.1) # Short delay to ensure port release + return preferred_port + else: + raise CommandError("Tunnel Start", f"Port {preferred_port} is already in use.") + + for port in range(start_port, end_port + 1): + if port not in tried_ports and is_port_open(host, port): + time.sleep(0.1) # Short delay to ensure port release + return port + + return None + + +def is_port_open(host: str, port: int) -> bool: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + try: + s.bind((host, port)) + return True + except OSError: + return False + except Exception as e: + import logging + logging.error(f"Error while checking port {port}: {e}") + return False + + +def tunnel_encrypt(symmetric_key: AESGCM, data: bytes): + """ Encrypts data using the symmetric key """ + nonce = os.urandom(NONCE_LENGTH) # 12-byte nonce for AES-GCM + encrypted_data = symmetric_key.encrypt(nonce, data, None) + return bytes_to_base64(nonce + encrypted_data) + + +def tunnel_decrypt(symmetric_key: AESGCM, encrypted_data: str): + """ Decrypts data using the symmetric key """ + mixed_data = base64_to_bytes(encrypted_data) + + if len(mixed_data) <= NONCE_LENGTH: + return None + nonce = mixed_data[:NONCE_LENGTH] + encrypted_data = mixed_data[NONCE_LENGTH:] + + try: + return symmetric_key.decrypt(nonce, encrypted_data, None) + except Exception as e: + import logging + logging.error(f'Error decrypting data: {e}') + return None + + +def get_config_uid_from_local_vault(params, record_uid): + """ + Resolve resource_uid -> config_uid by scanning the user's local PAM + Configuration records (record_version=6) for one whose `pamResources.resourceRef` + list contains `record_uid`. Returns the config record_uid (str) on match, or + None if no local config owns this resource. + + Web Vault parity: zero network, no gateway required. Used as the primary + lookup in `get_config_uid`; the legacy `get_dag_leafs` path remains as a + fallback (e.g. vault not yet synced). + """ + if not record_uid: + return None + try: + from ...compat import vault + from keepersdk.vault import vault_extensions + except Exception as e: + logging.debug('local-vault config scan: imports failed: %s', e) + return None + try: + for kr in _find_pam_config_records(params): + if not isinstance(kr, vault.TypedRecord): + continue + try: + field = kr.get_typed_field('pamResources') + if not field: + continue + value = field.get_default_value(dict) + if not value: + continue + refs = value.get('resourceRef') or [] + if record_uid in refs: + return kr.record_uid + except Exception as e: + logging.debug('local-vault config scan: skipping record %s: %s', + getattr(kr, 'record_uid', '?'), e) + continue + except Exception as e: + logging.debug('local-vault config scan failed: %s', e) + return None + + +def get_config_uid(params, encrypted_session_token, encrypted_transmission_key, record_uid): + # Tier 1: local vault scan (fastest, no network) — Web Vault parity. + local_config_uid = get_config_uid_from_local_vault(params, record_uid) + if local_config_uid: + return local_config_uid + + # Tier 2: krouter per-graph PAM_LINK get_leafs (server-side, no gateway). + # Precise single-owner resolution — the Web Vault uses the same call. The + # legacy graphId=0 lookup below can return a stale/duplicate config when a + # resource still carries link edges under more than one PAM config; loading + # that graph then raises DAGPathException ("Found multiple vertex that use + # the path") on the next get_vertex. Resolving the owner precisely here + # avoids it. Mirrors the dag-api-migration resolution order. + link_config_uid = get_config_uid_via_pam_link(params, record_uid) + if link_config_uid: + return link_config_uid + + # Tier 3: legacy gateway-mediated lookup via the old `/api/user/get_leafs`. + try: + rs = get_dag_leafs(params, encrypted_session_token, encrypted_transmission_key, record_uid) + # response: "[{\"type\":\"rec\",\"value\":\"Jagbt2dxrft_91FovB5dwg\",\"name\":null}]" + if not rs: + return None + else: + return rs[0].get('value', '') + except Exception as e: + print(f"{bcolors.FAIL}Error getting configuration: {e}{bcolors.ENDC}") + return None + + +def get_config_uid_via_pam_link(params, record_uid): + """Resolve a resource record's PAM Config UID via KRouter's PAM_LINK graph. + + Returns the config_uid that owns the resource. Used as a fallback when + ``get_config_uid`` (legacy ``/api/user/get_leafs`` with graphId=0) returns + nothing for resources whose link lives only in the new PAM_LINK stream. + + Returns the config_uid as a base64-url-safe string, or empty string on + failure / no link. + """ + try: + from keepersdk.proto import GraphSync_pb2 as gs_pb2 + from ..router_utils import _post_request_to_router_for_params as _post_request_to_router + record_uid_bytes = url_safe_str_to_bytes(record_uid) + rq = gs_pb2.GraphSyncLeafsQuery(vertices=[record_uid_bytes]) + rs = _post_request_to_router(params, 'graph-sync/pam/get_leafs', + rq_proto=rq, rs_type=gs_pb2.GraphSyncRefsResult) + if rs and rs.refs: + for ref in rs.refs: + if ref.value: + return utils.base64_url_encode(ref.value) + except Exception as e: + logging.debug('get_config_uid_via_pam_link: lookup failed for %s: %s', record_uid, e) + return '' + + +def get_keeper_tokens(params): + transmission_key = generate_random_bytes(32) + server_public_key = rest_api.SERVER_PUBLIC_KEYS[params.rest_context.server_key_id] + + if params.rest_context.server_key_id < 7: + encrypted_transmission_key = crypto.encrypt_rsa(transmission_key, server_public_key) + else: + encrypted_transmission_key = crypto.encrypt_ec(transmission_key, server_public_key) + encrypted_session_token = crypto.encrypt_aes_v2( + utils.base64_url_decode(params.session_token), transmission_key) + + return encrypted_session_token, encrypted_transmission_key, transmission_key + + +def get_config_uid_from_record(params, vault, record_uid): + record = vault.KeeperRecord.load(params, record_uid) + if record is None: + raise CommandError('', f"{bcolors.FAIL}Record {record_uid} not found.{bcolors.ENDC}") + if not isinstance(record, vault.TypedRecord): + raise CommandError('', f"{bcolors.FAIL}Record {record_uid} is not v3/typed record.{bcolors.ENDC}") + record_type = record.record_type + if record_type not in "pamMachine pamDatabase pamDirectory pamRemoteBrowser".split(): + raise CommandError('', f"{bcolors.FAIL}This record's type is not supported for tunnels. " + f"Tunnels are only supported on pamMachine, pamDatabase, pamDirectory, " + f"and pamRemoteBrowser records{bcolors.ENDC}") + + encrypted_session_token, encrypted_transmission_key, transmission_key = get_keeper_tokens(params) + existing_config_uid = get_config_uid(params, encrypted_session_token, encrypted_transmission_key, record_uid) + return existing_config_uid + + +def get_gateway_uid_from_record(params, vault, record_uid): + """Resolve gateway UID for a PAM resource record (pamMachine, etc.). + + Lookup flow: record_uid -> PAM_LINK DAG -> config_uid -> gateway (controller) + Gateway is read from config record's pamResources.controllerUid; if missing, + falls back to pam/get_configuration_controller API + """ + gateway_uid = '' + pam_config_uid = get_config_uid_from_record(params, vault, record_uid) + if pam_config_uid: + record = vault.KeeperRecord.load(params, pam_config_uid) + if record: + field = record.get_typed_field('pamResources') + value = field.get_default_value(dict) + if value: + gateway_uid = value.get('controllerUid', '') or '' + + # Fallback: ask server for controller when config record has no local controllerUid + if not gateway_uid: + try: + config_uid_bytes = url_safe_str_to_bytes(pam_config_uid) + controller = configuration_controller_get(params, config_uid_bytes) + if controller and controller.controllerUid: + gateway_uid = utils.base64_url_encode(controller.controllerUid) + except Exception as e: + logging.debug('get_gateway_uid_from_record: get_configuration_controller fallback failed: %s', e) + + return gateway_uid + + +def create_rust_webrtc_settings(params, host, port, target_host, target_port, socks, nonce, ): + """Create WebRTC settings for the Rust implementation""" + # Get relay server configuration + relay_url = get_relay_host(params.server) + + response = router_get_relay_access_creds(params=params, expire_sec=60000000) + if response is None: + raise CommandError('Tunnel Start', 'Error getting relay access credentials') + + return { + "turn_only": False, + "relay_url": relay_url, + "stun_url": f"stun:{relay_url}:3478", + "turn_url": f"turn:{relay_url}:3478", + "turn_username": response.username, + "turn_password": response.password, + "conversationType": "tunnel", + "local_listen_addr": f"{host}:{port}", + "target_host": target_host, + "target_port": target_port, + "socks_mode": socks, + "callback_token": bytes_to_base64(nonce) + } + + +def remove_field(record, field): # type: (vault.TypedRecord, vault.TypedField) -> bool + # Since TypedRecord.get_typed_field scans both fields[] and custom[] + # we need corresponding remove field lookup + fld = next((x for x in record.fields if field.type == x.type and + (not field.label or + (x.label and field.label.casefold() == x.label.casefold()))), None) + if fld is not None: + record.fields.remove(field) + return True + + fld = next((x for x in record.custom if field.type == x.type and + (not field.label or + (x.label and field.label.casefold() == x.label.casefold()))), None) + if fld is not None: + record.custom.remove(field) + return True + + return False + +def resolve_record(params, name): + record_uid = None + if name in params.record_cache: + record_uid = name # unique record UID + else: + # lookup unique folder/record path + rs = try_resolve_path(params, name) + if rs is not None: + folder, name = rs + if folder is not None and name is not None: + folder_uid = folder.uid or '' + if folder_uid in params.subfolder_record_cache: + for uid in params.subfolder_record_cache[folder_uid]: + r = api.get_record(params, uid) + if r.title.lower() == name.lower(): + record_uid = uid + break + if not record_uid: + # lookup unique record title + records = [] + for uid in params.record_cache: + data_json = params.record_cache[uid].get("data_unencrypted", "{}") or {} + data = json.loads(data_json) + if "pamMachine" == str(data.get("type", "")): + title = data.get('title', '') or '' + if title.lower() == name.lower(): + records.append(uid) + uniq_recs = len(set(records)) + if uniq_recs > 1: + print(f"{bcolors.FAIL}Multiple PAM Machine records match title '{name}' - " + f"specify unique record path/name.{bcolors.ENDC}") + elif records: + record_uid = records[0] + return record_uid + +def resolve_folder(params, name): + folder_uid = '' + if name: + # lookup unique folder path + folder_uid = FolderMixin.resolve_folder(params, name) + # lookup unique folder name/uid + if not folder_uid and name != '/': + folders = [] + for fkey in params.subfolder_cache: + data_json = params.subfolder_cache[fkey].get('data_unencrypted', '{}') or {} + data = json.loads(data_json) + fname = data.get('name', '') or '' + if fname == name: + folders.append(fkey) + uniq_items = len(set(folders)) + if uniq_items > 1: + print(f"{bcolors.FAIL}Multiple folders match '{name}' - specify unique " + f"folder name or use folder UID (or omit --folder parameter to create " + f"PAM User record in same folder as PAM Machine record).{bcolors.ENDC}") + folders = [] + folder_uid = folders[0] if folders else '' + return folder_uid + +def resolve_pam_config(params, record_uid, pam_config_option): + # PAM Config lookup - Legacy PAM Machine will have associated PAM Config + # only if it is set up for rotation - otherwise PAM Config must be provided + encrypted_session_token, encrypted_transmission_key, transmission_key = get_keeper_tokens(params) + pamcfg_rec = get_config_uid(params, encrypted_session_token, encrypted_transmission_key, record_uid) + if not pamcfg_rec and not pam_config_option: + print(f"{bcolors.FAIL}Unable to find PAM Config associated with record '{record_uid}' " + "- please provide PAM Config with --configuration|-c option. " + "(Note: Legacy PAM Machine is linked to PAM Config only if " + f"the machine is set up for rotation).{bcolors.ENDC}") + return None + + pamcfg_cmd = '' + if pam_config_option: + pam_uids = [] + for uid in params.record_cache: + if params.record_cache[uid].get('version', 0) == 6: + r = api.get_record(params, uid) + if r.record_uid == pam_config_option or r.title.lower() == pam_config_option.lower(): + pam_uids.append(uid) + uniq_recs = len(set(pam_uids)) + if uniq_recs > 1: + print(f"{bcolors.FAIL}Multiple PAM Config records match '{pam_config_option}' - " + f"specify unique record UID/Title.{bcolors.ENDC}") + elif pam_uids: + pamcfg_cmd = pam_uids[0] + elif not pamcfg_rec: + print(f"{bcolors.FAIL}Unable to find PAM Configuration '{pam_config_option}'.{bcolors.ENDC}") + + # PAM Config set on command line overrides the PAM Machine associated PAM Config + pam_config_uid = pamcfg_cmd or pamcfg_rec or "" + if pamcfg_cmd and pamcfg_rec and pamcfg_cmd != pamcfg_rec: + print(f"{bcolors.WARNING}PAM Config associated with record '{record_uid}' " + "is different from PAM Config set with --configuration|-c option. " + f"Using the configuration from command line option.{bcolors.ENDC}") + + return pam_config_uid + + +# Direct WebSocket handler - no stored state +async def connect_websocket_with_fallback(ws_endpoint, headers, ssl_context, tube_registry, timeout, ready_event=None, stop_event=None): + """ + Connect to WebSocket with backward compatibility for both websockets 15.0.1+ and 11.0.3 + Handles parameter name differences between versions + + Args: + ws_endpoint: WebSocket URL + headers: Connection headers + ssl_context: SSL context for secure connections + tube_registry: PyTubeRegistry instance + timeout: Maximum time to listen for messages + ready_event: threading.Event to signal when connected + stop_event: threading.Event to signal when to close + """ + # Base connection parameters that work across versions + base_kwargs = { + "ping_interval": 20, + "ping_timeout": 20, + "close_timeout": 30 + } + + if WEBSOCKETS_VERSION == "asyncio": + # websockets 15.0.1+ uses additional_headers and ssl parameters + connect_kwargs = { + **base_kwargs, + "additional_headers": headers + } + + # Try ssl_context parameter first, fallback to ssl if not supported + if ssl_context: + try: + async with websockets_connect(ws_endpoint, ssl_context=ssl_context, **connect_kwargs) as websocket: + logging.debug("WebSocket connection established with ssl_context parameter") + # Signal ready event immediately after connection + if ready_event: + ready_event.set() + logging.debug("WebSocket ready event signaled") + await handle_websocket_messages(websocket, tube_registry, timeout, stop_event) + return + except TypeError as e: + if "ssl_context" in str(e): + logging.debug("ssl_context parameter not supported, trying with ssl parameter") + async with websockets_connect(ws_endpoint, ssl=ssl_context, **connect_kwargs) as websocket: + logging.debug("WebSocket connection established with ssl parameter") + # Signal ready event immediately after connection + if ready_event: + ready_event.set() + logging.debug("WebSocket ready event signaled") + await handle_websocket_messages(websocket, tube_registry, timeout, stop_event) + return + else: + raise + else: + async with websockets_connect(ws_endpoint, **connect_kwargs) as websocket: + logging.debug("WebSocket connection established") + # Signal ready event immediately after connection + if ready_event: + ready_event.set() + logging.debug("WebSocket ready event signaled") + await handle_websocket_messages(websocket, tube_registry, timeout, stop_event) + + elif WEBSOCKETS_VERSION == "legacy": + # websockets 11.0.3 uses extra_headers and ssl parameters + connect_kwargs = { + **base_kwargs, + "extra_headers": headers + } + + if ssl_context: + async with websockets_connect(ws_endpoint, ssl=ssl_context, **connect_kwargs) as websocket: + logging.debug("WebSocket connection established (legacy)") + # Signal ready event immediately after connection + if ready_event: + ready_event.set() + logging.debug("WebSocket ready event signaled") + await handle_websocket_messages(websocket, tube_registry, timeout, stop_event) + else: + async with websockets_connect(ws_endpoint, **connect_kwargs) as websocket: + logging.debug("WebSocket connection established (legacy)") + # Signal ready event immediately after connection + if ready_event: + ready_event.set() + logging.debug("WebSocket ready event signaled") + await handle_websocket_messages(websocket, tube_registry, timeout, stop_event) + else: + raise Exception("No compatible websockets version available") + + +# NOTE: signal_websocket_ready() function removed - no longer needed. +# Each tunnel's ready_event is now signaled directly in connect_websocket_with_fallback() + + +async def handle_websocket_responses(params, tube_registry, timeout=60, gateway_uid=None, ready_event=None, stop_event=None, router_tokens=None, cookie_header=None): + """ + Direct WebSocket handler that connects, listens for responses, and routes them to Rust. + Uses global conversation key store to support multiple concurrent tunnels. + + Args: + params: KeeperParams instance + tube_registry: PyTubeRegistry instance + timeout: Maximum time to listen for messages (seconds) + gateway_uid: Gateway UID (optional, for filtering) + ready_event: threading.Event to signal when WebSocket is connected + stop_event: threading.Event to signal when WebSocket should close + router_tokens: Optional (encrypted_session_token, encrypted_transmission_key, transmission_key) + so WebSocket uses same auth as streaming HTTP (required for trickle ICE) + cookie_header: Optional Cookie header from bind_to_controller for ALB stickiness + """ + if not WEBSOCKETS_AVAILABLE: + raise Exception("WebSocket library not available - install with: pip install websockets") + + # Get WebSocket URL for client listening + connect_ws_endpoint = get_router_ws_url(params) + ws_endpoint = connect_ws_endpoint + "/api/user/client" + + logging.debug(f"Connecting to WebSocket: {ws_endpoint}") + + # Use shared router tokens when provided (trickle ICE) so router associates this socket with streaming HTTP + if router_tokens and len(router_tokens) >= 2: + encrypted_session_token, encrypted_transmission_key = router_tokens[0], router_tokens[1] + else: + encrypted_session_token, encrypted_transmission_key, _ = get_keeper_tokens(params) + headers = { + 'TransmissionKey': bytes_to_base64(encrypted_transmission_key), + 'Authorization': f'KeeperUser {bytes_to_base64(encrypted_session_token)}', + } + if cookie_header: + headers['Cookie'] = cookie_header + # Set up SSL context + ssl_context = None + if ws_endpoint.startswith('wss://'): + ssl_context = ssl.create_default_context() + if not VERIFY_SSL: + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + + # Connect and handle messages with backward compatibility + # Handle parameter differences between websockets versions + await connect_websocket_with_fallback(ws_endpoint, headers, ssl_context, tube_registry, timeout, ready_event, stop_event) + + +async def handle_websocket_messages(websocket, tube_registry, timeout, stop_event=None): + """Handle WebSocket message processing + + Args: + websocket: WebSocket connection + tube_registry: PyTubeRegistry instance + timeout: Maximum time to listen for messages + stop_event: threading.Event to signal when to stop listening + """ + + # Listen for messages with timeout + try: + start_time = time.time() + while time.time() - start_time < timeout: + # Check if stop event is set (tunnel closed) + if stop_event and stop_event.is_set(): + logging.debug("WebSocket stop event received, closing connection") + break + + try: + # Wait for a message with short timeout to allow checking stop event and overall timeout + message_text = await asyncio.wait_for(websocket.recv(), timeout=1.0) + logging.debug(f"WebSocket received: {message_text[:200]}...") + + # Parse response - can be an array or single object + response_data = json.loads(message_text) + if isinstance(response_data, list): + # Handle an array of responses + logging.debug(f"Received {len(response_data)} WebSocket messages") + for idx, response_item in enumerate(response_data): + logging.debug(f" Message {idx+1}/{len(response_data)}: conversationId={response_item.get('conversationId', 'N/A')}, type={response_item.get('type', 'N/A')}") + if 'payload' in response_item: + logging.debug(f" Payload preview: {str(response_item['payload'])[:100]}...") + route_message_to_rust(response_item, tube_registry) + elif isinstance(response_data, dict): + # Handle a single response object + logging.debug(f"Received WebSocket message: conversationId={response_data.get('conversationId', 'N/A')}, type={response_data.get('type', 'N/A')}") + if 'payload' in response_data: + logging.debug(f" Payload preview: {str(response_data['payload'])[:100]}...") + route_message_to_rust(response_data, tube_registry) + else: + logging.warning(f"Unexpected WebSocket message format: {type(response_data)}") + + except asyncio.TimeoutError: + # No message received within 1 second, continue loop to check stop event and overall timeout + continue + except ConnectionClosed: + logging.debug("WebSocket connection closed") + break + + except Exception as e: + logging.error(f"Error in WebSocket message handling: {e}") + finally: + logging.debug("WebSocket handler completed") + + +def route_message_to_rust(response_item, tube_registry): + """Route a single message to Rust - decrypt it first using the conversation's key""" + try: + conversation_id = response_item.get('conversationId') + logging.debug(f"Processing WebSocket message for conversation: {conversation_id}") + + if not conversation_id: + logging.debug("No conversationId in response, skipping") + return + + # Get the symmetric key for this conversation from global store + symmetric_key = get_conversation_key(conversation_id) + + if not symmetric_key: + logging.debug(f"No encryption key found for conversation: {conversation_id}") + logging.debug(f"Registered conversations: {get_all_conversation_ids()}") + return + + logging.debug(f"Found encryption key for conversation: {conversation_id}") + + # Decrypt the message payload + encrypted_payload = response_item.get('payload', '') + logging.debug(f"Processing payload for conversation {conversation_id}, payload length: {len(encrypted_payload) if encrypted_payload else 0}") + + if encrypted_payload: + # Parse the payload JSON string first + try: + payload_data = json.loads(encrypted_payload) + logging.debug(f"Successfully parsed payload JSON for {conversation_id}") + logging.debug(f"Payload is_ok: {payload_data.get('is_ok')}, progress_status: {payload_data.get('progress_status')}") + except json.JSONDecodeError as e: + logging.error(f"Failed to parse payload as JSON: {e}") + logging.error(f"Raw payload: {encrypted_payload[:200]}...") + return + + # Handle different types of responses + if payload_data.get('is_ok') and payload_data.get('data'): + data_field = payload_data.get('data', '') + + # Check if this is a plain text acknowledgment (not encrypted) + if isinstance(data_field, str) and ( + "ice candidate" in data_field.lower() or + "buffered" in data_field.lower() or + "connected" in data_field.lower() or + "disconnected" in data_field.lower() or + "error" in data_field.lower() or + data_field.endswith(conversation_id) # Plain text responses often end with conversation ID + ): + logging.debug(f"Received plain text acknowledgment: {data_field}") + + # CRITICAL: Mark ICE candidate response received to allow next candidate + if "ice candidate" in data_field.lower() or "ice candidates" in data_field.lower(): + # Find the signal handler and mark response received + tube_id = tube_registry.tube_id_from_connection_id(conversation_id) + if tube_id: + session = get_tunnel_session(tube_id) + if session and hasattr(session, 'signal_handler') and session.signal_handler: + session.signal_handler.ice_candidate_response_received = True + logging.debug(f"Marked ICE candidate response received for tube {tube_id}") + + return + + # Check if this is just a buffered acknowledgment (these sometimes have invalid base64) + if "buffered" in data_field.lower(): + logging.debug(f"Received buffered acknowledgment: {data_field}") + return + + logging.debug("Detected SDP answer response - processing...") + # This looks like an SDP answer response + encrypted_data = data_field + if encrypted_data: + logging.debug(f"Found encrypted data, length: {len(encrypted_data)}") + # Decrypt the SDP answer + try: + decrypted_data = tunnel_decrypt(symmetric_key, encrypted_data) + except Exception as e: + # If decryption fails, it might be a plain text response + logging.debug(f"Decryption failed, might be plain text: {e}") + logging.debug(f"Data content: {encrypted_data[:100]}...") + return + if decrypted_data: + data_text = bytes_to_string(decrypted_data).replace("'", '"') + logging.debug(f"Successfully decrypted data for {conversation_id}, length: {len(data_text)}") + + # Check if this is a simple JSON-encoded acknowledgment string + try: + parsed_text = json.loads(data_text) + if isinstance(parsed_text, str) and parsed_text.lower() in [ + "connected", "disconnected", "error", "success", "ok", "acknowledged" + ]: + logging.debug(f"Received JSON-encoded acknowledgment: {parsed_text}") + return + except (json.JSONDecodeError, TypeError): + pass # Not a simple JSON string, continue with normal processing + + try: + data_json = json.loads(data_text) + except (json.JSONDecodeError, TypeError): + data_json = None + + # Fallback: decrypted data may be raw SDP + answer_sdp = None + if isinstance(data_json, dict): + logging.debug(f"🔓 Decrypted payload type: {data_json.get('type', 'unknown')}, keys: {list(data_json.keys())}") + # 'answer' field is already base64 (initial answer); 'sdp' field is plain text (ICE restart answer) + answer_sdp = data_json.get('answer') + if not answer_sdp: + raw_sdp = data_json.get('sdp') + if raw_sdp: + answer_sdp = base64.b64encode(raw_sdp.encode('utf-8')).decode('ascii') + elif data_text.strip().startswith('v=') and 'm=' in data_text: + answer_sdp = base64.b64encode(data_text.strip().encode('utf-8')).decode('ascii') + logging.debug("Decrypted data appears to be raw SDP (not JSON), using as answer") + + if answer_sdp: + logging.debug(f"Found SDP answer, sending to Rust for conversation: {conversation_id}") + # Try to find tube ID - gateway may have converted URL-safe base64 to standard + tube_id = tube_registry.tube_id_from_connection_id(conversation_id) + if not tube_id: + url_safe_conversation_id = conversation_id.replace('+', '-').replace('/', '_').rstrip('=') + tube_id = tube_registry.tube_id_from_connection_id(url_safe_conversation_id) + if tube_id: + logging.debug(f"Found tube using URL-safe conversion: {url_safe_conversation_id}") + + if not tube_id: + logging.error(f"No tube ID found for conversation: {conversation_id} (also tried URL-safe version)") + else: + try: + set_remote_description_and_parse_version(tube_registry, tube_id, answer_sdp, is_answer=True) + except RuntimeError as _rte: + if "Invalid signaling state transition from Stable" in str(_rte): + # ICE restart answer arrived after the connection already + # re-established via another path — safe to ignore. + logging.debug( + f"ICE restart answer arrived for already-stable tube " + f"{tube_id} ({conversation_id}) — ignoring late answer" + ) + else: + raise + logging.debug("Connection state: SDP answer received, connecting...") + + session = get_tunnel_session(tube_id) + if session and session.buffered_ice_candidates: + if hasattr(session, 'signal_handler') and session.signal_handler: + session.signal_handler._send_ice_candidates_batch( + session.buffered_ice_candidates, tube_id + ) + session.buffered_ice_candidates.clear() + else: + logging.warning(f"No signal handler found for tube {tube_id} to send buffered candidates") + elif isinstance(data_json, dict) and ("offer" in data_json or data_json.get("type") == "offer"): + # Gateway is sending us an ICE restart offer + # 'sdp' field from gateway is plain SDP (not base64); encode for Rust + raw_offer = data_json.get('sdp') or data_json.get('offer') + offer_sdp = base64.b64encode(raw_offer.encode('utf-8')).decode('ascii') if raw_offer else None + + if offer_sdp: + logging.debug(f"Received ICE restart offer from Gateway for conversation: {conversation_id}") + + tube_id = tube_registry.tube_id_from_connection_id(conversation_id) + if not tube_id: + logging.error(f"No tube ID for conversation: {conversation_id}") + return + + # Get session to check if trickle ICE is enabled + session = get_tunnel_session(tube_id) + if not session: + logging.error(f"No tunnel session found for tube {tube_id}") + return + + # ICE restart requires trickle ICE mode - check via signal_handler + if hasattr(session, 'signal_handler') and session.signal_handler: + if not session.signal_handler.trickle_ice: + logging.warning(f"ICE restart offer ignored - trickle ICE not enabled for tube {tube_id}") + return + else: + logging.warning(f"Cannot verify trickle ICE status for tube {tube_id} - no signal handler") + return + + logging.debug("Connection state: ICE restart offer received from Gateway...") + + try: + # Apply the offer from Gateway + tube_registry.set_remote_description(tube_id, offer_sdp, is_answer=False) + logging.debug(f"Applied ICE restart offer for tube {tube_id}") + + # Generate answer + answer_sdp = tube_registry.create_answer(tube_id) + + if answer_sdp: + logging.debug(f"Generated ICE restart answer for tube {tube_id}") + + # Get session to access symmetric key and other info + session = get_tunnel_session(tube_id) + if not session: + logging.error(f"No tunnel session found for tube {tube_id}") + return + + # Prepare answer payload + answer_payload = { + "type": "answer", + "sdp": answer_sdp, + "ice_restart": True + } + + # Encrypt the answer + string_data = json.dumps(answer_payload) + bytes_data = string_to_bytes(string_data) + encrypted_data = tunnel_encrypt(session.symmetric_key, bytes_data) + + # Get signal handler from session + if hasattr(session, 'signal_handler') and session.signal_handler: + signal_handler = session.signal_handler + + # Send answer back to Gateway via HTTP POST + logging.debug(f"Sending ICE restart answer to Gateway for tube {tube_id}") + + answer_kwargs = {} + if signal_handler.trickle_ice and getattr(signal_handler, "_router_transmission_key", None) is not None: + answer_kwargs = { + "transmission_key": signal_handler._router_transmission_key, + "encrypted_transmission_key": signal_handler._router_encrypted_transmission_key, + "encrypted_session_token": signal_handler._router_encrypted_session_token, + } + if getattr(signal_handler, "_http_session", None) is not None: + answer_kwargs["http_session"] = signal_handler._http_session + router_response = router_send_action_to_gateway( + params=signal_handler.params, + destination_gateway_uid_str=session.gateway_uid, + gateway_action=GatewayActionWebRTCSession( + conversation_id=session.conversation_id, + message_id=GatewayAction.conversation_id_to_message_id(session.conversation_id), + inputs={ + "recordUid": signal_handler.record_uid, + 'kind': 'ice_restart_answer', + 'base64Nonce': signal_handler.base64_nonce, + 'conversationType': signal_handler.conversation_type, + "data": encrypted_data, + "trickleICE": signal_handler.trickle_ice, + } + ), + message_type=pam_pb2.CMT_CONNECT, + is_streaming=signal_handler.trickle_ice, # Streaming only for trickle ICE + gateway_timeout=GATEWAY_TIMEOUT, + **answer_kwargs + ) + + logging.debug(f"ICE restart answer sent for tube {tube_id}") + else: + logging.error(f"No signal handler found for tube {tube_id} to send answer") + else: + logging.error(f"Failed to generate ICE restart answer for tube {tube_id}") + + except Exception as e: + logging.error(f"Error handling ICE restart offer for tube {tube_id}: {e}") + else: + logging.warning(f"Received offer message without SDP data for conversation: {conversation_id}") + elif "candidates" in data_json: + # Try to find tube ID - gateway may have converted URL-safe base64 to standard + tube_id = tube_registry.tube_id_from_connection_id(conversation_id) + if not tube_id: + # Try URL-safe version (convert + to -, / to _, remove =) + url_safe_conversation_id = conversation_id.replace('+', '-').replace('/', '_').rstrip('=') + tube_id = tube_registry.tube_id_from_connection_id(url_safe_conversation_id) + if tube_id: + logging.debug(f"Found tube using URL-safe conversion: {url_safe_conversation_id}") + + if not tube_id: + logging.error(f"No tube ID found for conversation: {conversation_id} (also tried URL-safe version)") + return + # Handle ICE candidates from gateway (always array format) + candidates_list = data_json.get('candidates', []) + candidate_count = len(candidates_list) + logging.debug(f"Received {candidate_count} ICE candidates from gateway for {conversation_id}") + + # Gateway sends candidates in consistent format, pass them directly to Rust + for candidate in candidates_list: + logging.debug(f"Forwarding candidate to Rust: {candidate[:100]}...") # Log first 100 chars + tube_registry.add_ice_candidate(tube_id, candidate) + + logging.debug(f"Connection state: received {candidate_count} ICE candidate(s)...") + else: + logging.warning(f"No known field found in decrypted data {decrypted_data}") + else: + logging.error("Failed to decrypt data") + else: + logging.warning("No 'data' field found in response") + + # Handle error responses + elif (payload_data.get('errors') is not None and + payload_data.get('errors') != [] and + payload_data.get('errors') != ['']): + errors = payload_data.get('errors', ['']) + logging.error(f"Gateway returned errors for {conversation_id}: {errors}") + + elif not payload_data.get('is_ok', True): + # Gateway returned an explicit error (is_ok=False) — log the message and move on. + # This includes auth failures (401 on get_leafs), overload responses, etc. + logging.error( + f"Gateway error for {conversation_id}: {payload_data.get('data', 'unknown error')}" + ) + elif payload_data.get('data', '') == '': + logging.debug("Empty data field an acknowledgment, no action needed") + elif payload_data.get('data') and "ice candidate added" in payload_data.get('data').lower(): + logging.debug("Received ice candidate added") + else: + logging.debug(f"Unhandled payload for {conversation_id}: {payload_data}") + else: + logging.warning(f"No encrypted payload in message for conversation: {conversation_id}") + + except Exception as e: + logging.error(f"Error routing message to Rust: {e}") + import traceback + logging.error(f"Full traceback: {traceback.format_exc()}") + + +def start_websocket_listener(params, tube_registry, timeout=60, gateway_uid=None, tunnel_session=None, router_tokens=None, cookie_header=None): + """ + Start WebSocket listener in a background thread. + + Creates a DEDICATED WebSocket for the provided tunnel_session. + Each tunnel gets its own independent WebSocket connection. + + Args: + params: KeeperParams instance + tube_registry: PyTubeRegistry instance + timeout: Maximum time to listen for messages (seconds) + gateway_uid: Gateway UID (optional) + tunnel_session: TunnelSession instance for dedicated WebSocket (required) + router_tokens: Optional (encrypted_session_token, encrypted_transmission_key, transmission_key) + for trickle ICE so WebSocket uses same auth as streaming HTTP requests + cookie_header: Optional Cookie header from bind_to_controller for ALB stickiness + + Returns: + (thread, is_reused) tuple - is_reused is always False (each tunnel gets its own WebSocket) + """ + if tunnel_session is None: + raise ValueError("tunnel_session is required for dedicated WebSocket architecture") + + logging.debug(f"Creating dedicated WebSocket for tunnel {tunnel_session.tube_id}") + + # Create per-tunnel events + tunnel_session.websocket_ready_event = threading.Event() + tunnel_session.websocket_stop_event = threading.Event() + + # Start a dedicated WebSocket listener thread for this tunnel + def run_dedicated_websocket(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete(handle_websocket_responses( + params, tube_registry, timeout, gateway_uid, + ready_event=tunnel_session.websocket_ready_event, + stop_event=tunnel_session.websocket_stop_event, + router_tokens=router_tokens, + cookie_header=cookie_header + )) + except Exception as e: + logging.error(f"Dedicated WebSocket listener error for tunnel {tunnel_session.tube_id}: {e}") + finally: + loop.close() + logging.debug(f"Dedicated WebSocket closed for tunnel {tunnel_session.tube_id}") + + tunnel_session.websocket_thread = threading.Thread( + target=run_dedicated_websocket, + daemon=True, + name=f"WebSocket-{tunnel_session.tube_id[:8]}" + ) + tunnel_session.websocket_thread.start() + logging.debug(f"Dedicated WebSocket listener started for tunnel {tunnel_session.tube_id}") + return (tunnel_session.websocket_thread, False) # Never reused - each tunnel gets its own + + + +# Simplified tunnel entrance for compatibility with existing code +class SimpleRustTunnelEntrance: + """Simple compatibility wrapper for Rust-based tunnels""" + + def __init__(self, conversation_id, host, port, record_uid): + self.conversation_id = conversation_id + self.host = host + self.port = port + self.record_uid = record_uid + + # Create a compatibility object that mimics the old WebRTCConnection + self.pc = SimpleRustPCCompat(conversation_id, record_uid) + + +# Simple compatibility object to replace the old WebRTCConnection interface +class SimpleRustPCCompat: + """Simple compatibility wrapper to mimic old WebRTCConnection interface""" + + def __init__(self, conversation_id, record_uid): + self.endpoint_name = conversation_id + self.record_uid = record_uid + self.conversation_id = conversation_id + + +# Callback handler class for WebRTC signals +class TunnelSignalHandler: + """ + Signal handler for WebRTC tunnel events with HTTP sending and WebSocket receiving. + + Features immediate ICE candidate sending: + - Sends ICE candidates immediately as they arrive from Rust + - Always sends candidates in {"candidates": [candidate]} array format for gateway consistency + - Maintains consistent protocol with gateway expectations + """ + + def __init__(self, params, record_uid, gateway_uid, symmetric_key, base64_nonce, conversation_id, + tube_registry, tube_id=None, trickle_ice=False, websocket_router=None, + conversation_type='tunnel', router_tokens=None, http_session=None, silent=False): + self.params = params + self.record_uid = record_uid + self.gateway_uid = gateway_uid + self.symmetric_key = symmetric_key + self.base64_nonce = base64_nonce + self.conversation_id = conversation_id + self.conversation_type = conversation_type + self.tube_registry = tube_registry + self.tube_id = tube_id + self.trickle_ice = trickle_ice + self.silent = silent # Suppress connection-established display (probe/stress mode) + self.tube_close_initiated = False # Set when Rust initiates close (AdminClosed/Normal); skip redundant cleanup close_tube call + self.connection_success_shown = False # Track if we've shown success messages + self.connection_connected = False # Track if WebRTC connection is established + self.ice_sending_in_progress = False # Serialize ICE candidate sending + self.host = None # Will be set later when the socket is ready + self.port = None + self.websocket_router = websocket_router # For key cleanup + self.offer_sent = False # Track if offer has been sent to gateway + self.buffered_ice_candidates = [] # Buffer ICE candidates until offer is sent + # Shared router auth for streaming: (encrypted_session_token, encrypted_transmission_key, transmission_key) + self._router_transmission_key = None + self._router_encrypted_transmission_key = None + self._router_encrypted_session_token = None + if router_tokens and len(router_tokens) >= 3: + self._router_encrypted_session_token, self._router_encrypted_transmission_key, self._router_transmission_key = router_tokens[0], router_tokens[1], router_tokens[2] + self._http_session = http_session # Shared session for ALB stickiness (bind_to_controller cookie) + + # WebSocket routing is handled automatically - no setup needed + if trickle_ice and not WEBSOCKETS_AVAILABLE: + raise Exception("Trickle ICE requires WebSocket support - install with: pip install websockets") + + def signal_from_rust(self, response: dict): + """Signal callback to handle Rust events and gateway communication. + + Called by Rust via PyO3. Any unhandled exception here crosses the FFI + boundary as a PyErr which can destabilise the tube, so we wrap the whole + body and log rather than propagate. + """ + try: + self._signal_from_rust_inner(response) + except Exception as _sig_err: + logging.error( + f"Unhandled exception in signal_from_rust " + f"(kind={response.get('kind','?')}, tube={response.get('tube_id','?')}): {_sig_err}", + exc_info=True, + ) + + def _signal_from_rust_inner(self, response: dict): + signal_kind = response.get('kind', '') + tube_id = response.get('tube_id', '') + data = response.get('data', '') + conversation_id_from_signal = response.get('conversation_id', '') + + logging.debug(f"Received signal: kind={signal_kind}, tube_id={tube_id}, conversation_id={conversation_id_from_signal}") + + # Get the tunnel session for this tube + session = get_tunnel_session(tube_id) if tube_id else None + if session: + session.update_activity() + else: + if tube_id: + logging.debug( + f"No tunnel session found for tube {tube_id} while handling signal '{signal_kind}'" + ) + + # Handle local connection state changes + if signal_kind == 'connection_state_changed': + new_state = data.lower() + logging.debug(f"Connection state changed for tube {tube_id}: {new_state}") + + # Detailed logging for specific states + if new_state == 'disconnected': + logging.debug(f"Connection disconnected for tube {tube_id} - ICE restart may be attempted by Rust") + + elif new_state == 'failed': + logging.debug(f"Connection failed for tube {tube_id} - ICE restart may be attempted by Rust") + + elif new_state == 'connected': + logging.debug( + f"Connection established/restored for tube {tube_id} " + f"(conversation_id={conversation_id_from_signal or self.conversation_id})" + ) + logging.debug("Connection state: connected") + + # CRITICAL: Mark connection as connected - IMMEDIATELY stop sending ICE candidates + self.connection_connected = True + self.ice_sending_in_progress = False # Stop any pending ICE candidate sends + + if not self.connection_success_shown: + self.connection_success_shown = True + + # Get tunnel session for record details + if session and not self.silent: + logging.info(f"\n{bcolors.OKGREEN}Connection established successfully.{bcolors.ENDC}") + + # Display record title if available + if session.record_title: + logging.info(f"{bcolors.OKBLUE}Record:{bcolors.ENDC} {session.record_title}") + + # Display remote target + if session.target_host and session.target_port: + logging.info(f"{bcolors.OKBLUE}Remote:{bcolors.ENDC} {session.target_host}:{session.target_port}") + + # Display local listening address + if session.host and session.port: + logging.info(f"{bcolors.OKBLUE}Local:{bcolors.ENDC} {session.host}:{session.port}") + + # Display conversation ID + if session.conversation_id: + logging.info(f"{bcolors.OKBLUE}Conversation ID:{bcolors.ENDC} {session.conversation_id}") + + # Flush any buffered ICE candidates now that we're connected + if session and session.buffered_ice_candidates: + logging.debug(f"Flushing {len(session.buffered_ice_candidates)} buffered ICE candidates") + self._send_ice_candidates_batch( + session.buffered_ice_candidates, tube_id + ) + session.buffered_ice_candidates.clear() + + elif new_state == "connecting": + logging.debug(f"Connection in progress for tube {tube_id}") + + elif new_state == "closed": + logging.debug(f"Connection closed for tube {tube_id}") + # The WebRTC peer connection reached "closed" state. This can happen + # without a preceding channel_closed signal (e.g. ICE timeout, network + # drop). If we don't clean up here the Rust tube keeps running and any + # active forwarded TCP sessions (SSH, MySQL, etc.) keep flowing while + # Commander stops reporting the tunnel entirely. + if tube_id and self.tube_registry: + try: + self.tube_registry.close_tube(tube_id, reason=CloseConnectionReasons.Normal) + except Exception: + pass # Already closed — idempotent + if tube_id: + session = get_tunnel_session(tube_id) + if session: + if hasattr(session, 'signal_handler') and session.signal_handler: + session.signal_handler.cleanup() + if session.websocket_stop_event and session.websocket_thread: + session.websocket_stop_event.set() + session.websocket_thread.join(timeout=5.0) + unregister_tunnel_session(tube_id) + + else: + logging.debug(f"Connection state for tube {tube_id}: {new_state}") + + return # Local event, no gateway response needed + + elif signal_kind == 'channel_closed': + conversation_id_from_signal = conversation_id_from_signal or self.conversation_id + logging.debug(f"Received 'channel_closed' signal for conversation '{conversation_id_from_signal}' of tube '{tube_id}'.") + + # Check if the tunnel session exists and is already closed + session = get_tunnel_session(tube_id) if tube_id else None + if session: + # For now, we don't have a tunnel_closed flag in TunnelSession like the gateway, + # but we could add it if needed for preventing redundant handling + pass + + try: + data_json = json.loads(data) if data else {} + + # Try to get structured close reason first + close_reason = None + if "close_reason" in data_json: + reason_code = data_json["close_reason"].get("code") + if reason_code is not None: + close_reason = CloseConnectionReason.from_code(reason_code) + logging.debug(f" Structured close reason: {close_reason.name} (code: {reason_code})") + + # Fallback to old string-based outcome for backward compatibility + if close_reason is None: + outcome = data_json.get("outcome", "unknown") + close_reason = CloseConnectionReason.from_legacy_outcome(outcome) + logging.debug(f" Legacy outcome: '{outcome}' -> {close_reason.name}") + + # Handle based on reason type + if close_reason.is_critical(): + logging.error(f"{bcolors.FAIL}Tunnel closed due to critical failure - '{tube_id}': {close_reason.name}{bcolors.ENDC}") + + elif close_reason.is_user_initiated(): + # Rust is already handling the close — skip redundant close_tube in cleanup() + self.tube_close_initiated = True + logging.debug(f"{bcolors.OKBLUE}User-initiated closure of tunnel '{tube_id}': {close_reason.name}{bcolors.ENDC}") + + elif close_reason.is_retryable(): + logging.debug(f"{bcolors.WARNING}Tunnel closed with retryable error - '{tube_id}': {close_reason.name}{bcolors.ENDC}") + + else: + logging.debug(f"{bcolors.OKBLUE}Tunnel '{tube_id}' closed with reason: {close_reason.name}{bcolors.ENDC}") + + except (json.JSONDecodeError, KeyError) as e: + logging.error(f"Failed to parse close reason: {e}. Defaulting to critical handling.") + logging.debug(f"{bcolors.FAIL}Tunnel closed due to unknown error{bcolors.ENDC}") + + # Clean up the tunnel session when channel closes + if tube_id: + # Get session before unregistering to access signal handler + session = get_tunnel_session(tube_id) + if session: + # Cleanup signal handler (conversation keys) + if hasattr(session, 'signal_handler') and session.signal_handler: + session.signal_handler.cleanup() + logging.debug(f"Cleaned up conversation keys for closed tunnel {tube_id}") + + # Stop dedicated WebSocket if this tunnel has one + if session.websocket_stop_event and session.websocket_thread: + logging.debug(f"Stopping dedicated WebSocket for tunnel {tube_id}") + session.websocket_stop_event.set() # Signal WebSocket to close + # Give it a moment to close gracefully + session.websocket_thread.join(timeout=5.0) + if session.websocket_thread.is_alive(): + logging.debug(f"Dedicated WebSocket for tunnel {tube_id} did not close in time") + else: + logging.debug(f"Dedicated WebSocket closed for tunnel {tube_id}") + + unregister_tunnel_session(tube_id) + return # Local event, no gateway response needed + + elif signal_kind == 'error': + error_msg = data if data else 'Unknown error' + logging.error(f"Tunnel error for {tube_id}: {error_msg}") + # Clean up on error as well + if tube_id and data.lower() in ["failed", "closed"]: + # Get session before unregistering to access signal handler + session = get_tunnel_session(tube_id) + if session: + # Cleanup signal handler (conversation keys) + if hasattr(session, 'signal_handler') and session.signal_handler: + session.signal_handler.cleanup() + logging.debug(f"Cleaned up conversation keys for failed tunnel {tube_id}") + + # Stop dedicated WebSocket if this tunnel has one + if session.websocket_stop_event and session.websocket_thread: + logging.debug(f"Stopping dedicated WebSocket for failed tunnel {tube_id}") + session.websocket_stop_event.set() # Signal WebSocket to close + # Give it a moment to close gracefully + session.websocket_thread.join(timeout=5.0) + if session.websocket_thread.is_alive(): + logging.debug(f"Dedicated WebSocket for tunnel {tube_id} did not close in time") + else: + logging.debug(f"Dedicated WebSocket closed for failed tunnel {tube_id}") + + unregister_tunnel_session(tube_id) + return # Local event, no gateway response needed + + # Handle ICE candidates - use session to check if offer is sent AND WebSocket is ready + elif signal_kind == 'icecandidate': + # CRITICAL: Stop immediately if connection is already established + if self.connection_connected: + logging.debug(f"Skipping ICE candidate - connection already established for tube {tube_id}") + return + + # CRITICAL: Check if we're already sending a candidate (serialize) + if self.ice_sending_in_progress: + logging.debug(f"ICE candidate send already in progress for tube {tube_id}, buffering this candidate") + if session: + session.buffered_ice_candidates.append(data) + priority_score, candidate_type = self._get_ice_candidate_priority(data) + logging.debug(f"Buffered candidate (priority={priority_score}, type={candidate_type})") + return + + logging.debug(f"Received ICE candidate for tube {tube_id}") + + # Check if we should buffer this candidate + should_buffer = False + if session: + # Buffer if offer not sent yet + if not session.offer_sent: + should_buffer = True + logging.debug(f"Buffering ICE candidate - offer not yet sent for tube {tube_id}") + # Also buffer if WebSocket is not ready yet (even if offer was sent) + elif session.websocket_ready_event and not session.websocket_ready_event.is_set(): + should_buffer = True + logging.debug(f"Buffering ICE candidate - WebSocket not ready yet for tube {tube_id}") + + if should_buffer: + if session: + session.buffered_ice_candidates.append(data) + else: + # Send the candidate immediately (but still in array format for gateway consistency) + self._send_ice_candidate_immediately(data, tube_id) + return + elif signal_kind == 'ice_restart_request': + logging.debug(f"Received ICE restart request for tube {tube_id}") + + # ICE restart requires trickle ICE mode + if not self.trickle_ice: + logging.warning(f"ICE restart request ignored - trickle ICE not enabled for tube {tube_id}") + return + + try: + # Execute ICE restart through your tube registry + restart_sdp = self.tube_registry.restart_ice(tube_id) + + if restart_sdp: + logging.debug(f"ICE restart successful for tube {tube_id}") + self._send_restart_offer(restart_sdp, tube_id) + else: + logging.error(f"ICE restart failed for tube {tube_id}") + + except Exception as e: + logging.error(f"ICE restart error for tube {tube_id}: {e}") + + return # Local event, no gateway response needed + + elif signal_kind == 'ice_restart_offer': + # Rust initiated ICE restart and generated offer (e.g., network change detected) + # We need to send this offer to Gateway and get an answer + logging.debug(f"Received ice_restart_offer from Rust for tube {tube_id}") + + # ICE restart requires trickle ICE mode + if not self.trickle_ice: + logging.warning(f"ICE restart offer ignored - trickle ICE not enabled for tube {tube_id}") + return + + offer_sdp = data # Already base64 encoded from Rust + + if not offer_sdp: + logging.error(f"Empty ICE restart offer received for tube {tube_id}") + return + + # Send the offer to Gateway + self._send_restart_offer(offer_sdp, tube_id) + return + + # Unknown signal type + else: + logging.debug(f"Unknown signal type: {signal_kind}") + + def _get_ice_candidate_priority(self, candidate_data): + """ + Extract priority from ICE candidate string to determine send order. + + ICE candidate priority order (highest to lowest): + 1. typ host - Direct local connection (fastest, most reliable) + 2. typ srflx - Server reflexive via STUN (medium speed) + 3. typ relay - Relay via TURN (slowest, last resort) + + Args: + candidate_data: ICE candidate string (SDP format) + + Returns: + tuple: (priority_score, candidate_type) where: + - priority_score: Higher = better (0-100) + - candidate_type: 'host', 'srflx', 'relay', or 'unknown' + """ + if not isinstance(candidate_data, str): + candidate_str = str(candidate_data) + else: + candidate_str = candidate_data + + # Parse candidate string for type + candidate_lower = candidate_str.lower() + + if 'typ host' in candidate_lower: + # Highest priority: direct local connection + return (100, 'host') + elif 'typ srflx' in candidate_lower: + # Medium priority: server reflexive (NAT traversal via STUN) + return (50, 'srflx') + elif 'typ relay' in candidate_lower: + # Lowest priority: relay via TURN (most expensive/slowest) + return (0, 'relay') + else: + # Unknown type - try to extract priority number from candidate string + # Format: "candidate:foundation component priority protocol ..." + import re + priority_match = re.search(r'candidate:[^\s]+\s+\d+\s+\w+\s+(\d+)', candidate_str) + if priority_match: + priority_num = int(priority_match.group(1)) + # Normalize priority (ICE priorities are typically 0-2^31) + # Higher priority number = better candidate + normalized = min(100, priority_num // 21474836) # Scale to 0-100 + return (normalized, 'unknown') + return (10, 'unknown') # Default low priority for unknown types + + def _sort_candidates_by_priority(self, candidates): + """ + Sort ICE candidates by priority (best/fastest first). + + Args: + candidates: List of candidate strings + + Returns: + List of candidates sorted by priority (highest first) + """ + def priority_key(candidate): + priority_score, candidate_type = self._get_ice_candidate_priority(candidate) + # Sort by priority score (descending), then by type + return (-priority_score, candidate_type) + + return sorted(candidates, key=priority_key) + def _send_ice_candidate_immediately(self, candidate_data, tube_id=None): + """Send a single ICE candidate immediately via HTTP POST to /send_controller_message + + Always sends candidates as {"candidates": [candidate]} array format for gateway consistency. + This matches the gateway expectation: action_inputs['data'].get('candidates') + + Serializes sending to prevent parallel sends and stops immediately if connection is established. + """ + # CRITICAL: Double-check connection state before sending (connection might have been established) + if self.connection_connected: + logging.debug(f"Skipping ICE candidate send - connection already established") + return + + # Set flag to serialize sending (prevent parallel sends) + self.ice_sending_in_progress = True + + try: + # Always use array format for consistency with gateway expectations + # Gateway expects: action_inputs['data'].get('candidates') and iterates: for candidate in ice_candidates + candidates_payload = {"candidates": [candidate_data]} + string_data = json.dumps(candidates_payload) + bytes_data = string_to_bytes(string_data) + encrypted_data = tunnel_encrypt(self.symmetric_key, bytes_data) + + logging.debug(f"Sending ICE candidate to gateway immediately") + + # Use same router tokens and session as WebSocket when streaming (ALB stickiness) + ice_kwargs = {} + if self.trickle_ice and self._router_transmission_key is not None: + ice_kwargs = { + "transmission_key": self._router_transmission_key, + "encrypted_transmission_key": self._router_encrypted_transmission_key, + "encrypted_session_token": self._router_encrypted_session_token, + } + if self.trickle_ice and getattr(self, "_http_session", None) is not None: + ice_kwargs["http_session"] = self._http_session + router_response = router_send_action_to_gateway( + params=self.params, + destination_gateway_uid_str=self.gateway_uid, + gateway_action=GatewayActionWebRTCSession( + conversation_id=self.conversation_id, + message_id=GatewayAction.conversation_id_to_message_id(self.conversation_id), + inputs={ + "recordUid": self.record_uid, + 'kind': 'icecandidate', + 'base64Nonce': self.base64_nonce, + 'conversationType': self.conversation_type, + "data": encrypted_data, + "trickleICE": self.trickle_ice, + } + ), + message_type=pam_pb2.CMT_CONNECT, + is_streaming=self.trickle_ice, # Streaming only for trickle ICE + gateway_timeout=GATEWAY_TIMEOUT, + **ice_kwargs + ) + + if self.trickle_ice: + logging.debug("ICE candidate sent via HTTP POST - response expected via WebSocket") + else: + logging.debug("ICE candidate sent via HTTP POST") + + except Exception as e: + # Check if this is a gateway offline error (RRC_CONTROLLER_DOWN) or bad state error (RRC_BAD_STATE) + error_str = str(e) + is_gateway_offline = 'RRC_CONTROLLER_DOWN' in error_str + is_bad_state = 'RRC_BAD_STATE' in error_str + + if is_gateway_offline: + # Gateway is offline - this is expected if gateway is down, log at debug level + logging.debug(f"Gateway offline when sending ICE candidate: {e}") + elif is_bad_state: + # Bad state - transient during WebSocket startup, log at debug level + logging.debug(f"Bad state when sending ICE candidate: {e}") + else: + # Other errors - log at error level + logging.error(f"Failed to send ICE candidate via HTTP: {e}") + + def _send_ice_candidates_batch(self, candidates_list, tube_id=None): + """Send multiple ICE candidates in a single HTTP POST. + + The gateway already iterates ``for candidate in ice_candidates`` inside + ``WebRTCSessionAction.add_ice_candidates_to_conversation_tunnel`` and the + per-candidate ``add_ice_candidate`` PyO3 binding is spawn-and-return — + so one request with N candidates costs the same server-side as one + request with one candidate. Client-side we were paying + N * ~500 ms sequential round-trips per flush (measured 7 × ~500 ms = + ~3.5 s on a typical launch). Sending them batched collapses the flush + window to one round-trip. + + Used by all offer-complete flush sites (streaming offer, non-streaming + SDP-answer processing, and the tunnel-start flush paths). The + single-candidate live path (``_send_ice_candidate_immediately``) stays + on the existing per-candidate call since it is already one request. + """ + if not candidates_list: + return + + # CRITICAL: Double-check connection state before sending (connection might have been established) + if self.connection_connected: + logging.debug(f"Skipping ICE candidate batch send - connection already established") + return + + # Set flag to serialize sending (prevent parallel sends) + self.ice_sending_in_progress = True + + try: + # Wire format matches the documented gateway contract: + # WebRTCSessionAction: "The 'data' field must contain: {'candidates': [...]}" + candidates_payload = {"candidates": list(candidates_list)} + string_data = json.dumps(candidates_payload) + bytes_data = string_to_bytes(string_data) + encrypted_data = tunnel_encrypt(self.symmetric_key, bytes_data) + + logging.debug(f"Sending {len(candidates_list)} ICE candidates to gateway in one batch") + + # Use same router tokens and session as WebSocket when streaming (ALB stickiness) + ice_kwargs = {} + if self.trickle_ice and self._router_transmission_key is not None: + ice_kwargs = { + "transmission_key": self._router_transmission_key, + "encrypted_transmission_key": self._router_encrypted_transmission_key, + "encrypted_session_token": self._router_encrypted_session_token, + } + if self.trickle_ice and getattr(self, "_http_session", None) is not None: + ice_kwargs["http_session"] = self._http_session + router_response = router_send_action_to_gateway( + params=self.params, + destination_gateway_uid_str=self.gateway_uid, + gateway_action=GatewayActionWebRTCSession( + conversation_id=self.conversation_id, + message_id=GatewayAction.conversation_id_to_message_id(self.conversation_id), + inputs={ + "recordUid": self.record_uid, + 'kind': 'icecandidate', + 'base64Nonce': self.base64_nonce, + 'conversationType': self.conversation_type, + "data": encrypted_data, + "trickleICE": self.trickle_ice, + } + ), + message_type=pam_pb2.CMT_CONNECT, + is_streaming=self.trickle_ice, # Streaming only for trickle ICE + gateway_timeout=GATEWAY_TIMEOUT, + **ice_kwargs + ) + + if self.trickle_ice: + logging.debug( + f"{len(candidates_list)} ICE candidates sent via HTTP POST " + "- response expected via WebSocket" + ) + else: + logging.debug(f"{len(candidates_list)} ICE candidates sent via HTTP POST") + + except Exception as e: + # Same error classification as the single-candidate path + error_str = str(e) + is_gateway_offline = 'RRC_CONTROLLER_DOWN' in error_str + is_bad_state = 'RRC_BAD_STATE' in error_str + + if is_gateway_offline: + logging.debug(f"Gateway offline when sending ICE candidate batch: {e}") + elif is_bad_state: + logging.debug(f"Bad state when sending ICE candidate batch: {e}") + else: + logging.error(f"Failed to send ICE candidate batch via HTTP: {e}") + + def _send_restart_offer(self, restart_sdp, tube_id): + """Send ICE restart offer via HTTP POST to /send_controller_message with encryption + + Similar to _send_ice_candidate_immediately but sends an offer instead of candidates. + """ + try: + # Format as offer payload for gateway. + # restart_sdp arrives base64-encoded from Rust (API contract), but the payload + # SDP field is raw text — gateway and vault both decode base64 before using it + # with the Rust boundary (gateway) or WebRTC API (vault). + raw_sdp = bytes_to_string(base64.b64decode(restart_sdp)) + offer_payload = { + "type": "offer", + "sdp": raw_sdp, + "ice_restart": True # Flag to indicate this is an ICE restart offer + } + string_data = json.dumps(offer_payload) + bytes_data = string_to_bytes(string_data) + encrypted_data = tunnel_encrypt(self.symmetric_key, bytes_data) + + logging.debug(f"Sending ICE restart offer to gateway for tube {tube_id}") + + restart_kwargs = {} + if self.trickle_ice and self._router_transmission_key is not None: + restart_kwargs = { + "transmission_key": self._router_transmission_key, + "encrypted_transmission_key": self._router_encrypted_transmission_key, + "encrypted_session_token": self._router_encrypted_session_token, + } + if self.trickle_ice and getattr(self, "_http_session", None) is not None: + restart_kwargs["http_session"] = self._http_session + router_response = router_send_action_to_gateway( + params=self.params, + destination_gateway_uid_str=self.gateway_uid, + gateway_action=GatewayActionWebRTCSession( + conversation_id=self.conversation_id, + message_id=GatewayAction.conversation_id_to_message_id(self.conversation_id), + inputs={ + "recordUid": self.record_uid, + 'kind': 'ice_restart_offer', # New kind for ICE restart + 'base64Nonce': self.base64_nonce, + 'conversationType': self.conversation_type, + "data": encrypted_data, + "trickleICE": self.trickle_ice, + } + ), + message_type=pam_pb2.CMT_CONNECT, + is_streaming=self.trickle_ice, # Streaming only for trickle ICE + gateway_timeout=GATEWAY_TIMEOUT, + **restart_kwargs + ) + + if self.trickle_ice: + logging.debug(f"ICE restart offer sent via HTTP POST for tube {tube_id} - response expected via WebSocket") + else: + logging.debug(f"ICE restart offer sent via HTTP POST for tube {tube_id}") + logging.debug(f"{bcolors.OKGREEN}ICE restart offer sent successfully{bcolors.ENDC}") + + except Exception as e: + # Check if this is a gateway offline error (RRC_CONTROLLER_DOWN) or bad state error (RRC_BAD_STATE) + error_str = str(e) + is_gateway_offline = 'RRC_CONTROLLER_DOWN' in error_str + is_bad_state = 'RRC_BAD_STATE' in error_str + + if is_gateway_offline: + # Gateway is offline - this is expected if gateway is down, log at debug level + logging.debug(f"Gateway offline when sending ICE restart offer for tube {tube_id}: {e}") + elif is_bad_state: + # Bad state - transient during WebSocket startup, log at debug level + logging.debug(f"Bad state when sending ICE restart offer for tube {tube_id}: {e}") + else: + # Other errors - log at error level + logging.error(f"Failed to send ICE restart offer for tube {tube_id}: {e}") + + def cleanup(self): + """Cleanup resources""" + # Close the tube in Rust registry if it exists + if self.tube_id and self.tube_registry and not self.tube_close_initiated: + try: + logging.debug(f"Closing tube {self.tube_id} in cleanup") + self.tube_registry.close_tube(self.tube_id, reason=CloseConnectionReasons.Error) + logging.debug(f"Tube {self.tube_id} closed successfully") + except Exception as e: + # Tube might not exist yet or already closed + logging.debug(f"Could not close tube {self.tube_id} during cleanup: {e}") + + # Unregister conversation key from global store + if self.conversation_id: + unregister_conversation_key(self.conversation_id) + # Also unregister the standard base64 version + standard_conversation_id = self.conversation_id.replace('-', '+').replace('_', '/') + padding_needed = (4 - len(standard_conversation_id) % 4) % 4 + if padding_needed: + standard_conversation_id += '=' * padding_needed + if standard_conversation_id != self.conversation_id: + unregister_conversation_key(standard_conversation_id) + logging.debug("TunnelSignalHandler cleaned up") + +def start_rust_tunnel(params, record_uid, gateway_uid, host, port, + seed, target_host, target_port, socks, trickle_ice=True, record_title=None, allow_supply_host=False, two_factor_value=None, kind='start', probe_duration=30, probe_turn_only=False, probe_stun_only=False): + """ + Start a tunnel using Rust WebRTC with trickle ICE via HTTP POST and WebSocket responses. + + This function uses a global WebSocket architecture that supports multiple concurrent tunnels. + Messages are routed to Rust based on conversationId using a shared global key store. + The endpoint table is displayed ONLY when both the local socket AND WebRTC connection are ready. + + Architecture: + - Shared WebSocket listener handles multiple tunnels simultaneously + - Global conversation key store: conversationId → symmetric_key mapping + - Message flow: WebSocket → decrypt with a conversation key → send to Rust + - Signal handler shows endpoint table only when fully connected + - Multiple tunnels can run concurrently + + Display flow: + 1. "Establishing a tunnel with trickle ICE between Commander and Gateway..." + 2. "Creating WebRTC offer and setting up local listener..." + 4. "Sending offer to gateway..." + 5. "Offer sent to gateway" + 6. "Connection state: gathering candidates..." + 7. Real-time state updates: + - "sending ICE candidates..." + - "SDP answer received, connecting..." + - "exchanging ICE candidates..." + - "connected" + 8. Shows endpoint table with listening address (ONLY when fully ready) + 9. "Tunnel is ready for traffic" + + Multi-tunnel Support: + - Each tunnel gets its own conversation ID and encryption key + - Single shared WebSocket connection handles all tunnel communications + - Automatic key registration/cleanup per tunnel + - Concurrent tunnels work independently + + Usage: + # Start tunnel (shows endpoint table only when truly ready) + result = start_rust_tunnel(params, record_uid, gateway_uid, host, port, seed, target_host, target_port, socks) + + If result["success"]: + # Global WebSocket router automatically handles all responses + # Endpoint table shown only when both socket and WebRTC are ready + + # Multiple tunnels can be started concurrently + result2 = start_rust_tunnel(params, record_uid2, gateway_uid2, host2, port2, ...) + + Returns: + dict: { + "success": bool, + "tube_id": str, + "entrance": SimpleRustTunnelEntrance, + "signal_handler": TunnelSignalHandler, + "websocket_thread": Thread, + "conversation_id": str, + "tube_registry": PyTubeRegistry, + "status": "connecting" + } + """ + logging.debug("Establishing tunnel with trickle ICE between Commander and Gateway. Please wait...") + + try: + # Symmetric key generation for tunnel encryption + if isinstance(seed, str): + seed = base64_to_bytes(seed) + # Generate 128-bit (16-byte) random nonce + nonce = os.urandom(MAIN_NONCE_LENGTH) + # Derive the encryption key using HKDF + hkdf = HKDF( + algorithm=hashes.SHA256(), + length=SYMMETRIC_KEY_LENGTH, # 256-bit key + salt=nonce, + info=b"KEEPER_TUNNEL_ENCRYPT_AES_GCM_128", + backend=default_backend() + ).derive(seed) + symmetric_key = AESGCM(hkdf) + + # Get tube registry and set server mode + tube_registry = get_or_create_tube_registry(params) + if not tube_registry: + return {"success": False, "error": "Rust WebRTC library not available"} + + tube_registry.set_server_mode(True) + + conversation_id_original = GatewayAction.generate_conversation_id() + conversation_id_bytes = url_safe_str_to_bytes(conversation_id_original) + conversation_id = base64.b64encode(conversation_id_bytes).decode('utf-8') + + base64_nonce = bytes_to_base64(nonce) + + # Create WebRTC settings for the Rust implementation + webrtc_settings = create_rust_webrtc_settings( + params, host, port, target_host, target_port, socks, nonce + ) + + # For probe mode with turn_only, force relay-only ICE on Commander's side too. + # Both peers must use RTCIceTransportPolicy::Relay for the connection to be + # pure TURN — otherwise the controlling peer (Commander) offers host/srflx + # candidates and ICE selects a direct path, bypassing the relay. + if probe_turn_only: + webrtc_settings["turn_only"] = True + elif probe_stun_only: + # Strip TURN credentials so Commander's ICE agent only gathers host/srflx + # candidates — no relay path is available on either side. + webrtc_settings.pop("turn_url", None) + webrtc_settings.pop("turn_username", None) + webrtc_settings.pop("turn_password", None) + + # Determine conversation type (tunnel or protocol-specific) + conversation_type = webrtc_settings.get('conversationType', 'tunnel') + + # Register the encryption key in the global conversation store + # IMPORTANT: Gateway may convert URL-safe base64 to standard base64 and add padding + # Register both versions to handle the conversion: + # - URL-safe: uses - and _ (e.g., "2srIxfCAsQAEWGmH-52yzw") + # - Standard: uses + and / with = padding (e.g., "2srIxfCAsQAEWGmH+52yzw==") + register_conversation_key(conversation_id_original, symmetric_key) + + # Also register the standard base64 version that gateway might return + # Convert URL-safe base64 to standard base64 + standard_conversation_id = conversation_id_original.replace('-', '+').replace('_', '/') + # Add padding if needed + padding_needed = (4 - len(standard_conversation_id) % 4) % 4 + if padding_needed: + standard_conversation_id += '=' * padding_needed + if standard_conversation_id != conversation_id_original: + register_conversation_key(standard_conversation_id, symmetric_key) + logging.debug(f"Registered both URL-safe and standard base64 conversation IDs") + # Create a temporary tunnel session BEFORE creating the tube so ICE candidates can be buffered immediately + import uuid + temp_tube_id = str(uuid.uuid4()) + + # Pre-create tunnel session with temporary ID to buffer early ICE candidates + tunnel_session = TunnelSession( + tube_id=temp_tube_id, + conversation_id=conversation_id_original, # Use original, not base64 encoded + gateway_uid=gateway_uid, + symmetric_key=symmetric_key, + offer_sent=False, + host=host, + port=port, + record_title=record_title, + record_uid=record_uid, + target_host=target_host, + target_port=target_port + ) + + # Register the temporary session so ICE candidates can be buffered immediately + register_tunnel_session(temp_tube_id, tunnel_session) + + # For trickle ICE, use shared tokens and bind_to_controller for ALB stickiness (same worker for WebSocket + POST) + router_tokens = None + http_session = None + cookie_header = None + if trickle_ice: + router_tokens = get_keeper_tokens(params) + logging.debug("Using shared router tokens for WebSocket and streaming HTTP") + http_session = requests.Session() + krouter_host = get_router_url(params) + try: + bind_url = krouter_host + "/api/user/bind_to_controller/" + gateway_uid + http_session.get(bind_url, verify=params.ssl_verify, timeout=10) + except Exception as e: + logging.debug(f"bind_to_controller GET failed (continuing): %s", e) + if http_session.cookies: + cookie_header = "; ".join(f"{c.name}={c.value}" for c in http_session.cookies) + logging.debug("Bound to controller for ALB stickiness (WebSocket and streaming HTTP will use same backend)") + + # Create the tube to get the WebRTC offer with trickle ICE + logging.debug("Creating WebRTC offer with trickle ICE gathering") + + # Create signal handler for Rust events + signal_handler = TunnelSignalHandler( + params=params, + record_uid=record_uid, + gateway_uid=gateway_uid, + symmetric_key=symmetric_key, + base64_nonce=base64_nonce, + conversation_id=conversation_id_original, # Use original, not base64 encoded + tube_registry=tube_registry, + tube_id=temp_tube_id, # Use temp ID initially + trickle_ice=trickle_ice, + conversation_type=conversation_type, + router_tokens=router_tokens, + http_session=http_session, + silent=(kind == 'probe'), # Suppress connection display for probe/stress mode + ) + + # Store signal handler reference so we can send buffered candidates later + tunnel_session.signal_handler = signal_handler + + logging.debug(f"{bcolors.OKBLUE}Creating WebRTC offer and setting up local listener...{bcolors.ENDC}") + + offer = tube_registry.create_tube( + conversation_id=conversation_id_original, # Use original, not base64 encoded + settings=webrtc_settings, + trickle_ice=trickle_ice, # Use trickle ICE for real-time candidate exchange + callback_token=webrtc_settings["callback_token"], + ksm_config="", + krelay_server=get_relay_host(params.server), + client_version="Commander-Python", + offer=None, # Let Rust create the offer + signal_callback=signal_handler.signal_from_rust + ) + + if not offer or 'tube_id' not in offer or 'offer' not in offer: + error_msg = "Failed to create tube" + if offer: + error_msg = offer.get('error', error_msg) + # Clean up temporary session on failure + unregister_tunnel_session(temp_tube_id) + return {"success": False, "error": error_msg} + + commander_tube_id = offer['tube_id'] + + # Update both signal handler and tunnel session with real tube ID + signal_handler.tube_id = commander_tube_id + signal_handler.host = host # Store for later endpoint display + signal_handler.port = port + tunnel_session.tube_id = commander_tube_id + + # Get the actual listening address from Rust (source of truth) + if 'actual_local_listen_addr' in offer and offer['actual_local_listen_addr']: + rust_addr = offer['actual_local_listen_addr'] + try: + if ':' in rust_addr: + rust_host, rust_port = rust_addr.rsplit(':', 1) + tunnel_session.host = rust_host + tunnel_session.port = int(rust_port) + signal_handler.host = rust_host + signal_handler.port = int(rust_port) + logging.debug(f"Using actual Rust listening address: {rust_host}:{rust_port}") + except Exception as e: + logging.warning(f"Failed to parse Rust address '{rust_addr}': {e}") + + # Unregister temporary session and register with real tube ID + unregister_tunnel_session(temp_tube_id) + register_tunnel_session(commander_tube_id, tunnel_session) + + logging.debug(f"Registered encryption key for conversation: {conversation_id_original}") + logging.debug(f"Expecting WebSocket responses for conversation ID: {conversation_id_original}") + + # Start DEDICATED WebSocket listener for this tunnel + # Each tunnel gets its own WebSocket connection - no sharing, no contention! + websocket_thread, is_websocket_reused = start_websocket_listener( + params, tube_registry, timeout=300, gateway_uid=gateway_uid, + tunnel_session=tunnel_session, + router_tokens=router_tokens, + cookie_header=cookie_header + ) + + # Wait for WebSocket to establish connection before sending streaming requests + # The router requires an active WebSocket connection for is_streaming=True + if trickle_ice: + # For trickle ICE, we MUST wait for WebSocket to be ready + max_wait = 15.0 # Maximum wait time in seconds (increased for slow networks) + + # Use the tunnel's dedicated ready event (not global) + if tunnel_session.websocket_ready_event: + logging.debug(f"Waiting for dedicated WebSocket to connect (max {max_wait}s)...") + websocket_ready = tunnel_session.websocket_ready_event.wait(timeout=max_wait) + if not websocket_ready: + logging.error(f"Dedicated WebSocket did not become ready within {max_wait}s") + signal_handler.cleanup() + unregister_tunnel_session(commander_tube_id) + return {"success": False, "error": "WebSocket connection timeout"} + logging.debug("Dedicated WebSocket connection established and ready for streaming") + + # DEDICATED WebSocket: No mutex needed! Each tunnel has its own connection. + # Backend registration is independent - no contention, no delays needed. + # Just a small delay to ensure backend is ready (much shorter than before) + backend_registration_delay = float(os.getenv('WEBSOCKET_BACKEND_DELAY', '2.0')) + logging.debug(f"Dedicated WebSocket: Waiting {backend_registration_delay}s for backend to register conversation {conversation_id_original}...") + time.sleep(backend_registration_delay) + logging.debug("Backend conversation registration delay complete") + else: + logging.error("No WebSocket ready event available for tunnel") + signal_handler.cleanup() + unregister_tunnel_session(commander_tube_id) + return {"success": False, "error": "WebSocket event not initialized"} + else: + # For non-trickle ICE, WebSocket is optional (just for monitoring) + # Give it a moment to start but don't block + time.sleep(0.5) + logging.debug("Non-trickle ICE: WebSocket optional, proceeding") + + # Verify the session was stored correctly + stored_session = get_tunnel_session(commander_tube_id) + if stored_session: + logging.debug(f"Verified tunnel session stored: tube={commander_tube_id}, host={stored_session.host}, port={stored_session.port}") + else: + logging.error(f"Failed to store tunnel session for tube: {commander_tube_id}") + + # Send offer to gateway via HTTP POST with streamResponse=true + logging.debug(f"{bcolors.OKBLUE}Sending offer for {conversation_id_original} to gateway...{bcolors.ENDC}") + + # Prepare the offer data + data = {"offer": offer.get("offer")} + + # For probe mode embed the flag in the encrypted payload so the gateway + # routes to its lightweight probe path. We still send kind='start' below + # so the Keeper router injects routerToken (required for TURN auth). + if kind == 'probe': + data["probe"] = True + if probe_duration != 30: + data["probe_duration"] = probe_duration + if probe_turn_only: + data["turn_only"] = True + if probe_stun_only: + data["stun_only"] = True + + # If allowSupplyHost is enabled, include the target host and port in the payload + if allow_supply_host: + data["host"] = { + "hostName": target_host, + "port": target_port + } + logging.debug(f"Including user-supplied host in payload: {target_host}:{target_port}") + + string_data = json.dumps(data) + bytes_data = string_to_bytes(string_data) + encrypted_data = tunnel_encrypt(symmetric_key, bytes_data) + + # Send offer via HTTP POST with retry logic for RRC_BAD_STATE + # CRITICAL: For trickle ICE, use is_streaming=True (response via WebSocket) + # For non-trickle ICE, use is_streaming=False (response via HTTP) + max_retries = int(os.getenv('TUNNEL_START_RETRIES', '2')) + retry_delay = float(os.getenv('TUNNEL_RETRY_DELAY', '1.5')) + + for attempt in range(max_retries + 1): + try: + if attempt > 0: + logging.debug(f"Retry attempt {attempt}/{max_retries} after {retry_delay}s delay...") + time.sleep(retry_delay) + + offer_kwargs = {} + if trickle_ice and router_tokens: + offer_kwargs = { + "transmission_key": router_tokens[2], + "encrypted_transmission_key": router_tokens[1], + "encrypted_session_token": router_tokens[0], + } + if trickle_ice and http_session is not None: + offer_kwargs["http_session"] = http_session + + # Build tunnel inputs + inputs = { + "recordUid": record_uid, + "tubeId": commander_tube_id, + 'kind': 'start', + 'base64Nonce': base64_nonce, + 'conversationType': 'tunnel', + "data": encrypted_data, + "trickleICE": trickle_ice, + } + if two_factor_value: + inputs['twoFactorValue'] = two_factor_value + + router_response = router_send_action_to_gateway( + params=params, + destination_gateway_uid_str=gateway_uid, + gateway_action=GatewayActionWebRTCSession( + conversation_id = conversation_id_original, + inputs=inputs + ), + message_type=pam_pb2.CMT_CONNECT, + is_streaming=trickle_ice, + gateway_timeout=GATEWAY_TIMEOUT, + **offer_kwargs + ) + + if router_response is None: + raise Exception('This Gateway currently is not online.') + + # Success! Break out of retry loop + logging.debug(f"{bcolors.OKGREEN}Offer sent to gateway{bcolors.ENDC}") + + # Mark offer as sent in both signal handler and session + signal_handler.offer_sent = True + tunnel_session.offer_sent = True + break # Success - exit retry loop + + except Exception as e: + error_msg = str(e) + is_bad_state = "RRC_BAD_STATE" in error_msg + is_last_attempt = (attempt == max_retries) + + if is_bad_state and not is_last_attempt: + # Retryable error and we have attempts left - this is expected during WebSocket startup + logging.debug(f"RRC_BAD_STATE on attempt {attempt + 1}/{max_retries + 1} - WebSocket backend may need more time") + logging.debug(f"Will retry in {retry_delay}s...") + continue # Retry + else: + # Fatal error or out of retries + if is_bad_state and is_last_attempt: + logging.error(f"RRC_BAD_STATE persists after {max_retries} retries") + logging.error("This may indicate network issues or backend problems") + + logging.error(f"Failed to send offer via HTTP: {error_msg}") + + # Cleanup on final failure + logging.debug(f"Cleaning up failed tunnel {commander_tube_id}") + + # Stop the WebSocket if it was started + if tunnel_session.websocket_stop_event and tunnel_session.websocket_thread: + logging.debug(f"Stopping WebSocket for failed tunnel {commander_tube_id}") + tunnel_session.websocket_stop_event.set() + tunnel_session.websocket_thread.join(timeout=2.0) + if tunnel_session.websocket_thread.is_alive(): + logging.warning(f"WebSocket for tunnel {commander_tube_id} did not close in time") + + if not is_bad_state: + # Non-retryable error - cleanup immediately + signal_handler.cleanup() + else: + # RRC_BAD_STATE after all retries - cleanup but log it + logging.debug("Cleaning up after RRC_BAD_STATE exhausted retries") + signal_handler.cleanup() + + unregister_tunnel_session(commander_tube_id) + return {"success": False, "error": f"Failed to send offer via HTTP: {e}"} + + # Continue with the rest of the flow after successful offer send + # Trickle ICE: Response comes via WebSocket (HTTP response is empty) + # Non-trickle ICE: Response comes via HTTP (contains SDP answer) + + # For non-trickle ICE, process the HTTP response (contains SDP answer) + if not trickle_ice and router_response: + logging.debug("Non-trickle ICE: Processing SDP answer from HTTP response") + try: + # router_response is a dict with 'response' key containing the gateway payload + gateway_payload = router_response.get('response', {}) + + # The response has nested structure: response -> payload (JSON string) -> data (encrypted) + payload_str = gateway_payload.get('payload') + if payload_str: + payload_json = json.loads(payload_str) + logging.debug(f"Non-trickle ICE: Parsed payload JSON, keys: {payload_json.keys()}") + + encrypted_answer = payload_json.get('data') + if encrypted_answer: + # Decrypt the answer using the tunnel's symmetric key + decrypted_answer = tunnel_decrypt(symmetric_key, encrypted_answer) + answer_data = json.loads(decrypted_answer) + + if 'answer' in answer_data or 'sdp' in answer_data: + answer_sdp = answer_data.get('answer') or answer_data.get('sdp') + if answer_sdp: + logging.debug("Non-trickle ICE: Received SDP answer via HTTP, setting in Rust") + set_remote_description_and_parse_version(tube_registry, commander_tube_id, answer_sdp, is_answer=True) + logging.debug("Non-trickle ICE: SDP answer set successfully") + else: + logging.error(f"Non-trickle ICE: No 'answer' or 'sdp' field in decrypted data: {answer_data}") + else: + logging.error(f"Non-trickle ICE: No 'data' field in payload JSON: {payload_json}") + else: + logging.error(f"Non-trickle ICE: No 'payload' field in gateway response: {gateway_payload}") + except Exception as e: + logging.error(f"Non-trickle ICE: Failed to process HTTP response: {e}") + import traceback + logging.error(f"Traceback: {traceback.format_exc()}") + + # Send any buffered ICE candidates that arrived before offer was sent (trickle ICE only) + if trickle_ice and tunnel_session.buffered_ice_candidates: + # Ensure WebSocket backend is fully ready before flushing candidates + # The backend might need a bit more time after the offer succeeds + if tunnel_session.websocket_ready_event and not tunnel_session.websocket_ready_event.is_set(): + logging.debug(f"Waiting for WebSocket to be ready before flushing ICE candidates...") + websocket_ready = tunnel_session.websocket_ready_event.wait(timeout=5.0) + if not websocket_ready: + logging.warning(f"WebSocket not ready after 5s, flushing candidates anyway") + + logging.debug(f"Flushing {len(tunnel_session.buffered_ice_candidates)} buffered ICE candidates after offer sent") + signal_handler._send_ice_candidates_batch( + tunnel_session.buffered_ice_candidates, commander_tube_id + ) + tunnel_session.buffered_ice_candidates.clear() + + # Create an entrance object that can be used to monitor connection status + entrance = SimpleRustTunnelEntrance( + conversation_id=conversation_id_original, # Use original, not base64 encoded + host=host, + port=port, + record_uid=record_uid + ) + + logging.debug(f"{bcolors.OKBLUE}Connection state: {bcolors.ENDC}gathering candidates...") + + return { + "success": True, + "tube_id": commander_tube_id, + "entrance": entrance, + "signal_handler": signal_handler, + "websocket_thread": websocket_thread, + "conversation_id": conversation_id_original, # Use original, not base64 encoded + "tube_registry": tube_registry, + "status": "connecting", # Indicates async connection in progress + "local_port": tunnel_session.port, # Actual bound port (may differ from requested) + } + + except Exception as e: + logging.error(f"Error in start_rust_tunnel: {e}") + # Clean up if needed + if 'conversation_id_original' in locals() and conversation_id_original: + unregister_conversation_key(conversation_id_original) + # Also clean up standard base64 version + standard_conversation_id = conversation_id_original.replace('-', '+').replace('_', '/') + padding_needed = (4 - len(standard_conversation_id) % 4) % 4 + if padding_needed: + standard_conversation_id += '=' * padding_needed + if standard_conversation_id != conversation_id_original: + unregister_conversation_key(standard_conversation_id) + if 'signal_handler' in locals(): + signal_handler.cleanup() + return {"success": False, "error": f"Failed to establish tunnel: {e}"} + + +def check_tunnel_connection_status(tube_registry, tube_id, timeout=None): + """ + Check the connection status of a tunnel tube. + + Args: + tube_registry: The PyTubeRegistry instance + tube_id: The tube ID to check + timeout: Optional timeout in seconds to wait for connection (None = no waiting) + + Returns: + dict: { + "connected": bool, + "state": str, + "error": str (if any) + } + """ + if not tube_registry or not tube_id: + return {"connected": False, "state": "unknown", "error": "Invalid tube registry or ID"} + + try: + if timeout is None: + # Check the current state + state = tube_registry.get_connection_state(tube_id) + return { + "connected": state.lower() == "connected", + "state": state, + "error": None + } + else: + # Wait for connection with timeout + max_wait_time = timeout + check_interval = 0.5 + + for i in range(int(max_wait_time / check_interval)): + try: + state = tube_registry.get_connection_state(tube_id) + logging.debug(f"Connection state check {i+1}: {state}") + + if state.lower() == "connected": + return {"connected": True, "state": state, "error": None} + elif state.lower() in ["failed", "closed", "disconnected"]: + return {"connected": False, "state": state, "error": f"Connection failed with state: {state}"} + + time.sleep(check_interval) + except Exception as e: + if "not found" in str(e).lower(): + return {"connected": False, "state": "not_found", "error": "Tube was removed from registry"} + else: + logging.warning(f"Could not check connection state: {e}") + time.sleep(check_interval) + + # Timeout reached + try: + final_state = tube_registry.get_connection_state(tube_id) + return {"connected": False, "state": final_state, "error": f"Connection timed out after {max_wait_time} seconds"} + except Exception as e: + if "not found" in str(e).lower(): + return {"connected": False, "state": "not_found", "error": "Tube was removed from registry"} + else: + return {"connected": False, "state": "unknown", "error": f"Connection verification failed: {e}"} + + except Exception as e: + return {"connected": False, "state": "error", "error": str(e)} + + +def wait_for_tunnel_connection(tunnel_result, timeout=30, show_progress=True): + """ + Wait for a tunnel to establish connection, with optional progress display. + + Args: + tunnel_result: Result dict from start_rust_tunnel + timeout: Maximum time to wait in seconds + show_progress: Whether to show progress messages + + Returns: + dict: Connection status result + """ + if not tunnel_result.get("success"): + return {"connected": False, "error": "Tunnel initiation failed"} + + tube_registry = tunnel_result.get("tube_registry") + tube_id = tunnel_result.get("tube_id") + + if not tube_registry or not tube_id: + return {"connected": False, "error": "Invalid tunnel result - missing registry or tube ID"} + + if show_progress: + logging.debug(f"{bcolors.OKBLUE}Waiting for tunnel connection (timeout: {timeout}s)...{bcolors.ENDC}") + + result = check_tunnel_connection_status(tube_registry, tube_id, timeout) + + if show_progress: + if result["connected"]: + # Success messages are now shown by the signal handler when connection establishes + logging.debug("Tunnel connection wait completed successfully") + else: + error_msg = result.get("error", "Unknown error") + logging.debug(f"{bcolors.FAIL}Tunnel connection failed: {error_msg}{bcolors.ENDC}") + + return result diff --git a/keepersdk-package/src/keepersdk/vault/nsf_management.py b/keepersdk-package/src/keepersdk/vault/nsf_management.py index 090d11a1..cc026c6a 100644 --- a/keepersdk-package/src/keepersdk/vault/nsf_management.py +++ b/keepersdk-package/src/keepersdk/vault/nsf_management.py @@ -356,6 +356,7 @@ def load_nsf_record_metadata(vault: VaultOnline, record_uid: str) -> Dict[str, A 'title': str(payload.get('title') or record_uid), 'type': str(payload.get('type') or ''), 'fields': list(payload.get('fields') or []), + 'custom': list(payload.get('custom') or []), 'notes': str(payload.get('notes') or ''), 'revision': entry.revision, 'version': entry.version, @@ -694,6 +695,95 @@ def update_nsf_record( return result +def update_nsf_typed_record( + vault: VaultOnline, + record: 'vault_record.TypedRecord', + *, + request_sync: bool = True) -> NsfModifyResult: + """Update an NSF typed record, including record-link adds/removes for file/script refs. + + Mirrors classic ``record_management.update_record`` so PAM rotation scripts + (and other fileRef/script attachments) work on Keeper Drive records. + """ + from . import vault_record as vr + + if not isinstance(record, vr.TypedRecord) or not record.record_uid: + raise NsfError('TypedRecord with record_uid is required') + record_uid = resolve_nsf_record_uid(vault, record.record_uid) or record.record_uid + if not is_nsf_record(vault, record_uid): + raise NsfError(f'NSF record not found: {record.record_uid}') + record.record_uid = record_uid + + record_key = _get_record_key(vault, record_uid) + storage_row = _nsf_view(vault).storage.records.get_entity(record_uid) + revision = storage_row.revision if storage_row else 0 + + existing = vr.TypedRecord() + existing.record_uid = record_uid + try: + meta = load_nsf_record_metadata(vault, record_uid) + existing.load_record_data({ + 'type': meta.get('type') or '', + 'title': meta.get('title') or record_uid, + 'notes': meta.get('notes') or '', + 'fields': meta.get('fields') or [], + 'custom': meta.get('custom') or [], + }) + except NsfError: + pass + + data = vault_extensions.extract_typed_record_data(record, None) + ru = record_pb2.RecordUpdate() + ru.record_uid = utils.base64_url_decode(record_uid) + ru.client_modified_time = utils.current_milli_time() + ru.revision = revision + ru.data = crypto.encrypt_aes_v2(vault_extensions.get_padded_json_bytes(data), record_key) + + existing_refs = vault_extensions.extract_typed_record_refs(existing) + refs = vault_extensions.extract_typed_record_refs(record) + for ref_uid in refs.difference(existing_refs): + ref_key = None + if record.linked_keys and ref_uid in record.linked_keys: + ref_key = record.linked_keys[ref_uid] + if not ref_key: + try: + ref_key = vault.vault_data.get_record_key(ref_uid) + except Exception: + ref_key = None + if not ref_key and vault.nsf_data: + entry = vault.nsf_data.get_record(ref_uid) + if entry: + ref_key = entry.record_key + if not ref_key: + continue + link = record_pb2.RecordLink() + link.record_uid = utils.base64_url_decode(ref_uid) + link.record_key = crypto.encrypt_aes_v2(ref_key, record_key) + ru.record_links_add.append(link) + for ref_uid in existing_refs.difference(refs): + ru.record_links_remove.append(utils.base64_url_decode(ref_uid)) + + rq = record_pb2.RecordsUpdateRequest() + rq.client_time = utils.current_milli_time() + rq.records.append(ru) + + auth = vault.keeper_auth + response = auth.execute_auth_rest( + 'vault/records/v3/update', rq, response_type=record_pb2.RecordsModifyResponse) + if response is None: + response = auth.execute_auth_rest( + 'vault/records_update', rq, response_type=record_pb2.RecordsModifyResponse) + assert response is not None + + result = _parse_modify_response(response, record_uid) + if not result.success: + raise KeeperApiError(result.status, result.message) + if request_sync: + vault.sync_requested = True + vault.run_pending_jobs() + return result + + def get_nsf_record_details( vault: VaultOnline, record_uids: Iterable[str]) -> Dict[str, Any]: diff --git a/keepersdk-package/src/keepersdk/vault/vault_extensions.py b/keepersdk-package/src/keepersdk/vault/vault_extensions.py index 7fe695fc..356fc47e 100644 --- a/keepersdk-package/src/keepersdk/vault/vault_extensions.py +++ b/keepersdk-package/src/keepersdk/vault/vault_extensions.py @@ -236,11 +236,20 @@ def extract_audit_data(record: Union[vault_record.KeeperRecord, vault_record.Typ def extract_typed_record_refs(record: vault_record.TypedRecord) -> Set[str]: refs = set() for field in itertools.chain(record.fields, record.custom): - if field.type in {'fileRef', 'addressRef', 'cardRef'}: + if field.type in {'fileRef', 'addressRef', 'cardRef', 'recordRef'}: if isinstance(field.value, list): for ref in field.value: if isinstance(ref, str): refs.add(ref) + elif field.type == 'script': + if not isinstance(field.value, list): + continue + for script in field.value: + if not isinstance(script, dict): + continue + file_ref = script.get('fileRef') + if isinstance(file_ref, str) and file_ref: + refs.add(file_ref) return refs