Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions keepercli-package/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions keepercli-package/setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
12 changes: 9 additions & 3 deletions keepercli-package/src/keepercli/commands/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
2 changes: 2 additions & 0 deletions keepercli-package/src/keepercli/commands/pam/keeper_pam.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
193 changes: 159 additions & 34 deletions keepercli-package/src/keepercli/commands/pam/pam_connection.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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):

Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -183,32 +298,41 @@ 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:
raise base.CommandError(f"Unable to add Seed to record {record_uid}. "
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,
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions keepercli-package/src/keepercli/commands/pam/pam_dto.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .base import ConnectionProtocol

__all__ = ['ConnectionProtocol']
31 changes: 31 additions & 0 deletions keepercli-package/src/keepercli/commands/pam/pam_import/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# _ __
# | |/ /___ ___ _ __ ___ _ _ ®
# | ' </ -_) -_) '_ \/ -_) '_|
# |_|\_\___\___| .__/\___|_|
# |_|
#
# Keeper Commander
# Copyright 2026 Keeper Security Inc.
# Contact: ops@keepersecurity.com
#

from enum import Enum


class ConnectionProtocol(Enum):
RDP = "rdp"
VNC = "vnc"
TELNET = "telnet"
SSH = "ssh"
KUBERNETES = "kubernetes"
SQLSERVER = "sql-server"
POSTGRESQL = "postgresql"
MYSQL = "mysql"
MARIADB = "mariadb"
ORACLE = "oracle"
MONGODB = "mongodb"
REDIS = "redis"
ELASTICSEARCH = "elasticsearch"
CLICKHOUSE = "clickhouse"
DYNAMODB = "dynamodb"
HTTP = "http"
Loading