From b53584117b04aa6343ade28f6e23716d77cc48e0 Mon Sep 17 00:00:00 2001 From: idimov-keeper <78815270+idimov-keeper@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:39:06 -0500 Subject: [PATCH 1/2] Pam launch jit fix (#1991) * Added proper error message when Workflow blocks connection * Increased WebRTC connect timeout to accomodate for ephemeral accounts * Added JIT / Ephemeral suport to pam launch command --- .../commands/pam_launch/connect_timing.py | 23 ++--- keepercommander/commands/pam_launch/launch.py | 5 + .../pam_launch/terminal_connection.py | 91 ++++++++++++++++--- 3 files changed, 94 insertions(+), 25 deletions(-) diff --git a/keepercommander/commands/pam_launch/connect_timing.py b/keepercommander/commands/pam_launch/connect_timing.py index 51337a500..6e5bf6e62 100644 --- a/keepercommander/commands/pam_launch/connect_timing.py +++ b/keepercommander/commands/pam_launch/connect_timing.py @@ -135,23 +135,24 @@ def webrtc_connection_poll_sec() -> float: _PAM_WEBRTC_CONNECT_TIMEOUT_ENV = 'PAM_WEBRTC_CONNECT_TIMEOUT_SEC' -_PAM_WEBRTC_CONNECT_TIMEOUT_DEFAULT = 16.0 # seconds — see note below +_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 16s — one second above the gateway/guacd side's own 15s connect - timeout, so the client times out *just after* the remote side would - have, instead of hanging on a dead connection. If peer-to-peer ICE - has not settled in that window it is almost certainly stuck (state - staying at ``Connecting`` / tube_status ``connecting`` indefinitely); - extending the local wait further just makes the user stare at a - spinner while the remote side has already given up. Fail fast, let - the user re-run ``pam launch`` — the retry typically succeeds on a - fresh ICE gathering pass. Set ``PAM_WEBRTC_CONNECT_TIMEOUT_SEC`` to - override for targeted diagnostics. + 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) diff --git a/keepercommander/commands/pam_launch/launch.py b/keepercommander/commands/pam_launch/launch.py index e61bba921..9fb39928d 100644 --- a/keepercommander/commands/pam_launch/launch.py +++ b/keepercommander/commands/pam_launch/launch.py @@ -611,6 +611,11 @@ def execute(self, params: KeeperParams, **kwargs): from ..workflow import check_workflow_and_prompt_2fa should_proceed, two_factor_value = check_workflow_and_prompt_2fa(params, record_uid) if not should_proceed: + logging.error( + "pam launch aborted for record %s: workflow access is not allowed for connect, " + "or workflow requires MFA and no valid MFA response was provided.", + record_uid, + ) return if two_factor_value: kwargs['two_factor_value'] = two_factor_value diff --git a/keepercommander/commands/pam_launch/terminal_connection.py b/keepercommander/commands/pam_launch/terminal_connection.py index 004264d6d..d0e8437bc 100644 --- a/keepercommander/commands/pam_launch/terminal_connection.py +++ b/keepercommander/commands/pam_launch/terminal_connection.py @@ -75,6 +75,7 @@ from ...params import KeeperParams from ..pam_import.base import ConnectionProtocol +from ..pam_import.keeper_ai_settings import get_resource_jit_settings from .connect_timing import ( PamConnectTiming, websocket_backend_delay_sec, @@ -422,6 +423,10 @@ def extract_terminal_settings( '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. @@ -543,6 +548,21 @@ def extract_terminal_settings( # 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) @@ -675,6 +695,8 @@ def create_connection_context(params: KeeperParams, # 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 @@ -1068,7 +1090,11 @@ def _build_guacamole_connection_settings( # 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 == 'userSupplied' and not user_record_uid: + 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") @@ -1432,19 +1458,24 @@ def _open_terminal_webrtc_tunnel(params: KeeperParams, # 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 allowSupplyHost, allowSupplyUser flags - # This matches gateway validation logic: - # - If allowSupplyHost=True: must be 'userSupplied' - # - If allowSupplyUser=True and no linked user: use 'userSupplied' - # - If linked user present: use 'linked' + # 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) - # Priority: if user_record_uid is provided (from CLI or record), use 'linked' to send those credentials credential_type = None - if user_record_uid: + 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}") @@ -1716,17 +1747,34 @@ def _open_terminal_webrtc_tunnel(params: KeeperParams, 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: + # 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 - # Priority: prefer 'linked' when DAG has credentials (even if allowSupply* is enabled). - # Use 'userSupplied' only when no linked credential but allowSupply* enabled. + # 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 cli_user_override: + 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). @@ -1763,7 +1811,12 @@ def _open_terminal_webrtc_tunnel(params: KeeperParams, } # Add credential type and userRecordUid based on mode - if credential_type_for_gateway == 'linked' and user_record_uid: + 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': @@ -1820,6 +1873,16 @@ def _send_gateway_offer_with_retry(is_streaming, **extra_kwargs): '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, @@ -1831,7 +1894,7 @@ def _send_gateway_offer_with_retry(is_streaming, **extra_kwargs): ), message_type=pam_pb2.CMT_CONNECT, is_streaming=is_streaming, - gateway_timeout=30000, + gateway_timeout=_gw_to, **extra_kwargs, ) except requests.exceptions.RequestException as _re: From 4ee01efe7fd353907fd91c9d4e63b7527a84c73b Mon Sep 17 00:00:00 2001 From: Sergey Kolupaev Date: Fri, 24 Apr 2026 14:46:06 -0700 Subject: [PATCH 2/2] Release 17.2.15 --- keepercommander/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/keepercommander/__init__.py b/keepercommander/__init__.py index 41f7f2bba..76c9943ff 100644 --- a/keepercommander/__init__.py +++ b/keepercommander/__init__.py @@ -10,4 +10,4 @@ # Contact: commander@keepersecurity.com # -__version__ = '17.2.14' +__version__ = '17.2.15'