diff --git a/.gitignore b/.gitignore
index 7d1e58bee..d277b523e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,6 +25,7 @@ __pycache__/
.mcp/
tests/*_results/
tests/*.log
+keepercommander/service/core/logs/
tests/compliance_test.env
docker-compose.yml
uv.lock
diff --git a/docs/cyberark-pam-import.md b/docs/cyberark-pam-import.md
index 86f34960a..b221ef744 100644
--- a/docs/cyberark-pam-import.md
+++ b/docs/cyberark-pam-import.md
@@ -227,6 +227,7 @@ pam project cyberark-cleanup --name "CyberArk Migration" --dry-run
| `--user-map` | CyberArk→Keeper user mapping JSON file |
| `--no-verify-ssl` | Disable SSL verification (self-hosted only) |
| `--include-system-safes` | Include system safes (PSM, PasswordManager, etc.) |
+| `--add-cyberark-users` | Create CybeArk Users in Keeper Admin Console. |
---
diff --git a/keepercommander/command_categories.py b/keepercommander/command_categories.py
index a61942beb..3a81f36e5 100644
--- a/keepercommander/command_categories.py
+++ b/keepercommander/command_categories.py
@@ -98,7 +98,7 @@
'this-device', 'login', 'login-status', 'biometric', 'whoami', 'logout',
'help', 'sync-down', 'version', 'clear', 'history', 'quit', 'run-batch', 'generate',
'reset-password', 'sync-security-data', 'keeper-fill', '2fa', 'create-account',
- 'run-as', 'sleep', 'server', 'proxy', 'keep-alive', 'supershell'
+ 'run-as', 'sleep', 'server', 'proxy', 'keep-alive', 'supershell', 'notification'
},
# KeeperPAM Commands
diff --git a/keepercommander/commands/aram.py b/keepercommander/commands/aram.py
index ce9cf1fb4..80c8060f8 100644
--- a/keepercommander/commands/aram.py
+++ b/keepercommander/commands/aram.py
@@ -39,7 +39,7 @@
from .. import api, vault, record_management
from ..constants import EMAIL_PATTERN
from ..display import bcolors
-from ..error import CommandError
+from ..error import CommandError, Error
from ..params import KeeperParams
from ..proto import enterprise_pb2
from ..sox import sox_data, get_prelim_data, is_compliance_reporting_enabled, get_sox_database_name, \
@@ -177,8 +177,25 @@
API_EVENT_RAW_ROW_LIMIT = 1000
-def fetch_audit_events(params, audit_filter, columns=None, aggregate=None, report_type='span', limit=None, order=None):
- # type: (KeeperParams, Dict[str, Any], Optional[List[str]], Optional[List[str]], str, Optional[int], Optional[str]) -> List[Dict[str, Any]]
+def raise_if_audit_report_failed(response, cmd):
+ # type: (dict, str) -> None
+ if isinstance(response, dict) and response.get('result') == 'fail':
+ raise CommandError(cmd, response.get('message') or 'Request failed')
+
+
+def communicate_audit_report(params, request, cmd):
+ # type: (KeeperParams, Dict[str, Any], str) -> Dict[str, Any]
+ try:
+ response = api.communicate(params, request)
+ except Error as e:
+ raise CommandError(cmd, e.message) from e
+ raise_if_audit_report_failed(response, cmd)
+ return response
+
+
+def fetch_audit_events(params, audit_filter, columns=None, aggregate=None, report_type='span', limit=None, order=None,
+ cmd='audit-report'):
+ # type: (KeeperParams, Dict[str, Any], Optional[List[str]], Optional[List[str]], str, Optional[int], Optional[str], str) -> List[Dict[str, Any]]
"""
Fetch audit events with pagination support.
@@ -235,7 +252,8 @@ def fetch_audit_events(params, audit_filter, columns=None, aggregate=None, repor
should_stop = False
for idx, rs in enumerate(rss):
- batch_events = rs.get('audit_event_overview_report_rows', [])
+ raise_if_audit_report_failed(rs, cmd)
+ batch_events = rs.get('audit_event_overview_report_rows') or []
if not batch_events or not isinstance(batch_events, list):
continue
@@ -283,8 +301,8 @@ def fetch_audit_events(params, audit_filter, columns=None, aggregate=None, repor
reverse = 'descending' if asc else 'ascending'
last_rq['order'] = reverse
last_rq['limit'] = 1
- rs_last = api.communicate(params, last_rq)
- last_row_events = rs_last.get('audit_event_overview_report_rows')
+ rs_last = communicate_audit_report(params, last_rq, cmd)
+ last_row_events = rs_last.get('audit_event_overview_report_rows') or []
if last_row_events:
last_row = last_row_events[0]
last_timestamp = last_row.get(timestamp_field, last_row.get('created', 0))
@@ -583,9 +601,12 @@ def __init__(self):
def convert_event(self, props, event):
pri = 13 * 8 + 6
dt = datetime.datetime.fromtimestamp(event['created'], tz=datetime.timezone.utc)
- ip = "-"
- if 'ip_address' in event:
- ip = event['ip_address']
+ # RFC 5424 section 6: HOSTNAME = NILVALUE / 1*255PRINTUSASCII (codes 33-126).
+ # Fall back to NILVALUE "-" for missing, null, empty, whitespace or non-printable values.
+ ip = str(event.get('ip_address') or '').strip()
+ if not ip or not all(33 <= ord(ch) <= 126 for ch in ip):
+ ip = '-'
+ ip = ip[:255]
message = '<{0}>1 {1} {2} {3} - {4}'.format(pri, dt.strftime('%Y-%m-%dT%H:%M:%SZ'), ip, 'Keeper', event['id'])
@@ -927,9 +948,20 @@ def resolve_uid(self, cache, username):
uid = cache.get(uname)
return uid
- def execute(self, params, **kwargs):
- load_syslog_templates(params)
+ @staticmethod
+ def validate_reporting_access(params):
+ """Ask the server to authorize audit reporting before prompting or creating records."""
+ now_ts = int(datetime.datetime.now().timestamp())
+ request = {
+ 'command': 'get_audit_event_reports',
+ 'report_type': 'span',
+ 'scope': 'enterprise',
+ 'limit': 1,
+ 'filter': {'created': {'min': 0, 'max': now_ts}}
+ }
+ communicate_audit_report(params, request, 'audit-log')
+ def execute(self, params, **kwargs):
target = kwargs.get('target')
log_export = None # type: Optional[AuditLogBaseExport]
@@ -948,6 +980,9 @@ def execute(self, params, **kwargs):
else:
raise CommandError('audit-log', 'Audit log export: unsupported target')
+ self.validate_reporting_access(params)
+ load_syslog_templates(params)
+
self.log_export = log_export
record = None # type: Union[vault.PasswordRecord, vault.TypedRecord, None]
record_name = kwargs.get('record') or log_export.default_record_title()
@@ -1061,13 +1096,9 @@ def execute(self, params, **kwargs):
filter_copy = {**rq_filter, 'created': created_filter_copy}
total_events_rq = {**rq, 'filter': filter_copy, 'report_type': 'span'}
total_events = 0
- try:
- total_events_rs = api.communicate(params, total_events_rq)
- rows = total_events_rs['audit_event_overview_report_rows']
- total_events = rows[0].get('occurrences', 0) if rows else 0
- except:
- logging.info('No events to export')
- return
+ total_events_rs = communicate_audit_report(params, total_events_rq, 'audit-log')
+ rows = total_events_rs.get('audit_event_overview_report_rows') or []
+ total_events = rows[0].get('occurrences', 0) if rows else 0
while not finished:
finished = True
@@ -1075,10 +1106,10 @@ def execute(self, params, **kwargs):
if last_event_time > 0:
created_filter['min'] = last_event_time
- rs = api.communicate(params, rq)
- if rs['result'] == 'success':
+ rs = communicate_audit_report(params, rq, 'audit-log')
+ if rs.get('result') == 'success':
finished = True
- if 'audit_event_overview_report_rows' in rs:
+ if rs.get('audit_event_overview_report_rows'):
audit_events = rs['audit_event_overview_report_rows']
event_count = len(audit_events)
last_event_time = int(audit_events[-1]['created']) if event_count else now_ts
@@ -1750,7 +1781,8 @@ def normalize_param(param_value):
while reqs:
reqs = []
for rs in rss:
- events = rs.get('audit_event_overview_report_rows')
+ raise_if_audit_report_failed(rs, 'audit-report')
+ events = rs.get('audit_event_overview_report_rows') or []
for event in events:
if misc_fields:
lenf = len(fields)
@@ -1823,7 +1855,8 @@ def normalize_param(param_value):
aggregate=req.get('aggregate'),
report_type=report_type,
limit=user_limit,
- order=req.get('order')
+ order=req.get('order'),
+ cmd='audit-report'
)
all_events.extend(events)
@@ -2165,7 +2198,8 @@ def get_excluded(candidate_usernames, query_filter, username_field='username'):
columns=cols,
aggregate=['last_created'],
report_type='span',
- limit=None # Get all matching events
+ limit=None, # Get all matching events
+ cmd='action-report'
)
# Extract usernames from events that match the candidate usernames
@@ -2218,7 +2252,8 @@ def get_latest_lock_times(usernames):
columns=['to_username'],
aggregate=['last_created'],
report_type='span',
- limit=API_EVENT_SUMMARY_ROW_LIMIT
+ limit=API_EVENT_SUMMARY_ROW_LIMIT,
+ cmd='action-report'
)
for event in events:
username = (event.get('to_username') or '').lower()
diff --git a/keepercommander/commands/base.py b/keepercommander/commands/base.py
index 572e0f8c0..861bb8567 100644
--- a/keepercommander/commands/base.py
+++ b/keepercommander/commands/base.py
@@ -152,6 +152,10 @@ def register_commands(commands, aliases, command_info):
device_management.register_commands(commands)
device_management.register_command_info(aliases, command_info)
+ from . import notification
+ notification.register_commands(commands)
+ notification.register_command_info(aliases, command_info)
+
from . import nested_share_folder
nested_share_folder.register_commands(commands)
nested_share_folder.register_command_info(aliases, command_info)
@@ -709,6 +713,14 @@ def normalize_output_param(args: str) -> str:
return args
+def tokenize_command_args(args, envvars=None):
+ """Tokenize command arguments using the same transforms as Command.execute_args."""
+ args = '' if args is None else args
+ if envvars is not None:
+ args = expand_cmd_args(args, envvars)
+ return shlex.split(normalize_output_param(args))
+
+
def as_boolean(value, default=None): # type: (Any, Optional[bool]) -> bool
if isinstance(value, bool):
return value
@@ -765,14 +777,13 @@ def execute_args(self, params, args, **kwargs):
alias_cmd = d.get('command')
if alias_cmd and alias_cmd != parser.prog:
parser.prog = alias_cmd
- args = expand_cmd_args(args, envvars)
- args = normalize_output_param(args)
+ command_tokens = tokenize_command_args(args, envvars)
if self.support_extra_parameters():
- opts, extra_args = parser.parse_known_args(shlex.split(args))
+ opts, extra_args = parser.parse_known_args(command_tokens)
if extra_args:
self.extra_parameters = ' '.join(extra_args)
else:
- opts = parser.parse_args(shlex.split(args))
+ opts = parser.parse_args(command_tokens)
d.update(opts.__dict__)
return self.execute(params, **d)
diff --git a/keepercommander/commands/compliance.py b/keepercommander/commands/compliance.py
index ffbff8930..791883dcf 100644
--- a/keepercommander/commands/compliance.py
+++ b/keepercommander/commands/compliance.py
@@ -18,7 +18,7 @@
from ..sox import sox_types, get_node_id
from ..sox.sox_data import SoxData
from ..sox.storage_types import StorageRecordAging
-from .aram import API_EVENT_SUMMARY_ROW_LIMIT
+from .aram import API_EVENT_SUMMARY_ROW_LIMIT, raise_if_audit_report_failed
compliance_parser = argparse.ArgumentParser(add_help=False, parents=[report_output_parser])
rebuild_group = compliance_parser.add_mutually_exclusive_group()
@@ -228,8 +228,10 @@ def get_requests(filter_recs, filter_type, order='descending', aggregate='last_c
for stat in aging_stats:
start, count = request_slices[stat]
stat_responses = responses[start:start + count]
+ for response in stat_responses:
+ raise_if_audit_report_failed(response, 'compliance report')
events = list(itertools.chain.from_iterable(
- rs.get('audit_event_overview_report_rows', []) for rs in stat_responses
+ (rs.get('audit_event_overview_report_rows') or []) for rs in stat_responses
))
aggregate = 'first_created' if stat == 'created' else 'last_created'
record_timestamps = {e.get('record_uid'): e.get(aggregate) for e in events if e.get('record_uid')}
@@ -238,8 +240,10 @@ def get_requests(filter_recs, filter_type, order='descending', aggregate='last_c
# Parse event 80 occurrence counts
pw_count_start, pw_count_n = request_slices['_pw_count']
pw_count_responses = responses[pw_count_start:pw_count_start + pw_count_n]
+ for response in pw_count_responses:
+ raise_if_audit_report_failed(response, 'compliance report')
pw_count_events = list(itertools.chain.from_iterable(
- rs.get('audit_event_overview_report_rows', []) for rs in pw_count_responses
+ (rs.get('audit_event_overview_report_rows') or []) for rs in pw_count_responses
))
pw_occurrences = {e.get('record_uid'): int(e.get('occurrences', 0))
for e in pw_count_events if e.get('record_uid')}
@@ -824,9 +828,11 @@ def get_records_accessed(emails, limit_to_vault=False):
spinner.message = f'Loading record access events - Users remaining: {len(users_to_query)}/{total_users}'
requests = [get_records_accessed_rq(email, **filters_by_user.get(email)) for email in users_to_query]
responses = api.execute_batch(params, requests)
+ for response in responses:
+ raise_if_audit_report_failed(response, 'compliance record-access-report')
responses_by_user = zip(users_to_query, responses)
for user, response in responses_by_user:
- access_events = response.get('audit_event_overview_report_rows', [])
+ access_events = response.get('audit_event_overview_report_rows') or []
records_accessed = records_accessed_by_user.get(user, {})
records_accessed_new, filters = process_access_events(access_events, filter_recs=filters_by_user.get(user, {}).get('filter_recs'))
for rec_uid, event in records_accessed_new.items():
diff --git a/keepercommander/commands/enterprise.py b/keepercommander/commands/enterprise.py
index dc5784e00..49bd6d471 100644
--- a/keepercommander/commands/enterprise.py
+++ b/keepercommander/commands/enterprise.py
@@ -4014,7 +4014,8 @@ def get_user_info(user):
report_filter['username'] = missing[:limit]
missing = missing[limit:]
rs = api.communicate(params, rq)
- report_rows = rs['audit_event_overview_report_rows']
+ aram.raise_if_audit_report_failed(rs, 'user-report')
+ report_rows = rs.get('audit_event_overview_report_rows') or []
last_login.update({row.get('username', '').lower(): row.get('last_created') for row in report_rows})
get_fmt_dt = lambda x: dt_module.fromtimestamp(x, datetime.timezone.utc)
@@ -4335,14 +4336,14 @@ def execute(self, params, **kwargs):
'limit': 1000
}
rs = api.communicate(params, rq)
+ aram.raise_if_audit_report_failed(rs, 'device-approve')
ip_map = {}
- if 'audit_event_overview_report_rows' in rs:
- for row in rs['audit_event_overview_report_rows']:
- if 'username' in row and 'ip_address' in row:
- uname = row['username']
- if uname not in ip_map:
- ip_map[uname] = set()
- ip_map[uname].add(row['ip_address'])
+ for row in rs.get('audit_event_overview_report_rows') or []:
+ if 'username' in row and 'ip_address' in row:
+ uname = row['username']
+ if uname not in ip_map:
+ ip_map[uname] = set()
+ ip_map[uname].add(row['ip_address'])
# Filter out users that tried to login from an untrusted IP
trusted_devices = {} # To avoid array modification in a loop, we will store this into a separated dict
diff --git a/keepercommander/commands/helpers/enterprise.py b/keepercommander/commands/helpers/enterprise.py
index 97179f5f2..516b46d04 100644
--- a/keepercommander/commands/helpers/enterprise.py
+++ b/keepercommander/commands/helpers/enterprise.py
@@ -78,14 +78,29 @@ def user_has_privilege(params, privilege): # type: (KeeperParams, str) -> bool
return False
# Check role-derived privileges
- username = params.user
- users = enterprise.get('users')
- e_user_id = next(iter([u.get('enterprise_user_id') for u in users if u.get('username') == username]))
- role_users = enterprise.get('role_users')
- r_ids = [ru.get('role_id') for ru in role_users if ru.get('enterprise_user_id') == e_user_id]
- r_privileges = enterprise.get('role_privileges')
- p_key = 'privilege'
- return any(rp for rp in r_privileges if rp.get('role_id') in r_ids and rp.get(p_key) == privilege)
+ username = (params.user or '').casefold()
+ users = enterprise.get('users') or []
+ e_user_id = next(
+ (u.get('enterprise_user_id') for u in users
+ if (u.get('username') or '').casefold() == username),
+ None
+ )
+ if e_user_id is None:
+ return False
+
+ role_users = enterprise.get('role_users') or []
+ r_ids = {
+ ru.get('role_id') for ru in role_users
+ if ru.get('enterprise_user_id') == e_user_id
+ }
+ if not r_ids:
+ return False
+
+ r_privileges = enterprise.get('role_privileges') or []
+ return any(
+ rp.get('role_id') in r_ids and rp.get('privilege') == privilege
+ for rp in r_privileges
+ )
def get_enterprise_key(params, is_rsa=False):
keys = params.enterprise.get('keys', {})
diff --git a/keepercommander/commands/ksm.py b/keepercommander/commands/ksm.py
index a80c5ad05..76e483ebd 100644
--- a/keepercommander/commands/ksm.py
+++ b/keepercommander/commands/ksm.py
@@ -774,7 +774,7 @@ def _usage_metrics(params, **kwargs):
audit_filter = {'audit_event_type': [KSM_USAGE_EVENT_TYPE], 'created': created_filter}
rows = fetch_audit_events(
params, audit_filter, columns=['app_uid', 'device_name', 'username'],
- aggregate=['occurrences'], report_type='span', limit=limit)
+ aggregate=['occurrences'], report_type='span', limit=limit, cmd='secrets-manager usage')
# Aggregation maps mirror Console getSecretsManagerMetrics. The *_apps maps track which
# application UID(s) contributed to each row so we can flag rows whose app has been deleted
@@ -928,7 +928,7 @@ def _usage_timeline(params, **kwargs):
audit_filter = {'audit_event_type': KSM_EVENT_TYPES, 'created': created_filter}
rows = fetch_audit_events(
params, audit_filter, columns=['audit_event_type'], aggregate=['occurrences'],
- report_type=report_type, limit=limit, order='descending')
+ report_type=report_type, limit=limit, order='descending', cmd='secrets-manager usage')
def label(evt):
return AUDIT_EVENT_STATE_MAPPING.get(evt, evt)
@@ -2475,4 +2475,3 @@ def get_hash_of_one_time_token(one_time_token):
digest).digest())
return one_time_token_hash
-
diff --git a/keepercommander/commands/notification.py b/keepercommander/commands/notification.py
new file mode 100644
index 000000000..4c275ecb6
--- /dev/null
+++ b/keepercommander/commands/notification.py
@@ -0,0 +1,283 @@
+# _ __
+# | |/ /___ ___ _ __ ___ _ _ ®
+# | ' -_) -_) '_ \/ -_) '_|
+# |_|\_\___\___| .__/\___|_|
+# |_|
+#
+# Keeper Commander
+# Copyright 2026 Keeper Security Inc.
+# Contact: ops@keepersecurity.com
+#
+
+import argparse
+import datetime
+import json
+import logging
+from typing import Any, Dict, List, Optional
+
+from . import base
+from .helpers import report_utils
+from .. import api, crypto, notification_center, utils
+from ..notification_center import NC, NotificationEntry
+from ..params import KeeperParams
+from ..proto import APIRequest_pb2, GraphSync_pb2, NotificationCenter_pb2, workflow_pb2
+
+
+def register_commands(commands):
+ commands['notification'] = NotificationCommand()
+
+
+def register_command_info(aliases, command_info):
+ aliases['ntf'] = 'notification'
+ command_info['notification'] = 'Notification Center: view and act on notifications'
+
+
+class NotificationCommand(base.GroupCommandNew):
+ def __init__(self):
+ super().__init__('Notification Center: view and act on notifications')
+ self.register_command_new(NotificationListCommand(), 'list', 'l')
+ self.register_command_new(NotificationViewCommand(), 'view', 'v')
+ self.register_command_new(NotificationReadCommand(), 'read')
+ self.register_command_new(NotificationUnreadCommand(), 'unread')
+ self.register_command_new(NotificationApproveCommand(), 'approve')
+ self.register_command_new(NotificationDenyCommand(), 'deny')
+ self.default_verb = 'list'
+
+
+def _format_time(ts: int) -> Optional[datetime.datetime]:
+ return datetime.datetime.fromtimestamp(ts) if ts else None
+
+
+def _ref_type_name(ref_type: int) -> str:
+ try:
+ return GraphSync_pb2.RefType.Name(ref_type)
+ except ValueError:
+ return str(ref_type)
+
+
+def _resolve_entry(params: KeeperParams, uid: str) -> NotificationEntry:
+ entry = params.notification_cache.get(uid)
+ if entry is None:
+ matches = [x for x in notification_center.get_notifications(params) if x.uid.startswith(uid)]
+ if len(matches) == 1:
+ entry = matches[0]
+ elif len(matches) > 1:
+ raise base.CommandError(f'Notification UID prefix "{uid}" is ambiguous')
+ if entry is None or entry.notification is None:
+ raise base.CommandError(f'Notification "{uid}" not found. Run "sync-down" to refresh notifications')
+ return entry
+
+
+def _send_read_status(params: KeeperParams, uids: List[str], status: int) -> None:
+ for i in range(0, len(uids), 1000):
+ rq = NotificationCenter_pb2.ProcessMarkReadEventsRequest()
+ for uid in uids[i:i + 1000]:
+ rsu = NotificationCenter_pb2.ReadStatusUpdate()
+ rsu.notificationUid = utils.base64_url_decode(uid)
+ rsu.status = status
+ rq.readStatusUpdate.append(rsu)
+ api.communicate_rest(params, rq, 'notification/process_mark_read_events')
+ notification_center.apply_local_read_status(params, uids, status)
+
+
+class NotificationListCommand(base.ArgparseCommand):
+ def __init__(self):
+ parser = argparse.ArgumentParser(prog='list', description='List notifications', parents=[base.report_output_parser])
+ parser.add_argument('--unread', dest='unread', action='store_true', help='show unread notifications only')
+ parser.add_argument('--pending', dest='pending', action='store_true', help='show notifications awaiting approve/deny only')
+ parser.add_argument('--all', dest='all', action='store_true', help='include notification types the Vault hides')
+ super().__init__(parser)
+
+ def execute(self, params: KeeperParams, **kwargs) -> Any:
+ fmt = kwargs.get('format')
+ headers = ['notification_uid', 'created', 'type', 'title', 'from', 'message', 'read', 'status']
+ table = [] # type: List[List[Any]]
+ for entry in notification_center.get_notifications(params):
+ n = entry.notification
+ if not kwargs.get('all') and n.type in notification_center.HIDDEN_TYPES:
+ continue
+ if kwargs.get('unread') and entry.is_read:
+ continue
+ if kwargs.get('pending') and not notification_center.is_actionable(entry):
+ continue
+ table.append([entry.uid, _format_time(entry.timestamp), notification_center.type_name(n.type),
+ notification_center.TYPE_TITLES.get(n.type, ''), notification_center.sender_name(n),
+ notification_center.get_message(params, entry), entry.is_read,
+ notification_center.status_label(entry)])
+ if fmt != 'json':
+ headers = [report_utils.field_to_title(x) for x in headers]
+ return report_utils.dump_report_data(table, headers, fmt=fmt, filename=kwargs.get('output'), row_number=True)
+
+
+class NotificationViewCommand(base.ArgparseCommand):
+ def __init__(self):
+ parser = argparse.ArgumentParser(prog='view', description='Show notification details')
+ parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], default='table', help='output format')
+ parser.add_argument('uid', help='Notification UID or a unique UID prefix')
+ super().__init__(parser)
+
+ def execute(self, params: KeeperParams, **kwargs) -> Any:
+ entry = _resolve_entry(params, kwargs.get('uid'))
+ n = entry.notification
+ details = {
+ 'notification_uid': entry.uid,
+ 'type': notification_center.type_name(n.type),
+ 'title': notification_center.TYPE_TITLES.get(n.type, ''),
+ 'created': str(_format_time(entry.timestamp)),
+ 'from': notification_center.sender_name(n),
+ 'message': notification_center.get_message(params, entry),
+ 'read': entry.is_read,
+ 'read_status': NC.NotificationReadStatus.Name(entry.read_status),
+ 'approval_status': notification_center.status_label(entry),
+ } # type: Dict[str, Any]
+ if entry.approval_timestamp:
+ details['approval_status_time'] = str(_format_time(entry.approval_timestamp))
+ details['refs'] = [f'{_ref_type_name(x.type)}: {utils.base64_url_encode(x.value)}' + (f' ({x.name})' if x.name else '')
+ for x in n.refs]
+
+ if n.type == NC.NT_DEVICE_APPROVAL:
+ data = notification_center.decrypt_device_approval(params, n)
+ if data:
+ details['device'] = {k: v for k, v in data.items() if k != 'publicKey'}
+ elif n.type == NC.NT_APPROVAL_REQUEST:
+ for key in ('reason', 'ticket'):
+ value = notification_center.decrypt_record_parameter(params, n, key)
+ if value:
+ details[key] = value
+
+ if kwargs.get('format') == 'json':
+ return json.dumps(details, indent=2, default=str)
+ table = []
+ for key, value in details.items():
+ if isinstance(value, dict):
+ value = [f'{k}: {v}' for k, v in value.items()]
+ table.append([report_utils.field_to_title(key), value])
+ base.dump_report_data(table, ['key', 'value'], no_header=True)
+
+
+class NotificationReadCommand(base.ArgparseCommand):
+ def __init__(self):
+ parser = argparse.ArgumentParser(prog='read', description='Mark notifications as read')
+ parser.add_argument('--all', dest='all', action='store_true', help='mark all notifications as read')
+ parser.add_argument('uid', nargs='*', help='Notification UID or a unique UID prefix')
+ super().__init__(parser)
+
+ def execute(self, params: KeeperParams, **kwargs) -> None:
+ if kwargs.get('all'):
+ entries = notification_center.get_notifications(params)
+ if not entries:
+ return
+ # NRS_LAST on the newest notification marks it and every older notification as read.
+ _send_read_status(params, [entries[0].uid], NC.NRS_LAST)
+ return
+ uids = [_resolve_entry(params, x).uid for x in kwargs.get('uid') or []]
+ if not uids:
+ raise base.CommandError('Specify notification UID(s) or --all')
+ _send_read_status(params, uids, NC.NRS_READ)
+
+
+class NotificationUnreadCommand(base.ArgparseCommand):
+ def __init__(self):
+ parser = argparse.ArgumentParser(prog='unread', description='Mark notifications as unread')
+ parser.add_argument('uid', nargs='+', help='Notification UID or a unique UID prefix')
+ super().__init__(parser)
+
+ def execute(self, params: KeeperParams, **kwargs) -> None:
+ uids = [_resolve_entry(params, x).uid for x in kwargs.get('uid') or []]
+ _send_read_status(params, uids, NC.NRS_UNREAD)
+
+
+class _NotificationActionCommand(base.ArgparseCommand):
+ def __init__(self, deny: bool):
+ verb = 'deny' if deny else 'approve'
+ parser = argparse.ArgumentParser(prog=verb, description=f'{verb.capitalize()} a request notification')
+ parser.add_argument('uid', help='Notification UID or a unique UID prefix')
+ if deny:
+ parser.add_argument('-r', '--reason', dest='reason', action='store', help='denial reason (PAM workflow requests only)')
+ super().__init__(parser)
+ self.deny = deny
+
+ def execute(self, params: KeeperParams, **kwargs) -> None:
+ entry = _resolve_entry(params, kwargs.get('uid'))
+ n = entry.notification
+ if n.type not in notification_center.ACTION_TYPES:
+ raise base.CommandError(f'Notification type {notification_center.type_name(n.type)} has no approve/deny action')
+ if entry.approval_status != NC.NAS_UNSPECIFIED:
+ raise base.CommandError(f'Request is already {notification_center.approval_status_name(entry.approval_status)}')
+
+ if n.type == NC.NT_DEVICE_APPROVAL:
+ _act_device_approval(params, n, self.deny)
+ elif n.type == NC.NT_SHARE_APPROVAL:
+ _act_share_approval(params, n, self.deny)
+ elif n.type == NC.NT_APPROVAL_REQUEST:
+ _act_workflow_approval(params, n, self.deny, kwargs.get('reason'))
+
+ # The server posts the approval status for the notification; a sync picks it up.
+ if not entry.is_read:
+ try:
+ _send_read_status(params, [entry.uid], NC.NRS_READ)
+ except Exception as e:
+ logging.debug('Mark notification read error: %s', e)
+ params.sync_data = True
+ logging.info('Request %s', 'denied' if self.deny else 'approved')
+
+
+class NotificationApproveCommand(_NotificationActionCommand):
+ def __init__(self):
+ super().__init__(deny=False)
+
+
+class NotificationDenyCommand(_NotificationActionCommand):
+ def __init__(self):
+ super().__init__(deny=True)
+
+
+def _act_device_approval(params: KeeperParams, n: NotificationCenter_pb2.Notification, deny: bool) -> None:
+ data = notification_center.decrypt_device_approval(params, n)
+ public_key = data.get('publicKey') if data else None
+ if not public_key:
+ raise base.CommandError('Cannot decrypt device approval notification')
+ device_public_key = utils.base64_url_decode(public_key)
+
+ from ..loginv3 import LoginV3API
+ summary = LoginV3API.accountSummary(params)
+ device = next((x for x in summary.devices if x.devicePublicKey == device_public_key), None)
+ if device is None:
+ raise base.CommandError('The device requesting approval was not found')
+
+ rq = APIRequest_pb2.ApproveDeviceRequest()
+ rq.encryptedDeviceToken = device.encryptedDeviceToken
+ if deny:
+ rq.denyApproval = True
+ elif summary.settings.ssoUser:
+ rq.encryptedDeviceDataKey = crypto.encrypt_ec(params.data_key, crypto.load_ec_public_key(device_public_key))
+ api.communicate_rest(params, rq, 'authentication/approve_device')
+
+
+def _act_share_approval(params: KeeperParams, n: NotificationCenter_pb2.Notification, deny: bool) -> None:
+ from_email = n.sender.name
+ if not from_email:
+ raise base.CommandError('Share request has no sender email')
+ rq = {'command': 'cancel_share' if deny else 'accept_share', 'from_email': from_email}
+ api.communicate(params, rq)
+ params.pending_share_requests.discard(from_email)
+
+
+def _act_workflow_approval(params: KeeperParams, n: NotificationCenter_pb2.Notification, deny: bool, reason: Optional[str]) -> None:
+ from .pam.router_helper import _post_request_to_router
+ wf_ref = notification_center.get_ref(n, GraphSync_pb2.RFT_WORKFLOW)
+ if not wf_ref:
+ raise base.CommandError('Approval request has no workflow reference')
+ rq = workflow_pb2.WorkflowApprovalOrDenial()
+ rq.flowUid = wf_ref.value
+ rq.deny = deny
+ if deny and reason:
+ # Same encryption as the Vault: AES-GCM with the key of the requested record.
+ rec_ref = notification_center.get_ref(n, GraphSync_pb2.RFT_REC)
+ r_key = notification_center.record_key(params, utils.base64_url_encode(rec_ref.value)) if rec_ref else None
+ if r_key:
+ rq.denialReason = crypto.encrypt_aes_v2(reason.encode('utf-8'), r_key)
+ else:
+ logging.warning('Record key is not available; the denial reason is not attached')
+ _post_request_to_router(params, 'approve_or_deny_workflow_access', rq_proto=rq)
+
diff --git a/keepercommander/commands/pam/pam_dto.py b/keepercommander/commands/pam/pam_dto.py
index b1e056b49..b64a4ffbf 100644
--- a/keepercommander/commands/pam/pam_dto.py
+++ b/keepercommander/commands/pam/pam_dto.py
@@ -396,9 +396,10 @@ def toJSON(self):
class GatewayActionUniversalSyncRun(GatewayAction):
- def __init__(self, inputs: GatewayActionUniversalSyncRunInputs, conversation_id=None, gateway_destination=None):
+ def __init__(self, inputs: GatewayActionUniversalSyncRunInputs, conversation_id=None, gateway_destination=None,
+ is_scheduled=True):
super().__init__('universal-secrets-sync', inputs=inputs, conversation_id=conversation_id,
- gateway_destination=gateway_destination, is_scheduled=True)
+ gateway_destination=gateway_destination, is_scheduled=is_scheduled)
def toJSON(self):
return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True, indent=4)
diff --git a/keepercommander/commands/pam/router_helper.py b/keepercommander/commands/pam/router_helper.py
index 40aa9bf3b..b9aa61208 100644
--- a/keepercommander/commands/pam/router_helper.py
+++ b/keepercommander/commands/pam/router_helper.py
@@ -753,7 +753,7 @@ class _AccessElevationSocket:
relays its terminal response over the caller's own open socket instead of the HTTP response --
krouter rejects the request outright if streamResponse=true but no socket is open."""
- def __init__(self, params, router_tokens, cookie_header):
+ def __init__(self, params, router_tokens, cookie_header, frame_parser=None):
from ..tunnel.port_forward.tunnel_helpers import WEBSOCKETS_VERSION, websockets_connect
if WEBSOCKETS_VERSION is None:
raise CommandError('', 'The websockets library is required for this action.')
@@ -761,6 +761,7 @@ def __init__(self, params, router_tokens, cookie_header):
encrypted_session_token, encrypted_transmission_key, _ = router_tokens
self._connect = websockets_connect
self._version = WEBSOCKETS_VERSION
+ self._frame_parser = frame_parser or _parse_access_elevation_frame
self.url = get_router_ws_url(params) + '/api/user/client'
self.headers = {
'TransmissionKey': bytes_to_base64(encrypted_transmission_key),
@@ -775,6 +776,7 @@ def __init__(self, params, router_tokens, cookie_header):
self._ssl_context.check_hostname = False
self._ssl_context.verify_mode = ssl.CERT_NONE
self._data = None
+ self._error = None
self._ready = threading.Event()
self._done = threading.Event()
self._stop = threading.Event()
@@ -785,6 +787,7 @@ def _run(self):
asyncio.run(self._listen())
except Exception as e:
logging.debug('Access elevation socket listener exited: %r', e)
+ self._error = e
finally:
self._ready.set()
self._done.set()
@@ -802,9 +805,10 @@ async def _listen(self):
msg = await asyncio.wait_for(ws.recv(), timeout=1.0)
except asyncio.TimeoutError:
continue
- except Exception:
+ except Exception as e:
+ self._error = e
break
- data = _parse_access_elevation_frame(msg)
+ data = self._frame_parser(msg)
if data is not None:
self._data = data
self._done.set()
@@ -820,17 +824,22 @@ def stop(self):
self._thread.join(timeout=5)
def drain_terminal_response(self, timeout=90):
- return self._data if self._done.wait(timeout=timeout) else None
+ if not self._done.wait(timeout=timeout):
+ return None
+ if self._data is None and self._error is not None:
+ raise CommandError('', f'The WebSocket connection to the router failed: {self._error}')
+ return self._data
-def dispatch_streamed_access_elevation_action(params, gateway_action, destination_gateway_uid_str=None, timeout=90):
- """Dispatch an ACCESS_ELEVATION gateway action that requires streamResponse=true (currently
- only rm-grant-temporary-access-token) and return the parsed terminal frame's `data` dict.
+def dispatch_streamed_gateway_action(params, gateway_action, message_type, frame_parser=None,
+ destination_gateway_uid_str=None, timeout=90):
+ """Dispatch a gateway action with streamResponse=true and return the terminal frame's `data` dict
+ (as extracted by `frame_parser`), or None if no terminal frame arrived within `timeout` seconds.
Opens a short-lived socket to /api/user/client first (see _AccessElevationSocket), binds the
HTTP dispatch to the same krouter worker for ALB stickiness, then sends the action with
is_streaming=True and waits for the result to arrive over the socket. Raises CommandError on
- missing/ambiguous gateway, transport failure, or timeout.
+ missing/ambiguous gateway or transport failure.
"""
from ..tunnel.port_forward.tunnel_helpers import get_keeper_tokens
@@ -860,13 +869,13 @@ def dispatch_streamed_access_elevation_action(params, gateway_action, destinatio
cookie_header = ('; '.join(f'{c.name}={c.value}' for c in http_session.cookies)
if http_session.cookies else None)
- socket = _AccessElevationSocket(params, router_tokens, cookie_header)
+ socket = _AccessElevationSocket(params, router_tokens, cookie_header, frame_parser=frame_parser)
socket.start()
try:
response = router_send_action_to_gateway(
params=params,
gateway_action=gateway_action,
- message_type=pam_pb2.CMT_WORKFLOW_ACCESS_ELEVATION,
+ message_type=message_type,
is_streaming=True,
destination_gateway_uid_str=destination_gateway_uid_str,
gateway_timeout=timeout * 1000,
@@ -877,10 +886,77 @@ def dispatch_streamed_access_elevation_action(params, gateway_action, destinatio
)
if response is None:
raise CommandError('', 'The Gateway did not respond.')
- data = socket.drain_terminal_response(timeout=timeout)
+ return socket.drain_terminal_response(timeout=timeout)
finally:
socket.stop()
+
+def dispatch_streamed_access_elevation_action(params, gateway_action, destination_gateway_uid_str=None, timeout=90):
+ """Dispatch an ACCESS_ELEVATION gateway action that requires streamResponse=true (currently
+ only rm-grant-temporary-access-token) and return the parsed terminal frame's `data` dict.
+ Raises CommandError on missing/ambiguous gateway, transport failure, or timeout."""
+ data = dispatch_streamed_gateway_action(
+ params, gateway_action, pam_pb2.CMT_WORKFLOW_ACCESS_ELEVATION,
+ destination_gateway_uid_str=destination_gateway_uid_str, timeout=timeout)
if data is None:
raise CommandError('', 'Timed out waiting for the Gateway response.')
return data
+
+
+def make_uss_frame_parser(conversation_id):
+ """Build a WebSocket frame parser that returns the `data` dict of the terminal Universal Secrets Sync
+ response for `conversation_id`. The router may send a single frame object or a JSON array of them. The
+ "Queued" ack (data has only Response/jobId), progress frames (progressFlag other than 0/COMPLETE or
+ 1/FAIL) and frames belonging to other conversations are ignored."""
+ def norm(value):
+ # Tolerate padding and url-safe vs. standard base64 differences between the router and Commander
+ if not isinstance(value, str):
+ return None
+ return value.rstrip('=').replace('+', '-').replace('/', '_')
+
+ wanted = norm(conversation_id)
+ ack_keys = {'response', 'jobid'}
+
+ def load(value):
+ if isinstance(value, (str, bytes)):
+ try:
+ return json.loads(value)
+ except Exception:
+ return value
+ return value
+
+ def parse_one(obj):
+ if not isinstance(obj, dict):
+ return None
+ payload = load(obj.get('payload'))
+ if not isinstance(payload, dict):
+ payload = obj
+ frame_id = norm(obj.get('conversationId')) or norm(payload.get('conversation_id')) \
+ or norm(payload.get('conversationId'))
+ if frame_id != wanted:
+ return None
+ flag = obj.get('progressFlag', payload.get('progressFlag'))
+ if flag is not None and flag not in (0, 1):
+ return None
+ if payload.get('is_ok') is False:
+ return {'error': json.dumps(payload.get('errors') or payload.get('data') or payload, default=str)}
+ data = load(payload.get('data'))
+ if isinstance(data, dict):
+ if {str(k).lower() for k in data} <= ack_keys:
+ return None
+ return data
+ return None
+
+ def parse(raw):
+ logging.debug('Router WebSocket frame: %.500s', raw)
+ try:
+ obj = json.loads(raw)
+ except Exception:
+ return None
+ for item in obj if isinstance(obj, list) else [obj]:
+ data = parse_one(item)
+ if data is not None:
+ return data
+ return None
+
+ return parse
diff --git a/keepercommander/commands/pam_import/cyberark_import.py b/keepercommander/commands/pam_import/cyberark_import.py
index 950404a48..94e35cb5f 100644
--- a/keepercommander/commands/pam_import/cyberark_import.py
+++ b/keepercommander/commands/pam_import/cyberark_import.py
@@ -25,7 +25,7 @@
import tempfile
import time
from dataclasses import dataclass, field
-from typing import Any, Optional
+from typing import Any, Dict, List, Optional
from prompt_toolkit import HTML, print_formatted_text
@@ -176,6 +176,7 @@ class ImportRunOptions:
strict_policies: bool = False
use_nsf: bool = False
raw_kwargs: dict = field(default_factory=dict)
+ add_cyberark_users: bool = False
@dataclass
@@ -198,6 +199,7 @@ class MappedImportResult:
class CyberArkImportOrchestrator:
"""Coordinates CyberArk PVWA fetch → map → vault import phases (SRP)."""
+ DELAY = 0.5 # seconds between CyberArk API calls to avoid throttling
def __init__(self, command: 'CyberArkPAMImportCommand', params, client: CyberArkPVWAClient,
options: ImportRunOptions):
@@ -271,6 +273,9 @@ def run(self) -> None:
# We still run the update path in case any records were
# tagged as UPDATE (their data changed even though no new
# records need to be created).
+ enterprise_users = None
+ if self.options.add_cyberark_users:
+ enterprise_users = self._provision_cyberark_users()
skip_vault_import = (
idempotency_ctx is not None
and not mapped.pam_resources
@@ -290,9 +295,80 @@ def run(self) -> None:
idempotency_ctx["update_summary"] = update_summary
self._finalize_report(
mapped, mapped.unmapped_items, project_result, total_accounts,
- user_team_matcher, idempotency_ctx,
+ user_team_matcher, idempotency_ctx, enterprise_users=enterprise_users
)
+ def _provision_cyberark_users(self) -> dict:
+ # Optional step: a CyberArk user-fetch failure must not abort the PAM import.
+ print_formatted_text(HTML("\nFetching CyberArk Users..."))
+ try:
+ cyberark_users = self._prepare_users()
+ except Exception as e:
+ logging.warning('Failed to fetch CyberArk users: %s', e)
+ return {"users": [], "error": "Failed to fetch CyberArk users"}
+ return self._cmd._create_enterprise_users(self.params, cyberark_users)
+
+ @staticmethod
+ def _get_case_insensitive(data: dict, target_key: str) -> Optional[Any]:
+ if not isinstance(data, dict):
+ return None
+ if target_key in data:
+ return data[target_key]
+ target_lower = target_key.lower()
+ return next((v for k, v in data.items() if str(k).lower() == target_lower), None)
+
+
+ def _extract_user_email(self, user):
+ """Return the best available email for a CyberArk user dict, or None."""
+ internet = self._get_case_insensitive(user, "internet")
+ personal = self._get_case_insensitive(user, "personaldetails")
+ sources = (
+ (internet, "businessEmail"),
+ (internet, "homeEmail"),
+ (internet, "otherEmail"),
+ (user, "email"),
+ (personal, "email"),
+ )
+ for target_dict, target_key in sources:
+ email = self._get_case_insensitive(target_dict, target_key)
+ # Edge Case : if email = " " --> condition checks that it is not None or empty string
+ if isinstance(email, str):
+ sanitized_email = email.strip()
+ if sanitized_email:
+ return sanitized_email
+ return None
+
+ def _get_user_details(self, id):
+ try:
+ user_detail = self.client.fetch_user_details(id)
+ return user_detail
+ except Exception as e:
+ logging.warning('Failed to fetch user with id "%s": %s', id, e)
+ return None
+
+ def _prepare_users(self): # type: () -> list[dict]
+ all_users = self.client.fetch_users()
+ result = []
+ for u in all_users:
+ if isinstance(u, dict):
+ email = self._extract_user_email(u)
+ user_id = u.get("id")
+ if not email and user_id not in (None,""):
+ time.sleep(self.DELAY)
+ user_details = self._get_user_details(user_id)
+ email = self._extract_user_email(user_details)
+ # in user_details also if we did not find the email, we will not add the user to the result
+ if email:
+ result.append(
+ {
+ "id": user_id,
+ "username": u.get("username") or "",
+ "email": email
+ }
+ )
+ print_formatted_text(HTML(f"Found {len(result)} CyberArk users "))
+ return result
+
def _load_master_policy(self, unmapped_items: list[dict]) -> dict:
master_policy_config = dict(MasterPolicyMapper.DEFAULTS)
print_formatted_text(HTML("Fetching Master Policy..."))
@@ -1029,7 +1105,7 @@ def _populate_folder_info(self, project_result: dict, project_name: str) -> None
def _finalize_report(self, mapped: MappedImportResult, unmapped_items: list[dict],
project_result: Optional[dict], total_accounts: int,
user_team_matcher: Optional[UserTeamMatcher],
- idempotency_ctx: Optional[dict] = None):
+ idempotency_ctx: Optional[dict] = None, enterprise_users: Optional[list] = None):
if project_result is None:
return
dependent_summary = self._apply_service_dependent_mappings(
@@ -1053,6 +1129,7 @@ def _finalize_report(self, mapped: MappedImportResult, unmapped_items: list[dict
project_result=project_result,
unmapped_platforms=mapped.mapper.unmapped_platforms,
unmapped_items=unmapped_items,
+ enterprise_users=enterprise_users,
server=self.options.server,
)
notes_text = report_text
@@ -2019,6 +2096,9 @@ class CyberArkPAMImportCommand(Command):
help="Fail the import when a platform's rotation policy "
"is inaccessible instead of inheriting the master "
"policy default")
+ parser.add_argument("--add-cyberark-users", required=False, dest="add_cyberark_users",
+ action="store_true", default=False,
+ help="Create CybeArk Users in Keeper Admin Console")
def get_parser(self):
return CyberArkPAMImportCommand.parser
@@ -2044,6 +2124,7 @@ def execute(self, params, **kwargs):
batch_delay = float(kwargs.get("batch_delay") or 0.5)
platform_map_file = kwargs.get("platform_map", "")
state_filter_str = kwargs.get("state_filter", "")
+ add_cyberark_users = kwargs.get("add_cyberark_users", False)
# Validate batch parameters
if batch_size < 1:
@@ -2150,6 +2231,7 @@ def execute(self, params, **kwargs):
strict_policies=bool(kwargs.get("strict_policies", False)),
use_nsf=kwargs.get("use_nsf", False) is True,
raw_kwargs=kwargs,
+ add_cyberark_users=add_cyberark_users
)
CyberArkImportOrchestrator(self, params, client, options).run()
finally:
@@ -2178,6 +2260,84 @@ def _execute_import(self, params, import_data: dict, project_name: str,
resources, users, batch_size, batch_delay, use_nsf=use_nsf,
)
+ def _load_existing_keeper_users(self, params) -> tuple[list, str]:
+ # Fetch the initial data
+ enterprise_data = getattr(params, "enterprise", None)
+ if not enterprise_data:
+ try:
+ api.query_enterprise(params)
+ except Exception as e:
+ logging.warning("Error loading existing keeper users: %s", e)
+ return [], "Failed to Load Existing User"
+ # After running the query_enterprise, we will have the enterprise data in the params
+ enterprise_data = getattr(params, "enterprise", None)
+ if not isinstance(enterprise_data, dict):
+ return [], "Failed to Load Existing User"
+ existing_users = []
+ for user in enterprise_data.get("users", []):
+ if isinstance(user, dict):
+ email = self._user_field(user, "email") or self._user_field(user, "username")
+ user_status = user.get("status") or "active"
+ user_details = {
+ "email": email,
+ "status": user_status,
+ }
+ existing_users.append(user_details)
+ return existing_users , ""
+
+ def _create_keeper_user(self, params, email: str, name: str) -> dict:
+ from ..enterprise_create_user import CreateEnterpriseUserCommand
+ try:
+ creator = CreateEnterpriseUserCommand()
+ created = creator.execute(params, email=email, name=name)
+ return created
+ except Exception as e:
+ logging.warning('Could not create enterprise user "%s": %s', email, e)
+ return None
+
+ @staticmethod
+ def _user_field(user: dict, key: str) -> str:
+ return str(user.get(key) or "").strip()
+
+ def _create_enterprise_users(self, params, cyberark_users: list[dict]) -> dict:
+ from ...constants import EMAIL_PATTERN
+ def row(user, flag, **extra):
+ item = {
+ "username": str(user.get("username") or ""),
+ "email": self._user_field(user, "email"),
+ "flag": flag,
+ }
+ item.update(extra)
+ return item
+ seen: set[str] = set()
+ outcomes = []
+ existing_users, error = self._load_existing_keeper_users(params)
+ if error:
+ print(error)
+ is_valid_email = re.compile(EMAIL_PATTERN).match
+ for user in cyberark_users:
+ if not isinstance(user, dict):
+ continue
+ email = self._user_field(user, "email")
+ email_key = email.casefold()
+ if email_key in seen:
+ continue
+ user_exist: dict[Any, Any] = next((u for u in existing_users if (u.get("email") or "").casefold() == email_key), None)
+ if user_exist:
+ seen.add(email_key)
+ status = user_exist.get("status") or "active"
+ outcomes.append(row(user, "existing", status=status))
+ elif is_valid_email(email):
+ seen.add(email_key)
+ create_user = self._create_keeper_user(params, email, user.get("username") or email)
+ if create_user:
+ outcomes.append(row(user, "created"))
+ else:
+ outcomes.append(row(user, "failed", reason="user was not created"))
+ else:
+ outcomes.append(row(user, "skipped", reason="invalid email"))
+ return {"users": outcomes}
+
def _single_batch_import(self, params, import_data: dict,
project_name: str, config_uid: str,
use_nsf: bool = False) -> dict:
diff --git a/keepercommander/commands/record_edit.py b/keepercommander/commands/record_edit.py
index 4b6bd2da3..1560a6e46 100644
--- a/keepercommander/commands/record_edit.py
+++ b/keepercommander/commands/record_edit.py
@@ -1572,6 +1572,23 @@ def execute(self, params, **kwargs):
subfolder_path = subfolder_path.replace(2*os.sep, os.sep)
if subfolder_path:
subfolder_path = os.path.join(output_dir, subfolder_path)
+
+ # Prevent path traversal: ensure resolved path stays within output_dir
+ resolved_path = os.path.abspath(subfolder_path)
+ resolved_output_dir = os.path.abspath(output_dir)
+
+ # Check if resolved path is under output_dir
+ try:
+ os.path.relpath(resolved_path, resolved_output_dir)
+ if not (resolved_path == resolved_output_dir or
+ resolved_path.startswith(resolved_output_dir + os.sep)):
+ raise CommandError('download-attachment',
+ f'Path traversal detected: folder name results in path outside output directory')
+ except ValueError:
+ # Paths on different drives on Windows
+ raise CommandError('download-attachment',
+ f'Path traversal detected: folder name results in path outside output directory')
+
if not os.path.isdir(subfolder_path):
os.makedirs(subfolder_path)
else:
diff --git a/keepercommander/commands/register.py b/keepercommander/commands/register.py
index 73b839576..ddd124166 100644
--- a/keepercommander/commands/register.py
+++ b/keepercommander/commands/register.py
@@ -31,6 +31,7 @@
from .helpers.record import get_record_uids
from .helpers.timeout import parse_timeout
from .record import RecordRemoveCommand
+from .aram import raise_if_audit_report_failed
from .utils import SyncDownCommand
from .. import api, utils, crypto, constants, rest_api, vault, vault_extensions
from ..display import bcolors
@@ -1831,9 +1832,10 @@ def get_record_share_activities(params: KeeperParams, record_uid: str):
return False
rs = api.communicate(params, rq)
+ raise_if_audit_report_failed(rs, 'share-report')
if rs['result'] == 'success':
- audit_events = rs['audit_event_overview_report_rows']
+ audit_events = rs.get('audit_event_overview_report_rows') or []
# sort list by creation date
audit_events = sorted(audit_events, key=lambda i: i['created'])
diff --git a/keepercommander/commands/ssh_agent.py b/keepercommander/commands/ssh_agent.py
index d7d53691d..30f128dcd 100644
--- a/keepercommander/commands/ssh_agent.py
+++ b/keepercommander/commands/ssh_agent.py
@@ -514,8 +514,9 @@ def _process_ssh_agent_request(self, request): # type: (bytes) -> bytes
raise ValueError(f'Unsupported EC key: {curve_name.decode()}')
public_key_bytes, pos = ssh_agent_get_next_value(request, pos)
- key.key_blob = ssh_agent_encode_bytes(key_type) + ssh_agent_encode_bytes(public_key_bytes)
- public_numbers = ec.EllipticCurvePublicNumbers.from_encoded_point(curve, public_key_bytes)
+ key.key_blob = ssh_agent_encode_bytes(key_type) + ssh_agent_encode_bytes(curve_name) + ssh_agent_encode_bytes(public_key_bytes)
+ public_key = ec.EllipticCurvePublicKey.from_encoded_point(curve, public_key_bytes)
+ public_numbers = public_key.public_numbers()
priv_key, pos = ssh_agent_get_next_value(request, pos)
private_int = int.from_bytes(priv_key, byteorder='big')
diff --git a/keepercommander/commands/universalsecretsync.py b/keepercommander/commands/universalsecretsync.py
index 2cdde30f5..5b3c51d15 100644
--- a/keepercommander/commands/universalsecretsync.py
+++ b/keepercommander/commands/universalsecretsync.py
@@ -143,6 +143,19 @@ def print_uss_configurations_list(params, format_type='table'):
logging.debug(f"Failed to decrypt vault_name for record {record.record_uid}: {e}")
vault_name = 'N/A'
+ # Decrypt object_storage_bucket if present. The router stores it under
+ # the 'objectStorageBucket' key as a base64-url string of the encrypted bytes.
+ object_storage_bucket = 'N/A'
+ object_storage_bucket_encrypted = config_data.get('objectStorageBucket')
+ if object_storage_bucket_encrypted:
+ try:
+ object_storage_bucket_bytes = crypto.decrypt_aes_v2(
+ utils.base64_url_decode(object_storage_bucket_encrypted), record.record_key)
+ object_storage_bucket = object_storage_bucket_bytes.decode('utf-8')
+ except Exception as e:
+ logging.debug(f"Failed to decrypt object_storage_bucket for record {record.record_uid}: {e}")
+ object_storage_bucket = 'N/A'
+
# Decrypt sync_identity if present. The router stores it under the
# 'syncIdentity' key as a base64-url string of the encrypted bytes; the
# decrypted value is the UID of the Identity record used for syncing.
@@ -216,6 +229,7 @@ def print_uss_configurations_list(params, format_type='table'):
'dry_run_enabled': config_data.get('dryRunEnabled', False),
'folder_count': folder_count,
'vault_name': vault_name,
+ 'object_storage_bucket': object_storage_bucket,
'sync_identity': sync_identity,
'scope': scope_str,
'owner': owner,
@@ -242,8 +256,9 @@ def print_uss_configurations_list(params, format_type='table'):
# Display as simple summary table
table = []
- headers = ['Network UID', 'Title', 'Type', 'Enabled', 'Dry Run', 'Folders', 'Vault Name', 'Sync Identity',
- 'Scope', 'Owner', 'Org Visibility', 'Repos', 'Vault Base URL']
+ headers = ['Network UID', 'Title', 'Type', 'Enabled', 'Dry Run', 'Folders', 'Vault Name',
+ 'Object Storage Bucket', 'Sync Identity', 'Scope', 'Owner', 'Org Visibility', 'Repos',
+ 'Vault Base URL']
for config in configs_data:
enabled_str = f"{bcolors.OKGREEN}Yes{bcolors.ENDC}" if config['enabled'] else f"{bcolors.FAIL}No{bcolors.ENDC}"
@@ -261,6 +276,7 @@ def print_uss_configurations_list(params, format_type='table'):
dry_run_str,
folders_str,
config['vault_name'],
+ config.get('object_storage_bucket', 'N/A'),
config['sync_identity'],
config.get('scope', 'N/A'),
config.get('owner', 'N/A'),
@@ -355,6 +371,19 @@ def print_uss_configuration_details(params, network_uid, format_type='table'):
logging.debug(f"Failed to decrypt vault_name for network {network.record_uid}: {e}")
vault_name = 'N/A'
+ # Decrypt object_storage_bucket if present. The router stores it under
+ # the 'objectStorageBucket' key as a base64-url string of the encrypted bytes.
+ object_storage_bucket = 'N/A'
+ object_storage_bucket_encrypted = config_data.get('objectStorageBucket')
+ if object_storage_bucket_encrypted:
+ try:
+ object_storage_bucket_bytes = crypto.decrypt_aes_v2(
+ utils.base64_url_decode(object_storage_bucket_encrypted), network.record_key)
+ object_storage_bucket = object_storage_bucket_bytes.decode('utf-8')
+ except Exception as e:
+ logging.debug(f"Failed to decrypt object_storage_bucket for network {network.record_uid}: {e}")
+ object_storage_bucket = 'N/A'
+
# Decrypt sync_identity if present. The router stores it under the
# 'syncIdentity' key as a base64-url string of the encrypted bytes; the
# decrypted value is the UID of the Identity record used for syncing.
@@ -465,6 +494,7 @@ def print_uss_configuration_details(params, network_uid, format_type='table'):
'enabled': config_data.get('enabled', False),
'dry_run_enabled': config_data.get('dryRunEnabled', False),
'vault_name': vault_name,
+ 'object_storage_bucket': object_storage_bucket,
'sync_identity': sync_identity,
'scope': scope_str,
'owner': owner,
@@ -501,6 +531,7 @@ def print_uss_configuration_details(params, network_uid, format_type='table'):
table.append(['Enabled', 'Yes' if config_data.get('enabled', False) else 'No'])
table.append(['Dry Run', 'Yes' if config_data.get('dryRunEnabled', False) else 'No'])
table.append(['Vault Name', vault_name])
+ table.append(['Object Storage Bucket', object_storage_bucket])
table.append(['Sync Identity', sync_identity])
table.append(['Scope', scope_str])
table.append(['Owner', owner])
@@ -568,6 +599,8 @@ class PAMUniversalSyncConfigAddCommand(Command):
help='Identity record UID to use for syncing')
parser.add_argument('--vault-name', '-vn', dest='vault_name', action='store',
help='Vault name for universal sync')
+ parser.add_argument('--object-storage-bucket', '-osb', dest='object_storage_bucket', action='store',
+ help='Object storage bucket for storing file reference content')
parser.add_argument('--scope', '-sc', dest='scope', action='store',
choices=['repository', 'organization'],
help='GitHub sync scope: a single repository or an entire organization')
@@ -636,6 +669,12 @@ def execute(self, params, **kwargs):
encrypted_vault_name = crypto.encrypt_aes_v2(vault_name_bytes, network.record_key)
rq.vaultName = encrypted_vault_name
+ object_storage_bucket = kwargs.get('object_storage_bucket')
+ if object_storage_bucket:
+ object_storage_bucket_bytes = string_to_bytes(object_storage_bucket)
+ encrypted_object_storage_bucket = crypto.encrypt_aes_v2(object_storage_bucket_bytes, network.record_key)
+ rq.objectStorageBucket = encrypted_object_storage_bucket
+
scope = kwargs.get('scope')
if scope is not None:
rq.github.scope = pam_pb2.ORGANIZATION if scope == 'organization' else pam_pb2.REPOSITORY
@@ -708,6 +747,8 @@ class PAMUniversalSyncConfigEditCommand(Command):
help='Identity record UID to use for syncing')
parser.add_argument('--vault-name', '-vn', dest='vault_name', action='store',
help='Vault name for universal sync')
+ parser.add_argument('--object-storage-bucket', '-osb', dest='object_storage_bucket', action='store',
+ help='Object storage bucket for storing file reference content')
parser.add_argument('--scope', '-sc', dest='scope', action='store',
choices=['repository', 'organization'],
help='GitHub sync scope: a single repository or an entire organization')
@@ -822,6 +863,14 @@ def execute(self, params, **kwargs):
elif existing_config.get('vaultName'):
rq.vaultName = utils.base64_url_decode(existing_config['vaultName'])
+ object_storage_bucket = kwargs.get('object_storage_bucket')
+ if object_storage_bucket:
+ object_storage_bucket_bytes = string_to_bytes(object_storage_bucket)
+ encrypted_object_storage_bucket = crypto.encrypt_aes_v2(object_storage_bucket_bytes, network.record_key)
+ rq.objectStorageBucket = encrypted_object_storage_bucket
+ elif existing_config.get('objectStorageBucket'):
+ rq.objectStorageBucket = utils.base64_url_decode(existing_config['objectStorageBucket'])
+
# GitHub-specific fields live under the nested 'github' object, both in the
# request message (rq.github) and in the existing DAG edge content.
existing_github = existing_config.get('github') or {}
@@ -951,7 +1000,7 @@ def get_parser(self):
def execute(self, params, **kwargs):
from keeper_secrets_manager_core.utils import url_safe_str_to_bytes
from .pam.config_helper import configuration_controller_get
- from .pam.router_helper import router_get_connected_gateways, print_router_response
+ from .pam.router_helper import router_get_connected_gateways
network_name = kwargs.get('network')
if not network_name:
@@ -984,7 +1033,124 @@ def execute(self, params, **kwargs):
print(f'{bcolors.WARNING}There are no connected gateways.{bcolors.ENDC}')
return
- action_inputs = GatewayActionUniversalSyncRunInputs(network_uid=network.record_uid)
+ if not self._is_dry_run_enabled(params, network):
+ self._run_real_sync(params, gateway_uid, network.record_uid)
+ return
+
+ data = self._run_dry_run(params, gateway_uid, network.record_uid)
+ if data is None:
+ return
+
+ self._print_dry_run_results(data)
+
+ if getattr(params, 'service_mode', False):
+ # No interactive user to confirm; report the dry run results only.
+ return
+
+ try:
+ answer = input('Sync now? (default: yes) ').strip().lower()
+ except (EOFError, KeyboardInterrupt):
+ print()
+ return
+ if answer in ('', 'y', 'yes'):
+ self._run_real_sync(params, gateway_uid, network.record_uid)
+
+ @staticmethod
+ def _find_dry_run_result(value, depth=0):
+ """Locate the dict holding new_secrets/updated_secrets/unchanged_secrets anywhere in the response."""
+ import ast
+ keys = ('new_secrets', 'updated_secrets', 'unchanged_secrets')
+ if isinstance(value, (str, bytes)) and depth < 6:
+ for loader in (json.loads, ast.literal_eval):
+ try:
+ value = loader(value)
+ break
+ except (ValueError, SyntaxError):
+ continue
+ if isinstance(value, dict):
+ if any(k in value for k in keys):
+ return value
+ children = value.values()
+ elif isinstance(value, list):
+ children = value
+ else:
+ return None
+ if depth >= 6:
+ return None
+ for child in children:
+ found = PAMUniversalSyncRunCommand._find_dry_run_result(child, depth + 1)
+ if found is not None:
+ return found
+ return None
+
+ @staticmethod
+ def _is_dry_run_enabled(params, network):
+ from ..keeper_dag import DAG
+ from ..keeper_dag.connection.commander import Connection
+
+ try:
+ conn = Connection(params=params)
+ dag = DAG(conn=conn, record=network, graph_id=0, logger=logging, history_level=1)
+ dag.load(sync_point=0)
+ root = dag.get_root
+ for edge in root.edges:
+ if edge.path == 'universal_sync' and edge.head_uid == root.uid:
+ return bool((edge.content_as_dict or {}).get('dryRunEnabled', False))
+ except Exception as e:
+ logging.debug(f'Failed to read dry run setting for {network.record_uid}: {e}')
+ return False
+
+ @staticmethod
+ def _run_dry_run(params, gateway_uid, network_uid):
+ """Run a dry run and return the sync result dict, or None (after reporting why) if there isn't one.
+
+ The gateway queues the job and replies over HTTP with only an ack; the result arrives later as a
+ frame on the router WebSocket, so wait for it there (as the Vault web UI does).
+ """
+ from ..display import Spinner
+ from .pam.router_helper import dispatch_streamed_gateway_action, make_uss_frame_parser
+
+ timeout = 300
+ conversation_id = GatewayAction.generate_conversation_id()
+ action = GatewayActionUniversalSyncRun(
+ inputs=GatewayActionUniversalSyncRunInputs(network_uid=network_uid, dry_run=True),
+ conversation_id=conversation_id, gateway_destination=gateway_uid, is_scheduled=False)
+
+ spinner = Spinner('Running dry run...')
+ spinner.start()
+ try:
+ frame = dispatch_streamed_gateway_action(
+ params, action, pam_pb2.CMT_USS, frame_parser=make_uss_frame_parser(conversation_id),
+ destination_gateway_uid_str=gateway_uid, timeout=timeout)
+ except Exception as e:
+ raise CommandError('', f'{bcolors.FAIL}Error running universal sync dry run: {e}{bcolors.ENDC}')
+ finally:
+ spinner.stop()
+
+ if frame is None:
+ print(f'{bcolors.FAIL}Timed out after {timeout}s waiting for the dry run result. No changes were made '
+ f'by Commander; use "pam action job-info {conversation_id} --gateway={gateway_uid}" to check the job.'
+ f'{bcolors.ENDC}')
+ return None
+ if frame.get('error') or frame.get('success') is False:
+ print(f'{bcolors.FAIL}Dry run failed: {frame.get("error") or json.dumps(frame, default=str)}'
+ f'{bcolors.ENDC}')
+ return None
+
+ if str(frame.get('status', '')).lower() == 'disabled':
+ print(f'{bcolors.WARNING}{frame.get("message") or "Universal Secrets Sync is disabled."}{bcolors.ENDC}')
+ return None
+
+ data = PAMUniversalSyncRunCommand._find_dry_run_result(frame)
+ if data is None:
+ logging.debug('Unrecognized dry run response: %r', frame)
+ print(f'{bcolors.FAIL}Dry run response did not contain results. Gateway response:{bcolors.ENDC}')
+ print(json.dumps(frame, indent=4, default=str))
+ return data
+
+ @staticmethod
+ def _send_sync(params, gateway_uid, network_uid):
+ action_inputs = GatewayActionUniversalSyncRunInputs(network_uid=network_uid)
encrypted_session_token, encrypted_transmission_key, transmission_key = get_keeper_tokens(params)
@@ -1003,11 +1169,40 @@ def execute(self, params, **kwargs):
)
except Exception as e:
raise CommandError('', f'{bcolors.FAIL}Error running universal sync: {e}{bcolors.ENDC}')
+ return router_response, conversation_id
+
+ @staticmethod
+ def _run_real_sync(params, gateway_uid, network_uid):
+ from .pam.router_helper import print_router_response
+
+ router_response, conversation_id = PAMUniversalSyncRunCommand._send_sync(
+ params, gateway_uid, network_uid)
if router_response is None:
print(f'{bcolors.FAIL}The router returned a failure.{bcolors.ENDC}')
return
print_router_response(router_response, 'job_info', conversation_id, gateway_uid=gateway_uid)
- print(f"\nAfter action is finished, use: '{bcolors.OKGREEN}pam usc list -n {network.record_uid}{bcolors.ENDC}, to view sync results'")
+ print(f"\nAfter action is finished, use: '{bcolors.OKGREEN}pam usc list -n {network_uid}{bcolors.ENDC}, to view sync results'")
+ @staticmethod
+ def _print_dry_run_results(data):
+ def names(key):
+ return ', '.join(str(e.get('secret_name')) for e in data.get(key) or [] if isinstance(e, dict)) or 'none'
+
+ print('Dry Run Results:')
+ print(f'NEW: {names("new_secrets")}')
+ print(f'UPDATE: {names("updated_secrets")}')
+ if 'unchanged_secrets' in data:
+ print(f'UNCHANGED: {names("unchanged_secrets")}')
+ else:
+ # Gateways older than KDNRM 1.4.42 do not report unchanged secrets
+ print('UNCHANGED: not reported (requires Gateway KDNRM 1.4.42 or later)')
+
+ failures = [r for region in (data.get('results_by_region') or {}).values()
+ for r in region.get('results') or [] if not r.get('success', True)]
+ if data.get('total_error_count') or failures:
+ print(f'{bcolors.WARNING}Errors: {data.get("total_error_count", len(failures))}{bcolors.ENDC}')
+ for r in failures:
+ print(f'{bcolors.WARNING} {r.get("aws_secret_name") or r.get("record_title")}: '
+ f'{r.get("error")}{bcolors.ENDC}')
diff --git a/keepercommander/importer/commands.py b/keepercommander/importer/commands.py
index dd3400c7d..7a701f988 100644
--- a/keepercommander/importer/commands.py
+++ b/keepercommander/importer/commands.py
@@ -169,6 +169,8 @@ def _positive_int_arg(value):
download_membership_parser.add_argument('--sub-folder', '-sf', dest='sub_folder', action='store', choices=['ignore', 'flatten'],
help='shared sub-folder handling')
download_membership_parser.add_argument('name', type=str, nargs='?', help='Output file name. "shared_folder_membership.json" if omitted.')
+download_membership_parser.add_argument('--timeout', dest='timeout', action='store', type=int, default=10, help='Timeout in seconds for network requests. Currently, supported for cyberark. Default: 10')
+download_membership_parser.add_argument('--api-page-size-limit', dest='api_page_size_limit', action='store', type=int, default=200, help='Page size limit for API requests. Currently, supported for cyberark. Default: 200')
download_membership_parser.error = raise_parse_exception
download_membership_parser.exit = suppress_exit
@@ -506,7 +508,9 @@ def execute(self, params, **kwargs): # type: (KeeperParams, **any) -> any
logging.warning('Error loading membership plugin: %s', source)
return
- for obj in plugin.download_membership(params, folders_only=folders_only):
+ download_membership = plugin.download_membership(params, folders_only=folders_only,timeout=kwargs.get('timeout'), api_page_size_limit=kwargs.get('api_page_size_limit'))
+
+ for obj in download_membership:
if isinstance(obj, SharedFolder):
obj.path = obj.path.strip()
if import_into:
diff --git a/keepercommander/importer/cyberark/cyberark.py b/keepercommander/importer/cyberark/cyberark.py
index 5eabf0d13..45b124902 100644
--- a/keepercommander/importer/cyberark/cyberark.py
+++ b/keepercommander/importer/cyberark/cyberark.py
@@ -281,6 +281,7 @@ class CyberArkImporter(BaseImporter):
}
# Request timeout in seconds
TIMEOUT = 10
+ API_PAGE_SIZE_LIMIT = 200
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -289,10 +290,12 @@ def __init__(self, *args, **kwargs):
self._tmp_cert_files = []
# ``verify`` value used for every PVWA request. Defaults to False (self-hosted PVWAs typically use a private CA), but can be overridden by the ``_CYBERARK_CA_BUNDLE`` env var to point at a CA file/dir.
self._verify_tls = False
+ self.timeout = self.TIMEOUT
+ self.api_page_size_limit = self.API_PAGE_SIZE_LIMIT
timeout_env = environ.get("_CYBERARK_TIMEOUT")
if timeout_env:
try:
- self.TIMEOUT = int(timeout_env)
+ self.timeout = int(timeout_env)
except ValueError:
pass
@@ -661,7 +664,7 @@ def get_response(self, url, authorization_token, query_params):
"Content-Type": "application/json",
},
params=query_params,
- timeout=self.TIMEOUT,
+ timeout=self.timeout,
cert=self._client_cert,
verify=self._verify_tls,
)
@@ -835,13 +838,13 @@ def fetch_all_safes(self, pvwa_host, authorization_token, safes_filter=None):
safes = []
offset = 0
- limit = 200
+ limit = self.api_page_size_limit
while True:
sleep(self.DELAY)
response = self.get_response(
self.get_url(pvwa_host, "safes"),
authorization_token,
- {"offset": offset, "limit": limit},
+ {"offset": offset, "limit": limit},
)
if response is None or response.status_code != 200:
break
@@ -873,7 +876,7 @@ def fetch_safe_members(self, pvwa_host, authorization_token, safe_url_id):
url = f"{self.get_url(pvwa_host, 'safes')}/{safe_url_id}/Members"
members = []
offset = 0
- limit = 100
+ limit = self.api_page_size_limit
while True:
sleep(self.DELAY)
response = self.get_response(
@@ -1006,7 +1009,7 @@ def _authenticate_pvwa(self, filename):
"POST",
self.get_url(pvwa_host, "logon").format(type=login_type),
json={"username": username, "password": password},
- timeout=self.TIMEOUT,
+ timeout=self.timeout,
verify=self._verify_tls,
cert=self._client_cert,
)
@@ -1412,7 +1415,7 @@ def _do_import_inner(self, filename, **kwargs):
"Content-Type": "application/json",
},
json={"reason": "Keeper Commander Import"},
- timeout=self.TIMEOUT,
+ timeout=self.timeout,
verify=True if pvwa_host.endswith(".cyberark.cloud") else self._verify_tls,
cert=None if pvwa_host.endswith(".cyberark.cloud") else self._client_cert,
)
@@ -1436,7 +1439,10 @@ def _do_import_inner(self, filename, **kwargs):
retry = False
continue
if response.status_code == 200:
- record.password = response.text.strip('"')
+ try:
+ record.password = response.json()
+ except ValueError:
+ record.password = response.text.strip('"')
retry = False
yield record
elif 400 <= response.status_code <= 500:
@@ -2401,6 +2407,37 @@ class CyberArkMembershipDownload(CyberArkImporter, BaseDownloadMembership):
Maps CyberArk Safes → Keeper ``SharedFolder`` objects (with per-member
``Permission`` entries) and CyberArk User Groups → Keeper ``Team`` objects.
"""
+ @staticmethod
+ def _ispositive_int(raw):
+ try:
+ value = int(raw)
+ except (TypeError, ValueError):
+ return None
+ if value <= 0:
+ return None
+ return value
+
+ def set_timeout(self, value):
+ """Set the timeout for CyberArk API requests (in seconds)."""
+ if value is None:
+ return True
+ timeout = self._ispositive_int(value)
+ if timeout is None or timeout < 10:
+ print_formatted_text(HTML("Timeout must be a positive integer greater than 10 seconds"))
+ return False
+ self.timeout = timeout
+ return True
+
+ def set_api_page_size_limit(self, value):
+ """Set the page size limit for CyberArk API requests (in items per page)."""
+ if value is None:
+ return True
+ limit = self._ispositive_int(value)
+ if limit is None or limit < 10:
+ print_formatted_text(HTML("API page size limit must be a positive integer greater than 10"))
+ return False
+ self.api_page_size_limit = limit
+ return True
@staticmethod
def _resolve_pvwa_host():
@@ -2449,7 +2486,10 @@ def _member_permission_name(member, user_email_lookup):
def download_membership(self, params, **kwargs):
folders_only = kwargs.get("folders_only") is True
-
+ if not self.set_timeout(kwargs.get("timeout")):
+ return
+ if not self.set_api_page_size_limit(kwargs.get("api_page_size_limit")):
+ return
include_service_accounts = environ.get(
"_CYBERARK_INCLUDE_COMPONENT_USERS", ""
).lower() in ("1", "true", "yes")
@@ -2521,6 +2561,8 @@ def download_membership(self, params, **kwargs):
if not safe_name:
continue
+ print(f"Fetching membership for safe '{safe_name}'... (this may take a while)")
+
members = self.fetch_safe_members(pvwa_host, authorization_token, safe_url_id)
if not members:
continue
diff --git a/keepercommander/importer/cyberark/pam/client.py b/keepercommander/importer/cyberark/pam/client.py
index 3a916219a..5c27df10a 100644
--- a/keepercommander/importer/cyberark/pam/client.py
+++ b/keepercommander/importer/cyberark/pam/client.py
@@ -1072,6 +1072,56 @@ def _auth_self_hosted(self) -> bool:
print_formatted_text(HTML("Log on successful"))
return True
+ @staticmethod
+ def _get_total_count(data: dict) -> Optional[int]:
+ raw = data.get("count")
+ if raw is None:
+ raw = data.get("Total")
+ if isinstance(raw, bool) or not isinstance(raw, (int, float, str)):
+ return None
+ try:
+ return int(raw)
+ except (TypeError, ValueError):
+ return None
+
+
+ @staticmethod
+ def _get_next_link(data: dict) -> Optional[str]:
+ link = data.get("nextLink")
+ if not isinstance(link, str):
+ return None
+ link = link.strip()
+ return link if link else None
+
+ @staticmethod
+ def _resolve_next_link(base_url: str, next_link: str) -> Optional[str]:
+ try:
+ base = urlparse(base_url)
+ if next_link.startswith(("http://", "https://", "/", "?", "#")):
+ resolved = urljoin(base_url, next_link)
+ else:
+ path = base.path
+ vault_path = "/PasswordVault/"
+ vault_index = path.lower().find(vault_path.lower())
+ base_path = path[:vault_index + len(vault_path)] if vault_index >= 0 else "/"
+ resolved = urljoin(f"{base.scheme}://{base.netloc}{base_path}", next_link)
+ target = urlparse(resolved)
+ except ValueError:
+ logging.warning('Invalid nextLink URL — stopping pagination')
+ return None
+ if (
+ not target.netloc
+ or target.scheme.lower() != base.scheme.lower()
+ or target.netloc.lower() != base.netloc.lower()
+ ):
+ logging.warning(
+ 'nextLink points to a different origin (%s vs %s) — '
+ 'stopping pagination to avoid token leakage',
+ target.netloc, base.netloc,
+ )
+ return None
+ return resolved
+
def _paginate(self, url: str, params: Optional[dict] = None,
items_key: str = "value", limit: int = 100,
filter_fn=None) -> List[dict]:
@@ -1080,10 +1130,24 @@ def _paginate(self, url: str, params: Optional[dict] = None,
Follows the server's nextLink URL for subsequent pages instead of
manually incrementing offset — more robust against API changes.
"""
+ if limit <= 0:
+ raise ValueError("Pagination limit must be greater than zero")
+
results = []
page_params = dict(params or {}, limit=str(limit), offset="0")
next_url = url
+ fetched_count = 0
+ visited_requests = set()
while True:
+ request_params_key = tuple(
+ sorted((str(key), repr(value)) for key, value in (page_params or {}).items())
+ )
+ request_key = (next_url, request_params_key)
+ if request_key in visited_requests:
+ logging.warning('Repeated pagination request detected; stopping at %s', next_url)
+ break
+ visited_requests.add(request_key)
+
time.sleep(self.DELAY)
response = self._get(next_url, params=page_params)
if response is None:
@@ -1108,41 +1172,54 @@ def _paginate(self, url: str, params: Optional[dict] = None,
try:
data = response.json()
except (ValueError, KeyError):
+ logging.warning('Paginated fetch returned invalid JSON: %s', next_url)
+ break
+
+ if not isinstance(data, dict):
+ logging.warning('Paginated fetch returned a non-object JSON value: %s', next_url)
break
batch = data.get(items_key, [])
+ if not isinstance(batch, list):
+ logging.warning(
+ 'Paginated fetch returned a non-list %s field: %s',
+ items_key, next_url,
+ )
+ break
+
+ next_link = self._get_next_link(data)
+ total_count = self._get_total_count(data)
+ raw_remaining = MAX_FETCH_RECORDS - fetched_count
+ batch_to_process = batch[:raw_remaining]
+ fetched_count += len(batch_to_process)
if filter_fn:
- batch = [item for item in batch if filter_fn(item)]
- results.extend(batch)
+ processed_batch = [item for item in batch_to_process if filter_fn(item)]
+ else:
+ processed_batch = batch_to_process
+ result_remaining = MAX_FETCH_RECORDS - len(results)
+ results.extend(processed_batch[:result_remaining])
if len(results) >= MAX_FETCH_RECORDS:
logging.warning('Pagination cap reached (%d) — truncating', MAX_FETCH_RECORDS)
break
- # Follow nextLink if present, otherwise check batch size
- next_link = data.get("nextLink")
+ if fetched_count >= MAX_FETCH_RECORDS:
+ logging.warning('Pagination fetch cap reached (%d) — stopping', MAX_FETCH_RECORDS)
+ break
+
if next_link:
-
- try:
- base_host = urlparse(url).netloc
- next_host = urlparse(next_link).netloc
- except ValueError:
- logging.warning('Invalid nextLink URL — stopping pagination')
+ resolved = self._resolve_next_link(next_url, next_link)
+ if not resolved:
break
- if next_host and next_host != base_host:
- logging.warning('nextLink points to a different host (%s vs %s) — '
- 'stopping pagination to avoid token leakage',
- next_host, base_host)
- break
- # nextLink is a full URL — use it directly, no params needed
- next_url = next_link
- page_params = {}
+ next_url = resolved
+ page_params = None
+ elif not batch:
+ break
+ elif total_count is not None and fetched_count >= total_count:
+ break
+ elif total_count is None and len(batch) < limit:
+ break
else:
- # No nextLink and batch smaller than limit = last page
- raw_count = len(data.get(items_key, []))
- if raw_count < limit:
- break
- # No nextLink but full page — fallback to manual offset
- current_offset = int(page_params.get("offset", "0"))
+ current_offset = fetched_count
page_params = dict(params or {}, limit=str(limit),
- offset=str(current_offset + limit))
+ offset=str(current_offset))
next_url = url
return results
@@ -1294,6 +1371,15 @@ def fetch_users(self) -> List[dict]:
limit=100,
)
+ def fetch_user_details(self, user_id: str) -> Optional[dict]:
+ """Fetch details of a vault user."""
+ base = self._get_url('safes').rsplit('/Safes', 1)[0]
+ response = self._get(f"{base}/Users/{user_id}")
+ if response is not None:
+ response.raise_for_status()
+ return response.json()
+
+
def fetch_user_groups(self) -> List[dict]:
"""Fetch all vault user groups."""
base = self._get_url('safes').rsplit('/Safes', 1)[0]
@@ -1549,7 +1635,7 @@ def fetch_accounts(self, safe_names: List[str], query_params: Optional[dict] = N
params = dict(base_params, filter=f"safeName eq {safe}")
accounts = self._paginate(
self._get_url("accounts"), params=params,
- limit=1000, # CyberArk max for accounts endpoint
+ limit=500, # CyberArk max for accounts endpoint
)
if not accounts:
print_formatted_text(HTML(f"No accounts in safe {_esc(safe)}"))
diff --git a/keepercommander/importer/cyberark/pam/import_builder.py b/keepercommander/importer/cyberark/pam/import_builder.py
index b0596a3ac..8a6184764 100644
--- a/keepercommander/importer/cyberark/pam/import_builder.py
+++ b/keepercommander/importer/cyberark/pam/import_builder.py
@@ -312,6 +312,37 @@ def format_unmapped_section(unmapped_items: Optional[List[dict]]) -> str:
lines.append('')
return "\n".join(lines)
+def _format_enterprise_users(enterprise_users: dict) -> List[str]:
+ """Render Keeper user provisioning with a status flag on each row."""
+ users = enterprise_users.get("users") or []
+ counts = {"created": 0, "existing": 0, "skipped": 0, "failed": 0}
+ for user in users:
+ flag = str((user or {}).get("flag") or "skipped")
+ if flag in counts:
+ counts[flag] += 1
+ lines = [
+ ' ENTERPRISE USERS',
+ ' ' + '-' * 40,
+ (
+ f' created {counts["created"]:>4d} existing {counts["existing"]:>4d} '
+ f'skipped {counts["skipped"]:>4d} failed {counts["failed"]:>4d}'
+ ),
+ ]
+ error = enterprise_users.get("error") or ""
+ if error:
+ lines.append(f' {error}')
+ for user in users:
+ if not isinstance(user, dict):
+ continue
+ flag = str(user.get("flag") or "skipped")
+ label = user.get("email") or user.get("username") or "(no email)"
+ detail = user.get("reason") or ""
+ if not detail and flag == "existing" and user.get("status"):
+ detail = str(user.get("status"))
+ suffix = f' {detail}' if detail else ''
+ lines.append(f' [{flag}] {label}{suffix}')
+ lines.append('')
+ return lines
def build_report(project_name: str, safes_processed: int, total_accounts: int,
resource_counts: Dict[str, Dict[str, int]],
@@ -320,6 +351,7 @@ def build_report(project_name: str, safes_processed: int, total_accounts: int,
duration: float, project_result: Optional[dict] = None,
unmapped_platforms: Optional[Dict[str, int]] = None,
unmapped_items: Optional[List[dict]] = None,
+ enterprise_users: Optional[List[dict]] = None,
server: str = '') -> str:
"""Build structured post-import report matching the spec."""
lines = []
@@ -396,6 +428,8 @@ def build_report(project_name: str, safes_processed: int, total_accounts: int,
lines.append(f' Duration: {format_duration(duration)}')
lines.append('')
+ if enterprise_users:
+ lines.extend(_format_enterprise_users(enterprise_users))
# Platform mapping
if platform_counts:
lines.append(' PLATFORM MAPPING')
diff --git a/keepercommander/notification_center.py b/keepercommander/notification_center.py
new file mode 100644
index 000000000..a40f1157e
--- /dev/null
+++ b/keepercommander/notification_center.py
@@ -0,0 +1,291 @@
+# _ __
+# | |/ /___ ___ _ __ ___ _ _ ®
+# | ' -_) -_) '_ \/ -_) '_|
+# |_|\_\___\___| .__/\___|_|
+# |_|
+#
+# Keeper Commander
+# Copyright 2026 Keeper Security Inc.
+# Contact: ops@keepersecurity.com
+#
+
+import datetime
+import json
+import logging
+from typing import Any, Dict, Iterable, List, Optional
+
+from . import crypto, utils
+from .params import KeeperParams
+from .proto import GraphSync_pb2, NotificationCenter_pb2
+
+NC = NotificationCenter_pb2
+
+# Notification types that carry approve/deny actions.
+ACTION_TYPES = {NC.NT_DEVICE_APPROVAL, NC.NT_SHARE_APPROVAL, NC.NT_APPROVAL_REQUEST}
+
+# Types the Vault hides from its notification list; shown by "notification list --all".
+HIDDEN_TYPES = {NC.NT_UNSPECIFIED, NC.NT_ALERT, NC.NT_LICENSE_LIMIT_REACHED, NC.NT_SHARE_APPROVAL_DENIED,
+ NC.NT_DEVICE_APPROVAL_APPROVED, NC.NT_DEVICE_APPROVAL_DENIED}
+
+TYPE_TITLES = {
+ NC.NT_ALERT: 'Alert',
+ NC.NT_DEVICE_APPROVAL: 'Login approval required',
+ NC.NT_MASTER_PASS_UPDATED: 'Master password updated',
+ NC.NT_SHARE_APPROVAL: 'Share request',
+ NC.NT_SHARE_APPROVAL_APPROVED: 'Share request accepted',
+ NC.NT_SHARE_APPROVAL_DENIED: 'Share request denied',
+ NC.NT_SHARED: 'Shared with you',
+ NC.NT_TRANSFERRED: 'Ownership transferred',
+ NC.NT_LICENSE_LIMIT_REACHED: 'User license limit reached',
+ NC.NT_APPROVAL_REQUEST: 'Approval request',
+ NC.NT_APPROVED_RESPONSE: 'Request approved',
+ NC.NT_DENIED_RESPONSE: 'Request denied',
+ NC.NT_2FA_CONFIGURED: '2FA configured',
+ NC.NT_DEVICE_APPROVAL_APPROVED: 'Login approved',
+ NC.NT_DEVICE_APPROVAL_DENIED: 'Login denied',
+ NC.NT_ACCOUNT_CREATED: 'Account created',
+ NC.NT_2FA_ENABLED: '2FA enabled',
+ NC.NT_2FA_DISABLED: '2FA disabled',
+ NC.NT_SECURITY_KEYS_ENABLED: 'Security keys enabled',
+ NC.NT_SECURITY_KEYS_DISABLED: 'Security keys disabled',
+ NC.NT_SSL_CERTIFICATE_EXPIRES_SOON: 'SSL certificate expires soon',
+ NC.NT_SSL_CERTIFICATE_EXPIRED: 'SSL certificate expired',
+ NC.NT_RECORD_REMINDER: 'Record reminder',
+}
+
+# Types this client handles; other notifications are dropped on sync.
+SUPPORTED_TYPES = set(TYPE_TITLES) | HIDDEN_TYPES
+
+
+class NotificationEntry:
+ def __init__(self, uid: str):
+ self.uid = uid
+ self.notification = None # type: Optional[NotificationCenter_pb2.Notification]
+ self.timestamp = 0 # epoch seconds of the notification wrapper
+ self.read_status = NC.NRS_UNSPECIFIED
+ self.read_timestamp = 0
+ self.approval_status = NC.NAS_UNSPECIFIED
+ self.approval_timestamp = 0
+ self.is_read = False # derived by update_read_statuses
+
+
+def clear_cache(params: KeeperParams) -> None:
+ params.notification_cache.clear()
+
+
+def process_notification_sync(params: KeeperParams, wrappers: Iterable[NotificationCenter_pb2.NotificationWrapper]) -> None:
+ cache = params.notification_cache # type: Dict[str, NotificationEntry]
+ for wrapper in wrappers:
+ if not wrapper.uid:
+ continue
+ uid = utils.base64_url_encode(wrapper.uid)
+ content_type = wrapper.content.WhichOneof('type')
+ if content_type == 'trimmingPoint':
+ _apply_trimming_point(cache, uid)
+ continue
+ if content_type == 'notification' and wrapper.content.notification.type not in SUPPORTED_TYPES:
+ cache.pop(uid, None)
+ continue
+ entry = cache.get(uid)
+ if entry is None:
+ entry = NotificationEntry(uid)
+ cache[uid] = entry
+ if content_type == 'notification':
+ entry.notification = wrapper.content.notification
+ entry.timestamp = wrapper.timestamp
+ elif content_type == 'readStatus':
+ entry.read_status = wrapper.content.readStatus
+ entry.read_timestamp = wrapper.timestamp
+ elif content_type == 'approvalStatus':
+ entry.approval_status = wrapper.content.approvalStatus
+ entry.approval_timestamp = wrapper.timestamp
+ update_read_statuses(params)
+
+
+def _apply_trimming_point(cache: Dict[str, NotificationEntry], uid: str) -> None:
+ # The server trimmed the notification at uid and everything older than it.
+ point = cache.get(uid)
+ if point is None or not point.timestamp:
+ cache.pop(uid, None)
+ return
+ for key in [k for k, v in cache.items() if v.timestamp <= point.timestamp]:
+ del cache[key]
+
+
+def update_read_statuses(params: KeeperParams) -> None:
+ # NRS_LAST marks the newest such notification and everything before it as read; see Vault notification-utils.ts.
+ entries = [x for x in params.notification_cache.values() if x.notification is not None]
+ last_read = 0
+ for entry in sorted(entries, key=lambda x: x.timestamp, reverse=True):
+ if entry.read_status == NC.NRS_LAST:
+ last_read = entry.read_timestamp
+ break
+ for entry in entries:
+ if entry.read_status in (NC.NRS_LAST, NC.NRS_READ):
+ entry.is_read = True
+ else:
+ entry.is_read = (entry.read_timestamp or entry.timestamp) < last_read
+
+
+def apply_local_read_status(params: KeeperParams, uids: Iterable[str], status: int) -> None:
+ # The server does not echo this device's own read marks back in sync, so apply them locally.
+ now = int(datetime.datetime.now().timestamp())
+ for uid in uids:
+ entry = params.notification_cache.get(uid)
+ if entry:
+ entry.read_status = status
+ entry.read_timestamp = now
+ update_read_statuses(params)
+
+
+def get_notifications(params: KeeperParams) -> List[NotificationEntry]:
+ entries = [x for x in params.notification_cache.values() if x.notification is not None]
+ entries.sort(key=lambda x: x.timestamp, reverse=True)
+ return entries
+
+
+def type_name(notification_type: int) -> str:
+ try:
+ return NC.NotificationType.Name(notification_type)
+ except ValueError:
+ return str(notification_type)
+
+
+def approval_status_name(status: int) -> str:
+ if status == NC.NAS_UNSPECIFIED:
+ return ''
+ try:
+ return NC.NotificationApprovalStatus.Name(status)[4:].replace('_', ' ').lower()
+ except ValueError:
+ return str(status)
+
+
+def is_actionable(entry: NotificationEntry) -> bool:
+ return entry.notification.type in ACTION_TYPES and entry.approval_status == NC.NAS_UNSPECIFIED
+
+
+def status_label(entry: NotificationEntry) -> str:
+ return approval_status_name(entry.approval_status) or ('pending' if is_actionable(entry) else '')
+
+
+def get_ref(notification: NotificationCenter_pb2.Notification, ref_type: int) -> Optional[GraphSync_pb2.GraphSyncRef]:
+ return next((x for x in notification.refs if x.type == ref_type), None)
+
+
+def get_parameter(notification: NotificationCenter_pb2.Notification, key: str) -> Optional[bytes]:
+ return next((x.data for x in notification.parameters if x.key == key), None)
+
+
+def sender_name(notification: NotificationCenter_pb2.Notification) -> str:
+ return notification.sender.name or notification.senderFullName
+
+
+def record_title(params: KeeperParams, record_uid: str) -> Optional[str]:
+ if record_uid in params.record_cache:
+ from . import vault
+ record = vault.KeeperRecord.load(params, record_uid)
+ if record:
+ return record.title
+ return None
+
+
+def record_key(params: KeeperParams, record_uid: str) -> Optional[bytes]:
+ record = params.record_cache.get(record_uid)
+ return record.get('record_key_unencrypted') if record else None
+
+
+def team_name(params: KeeperParams, team_uid: str) -> Optional[str]:
+ team = params.team_cache.get(team_uid)
+ return team.get('name') if team else None
+
+
+def decrypt_device_approval(params: KeeperParams, notification: NotificationCenter_pb2.Notification) -> Optional[Dict[str, Any]]:
+ if not notification.encryptedData.data or not params.ecc_key:
+ return None
+ try:
+ return json.loads(crypto.decrypt_ec(notification.encryptedData.data, params.ecc_key))
+ except Exception as e:
+ logging.debug('Decrypt device approval notification error: %s', e)
+ return None
+
+
+def decrypt_record_parameter(params: KeeperParams, notification: NotificationCenter_pb2.Notification, key: str) -> Optional[str]:
+ # Workflow reason/ticket parameters are AES-GCM encrypted with the record key.
+ data = get_parameter(notification, key)
+ rec_ref = get_ref(notification, GraphSync_pb2.RFT_REC)
+ if not data or not rec_ref:
+ return None
+ r_key = record_key(params, utils.base64_url_encode(rec_ref.value))
+ if not r_key:
+ return None
+ try:
+ return crypto.decrypt_aes_v2(data, r_key).decode('utf-8')
+ except Exception as e:
+ logging.debug('Decrypt notification parameter "%s" error: %s', key, e)
+ return None
+
+
+def _ref_number(value: bytes) -> int:
+ return int.from_bytes(value, byteorder='big')
+
+
+def get_message(params: KeeperParams, entry: NotificationEntry) -> str:
+ n = entry.notification
+ t = n.type
+ sender = sender_name(n) or 'Someone'
+ rec_ref = get_ref(n, GraphSync_pb2.RFT_REC)
+ rec_uid = utils.base64_url_encode(rec_ref.value) if rec_ref else None
+ rec_title = (record_title(params, rec_uid) or rec_uid) if rec_uid else None
+
+ if t == NC.NT_DEVICE_APPROVAL:
+ data = decrypt_device_approval(params, n) or {}
+ return (f'{data.get("notification_device") or "A device"} is attempting to log in to your account from '
+ f'{data.get("location") or "unknown location"} and IP address {data.get("ip_address") or "unknown"}.')
+ if t == NC.NT_MASTER_PASS_UPDATED:
+ return 'You have successfully updated your master password.'
+ if t == NC.NT_SHARE_APPROVAL:
+ return f'{sender} wants to establish a sharing relationship with you.'
+ if t == NC.NT_SHARE_APPROVAL_APPROVED:
+ return f'{sender} accepted your request for a sharing relationship.'
+ if t == NC.NT_SHARE_APPROVAL_DENIED:
+ return f'{sender} denied your request for a sharing relationship.'
+ if t == NC.NT_SHARED:
+ if rec_uid:
+ return f'{sender} has shared a record with you: {rec_title}'
+ folder_ref = get_ref(n, GraphSync_pb2.RFT_FOLDER)
+ if folder_ref:
+ folder_uid = utils.base64_url_encode(folder_ref.value)
+ folder = params.folder_cache.get(folder_uid) if params.folder_cache else None
+ return f'{sender} has shared a folder with you: {folder.name if folder else folder_uid}'
+ team_ref = get_ref(n, GraphSync_pb2.RFT_TEAM)
+ if team_ref:
+ team_uid = utils.base64_url_encode(team_ref.value)
+ return f'{sender} added you to team: {team_name(params, team_uid) or team_uid}'
+ return f'{sender} has shared with you.'
+ if t == NC.NT_TRANSFERRED:
+ return f'{sender} transferred ownership of a record to you: {rec_title or "unknown record"}'
+ if t == NC.NT_APPROVAL_REQUEST:
+ text = f'{n.senderFullName or sender} requests to access {rec_title or "unknown resource"}'
+ if get_parameter(n, 'escalated') is not None:
+ text += ' (escalated)'
+ return text
+ if t == NC.NT_APPROVED_RESPONSE:
+ return f'{n.senderFullName or sender} approved your request to access {rec_title or "unknown resource"}'
+ if t == NC.NT_DENIED_RESPONSE:
+ text = f'{n.senderFullName or sender} denied your request to access {rec_title or "unknown resource"}'
+ reason = decrypt_record_parameter(params, n, 'reason')
+ if reason:
+ text += f': {reason}'
+ return text
+ if t == NC.NT_2FA_CONFIGURED:
+ return 'You have successfully configured two-factor authentication.'
+ if t in (NC.NT_SSL_CERTIFICATE_EXPIRES_SOON, NC.NT_SSL_CERTIFICATE_EXPIRED):
+ bindings = {x.name: x.value for x in n.refs if x.type == GraphSync_pb2.RFT_GENERAL}
+ provider = bindings.get('XXX', b'').decode('utf-8', 'replace') or 'unknown provider'
+ days = _ref_number(bindings['ZZZ']) if 'ZZZ' in bindings else None
+ if t == NC.NT_SSL_CERTIFICATE_EXPIRED:
+ return f'SSL certificate for {provider} expired {days} day(s) ago' if days is not None else f'SSL certificate for {provider} expired'
+ return f'SSL certificate for {provider} expires in {days} day(s)' if days is not None else f'SSL certificate for {provider} expires soon'
+ if t == NC.NT_RECORD_REMINDER:
+ return f'Reminder for record: {rec_title or "unknown record"}'
+ return TYPE_TITLES.get(t) or type_name(t)
diff --git a/keepercommander/params.py b/keepercommander/params.py
index 7a19c6c49..c40cdffee 100644
--- a/keepercommander/params.py
+++ b/keepercommander/params.py
@@ -231,6 +231,7 @@ def __init__(self, config_filename='', config=None, server='keepersecurity.com')
self.batch_mode = False
self.__rest_context = RestApiContext(server=server)
self.pending_share_requests = set()
+ self.notification_cache = {} # type: dict
self.environment_variables = {}
self.record_history = {} # type: dict[str, (list[dict], int)]
self.event_queue = []
@@ -324,6 +325,7 @@ def clear_session(self):
self.enterprise_id = 0
self.msp_tree_key = None
self.pending_share_requests.clear()
+ self.notification_cache.clear()
self.environment_variables.clear()
self.record_history.clear()
self.event_queue.clear()
diff --git a/keepercommander/proto/NotificationCenter_pb2.py b/keepercommander/proto/NotificationCenter_pb2.py
index 583169f23..741309448 100644
--- a/keepercommander/proto/NotificationCenter_pb2.py
+++ b/keepercommander/proto/NotificationCenter_pb2.py
@@ -25,7 +25,7 @@
from . import GraphSync_pb2 as GraphSync__pb2
-DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x18NotificationCenter.proto\x12\x12NotificationCenter\x1a\x0fGraphSync.proto\".\n\rEncryptedData\x12\x0f\n\x07version\x18\x01 \x01(\x05\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c\"2\n\x15NotificationParameter\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c\"\xa1\x03\n\x0cNotification\x12\x32\n\x04type\x18\x01 \x01(\x0e\x32$.NotificationCenter.NotificationType\x12>\n\x08\x63\x61tegory\x18\x02 \x01(\x0e\x32(.NotificationCenter.NotificationCategoryB\x02\x18\x01\x12\'\n\x06sender\x18\x03 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12\x16\n\x0esenderFullName\x18\x04 \x01(\t\x12\x38\n\rencryptedData\x18\x05 \x01(\x0b\x32!.NotificationCenter.EncryptedData\x12%\n\x04refs\x18\x06 \x03(\x0b\x32\x17.GraphSync.GraphSyncRef\x12<\n\ncategories\x18\x07 \x03(\x0e\x32(.NotificationCenter.NotificationCategory\x12=\n\nparameters\x18\x08 \x03(\x0b\x32).NotificationCenter.NotificationParameter\"\x97\x01\n\x14NotificationReadMark\x12\x0b\n\x03uid\x18\x01 \x01(\x0c\x12\x1c\n\x14notification_edge_id\x18\x02 \x01(\x03\x12\x14\n\x0cmark_edge_id\x18\x03 \x01(\x03\x12>\n\nreadStatus\x18\x04 \x01(\x0e\x32*.NotificationCenter.NotificationReadStatus\"\xa6\x02\n\x13NotificationContent\x12\x38\n\x0cnotification\x18\x01 \x01(\x0b\x32 .NotificationCenter.NotificationH\x00\x12@\n\nreadStatus\x18\x02 \x01(\x0e\x32*.NotificationCenter.NotificationReadStatusH\x00\x12H\n\x0e\x61pprovalStatus\x18\x03 \x01(\x0e\x32..NotificationCenter.NotificationApprovalStatusH\x00\x12\x17\n\rtrimmingPoint\x18\x04 \x01(\x08H\x00\x12\x15\n\rclientTypeIDs\x18\x05 \x03(\x05\x12\x11\n\tdeviceIDs\x18\x06 \x03(\x03\x42\x06\n\x04type\"o\n\x13NotificationWrapper\x12\x0b\n\x03uid\x18\x01 \x01(\x0c\x12\x38\n\x07\x63ontent\x18\x02 \x01(\x0b\x32\'.NotificationCenter.NotificationContent\x12\x11\n\ttimestamp\x18\x03 \x01(\x03\"m\n\x10NotificationSync\x12\x35\n\x04\x64\x61ta\x18\x01 \x03(\x0b\x32\'.NotificationCenter.NotificationWrapper\x12\x11\n\tsyncPoint\x18\x02 \x01(\x03\x12\x0f\n\x07hasMore\x18\x03 \x01(\x08\"g\n\x10ReadStatusUpdate\x12\x17\n\x0fnotificationUid\x18\x01 \x01(\x0c\x12:\n\x06status\x18\x02 \x01(\x0e\x32*.NotificationCenter.NotificationReadStatus\"o\n\x14\x41pprovalStatusUpdate\x12\x17\n\x0fnotificationUid\x18\x01 \x01(\x0c\x12>\n\x06status\x18\x02 \x01(\x0e\x32..NotificationCenter.NotificationApprovalStatus\"^\n\x1cProcessMarkReadEventsRequest\x12>\n\x10readStatusUpdate\x18\x01 \x03(\x0b\x32$.NotificationCenter.ReadStatusUpdate\"\xd6\x01\n\x17NotificationSendRequest\x12+\n\nrecipients\x18\x01 \x03(\x0b\x32\x17.GraphSync.GraphSyncRef\x12\x36\n\x0cnotification\x18\x02 \x01(\x0b\x32 .NotificationCenter.Notification\x12\x15\n\rclientTypeIDs\x18\x03 \x03(\x05\x12\x11\n\tdeviceIDs\x18\x04 \x03(\x03\x12\x1a\n\rpredefinedUid\x18\x05 \x01(\x0cH\x00\x88\x01\x01\x42\x10\n\x0e_predefinedUid\"^\n\x18NotificationsSendRequest\x12\x42\n\rnotifications\x18\x01 \x03(\x0b\x32+.NotificationCenter.NotificationSendRequest\",\n\x17NotificationSyncRequest\x12\x11\n\tsyncPoint\x18\x01 \x01(\x03\"9\n\x10SentNotification\x12\x0c\n\x04user\x18\x01 \x01(\x05\x12\x17\n\x0fnotificationUid\x18\x02 \x01(\x0c\"\xa7\x01\n(NotificationsApprovalStatusUpdateRequest\x12>\n\x06status\x18\x01 \x01(\x0e\x32..NotificationCenter.NotificationApprovalStatus\x12;\n\rnotifications\x18\x02 \x03(\x0b\x32$.NotificationCenter.SentNotification*\x9f\x01\n\x14NotificationCategory\x12\x12\n\x0eNC_UNSPECIFIED\x10\x00\x12\x0e\n\nNC_ACCOUNT\x10\x01\x12\x0e\n\nNC_SHARING\x10\x02\x12\x11\n\rNC_ENTERPRISE\x10\x03\x12\x0f\n\x0bNC_SECURITY\x10\x04\x12\x0e\n\nNC_REQUEST\x10\x05\x12\r\n\tNC_SYSTEM\x10\x06\x12\x10\n\x0cNC_PROMOTION\x10\x07*\xe3\x04\n\x10NotificationType\x12\x12\n\x0eNT_UNSPECIFIED\x10\x00\x12\x0c\n\x08NT_ALERT\x10\x01\x12\x16\n\x12NT_DEVICE_APPROVAL\x10\x02\x12\x1a\n\x16NT_MASTER_PASS_UPDATED\x10\x03\x12\x15\n\x11NT_SHARE_APPROVAL\x10\x04\x12\x1e\n\x1aNT_SHARE_APPROVAL_APPROVED\x10\x05\x12\r\n\tNT_SHARED\x10\x06\x12\x12\n\x0eNT_TRANSFERRED\x10\x07\x12\x1c\n\x18NT_LICENSE_LIMIT_REACHED\x10\x08\x12\x17\n\x13NT_APPROVAL_REQUEST\x10\t\x12\x18\n\x14NT_APPROVED_RESPONSE\x10\n\x12\x16\n\x12NT_DENIED_RESPONSE\x10\x0b\x12\x15\n\x11NT_2FA_CONFIGURED\x10\x0c\x12\x1c\n\x18NT_SHARE_APPROVAL_DENIED\x10\r\x12\x1f\n\x1bNT_DEVICE_APPROVAL_APPROVED\x10\x0e\x12\x1d\n\x19NT_DEVICE_APPROVAL_DENIED\x10\x0f\x12\x16\n\x12NT_ACCOUNT_CREATED\x10\x10\x12\x12\n\x0eNT_2FA_ENABLED\x10\x11\x12\x13\n\x0fNT_2FA_DISABLED\x10\x12\x12\x1c\n\x18NT_SECURITY_KEYS_ENABLED\x10\x13\x12\x1d\n\x19NT_SECURITY_KEYS_DISABLED\x10\x14\x12#\n\x1fNT_SSL_CERTIFICATE_EXPIRES_SOON\x10\x15\x12\x1e\n\x1aNT_SSL_CERTIFICATE_EXPIRED\x10\x16*Y\n\x16NotificationReadStatus\x12\x13\n\x0fNRS_UNSPECIFIED\x10\x00\x12\x0c\n\x08NRS_LAST\x10\x01\x12\x0c\n\x08NRS_READ\x10\x02\x12\x0e\n\nNRS_UNREAD\x10\x03*\x86\x01\n\x1aNotificationApprovalStatus\x12\x13\n\x0fNAS_UNSPECIFIED\x10\x00\x12\x10\n\x0cNAS_APPROVED\x10\x01\x12\x0e\n\nNAS_DENIED\x10\x02\x12\x1c\n\x18NAS_LOST_APPROVAL_RIGHTS\x10\x03\x12\x13\n\x0fNAS_LOST_ACCESS\x10\x04\x42.\n\x18\x63om.keepersecurity.protoB\x12NotificationCenterb\x06proto3')
+DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x18NotificationCenter.proto\x12\x12NotificationCenter\x1a\x0fGraphSync.proto\".\n\rEncryptedData\x12\x0f\n\x07version\x18\x01 \x01(\x05\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c\"2\n\x15NotificationParameter\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c\"\xa1\x03\n\x0cNotification\x12\x32\n\x04type\x18\x01 \x01(\x0e\x32$.NotificationCenter.NotificationType\x12>\n\x08\x63\x61tegory\x18\x02 \x01(\x0e\x32(.NotificationCenter.NotificationCategoryB\x02\x18\x01\x12\'\n\x06sender\x18\x03 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12\x16\n\x0esenderFullName\x18\x04 \x01(\t\x12\x38\n\rencryptedData\x18\x05 \x01(\x0b\x32!.NotificationCenter.EncryptedData\x12%\n\x04refs\x18\x06 \x03(\x0b\x32\x17.GraphSync.GraphSyncRef\x12<\n\ncategories\x18\x07 \x03(\x0e\x32(.NotificationCenter.NotificationCategory\x12=\n\nparameters\x18\x08 \x03(\x0b\x32).NotificationCenter.NotificationParameter\"\x97\x01\n\x14NotificationReadMark\x12\x0b\n\x03uid\x18\x01 \x01(\x0c\x12\x1c\n\x14notification_edge_id\x18\x02 \x01(\x03\x12\x14\n\x0cmark_edge_id\x18\x03 \x01(\x03\x12>\n\nreadStatus\x18\x04 \x01(\x0e\x32*.NotificationCenter.NotificationReadStatus\"\xa6\x02\n\x13NotificationContent\x12\x38\n\x0cnotification\x18\x01 \x01(\x0b\x32 .NotificationCenter.NotificationH\x00\x12@\n\nreadStatus\x18\x02 \x01(\x0e\x32*.NotificationCenter.NotificationReadStatusH\x00\x12H\n\x0e\x61pprovalStatus\x18\x03 \x01(\x0e\x32..NotificationCenter.NotificationApprovalStatusH\x00\x12\x17\n\rtrimmingPoint\x18\x04 \x01(\x08H\x00\x12\x15\n\rclientTypeIDs\x18\x05 \x03(\x05\x12\x11\n\tdeviceIDs\x18\x06 \x03(\x03\x42\x06\n\x04type\"o\n\x13NotificationWrapper\x12\x0b\n\x03uid\x18\x01 \x01(\x0c\x12\x38\n\x07\x63ontent\x18\x02 \x01(\x0b\x32\'.NotificationCenter.NotificationContent\x12\x11\n\ttimestamp\x18\x03 \x01(\x03\"m\n\x10NotificationSync\x12\x35\n\x04\x64\x61ta\x18\x01 \x03(\x0b\x32\'.NotificationCenter.NotificationWrapper\x12\x11\n\tsyncPoint\x18\x02 \x01(\x03\x12\x0f\n\x07hasMore\x18\x03 \x01(\x08\"g\n\x10ReadStatusUpdate\x12\x17\n\x0fnotificationUid\x18\x01 \x01(\x0c\x12:\n\x06status\x18\x02 \x01(\x0e\x32*.NotificationCenter.NotificationReadStatus\"o\n\x14\x41pprovalStatusUpdate\x12\x17\n\x0fnotificationUid\x18\x01 \x01(\x0c\x12>\n\x06status\x18\x02 \x01(\x0e\x32..NotificationCenter.NotificationApprovalStatus\"^\n\x1cProcessMarkReadEventsRequest\x12>\n\x10readStatusUpdate\x18\x01 \x03(\x0b\x32$.NotificationCenter.ReadStatusUpdate\"\xd6\x01\n\x17NotificationSendRequest\x12+\n\nrecipients\x18\x01 \x03(\x0b\x32\x17.GraphSync.GraphSyncRef\x12\x36\n\x0cnotification\x18\x02 \x01(\x0b\x32 .NotificationCenter.Notification\x12\x15\n\rclientTypeIDs\x18\x03 \x03(\x05\x12\x11\n\tdeviceIDs\x18\x04 \x03(\x03\x12\x1a\n\rpredefinedUid\x18\x05 \x01(\x0cH\x00\x88\x01\x01\x42\x10\n\x0e_predefinedUid\"^\n\x18NotificationsSendRequest\x12\x42\n\rnotifications\x18\x01 \x03(\x0b\x32+.NotificationCenter.NotificationSendRequest\",\n\x17NotificationSyncRequest\x12\x11\n\tsyncPoint\x18\x01 \x01(\x03\"9\n\x10SentNotification\x12\x0c\n\x04user\x18\x01 \x01(\x05\x12\x17\n\x0fnotificationUid\x18\x02 \x01(\x0c\"\xa7\x01\n(NotificationsApprovalStatusUpdateRequest\x12>\n\x06status\x18\x01 \x01(\x0e\x32..NotificationCenter.NotificationApprovalStatus\x12;\n\rnotifications\x18\x02 \x03(\x0b\x32$.NotificationCenter.SentNotification*\x9f\x01\n\x14NotificationCategory\x12\x12\n\x0eNC_UNSPECIFIED\x10\x00\x12\x0e\n\nNC_ACCOUNT\x10\x01\x12\x0e\n\nNC_SHARING\x10\x02\x12\x11\n\rNC_ENTERPRISE\x10\x03\x12\x0f\n\x0bNC_SECURITY\x10\x04\x12\x0e\n\nNC_REQUEST\x10\x05\x12\r\n\tNC_SYSTEM\x10\x06\x12\x10\n\x0cNC_PROMOTION\x10\x07*\x99\x05\n\x10NotificationType\x12\x12\n\x0eNT_UNSPECIFIED\x10\x00\x12\x0c\n\x08NT_ALERT\x10\x01\x12\x16\n\x12NT_DEVICE_APPROVAL\x10\x02\x12\x1a\n\x16NT_MASTER_PASS_UPDATED\x10\x03\x12\x15\n\x11NT_SHARE_APPROVAL\x10\x04\x12\x1e\n\x1aNT_SHARE_APPROVAL_APPROVED\x10\x05\x12\r\n\tNT_SHARED\x10\x06\x12\x12\n\x0eNT_TRANSFERRED\x10\x07\x12\x1c\n\x18NT_LICENSE_LIMIT_REACHED\x10\x08\x12\x17\n\x13NT_APPROVAL_REQUEST\x10\t\x12\x18\n\x14NT_APPROVED_RESPONSE\x10\n\x12\x16\n\x12NT_DENIED_RESPONSE\x10\x0b\x12\x15\n\x11NT_2FA_CONFIGURED\x10\x0c\x12\x1c\n\x18NT_SHARE_APPROVAL_DENIED\x10\r\x12\x1f\n\x1bNT_DEVICE_APPROVAL_APPROVED\x10\x0e\x12\x1d\n\x19NT_DEVICE_APPROVAL_DENIED\x10\x0f\x12\x16\n\x12NT_ACCOUNT_CREATED\x10\x10\x12\x12\n\x0eNT_2FA_ENABLED\x10\x11\x12\x13\n\x0fNT_2FA_DISABLED\x10\x12\x12\x1c\n\x18NT_SECURITY_KEYS_ENABLED\x10\x13\x12\x1d\n\x19NT_SECURITY_KEYS_DISABLED\x10\x14\x12#\n\x1fNT_SSL_CERTIFICATE_EXPIRES_SOON\x10\x15\x12\x1e\n\x1aNT_SSL_CERTIFICATE_EXPIRED\x10\x16\x12\x16\n\x12NT_RECORD_REMINDER\x10\x17\x12\x1c\n\x18NT_PEDM_APPROVAL_REQUEST\x10\x18*Y\n\x16NotificationReadStatus\x12\x13\n\x0fNRS_UNSPECIFIED\x10\x00\x12\x0c\n\x08NRS_LAST\x10\x01\x12\x0c\n\x08NRS_READ\x10\x02\x12\x0e\n\nNRS_UNREAD\x10\x03*\x97\x01\n\x1aNotificationApprovalStatus\x12\x13\n\x0fNAS_UNSPECIFIED\x10\x00\x12\x10\n\x0cNAS_APPROVED\x10\x01\x12\x0e\n\nNAS_DENIED\x10\x02\x12\x1c\n\x18NAS_LOST_APPROVAL_RIGHTS\x10\x03\x12\x13\n\x0fNAS_LOST_ACCESS\x10\x04\x12\x0f\n\x0bNAS_EXPIRED\x10\x05\x42.\n\x18\x63om.keepersecurity.protoB\x12NotificationCenterb\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
@@ -38,11 +38,11 @@
_globals['_NOTIFICATIONCATEGORY']._serialized_start=2163
_globals['_NOTIFICATIONCATEGORY']._serialized_end=2322
_globals['_NOTIFICATIONTYPE']._serialized_start=2325
- _globals['_NOTIFICATIONTYPE']._serialized_end=2936
- _globals['_NOTIFICATIONREADSTATUS']._serialized_start=2938
- _globals['_NOTIFICATIONREADSTATUS']._serialized_end=3027
- _globals['_NOTIFICATIONAPPROVALSTATUS']._serialized_start=3030
- _globals['_NOTIFICATIONAPPROVALSTATUS']._serialized_end=3164
+ _globals['_NOTIFICATIONTYPE']._serialized_end=2990
+ _globals['_NOTIFICATIONREADSTATUS']._serialized_start=2992
+ _globals['_NOTIFICATIONREADSTATUS']._serialized_end=3081
+ _globals['_NOTIFICATIONAPPROVALSTATUS']._serialized_start=3084
+ _globals['_NOTIFICATIONAPPROVALSTATUS']._serialized_end=3235
_globals['_ENCRYPTEDDATA']._serialized_start=65
_globals['_ENCRYPTEDDATA']._serialized_end=111
_globals['_NOTIFICATIONPARAMETER']._serialized_start=113
diff --git a/keepercommander/proto/NotificationCenter_pb2.pyi b/keepercommander/proto/NotificationCenter_pb2.pyi
index 891ae657f..ba0fb9166 100644
--- a/keepercommander/proto/NotificationCenter_pb2.pyi
+++ b/keepercommander/proto/NotificationCenter_pb2.pyi
@@ -3,8 +3,7 @@ from google.protobuf.internal import containers as _containers
from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper
from google.protobuf import descriptor as _descriptor
from google.protobuf import message as _message
-from collections.abc import Iterable as _Iterable, Mapping as _Mapping
-from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union
+from typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Mapping, Optional as _Optional, Union as _Union
DESCRIPTOR: _descriptor.FileDescriptor
@@ -44,6 +43,8 @@ class NotificationType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
NT_SECURITY_KEYS_DISABLED: _ClassVar[NotificationType]
NT_SSL_CERTIFICATE_EXPIRES_SOON: _ClassVar[NotificationType]
NT_SSL_CERTIFICATE_EXPIRED: _ClassVar[NotificationType]
+ NT_RECORD_REMINDER: _ClassVar[NotificationType]
+ NT_PEDM_APPROVAL_REQUEST: _ClassVar[NotificationType]
class NotificationReadStatus(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = ()
@@ -59,6 +60,7 @@ class NotificationApprovalStatus(int, metaclass=_enum_type_wrapper.EnumTypeWrapp
NAS_DENIED: _ClassVar[NotificationApprovalStatus]
NAS_LOST_APPROVAL_RIGHTS: _ClassVar[NotificationApprovalStatus]
NAS_LOST_ACCESS: _ClassVar[NotificationApprovalStatus]
+ NAS_EXPIRED: _ClassVar[NotificationApprovalStatus]
NC_UNSPECIFIED: NotificationCategory
NC_ACCOUNT: NotificationCategory
NC_SHARING: NotificationCategory
@@ -90,6 +92,8 @@ NT_SECURITY_KEYS_ENABLED: NotificationType
NT_SECURITY_KEYS_DISABLED: NotificationType
NT_SSL_CERTIFICATE_EXPIRES_SOON: NotificationType
NT_SSL_CERTIFICATE_EXPIRED: NotificationType
+NT_RECORD_REMINDER: NotificationType
+NT_PEDM_APPROVAL_REQUEST: NotificationType
NRS_UNSPECIFIED: NotificationReadStatus
NRS_LAST: NotificationReadStatus
NRS_READ: NotificationReadStatus
@@ -99,6 +103,7 @@ NAS_APPROVED: NotificationApprovalStatus
NAS_DENIED: NotificationApprovalStatus
NAS_LOST_APPROVAL_RIGHTS: NotificationApprovalStatus
NAS_LOST_ACCESS: NotificationApprovalStatus
+NAS_EXPIRED: NotificationApprovalStatus
class EncryptedData(_message.Message):
__slots__ = ("version", "data")
@@ -162,7 +167,7 @@ class NotificationContent(_message.Message):
trimmingPoint: bool
clientTypeIDs: _containers.RepeatedScalarFieldContainer[int]
deviceIDs: _containers.RepeatedScalarFieldContainer[int]
- def __init__(self, notification: _Optional[_Union[Notification, _Mapping]] = ..., readStatus: _Optional[_Union[NotificationReadStatus, str]] = ..., approvalStatus: _Optional[_Union[NotificationApprovalStatus, str]] = ..., trimmingPoint: _Optional[bool] = ..., clientTypeIDs: _Optional[_Iterable[int]] = ..., deviceIDs: _Optional[_Iterable[int]] = ...) -> None: ...
+ def __init__(self, notification: _Optional[_Union[Notification, _Mapping]] = ..., readStatus: _Optional[_Union[NotificationReadStatus, str]] = ..., approvalStatus: _Optional[_Union[NotificationApprovalStatus, str]] = ..., trimmingPoint: bool = ..., clientTypeIDs: _Optional[_Iterable[int]] = ..., deviceIDs: _Optional[_Iterable[int]] = ...) -> None: ...
class NotificationWrapper(_message.Message):
__slots__ = ("uid", "content", "timestamp")
@@ -182,7 +187,7 @@ class NotificationSync(_message.Message):
data: _containers.RepeatedCompositeFieldContainer[NotificationWrapper]
syncPoint: int
hasMore: bool
- def __init__(self, data: _Optional[_Iterable[_Union[NotificationWrapper, _Mapping]]] = ..., syncPoint: _Optional[int] = ..., hasMore: _Optional[bool] = ...) -> None: ...
+ def __init__(self, data: _Optional[_Iterable[_Union[NotificationWrapper, _Mapping]]] = ..., syncPoint: _Optional[int] = ..., hasMore: bool = ...) -> None: ...
class ReadStatusUpdate(_message.Message):
__slots__ = ("notificationUid", "status")
diff --git a/keepercommander/proto/pam_pb2.py b/keepercommander/proto/pam_pb2.py
index b773f6c8e..6c0f68645 100644
--- a/keepercommander/proto/pam_pb2.py
+++ b/keepercommander/proto/pam_pb2.py
@@ -26,7 +26,7 @@
from . import record_pb2 as record__pb2
-DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\tpam.proto\x12\x03PAM\x1a\x10\x65nterprise.proto\x1a\x0crecord.proto\"\x83\x01\n\x13PAMRotationSchedule\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x18\n\x10\x63onfigurationUid\x18\x02 \x01(\x0c\x12\x15\n\rcontrollerUid\x18\x03 \x01(\x0c\x12\x14\n\x0cscheduleData\x18\x04 \x01(\t\x12\x12\n\nnoSchedule\x18\x05 \x01(\x08\"K\n\x1cPAMRotationSchedulesResponse\x12+\n\tschedules\x18\x01 \x03(\x0b\x32\x18.PAM.PAMRotationSchedule\"\x94\x01\n\x13PAMOnlineController\x12\x15\n\rcontrollerUid\x18\x01 \x01(\x0c\x12\x13\n\x0b\x63onnectedOn\x18\x02 \x01(\x03\x12\x11\n\tipAddress\x18\x03 \x01(\t\x12\x0f\n\x07version\x18\x04 \x01(\t\x12-\n\x0b\x63onnections\x18\x05 \x03(\x0b\x32\x18.PAM.PAMWebRtcConnection\"\xa7\x01\n\x13PAMWebRtcConnection\x12\x15\n\rconnectionUid\x18\x01 \x01(\x0c\x12\'\n\x04type\x18\x02 \x01(\x0e\x32\x19.PAM.WebRtcConnectionType\x12\x11\n\trecordUid\x18\x03 \x01(\x0c\x12\x10\n\x08userName\x18\x04 \x01(\t\x12\x11\n\tstartedOn\x18\x05 \x01(\x03\x12\x18\n\x10\x63onfigurationUid\x18\x06 \x01(\x0c\"Y\n\x14PAMOnlineControllers\x12\x12\n\ndeprecated\x18\x01 \x03(\x0c\x12-\n\x0b\x63ontrollers\x18\x02 \x03(\x0b\x32\x18.PAM.PAMOnlineController\"9\n\x10PAMRotateRequest\x12\x12\n\nrequestUid\x18\x01 \x01(\x0c\x12\x11\n\trecordUid\x18\x02 \x01(\x0c\"A\n\x16PAMControllersResponse\x12\'\n\x0b\x63ontrollers\x18\x01 \x03(\x0b\x32\x12.PAM.PAMController\"=\n\x13PAMRemoveController\x12\x15\n\rcontrollerUid\x18\x01 \x01(\x0c\x12\x0f\n\x07message\x18\x02 \x01(\t\"L\n\x1bPAMRemoveControllerResponse\x12-\n\x0b\x63ontrollers\x18\x01 \x03(\x0b\x32\x18.PAM.PAMRemoveController\"=\n\x10PAMModifyRequest\x12)\n\noperations\x18\x01 \x03(\x0b\x32\x15.PAM.PAMDataOperation\"\x98\x01\n\x10PAMDataOperation\x12,\n\roperationType\x18\x01 \x01(\x0e\x32\x15.PAM.PAMOperationType\x12\x30\n\rconfiguration\x18\x02 \x01(\x0b\x32\x19.PAM.PAMConfigurationData\x12$\n\x07\x65lement\x18\x03 \x01(\x0b\x32\x13.PAM.PAMElementData\"e\n\x14PAMConfigurationData\x12\x18\n\x10\x63onfigurationUid\x18\x01 \x01(\x0c\x12\x0e\n\x06nodeId\x18\x02 \x01(\x03\x12\x15\n\rcontrollerUid\x18\x03 \x01(\x0c\x12\x0c\n\x04\x64\x61ta\x18\x04 \x01(\x0c\"E\n\x0ePAMElementData\x12\x12\n\nelementUid\x18\x01 \x01(\x0c\x12\x11\n\tparentUid\x18\x02 \x01(\x0c\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\"m\n\x19PAMElementOperationResult\x12\x12\n\nelementUid\x18\x01 \x01(\x0c\x12+\n\x06result\x18\x02 \x01(\x0e\x32\x1b.PAM.PAMOperationResultType\x12\x0f\n\x07message\x18\x03 \x01(\t\"B\n\x0fPAMModifyResult\x12/\n\x07results\x18\x01 \x03(\x0b\x32\x1e.PAM.PAMElementOperationResult\"x\n\nPAMElement\x12\x12\n\nelementUid\x18\x01 \x01(\x0c\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c\x12\x0f\n\x07\x63reated\x18\x03 \x01(\x03\x12\x14\n\x0clastModified\x18\x04 \x01(\x03\x12!\n\x08\x63hildren\x18\x05 \x03(\x0b\x32\x0f.PAM.PAMElement\"#\n\x14PAMGenericUidRequest\x12\x0b\n\x03uid\x18\x01 \x01(\x0c\"%\n\x15PAMGenericUidsRequest\x12\x0c\n\x04uids\x18\x01 \x03(\x0c\"\xab\x01\n\x10PAMConfiguration\x12\x18\n\x10\x63onfigurationUid\x18\x01 \x01(\x0c\x12\x0e\n\x06nodeId\x18\x02 \x01(\x03\x12\x15\n\rcontrollerUid\x18\x03 \x01(\x0c\x12\x0c\n\x04\x64\x61ta\x18\x04 \x01(\x0c\x12\x0f\n\x07\x63reated\x18\x05 \x01(\x03\x12\x14\n\x0clastModified\x18\x06 \x01(\x03\x12!\n\x08\x63hildren\x18\x07 \x03(\x0b\x32\x0f.PAM.PAMElement\"B\n\x11PAMConfigurations\x12-\n\x0e\x63onfigurations\x18\x01 \x03(\x0b\x32\x15.PAM.PAMConfiguration\"\xab\x02\n\rPAMController\x12\x15\n\rcontrollerUid\x18\x01 \x01(\x0c\x12\x16\n\x0e\x63ontrollerName\x18\x02 \x01(\t\x12\x13\n\x0b\x64\x65viceToken\x18\x03 \x01(\t\x12\x12\n\ndeviceName\x18\x04 \x01(\t\x12\x0e\n\x06nodeId\x18\x05 \x01(\x03\x12\x0f\n\x07\x63reated\x18\x06 \x01(\x03\x12\x14\n\x0clastModified\x18\x07 \x01(\x03\x12\x16\n\x0e\x61pplicationUid\x18\x08 \x01(\x0c\x12\x30\n\rappClientType\x18\t \x01(\x0e\x32\x19.Enterprise.AppClientType\x12\x15\n\risInitialized\x18\n \x01(\x08\x12\x18\n\x10maxInstanceCount\x18\x0b \x01(\x05\x12\x10\n\x08lastSeen\x18\x0c \x01(\x03\"P\n\x1dPAMSetMaxInstanceCountRequest\x12\x15\n\rcontrollerUid\x18\x01 \x01(\x0c\x12\x18\n\x10maxInstanceCount\x18\x02 \x01(\x05\"%\n\x12\x43ontrollerResponse\x12\x0f\n\x07payload\x18\x01 \x01(\t\"M\n\x1aPAMConfigurationController\x12\x18\n\x10\x63onfigurationUid\x18\x01 \x01(\x0c\x12\x15\n\rcontrollerUid\x18\x02 \x01(\x0c\"\xa3\x01\n\x17\x43onfigurationAddRequest\x12\x18\n\x10\x63onfigurationUid\x18\x01 \x01(\x0c\x12\x11\n\trecordKey\x18\x02 \x01(\x0c\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\x12(\n\x0brecordLinks\x18\x04 \x03(\x0b\x32\x13.Records.RecordLink\x12#\n\x05\x61udit\x18\x05 \x01(\x0b\x32\x14.Records.RecordAudit\"J\n\x10RelayAccessCreds\x12\x10\n\x08username\x18\x01 \x01(\t\x12\x10\n\x08password\x18\x02 \x01(\t\x12\x12\n\nserverTime\x18\x03 \x01(\x03\"\\\n\x10KturnAccessCreds\x12\x0b\n\x03url\x18\x01 \x01(\t\x12\r\n\x05token\x18\x02 \x01(\x0c\x12\x18\n\x10\x63\x65rt_fingerprint\x18\x03 \x01(\t\x12\x12\n\nexpires_at\x18\x04 \x01(\x04\"\x81\x02\n\x14PAMRecordingsRequest\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x10\n\x08maxCount\x18\x02 \x01(\x05\x12\x17\n\nrangeStart\x18\x03 \x01(\x03H\x00\x88\x01\x01\x12\x15\n\x08rangeEnd\x18\x04 \x01(\x03H\x01\x88\x01\x01\x12$\n\x05types\x18\x05 \x03(\x0e\x32\x15.PAM.PAMRecordingType\x12)\n\x05risks\x18\x06 \x03(\x0e\x32\x1a.PAM.PAMRecordingRiskLevel\x12\x11\n\tprotocols\x18\x07 \x03(\t\x12\x14\n\x0c\x63loseReasons\x18\x08 \x03(\x05\x42\r\n\x0b_rangeStartB\x0b\n\t_rangeEnd\"\x89\x02\n\x1cPAMRecordingsForUsersRequest\x12\x11\n\tusernames\x18\x01 \x03(\t\x12\x10\n\x08maxCount\x18\x02 \x01(\x05\x12\x17\n\nrangeStart\x18\x03 \x01(\x03H\x00\x88\x01\x01\x12\x15\n\x08rangeEnd\x18\x04 \x01(\x03H\x01\x88\x01\x01\x12$\n\x05types\x18\x05 \x03(\x0e\x32\x15.PAM.PAMRecordingType\x12)\n\x05risks\x18\x06 \x03(\x0e\x32\x1a.PAM.PAMRecordingRiskLevel\x12\x11\n\tprotocols\x18\x07 \x03(\t\x12\x14\n\x0c\x63loseReasons\x18\x08 \x03(\x05\x42\r\n\x0b_rangeStartB\x0b\n\t_rangeEnd\"\xd4\x02\n\x0cPAMRecording\x12\x15\n\rconnectionUid\x18\x01 \x01(\x0c\x12,\n\rrecordingType\x18\x02 \x01(\x0e\x32\x15.PAM.PAMRecordingType\x12\x11\n\trecordUid\x18\x03 \x01(\x0c\x12\x10\n\x08userName\x18\x04 \x01(\t\x12\x11\n\tstartedOn\x18\x05 \x01(\x03\x12\x0e\n\x06length\x18\x06 \x01(\x05\x12\x10\n\x08\x66ileSize\x18\x07 \x01(\x03\x12\x11\n\tcreatedOn\x18\x08 \x01(\x03\x12\x10\n\x08protocol\x18\t \x01(\t\x12\x13\n\x0b\x63loseReason\x18\n \x01(\x05\x12\x19\n\x11recordingDuration\x18\x0b \x01(\x05\x12\x36\n\x12\x61iOverallRiskLevel\x18\x0c \x01(\x0e\x32\x1a.PAM.PAMRecordingRiskLevel\x12\x18\n\x10\x61iOverallSummary\x18\r \x01(\x0c\"O\n\x15PAMRecordingsResponse\x12%\n\nrecordings\x18\x01 \x03(\x0b\x32\x11.PAM.PAMRecording\x12\x0f\n\x07hasMore\x18\x02 \x01(\x08\"*\n\x07PAMData\x12\x0e\n\x06vertex\x18\x01 \x01(\x0c\x12\x0f\n\x07\x63ontent\x18\x02 \x01(\x0c\"\x17\n\x07UidList\x12\x0c\n\x04uids\x18\x01 \x03(\x0c\"5\n\x0fPAMServiceNames\x12\x13\n\x0bresourceUid\x18\x01 \x01(\x0c\x12\r\n\x05names\x18\x02 \x01(\x0c\"\xb4\x03\n\x11PAMResourceConfig\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x17\n\nnetworkUid\x18\x02 \x01(\x0cH\x00\x88\x01\x01\x12\x15\n\x08\x61\x64minUid\x18\x03 \x01(\x0cH\x01\x88\x01\x01\x12\x11\n\x04meta\x18\x04 \x01(\x0cH\x02\x88\x01\x01\x12\x1f\n\x12\x63onnectionSettings\x18\x05 \x01(\x0cH\x03\x88\x01\x01\x12\'\n\x0c\x63onnectUsers\x18\x06 \x01(\x0b\x32\x0c.PAM.UidListH\x04\x88\x01\x01\x12\x16\n\tdomainUid\x18\x07 \x01(\x0cH\x05\x88\x01\x01\x12\x18\n\x0bjitSettings\x18\x08 \x01(\x0cH\x06\x88\x01\x01\x12\x1d\n\x10keeperAiSettings\x18\t \x01(\x0cH\x07\x88\x01\x01\x12\x1b\n\x0eupdateServices\x18\n \x01(\x08H\x08\x88\x01\x01\x42\r\n\x0b_networkUidB\x0b\n\t_adminUidB\x07\n\x05_metaB\x15\n\x13_connectionSettingsB\x0f\n\r_connectUsersB\x0c\n\n_domainUidB\x0e\n\x0c_jitSettingsB\x13\n\x11_keeperAiSettingsB\x11\n\x0f_updateServices\"%\n\x16PAMUniversalSyncFolder\x12\x0b\n\x03uid\x18\x01 \x01(\x0c\" \n\x10GitHubRepository\x12\x0c\n\x04name\x18\x01 \x01(\x0c\"\xd6\x01\n\x0cGitHubConfig\x12$\n\x05scope\x18\x01 \x01(\x0e\x32\x10.PAM.GitHubScopeH\x00\x88\x01\x01\x12\r\n\x05owner\x18\x02 \x01(\x0c\x12\x46\n\x16organizationVisibility\x18\x03 \x01(\x0e\x32!.PAM.GitHubOrganizationVisibilityH\x01\x88\x01\x01\x12$\n\x05repos\x18\x04 \x03(\x0b\x32\x15.PAM.GitHubRepositoryB\x08\n\x06_scopeB\x19\n\x17_organizationVisibility\"\xaf\x02\n\x16PAMUniversalSyncConfig\x12\x12\n\nnetworkUid\x18\x01 \x01(\x0c\x12\x14\n\x07\x65nabled\x18\x02 \x01(\x08H\x00\x88\x01\x01\x12\x1a\n\rdryRunEnabled\x18\x03 \x01(\x08H\x01\x88\x01\x01\x12,\n\x07\x66olders\x18\x04 \x03(\x0b\x32\x1b.PAM.PAMUniversalSyncFolder\x12\x19\n\x0csyncIdentity\x18\x05 \x01(\x0cH\x02\x88\x01\x01\x12\x16\n\tvaultName\x18\x06 \x01(\x0cH\x03\x88\x01\x01\x12&\n\x06github\x18\x07 \x01(\x0b\x32\x11.PAM.GitHubConfigH\x04\x88\x01\x01\x42\n\n\x08_enabledB\x10\n\x0e_dryRunEnabledB\x0f\n\r_syncIdentityB\x0c\n\n_vaultNameB\t\n\x07_github\"7\n\x11NhiMetricsRequest\x12\x11\n\tstartTime\x18\x01 \x01(\x03\x12\x0f\n\x07\x65ndTime\x18\x02 \x01(\x03\"\x9c\x02\n\x0ePamUsageByUser\x12\x0e\n\x06userId\x18\x01 \x01(\x05\x12!\n\x19recordRotationScheduledOk\x18\x02 \x01(\x05\x12\x1c\n\x14pamConnectionStarted\x18\x03 \x01(\x05\x12\x18\n\x10pamTunnelStarted\x18\x04 \x01(\x05\x12\x1b\n\x13\x64iscoveryJobStarted\x18\x05 \x01(\x05\x12 \n\x18recordRotationOnDemandOk\x18\x06 \x01(\x05\x12\"\n\x1apamSessionRecordingStarted\x18\x07 \x01(\x05\x12\x15\n\rpamRbiStarted\x18\x08 \x01(\x05\x12%\n\x1dpamSessionRbiRecordingStarted\x18\t \x01(\x05\"p\n\x0eNhiUsageByUser\x12\x0e\n\x06userId\x18\x01 \x01(\x05\x12\x11\n\trotations\x18\x02 \x01(\x05\x12\x0f\n\x07tunnels\x18\x03 \x01(\x05\x12\x13\n\x0b\x63onnections\x18\x04 \x01(\x05\x12\x15\n\rdiscoveryJobs\x18\x05 \x01(\x05\"\x84\x02\n\x12NhiMetricsResponse\x12\x14\n\x0c\x65nterpriseId\x18\x01 \x01(\x03\x12\x11\n\tstartTime\x18\x02 \x01(\x03\x12\x0f\n\x07\x65ndTime\x18\x03 \x01(\x03\x12\x18\n\x10uniqueKsmDevices\x18\x04 \x01(\x05\x12\x18\n\x10pamGatewayOnline\x18\x05 \x01(\x05\x12/\n\x0epamUsageByUser\x18\x06 \x03(\x0b\x32\x13.PAM.PamUsageByUserB\x02\x18\x01\x12\x10\n\x08nhiCount\x18\x07 \x01(\x05\x12\x13\n\x0bksmNhiCount\x18\x08 \x01(\x05\x12(\n\x0busageByUser\x18\t \x03(\x0b\x32\x13.PAM.NhiUsageByUser\"D\n\x16NhiBulkMetricsResponse\x12*\n\tresponses\x18\x01 \x03(\x0b\x32\x17.PAM.NhiMetricsResponse\"^\n\x0bNhiUidEntry\x12\x0b\n\x03uid\x18\x01 \x01(\t\x12\"\n\x08\x63\x61tegory\x18\x02 \x01(\x0e\x32\x10.PAM.NhiCategory\x12\x0e\n\x06ksmNhi\x18\x03 \x01(\x08\x12\x0e\n\x06\x61ppUid\x18\x04 \x01(\t\"7\n\x11GetNhiUidsRequest\x12\x11\n\tstartTime\x18\x01 \x01(\x03\x12\x0f\n\x07\x65ndTime\x18\x02 \x01(\x03\"4\n\x12GetNhiUidsResponse\x12\x1e\n\x04uids\x18\x01 \x03(\x0b\x32\x10.PAM.NhiUidEntry\"6\n\x1dSetNhiKsmEffectiveDateRequest\x12\x15\n\reffectiveDate\x18\x01 \x01(\x03\"L\n\x1eGetNhiKsmEffectiveDateResponse\x12\x15\n\reffectiveDate\x18\x01 \x01(\x03\x12\x13\n\x0b\x64\x65\x66\x61ultDate\x18\x02 \x01(\x03\"I\n\x1fPAMUniversalSyncPreCheckRequest\x12\x12\n\nnetworkUid\x18\x01 \x01(\x0c\x12\x12\n\nfolderUids\x18\x02 \x03(\x0c\"C\n\x1ePAMUniversalSyncPreCheckResult\x12\x11\n\tfolderUid\x18\x01 \x01(\x0c\x12\x0e\n\x06isUsed\x18\x02 \x01(\x08\"X\n PAMUniversalSyncPreCheckResponse\x12\x34\n\x07results\x18\x01 \x03(\x0b\x32#.PAM.PAMUniversalSyncPreCheckResult*\x9e\x01\n\x14WebRtcConnectionType\x12\x0e\n\nCONNECTION\x10\x00\x12\n\n\x06TUNNEL\x10\x01\x12\x07\n\x03SSH\x10\x02\x12\x07\n\x03RDP\x10\x03\x12\x08\n\x04HTTP\x10\x04\x12\x07\n\x03VNC\x10\x05\x12\n\n\x06TELNET\x10\x06\x12\t\n\x05MYSQL\x10\x07\x12\x0e\n\nSQL_SERVER\x10\x08\x12\x0e\n\nPOSTGRESQL\x10\t\x12\x0e\n\nKUBERNETES\x10\n*@\n\x10PAMOperationType\x12\x07\n\x03\x41\x44\x44\x10\x00\x12\n\n\x06UPDATE\x10\x01\x12\x0b\n\x07REPLACE\x10\x02\x12\n\n\x06\x44\x45LETE\x10\x03*p\n\x16PAMOperationResultType\x12\x0f\n\x0bPOT_SUCCESS\x10\x00\x12\x15\n\x11POT_UNKNOWN_ERROR\x10\x01\x12\x16\n\x12POT_ALREADY_EXISTS\x10\x02\x12\x16\n\x12POT_DOES_NOT_EXIST\x10\x03*\xc9\x01\n\x15\x43ontrollerMessageType\x12\x0f\n\x0b\x43MT_GENERAL\x10\x00\x12\x0e\n\nCMT_ROTATE\x10\x01\x12\x11\n\rCMT_DISCOVERY\x10\x02\x12\x0f\n\x0b\x43MT_CONNECT\x10\x03\x12\x19\n\x15\x43MT_ANALYZE_RECORDING\x10\x04\x12!\n\x1d\x43MT_WORKFLOW_ACCESS_ELEVATION\x10\x05\x12\x0b\n\x07\x43MT_USS\x10\x06\x12\x0c\n\x08\x43MT_INFO\x10\x07\x12\x12\n\x0e\x43MT_AUTOMATION\x10\x08*V\n\x10PAMRecordingType\x12\x0f\n\x0bPRT_SESSION\x10\x00\x12\x12\n\x0ePRT_TYPESCRIPT\x10\x01\x12\x0c\n\x08PRT_TIME\x10\x02\x12\x0f\n\x0bPRT_SUMMARY\x10\x03*i\n\x15PAMRecordingRiskLevel\x12\x13\n\x0fPRR_UNSPECIFIED\x10\x00\x12\x0b\n\x07PRR_LOW\x10\x01\x12\x0e\n\nPRR_MEDIUM\x10\x02\x12\x0c\n\x08PRR_HIGH\x10\x03\x12\x10\n\x0cPRR_CRITICAL\x10\x04*/\n\x0bGitHubScope\x12\x0e\n\nREPOSITORY\x10\x00\x12\x10\n\x0cORGANIZATION\x10\x01*B\n\x1cGitHubOrganizationVisibility\x12\x07\n\x03\x41LL\x10\x00\x12\x0b\n\x07PRIVATE\x10\x01\x12\x0c\n\x08SELECTED\x10\x02*`\n\x0bNhiCategory\x12\x18\n\x14NHI_CATEGORY_UNKNOWN\x10\x00\x12\x0c\n\x08PAM_USER\x10\x01\x12\x10\n\x0cPAM_RESOURCE\x10\x02\x12\x0b\n\x07GATEWAY\x10\x03\x12\n\n\x06\x44\x45VICE\x10\x04\x42\x1f\n\x18\x63om.keepersecurity.protoB\x03PAMb\x06proto3')
+DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\tpam.proto\x12\x03PAM\x1a\x10enterprise.proto\x1a\x0crecord.proto"\x83\x01\n\x13PAMRotationSchedule\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x18\n\x10configurationUid\x18\x02 \x01(\x0c\x12\x15\n\rcontrollerUid\x18\x03 \x01(\x0c\x12\x14\n\x0cscheduleData\x18\x04 \x01(\t\x12\x12\n\nnoSchedule\x18\x05 \x01(\x08"K\n\x1cPAMRotationSchedulesResponse\x12+\n\tschedules\x18\x01 \x03(\x0b2\x18.PAM.PAMRotationSchedule"\x94\x01\n\x13PAMOnlineController\x12\x15\n\rcontrollerUid\x18\x01 \x01(\x0c\x12\x13\n\x0bconnectedOn\x18\x02 \x01(\x03\x12\x11\n\tipAddress\x18\x03 \x01(\t\x12\x0f\n\x07version\x18\x04 \x01(\t\x12-\n\x0bconnections\x18\x05 \x03(\x0b2\x18.PAM.PAMWebRtcConnection"\xa7\x01\n\x13PAMWebRtcConnection\x12\x15\n\rconnectionUid\x18\x01 \x01(\x0c\x12\'\n\x04type\x18\x02 \x01(\x0e2\x19.PAM.WebRtcConnectionType\x12\x11\n\trecordUid\x18\x03 \x01(\x0c\x12\x10\n\x08userName\x18\x04 \x01(\t\x12\x11\n\tstartedOn\x18\x05 \x01(\x03\x12\x18\n\x10configurationUid\x18\x06 \x01(\x0c"Y\n\x14PAMOnlineControllers\x12\x12\n\ndeprecated\x18\x01 \x03(\x0c\x12-\n\x0bcontrollers\x18\x02 \x03(\x0b2\x18.PAM.PAMOnlineController"9\n\x10PAMRotateRequest\x12\x12\n\nrequestUid\x18\x01 \x01(\x0c\x12\x11\n\trecordUid\x18\x02 \x01(\x0c"A\n\x16PAMControllersResponse\x12\'\n\x0bcontrollers\x18\x01 \x03(\x0b2\x12.PAM.PAMController"=\n\x13PAMRemoveController\x12\x15\n\rcontrollerUid\x18\x01 \x01(\x0c\x12\x0f\n\x07message\x18\x02 \x01(\t"L\n\x1bPAMRemoveControllerResponse\x12-\n\x0bcontrollers\x18\x01 \x03(\x0b2\x18.PAM.PAMRemoveController"=\n\x10PAMModifyRequest\x12)\n\noperations\x18\x01 \x03(\x0b2\x15.PAM.PAMDataOperation"\x98\x01\n\x10PAMDataOperation\x12,\n\roperationType\x18\x01 \x01(\x0e2\x15.PAM.PAMOperationType\x120\n\rconfiguration\x18\x02 \x01(\x0b2\x19.PAM.PAMConfigurationData\x12$\n\x07element\x18\x03 \x01(\x0b2\x13.PAM.PAMElementData"e\n\x14PAMConfigurationData\x12\x18\n\x10configurationUid\x18\x01 \x01(\x0c\x12\x0e\n\x06nodeId\x18\x02 \x01(\x03\x12\x15\n\rcontrollerUid\x18\x03 \x01(\x0c\x12\x0c\n\x04data\x18\x04 \x01(\x0c"E\n\x0ePAMElementData\x12\x12\n\nelementUid\x18\x01 \x01(\x0c\x12\x11\n\tparentUid\x18\x02 \x01(\x0c\x12\x0c\n\x04data\x18\x03 \x01(\x0c"m\n\x19PAMElementOperationResult\x12\x12\n\nelementUid\x18\x01 \x01(\x0c\x12+\n\x06result\x18\x02 \x01(\x0e2\x1b.PAM.PAMOperationResultType\x12\x0f\n\x07message\x18\x03 \x01(\t"B\n\x0fPAMModifyResult\x12/\n\x07results\x18\x01 \x03(\x0b2\x1e.PAM.PAMElementOperationResult"x\n\nPAMElement\x12\x12\n\nelementUid\x18\x01 \x01(\x0c\x12\x0c\n\x04data\x18\x02 \x01(\x0c\x12\x0f\n\x07created\x18\x03 \x01(\x03\x12\x14\n\x0clastModified\x18\x04 \x01(\x03\x12!\n\x08children\x18\x05 \x03(\x0b2\x0f.PAM.PAMElement"#\n\x14PAMGenericUidRequest\x12\x0b\n\x03uid\x18\x01 \x01(\x0c"%\n\x15PAMGenericUidsRequest\x12\x0c\n\x04uids\x18\x01 \x03(\x0c"\xab\x01\n\x10PAMConfiguration\x12\x18\n\x10configurationUid\x18\x01 \x01(\x0c\x12\x0e\n\x06nodeId\x18\x02 \x01(\x03\x12\x15\n\rcontrollerUid\x18\x03 \x01(\x0c\x12\x0c\n\x04data\x18\x04 \x01(\x0c\x12\x0f\n\x07created\x18\x05 \x01(\x03\x12\x14\n\x0clastModified\x18\x06 \x01(\x03\x12!\n\x08children\x18\x07 \x03(\x0b2\x0f.PAM.PAMElement"B\n\x11PAMConfigurations\x12-\n\x0econfigurations\x18\x01 \x03(\x0b2\x15.PAM.PAMConfiguration"\xab\x02\n\rPAMController\x12\x15\n\rcontrollerUid\x18\x01 \x01(\x0c\x12\x16\n\x0econtrollerName\x18\x02 \x01(\t\x12\x13\n\x0bdeviceToken\x18\x03 \x01(\t\x12\x12\n\ndeviceName\x18\x04 \x01(\t\x12\x0e\n\x06nodeId\x18\x05 \x01(\x03\x12\x0f\n\x07created\x18\x06 \x01(\x03\x12\x14\n\x0clastModified\x18\x07 \x01(\x03\x12\x16\n\x0eapplicationUid\x18\x08 \x01(\x0c\x120\n\rappClientType\x18\t \x01(\x0e2\x19.Enterprise.AppClientType\x12\x15\n\risInitialized\x18\n \x01(\x08\x12\x18\n\x10maxInstanceCount\x18\x0b \x01(\x05\x12\x10\n\x08lastSeen\x18\x0c \x01(\x03"P\n\x1dPAMSetMaxInstanceCountRequest\x12\x15\n\rcontrollerUid\x18\x01 \x01(\x0c\x12\x18\n\x10maxInstanceCount\x18\x02 \x01(\x05"%\n\x12ControllerResponse\x12\x0f\n\x07payload\x18\x01 \x01(\t"M\n\x1aPAMConfigurationController\x12\x18\n\x10configurationUid\x18\x01 \x01(\x0c\x12\x15\n\rcontrollerUid\x18\x02 \x01(\x0c"\xa3\x01\n\x17ConfigurationAddRequest\x12\x18\n\x10configurationUid\x18\x01 \x01(\x0c\x12\x11\n\trecordKey\x18\x02 \x01(\x0c\x12\x0c\n\x04data\x18\x03 \x01(\x0c\x12(\n\x0brecordLinks\x18\x04 \x03(\x0b2\x13.Records.RecordLink\x12#\n\x05audit\x18\x05 \x01(\x0b2\x14.Records.RecordAudit"J\n\x10RelayAccessCreds\x12\x10\n\x08username\x18\x01 \x01(\t\x12\x10\n\x08password\x18\x02 \x01(\t\x12\x12\n\nserverTime\x18\x03 \x01(\x03"\\\n\x10KturnAccessCreds\x12\x0b\n\x03url\x18\x01 \x01(\t\x12\r\n\x05token\x18\x02 \x01(\x0c\x12\x18\n\x10cert_fingerprint\x18\x03 \x01(\t\x12\x12\n\nexpires_at\x18\x04 \x01(\x04"\x81\x02\n\x14PAMRecordingsRequest\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x10\n\x08maxCount\x18\x02 \x01(\x05\x12\x17\n\nrangeStart\x18\x03 \x01(\x03H\x00\x88\x01\x01\x12\x15\n\x08rangeEnd\x18\x04 \x01(\x03H\x01\x88\x01\x01\x12$\n\x05types\x18\x05 \x03(\x0e2\x15.PAM.PAMRecordingType\x12)\n\x05risks\x18\x06 \x03(\x0e2\x1a.PAM.PAMRecordingRiskLevel\x12\x11\n\tprotocols\x18\x07 \x03(\t\x12\x14\n\x0ccloseReasons\x18\x08 \x03(\x05B\r\n\x0b_rangeStartB\x0b\n\t_rangeEnd"\x89\x02\n\x1cPAMRecordingsForUsersRequest\x12\x11\n\tusernames\x18\x01 \x03(\t\x12\x10\n\x08maxCount\x18\x02 \x01(\x05\x12\x17\n\nrangeStart\x18\x03 \x01(\x03H\x00\x88\x01\x01\x12\x15\n\x08rangeEnd\x18\x04 \x01(\x03H\x01\x88\x01\x01\x12$\n\x05types\x18\x05 \x03(\x0e2\x15.PAM.PAMRecordingType\x12)\n\x05risks\x18\x06 \x03(\x0e2\x1a.PAM.PAMRecordingRiskLevel\x12\x11\n\tprotocols\x18\x07 \x03(\t\x12\x14\n\x0ccloseReasons\x18\x08 \x03(\x05B\r\n\x0b_rangeStartB\x0b\n\t_rangeEnd"\xd4\x02\n\x0cPAMRecording\x12\x15\n\rconnectionUid\x18\x01 \x01(\x0c\x12,\n\rrecordingType\x18\x02 \x01(\x0e2\x15.PAM.PAMRecordingType\x12\x11\n\trecordUid\x18\x03 \x01(\x0c\x12\x10\n\x08userName\x18\x04 \x01(\t\x12\x11\n\tstartedOn\x18\x05 \x01(\x03\x12\x0e\n\x06length\x18\x06 \x01(\x05\x12\x10\n\x08fileSize\x18\x07 \x01(\x03\x12\x11\n\tcreatedOn\x18\x08 \x01(\x03\x12\x10\n\x08protocol\x18\t \x01(\t\x12\x13\n\x0bcloseReason\x18\n \x01(\x05\x12\x19\n\x11recordingDuration\x18\x0b \x01(\x05\x126\n\x12aiOverallRiskLevel\x18\x0c \x01(\x0e2\x1a.PAM.PAMRecordingRiskLevel\x12\x18\n\x10aiOverallSummary\x18\r \x01(\x0c"O\n\x15PAMRecordingsResponse\x12%\n\nrecordings\x18\x01 \x03(\x0b2\x11.PAM.PAMRecording\x12\x0f\n\x07hasMore\x18\x02 \x01(\x08"*\n\x07PAMData\x12\x0e\n\x06vertex\x18\x01 \x01(\x0c\x12\x0f\n\x07content\x18\x02 \x01(\x0c"\x17\n\x07UidList\x12\x0c\n\x04uids\x18\x01 \x03(\x0c"5\n\x0fPAMServiceNames\x12\x13\n\x0bresourceUid\x18\x01 \x01(\x0c\x12\r\n\x05names\x18\x02 \x01(\x0c"\xb4\x03\n\x11PAMResourceConfig\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x17\n\nnetworkUid\x18\x02 \x01(\x0cH\x00\x88\x01\x01\x12\x15\n\x08adminUid\x18\x03 \x01(\x0cH\x01\x88\x01\x01\x12\x11\n\x04meta\x18\x04 \x01(\x0cH\x02\x88\x01\x01\x12\x1f\n\x12connectionSettings\x18\x05 \x01(\x0cH\x03\x88\x01\x01\x12\'\n\x0cconnectUsers\x18\x06 \x01(\x0b2\x0c.PAM.UidListH\x04\x88\x01\x01\x12\x16\n\tdomainUid\x18\x07 \x01(\x0cH\x05\x88\x01\x01\x12\x18\n\x0bjitSettings\x18\x08 \x01(\x0cH\x06\x88\x01\x01\x12\x1d\n\x10keeperAiSettings\x18\t \x01(\x0cH\x07\x88\x01\x01\x12\x1b\n\x0eupdateServices\x18\n \x01(\x08H\x08\x88\x01\x01B\r\n\x0b_networkUidB\x0b\n\t_adminUidB\x07\n\x05_metaB\x15\n\x13_connectionSettingsB\x0f\n\r_connectUsersB\x0c\n\n_domainUidB\x0e\n\x0c_jitSettingsB\x13\n\x11_keeperAiSettingsB\x11\n\x0f_updateServices"%\n\x16PAMUniversalSyncFolder\x12\x0b\n\x03uid\x18\x01 \x01(\x0c" \n\x10GitHubRepository\x12\x0c\n\x04name\x18\x01 \x01(\x0c"\xd6\x01\n\x0cGitHubConfig\x12$\n\x05scope\x18\x01 \x01(\x0e2\x10.PAM.GitHubScopeH\x00\x88\x01\x01\x12\r\n\x05owner\x18\x02 \x01(\x0c\x12F\n\x16organizationVisibility\x18\x03 \x01(\x0e2!.PAM.GitHubOrganizationVisibilityH\x01\x88\x01\x01\x12$\n\x05repos\x18\x04 \x03(\x0b2\x15.PAM.GitHubRepositoryB\x08\n\x06_scopeB\x19\n\x17_organizationVisibility"\xe9\x02\n\x16PAMUniversalSyncConfig\x12\x12\n\nnetworkUid\x18\x01 \x01(\x0c\x12\x14\n\x07enabled\x18\x02 \x01(\x08H\x00\x88\x01\x01\x12\x1a\n\rdryRunEnabled\x18\x03 \x01(\x08H\x01\x88\x01\x01\x12,\n\x07folders\x18\x04 \x03(\x0b2\x1b.PAM.PAMUniversalSyncFolder\x12\x19\n\x0csyncIdentity\x18\x05 \x01(\x0cH\x02\x88\x01\x01\x12\x16\n\tvaultName\x18\x06 \x01(\x0cH\x03\x88\x01\x01\x12&\n\x06github\x18\x07 \x01(\x0b2\x11.PAM.GitHubConfigH\x04\x88\x01\x01\x12 \n\x13objectStorageBucket\x18\x08 \x01(\x0cH\x05\x88\x01\x01B\n\n\x08_enabledB\x10\n\x0e_dryRunEnabledB\x0f\n\r_syncIdentityB\x0c\n\n_vaultNameB\t\n\x07_githubB\x16\n\x14_objectStorageBucket"7\n\x11NhiMetricsRequest\x12\x11\n\tstartTime\x18\x01 \x01(\x03\x12\x0f\n\x07endTime\x18\x02 \x01(\x03"\x9c\x02\n\x0ePamUsageByUser\x12\x0e\n\x06userId\x18\x01 \x01(\x05\x12!\n\x19recordRotationScheduledOk\x18\x02 \x01(\x05\x12\x1c\n\x14pamConnectionStarted\x18\x03 \x01(\x05\x12\x18\n\x10pamTunnelStarted\x18\x04 \x01(\x05\x12\x1b\n\x13discoveryJobStarted\x18\x05 \x01(\x05\x12 \n\x18recordRotationOnDemandOk\x18\x06 \x01(\x05\x12"\n\x1apamSessionRecordingStarted\x18\x07 \x01(\x05\x12\x15\n\rpamRbiStarted\x18\x08 \x01(\x05\x12%\n\x1dpamSessionRbiRecordingStarted\x18\t \x01(\x05"p\n\x0eNhiUsageByUser\x12\x0e\n\x06userId\x18\x01 \x01(\x05\x12\x11\n\trotations\x18\x02 \x01(\x05\x12\x0f\n\x07tunnels\x18\x03 \x01(\x05\x12\x13\n\x0bconnections\x18\x04 \x01(\x05\x12\x15\n\rdiscoveryJobs\x18\x05 \x01(\x05"\x84\x02\n\x12NhiMetricsResponse\x12\x14\n\x0centerpriseId\x18\x01 \x01(\x03\x12\x11\n\tstartTime\x18\x02 \x01(\x03\x12\x0f\n\x07endTime\x18\x03 \x01(\x03\x12\x18\n\x10uniqueKsmDevices\x18\x04 \x01(\x05\x12\x18\n\x10pamGatewayOnline\x18\x05 \x01(\x05\x12/\n\x0epamUsageByUser\x18\x06 \x03(\x0b2\x13.PAM.PamUsageByUserB\x02\x18\x01\x12\x10\n\x08nhiCount\x18\x07 \x01(\x05\x12\x13\n\x0bksmNhiCount\x18\x08 \x01(\x05\x12(\n\x0busageByUser\x18\t \x03(\x0b2\x13.PAM.NhiUsageByUser"D\n\x16NhiBulkMetricsResponse\x12*\n\tresponses\x18\x01 \x03(\x0b2\x17.PAM.NhiMetricsResponse"^\n\x0bNhiUidEntry\x12\x0b\n\x03uid\x18\x01 \x01(\t\x12"\n\x08category\x18\x02 \x01(\x0e2\x10.PAM.NhiCategory\x12\x0e\n\x06ksmNhi\x18\x03 \x01(\x08\x12\x0e\n\x06appUid\x18\x04 \x01(\t"7\n\x11GetNhiUidsRequest\x12\x11\n\tstartTime\x18\x01 \x01(\x03\x12\x0f\n\x07endTime\x18\x02 \x01(\x03"4\n\x12GetNhiUidsResponse\x12\x1e\n\x04uids\x18\x01 \x03(\x0b2\x10.PAM.NhiUidEntry"6\n\x1dSetNhiKsmEffectiveDateRequest\x12\x15\n\reffectiveDate\x18\x01 \x01(\x03"L\n\x1eGetNhiKsmEffectiveDateResponse\x12\x15\n\reffectiveDate\x18\x01 \x01(\x03\x12\x13\n\x0bdefaultDate\x18\x02 \x01(\x03"I\n\x1fPAMUniversalSyncPreCheckRequest\x12\x12\n\nnetworkUid\x18\x01 \x01(\x0c\x12\x12\n\nfolderUids\x18\x02 \x03(\x0c"C\n\x1ePAMUniversalSyncPreCheckResult\x12\x11\n\tfolderUid\x18\x01 \x01(\x0c\x12\x0e\n\x06isUsed\x18\x02 \x01(\x08"X\n PAMUniversalSyncPreCheckResponse\x124\n\x07results\x18\x01 \x03(\x0b2#.PAM.PAMUniversalSyncPreCheckResult*\x9e\x01\n\x14WebRtcConnectionType\x12\x0e\n\nCONNECTION\x10\x00\x12\n\n\x06TUNNEL\x10\x01\x12\x07\n\x03SSH\x10\x02\x12\x07\n\x03RDP\x10\x03\x12\x08\n\x04HTTP\x10\x04\x12\x07\n\x03VNC\x10\x05\x12\n\n\x06TELNET\x10\x06\x12\t\n\x05MYSQL\x10\x07\x12\x0e\n\nSQL_SERVER\x10\x08\x12\x0e\n\nPOSTGRESQL\x10\t\x12\x0e\n\nKUBERNETES\x10\n*@\n\x10PAMOperationType\x12\x07\n\x03ADD\x10\x00\x12\n\n\x06UPDATE\x10\x01\x12\x0b\n\x07REPLACE\x10\x02\x12\n\n\x06DELETE\x10\x03*p\n\x16PAMOperationResultType\x12\x0f\n\x0bPOT_SUCCESS\x10\x00\x12\x15\n\x11POT_UNKNOWN_ERROR\x10\x01\x12\x16\n\x12POT_ALREADY_EXISTS\x10\x02\x12\x16\n\x12POT_DOES_NOT_EXIST\x10\x03*\xc9\x01\n\x15ControllerMessageType\x12\x0f\n\x0bCMT_GENERAL\x10\x00\x12\x0e\n\nCMT_ROTATE\x10\x01\x12\x11\n\rCMT_DISCOVERY\x10\x02\x12\x0f\n\x0bCMT_CONNECT\x10\x03\x12\x19\n\x15CMT_ANALYZE_RECORDING\x10\x04\x12!\n\x1dCMT_WORKFLOW_ACCESS_ELEVATION\x10\x05\x12\x0b\n\x07CMT_USS\x10\x06\x12\x0c\n\x08CMT_INFO\x10\x07\x12\x12\n\x0eCMT_AUTOMATION\x10\x08*V\n\x10PAMRecordingType\x12\x0f\n\x0bPRT_SESSION\x10\x00\x12\x12\n\x0ePRT_TYPESCRIPT\x10\x01\x12\x0c\n\x08PRT_TIME\x10\x02\x12\x0f\n\x0bPRT_SUMMARY\x10\x03*i\n\x15PAMRecordingRiskLevel\x12\x13\n\x0fPRR_UNSPECIFIED\x10\x00\x12\x0b\n\x07PRR_LOW\x10\x01\x12\x0e\n\nPRR_MEDIUM\x10\x02\x12\x0c\n\x08PRR_HIGH\x10\x03\x12\x10\n\x0cPRR_CRITICAL\x10\x04*/\n\x0bGitHubScope\x12\x0e\n\nREPOSITORY\x10\x00\x12\x10\n\x0cORGANIZATION\x10\x01*B\n\x1cGitHubOrganizationVisibility\x12\x07\n\x03ALL\x10\x00\x12\x0b\n\x07PRIVATE\x10\x01\x12\x0c\n\x08SELECTED\x10\x02*`\n\x0bNhiCategory\x12\x18\n\x14NHI_CATEGORY_UNKNOWN\x10\x00\x12\x0c\n\x08PAM_USER\x10\x01\x12\x10\n\x0cPAM_RESOURCE\x10\x02\x12\x0b\n\x07GATEWAY\x10\x03\x12\n\n\x06DEVICE\x10\x04B\x1f\n\x18com.keepersecurity.protoB\x03PAMb\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
@@ -36,24 +36,24 @@
_globals['DESCRIPTOR']._serialized_options = b'\n\030com.keepersecurity.protoB\003PAM'
_globals['_NHIMETRICSRESPONSE'].fields_by_name['pamUsageByUser']._loaded_options = None
_globals['_NHIMETRICSRESPONSE'].fields_by_name['pamUsageByUser']._serialized_options = b'\030\001'
- _globals['_WEBRTCCONNECTIONTYPE']._serialized_start=6267
- _globals['_WEBRTCCONNECTIONTYPE']._serialized_end=6425
- _globals['_PAMOPERATIONTYPE']._serialized_start=6427
- _globals['_PAMOPERATIONTYPE']._serialized_end=6491
- _globals['_PAMOPERATIONRESULTTYPE']._serialized_start=6493
- _globals['_PAMOPERATIONRESULTTYPE']._serialized_end=6605
- _globals['_CONTROLLERMESSAGETYPE']._serialized_start=6608
- _globals['_CONTROLLERMESSAGETYPE']._serialized_end=6809
- _globals['_PAMRECORDINGTYPE']._serialized_start=6811
- _globals['_PAMRECORDINGTYPE']._serialized_end=6897
- _globals['_PAMRECORDINGRISKLEVEL']._serialized_start=6899
- _globals['_PAMRECORDINGRISKLEVEL']._serialized_end=7004
- _globals['_GITHUBSCOPE']._serialized_start=7006
- _globals['_GITHUBSCOPE']._serialized_end=7053
- _globals['_GITHUBORGANIZATIONVISIBILITY']._serialized_start=7055
- _globals['_GITHUBORGANIZATIONVISIBILITY']._serialized_end=7121
- _globals['_NHICATEGORY']._serialized_start=7123
- _globals['_NHICATEGORY']._serialized_end=7219
+ _globals['_WEBRTCCONNECTIONTYPE']._serialized_start=6325
+ _globals['_WEBRTCCONNECTIONTYPE']._serialized_end=6483
+ _globals['_PAMOPERATIONTYPE']._serialized_start=6485
+ _globals['_PAMOPERATIONTYPE']._serialized_end=6549
+ _globals['_PAMOPERATIONRESULTTYPE']._serialized_start=6551
+ _globals['_PAMOPERATIONRESULTTYPE']._serialized_end=6663
+ _globals['_CONTROLLERMESSAGETYPE']._serialized_start=6666
+ _globals['_CONTROLLERMESSAGETYPE']._serialized_end=6867
+ _globals['_PAMRECORDINGTYPE']._serialized_start=6869
+ _globals['_PAMRECORDINGTYPE']._serialized_end=6955
+ _globals['_PAMRECORDINGRISKLEVEL']._serialized_start=6957
+ _globals['_PAMRECORDINGRISKLEVEL']._serialized_end=7062
+ _globals['_GITHUBSCOPE']._serialized_start=7064
+ _globals['_GITHUBSCOPE']._serialized_end=7111
+ _globals['_GITHUBORGANIZATIONVISIBILITY']._serialized_start=7113
+ _globals['_GITHUBORGANIZATIONVISIBILITY']._serialized_end=7179
+ _globals['_NHICATEGORY']._serialized_start=7181
+ _globals['_NHICATEGORY']._serialized_end=7277
_globals['_PAMROTATIONSCHEDULE']._serialized_start=51
_globals['_PAMROTATIONSCHEDULE']._serialized_end=182
_globals['_PAMROTATIONSCHEDULESRESPONSE']._serialized_start=184
@@ -131,31 +131,31 @@
_globals['_GITHUBCONFIG']._serialized_start=4378
_globals['_GITHUBCONFIG']._serialized_end=4592
_globals['_PAMUNIVERSALSYNCCONFIG']._serialized_start=4595
- _globals['_PAMUNIVERSALSYNCCONFIG']._serialized_end=4898
- _globals['_NHIMETRICSREQUEST']._serialized_start=4900
- _globals['_NHIMETRICSREQUEST']._serialized_end=4955
- _globals['_PAMUSAGEBYUSER']._serialized_start=4958
- _globals['_PAMUSAGEBYUSER']._serialized_end=5242
- _globals['_NHIUSAGEBYUSER']._serialized_start=5244
- _globals['_NHIUSAGEBYUSER']._serialized_end=5356
- _globals['_NHIMETRICSRESPONSE']._serialized_start=5359
- _globals['_NHIMETRICSRESPONSE']._serialized_end=5619
- _globals['_NHIBULKMETRICSRESPONSE']._serialized_start=5621
- _globals['_NHIBULKMETRICSRESPONSE']._serialized_end=5689
- _globals['_NHIUIDENTRY']._serialized_start=5691
- _globals['_NHIUIDENTRY']._serialized_end=5785
- _globals['_GETNHIUIDSREQUEST']._serialized_start=5787
- _globals['_GETNHIUIDSREQUEST']._serialized_end=5842
- _globals['_GETNHIUIDSRESPONSE']._serialized_start=5844
- _globals['_GETNHIUIDSRESPONSE']._serialized_end=5896
- _globals['_SETNHIKSMEFFECTIVEDATEREQUEST']._serialized_start=5898
- _globals['_SETNHIKSMEFFECTIVEDATEREQUEST']._serialized_end=5952
- _globals['_GETNHIKSMEFFECTIVEDATERESPONSE']._serialized_start=5954
- _globals['_GETNHIKSMEFFECTIVEDATERESPONSE']._serialized_end=6030
- _globals['_PAMUNIVERSALSYNCPRECHECKREQUEST']._serialized_start=6032
- _globals['_PAMUNIVERSALSYNCPRECHECKREQUEST']._serialized_end=6105
- _globals['_PAMUNIVERSALSYNCPRECHECKRESULT']._serialized_start=6107
- _globals['_PAMUNIVERSALSYNCPRECHECKRESULT']._serialized_end=6174
- _globals['_PAMUNIVERSALSYNCPRECHECKRESPONSE']._serialized_start=6176
- _globals['_PAMUNIVERSALSYNCPRECHECKRESPONSE']._serialized_end=6264
+ _globals['_PAMUNIVERSALSYNCCONFIG']._serialized_end=4956
+ _globals['_NHIMETRICSREQUEST']._serialized_start=4958
+ _globals['_NHIMETRICSREQUEST']._serialized_end=5013
+ _globals['_PAMUSAGEBYUSER']._serialized_start=5016
+ _globals['_PAMUSAGEBYUSER']._serialized_end=5300
+ _globals['_NHIUSAGEBYUSER']._serialized_start=5302
+ _globals['_NHIUSAGEBYUSER']._serialized_end=5414
+ _globals['_NHIMETRICSRESPONSE']._serialized_start=5417
+ _globals['_NHIMETRICSRESPONSE']._serialized_end=5677
+ _globals['_NHIBULKMETRICSRESPONSE']._serialized_start=5679
+ _globals['_NHIBULKMETRICSRESPONSE']._serialized_end=5747
+ _globals['_NHIUIDENTRY']._serialized_start=5749
+ _globals['_NHIUIDENTRY']._serialized_end=5843
+ _globals['_GETNHIUIDSREQUEST']._serialized_start=5845
+ _globals['_GETNHIUIDSREQUEST']._serialized_end=5900
+ _globals['_GETNHIUIDSRESPONSE']._serialized_start=5902
+ _globals['_GETNHIUIDSRESPONSE']._serialized_end=5954
+ _globals['_SETNHIKSMEFFECTIVEDATEREQUEST']._serialized_start=5956
+ _globals['_SETNHIKSMEFFECTIVEDATEREQUEST']._serialized_end=6010
+ _globals['_GETNHIKSMEFFECTIVEDATERESPONSE']._serialized_start=6012
+ _globals['_GETNHIKSMEFFECTIVEDATERESPONSE']._serialized_end=6088
+ _globals['_PAMUNIVERSALSYNCPRECHECKREQUEST']._serialized_start=6090
+ _globals['_PAMUNIVERSALSYNCPRECHECKREQUEST']._serialized_end=6163
+ _globals['_PAMUNIVERSALSYNCPRECHECKRESULT']._serialized_start=6165
+ _globals['_PAMUNIVERSALSYNCPRECHECKRESULT']._serialized_end=6232
+ _globals['_PAMUNIVERSALSYNCPRECHECKRESPONSE']._serialized_start=6234
+ _globals['_PAMUNIVERSALSYNCPRECHECKRESPONSE']._serialized_end=6322
# @@protoc_insertion_point(module_scope)
diff --git a/keepercommander/proto/pam_pb2.pyi b/keepercommander/proto/pam_pb2.pyi
index b83fdc735..5b5a29ce3 100644
--- a/keepercommander/proto/pam_pb2.pyi
+++ b/keepercommander/proto/pam_pb2.pyi
@@ -555,7 +555,7 @@ class GitHubConfig(_message.Message):
def __init__(self, scope: _Optional[_Union[GitHubScope, str]] = ..., owner: _Optional[bytes] = ..., organizationVisibility: _Optional[_Union[GitHubOrganizationVisibility, str]] = ..., repos: _Optional[_Iterable[_Union[GitHubRepository, _Mapping]]] = ...) -> None: ...
class PAMUniversalSyncConfig(_message.Message):
- __slots__ = ("networkUid", "enabled", "dryRunEnabled", "folders", "syncIdentity", "vaultName", "github")
+ __slots__ = ("networkUid", "enabled", "dryRunEnabled", "folders", "syncIdentity", "vaultName", "github", "objectStorageBucket")
NETWORKUID_FIELD_NUMBER: _ClassVar[int]
ENABLED_FIELD_NUMBER: _ClassVar[int]
DRYRUNENABLED_FIELD_NUMBER: _ClassVar[int]
@@ -563,6 +563,7 @@ class PAMUniversalSyncConfig(_message.Message):
SYNCIDENTITY_FIELD_NUMBER: _ClassVar[int]
VAULTNAME_FIELD_NUMBER: _ClassVar[int]
GITHUB_FIELD_NUMBER: _ClassVar[int]
+ OBJECTSTORAGEBUCKET_FIELD_NUMBER: _ClassVar[int]
networkUid: bytes
enabled: bool
dryRunEnabled: bool
@@ -570,7 +571,8 @@ class PAMUniversalSyncConfig(_message.Message):
syncIdentity: bytes
vaultName: bytes
github: GitHubConfig
- def __init__(self, networkUid: _Optional[bytes] = ..., enabled: _Optional[bool] = ..., dryRunEnabled: _Optional[bool] = ..., folders: _Optional[_Iterable[_Union[PAMUniversalSyncFolder, _Mapping]]] = ..., syncIdentity: _Optional[bytes] = ..., vaultName: _Optional[bytes] = ..., github: _Optional[_Union[GitHubConfig, _Mapping]] = ...) -> None: ...
+ objectStorageBucket: bytes
+ def __init__(self, networkUid: _Optional[bytes] = ..., enabled: _Optional[bool] = ..., dryRunEnabled: _Optional[bool] = ..., folders: _Optional[_Iterable[_Union[PAMUniversalSyncFolder, _Mapping]]] = ..., syncIdentity: _Optional[bytes] = ..., vaultName: _Optional[bytes] = ..., github: _Optional[_Union[GitHubConfig, _Mapping]] = ..., objectStorageBucket: _Optional[bytes] = ...) -> None: ...
class NhiMetricsRequest(_message.Message):
__slots__ = ("startTime", "endTime")
diff --git a/keepercommander/resources/service_config.ini b/keepercommander/resources/service_config.ini
index e20d6479b..6e4b4afb5 100644
--- a/keepercommander/resources/service_config.ini
+++ b/keepercommander/resources/service_config.ini
@@ -6,6 +6,12 @@ ngrok_custom_domain_prompt = Enter Ngrok Custom Domain:
cloudflare_prompt = Enable Cloudflare Tunneling? (y/n):
cloudflare_token_prompt = Enter Cloudflare tunnel token:
cloudflare_custom_domain_prompt = Enter Cloudflare custom domain:
+tailscale_prompt = Enable Tailscale Funnel? (y/n):
+tailscale_install_prompt = Tailscale CLI is not installed. Attempt automatic installation now? (y/n):
+tailscale_daemon_start_prompt = Tailscale daemon is not running. Attempt to start it now? (y/n):
+tailscale_operator_prompt = Grant this user Tailscale operator rights now (requires sudo, one-time)? (y/n):
+tailscale_auth_key_prompt = Enter Tailscale auth key:
+tailscale_advertise_tags_prompt = Enter Tailscale ACL tags to advertise, comma-separated (optional, required for OAuth-derived auth keys):
run_mode_prompt = Select run mode (foreground/background):
queue_enabled_prompt = Enable Request Queue? (y/n):
tls_certificate = Enable TLS Certificate? (y/n):
@@ -27,6 +33,8 @@ invalid_cloudflare_token = Invalid Cloudflare token:
invalid_cloudflare_domain = Invalid Cloudflare domain:
cloudflare_token_required = Cloudflare tunnel token is required when using Cloudflare tunnel.
cloudflare_domain_required = Cloudflare custom domain is required when using Cloudflare tunnel.
+invalid_tailscale_auth_key = Invalid Tailscale auth key:
+tailscale_auth_key_required = Tailscale auth key is required when using Tailscale Funnel.
invalid_run_mode = Invalid run mode:
invalid_certificate = Invalid Certificate:
invalid_rate_limit = Invalid rate limit:
diff --git a/keepercommander/service/README.md b/keepercommander/service/README.md
index a04b01174..a96ba7fb8 100644
--- a/keepercommander/service/README.md
+++ b/keepercommander/service/README.md
@@ -46,7 +46,10 @@ You'll be prompted to configure:
- Cloudflare tunneling (y/n) - *if ngrok is disabled*
- Cloudflare tunnel token (required)
- Cloudflare custom domain (required)
-- Enable TLS Certificate (y/n) - *if both ngrok and cloudflare are disabled*
+- Tailscale Funnel (y/n) - *if ngrok and cloudflare are disabled*
+ - Tailscale auth key (required)
+ - Tailscale ACL tags to advertise (optional, required only for OAuth-issued auth keys)
+- Enable TLS Certificate (y/n) - *if ngrok, cloudflare, and tailscale are all disabled*
- TLS Certificate path
- TLS Certificate password
- Enable Request Queue (y/n)
@@ -80,6 +83,20 @@ Configure the service streamlined with Cloudflare:
My Vault> service-create -p -f -c 'tree,record-add,audit-report' -cf -cfd -rm -q -aip -dip
```
+Configure the service streamlined with Tailscale:
+
+```bash
+ My Vault> service-create -p -f -c 'tree,record-add,audit-report' -ts -rm -q -aip -dip
+```
+
+If the auth key was generated by an OAuth client, also pass the ACL tag(s) it's scoped to:
+
+```bash
+ My Vault> service-create -p -f -c 'tree,record-add,audit-report' -ts -tst tag:commander-service -rm -q -aip -dip
+```
+
+**Note:** Commander always forces a fresh re-authentication (`tailscale up --force-reauth`) on every `service-create`/`service-start`, to guarantee the provided auth key is actually validated rather than silently reused from an existing session. A practical consequence: **a single-use auth key will only work for one successful start** — use a reusable key if you expect to restart the service more than once.
+
Parameters:
- `-p, --port`: Port number for the service
- `-c, --commands`: Comma-separated list of allowed commands
@@ -87,6 +104,8 @@ Parameters:
- `-cd, --ngrok_custom_domain`: Ngrok custom domain name
- `-cf, --cloudflare`: Cloudflare tunnel token (required when using cloudflare)
- `-cfd, --cloudflare_custom_domain`: Cloudflare custom domain name (required when using cloudflare)
+- `-ts, --tailscale`: Tailscale auth key to authenticate and generate public URL via Funnel (required when using tailscale)
+- `-tst, --tailscale_advertise_tags`: Comma-separated ACL tags to advertise (required only when the auth key is OAuth-client-issued, e.g. `tag:commander-service`)
- `-f, --fileformat`: File format (json/yaml)
- `-crtf, --certfile`: Certificate file path
- `-crtp, --certpassword`: Certificate password
@@ -320,6 +339,11 @@ The service configuration is stored as an attachment to a vault record in JSON/Y
- Cloudflare tunnel token
- Cloudflare custom domain
- Generated public URL
+- **Tailscale Configuration** (optional):
+ - Tailscale Funnel enabled/disabled
+ - Tailscale auth key
+ - Tailscale ACL tags to advertise
+ - Generated public URL
- **TLS Certificate Configuration** (optional):
- TLS certificate enabled/disabled
- Certificate file path
@@ -373,6 +397,13 @@ When Cloudflare tunneling is enabled, additional logs are maintained:
- **Includes**: Tunnel establishment, connection timeout detection, and firewall blocking diagnostics
- **Auto-created**: Created automatically when Cloudflare tunneling is configured and service starts
+### Tailscale Logging
+When Tailscale Funnel is enabled, additional logs are maintained:
+- **Location**: `keepercommander/service/core/logs/tailscale_subprocess.log`
+- **Content**: Tailscale CLI authentication attempts, Funnel enable/disable events, and status-check output
+- **Includes**: `tailscale up`/`tailscale funnel` command output (the auth key value is never written to this log)
+- **Auto-created**: Created automatically when Tailscale Funnel is configured and service starts
+
### General Logging Configuration
- **Configuration file**: `~/.keeper/logging_config.yaml` (auto-generated)
- **Default level**: `INFO`
@@ -401,6 +432,9 @@ The Docker container provides a streamlined way to deploy Keeper Commander Servi
### Prerequisites
1. Install [Docker](https://www.docker.com/)
+2. If enabling Tailscale Funnel in a Docker deployment:
+ - Docker Compose **v2.23 or later** is required. The Tailscale sidecar's Funnel configuration is supplied through an inline `configs` entry, which older Compose versions reject during schema validation (the whole file fails to load, not just that service). Check your version with `docker compose version`.
+ - The public Funnel URL is logged by the sidecar on startup: `docker logs keeper-tailscale- | grep "Funnel URL"`. `docker exec keeper-tailscale- tailscale funnel status` always shows it too.
### Pull Docker Image
diff --git a/keepercommander/service/commands/create_service.py b/keepercommander/service/commands/create_service.py
index 8ddc5538d..04ec08e2c 100644
--- a/keepercommander/service/commands/create_service.py
+++ b/keepercommander/service/commands/create_service.py
@@ -29,6 +29,8 @@ class StreamlineArgs:
ngrok_custom_domain: Optional[str]
cloudflare: Optional[str]
cloudflare_custom_domain: Optional[str]
+ tailscale: Optional[str]
+ tailscale_advertise_tags: Optional[str]
certfile: Optional[str]
certpassword: Optional[str]
fileformat: Optional[str]
@@ -72,6 +74,8 @@ def get_parser(self):
parser.add_argument('-cd', '--ngrok_custom_domain', type=str, help='ngrok custom domain name(optional)')
parser.add_argument('-cf', '--cloudflare', type=str, help='cloudflare tunnel token to generate public URL (required when using cloudflare)')
parser.add_argument('-cfd', '--cloudflare_custom_domain', type=str, help='cloudflare custom domain name (required when using cloudflare)')
+ parser.add_argument('-ts', '--tailscale', type=str, help='Tailscale auth key to generate public URL via Funnel (required when using tailscale)')
+ parser.add_argument('-tst', '--tailscale_advertise_tags', dest='tailscale_advertise_tags', type=str, help='Comma-separated ACL tags to advertise (required when the auth key is OAuth-client-derived, e.g. tag:commander-service)')
parser.add_argument('-crtf', '--certfile', type=str, help='certificate file path')
parser.add_argument('-crtp', '--certpassword', type=str, help='certificate password')
parser.add_argument('-f', '--fileformat', type=str, help='file format')
@@ -95,7 +99,8 @@ def execute(self, params: KeeperParams, **kwargs) -> None:
filtered_kwargs = {k: v for k, v in kwargs.items() if k in [
'port', 'allowedip', 'deniedip', 'commands', 'ngrok', 'ngrok_custom_domain',
- 'cloudflare', 'cloudflare_custom_domain', 'certfile', 'certpassword', 'fileformat',
+ 'cloudflare', 'cloudflare_custom_domain', 'tailscale', 'tailscale_advertise_tags',
+ 'certfile', 'certpassword', 'fileformat',
'run_mode', 'queue_enabled', 'update_vault_record', 'ratelimit', 'encryption',
'encryption_key', 'token_expiration',
]}
@@ -124,7 +129,7 @@ def execute(self, params: KeeperParams, **kwargs) -> None:
actual_service_url = self._get_service_url(config_data)
write_service_metadata(params, args.update_vault_record, actual_service_url, api_key)
- self._upload_and_start_service(params)
+ self._upload_and_start_service(params, args.update_vault_record, api_key)
except ValidationError as e:
print(f"Validation Error: {str(e)}")
@@ -166,27 +171,29 @@ def _create_and_save_record(self, config_data: Dict[str, Any], params: KeeperPar
# Return the API key for Docker mode
return record.get('api-key')
- def _upload_and_start_service(self, params: KeeperParams) -> None:
+ def _upload_and_start_service(self, params: KeeperParams, vault_record_uid: Optional[str] = None,
+ api_key: Optional[str] = None) -> None:
self.service_config.update_or_add_record(params)
from ..core.service_manager import ServiceManager
- ServiceManager.start_service()
+ ServiceManager.start_service(params=params, vault_record_uid=vault_record_uid, api_key=api_key)
def _get_service_url(self, config_data: Dict[str, Any]) -> str:
- """Determine the actual service URL (ngrok, cloudflare, or localhost) with API version path"""
- # Determine API version based on queue_enabled
- queue_enabled = config_data.get("queue_enabled", "y")
- api_path = "/api/v2" if queue_enabled == "y" else "/api/v1"
-
- # Priority: ngrok > cloudflare > localhost
+ """Determine the actual service URL (ngrok, cloudflare, tailscale, or localhost) with API version path"""
+ from ..util.str_util import get_api_path_suffix
+ api_path = get_api_path_suffix(config_data.get("queue_enabled", "y"))
+
+ # Priority: ngrok > cloudflare > tailscale > localhost
base_url = ""
if config_data.get("ngrok_public_url"):
base_url = config_data["ngrok_public_url"]
elif config_data.get("cloudflare_public_url"):
base_url = config_data["cloudflare_public_url"]
+ elif config_data.get("tailscale_public_url"):
+ base_url = config_data["tailscale_public_url"]
else:
# Fallback to localhost with correct protocol
port = config_data.get("port", 8080)
protocol = "https" if config_data.get("tls_certificate") == "y" else "http"
base_url = f"{protocol}://localhost:{port}"
-
+
return f"{base_url}{api_path}"
diff --git a/keepercommander/service/commands/integrations/integration_setup_base.py b/keepercommander/service/commands/integrations/integration_setup_base.py
index 901b82c09..90d26ee91 100644
--- a/keepercommander/service/commands/integrations/integration_setup_base.py
+++ b/keepercommander/service/commands/integrations/integration_setup_base.py
@@ -295,13 +295,18 @@ def _get_integration_service_configuration(self) -> ServiceConfig:
ngrok_config = self._get_ngrok_config()
- if not ngrok_config['ngrok_enabled']:
- cloudflare_config = self._get_cloudflare_config()
- else:
+ if ngrok_config['ngrok_enabled']:
cloudflare_config = {
'cloudflare_enabled': False, 'cloudflare_tunnel_token': '',
'cloudflare_custom_domain': '', 'cloudflare_public_url': ''
}
+ tailscale_config = self._disabled_tailscale_config()
+ else:
+ cloudflare_config = self._get_cloudflare_config()
+ if cloudflare_config['cloudflare_enabled']:
+ tailscale_config = self._disabled_tailscale_config()
+ else:
+ tailscale_config = self._get_tailscale_config()
return ServiceConfig(
port=port,
@@ -314,7 +319,11 @@ def _get_integration_service_configuration(self) -> ServiceConfig:
cloudflare_enabled=cloudflare_config['cloudflare_enabled'],
cloudflare_tunnel_token=cloudflare_config['cloudflare_tunnel_token'],
cloudflare_custom_domain=cloudflare_config['cloudflare_custom_domain'],
- cloudflare_public_url=cloudflare_config.get('cloudflare_public_url', '')
+ cloudflare_public_url=cloudflare_config.get('cloudflare_public_url', ''),
+ tailscale_enabled=tailscale_config['tailscale_enabled'],
+ tailscale_auth_key=tailscale_config['tailscale_auth_key'],
+ tailscale_advertise_tags=tailscale_config['tailscale_advertise_tags'],
+ tailscale_public_url=tailscale_config.get('tailscale_public_url', '')
)
# -- Phase 2 (integration-specific) --------------------------------
diff --git a/keepercommander/service/commands/service_config_handlers.py b/keepercommander/service/commands/service_config_handlers.py
index c63a01072..3df4c320b 100644
--- a/keepercommander/service/commands/service_config_handlers.py
+++ b/keepercommander/service/commands/service_config_handlers.py
@@ -63,14 +63,18 @@ def handle_streamlined_config(self, config_data: Dict[str, Any], args, params: K
# Apply logical tunneling flow for streamlined config
ngrok_enabled = "y" if args.ngrok else "n"
cloudflare_enabled = "y" if args.cloudflare else "n"
-
+ tailscale_enabled = "y" if args.tailscale else "n"
+
# Implement the same logic as interactive mode
ngrok_public_url = ""
cloudflare_public_url = ""
-
+ tailscale_auth_key = ""
+ tailscale_advertise_tags = ""
+
if ngrok_enabled == "y":
- # ngrok enabled → disable cloudflare and TLS
+ # ngrok enabled → disable cloudflare, tailscale and TLS
cloudflare_enabled = "n"
+ tailscale_enabled = "n"
cloudflare_token = ""
cloudflare_domain = ""
tls_enabled = "n"
@@ -84,14 +88,15 @@ def handle_streamlined_config(self, config_data: Dict[str, Any], args, params: K
ngrok_public_url = f"https://{ngrok_domain}.ngrok.io"
else:
ngrok_public_url = f"https://{ngrok_domain}"
- logger.debug("Ngrok enabled - disabling cloudflare and TLS")
+ logger.debug("Ngrok enabled - disabling cloudflare, tailscale and TLS")
elif cloudflare_enabled == "y":
- # cloudflare enabled → disable TLS, but validate required fields
+ # cloudflare enabled → disable tailscale and TLS, but validate required fields
if not args.cloudflare:
raise ValidationError("Cloudflare tunnel token is required when using Cloudflare tunnel.")
if not args.cloudflare_custom_domain:
raise ValidationError("Cloudflare custom domain is required when using Cloudflare tunnel.")
-
+
+ tailscale_enabled = "n"
tls_enabled = "n"
certfile = ""
certpassword = ""
@@ -99,9 +104,20 @@ def handle_streamlined_config(self, config_data: Dict[str, Any], args, params: K
cloudflare_domain = self.service_config.validator.validate_domain(args.cloudflare_custom_domain)
# Construct cloudflare public URL from custom domain
cloudflare_public_url = f"https://{cloudflare_domain}"
- logger.debug("Cloudflare enabled - disabling TLS")
+ logger.debug("Cloudflare enabled - disabling tailscale and TLS")
+ elif tailscale_enabled == "y":
+ # tailscale enabled → disable TLS
+ tls_enabled = "n"
+ certfile = ""
+ certpassword = ""
+ cloudflare_token = ""
+ cloudflare_domain = ""
+ tailscale_auth_key = self.service_config.validator.validate_tailscale_auth_key(args.tailscale)
+ tailscale_advertise_tags = args.tailscale_advertise_tags or ""
+ # URL is only known once Funnel actually starts at service-start time.
+ logger.debug("Tailscale enabled - disabling TLS")
else:
- # Both ngrok and cloudflare disabled → allow TLS
+ # ngrok, cloudflare, and tailscale all disabled → allow TLS
tls_enabled = "y" if args.certfile and args.certpassword else "n"
certfile = args.certfile if args.certfile else ""
certpassword = args.certpassword if args.certpassword else ""
@@ -139,6 +155,10 @@ def handle_streamlined_config(self, config_data: Dict[str, Any], args, params: K
"cloudflare_tunnel_token": cloudflare_token,
"cloudflare_custom_domain": cloudflare_domain,
"cloudflare_public_url": cloudflare_public_url,
+ "tailscale": tailscale_enabled,
+ "tailscale_auth_key": tailscale_auth_key,
+ "tailscale_advertise_tags": tailscale_advertise_tags,
+ "tailscale_public_url": "",
"tls_certificate": tls_enabled,
"certfile": certfile,
"certpassword": certpassword,
@@ -171,34 +191,52 @@ def _configure_port(self, config_data: Dict[str, Any]) -> None:
def _configure_tunneling_and_tls(self, config_data: Dict[str, Any]) -> None:
"""
Configure tunneling and TLS with logical flow:
- 1. If ngrok = yes → Skip cloudflare and TLS (ngrok provides public access with SSL)
+ 1. If ngrok = yes → Skip cloudflare, tailscale and TLS (ngrok provides public access with SSL)
2. If ngrok = no → Ask for cloudflare
- 3. If ngrok = no AND cloudflare = no → Ask for TLS (local HTTPS)
+ 3. If ngrok = no AND cloudflare = no → Ask for tailscale
+ 4. If ngrok = no AND cloudflare = no AND tailscale = no → Ask for TLS (local HTTPS)
"""
# First, always ask for ngrok
self._configure_ngrok(config_data)
-
+
if config_data["ngrok"] == "y":
- # ngrok provides public access with SSL, so skip cloudflare and TLS
+ # ngrok provides public access with SSL, so skip cloudflare, tailscale and TLS
config_data["cloudflare"] = "n"
config_data["cloudflare_tunnel_token"] = ""
config_data["cloudflare_custom_domain"] = ""
config_data["cloudflare_public_url"] = ""
+ config_data["tailscale"] = "n"
+ config_data["tailscale_auth_key"] = ""
+ config_data["tailscale_advertise_tags"] = ""
+ config_data["tailscale_public_url"] = ""
config_data["tls_certificate"] = "n"
config_data["certfile"] = ""
config_data["certpassword"] = ""
else:
# ngrok = no, so ask for cloudflare
self._configure_cloudflare(config_data)
-
+
if config_data["cloudflare"] == "y":
- # cloudflare provides public access with SSL, so skip TLS
+ # cloudflare provides public access with SSL, so skip tailscale and TLS
+ config_data["tailscale"] = "n"
+ config_data["tailscale_auth_key"] = ""
+ config_data["tailscale_advertise_tags"] = ""
+ config_data["tailscale_public_url"] = ""
config_data["tls_certificate"] = "n"
config_data["certfile"] = ""
config_data["certpassword"] = ""
else:
- # Both ngrok and cloudflare = no, so ask for TLS for local HTTPS
- self._configure_tls(config_data)
+ # ngrok and cloudflare = no, so ask for tailscale
+ self._configure_tailscale(config_data)
+
+ if config_data["tailscale"] == "y":
+ # tailscale provides public access with SSL, so skip TLS
+ config_data["tls_certificate"] = "n"
+ config_data["certfile"] = ""
+ config_data["certpassword"] = ""
+ else:
+ # ngrok, cloudflare and tailscale = no, so ask for TLS for local HTTPS
+ self._configure_tls(config_data)
def _configure_ngrok(self, config_data: Dict[str, Any]) -> None:
config_data["ngrok"] = self.service_config._get_yes_no_input(self.messages['ngrok_prompt'])
@@ -255,6 +293,31 @@ def _configure_cloudflare(self, config_data: Dict[str, Any]) -> None:
config_data["cloudflare_custom_domain"] = ""
config_data["cloudflare_public_url"] = ""
+ def _configure_tailscale(self, config_data: Dict[str, Any]) -> None:
+ config_data["tailscale"] = self.service_config._get_yes_no_input(
+ self.messages.get('tailscale_prompt', 'Do you want to use Tailscale Funnel? (y/n): ')
+ )
+
+ if config_data["tailscale"] == "y":
+ config_data["tailscale_auth_key"] = self._get_validated_input(
+ prompt_key='tailscale_auth_key_prompt',
+ validation_func=self.service_config.validator.validate_tailscale_auth_key,
+ error_key='invalid_tailscale_auth_key',
+ required=True
+ )
+ # Only required for OAuth-derived auth keys.
+ config_data["tailscale_advertise_tags"] = input(
+ self.messages.get(
+ 'tailscale_advertise_tags_prompt',
+ 'Enter Tailscale ACL tags to advertise, comma-separated (optional, required for OAuth-derived auth keys): '
+ )
+ ).strip()
+ config_data["tailscale_public_url"] = "" # known only once Funnel starts
+ else:
+ config_data["tailscale_auth_key"] = ""
+ config_data["tailscale_advertise_tags"] = ""
+ config_data["tailscale_public_url"] = ""
+
def _configure_tls(self, config_data: Dict[str, Any]) -> None:
config_data["tls_certificate"] = self.service_config._get_yes_no_input(self.messages['tls_certificate'])
diff --git a/keepercommander/service/commands/service_docker_setup.py b/keepercommander/service/commands/service_docker_setup.py
index e94d6a835..db9f0164b 100644
--- a/keepercommander/service/commands/service_docker_setup.py
+++ b/keepercommander/service/commands/service_docker_setup.py
@@ -108,20 +108,25 @@ def get_service_configuration(self, params) -> ServiceConfig:
# Queue mode
queue_enabled = self._get_queue_config()
- # Tunneling options (ngrok/cloudflare are mutually exclusive)
+ # Tunneling options (ngrok/cloudflare/tailscale are mutually exclusive)
ngrok_config = self._get_ngrok_config()
-
- if not ngrok_config['ngrok_enabled']:
- cloudflare_config = self._get_cloudflare_config()
- else:
+
+ if ngrok_config['ngrok_enabled']:
cloudflare_config = {
- 'cloudflare_enabled': False, 'cloudflare_tunnel_token': '',
+ 'cloudflare_enabled': False, 'cloudflare_tunnel_token': '',
'cloudflare_custom_domain': '', 'cloudflare_public_url': ''
}
-
+ tailscale_config = self._disabled_tailscale_config()
+ else:
+ cloudflare_config = self._get_cloudflare_config()
+ if cloudflare_config['cloudflare_enabled']:
+ tailscale_config = self._disabled_tailscale_config()
+ else:
+ tailscale_config = self._get_tailscale_config()
+
# Advanced security options
security_config = self._get_advanced_security_config()
-
+
return ServiceConfig(
port=port,
commands=commands,
@@ -134,6 +139,10 @@ def get_service_configuration(self, params) -> ServiceConfig:
cloudflare_tunnel_token=cloudflare_config['cloudflare_tunnel_token'],
cloudflare_custom_domain=cloudflare_config['cloudflare_custom_domain'],
cloudflare_public_url=cloudflare_config.get('cloudflare_public_url', ''),
+ tailscale_enabled=tailscale_config['tailscale_enabled'],
+ tailscale_auth_key=tailscale_config['tailscale_auth_key'],
+ tailscale_advertise_tags=tailscale_config['tailscale_advertise_tags'],
+ tailscale_public_url=tailscale_config.get('tailscale_public_url', ''),
allowed_ip=security_config['allowed_ip'],
denied_ip=security_config['denied_ip'],
rate_limit=security_config['rate_limit'],
diff --git a/keepercommander/service/config/config_validation.py b/keepercommander/service/config/config_validation.py
index c4cf07c6c..c557ea08b 100644
--- a/keepercommander/service/config/config_validation.py
+++ b/keepercommander/service/config/config_validation.py
@@ -122,6 +122,18 @@ def validate_cloudflare_token(token: str) -> str:
logger.debug("Cloudflare token validation successful")
return token
+ @staticmethod
+ def validate_tailscale_auth_key(auth_key: str) -> str:
+ """Check presence only; Tailscale's servers are authoritative on key validity at `tailscale up` time."""
+ logger.debug("Validating Tailscale auth key")
+
+ if not auth_key or not auth_key.strip():
+ msg = "Tailscale auth key cannot be empty"
+ raise ValidationError(msg)
+
+ logger.debug("Tailscale auth key validation successful")
+ return auth_key
+
@staticmethod
def validate_domain(domain: str, require_tld: bool = True) -> str:
"""
diff --git a/keepercommander/service/config/models.py b/keepercommander/service/config/models.py
index 59aa43d65..e87e6b738 100644
--- a/keepercommander/service/config/models.py
+++ b/keepercommander/service/config/models.py
@@ -38,3 +38,7 @@ class ServiceConfigData:
cloudflare_tunnel_token: str = ""
cloudflare_custom_domain: str = ""
cloudflare_public_url: str = ""
+ tailscale: str = "n"
+ tailscale_auth_key: str = ""
+ tailscale_advertise_tags: str = ""
+ tailscale_public_url: str = ""
diff --git a/keepercommander/service/config/service_config.py b/keepercommander/service/config/service_config.py
index 291c7bc44..6f6ff8137 100644
--- a/keepercommander/service/config/service_config.py
+++ b/keepercommander/service/config/service_config.py
@@ -93,6 +93,10 @@ def create_default_config(self) -> Dict[str, Any]:
cloudflare_tunnel_token="",
cloudflare_custom_domain="",
cloudflare_public_url="",
+ tailscale="n",
+ tailscale_auth_key="",
+ tailscale_advertise_tags="",
+ tailscale_public_url="",
tls_certificate="n",
certfile="",
certpassword="",
@@ -234,7 +238,24 @@ def load_config(self) -> Dict[str, Any]:
if 'cloudflare_public_url' not in config:
config['cloudflare_public_url'] = ''
logger.debug("Added default cloudflare_public_url for backwards compatibility")
-
+
+ # Add backwards compatibility for missing Tailscale fields
+ if 'tailscale' not in config:
+ config['tailscale'] = 'n' # Default to disabled for existing configs
+ logger.debug("Added default tailscale=n for backwards compatibility")
+
+ if 'tailscale_auth_key' not in config:
+ config['tailscale_auth_key'] = ''
+ logger.debug("Added default tailscale_auth_key for backwards compatibility")
+
+ if 'tailscale_advertise_tags' not in config:
+ config['tailscale_advertise_tags'] = ''
+ logger.debug("Added default tailscale_advertise_tags for backwards compatibility")
+
+ if 'tailscale_public_url' not in config:
+ config['tailscale_public_url'] = ''
+ logger.debug("Added default tailscale_public_url for backwards compatibility")
+
self._validate_config_structure(config)
return config
@@ -258,6 +279,10 @@ def _validate_config_structure(self, config: Dict[str, Any]) -> None:
self.validator.validate_cloudflare_token(config_data.cloudflare_tunnel_token)
self.validator.validate_domain(config_data.cloudflare_custom_domain)
+ if config_data.tailscale == 'y':
+ logger.debug("Validating tailscale configuration")
+ self.validator.validate_tailscale_auth_key(config_data.tailscale_auth_key)
+
if config_data.is_advanced_security_enabled == 'y':
logger.debug("Validating advanced security settings")
self.validator.validate_rate_limit(config_data.rate_limiting)
diff --git a/keepercommander/service/config/tailscale_config.py b/keepercommander/service/config/tailscale_config.py
new file mode 100644
index 000000000..6e50eaa0c
--- /dev/null
+++ b/keepercommander/service/config/tailscale_config.py
@@ -0,0 +1,175 @@
+# _ __
+# | |/ /___ ___ _ __ ___ _ _ ®
+# | ' -_) -_) '_ \/ -_) '_|
+# |_|\_\___\___| .__/\___|_|
+# |_|
+#
+# Keeper Commander
+# Copyright 2024 Keeper Security Inc.
+# Contact: ops@keepersecurity.com
+#
+
+from typing import Dict, Any, Optional
+
+from ..decorators.logging import logger, debug_decorator
+from .service_config import ServiceConfig
+from ..util.tunneling import (
+ is_tailscale_installed,
+ get_tailscale_install_guidance,
+ install_tailscale,
+ is_tailscale_daemon_running,
+ get_tailscale_daemon_start_guidance,
+ start_tailscale_daemon,
+ tailscale_up,
+ set_tailscale_operator,
+ TailscaleAccessDeniedError,
+ start_tailscale_funnel,
+ stop_tailscale_funnel,
+ get_tailscale_funnel_url,
+ get_tailscale_funnel_status,
+ reset_tailscale_log,
+)
+from ..util.exceptions import ValidationError
+
+class TailscaleConfigurator:
+
+ @staticmethod
+ def _validate_tailscale_config(config_data: Dict[str, Any], service_config: ServiceConfig) -> None:
+ """Validate Tailscale configuration parameters."""
+ required_keys = ["port", "tailscale_auth_key", "run_mode"]
+
+ for key in required_keys:
+ if key not in config_data:
+ raise ValidationError(f"Missing required configuration key: {key}")
+
+ service_config.validator.validate_port(config_data["port"])
+ service_config.validator.validate_tailscale_auth_key(config_data["tailscale_auth_key"])
+
+ if config_data["run_mode"] not in ["foreground", "background"]:
+ raise ValidationError(f"Invalid run_mode: {config_data['run_mode']}")
+
+ logger.debug("Tailscale configuration validation successful")
+
+ @staticmethod
+ def _ensure_ready(service_config: ServiceConfig, check_fn, guidance_fn, action_fn,
+ prompt_key: str, prompt_default: str, action_label: str, failure_label: str) -> None:
+ """
+ Generic check -> guidance -> prompt -> attempt -> reverify flow, shared
+ by the CLI-install and daemon-start checks below. Raises ValidationError
+ if the user declines or the automatic attempt doesn't fix the check.
+ """
+ if check_fn():
+ return
+
+ guidance = guidance_fn()
+ logger.error(guidance)
+ print(guidance)
+
+ choice = service_config._get_yes_no_input(service_config.messages.get(prompt_key, prompt_default))
+ if choice != 'y':
+ raise ValidationError(guidance)
+
+ print(f'Attempting to {action_label} automatically...')
+ action_fn()
+ if not check_fn():
+ raise ValidationError(f"{failure_label}. {guidance}")
+ logger.debug(f"{action_label.capitalize()} succeeded")
+
+ @staticmethod
+ def _authenticate(config_data: Dict[str, Any], service_config: ServiceConfig) -> None:
+ """Runs tailscale_up; on Linux operator-denied errors, offers a one-time fix and retries."""
+ try:
+ tailscale_up(config_data["tailscale_auth_key"], config_data.get("tailscale_advertise_tags"))
+ except TailscaleAccessDeniedError as e:
+ logger.warning(str(e))
+ print(str(e))
+ choice = service_config._get_yes_no_input(
+ service_config.messages.get(
+ 'tailscale_operator_prompt',
+ 'Grant this user Tailscale operator rights now (requires sudo, one-time)? (y/n): '
+ )
+ )
+ if choice != 'y':
+ raise ValidationError(str(e))
+
+ print('Attempting to grant Tailscale operator rights automatically...')
+ set_tailscale_operator()
+ tailscale_up(config_data["tailscale_auth_key"], config_data.get("tailscale_advertise_tags"))
+
+ @staticmethod
+ def _verify_funnel_active(local_port: int, max_retries: int = 3, retry_delay: float = 1) -> bool:
+ """`--bg` can exit 0 without the target ever going active; confirm via tailscaled's own status."""
+ import time
+ for attempt in range(max_retries):
+ if get_tailscale_funnel_status(local_port):
+ return True
+ if attempt < max_retries - 1:
+ time.sleep(retry_delay)
+ return False
+
+ @staticmethod
+ @debug_decorator
+ def configure_tailscale(config_data: Dict[str, Any], service_config: ServiceConfig) -> Optional[int]:
+ """
+ Configure Tailscale Funnel if enabled. Always returns None -- unlike
+ Ngrok/Cloudflare, Tailscale has no Commander-owned subprocess/PID to
+ track; lifecycle state lives in ProcessInfo.tailscale_enabled/tailscale_port.
+ """
+ if config_data.get("tailscale") != 'y':
+ return None
+
+ logger.debug("Configuring Tailscale Funnel")
+ reset_tailscale_log()
+
+ try:
+ logger.debug("Checking Tailscale CLI availability")
+ TailscaleConfigurator._ensure_ready(
+ service_config, is_tailscale_installed, get_tailscale_install_guidance, install_tailscale,
+ 'tailscale_install_prompt', 'Tailscale CLI is not installed. Attempt automatic installation now? (y/n): ',
+ 'install Tailscale', 'Automatic Tailscale installation did not succeed'
+ )
+
+ logger.debug("Checking Tailscale daemon status")
+ TailscaleConfigurator._ensure_ready(
+ service_config, is_tailscale_daemon_running, get_tailscale_daemon_start_guidance, start_tailscale_daemon,
+ 'tailscale_daemon_start_prompt', 'Tailscale daemon is not running. Attempt to start it now? (y/n): ',
+ 'start the Tailscale daemon', 'Could not start the Tailscale daemon automatically'
+ )
+
+ TailscaleConfigurator._validate_tailscale_config(config_data, service_config)
+
+ # Auth key used only for `tailscale up`; never logged, never used for API auth.
+ logger.debug("Authenticating with Tailscale")
+ TailscaleConfigurator._authenticate(config_data, service_config)
+
+ logger.debug(f"Starting Tailscale Funnel for port {config_data['port']}")
+ start_tailscale_funnel(config_data["port"])
+
+ if not TailscaleConfigurator._verify_funnel_active(config_data["port"]):
+ try:
+ stop_tailscale_funnel(config_data["port"])
+ except Exception as cleanup_error:
+ logger.debug(f"Funnel rollback failed: {cleanup_error}")
+ raise Exception(
+ "Tailscale Funnel did not become active after starting. First-time Funnel use "
+ "on this tailnet may be pending admin-console approval -- run `tailscale funnel status`."
+ )
+
+ public_url = get_tailscale_funnel_url(config_data["port"])
+ config_data["tailscale_public_url"] = public_url or ""
+
+ if public_url:
+ logger.info(f"Tailscale Funnel URL: {public_url}")
+ print(f'Generated Tailscale Funnel URL: {public_url}')
+ else:
+ logger.warning("Tailscale Funnel started but URL could not be retrieved")
+ print('Tailscale Funnel started, URL will be available via `tailscale funnel status`')
+
+ return None
+
+ except ValidationError as e:
+ logger.error(f"Invalid Tailscale configuration: {e}")
+ raise
+ except Exception as e:
+ logger.error(f"Failed to configure Tailscale Funnel: {e}")
+ raise
diff --git a/keepercommander/service/core/process_info.py b/keepercommander/service/core/process_info.py
index 3c495a48f..655b8ff5a 100644
--- a/keepercommander/service/core/process_info.py
+++ b/keepercommander/service/core/process_info.py
@@ -24,7 +24,9 @@ class ProcessInfo:
is_running: bool
ngrok_pid: Optional[int] = None
cloudflare_pid: Optional[int] = None
-
+ tailscale_enabled: bool = False
+ tailscale_port: Optional[int] = None
+
_env_file = utils.get_default_path() / ".service.env"
@classmethod
@@ -32,27 +34,34 @@ def _str_to_bool(cls, value: str) -> bool:
return value.lower() in ('true', '1', 'yes', 'on')
@classmethod
- def save(cls, pid, is_running: bool, ngrok_pid: Optional[int] = None, cloudflare_pid: Optional[int] = None) -> None:
+ def save(cls, pid, is_running: bool, ngrok_pid: Optional[int] = None, cloudflare_pid: Optional[int] = None,
+ tailscale_enabled: bool = False, tailscale_port: Optional[int] = None) -> None:
"""Save current process information to .env file."""
-
+
env_path = str(cls._env_file)
-
+
# Create the file if it doesn't exist
if not cls._env_file.exists():
cls._env_file.touch()
-
+
process_info = {
'KEEPER_SERVICE_PID': str(pid),
'KEEPER_SERVICE_TERMINAL': TerminalHandler.get_terminal_info() or '',
'KEEPER_SERVICE_IS_RUNNING': str(is_running).lower()
}
-
+
if ngrok_pid is not None:
process_info['KEEPER_SERVICE_NGROK_PID'] = str(ngrok_pid)
-
+
if cloudflare_pid is not None:
process_info['KEEPER_SERVICE_CLOUDFLARE_PID'] = str(cloudflare_pid)
-
+
+ if tailscale_enabled:
+ process_info['KEEPER_SERVICE_TAILSCALE_ENABLED'] = str(tailscale_enabled).lower()
+
+ if tailscale_port is not None:
+ process_info['KEEPER_SERVICE_TAILSCALE_PORT'] = str(tailscale_port)
+
try:
for key, value in process_info.items():
set_key(env_path, key, value, quote_mode='never')
@@ -84,20 +93,29 @@ def load(cls) -> 'ProcessInfo':
cloudflare_pid_str = os.getenv('KEEPER_SERVICE_CLOUDFLARE_PID')
cloudflare_pid = int(cloudflare_pid_str) if cloudflare_pid_str else None
-
+
+ tailscale_enabled_str = os.getenv('KEEPER_SERVICE_TAILSCALE_ENABLED', 'false')
+ tailscale_enabled = ProcessInfo._str_to_bool(tailscale_enabled_str)
+
+ tailscale_port_str = os.getenv('KEEPER_SERVICE_TAILSCALE_PORT')
+ tailscale_port = int(tailscale_port_str) if tailscale_port_str else None
+
logger.debug("Process information loaded successfully from .env")
return ProcessInfo(
pid=pid,
terminal=terminal,
is_running=is_running,
ngrok_pid=ngrok_pid,
- cloudflare_pid=cloudflare_pid
+ cloudflare_pid=cloudflare_pid,
+ tailscale_enabled=tailscale_enabled,
+ tailscale_port=tailscale_port
)
except Exception as e:
logger.error(f"Failed to load process information: {e}")
pass
-
- return ProcessInfo(pid=None, terminal=None, is_running=False, ngrok_pid=None, cloudflare_pid=None)
+
+ return ProcessInfo(pid=None, terminal=None, is_running=False, ngrok_pid=None, cloudflare_pid=None,
+ tailscale_enabled=False, tailscale_port=None)
@classmethod
def clear(cls) -> None:
diff --git a/keepercommander/service/core/service_manager.py b/keepercommander/service/core/service_manager.py
index d65d0c449..b672764aa 100644
--- a/keepercommander/service/core/service_manager.py
+++ b/keepercommander/service/core/service_manager.py
@@ -13,8 +13,10 @@
import logging
import signal
import psutil
+from typing import Optional
from ... import utils
+from ...params import KeeperParams
from ...service.config.service_config import ServiceConfig
from ..decorators.logging import logger, debug_decorator
from ..util.process_util import spawn_detached_process, CREATE_NO_WINDOW
@@ -57,7 +59,8 @@ def get_ssl_context(config_data):
@classmethod
- def start_service(cls) -> None:
+ def start_service(cls, params: Optional[KeeperParams] = None, vault_record_uid: Optional[str] = None,
+ api_key: Optional[str] = None) -> None:
"""Start the service if not already running."""
process_info = ProcessInfo.load()
@@ -67,6 +70,11 @@ def start_service(cls) -> None:
SignalHandler.setup_signal_handlers(cls._handle_shutdown)
+ # Initialized before any operation that could raise, so the outer except
+ # below can always safely check them to roll back a partially-started Funnel.
+ tailscale_enabled = False
+ tailscale_port = None
+
try:
service_config = ServiceConfig()
config_data = service_config.load_config()
@@ -77,6 +85,7 @@ def start_service(cls) -> None:
from ..config.ngrok_config import NgrokConfigurator
from ..config.cloudflare_config import CloudflareConfigurator
+ from ..config.tailscale_config import TailscaleConfigurator
is_running = True
queue_enabled = config_data.get("queue_enabled", "y")
@@ -119,6 +128,84 @@ def start_service(cls) -> None:
logger.error(f"\n{str(e)}")
return
+ # Set before the call, not after - needed even if configure_tailscale raises
+ # with Funnel already live (e.g. a timeout mid-verify), so rollback can still find it.
+ if config_data.get("tailscale") == 'y':
+ tailscale_enabled = True
+ tailscale_port = port
+
+ try:
+ TailscaleConfigurator.configure_tailscale(config_data, service_config)
+ if tailscale_enabled:
+ # Tailscale's URL is only known post-Funnel-start; persist it now.
+ if config_data.get("tailscale_public_url"):
+ try:
+ # save_config() writes plaintext; must re-encrypt or later
+ # load_config() calls (auth checks, routes) fail to decrypt.
+ service_config.save_config(config_data, config_data.get("fileformat"))
+ service_config.format_handler.encrypt_config_file(
+ service_config.format_handler.config_path, service_config.format_handler.config_dir
+ )
+ except Exception as save_error:
+ logger.debug(f"Could not persist tailscale_public_url: {save_error}")
+
+ if params and vault_record_uid and api_key:
+ try:
+ from ..util.str_util import get_api_path_suffix
+ from ..commands.integrations.vault_metadata import write_service_metadata
+ api_path = get_api_path_suffix(config_data.get("queue_enabled", "y"))
+ write_service_metadata(
+ params, vault_record_uid,
+ f"{config_data['tailscale_public_url']}{api_path}", api_key
+ )
+ except Exception as metadata_error:
+ logger.debug(f"Could not correct vault record with real Tailscale URL: {metadata_error}")
+ except (KeyboardInterrupt, Exception) as e:
+ # KeyboardInterrupt (e.g. Ctrl+C during a Tailscale install/daemon-start
+ # prompt) is not an Exception subclass -- must be caught explicitly here
+ # too, or this rollback (and the ones below) never runs on interrupt.
+ if ngrok_pid and psutil:
+ try:
+ process = psutil.Process(ngrok_pid)
+ process.terminate()
+ logger.debug(f"Terminated ngrok process {ngrok_pid}")
+ except (psutil.NoSuchProcess, psutil.AccessDenied, OSError) as ngrok_error:
+ logger.debug(f"Error terminating ngrok process: {type(ngrok_error).__name__}")
+ elif ngrok_pid:
+ logger.warning("Cannot terminate ngrok process: psutil not available")
+
+ if cloudflare_pid and psutil:
+ try:
+ process = psutil.Process(cloudflare_pid)
+ process.terminate()
+ logger.debug(f"Terminated cloudflare process {cloudflare_pid}")
+ except (psutil.NoSuchProcess, psutil.AccessDenied, OSError) as cf_error:
+ logger.debug(f"Error terminating cloudflare process: {type(cf_error).__name__}")
+ elif cloudflare_pid:
+ logger.warning("Cannot terminate cloudflare process: psutil not available")
+
+ if tailscale_enabled and tailscale_port:
+ try:
+ from ..util.tunneling import get_tailscale_funnel_status, stop_tailscale_funnel
+ # Most failures here (bad key, daemon not running, etc.) happen before
+ # Funnel is ever started - only tear down if it's actually live, so a
+ # pre-start failure doesn't trigger a pointless (and if it ever falls
+ # back to 'reset', potentially destructive) teardown attempt.
+ if get_tailscale_funnel_status(tailscale_port):
+ stop_tailscale_funnel(tailscale_port)
+ logger.debug("Stopped Tailscale Funnel after startup failure")
+ except Exception as ts_error:
+ logger.debug(f"Error stopping Tailscale Funnel: {type(ts_error).__name__}")
+
+ ProcessInfo.clear()
+
+ if isinstance(e, KeyboardInterrupt):
+ logger.info("Service startup interrupted by user")
+ raise
+
+ logger.info(f"\n{str(e)}")
+ return
+
# Custom logging filter to replace SSL handshake errors with user-friendly message
class SSLHandshakeFilter(logging.Filter):
def filter(self, record):
@@ -170,7 +257,7 @@ def filter(self, record):
logger.debug(f"Service subprocess logs available at: {log_file}")
print(f"Commander Service started with PID: {process.pid}")
- ProcessInfo.save(process.pid, is_running, ngrok_pid, cloudflare_pid)
+ ProcessInfo.save(process.pid, is_running, ngrok_pid, cloudflare_pid, tailscale_enabled=tailscale_enabled, tailscale_port=tailscale_port)
except Exception as e:
logger.error(f"Failed to start service subprocess: {e}")
@@ -178,6 +265,7 @@ def filter(self, record):
else:
cleanup_done = False
+ tailscale_cleanup_done = False
def cleanup_cloudflare_on_foreground_exit():
"""Clean up Cloudflare tunnel when foreground service exits."""
@@ -247,9 +335,25 @@ def cleanup_cloudflare_on_foreground_exit():
print(f"Unexpected error during Cloudflare cleanup: {e}")
logger.error(f"Unexpected error during Cloudflare cleanup: {e}")
+ def cleanup_tailscale_on_foreground_exit():
+ """Stop Funnel when foreground service exits. Leaves tailnet auth/daemon untouched."""
+ nonlocal tailscale_cleanup_done
+ if not tailscale_enabled or tailscale_cleanup_done:
+ return
+ tailscale_cleanup_done = True
+ try:
+ from ..util.tunneling import stop_tailscale_funnel
+ if stop_tailscale_funnel(tailscale_port):
+ print("Tailscale Funnel stopped")
+ except (KeyboardInterrupt, SystemExit):
+ raise
+ except Exception as e:
+ logger.debug(f"Tailscale funnel cleanup failed: {e}")
+
def foreground_signal_handler(signum, frame):
"""Handle interrupt signals in foreground mode."""
cleanup_cloudflare_on_foreground_exit()
+ cleanup_tailscale_on_foreground_exit()
sys.exit(0)
# Set up signal handlers for foreground mode
@@ -261,9 +365,9 @@ def foreground_signal_handler(signum, frame):
cls._flask_app = create_app()
cls._is_running = True
- ProcessInfo.save(os.getpid(), is_running, ngrok_pid, cloudflare_pid)
+ ProcessInfo.save(os.getpid(), is_running, ngrok_pid, cloudflare_pid, tailscale_enabled=tailscale_enabled, tailscale_port=tailscale_port)
ssl_context = ServiceManager.get_ssl_context(config_data)
-
+
try:
cls._flask_app.run(
host='0.0.0.0',
@@ -272,13 +376,25 @@ def foreground_signal_handler(signum, frame):
)
finally:
cleanup_cloudflare_on_foreground_exit()
-
+ cleanup_tailscale_on_foreground_exit()
+
except FileNotFoundError:
logging.info("Error: Service configuration file not found. Please use 'service-create' command to create a service_config file.")
return
except Exception as e:
logger.error(f"Error: Failed to start Commander Service")
logger.error(f"Reason: {e}")
+ # Funnel may already be live even though the service itself failed to start -
+ # stop it so a failed startup doesn't leave a public endpoint with nothing behind it.
+ # Only if it's actually live though (most failures happen before Funnel ever starts).
+ if tailscale_enabled and tailscale_port:
+ try:
+ from ..util.tunneling import get_tailscale_funnel_status, stop_tailscale_funnel
+ if get_tailscale_funnel_status(tailscale_port):
+ stop_tailscale_funnel(tailscale_port)
+ logger.debug("Stopped Tailscale Funnel after service startup failure")
+ except Exception as cleanup_error:
+ logger.debug(f"Failed to stop Tailscale Funnel during startup-failure rollback: {cleanup_error}")
cls._handle_shutdown()
@classmethod
@@ -389,6 +505,21 @@ def stop_service(cls) -> None:
if not cloudflare_stopped:
logger.debug("No Cloudflare tunnel processes found to stop")
+ # Stop Tailscale Funnel if it was enabled. Leaves tailnet auth/daemon untouched
+ # (tailscaled is system-wide; stopping it would affect other uses of this machine's Tailscale connection).
+ if process_info.tailscale_enabled and process_info.tailscale_port:
+ try:
+ logger.debug(f"Attempting to stop Tailscale Funnel on port {process_info.tailscale_port}")
+ from ..util.tunneling import stop_tailscale_funnel
+ if stop_tailscale_funnel(process_info.tailscale_port):
+ print("Tailscale Funnel stopped")
+ else:
+ logger.warning(f"Failed to stop Tailscale Funnel on port {process_info.tailscale_port}")
+ except Exception as e:
+ logger.warning(f"Error stopping Tailscale: {str(e)}")
+ else:
+ logger.debug("No Tailscale Funnel to stop")
+
# Stop the main service process
if ServiceManager.kill_process_by_pid(process_info.pid):
logger.debug(f"Commander Service stopped (PID: {process_info.pid})")
@@ -441,17 +572,54 @@ def get_status() -> str:
except psutil.NoSuchProcess:
status += f"\nCloudflare tunnel is Stopped (was PID: {process_info.cloudflare_pid})"
+ # Check Tailscale Funnel status if enabled
+ if process_info.tailscale_enabled and process_info.tailscale_port:
+ try:
+ from ..util.tunneling import get_tailscale_funnel_status, get_tailscale_funnel_url
+ funnel_on = get_tailscale_funnel_status(process_info.tailscale_port)
+ if funnel_on:
+ current_url = get_tailscale_funnel_url(process_info.tailscale_port, max_retries=1, retry_delay=0.5)
+ if current_url:
+ status += f"\nTailscale Funnel is Running (Port: {process_info.tailscale_port}, URL: {current_url})"
+ else:
+ status += f"\nTailscale Funnel is Running (Port: {process_info.tailscale_port})"
+ else:
+ status += f"\nTailscale Funnel is Stopped (was Port: {process_info.tailscale_port})"
+ except Exception as e:
+ logger.debug(f"Error checking Tailscale funnel status: {e}")
+ status += f"\nTailscale Funnel status could not be determined (Port: {process_info.tailscale_port})"
+
logger.debug(f"Service status check: {status}")
return status
except psutil.NoSuchProcess:
- ProcessInfo.clear()
- pass
+ # Funnel is managed by tailscaled, not tied to the Commander process --
+ # an unexpected crash/SIGKILL of the service can leave it publicly
+ # exposed with nothing behind it. A read-only status check shouldn't
+ # mutate system network config though, so just report it here (not
+ # tear it down) -- run 'service-stop' to actually clean it up.
+ dangling_funnel_note = ""
+ if process_info.tailscale_enabled and process_info.tailscale_port:
+ try:
+ from ..util.tunneling import get_tailscale_funnel_status
+ if get_tailscale_funnel_status(process_info.tailscale_port):
+ dangling_funnel_note = (
+ f"\nTailscale Funnel is still active on port {process_info.tailscale_port} "
+ "after the Commander process stopped unexpectedly. Run 'service-stop' to clean it up."
+ )
+ except Exception as check_error:
+ logger.debug(f"Failed to check dangling Tailscale Funnel: {check_error}")
+
+ # Keep the record when a Funnel is still live -- clearing it would drop the
+ # tailscale_port that service-stop needs to tear it down, leaving a public
+ # endpoint nothing can clean up (the note above would be unactionable).
+ if not dangling_funnel_note:
+ ProcessInfo.clear()
+ status = "Commander Service is Stopped" + dangling_funnel_note
+ logger.debug(f"Service status check: {status}")
+ return status
else:
status = "No Commander Service is running currently"
return status
- status = "Commander Service is Stopped"
- logger.debug(f"Service status check: {status}")
- return status
@staticmethod
def kill_process_by_pid(pid: int):
diff --git a/keepercommander/service/docker/compose_builder.py b/keepercommander/service/docker/compose_builder.py
index f24083c6c..5a525e7d9 100644
--- a/keepercommander/service/docker/compose_builder.py
+++ b/keepercommander/service/docker/compose_builder.py
@@ -10,8 +10,15 @@
#
"""docker-compose.yml generation."""
+import json
from typing import Dict, Any, List
+# containerboot swaps ${TS_CERT_DOMAIN} for the node's cert domain and re-applies on
+# change. `$$` so Compose emits a literal `$` instead of interpolating it away.
+TAILSCALE_CERT_DOMAIN_PLACEHOLDER = '$${TS_CERT_DOMAIN}'
+TAILSCALE_SERVE_CONFIG_NAME = 'tailscale-serve-config'
+TAILSCALE_SERVE_CONFIG_PATH = '/etc/tailscale/serve.json'
+
class DockerComposeBuilder:
"""Builds docker-compose.yml for Commander + integration services."""
@@ -27,6 +34,9 @@ def __init__(self, setup_result, config: Dict[str, Any], commander_service_name:
self._service_cmd_parts: List[str] = []
self._volumes: List[str] = []
self._services: Dict[str, Dict[str, Any]] = {}
+ self._top_level_volumes: Dict[str, Any] = {}
+ self._top_level_configs: Dict[str, Any] = {}
+ self._tailscale_sidecar_added = False
def build(self) -> str:
if self.commander_service_name not in self._services:
@@ -36,7 +46,13 @@ def build(self) -> str:
def build_dict(self) -> Dict[str, Any]:
if self.commander_service_name not in self._services:
self._services[self.commander_service_name] = self._build_commander_service()
- return {'services': self._services}
+ self._add_tailscale_sidecar_if_enabled()
+ result: Dict[str, Any] = {'services': self._services}
+ if self._top_level_volumes:
+ result['volumes'] = self._top_level_volumes
+ if self._top_level_configs:
+ result['configs'] = self._top_level_configs
+ return result
def add_integration_service(self, service_name: str, container_name: str,
image: str, record_uid: str,
@@ -147,14 +163,122 @@ def _add_tunneling_options(self) -> None:
self._service_cmd_parts.append(f"-cf {self.config['cloudflare_tunnel_token']}")
if self.config.get('cloudflare_custom_domain'):
self._service_cmd_parts.append(f"-cfd {self.config['cloudflare_custom_domain']}")
-
+
def _add_docker_options(self) -> None:
self._service_cmd_parts.extend([
f"-ur {self.setup_result.record_uid}",
f"--ksm-config {self.setup_result.b64_config}",
f"--record {self.setup_result.record_uid}"
])
-
+
+ def _add_tailscale_sidecar_if_enabled(self) -> None:
+ """
+ Tailscale runs as a sidecar, not a service-create flag: Docker is headless, so
+ Commander's own install/daemon prompts can't be answered. Ngrok/Cloudflare
+ unaffected. Funnel is declared via TS_SERVE_CONFIG so containerboot applies it.
+ """
+ if self._tailscale_sidecar_added:
+ return
+ if not (self.config.get('tailscale_enabled') and self.config.get('tailscale_auth_key')):
+ return
+ self._tailscale_sidecar_added = True
+
+ port = self.config['port']
+ tags = self.config.get('tailscale_advertise_tags')
+ # --advertise-tags always explicit: omitting it won't clear a prior run's tag.
+ # No --force-reauth: containerboot logs in every start anyway, and re-validating
+ # the key each boot breaks single-use keys from boot 2 and expired ones for good.
+ extra_args = f"--advertise-tags={tags or ''}"
+
+ # Declared, not scripted: containerboot re-applies on cert-domain change. A hook
+ # couldn't - the hostname can land after `tailscale up` returns, stranding Funnel.
+ host_port = f"{TAILSCALE_CERT_DOMAIN_PLACEHOLDER}:443"
+ serve_config = json.dumps({
+ 'TCP': {'443': {'HTTPS': True}},
+ 'Web': {host_port: {'Handlers': {'/': {'Proxy': f'http://localhost:{port}'}}}},
+ 'AllowFunnel': {host_port: True},
+ }, indent=2)
+ self._top_level_configs[TAILSCALE_SERVE_CONFIG_NAME] = {'content': serve_config}
+
+ # Print-only (TS_SERVE_CONFIG owns Funnel) and bounded at ~8s - a hook still
+ # running when `compose up` finishes gets SIGKILLed and tears the project down.
+ # Retries AllowFunnel, not just the hostname: cert fetch can take 30s+. No jq
+ # in this image, so matched via sed/case text.
+ sed_dns_name = r"""sed -n 's/.*"DNSName": *"\([^"]*\)\.".*/\1/p'"""
+ allow_funnel_glob = r"""*'"'"$$h"':443":'*'true'*""" # tolerates "true" and ": true" (Go JSON spacing varies)
+ not_active_msg = ("Tailscale hostname is $$h but Funnel is not active - check "
+ "'tailscale funnel status' (tailnet HTTPS Certificates or the Funnel ACL grant may be missing)")
+ url_hook = f"""i=0
+while [ "$$i" -lt 8 ]; do
+ h=$$(tailscale status --json --peers=false 2>/dev/null | {sed_dns_name} | head -n1)
+ if [ -n "$$h" ]; then
+ case "$$(tailscale funnel status --json 2>/dev/null)" in
+ {allow_funnel_glob})
+ echo "Tailscale Funnel URL: https://$$h" > /proc/1/fd/1
+ exit 0
+ ;;
+ esac
+ fi
+ i=$$((i+1))
+ sleep 1
+done
+if [ -n "$$h" ]; then
+ echo "{not_active_msg}" > /proc/1/fd/1
+fi
+exit 0
+"""
+
+ # Matches the keeper-service[-] convention already used for the
+ # commander container name (e.g. keeper-service-slack -> keeper-tailscale-slack).
+ tailscale_container_name = self.commander_container_name.replace('service', 'tailscale', 1)
+
+ self._services['tailscale'] = {
+ 'container_name': tailscale_container_name,
+ 'image': 'tailscale/tailscale:latest',
+ 'hostname': self.commander_service_name,
+ # This container, not commander, owns the compose-network identity, so the
+ # alias is what lets integration containers still resolve commander by name.
+ 'networks': {
+ 'default': {
+ 'aliases': [self.commander_service_name],
+ },
+ },
+ # Published here since commander's own `ports` is popped below - keeps
+ # printer.py's localhost: health check working.
+ 'ports': [f"127.0.0.1:{port}:{port}"],
+ 'environment': {
+ 'TS_AUTHKEY': self.config['tailscale_auth_key'],
+ 'TS_EXTRA_ARGS': extra_args,
+ 'TS_STATE_DIR': '/var/lib/tailscale',
+ 'TS_SERVE_CONFIG': TAILSCALE_SERVE_CONFIG_PATH,
+ },
+ 'volumes': ['tailscale-state:/var/lib/tailscale'],
+ # Single-file mount: edits to the JSON aren't watched, but it's generated.
+ 'configs': [
+ {'source': TAILSCALE_SERVE_CONFIG_NAME, 'target': TAILSCALE_SERVE_CONFIG_PATH},
+ ],
+ # start_period absorbs a slow first login: a key-type switch burns `tailscale
+ # up`'s 60s timeout plus a restart, which must not mark the sidecar unhealthy.
+ 'healthcheck': {
+ 'test': ['CMD', 'tailscale', 'status'],
+ 'interval': '10s',
+ 'timeout': '5s',
+ 'retries': 12,
+ 'start_period': '120s',
+ },
+ 'post_start': [
+ {'command': ['sh', '-c', url_hook]},
+ ],
+ 'restart': 'unless-stopped',
+ }
+ self._top_level_volumes['tailscale-state'] = None
+
+ commander = self._services[self.commander_service_name]
+ commander.pop('ports', None)
+ commander['network_mode'] = 'service:tailscale'
+ commander.setdefault('depends_on', {})['tailscale'] = {'condition': 'service_healthy'}
+
+
def _build_healthcheck(self) -> Dict[str, Any]:
port = self.config['port']
diff --git a/keepercommander/service/docker/models.py b/keepercommander/service/docker/models.py
index 4c263602c..14cf4ef07 100644
--- a/keepercommander/service/docker/models.py
+++ b/keepercommander/service/docker/models.py
@@ -76,6 +76,10 @@ class ServiceConfig:
token_expiration: str = ''
ngrok_public_url: str = ''
cloudflare_public_url: str = ''
+ tailscale_enabled: bool = False
+ tailscale_auth_key: str = ''
+ tailscale_advertise_tags: str = ''
+ tailscale_public_url: str = ''
@dataclass
diff --git a/keepercommander/service/docker/setup_base.py b/keepercommander/service/docker/setup_base.py
index d43eea9a6..b02b847c6 100644
--- a/keepercommander/service/docker/setup_base.py
+++ b/keepercommander/service/docker/setup_base.py
@@ -585,6 +585,36 @@ def _get_cloudflare_config(self) -> Dict[str, Any]:
return config
+ def _get_tailscale_config(self) -> Dict[str, Any]:
+ """Get Tailscale Funnel configuration"""
+ print(f"\n{bcolors.BOLD}Tailscale Funnel (optional):{bcolors.ENDC}")
+ print(f" Generate a public URL for your service using Tailscale Funnel")
+ use_tailscale = input(f"{bcolors.OKBLUE}Enable Tailscale? [Press Enter for No] (y/n):{bcolors.ENDC} ").strip().lower() == 'y'
+
+ config = {'tailscale_enabled': use_tailscale, 'tailscale_auth_key': '',
+ 'tailscale_advertise_tags': '', 'tailscale_public_url': ''}
+
+ if use_tailscale:
+ while True:
+ key = input(f"{bcolors.OKBLUE}Tailscale auth key:{bcolors.ENDC} ").strip()
+ try:
+ config['tailscale_auth_key'] = ConfigValidator.validate_tailscale_auth_key(key)
+ break
+ except ValidationError as e:
+ print(f"{bcolors.FAIL}Error: {str(e)}{bcolors.ENDC}")
+
+ config['tailscale_advertise_tags'] = input(
+ f"{bcolors.OKBLUE}Tailscale ACL tags to advertise, comma-separated "
+ f"[Press Enter to skip; required for OAuth-derived keys]:{bcolors.ENDC} "
+ ).strip()
+
+ return config
+
+ @staticmethod
+ def _disabled_tailscale_config() -> Dict[str, Any]:
+ return {'tailscale_enabled': False, 'tailscale_auth_key': '',
+ 'tailscale_advertise_tags': '', 'tailscale_public_url': ''}
+
def _get_advanced_security_config(self) -> Dict[str, Any]:
"""Get advanced security configuration (IP filter, rate limit, encryption, token expiry)."""
print(f"\n{bcolors.BOLD}Advanced Security (optional):{bcolors.ENDC}")
diff --git a/keepercommander/service/util/command_util.py b/keepercommander/service/util/command_util.py
index d38ca9d2b..ef7162d11 100644
--- a/keepercommander/service/util/command_util.py
+++ b/keepercommander/service/util/command_util.py
@@ -270,21 +270,40 @@ def execute(cls, command: str, temp_files: Optional[list] = None) -> Tuple[Any,
command = ensure_record_add_json_format(html.unescape(command))
try:
- command_tokens = Verifycommand.tokenize_service_command(command)
+ # Check the raw command before environment expansion: Service
+ # Mode explicitly forbids this syntax, and expansion must not
+ # erase evidence of it before the restriction check.
+ raw_command_tokens = Verifycommand.tokenize_service_command(command)
except ValueError:
return {"status": "error", "error": "Invalid command syntax"}, 400
- # This request's own FILEDATA directory - the only paths Service
- # Mode will treat as safe, not the whole shared OS temp root.
- request_temp_dir = os.path.dirname(temp_files[0]) if temp_files else None
+ blocked_command = raw_command_tokens[0] if raw_command_tokens else ''
def blocked(error):
logger.warning(
- f"Service Mode blocked command '{command_tokens[0] if command_tokens else ''}': {error}"
+ f"Service Mode blocked command '{blocked_command}': {error}"
)
return {"status": "error", "error": error}, 403
- # Same tokens the CLI will run — do not use raw HTTP split(" ")
+ expansion_error = Verifycommand.validate_service_mode_env_var_expansion_command(
+ raw_command_tokens
+ )
+ if expansion_error:
+ return blocked(expansion_error)
+
+ try:
+ command_tokens = Verifycommand.tokenize_service_command(
+ command, params.environment_variables
+ )
+ except ValueError:
+ return {"status": "error", "error": "Invalid command syntax"}, 400
+
+ # This request's own FILEDATA directory - the only paths Service
+ # Mode will treat as safe, not the whole shared OS temp root.
+ request_temp_dir = os.path.dirname(temp_files[0]) if temp_files else None
+
+ # Apply every Service Mode restriction to the same tokens the
+ # CLI's Command.execute_args parser will receive.
service_mode_error = Verifycommand.validate_service_mode_restrictions(
command_tokens, request_temp_dir
)
diff --git a/keepercommander/service/util/request_validation.py b/keepercommander/service/util/request_validation.py
index c5d9cbf32..7bb6b939c 100644
--- a/keepercommander/service/util/request_validation.py
+++ b/keepercommander/service/util/request_validation.py
@@ -16,6 +16,7 @@
import tempfile
import os
import json
+import shlex
import shutil
from ..decorators.logging import logger, sanitize_command_fields
@@ -82,19 +83,35 @@ def process_file_data(request_data: Dict[str, Any], command: str) -> Tuple[str,
temp_file_path = temp_file.name
temp_files.append(temp_file_path)
- # Replace FILEDATA placeholder with temporary file path, but only when it's used as a file parameter
- # Supports: PAM import (--filename), Import (positional), Enterprise-push (positional)
- # Callable replacement -- a plain string would let a Windows path's backslashes be parsed as regex backreferences.
- file_param_pattern = r'(--filename[=\s]+["\']?)FILEDATA(["\']?)'
+ # Replace the entire FILEDATA token with a quoted path.
+ # Reusing the caller's quotes would break when the
+ # generated path itself contains a single quote.
+ file_param_pattern = (
+ r'(--filename[=\s]+)(?P["\']?)FILEDATA(?P=quote)'
+ )
+
+ def replace_filename_placeholder(match):
+ prefix = match.group(1)
+ return f'{prefix}{shlex.quote(temp_file_path)}'
+
processed_command = re.sub(
file_param_pattern,
- lambda m: f'{m.group(1)}{temp_file_path}{m.group(2)}',
+ replace_filename_placeholder,
processed_command,
)
- # Also handle standalone FILEDATA (not in quotes)
- standalone_pattern = r'\bFILEDATA\b(?!["\'])'
- processed_command = re.sub(standalone_pattern, lambda _: temp_file_path, processed_command)
+ # Also handle positional FILEDATA (including a quoted
+ # placeholder), replacing its original quoting entirely.
+ standalone_pattern = r'(?P["\']?)\bFILEDATA\b(?P=quote)'
+
+ def replace_standalone_placeholder(match):
+ return shlex.quote(temp_file_path)
+
+ processed_command = re.sub(
+ standalone_pattern,
+ replace_standalone_placeholder,
+ processed_command,
+ )
logger.debug(f"Created temporary file {temp_file_path} for filedata")
diff --git a/keepercommander/service/util/str_util.py b/keepercommander/service/util/str_util.py
index b35266e8f..1ccc6ab35 100644
--- a/keepercommander/service/util/str_util.py
+++ b/keepercommander/service/util/str_util.py
@@ -12,3 +12,7 @@
def split_to_list(s: str, sep: str):
""" Split a string to a list using the supplied delimiter/separator """
return [e.strip() for e in s.split(sep) if e.strip()]
+
+
+def get_api_path_suffix(queue_enabled: str) -> str:
+ return "/api/v2" if queue_enabled == "y" else "/api/v1"
diff --git a/keepercommander/service/util/tunneling.py b/keepercommander/service/util/tunneling.py
index e3bf2dca3..22121e14a 100644
--- a/keepercommander/service/util/tunneling.py
+++ b/keepercommander/service/util/tunneling.py
@@ -10,6 +10,7 @@
#
from pyngrok import ngrok, conf
+import contextlib
import os
import logging
import subprocess
@@ -430,3 +431,532 @@ def generate_cloudflare_url(port, tunnel_token, custom_domain, run_mode):
custom_domain=custom_domain
)
return public_url, tunnel_pid
+
+
+# Tailscale Funnel Functions
+
+TAILSCALE_INSTALL_URL = "https://tailscale.com/download"
+
+
+def is_tailscale_installed():
+ """Check whether the Tailscale CLI is on PATH."""
+ import shutil
+ return shutil.which('tailscale') is not None
+
+
+def get_tailscale_install_guidance():
+ """Manual install guidance; used when auto-install is unavailable or fails."""
+ return (
+ "Tailscale CLI was not found on this system. Commander Service Mode "
+ "requires Tailscale to be installed before enabling Tailscale Funnel. "
+ f"Please install Tailscale from {TAILSCALE_INSTALL_URL} and retry."
+ )
+
+
+TAILSCALE_INSTALL_SCRIPT_URL = "https://tailscale.com/install.sh"
+TAILSCALE_MSI_INSTALLER_URL = "https://pkgs.tailscale.com/stable/tailscale-setup-latest-amd64.msi"
+TAILSCALE_INSTALL_TIMEOUT = 180
+
+
+def _run_privileged_tailscale_command(cmd, timeout, action_label):
+ """Run an install/daemon-start command with standard timeout/error handling."""
+ print(f"Running: {' '.join(cmd)}")
+ try:
+ result = subprocess.run(cmd, timeout=timeout, env=os.environ.copy())
+ if result.returncode != 0:
+ logging.error(f"{action_label} failed, exit code {result.returncode}")
+ return False
+ return True
+ except subprocess.TimeoutExpired:
+ logging.error(f"{action_label} timed out after {timeout}s")
+ return False
+ except Exception as e:
+ logging.error(f"Error during {action_label.lower()}: {type(e).__name__}")
+ return False
+
+
+def _install_tailscale_macos():
+ """Install via Homebrew. Returns False if Homebrew isn't available (no GUI/App Store fallback)."""
+ import shutil
+ if not shutil.which('brew'):
+ logging.info("Homebrew not available for automatic Tailscale install")
+ return False
+ return _run_privileged_tailscale_command(['brew', 'install', 'tailscale'], TAILSCALE_INSTALL_TIMEOUT, "Tailscale install")
+
+
+@contextlib.contextmanager
+def _downloaded_to_tempfile(url, suffix):
+ """Download url to a temp file, yield its path, and always delete it after."""
+ import urllib.request
+
+ tmp_path = None
+ try:
+ with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp_file:
+ tmp_path = tmp_file.name
+ urllib.request.urlretrieve(url, tmp_path)
+ yield tmp_path
+ finally:
+ if tmp_path:
+ try:
+ os.unlink(tmp_path)
+ except OSError:
+ pass
+
+
+def _install_tailscale_linux():
+ """Download and run the official install script. No published checksum exists to verify against."""
+ import hashlib
+ try:
+ with _downloaded_to_tempfile(TAILSCALE_INSTALL_SCRIPT_URL, '.sh') as tmp_path:
+ with open(tmp_path, 'rb') as f:
+ script_bytes = f.read()
+ if len(script_bytes) < 1000:
+ logging.error(f"Downloaded Tailscale install script looks truncated ({len(script_bytes)} bytes)")
+ return False
+ logging.info(f"Tailscale install script SHA256: {hashlib.sha256(script_bytes).hexdigest()}")
+ return _run_privileged_tailscale_command(['sh', tmp_path], TAILSCALE_INSTALL_TIMEOUT, "Tailscale install")
+ except Exception as e:
+ logging.error(f"Error downloading Tailscale install script: {type(e).__name__}")
+ return False
+
+
+def _is_windows_process_elevated():
+ """Check whether this process has Administrator privileges."""
+ try:
+ import ctypes
+ return bool(ctypes.windll.shell32.IsUserAnAdmin())
+ except Exception as e:
+ logging.debug(f"Could not determine Windows elevation state: {type(e).__name__}")
+ return False
+
+
+def _run_msiexec_elevated_windows(msi_path, timeout):
+ """Run msiexec via a UAC prompt (Start-Process -Verb RunAs). Returns exit code, or None if declined/failed."""
+ # Escape embedded quotes - msi_path sits in a single-quoted PS literal (e.g. O'Brien via %TEMP%).
+ escaped_msi_path = msi_path.replace("'", "''")
+ msi_args = f'/i "{escaped_msi_path}" /quiet TS_NOLAUNCH=1'
+ ps_command = (
+ "try { "
+ f"$p = Start-Process -FilePath msiexec.exe -ArgumentList '{msi_args}' -Verb RunAs -Wait -PassThru; "
+ "Write-Output $p.ExitCode "
+ "} catch { Write-Output 'ELEVATION_FAILED' }"
+ )
+ cmd = ["powershell", "-NoProfile", "-Command", ps_command]
+ print("Requesting Administrator approval (UAC prompt) to install Tailscale...")
+
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
+ output = (result.stdout or '').strip()
+ if 'ELEVATION_FAILED' in output:
+ logging.error("Elevation request failed or was declined")
+ return None
+ try:
+ return int(output.splitlines()[-1].strip())
+ except (ValueError, IndexError):
+ logging.error(f"Could not parse msiexec exit code: {output!r}")
+ return None
+
+
+def _verify_windows_msi_signer(msi_path, timeout):
+ """Verify the MSI is Tailscale-signed before elevating - UAC shows msiexec.exe as the
+ elevating process, not the payload's publisher, so this check has to stand in for the user."""
+ # Same single-quoted-PS-literal escaping as _run_msiexec_elevated_windows above.
+ escaped_msi_path = msi_path.replace("'", "''")
+ # Match the O= component specifically, not a bare substring of the whole subject --
+ # any validly-chained cert merely containing "Tailscale" would otherwise pass here.
+ ps_command = (
+ f"$sig = Get-AuthenticodeSignature -FilePath '{escaped_msi_path}'; "
+ "if ($sig.Status -eq 'Valid' -and $sig.SignerCertificate.Subject -match 'O=Tailscale Inc') "
+ "{ Write-Output 'VALID' } else { Write-Output 'INVALID' }"
+ )
+ try:
+ result = subprocess.run(
+ ["powershell", "-NoProfile", "-Command", ps_command],
+ capture_output=True, text=True, timeout=timeout,
+ )
+ return (result.stdout or '').strip() == 'VALID'
+ except Exception as e:
+ logging.error(f"Could not verify MSI signature: {type(e).__name__}")
+ return False
+
+
+def _install_tailscale_windows():
+ """Download the official MSI and install silently (no verified winget package exists). Elevates via UAC if needed."""
+ try:
+ with _downloaded_to_tempfile(TAILSCALE_MSI_INSTALLER_URL, '.msi') as tmp_path:
+ if not _verify_windows_msi_signer(tmp_path, TAILSCALE_INSTALL_TIMEOUT):
+ logging.error("Downloaded Tailscale MSI is not validly signed by Tailscale Inc. -- aborting install")
+ return False
+
+ if _is_windows_process_elevated():
+ cmd = ['msiexec', '/i', tmp_path, '/quiet', 'TS_NOLAUNCH=1']
+ print(f"Running: {' '.join(cmd)}")
+ result = subprocess.run(cmd, timeout=TAILSCALE_INSTALL_TIMEOUT, env=os.environ.copy())
+ returncode = result.returncode
+ else:
+ returncode = _run_msiexec_elevated_windows(tmp_path, TAILSCALE_INSTALL_TIMEOUT)
+
+ if returncode is None or returncode != 0:
+ logging.error(f"Tailscale install failed, exit code {returncode}")
+ return False
+
+ _add_windows_tailscale_to_process_path()
+ return True
+ except subprocess.TimeoutExpired:
+ logging.error(f"Tailscale install timed out after {TAILSCALE_INSTALL_TIMEOUT}s")
+ return False
+ except Exception as e:
+ logging.error(f"Error installing Tailscale via MSI: {type(e).__name__}")
+ return False
+
+
+def _add_windows_tailscale_to_process_path():
+ """Extend this process's PATH so is_tailscale_installed() sees a fresh install without a shell restart."""
+ default_install_dir = r"C:\Program Files\Tailscale"
+ current_path = os.environ.get("PATH", "")
+ if default_install_dir not in current_path.split(os.pathsep):
+ os.environ["PATH"] = current_path + os.pathsep + default_install_dir
+ logging.debug(f"Added {default_install_dir} to process PATH")
+
+
+def install_tailscale():
+ """Install Tailscale for the current OS. Caller should re-check is_tailscale_installed() after."""
+ import platform
+ system = platform.system()
+
+ if system == "Darwin":
+ return _install_tailscale_macos()
+ elif system == "Linux":
+ return _install_tailscale_linux()
+ elif system == "Windows":
+ return _install_tailscale_windows()
+ else:
+ logging.error(f"Automatic Tailscale install not supported on platform: {system}")
+ return False
+
+
+TAILSCALE_DAEMON_START_TIMEOUT = 60
+
+
+_TAILSCALE_DAEMON_UNREACHABLE_HINT = "failed to connect to local tailscale service"
+
+
+def is_tailscale_daemon_running():
+ """
+ Check whether tailscaled is reachable. `tailscale status` exits non-zero
+ both when unreachable and when merely logged out, so check for the
+ specific unreachable-connection message rather than the exit code.
+ """
+ try:
+ result = subprocess.run(['tailscale', 'status'], capture_output=True, text=True, timeout=10)
+ combined_output = f"{result.stdout or ''}{result.stderr or ''}".lower()
+ return _TAILSCALE_DAEMON_UNREACHABLE_HINT not in combined_output
+ except Exception as e:
+ logging.debug(f"Error checking Tailscale daemon status: {type(e).__name__}")
+ return False
+
+
+def get_tailscale_daemon_start_guidance():
+ """Manual daemon-start guidance; used when auto-start fails."""
+ return (
+ "Tailscale CLI is installed, but the Tailscale daemon is not running. "
+ "On macOS: run 'sudo brew services start tailscale' (or open the Tailscale app). "
+ "On Linux: run 'sudo systemctl start tailscaled'. "
+ "On Windows: ensure the Tailscale service is running (reinstall or restart it from Services). "
+ "Then retry."
+ )
+
+
+def _start_tailscale_daemon_macos():
+ """Start tailscaled via Homebrew services. Requires sudo."""
+ return _run_privileged_tailscale_command(['sudo', 'brew', 'services', 'start', 'tailscale'], TAILSCALE_DAEMON_START_TIMEOUT, "Daemon start")
+
+
+def _start_tailscale_daemon_linux():
+ """Start tailscaled via systemd. Requires sudo."""
+ return _run_privileged_tailscale_command(['sudo', 'systemctl', 'start', 'tailscaled'], TAILSCALE_DAEMON_START_TIMEOUT, "Daemon start")
+
+
+def _start_tailscale_daemon_windows():
+ """Start the Tailscale Windows service."""
+ return _run_privileged_tailscale_command(['net', 'start', 'Tailscale'], TAILSCALE_DAEMON_START_TIMEOUT, "Daemon start")
+
+
+def start_tailscale_daemon():
+ """Start the daemon for the current OS. Caller should re-check is_tailscale_daemon_running() after."""
+ import platform
+ system = platform.system()
+
+ if system == "Darwin":
+ return _start_tailscale_daemon_macos()
+ elif system == "Linux":
+ return _start_tailscale_daemon_linux()
+ elif system == "Windows":
+ return _start_tailscale_daemon_windows()
+ else:
+ logging.error(f"Automatic daemon start not supported on platform: {system}")
+ return False
+
+
+def _get_tailscale_log_path():
+ """Path to the Tailscale subprocess log file."""
+ service_core_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "core")
+ log_dir = os.path.join(service_core_dir, "logs")
+ os.makedirs(log_dir, exist_ok=True)
+ return os.path.join(log_dir, "tailscale_subprocess.log")
+
+
+def reset_tailscale_log():
+ """Truncate the log at lifecycle start - it's opened in append mode elsewhere
+ (several one-shot commands share it), so this is what keeps it from growing unbounded."""
+ try:
+ open(_get_tailscale_log_path(), 'w').close()
+ except OSError as e:
+ logging.debug(f"Could not reset Tailscale log: {type(e).__name__}")
+
+
+class TailscaleAccessDeniedError(Exception):
+ """Raised when `tailscale up`/`funnel` is denied for not being the tailscaled operator (fresh Linux installs)."""
+
+
+_TAILSCALE_ACCESS_DENIED_HINT = "checkprefs access denied"
+
+
+def set_tailscale_operator():
+ """One-time Linux fix: grants operator rights via `sudo tailscale set --operator=$USER`."""
+ # SUDO_USER first - under `sudo keeper ...`, $USER/$LOGNAME resolve to root instead.
+ user = os.environ.get("SUDO_USER") or os.environ.get("USER") or os.environ.get("LOGNAME") or ""
+ if not user:
+ logging.error("Could not determine current username for Tailscale operator setup")
+ return False
+ return _run_privileged_tailscale_command(
+ ["sudo", "tailscale", "set", f"--operator={user}"], TAILSCALE_DAEMON_START_TIMEOUT, "Operator setup"
+ )
+
+
+def tailscale_up(auth_key, advertise_tags=None):
+ """
+ Authenticate via `tailscale up --auth-key=... --advertise-tags=... --force-reauth`.
+
+ --advertise-tags is always explicit, even empty: omitting it doesn't clear tags
+ a prior run left set, and OAuth-issued keys require it outright.
+ --force-reauth is required so a bad key can't be silently ignored by an already-
+ authenticated node (exit 0, nothing re-validated) - may briefly disrupt another
+ active use of this Tailscale link (e.g. SSH), per Tailscale's own docs.
+ The key goes through a short-lived, owner-only temp file (`--auth-key=file:`)
+ instead of argv, so it isn't visible via `ps`/`/proc`. Never logged.
+ --unattended is Windows-only: see the comment at the call site below.
+ """
+ if not auth_key:
+ raise ValueError("Tailscale auth key must be provided for 'tailscale up'.")
+
+ import platform
+ import tempfile
+ log_file = _get_tailscale_log_path()
+ key_file_path = None
+
+ try:
+ fd, key_file_path = tempfile.mkstemp(suffix='.tskey')
+ os.chmod(key_file_path, 0o600)
+ with os.fdopen(fd, 'w') as key_f:
+ key_f.write(auth_key)
+
+ cmd = ["tailscale", "up", f"--auth-key=file:{key_file_path}",
+ f"--advertise-tags={advertise_tags or ''}", "--force-reauth"]
+ # Windows keeps the node up only while the GUI client runs, and the MSI is
+ # installed with TS_NOLAUNCH=1 (no GUI). Without this the backend sits at
+ # NoState after a successful login and Funnel has nothing to bind to.
+ if platform.system() == "Windows":
+ cmd.append("--unattended")
+
+ with open(log_file, 'a') as log_f:
+ result = subprocess.run(
+ cmd,
+ stdout=log_f,
+ stderr=subprocess.STDOUT,
+ env=os.environ.copy(),
+ timeout=60,
+ )
+ if result.returncode != 0:
+ hint = ""
+ access_denied = False
+ try:
+ with open(log_file, 'r') as f:
+ content = f.read()
+ if "requires --advertise-tags" in content and not advertise_tags:
+ hint = " This auth key requires --advertise-tags (OAuth-issued key)."
+ if _TAILSCALE_ACCESS_DENIED_HINT in content.lower():
+ access_denied = True
+ except OSError:
+ pass
+
+ logging.error(f"Tailscale authentication failed, exit code {result.returncode}")
+
+ if access_denied:
+ raise TailscaleAccessDeniedError(
+ "Tailscale denied the request because this user isn't the tailscaled "
+ "operator on this machine (common on a fresh Linux install). Run "
+ "'sudo tailscale set --operator=$USER' once, then retry."
+ )
+ raise Exception(
+ f"Tailscale authentication failed (exit code {result.returncode}).{hint} "
+ f"See {log_file} for details."
+ )
+ logging.info("Tailscale authentication successful")
+ except subprocess.TimeoutExpired:
+ logging.error("Tailscale authentication timed out")
+ raise Exception("Tailscale authentication timed out after 60 seconds.")
+ finally:
+ if key_file_path:
+ try:
+ os.unlink(key_file_path)
+ except OSError:
+ pass
+
+
+# Funnel only accepts one of these as the external-facing port (local target port is unrestricted).
+TAILSCALE_FUNNEL_ALLOWED_PORTS = (443, 8443, 10000)
+TAILSCALE_FUNNEL_DEFAULT_PORT = 443
+
+
+def start_tailscale_funnel(local_port, funnel_port=TAILSCALE_FUNNEL_DEFAULT_PORT):
+ """
+ Enable Funnel: forward funnel_port -> localhost:local_port.
+ --bg is required, otherwise the command blocks in the foreground indefinitely.
+ """
+ if not local_port:
+ raise ValueError("Port must be provided to start Tailscale Funnel.")
+ if funnel_port not in TAILSCALE_FUNNEL_ALLOWED_PORTS:
+ raise ValueError(
+ f"Invalid Tailscale Funnel port {funnel_port}; must be one of {TAILSCALE_FUNNEL_ALLOWED_PORTS}."
+ )
+
+ cmd = ["tailscale", "funnel", "--bg", f"--https={funnel_port}", f"localhost:{local_port}"]
+ log_file = _get_tailscale_log_path()
+
+ try:
+ with open(log_file, 'a') as log_f:
+ result = subprocess.run(
+ cmd,
+ stdout=log_f,
+ stderr=subprocess.STDOUT,
+ env=os.environ.copy(),
+ timeout=30,
+ )
+ if result.returncode != 0:
+ logging.error(f"Tailscale Funnel start failed, exit code {result.returncode}")
+ raise Exception(
+ f"Failed to start Tailscale Funnel (exit code {result.returncode}). "
+ f"See {log_file} for details. First-time Funnel use on a tailnet may "
+ "require one-time approval in the Tailscale admin console."
+ )
+ logging.info(f"Tailscale Funnel enabled: localhost:{local_port} -> :{funnel_port}")
+ except subprocess.TimeoutExpired:
+ logging.error("Starting Tailscale Funnel timed out")
+ raise Exception("Starting Tailscale Funnel timed out after 30 seconds.")
+
+
+def get_tailscale_funnel_url(local_port, funnel_port=TAILSCALE_FUNNEL_DEFAULT_PORT, max_retries=10, retry_delay=1):
+ """Build the public Funnel URL from this node's MagicDNS hostname + funnel_port."""
+ for attempt in range(max_retries):
+ try:
+ result = subprocess.run(
+ ["tailscale", "status", "--json"],
+ capture_output=True,
+ text=True,
+ timeout=10,
+ )
+ if result.returncode == 0 and result.stdout:
+ status = json.loads(result.stdout)
+ dns_name = (status.get("Self", {}).get("DNSName") or "").rstrip('.')
+ if dns_name:
+ if funnel_port == TAILSCALE_FUNNEL_DEFAULT_PORT:
+ return f"https://{dns_name}"
+ return f"https://{dns_name}:{funnel_port}"
+ except subprocess.TimeoutExpired:
+ logging.debug("Timed out retrieving Tailscale status")
+ except Exception as e:
+ logging.debug(f"Error retrieving Tailscale funnel URL: {type(e).__name__}")
+
+ if attempt < max_retries - 1:
+ time.sleep(retry_delay)
+
+ logging.warning(f"Could not retrieve Tailscale Funnel URL after {max_retries} attempts")
+ return None
+
+
+def _run_tailscale_funnel_teardown(cmd, log_file, local_port):
+ with open(log_file, 'a') as log_f:
+ result = subprocess.run(
+ cmd,
+ stdout=log_f,
+ stderr=subprocess.STDOUT,
+ env=os.environ.copy(),
+ timeout=30,
+ )
+ if result.returncode == 0:
+ logging.info(f"Tailscale Funnel disabled for localhost:{local_port}")
+ return True
+ return False
+
+
+def stop_tailscale_funnel(local_port, funnel_port=TAILSCALE_FUNNEL_DEFAULT_PORT):
+ """
+ Disable Funnel for just this target via `tailscale funnel --https= off`
+ (undocumented in --help, but confirmed working - it's what Tailscale's own CLI
+ suggests after `funnel --bg`). Falls back to the broader `funnel reset` only if
+ the target is genuinely still up afterwards, since reset wipes any other
+ Serve/Funnel config on the node too.
+ """
+ log_file = _get_tailscale_log_path()
+
+ try:
+ scoped_cmd = ["tailscale", "funnel", f"--https={funnel_port}", "off"]
+ if _run_tailscale_funnel_teardown(scoped_cmd, log_file, local_port):
+ return True
+
+ # Scoped teardown also exits non-zero when the target is already gone (routine on
+ # double teardown), so treat that as success - escalating to `reset` would wipe
+ # unrelated Serve/Funnel config for nothing.
+ if not get_tailscale_funnel_status(local_port):
+ logging.debug(f"Tailscale Funnel for localhost:{local_port} was already inactive")
+ return True
+
+ logging.warning("Scoped Tailscale Funnel teardown failed, falling back to 'funnel reset'")
+ reset_cmd = ["tailscale", "funnel", "reset"]
+ if _run_tailscale_funnel_teardown(reset_cmd, log_file, local_port):
+ return True
+
+ logging.warning("Failed to stop Tailscale Funnel")
+ return False
+ except Exception as e:
+ logging.error(f"Error stopping Tailscale Funnel: {type(e).__name__}")
+ return False
+
+
+def get_tailscale_funnel_status(local_port):
+ """
+ Check live Funnel status via `tailscale funnel status --json`: a target is active
+ when Web[host:port].Handlers[path].Proxy matches AND AllowFunnel[host:port] is
+ true - the same Web entry is also used for a tailnet-only `serve`, so Proxy alone
+ can't distinguish Funnel (public) from Serve (private).
+ """
+ try:
+ result = subprocess.run(
+ ["tailscale", "funnel", "status", "--json"],
+ capture_output=True,
+ text=True,
+ timeout=10,
+ )
+ if result.returncode == 0 and result.stdout:
+ data = json.loads(result.stdout)
+ target = f"http://localhost:{local_port}"
+ allow_funnel = data.get("AllowFunnel") or {}
+ for host_port, web_config in (data.get("Web") or {}).items():
+ if not allow_funnel.get(host_port):
+ continue
+ for handler in (web_config.get("Handlers") or {}).values():
+ if handler.get("Proxy") == target:
+ return True
+ except Exception as e:
+ logging.debug(f"Error checking Tailscale funnel status: {type(e).__name__}")
+ return False
diff --git a/keepercommander/service/util/verified_command.py b/keepercommander/service/util/verified_command.py
index 71c77e033..ba4165227 100644
--- a/keepercommander/service/util/verified_command.py
+++ b/keepercommander/service/util/verified_command.py
@@ -2,7 +2,6 @@
import contextlib
import io
import os
-import shlex
class Verifycommand:
@@ -99,16 +98,20 @@ class Verifycommand:
_HOST_FS_MSG = (
'Local filesystem access is not permitted through Service Mode'
)
+ _AUTOMATOR_DATA_EXPANSION_MSG = (
+ 'Automator file and environment expansions are not permitted through Service Mode'
+ )
@staticmethod
def validate_service_mode_restrictions(command_tokens, request_temp_dir=None):
- """Run Service Mode bans on executor tokens (shlex); error string or None."""
+ """Run Service Mode bans over the exact tokens used by the command parser."""
if not command_tokens:
return None
for validator in (
Verifycommand.validate_service_mode_double_dash,
Verifycommand.validate_service_mode_env_var_expansion_command,
+ Verifycommand.validate_service_mode_automator_file_access_command,
Verifycommand.validate_service_mode_legacy_command,
Verifycommand.validate_nsf_convert_command,
Verifycommand.validate_service_mode_pam_tunnel_command,
@@ -127,9 +130,10 @@ def validate_service_mode_restrictions(command_tokens, request_temp_dir=None):
return None
@staticmethod
- def tokenize_service_command(command):
- """Tokenize a command using the executor's backslash handling."""
- return shlex.split(command.replace('\\', '\\\\'))
+ def tokenize_service_command(command, envvars=None):
+ """Tokenize with the same argument transforms used by Command.execute_args."""
+ from ...commands.base import tokenize_command_args
+ return tokenize_command_args(command, envvars)
@staticmethod
def validate_nsf_convert_command(command_tokens, request_temp_dir=None):
@@ -186,6 +190,62 @@ def validate_service_mode_env_var_expansion_command(command_tokens, request_temp
return Verifycommand._ENV_VAR_EXPANSION_MSG
return None
+ @staticmethod
+ def validate_service_mode_automator_file_access_command(command_tokens, request_temp_dir=None):
+ """Block Automator operations that read local files or environment values."""
+ if not command_tokens:
+ return None
+
+ command = command_tokens[0].lower()
+ if command == 'automator-certificate':
+ return Verifycommand._HOST_FS_MSG
+
+ if command != 'automator' or len(command_tokens) < 2:
+ return None
+
+ action = command_tokens[1].lower()
+ if action == 'certificate':
+ # This is the registered group form in current Commander releases.
+ return Verifycommand._HOST_FS_MSG
+
+ if action != 'edit':
+ return None
+
+ # --set supports both ``--set KEY=VALUE`` and ``--set=KEY=VALUE``;
+ # argparse also accepts the unambiguous ``--se`` abbreviation.
+ # Extract only those settings instead of matching unrelated Automator
+ # arguments such as --url.
+ settings = []
+ i = 2
+ while i < len(command_tokens):
+ token = command_tokens[i]
+ option, separator, inline_value = token.partition('=')
+ if option in ('--set', '--se'):
+ if separator:
+ settings.append(inline_value)
+ elif i + 1 < len(command_tokens):
+ settings.append(command_tokens[i + 1])
+ i += 1
+ i += 1
+
+ for setting in settings:
+ separator = setting.find('=')
+ if separator <= 1:
+ # AutomatorEditCommand uses the same pos > 1 condition and
+ # returns before resolving expansions for malformed settings.
+ continue
+ value = setting[separator + 1:].strip()
+ if not (value.startswith('${') and value.endswith('}')):
+ continue
+ expansion_type, colon, source = value[2:-1].partition(':')
+ if (
+ colon
+ and expansion_type.lower() in ('file', 'base64', 'env')
+ and source
+ and '}' not in source):
+ return Verifycommand._AUTOMATOR_DATA_EXPANSION_MSG
+ return None
+
@staticmethod
def validate_service_mode_legacy_command(command_tokens, request_temp_dir=None):
"""Block legacy commands in Service Mode; error or None."""
@@ -254,7 +314,7 @@ def validate_service_mode_record_file_attachment_command(command_tokens, request
@staticmethod
def _is_record_file_attachment_arg(token):
- """True when parse_field would treat this token as a file attachment field."""
+ """True when parse_field would treat this executor token as a file field."""
if not token or '=' not in token:
return False
# Mirror RecordEditMixin.parse_field field-name normalization.
diff --git a/keepercommander/sync_down.py b/keepercommander/sync_down.py
index 35e12876f..c90edd089 100644
--- a/keepercommander/sync_down.py
+++ b/keepercommander/sync_down.py
@@ -15,7 +15,7 @@
import google
-from . import api, utils, crypto, convert_keys
+from . import api, utils, crypto, convert_keys, notification_center
from .nested_share_folder import sync as nested_share_folder_sync
from .display import bcolors, Spinner
from .params import KeeperParams, RecordOwner
@@ -116,9 +116,13 @@ def delete_team_key(team_uid):
params.breach_watch_security_data.clear()
params.breach_watch_records.clear()
params.security_score_data.clear()
+ notification_center.clear_cache(params)
if nsf_enabled:
nested_share_folder_sync.clear_caches(params)
+ if len(response.notificationSync) > 0:
+ notification_center.process_notification_sync(params, response.notificationSync)
+
if len(response.removedRecords) > 0:
logging.debug('Processing removed records')
for record_uid_bytes in response.removedRecords:
diff --git a/tests/test_cyberark_pam_import.py b/tests/test_cyberark_pam_import.py
index ea17651aa..a967025f3 100644
--- a/tests/test_cyberark_pam_import.py
+++ b/tests/test_cyberark_pam_import.py
@@ -4227,6 +4227,207 @@ def mock_get(url, **kwargs):
assert "attacker.example.com" not in u, \
f"pagination followed attacker nextLink: {u}"
+ def test_resolves_next_link_using_active_url_and_port(self):
+ from keepercommander.importer.cyberark.cyberark_pam import CyberArkPVWAClient
+
+ active_url = "https://pvwa.example.com:8443/PasswordVault/API/Users?offset=0"
+ assert CyberArkPVWAClient._resolve_next_link(
+ active_url, "?offset=100"
+ ) == "https://pvwa.example.com:8443/PasswordVault/API/Users?offset=100"
+
+ def test_resolves_root_relative_api_path_without_duplicating_endpoint_path(self):
+ from keepercommander.importer.cyberark.cyberark_pam import CyberArkPVWAClient
+
+ active_url = "https://pvwa.example.com:8443/API/Safes"
+ assert CyberArkPVWAClient._resolve_next_link(
+ active_url, "API/Safes?offset=100"
+ ) == "https://pvwa.example.com:8443/API/Safes?offset=100"
+
+ def test_relative_api_path_preserves_password_vault_prefix(self):
+ from keepercommander.importer.cyberark.cyberark_pam import CyberArkPVWAClient
+
+ active_url = "https://pvwa.example.com:8443/PasswordVault/API/Safes?offset=0"
+ assert CyberArkPVWAClient._resolve_next_link(
+ active_url, "API/Safes?offset=100"
+ ) == "https://pvwa.example.com:8443/PasswordVault/API/Safes?offset=100"
+
+ def test_resolves_absolute_same_origin_next_link(self):
+ from keepercommander.importer.cyberark.cyberark_pam import CyberArkPVWAClient
+
+ active_url = "https://pvwa.example.com:8443/API/Safes"
+ next_link = "https://pvwa.example.com:8443/API/Safes?offset=100"
+ assert CyberArkPVWAClient._resolve_next_link(active_url, next_link) == next_link
+
+ def test_rejects_same_host_http_next_link(self):
+ from keepercommander.importer.cyberark.cyberark_pam import CyberArkPVWAClient
+
+ active_url = "https://pvwa.example.com:8443/PasswordVault/API/Users"
+ assert CyberArkPVWAClient._resolve_next_link(
+ active_url, "http://pvwa.example.com:8443/PasswordVault/API/Users?offset=100"
+ ) is None
+
+ @patch("keepercommander.importer.cyberark.cyberark_pam.time.sleep")
+ @patch("keepercommander.importer.cyberark.cyberark_pam.socket.getaddrinfo",
+ return_value=[(2, 1, 6, '', ('93.184.216.34', 0))])
+ def test_empty_batch_without_next_link_stops_pagination(self, mock_dns, mock_sleep):
+ from keepercommander.importer.cyberark.cyberark_pam import CyberArkPVWAClient
+
+ response = MagicMock()
+ response.status_code = 200
+ response.json.return_value = {"Users": [], "Total": 100}
+
+ client = CyberArkPVWAClient("pvwa.example.com")
+ client._get = MagicMock(return_value=response)
+
+ assert client._paginate(
+ "https://pvwa.example.com/PasswordVault/API/Users",
+ items_key="Users",
+ limit=100,
+ ) == []
+ client._get.assert_called_once()
+
+ @patch("keepercommander.importer.cyberark.cyberark_pam.time.sleep")
+ @patch("keepercommander.importer.cyberark.cyberark_pam.socket.getaddrinfo",
+ return_value=[(2, 1, 6, '', ('93.184.216.34', 0))])
+ def test_full_page_without_total_count_falls_back_to_offset(self, mock_dns, mock_sleep):
+ from keepercommander.importer.cyberark.cyberark_pam import CyberArkPVWAClient
+
+ first_page = MagicMock()
+ first_page.status_code = 200
+ first_page.json.return_value = {"Users": [{"id": 1}, {"id": 2}]}
+ last_page = MagicMock()
+ last_page.status_code = 200
+ last_page.json.return_value = {"Users": []}
+
+ client = CyberArkPVWAClient("pvwa.example.com")
+ client._get = MagicMock(side_effect=[first_page, last_page])
+
+ assert client._paginate(
+ "https://pvwa.example.com/PasswordVault/API/Users",
+ items_key="Users",
+ limit=2,
+ ) == [{"id": 1}, {"id": 2}]
+ assert client._get.call_count == 2
+
+ @patch("keepercommander.importer.cyberark.cyberark_pam.time.sleep")
+ @patch("keepercommander.importer.cyberark.cyberark_pam.socket.getaddrinfo",
+ return_value=[(2, 1, 6, '', ('93.184.216.34', 0))])
+ def test_short_batches_continue_when_total_count_indicates_more(
+ self, mock_dns, mock_sleep
+ ):
+ from keepercommander.importer.cyberark.cyberark_pam import CyberArkPVWAClient
+
+ responses = []
+ for user_id in (1, 2, 3):
+ response = MagicMock()
+ response.status_code = 200
+ response.json.return_value = {"Users": [{"id": user_id}], "Total": 3}
+ responses.append(response)
+
+ client = CyberArkPVWAClient("pvwa.example.com")
+ client._get = MagicMock(side_effect=responses)
+
+ assert client._paginate(
+ "https://pvwa.example.com/PasswordVault/API/Users",
+ items_key="Users",
+ limit=2,
+ ) == [{"id": 1}, {"id": 2}, {"id": 3}]
+ assert client._get.call_count == 3
+
+ @patch("keepercommander.importer.cyberark.cyberark_pam.time.sleep")
+ @patch("keepercommander.importer.cyberark.cyberark_pam.socket.getaddrinfo",
+ return_value=[(2, 1, 6, '', ('93.184.216.34', 0))])
+ def test_empty_batch_follows_next_link(self, mock_dns, mock_sleep):
+ from keepercommander.importer.cyberark.cyberark_pam import CyberArkPVWAClient
+
+ first_page = MagicMock()
+ first_page.status_code = 200
+ first_page.json.return_value = {
+ "Users": [],
+ "nextLink": "API/Users?offset=100",
+ }
+ second_page = MagicMock()
+ second_page.status_code = 200
+ second_page.json.return_value = {"Users": [{"id": 1}]}
+
+ client = CyberArkPVWAClient("pvwa.example.com")
+ client._get = MagicMock(side_effect=[first_page, second_page])
+
+ assert client._paginate(
+ "https://pvwa.example.com/PasswordVault/API/Users",
+ items_key="Users",
+ limit=100,
+ ) == [{"id": 1}]
+ assert client._get.call_count == 2
+ assert client._get.call_args_list[1].args[0] == (
+ "https://pvwa.example.com/PasswordVault/API/Users?offset=100"
+ )
+
+ @patch("keepercommander.importer.cyberark.cyberark_pam.time.sleep")
+ @patch("keepercommander.importer.cyberark.cyberark_pam.socket.getaddrinfo",
+ return_value=[(2, 1, 6, '', ('93.184.216.34', 0))])
+ def test_repeated_next_link_stops_pagination(self, mock_dns, mock_sleep):
+ from keepercommander.importer.cyberark.cyberark_pam import CyberArkPVWAClient
+
+ response = MagicMock()
+ response.status_code = 200
+ response.json.return_value = {
+ "Users": [{"id": 1}],
+ "nextLink": "API/Users",
+ }
+
+ client = CyberArkPVWAClient("pvwa.example.com")
+ client._get = MagicMock(return_value=response)
+
+ assert client._paginate(
+ "https://pvwa.example.com/PasswordVault/API/Users",
+ items_key="Users",
+ limit=100,
+ ) == [{"id": 1}, {"id": 1}]
+ assert client._get.call_count == 2
+
+ @patch("keepercommander.importer.cyberark.cyberark_pam.time.sleep")
+ @patch("keepercommander.importer.cyberark.cyberark_pam.socket.getaddrinfo",
+ return_value=[(2, 1, 6, '', ('93.184.216.34', 0))])
+ def test_invalid_response_shapes_stop_pagination(self, mock_dns, mock_sleep):
+ from keepercommander.importer.cyberark.cyberark_pam import CyberArkPVWAClient
+
+ response = MagicMock()
+ response.status_code = 200
+ response.json.return_value = {"Users": {"unexpected": "object"}}
+
+ client = CyberArkPVWAClient("pvwa.example.com")
+ client._get = MagicMock(return_value=response)
+
+ assert client._paginate(
+ "https://pvwa.example.com/PasswordVault/API/Users",
+ items_key="Users",
+ ) == []
+ client._get.assert_called_once()
+
+ @patch("keepercommander.importer.cyberark.pam.client.MAX_FETCH_RECORDS", 3)
+ @patch("keepercommander.importer.cyberark.cyberark_pam.time.sleep")
+ @patch("keepercommander.importer.cyberark.cyberark_pam.socket.getaddrinfo",
+ return_value=[(2, 1, 6, '', ('93.184.216.34', 0))])
+ def test_fetch_cap_truncates_batch_and_stops(self, mock_dns, mock_sleep):
+ from keepercommander.importer.cyberark.cyberark_pam import CyberArkPVWAClient
+
+ response = MagicMock()
+ response.status_code = 200
+ response.json.return_value = {
+ "Users": [{"id": i} for i in range(5)],
+ "nextLink": "API/Users?offset=5",
+ }
+
+ client = CyberArkPVWAClient("pvwa.example.com")
+ client._get = MagicMock(return_value=response)
+
+ assert client._paginate(
+ "https://pvwa.example.com/PasswordVault/API/Users",
+ items_key="Users",
+ ) == [{"id": 0}, {"id": 1}, {"id": 2}]
+ client._get.assert_called_once()
+
class TestGetRetry429:
"""Tests for _get() HTTP 429 retry logic."""
@@ -5645,4 +5846,3 @@ def execute(self, filename, **kwargs):
assert captured["filename"] == "https://pvwa.example.com"
assert captured["kwargs"]["record_type"] == "test custom"
-
diff --git a/unit-tests/pam/test_cyberark_add_users.py b/unit-tests/pam/test_cyberark_add_users.py
new file mode 100644
index 000000000..8be6ce01d
--- /dev/null
+++ b/unit-tests/pam/test_cyberark_add_users.py
@@ -0,0 +1,352 @@
+import unittest
+from types import SimpleNamespace
+from unittest import mock
+
+from keepercommander.commands.pam_import.cyberark_import import (
+ CyberArkImportOrchestrator,
+ CyberArkPAMImportCommand,
+)
+from keepercommander.importer.cyberark.pam.client import CyberArkPVWAClient
+from keepercommander.importer.cyberark.pam.import_builder import (
+ _format_enterprise_users,
+ build_report,
+)
+
+_MODULE = "keepercommander.commands.pam_import.cyberark_import"
+
+
+class _QuietTestCase(unittest.TestCase):
+ """Silences console output, sleeps and logging for every test."""
+
+ def setUp(self):
+ for target in ("time.sleep", "print_formatted_text", "logging"):
+ self._patch(f"{_MODULE}.{target}")
+ self._patch(f"{_MODULE}.HTML", side_effect=lambda text: text)
+ self._patch("builtins.print")
+ self.log = self._mocks["logging"]
+ self.sleep = self._mocks["time.sleep"]
+
+ def _patch(self, target, **kwargs):
+ patcher = mock.patch(target, **kwargs)
+ self.__dict__.setdefault("_mocks", {})[target.rsplit(f"{_MODULE}.", 1)[-1]] = patcher.start()
+ self.addCleanup(patcher.stop)
+
+
+def _orchestrator(client=None, **options):
+ orch = CyberArkImportOrchestrator.__new__(CyberArkImportOrchestrator)
+ orch.client = client or mock.MagicMock()
+ orch.params = object()
+ orch._cmd = mock.MagicMock()
+ orch.options = SimpleNamespace(**{
+ "add_cyberark_users": False, "list_safes": False, "estimate_only": False,
+ "output_file": "", "dry_run": False, "project_name": "p", "config_uid": "", **options,
+ })
+ return orch
+
+
+def _command():
+ return CyberArkPAMImportCommand.__new__(CyberArkPAMImportCommand)
+
+
+class TestExtractUserEmail(_QuietTestCase):
+ def test_source_priority_and_edge_cases(self):
+ full = {
+ "internet": {"businessEmail": "biz@x.com", "homeEmail": "home@x.com", "otherEmail": "other@x.com"},
+ "email": "top@x.com",
+ "personalDetails": {"email": "personal@x.com"},
+ }
+ cases = [
+ ("business wins over everything", full, "biz@x.com"),
+ ("home when no business", {"internet": {"homeEmail": "home@x.com"}, "email": "top@x.com"}, "home@x.com"),
+ ("top-level before personal", {"email": "top@x.com", "personalDetails": {"email": "p@x.com"}}, "top@x.com"),
+ ("personal as last resort", {"personalDetails": {"email": "p@x.com"}}, "p@x.com"),
+ ("keys are case-insensitive", {"Internet": {"BUSINESSEMAIL": "biz@x.com"}}, "biz@x.com"),
+ ("value is trimmed", {"email": " top@x.com \n"}, "top@x.com"),
+ ("blank / non-string falls through", {"internet": {"businessEmail": " ", "homeEmail": 42},
+ "email": "top@x.com"}, "top@x.com"),
+ ("internet not a dict is ignored", {"internet": "biz@x.com", "email": "top@x.com"}, "top@x.com"),
+ ("no address anywhere", {"username": "bob", "internet": {}}, None),
+ ("None input", None, None),
+ ("list input", ["a@x.com"], None),
+ ]
+ orch = _orchestrator()
+ for name, user, expected in cases:
+ with self.subTest(name):
+ self.assertEqual(orch._extract_user_email(user), expected)
+
+
+class TestPrepareUsers(_QuietTestCase):
+ def _prepare(self, users, details=None):
+ client = mock.MagicMock()
+ client.fetch_users.return_value = users
+ client.fetch_user_details.side_effect = details or (lambda user_id: None)
+ return _orchestrator(client)._prepare_users(), client
+
+ def test_list_email_skips_detail_fetch(self):
+ found, client = self._prepare([{"id": "1", "username": "a", "email": "a@x.com"}])
+ self.assertEqual(found, [{"id": "1", "username": "a", "email": "a@x.com"}])
+ client.fetch_user_details.assert_not_called()
+ self.sleep.assert_not_called()
+
+ def test_missing_email_is_fetched_from_details_with_throttle(self):
+ found, client = self._prepare(
+ [{"id": "7", "username": "b"}],
+ details=lambda user_id: {"internet": {"businessEmail": "b@x.com"}},
+ )
+ self.assertEqual(found, [{"id": "7", "username": "b", "email": "b@x.com"}])
+ client.fetch_user_details.assert_called_once_with("7")
+ self.sleep.assert_called_once_with(CyberArkImportOrchestrator.DELAY)
+
+ def test_detail_fetch_failure_omits_only_that_user(self):
+ def details(user_id):
+ raise RuntimeError("403 Forbidden")
+ found, _ = self._prepare(
+ [{"id": "1", "username": "bad"}, {"id": "2", "username": "ok", "email": "ok@x.com"}],
+ details=details,
+ )
+ self.assertEqual([u["username"] for u in found], ["ok"])
+ self.log.warning.assert_called_once()
+
+ def test_rows_without_usable_email_or_id(self):
+ found, client = self._prepare([
+ {"username": "no-email-no-id"},
+ {"id": "", "username": "blank-id"},
+ {"username": "email-no-id", "email": "e@x.com"},
+ {"id": "3", "username": "details-empty"},
+ "not-a-dict",
+ None,
+ ])
+ self.assertEqual(found, [{"id": None, "username": "email-no-id", "email": "e@x.com"}])
+ client.fetch_user_details.assert_called_once_with("3")
+
+ def test_missing_username_becomes_empty_string(self):
+ found, _ = self._prepare([{"id": "1", "email": "a@x.com", "username": None}])
+ self.assertEqual(found[0]["username"], "")
+
+
+class TestLoadExistingKeeperUsers(_QuietTestCase):
+ def setUp(self):
+ super().setUp()
+ self._patch(f"{_MODULE}.api.query_enterprise")
+ self.query = self._mocks["api.query_enterprise"]
+
+ def test_cached_enterprise_with_real_keeper_shape(self):
+ # Keeper enterprise rows carry the address in "username", not "email".
+ params = SimpleNamespace(enterprise={"users": [
+ {"username": " Alice@X.com ", "status": "invited"},
+ {"username": "bob@x.com"},
+ "junk",
+ ]})
+ users, error = _command()._load_existing_keeper_users(params)
+ self.assertEqual(error, "")
+ self.assertEqual(users, [
+ {"email": "Alice@X.com", "status": "invited"},
+ {"email": "bob@x.com", "status": "active"},
+ ])
+ self.query.assert_not_called()
+
+ def test_enterprise_is_queried_when_not_loaded(self):
+ params = SimpleNamespace(enterprise=None)
+ self.query.side_effect = lambda p: setattr(p, "enterprise", {"users": [{"email": "a@x.com"}]})
+ users, error = _command()._load_existing_keeper_users(params)
+ self.assertEqual((users, error), ([{"email": "a@x.com", "status": "active"}], ""))
+
+ def test_query_failure_returns_error(self):
+ self.query.side_effect = RuntimeError("not an admin")
+ users, error = _command()._load_existing_keeper_users(SimpleNamespace(enterprise=None))
+ self.assertEqual(users, [])
+ self.assertTrue(error)
+
+
+class TestCreateEnterpriseUsers(_QuietTestCase):
+ def _run(self, users, existing=(), error="", create_result="uid"):
+ cmd = _command()
+ cmd._load_existing_keeper_users = mock.MagicMock(return_value=(list(existing), error))
+ cmd._create_keeper_user = mock.MagicMock(return_value=create_result)
+ report = cmd._create_enterprise_users(object(), users)
+ return report["users"], cmd._create_keeper_user
+
+ def test_every_outcome_in_input_order(self):
+ rows, create = self._run(
+ [
+ {"username": "new", "email": "new@x.com"},
+ {"username": "old", "email": "old@x.com"},
+ {"username": "bad", "email": "not-an-email"},
+ {"username": "blank", "email": ""},
+ {},
+ "",
+ None
+ ],
+ existing=[{"email": "old@x.com", "status": "locked"}],
+ )
+ self.assertEqual([(r["email"], r["flag"]) for r in rows], [
+ ("new@x.com", "created"), ("old@x.com", "existing"),
+ ("not-an-email", "skipped"), ("", "skipped"), ("", "skipped"),
+ ])
+ self.assertEqual(rows[1]["status"], "locked")
+ create.assert_called_once_with(mock.ANY, "new@x.com", "new")
+
+ def test_existing_match_ignores_case(self):
+ rows, create = self._run(
+ [{"username": "john", "email": "John.Doe@Corp.com"}, {}, None, ""],
+ existing=[{"email": "john.doe@corp.com", "status": "active"}],
+ )
+ self.assertEqual(rows[0]["flag"], "existing")
+ create.assert_not_called()
+
+ def test_case_variant_duplicates_are_created_once(self):
+ rows, create = self._run([
+ {"username": "a1", "email": "A@corp.com"},
+ {"username": "a2", "email": "a@corp.com"},
+ {}, # it will skip this
+ {}, # it will skip this
+ None,
+ "",
+ " ",
+ "!",
+ 12.3
+ ])
+ self.assertEqual(len(rows), 3)
+ create.assert_called_once()
+
+ def test_email_is_trimmed_and_username_falls_back_to_email(self):
+ rows, create = self._run([{"username": None, "email": " c@x.com "}])
+ create.assert_called_once_with(mock.ANY, "c@x.com", "c@x.com")
+ self.assertEqual(rows[0]["email"], "c@x.com")
+
+ def test_failed_create_is_reported_and_not_retried(self):
+ rows, create = self._run(
+ [{"username": "f", "email": "f@x.com"}, {"username": "f", "email": "f@x.com"}],
+ create_result=None,
+ )
+ self.assertEqual([r["flag"] for r in rows], ["failed"])
+ create.assert_called_once()
+
+ def test_loader_error_still_creates_users(self):
+ rows, _ = self._run([{"username": "n", "email": "n@x.com"}], error="Failed to Load Existing User")
+ self.assertEqual(rows[0]["flag"], "created")
+
+
+class TestCreateKeeperUser(_QuietTestCase):
+ def setUp(self):
+ super().setUp()
+ patcher = mock.patch("keepercommander.commands.enterprise_create_user.CreateEnterpriseUserCommand")
+ self.command_cls = patcher.start()
+ self.addCleanup(patcher.stop)
+
+ def test_forwards_email_and_name(self):
+ self.command_cls.return_value.execute.return_value = "record-uid"
+ params = object()
+ self.assertEqual(_command()._create_keeper_user(params, "a@x.com", "Alice"), "record-uid")
+ self.command_cls.return_value.execute.assert_called_once_with(params, email="a@x.com", name="Alice")
+
+ def test_error_returns_none_and_warns(self):
+ self.command_cls.return_value.execute.side_effect = RuntimeError("reserved domain required")
+ self.assertIsNone(_command()._create_keeper_user(object(), "a@x.com", "Alice"))
+ self.log.warning.assert_called_once()
+
+
+class TestFetchUserDetails(unittest.TestCase):
+ def _client(self, response):
+ client = CyberArkPVWAClient.__new__(CyberArkPVWAClient)
+ client.pvwa_host = "pvwa.example.com"
+ client._get = mock.MagicMock(return_value=response)
+ return client
+
+ def test_requests_user_endpoint_and_returns_json(self):
+ response = mock.MagicMock()
+ response.json.return_value = {"id": 5}
+ client = self._client(response)
+ self.assertEqual(client.fetch_user_details("5"), {"id": 5})
+ client._get.assert_called_once_with("https://pvwa.example.com/PasswordVault/API/Users/5")
+
+ def test_http_error_propagates(self):
+ response = mock.MagicMock()
+ response.raise_for_status.side_effect = RuntimeError("404")
+ with self.assertRaises(RuntimeError):
+ self._client(response).fetch_user_details("5")
+
+
+class TestEnterpriseUsersReport(unittest.TestCase):
+ def test_section_counts_and_rows(self):
+ lines = _format_enterprise_users({"users": [
+ {"email": "a@x.com", "flag": "created"},
+ {"email": "b@x.com", "flag": "existing", "status": "invited"},
+ {"email": "bad", "flag": "skipped", "reason": "invalid email"},
+ {"username": "c", "flag": "failed", "reason": "user was not created"},
+ ], "error": "Failed to Load Existing User"})
+ text = "\n".join(lines)
+ self.assertIn("created 1 existing 1 skipped 1 failed 1", text)
+ self.assertIn("Failed to Load Existing User", text)
+ self.assertIn("[existing] b@x.com invited", text)
+ self.assertIn("[skipped] bad invalid email", text)
+ self.assertIn("[failed] c user was not created", text)
+
+ def test_build_report_includes_section_only_when_provided(self):
+ args = dict(project_name="p", safes_processed=0, total_accounts=0, resource_counts={},
+ platform_counts={}, skipped=[], incomplete_count=0, duration=0.0)
+ self.assertNotIn("ENTERPRISE USERS", build_report(**args))
+ report = build_report(**args, enterprise_users={"users": [{"email": "a@x.com", "flag": "created"}]})
+ self.assertIn("ENTERPRISE USERS", report)
+
+
+class TestRunFlow(_QuietTestCase):
+ def _run(self, **options):
+ self._patch(f"{_MODULE}.validate_import_data", return_value=[])
+ orch = _orchestrator(**options)
+ mapped = mock.MagicMock()
+ mapped.mapper.platform_workflow_unmapped = []
+ stubs = {
+ "_prepare_safes": ([{"safeName": "s"}], 0), "_maybe_interactive_safe_pick": [{"safeName": "s"}],
+ "_fetch_safe_members": ({}, []), "_fetch_accounts": {"s": [{"id": "acc"}]},
+ "_map_accounts": mapped, "_prepare_idempotency": None, "_build_import_payload": {},
+ "_confirm_import": True, "_execute_vault_import": {"project_name": "p"},
+ "_prepare_users": [{"id": "1", "username": "a", "email": "a@x.com"}],
+ }
+ for name in ("_load_master_policy", "_build_user_team_matcher", "_maybe_auto_extend",
+ "_print_mapping_summary", "_print_dry_run", "_print_estimate",
+ "_write_output_file", "_finalize_report"):
+ setattr(orch, name, mock.MagicMock())
+ for name, value in stubs.items():
+ setattr(orch, name, mock.MagicMock(return_value=value))
+ orch._cmd._create_enterprise_users.return_value = {"users": ["report"]}
+ return orch
+
+ def test_flag_off_does_not_touch_users(self):
+ orch = self._run()
+ orch.run()
+ orch._prepare_users.assert_not_called()
+ orch._cmd._create_enterprise_users.assert_not_called()
+ self.assertIsNone(orch._finalize_report.call_args.kwargs["enterprise_users"])
+
+ def test_flag_on_creates_users_and_reports_them(self):
+ orch = self._run(add_cyberark_users=True)
+ orch.run()
+ orch._cmd._create_enterprise_users.assert_called_once_with(
+ orch.params, [{"id": "1", "username": "a", "email": "a@x.com"}])
+ self.assertEqual(orch._finalize_report.call_args.kwargs["enterprise_users"], {"users": ["report"]})
+
+ def test_user_fetch_failure_does_not_abort_import(self):
+ orch = self._run(add_cyberark_users=True)
+ orch._prepare_users.side_effect = RuntimeError("403 Forbidden")
+ orch.run()
+ orch._execute_vault_import.assert_called_once()
+ orch._cmd._create_enterprise_users.assert_not_called()
+ self.assertTrue(orch._finalize_report.call_args.kwargs["enterprise_users"]["error"])
+
+ def test_non_writing_modes_never_fetch_or_create_users(self):
+ for mode in ({"dry_run": True}, {"output_file": "out.json"}, {"estimate_only": True}, {"list_safes": True}):
+ with self.subTest(mode):
+ orch = self._run(add_cyberark_users=True, **mode)
+ orch.run()
+ orch._prepare_users.assert_not_called()
+ orch._cmd._create_enterprise_users.assert_not_called()
+
+ def test_parser_flag(self):
+ parser = CyberArkPAMImportCommand.parser
+ self.assertTrue(parser.parse_args(["pvwa.example.com", "--add-cyberark-users"]).add_cyberark_users)
+ self.assertFalse(parser.parse_args(["pvwa.example.com"]).add_cyberark_users)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/unit-tests/pam/test_uss_frame_parser.py b/unit-tests/pam/test_uss_frame_parser.py
new file mode 100644
index 000000000..76878fc06
--- /dev/null
+++ b/unit-tests/pam/test_uss_frame_parser.py
@@ -0,0 +1,76 @@
+"""Unit tests for make_uss_frame_parser and the USS dry-run result printer."""
+import io
+import json
+import unittest
+from contextlib import redirect_stdout
+
+from keepercommander.commands.pam.router_helper import make_uss_frame_parser
+from keepercommander.commands.universalsecretsync import PAMUniversalSyncRunCommand
+
+CONV = 'abcdefghijklmnopqrstuv'
+
+
+def frame(data, conv=CONV, **payload_extra):
+ payload = {'is_ok': True, 'data': json.dumps(data), **payload_extra}
+ return json.dumps({'type': 'gw_response', 'conversationId': conv, 'payload': json.dumps(payload)})
+
+
+class TestUssFrameParser(unittest.TestCase):
+ def setUp(self):
+ self.parse = make_uss_frame_parser(CONV)
+
+ def test_terminal_frame(self):
+ data = {'new_secrets': [{'secret_name': 'a'}]}
+ self.assertEqual(self.parse(frame(data)), data)
+
+ def test_ack_ignored(self):
+ self.assertIsNone(self.parse(frame({'Response': 'Queued', 'jobId': CONV})))
+
+ def test_other_conversation_ignored(self):
+ self.assertIsNone(self.parse(frame({'new_secrets': []}, conv='zzzzzzzzzzzzzzzzzzzzzz')))
+
+ def test_progress_frame_ignored(self):
+ self.assertIsNone(self.parse(frame({'new_secrets': []}, progressFlag=2)))
+
+ def test_complete_and_fail_flags_accepted(self):
+ for flag in (0, 1):
+ self.assertEqual(self.parse(frame({'new_secrets': []}, progressFlag=flag)), {'new_secrets': []})
+
+ def test_top_level_progress_flag(self):
+ raw = json.loads(frame({'new_secrets': []}))
+ raw['progressFlag'] = 2
+ self.assertIsNone(self.parse(json.dumps(raw)))
+ raw['progressFlag'] = 0
+ self.assertEqual(self.parse(json.dumps(raw)), {'new_secrets': []})
+
+ def test_progress_then_complete_in_array(self):
+ frames = [json.loads(frame({'new_secrets': ['p']}, progressFlag=2)),
+ json.loads(frame({'new_secrets': ['done']}, progressFlag=0))]
+ self.assertEqual(self.parse(json.dumps(frames)), {'new_secrets': ['done']})
+
+ def test_error_payload(self):
+ raw = json.dumps({'conversationId': CONV, 'payload': json.dumps({'is_ok': False, 'errors': ['boom']})})
+ self.assertIn('boom', self.parse(raw)['error'])
+
+ def test_garbage(self):
+ self.assertIsNone(self.parse('not json'))
+
+
+class TestDryRunPrinter(unittest.TestCase):
+ @staticmethod
+ def render(data):
+ buf = io.StringIO()
+ with redirect_stdout(buf):
+ PAMUniversalSyncRunCommand._print_dry_run_results(data)
+ return buf.getvalue()
+
+ def test_unchanged_present(self):
+ out = self.render({'new_secrets': [], 'unchanged_secrets': [{'secret_name': 'x'}]})
+ self.assertIn('UNCHANGED: x', out)
+
+ def test_unchanged_missing_mentions_version(self):
+ self.assertIn('KDNRM 1.4.42', self.render({'new_secrets': []}))
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/unit-tests/service/test_compose_builder.py b/unit-tests/service/test_compose_builder.py
new file mode 100644
index 000000000..95af978de
--- /dev/null
+++ b/unit-tests/service/test_compose_builder.py
@@ -0,0 +1,537 @@
+# _ __
+# | |/ /___ ___ _ __ ___ _ _ ®
+# | ' -_) -_) '_ \/ -_) '_|
+# |_|\_\___\___| .__/\___|_|
+# |_|
+#
+# Keeper Commander
+# Copyright 2026 Keeper Security Inc.
+# Contact: commander@keepersecurity.com
+#
+
+import copy
+import json
+import os
+import shutil
+import stat
+import subprocess
+import tempfile
+import time
+import unittest
+import uuid
+from unittest import TestCase, mock
+
+from keepercommander.service.docker.compose_builder import DockerComposeBuilder
+
+
+def _base_config(**overrides):
+ config = {
+ 'port': 8900,
+ 'commands': 'tree,ls',
+ 'queue_enabled': True,
+ 'ngrok_enabled': False,
+ 'ngrok_auth_token': '',
+ 'ngrok_custom_domain': '',
+ 'cloudflare_enabled': False,
+ 'cloudflare_tunnel_token': '',
+ 'cloudflare_custom_domain': '',
+ 'tailscale_enabled': False,
+ 'tailscale_auth_key': '',
+ 'tailscale_advertise_tags': '',
+ }
+ config.update(overrides)
+ return config
+
+
+def _build(config, commander_service_name='commander', commander_container_name='keeper-service'):
+ setup_result = mock.Mock(record_uid='rec-uid-123', b64_config='b64==')
+ builder = DockerComposeBuilder(
+ setup_result, config,
+ commander_service_name=commander_service_name,
+ commander_container_name=commander_container_name,
+ )
+ return builder.build_dict()
+
+
+def _command_for(config):
+ return _build(config)['services']['commander']['command']
+
+
+class TestNgrokCloudflareRegression(TestCase):
+ """Adding the Tailscale sidecar must not change existing Ngrok/Cloudflare
+ flag generation or the commander service's ports/network_mode."""
+
+ def test_no_tunnel_enabled_produces_no_tunnel_flags(self):
+ command = _command_for(_base_config())
+ for flag in ('-ng', '-cf', '-ts', '-cd', '-cfd', '-tst'):
+ self.assertNotIn(flag, command)
+
+ def test_ngrok_enabled_produces_ng_and_cd_flags_unchanged(self):
+ result = _build(_base_config(
+ ngrok_enabled=True, ngrok_auth_token='ngrok-tok', ngrok_custom_domain='mydomain'
+ ))
+ command = result['services']['commander']['command']
+ self.assertIn('-ng ngrok-tok', command)
+ self.assertIn('-cd mydomain', command)
+ self.assertNotIn('-cf', command)
+ self.assertNotIn('-ts', command)
+ # Ngrok/Cloudflare use the container's own published port, not the sidecar.
+ self.assertIn('ports', result['services']['commander'])
+ self.assertNotIn('network_mode', result['services']['commander'])
+ self.assertNotIn('tailscale', result['services'])
+
+ def test_cloudflare_enabled_produces_cf_and_cfd_flags_unchanged(self):
+ result = _build(_base_config(
+ cloudflare_enabled=True, cloudflare_tunnel_token='cf-tok', cloudflare_custom_domain='cf.example.com'
+ ))
+ command = result['services']['commander']['command']
+ self.assertIn('-cf cf-tok', command)
+ self.assertIn('-cfd cf.example.com', command)
+ self.assertNotIn('-ng', command)
+ self.assertNotIn('-ts', command)
+ self.assertIn('ports', result['services']['commander'])
+ self.assertNotIn('network_mode', result['services']['commander'])
+ self.assertNotIn('tailscale', result['services'])
+
+
+class TestTailscaleSidecar(TestCase):
+ """Tailscale runs as a sidecar (official tailscale/tailscale image), not a
+ service-create flag -- Docker deployments are headless and can't answer the
+ install/daemon-start prompts -ts would otherwise trigger inside the container."""
+
+ def test_tailscale_enabled_adds_sidecar_and_funnel_init_services(self):
+ result = _build(_base_config(
+ tailscale_enabled=True, tailscale_auth_key='tskey-auth-xxx',
+ tailscale_advertise_tags='tag:commander-service'
+ ), commander_service_name='commander-slack')
+ services = result['services']
+
+ self.assertIn('tailscale', services)
+ self.assertEqual(services['tailscale']['image'], 'tailscale/tailscale:latest')
+ self.assertEqual(services['tailscale']['hostname'], 'commander-slack')
+ self.assertEqual(services['tailscale']['environment']['TS_AUTHKEY'], 'tskey-auth-xxx')
+ self.assertIn('--advertise-tags=tag:commander-service', services['tailscale']['environment']['TS_EXTRA_ARGS'])
+ # No --force-reauth: it would re-validate the key every boot, breaking
+ # single-use keys from boot 2 and expired ones permanently.
+ self.assertNotIn('--force-reauth', services['tailscale']['environment']['TS_EXTRA_ARGS'])
+
+ # Declared, not executed: containerboot applies TS_SERVE_CONFIG itself. The only
+ # hook is print-only (logs the URL, and reads - never mutates - Funnel status).
+ self.assertEqual(services['tailscale']['environment']['TS_SERVE_CONFIG'],
+ '/etc/tailscale/serve.json')
+ hook_cmd = services['tailscale']['post_start'][0]['command'][2]
+ self.assertNotIn('funnel --bg', hook_cmd)
+ self.assertNotIn('funnel reset', hook_cmd)
+ self.assertNotIn('--https=', hook_cmd)
+
+ def test_tailscale_container_name_matches_commander_naming_convention(self):
+ """Without an explicit container_name, Compose falls back to
+ -tailscale-1 (unpredictable/inconsistent). Derive a real name
+ matching the existing keeper-service[-] convention instead."""
+ result = _build(
+ _base_config(tailscale_enabled=True, tailscale_auth_key='tskey-auth-xxx'),
+ commander_service_name='commander-slack',
+ commander_container_name='keeper-service-slack',
+ )
+ self.assertEqual(result['services']['tailscale']['container_name'], 'keeper-tailscale-slack')
+
+ def test_tailscale_container_name_default_no_integration_suffix(self):
+ result = _build(_base_config(tailscale_enabled=True, tailscale_auth_key='tskey-auth-xxx'))
+ self.assertEqual(result['services']['tailscale']['container_name'], 'keeper-tailscale')
+
+ def test_tailscale_enabled_without_tags_still_states_advertise_tags_explicitly(self):
+ """`--advertise-tags` must always be explicit (empty if unused), never omitted -
+ `tailscale up` requires every non-default setting restated on each call or it
+ errors out, and omitting the flag doesn't clear a tag left by a prior run on
+ the same node/volume (e.g. switching from an OAuth key to a plain key)."""
+ result = _build(_base_config(tailscale_enabled=True, tailscale_auth_key='tskey-auth-xxx'))
+ extra_args = result['services']['tailscale']['environment']['TS_EXTRA_ARGS']
+ self.assertIn('--advertise-tags=', extra_args)
+ self.assertNotIn('--advertise-tags=tag', extra_args)
+ self.assertNotIn('--force-reauth', extra_args)
+
+ def test_tailscale_enabled_reconfigures_commander_service_networking(self):
+ result = _build(_base_config(tailscale_enabled=True, tailscale_auth_key='tskey-auth-xxx'))
+ commander = result['services']['commander']
+
+ self.assertNotIn('ports', commander)
+ self.assertEqual(commander['network_mode'], 'service:tailscale')
+ self.assertEqual(commander['depends_on']['tailscale'], {'condition': 'service_healthy'})
+ # Tailscale is not passed as a service-create flag.
+ for flag in ('-ts', '-tst'):
+ self.assertNotIn(flag, commander['command'])
+
+ def test_tailscale_enabled_declares_persistent_state_volume(self):
+ result = _build(_base_config(tailscale_enabled=True, tailscale_auth_key='tskey-auth-xxx'))
+ self.assertIn('tailscale-state', result.get('volumes', {}))
+ self.assertIn('tailscale-state:/var/lib/tailscale', result['services']['tailscale']['volumes'])
+
+ def test_tailscale_enabled_but_no_auth_key_adds_no_sidecar(self):
+ """Mirrors ngrok/cloudflare's own guard - enabled alone isn't enough
+ without the actual credential."""
+ result = _build(_base_config(tailscale_enabled=True, tailscale_auth_key=''))
+ self.assertNotIn('tailscale', result['services'])
+ self.assertNotIn('volumes', result)
+ self.assertIn('ports', result['services']['commander'])
+
+ def test_no_tailscale_produces_no_volumes_or_configs_key(self):
+ result = _build(_base_config())
+ self.assertNotIn('volumes', result)
+ self.assertNotIn('configs', result)
+
+ def _serve_config(self):
+ """Serve config as the container reads it - Compose turns `$$` into `$`."""
+ result = _build(_base_config(tailscale_enabled=True, tailscale_auth_key='tskey-auth-xxx'))
+ raw = result['configs']['tailscale-serve-config']['content']
+ return json.loads(raw.replace('$$', '$'))
+
+ def test_funnel_declared_via_serve_config_not_by_the_hook(self):
+ """Funnel config must come from TS_SERVE_CONFIG, never the hook: the hostname can
+ land after `tailscale up` returns, which once stranded Funnel on a dead name."""
+ sidecar = _build(_base_config(tailscale_enabled=True, tailscale_auth_key='tskey-auth-xxx'))[
+ 'services']['tailscale']
+ self.assertEqual(sidecar['environment']['TS_SERVE_CONFIG'], '/etc/tailscale/serve.json')
+ self.assertEqual(sidecar['configs'],
+ [{'source': 'tailscale-serve-config', 'target': '/etc/tailscale/serve.json'}])
+
+ def _hook(self):
+ return _build(_base_config(tailscale_enabled=True, tailscale_auth_key='tskey-auth-xxx'))[
+ 'services']['tailscale']['post_start'][0]['command'][2]
+
+ def _run_hook(self, allow_funnel_json_body, dns_name='commander-sailpoint-5.taildb15b0.ts.net'):
+ """Runs the real generated hook under a stub `tailscale`. `allow_funnel_json_body`
+ is either one JSON string (every poll returns it) or a list consumed one-per-poll,
+ repeating the last entry once exhausted - lets a test simulate AllowFunnel turning
+ true only after a few retries. Returns stdout written to /proc/1/fd/1's stand-in."""
+ import os
+ import stat
+ import subprocess
+ import tempfile
+
+ bodies = allow_funnel_json_body if isinstance(allow_funnel_json_body, list) \
+ else [allow_funnel_json_body]
+
+ with tempfile.TemporaryDirectory() as bin_dir, tempfile.TemporaryDirectory() as work_dir:
+ sink = os.path.join(work_dir, 'sink.txt')
+ counter = os.path.join(work_dir, 'n')
+ stub_path = os.path.join(bin_dir, 'tailscale')
+ bodies_sh = ' '.join(f"'{b}'" for b in bodies)
+ with open(stub_path, 'w') as f:
+ f.write(
+ "#!/bin/sh\n"
+ "if [ \"$1\" = status ] && [ \"$2\" = \"--json\" ]; then\n"
+ f" echo '{{\"Self\":{{\"DNSName\":\"{dns_name}.\"}}}}'; exit 0\n"
+ "fi\n"
+ "if [ \"$1\" = funnel ] && [ \"$2\" = status ] && [ \"$3\" = \"--json\" ]; then\n"
+ f" N=$(cat '{counter}' 2>/dev/null || echo 0); echo $((N+1)) > '{counter}'\n"
+ f" i=0; for b in {bodies_sh}; do [ \"$i\" -ge \"$N\" ] && break; i=$((i+1)); done\n"
+ " echo \"$b\"; exit 0\n"
+ "fi\n"
+ "exit 0\n"
+ )
+ os.chmod(stub_path, os.stat(stub_path).st_mode | stat.S_IEXEC)
+
+ script = self._hook().replace('$$', '$').replace('/proc/1/fd/1', sink)
+ env = dict(os.environ, PATH=f"{bin_dir}:{os.environ.get('PATH', '')}")
+ subprocess.run(['sh', '-c', script], env=env, timeout=15)
+
+ if not os.path.exists(sink):
+ return ''
+ with open(sink) as f:
+ return f.read()
+
+ def test_url_hook_is_print_only_and_cannot_mutate_funnel(self):
+ """Only a read is allowed - TS_SERVE_CONFIG stays the sole source of truth."""
+ hook = self._hook()
+ self.assertIn('Tailscale Funnel URL: https://', hook)
+ self.assertIn('/proc/1/fd/1', hook) # else the line never reaches docker logs
+ self.assertIn('tailscale funnel status', hook)
+ self.assertNotIn('funnel --bg', hook)
+ self.assertNotIn('funnel reset', hook)
+ self.assertNotIn('--https=', hook)
+
+ def test_url_hook_finishes_well_inside_compose_up(self):
+ """A hook still running when `up` completes gets SIGKILLed and tears the
+ project down - the ~8s cap and clean exit avoid that."""
+ hook = self._hook()
+ self.assertIn('-lt 8', hook)
+ self.assertIn('sleep 1', hook)
+ self.assertTrue(hook.rstrip().endswith('exit 0'))
+ self.assertIn('h=$$(', hook) # Compose-escaped
+ self.assertNotIn('h=$(', hook)
+
+ def test_url_hook_prints_url_only_when_funnel_is_actually_live(self):
+ """A hostname alone isn't enough - must match tunneling.py's AllowFunnel check."""
+ out = self._run_hook(
+ '{"AllowFunnel":{"commander-sailpoint-5.taildb15b0.ts.net:443": true}}')
+ self.assertIn('Tailscale Funnel URL: https://commander-sailpoint-5.taildb15b0.ts.net', out)
+
+ def test_url_hook_tolerates_compact_json_with_no_space_after_colon(self):
+ """Go's JSON encoders don't always add a space after ':' - match must not depend on it."""
+ out = self._run_hook(
+ '{"AllowFunnel":{"commander-sailpoint-5.taildb15b0.ts.net:443":true}}')
+ self.assertIn('Tailscale Funnel URL: https://', out)
+
+ def test_url_hook_falls_back_to_diagnostic_when_allow_funnel_missing(self):
+ out = self._run_hook('{"AllowFunnel":{}}')
+ self.assertNotIn('Tailscale Funnel URL:', out)
+ self.assertIn('Funnel is not active', out)
+ self.assertIn('tailscale funnel status', out)
+
+ def test_url_hook_falls_back_to_diagnostic_when_allow_funnel_is_false(self):
+ """AllowFunnel must be true, not just present - a plain `serve` has a Proxy entry too."""
+ out = self._run_hook(
+ '{"AllowFunnel":{"commander-sailpoint-5.taildb15b0.ts.net:443": false}}')
+ self.assertNotIn('Tailscale Funnel URL:', out)
+ self.assertIn('Funnel is not active', out)
+
+ def test_url_hook_does_not_match_a_different_hosts_allow_funnel_entry(self):
+ out = self._run_hook(
+ '{"AllowFunnel":{"some-other-host.taildb15b0.ts.net:443": true}}')
+ self.assertNotIn('Tailscale Funnel URL:', out)
+ self.assertIn('Funnel is not active', out)
+
+ def test_url_hook_retries_allow_funnel_check_before_falling_back(self):
+ """Regression: TS_SERVE_CONFIG + the ACME fetch behind it can take 30s+ even
+ though the hostname lands in ~2s. The hook used to check AllowFunnel once and
+ give up; it must keep polling instead."""
+ out = self._run_hook([
+ '{"AllowFunnel":{}}',
+ '{"AllowFunnel":{}}',
+ '{"AllowFunnel":{"commander-sailpoint-5.taildb15b0.ts.net:443": true}}',
+ ])
+ self.assertIn('Tailscale Funnel URL: https://', out)
+ self.assertNotIn('Funnel is not active', out)
+
+ def test_serve_config_uses_cert_domain_placeholder_escaped_for_compose(self):
+ """Bare ${TS_CERT_DOMAIN} would be interpolated away by Compose, handing
+ containerboot an empty hostname."""
+ raw = _build(_base_config(tailscale_enabled=True, tailscale_auth_key='tskey-auth-xxx'))[
+ 'configs']['tailscale-serve-config']['content']
+ self.assertIn('$${TS_CERT_DOMAIN}:443', raw)
+ self.assertNotIn('${TS_CERT_DOMAIN}', raw.replace('$${TS_CERT_DOMAIN}', ''))
+
+ def test_serve_config_declares_funnel_for_the_commander_port(self):
+ cfg = self._serve_config()
+ host_port = '${TS_CERT_DOMAIN}:443'
+ self.assertEqual(cfg['TCP'], {'443': {'HTTPS': True}})
+ self.assertEqual(cfg['Web'][host_port]['Handlers']['/']['Proxy'], 'http://localhost:8900')
+ # AllowFunnel is what makes this public Funnel, not tailnet-only Serve
+ self.assertTrue(cfg['AllowFunnel'][host_port])
+
+ def test_sidecar_healthcheck_stays_a_plain_liveness_probe(self):
+ """Gating it on extra state once wedged the sidecar permanently unhealthy."""
+ sidecar = _build(_base_config(tailscale_enabled=True, tailscale_auth_key='tskey-auth-xxx'))[
+ 'services']['tailscale']
+ self.assertEqual(sidecar['healthcheck']['test'], ['CMD', 'tailscale', 'status'])
+
+ def test_sidecar_healthcheck_tolerates_a_slow_first_login(self):
+ """A key-type switch burns `tailscale up`'s 60s timeout plus a tailscaled
+ restart; without start_period those failures could mark the sidecar unhealthy
+ and abort commander's depends_on wait."""
+ hc = _build(_base_config(tailscale_enabled=True, tailscale_auth_key='tskey-auth-xxx'))[
+ 'services']['tailscale']['healthcheck']
+ self.assertEqual(hc['start_period'], '120s')
+
+ def test_sidecar_gets_network_alias_for_commander_service_name(self):
+ """network_mode: service:tailscale means commander isn't a named member of
+ the compose network on its own - hostname: on the sidecar doesn't register
+ a DNS alias either, so a sibling integration container resolving e.g.
+ commander-slack: would fail without this."""
+ result = _build(
+ _base_config(tailscale_enabled=True, tailscale_auth_key='tskey-auth-xxx'),
+ commander_service_name='commander-slack',
+ )
+ aliases = result['services']['tailscale']['networks']['default']['aliases']
+ self.assertIn('commander-slack', aliases)
+
+ def test_sidecar_publishes_the_host_port_commander_lost(self):
+ """commander's own 127.0.0.1:: binding is popped when Tailscale
+ is enabled (it no longer has its own network identity) - printer.py's
+ unconditional 'curl http://localhost:/health' guidance only keeps
+ working if something still publishes that port, so the sidecar does."""
+ result = _build(_base_config(tailscale_enabled=True, tailscale_auth_key='tskey-auth-xxx'))
+ self.assertIn('127.0.0.1:8900:8900', result['services']['tailscale']['ports'])
+
+ def test_integration_service_combined_with_tailscale(self):
+ """No existing test combined an integration service with Tailscale enabled -
+ this is the exact scenario Blocker 2 was found in: the sidecar's network
+ alias must be present so a sibling integration container can still resolve
+ the commander service by its original name."""
+ setup_result = mock.Mock(record_uid='rec-uid-123', b64_config='b64==')
+ builder = DockerComposeBuilder(
+ setup_result,
+ _base_config(tailscale_enabled=True, tailscale_auth_key='tskey-auth-xxx'),
+ commander_service_name='commander-slack',
+ commander_container_name='keeper-service-slack',
+ )
+ builder.add_integration_service(
+ 'slack-app', 'keeper-slack-app', 'keeper/slack-app:latest', 'rec-uid-123', 'SLACK_RECORD_UID',
+ )
+ result = builder.build_dict()
+
+ self.assertIn('tailscale', result['services'])
+ self.assertIn('slack-app', result['services'])
+ aliases = result['services']['tailscale']['networks']['default']['aliases']
+ self.assertIn('commander-slack', aliases)
+ # The integration service's own depends_on/networking is untouched by Tailscale.
+ self.assertEqual(
+ result['services']['slack-app']['depends_on'], {'commander-slack': {'condition': 'service_healthy'}}
+ )
+
+
+DOCKER_SIGNIN_REQUIRED_MARKER = "Sign in to continue using Docker Desktop"
+
+
+def _docker_compose_available():
+ """True only if containers can actually run, not just that the `docker` binary
+ or `docker info`/`compose version` succeed - confirmed experimentally that a
+ Docker Desktop instance requiring org sign-in still returns exit 0 for those two,
+ while `docker ps` (and any real container operation) fails identically to `up`."""
+ if shutil.which('docker') is None:
+ return False
+ try:
+ result = subprocess.run(['docker', 'ps'], capture_output=True, text=True, timeout=10)
+ return result.returncode == 0
+ except Exception:
+ return False
+
+
+@unittest.skipUnless(
+ _docker_compose_available(),
+ "requires a running Docker Engine with the Compose v2 plugin")
+class TestTailscaleSidecarDockerSmoke(TestCase):
+ """A real `docker compose up`, not mocks - covers the PR review's concern that the
+ sidecar path (TS_SERVE_CONFIG, Compose lifecycle, hostname timing) could break on
+ a slow first login. Only `tailscale`/`commander` images are swapped for a stub
+ (busybox + fake `tailscale` CLI); post_start/healthcheck/networks/configs are
+ exactly what production generates."""
+
+ def _write_stub_tailscale(self, bin_dir, dns_delay_s):
+ """`tailscale status` only exits 0 once dns_delay_s has elapsed, matching real
+ containerboot - a stub that always exits 0 would hide a real slow-login case."""
+ script = f"""#!/bin/sh
+STATE_FILE=/tmp/.smoke_start_time
+[ -f "$STATE_FILE" ] || date +%s > "$STATE_FILE"
+ELAPSED=$(($(date +%s) - $(cat "$STATE_FILE")))
+
+if [ "$1" = "status" ] && [ "$2" = "--json" ]; then
+ if [ "$ELAPSED" -ge {dns_delay_s} ]; then
+ echo '{{"Self":{{"DNSName":"smoketest-host.ts.net."}}}}'
+ else
+ echo '{{"Self":{{}}}}'
+ fi
+ exit 0
+fi
+
+if [ "$1" = "funnel" ] && [ "$2" = "status" ] && [ "$3" = "--json" ]; then
+ if [ "$ELAPSED" -ge {dns_delay_s} ]; then
+ echo '{{"AllowFunnel":{{"smoketest-host.ts.net:443": true}}}}'
+ else
+ echo '{{"AllowFunnel":{{}}}}'
+ fi
+ exit 0
+fi
+
+if [ "$1" = "status" ]; then
+ [ "$ELAPSED" -ge {dns_delay_s} ] && exit 0 || exit 1
+fi
+exit 0
+"""
+ stub_path = os.path.join(bin_dir, 'tailscale')
+ with open(stub_path, 'w') as f:
+ f.write(script)
+ os.chmod(stub_path, os.stat(stub_path).st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
+
+ def _patched_compose_dict(self, bin_dir):
+ """Real DockerComposeBuilder output, Tailscale enabled - only image/entrypoint
+ swapped for the tailscale/commander services. post_start, healthcheck,
+ networks, and configs are untouched, since those are what's under test."""
+ config = {
+ 'port': 18900,
+ 'commands': 'ls',
+ 'tailscale_enabled': True,
+ 'tailscale_auth_key': 'tskey-auth-smoketest-not-real',
+ }
+
+ class _FakeSetupResult:
+ b64_config = 'ZmFrZS1rc20tY29uZmln'
+ record_uid = 'smoke-test-record-uid'
+
+ compose = DockerComposeBuilder(_FakeSetupResult(), config).build_dict()
+ compose = copy.deepcopy(compose)
+
+ ts = compose['services']['tailscale']
+ ts['image'] = 'busybox:stable'
+ ts['healthcheck']['interval'] = '2s'
+ ts['healthcheck']['retries'] = 90
+ ts.setdefault('volumes', []).append(f'{bin_dir}:/usr/local/bin:ro')
+ ts['entrypoint'] = ['sh', '-c']
+ ts['command'] = ['tail -f /dev/null']
+
+ cmd = compose['services']['commander']
+ cmd['image'] = 'busybox:stable'
+ cmd.pop('healthcheck', None)
+ cmd.pop('command', None)
+ cmd['entrypoint'] = ['sh', '-c']
+ cmd['command'] = ['tail -f /dev/null']
+
+ return compose
+
+ def test_sidecar_survives_a_slow_first_login_without_tearing_down(self):
+ """A slow login (e.g. a key-type switch burning `tailscale up`'s 60s timeout)
+ must not SIGKILL the hook and tear the project down. Login forced to land at
+ 15s, past the hook's own ~8s bound, to test Compose's lifecycle, not the hook."""
+ import yaml
+
+ DNS_DELAY_S = 15
+ project = f"ts-smoke-{uuid.uuid4().hex[:8]}"
+ work_dir = tempfile.mkdtemp(prefix='tailscale_smoke_')
+ bin_dir = os.path.join(work_dir, 'bin')
+ os.makedirs(bin_dir)
+ compose_path = os.path.join(work_dir, 'docker-compose.yml')
+
+ try:
+ self._write_stub_tailscale(bin_dir, DNS_DELAY_S)
+ with open(compose_path, 'w') as f:
+ yaml.dump(self._patched_compose_dict(bin_dir), f,
+ default_flow_style=False, sort_keys=False)
+
+ up = subprocess.run(
+ ['docker', 'compose', '-p', project, '-f', compose_path, 'up', '-d'],
+ capture_output=True, text=True, timeout=60)
+ if DOCKER_SIGNIN_REQUIRED_MARKER in up.stderr:
+ self.skipTest("Docker Desktop requires sign-in in this environment "
+ "(flipped after the module-level availability check)")
+ self.assertEqual(up.returncode, 0, f"`compose up` failed: {up.stderr}")
+
+ # Past the hook's ~8s bound and the forced 15s login, before asserting.
+ time.sleep(DNS_DELAY_S + 10)
+
+ logs = subprocess.run(
+ ['docker', 'compose', '-p', project, '-f', compose_path,
+ 'logs', '--no-log-prefix', 'tailscale'],
+ capture_output=True, text=True, timeout=30).stdout
+
+ ps = subprocess.run(
+ ['docker', 'compose', '-p', project, '-f', compose_path,
+ 'ps', '-a', '--format', '{{.Service}}:{{.State}}'],
+ capture_output=True, text=True, timeout=30).stdout
+
+ self.assertNotIn('137', logs, f"hook was SIGKILLed - logs:\n{logs}")
+ self.assertIn('tailscale', ps, f"project was torn down - `ps`: {ps}")
+ self.assertIn('commander', ps, f"project was torn down - `ps`: {ps}")
+ self.assertNotIn('exited', ps.lower(), f"a service exited unexpectedly:\n{ps}")
+ # DNS_DELAY_S (15s) exceeds the hook's ~8s bound, so it exhausts and prints
+ # nothing - expected. Retry behavior itself is covered faster by
+ # test_url_hook_retries_allow_funnel_check_before_falling_back above.
+ self.assertNotIn('Tailscale Funnel URL: https://', logs,
+ f"hook should not have had time to see the hostname at all:\n{logs}")
+ finally:
+ subprocess.run(['docker', 'compose', '-p', project, '-f', compose_path, 'down', '-v'],
+ capture_output=True, timeout=30)
+ shutil.rmtree(work_dir, ignore_errors=True)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/unit-tests/service/test_create_service.py b/unit-tests/service/test_create_service.py
index c6ca781a8..5e6583eb3 100644
--- a/unit-tests/service/test_create_service.py
+++ b/unit-tests/service/test_create_service.py
@@ -39,7 +39,7 @@ def test_execute_service_already_running(self, mock_service_manager):
def test_handle_configuration_streamlined(self):
"""Test streamlined configuration handling."""
config_data = self.command.service_config.create_default_config()
- args = StreamlineArgs(port=8080, commands='record-list', ngrok=None, allowedip='0.0.0.0' ,deniedip='', ngrok_custom_domain=None, cloudflare=None, cloudflare_custom_domain=None, certfile='', certpassword='', fileformat='json', run_mode='foreground', queue_enabled='y', update_vault_record=None, ratelimit=None, encryption_key=None, token_expiration=None)
+ args = StreamlineArgs(port=8080, commands='record-list', ngrok=None, allowedip='0.0.0.0' ,deniedip='', ngrok_custom_domain=None, cloudflare=None, cloudflare_custom_domain=None, tailscale=None, tailscale_advertise_tags=None, certfile='', certpassword='', fileformat='json', run_mode='foreground', queue_enabled='y', update_vault_record=None, ratelimit=None, encryption_key=None, token_expiration=None)
with patch.object(self.command.config_handler, 'handle_streamlined_config') as mock_streamlined:
self.command._handle_configuration(config_data, self.params, args)
@@ -48,7 +48,7 @@ def test_handle_configuration_streamlined(self):
def test_handle_configuration_interactive(self):
"""Test interactive configuration handling."""
config_data = self.command.service_config.create_default_config()
- args = StreamlineArgs(port=None, commands=None, ngrok=None, allowedip='' ,deniedip='', ngrok_custom_domain=None, cloudflare=None, cloudflare_custom_domain=None, certfile='', certpassword='', fileformat='json', run_mode='foreground', queue_enabled=None, update_vault_record=None, ratelimit=None, encryption_key=None, token_expiration=None)
+ args = StreamlineArgs(port=None, commands=None, ngrok=None, allowedip='' ,deniedip='', ngrok_custom_domain=None, cloudflare=None, cloudflare_custom_domain=None, tailscale=None, tailscale_advertise_tags=None, certfile='', certpassword='', fileformat='json', run_mode='foreground', queue_enabled=None, update_vault_record=None, ratelimit=None, encryption_key=None, token_expiration=None)
with patch.object(self.command.config_handler, 'handle_interactive_config') as mock_interactive, \
patch.object(self.command.security_handler, 'configure_security') as mock_security:
@@ -59,7 +59,7 @@ def test_handle_configuration_interactive(self):
def test_create_and_save_record(self):
"""Test record creation and saving."""
config_data = self.command.service_config.create_default_config()
- args = StreamlineArgs(port=8080, commands='record-list', ngrok=None, allowedip='0.0.0.0' ,deniedip='', ngrok_custom_domain=None, cloudflare=None, cloudflare_custom_domain=None, certfile='', certpassword='', fileformat='json', run_mode='foreground', queue_enabled='y', update_vault_record=None, ratelimit=None, encryption_key=None, token_expiration=None)
+ args = StreamlineArgs(port=8080, commands='record-list', ngrok=None, allowedip='0.0.0.0' ,deniedip='', ngrok_custom_domain=None, cloudflare=None, cloudflare_custom_domain=None, tailscale=None, tailscale_advertise_tags=None, certfile='', certpassword='', fileformat='json', run_mode='foreground', queue_enabled='y', update_vault_record=None, ratelimit=None, encryption_key=None, token_expiration=None)
with patch.object(self.command.service_config, 'create_record') as mock_create_record, \
patch.object(self.command.service_config, 'save_config') as mock_save_config:
@@ -82,7 +82,7 @@ def test_create_and_save_record(self):
def test_validation_error_handling(self):
"""Test handling of validation errors during execution."""
- args = StreamlineArgs(port=-1, commands='record-list', ngrok=None, allowedip='0.0.0.0' ,deniedip='', ngrok_custom_domain=None, cloudflare=None, cloudflare_custom_domain=None, certfile='', certpassword='', fileformat='json', run_mode='foreground', queue_enabled='y', update_vault_record=None, ratelimit=None, encryption_key=None, token_expiration=None)
+ args = StreamlineArgs(port=-1, commands='record-list', ngrok=None, allowedip='0.0.0.0' ,deniedip='', ngrok_custom_domain=None, cloudflare=None, cloudflare_custom_domain=None, tailscale=None, tailscale_advertise_tags=None, certfile='', certpassword='', fileformat='json', run_mode='foreground', queue_enabled='y', update_vault_record=None, ratelimit=None, encryption_key=None, token_expiration=None)
with patch('builtins.print') as mock_print:
with patch.object(self.command.service_config, 'create_default_config') as mock_create_config:
@@ -103,6 +103,8 @@ def test_cloudflare_streamlined_configuration(self):
ngrok_custom_domain=None,
cloudflare='cf_token123',
cloudflare_custom_domain='tunnel.example.com',
+ tailscale=None,
+ tailscale_advertise_tags=None,
certfile='',
certpassword='',
fileformat='json',
@@ -129,6 +131,8 @@ def test_cloudflare_validation_missing_token(self):
ngrok_custom_domain=None,
cloudflare=None,
cloudflare_custom_domain='tunnel.example.com',
+ tailscale=None,
+ tailscale_advertise_tags=None,
certfile='',
certpassword='',
fileformat='json',
@@ -157,6 +161,8 @@ def test_cloudflare_validation_missing_domain(self):
ngrok_custom_domain=None,
cloudflare='cf_token123',
cloudflare_custom_domain=None,
+ tailscale=None,
+ tailscale_advertise_tags=None,
certfile='',
certpassword='',
fileformat='json',
@@ -185,6 +191,8 @@ def test_cloudflare_and_ngrok_mutual_exclusion(self):
ngrok_custom_domain='ngrok.example.com',
cloudflare='cf_token123',
cloudflare_custom_domain='tunnel.example.com',
+ tailscale=None,
+ tailscale_advertise_tags=None,
certfile='',
certpassword='',
fileformat='json',
@@ -224,6 +232,8 @@ def test_cloudflare_tunnel_startup_success(self, mock_cloudflare_configure):
ngrok_custom_domain=None,
cloudflare='cf_token123',
cloudflare_custom_domain='tunnel.example.com',
+ tailscale=None,
+ tailscale_advertise_tags=None,
certfile='',
certpassword='',
fileformat='json',
@@ -274,6 +284,8 @@ def test_cloudflare_tunnel_startup_failure(self, mock_get_status, mock_start_ser
ngrok_custom_domain=None,
cloudflare='cf_token123',
cloudflare_custom_domain='tunnel.example.com',
+ tailscale=None,
+ tailscale_advertise_tags=None,
certfile='',
certpassword='',
fileformat='json',
@@ -300,6 +312,8 @@ def test_cloudflare_token_validation(self):
ngrok_custom_domain=None,
cloudflare='eyJhIjoiYWJjZGVmZ2hpams', # Base64-like token
cloudflare_custom_domain='tunnel.example.com',
+ tailscale=None,
+ tailscale_advertise_tags=None,
certfile='',
certpassword='',
fileformat='json',
@@ -328,6 +342,8 @@ def test_cloudflare_domain_validation(self):
ngrok_custom_domain=None,
cloudflare='cf_token123',
cloudflare_custom_domain='my-tunnel.example.com',
+ tailscale=None,
+ tailscale_advertise_tags=None,
certfile='',
certpassword='',
fileformat='json',
@@ -344,5 +360,223 @@ def test_cloudflare_domain_validation(self):
self.command._handle_configuration(config_data, self.params, args)
mock_streamlined.assert_called_once_with(config_data, args, self.params)
+ def test_get_parser_tailscale(self):
+ """Test that -ts alone carries the auth key value, with no separate -tsk flag."""
+ parser = self.command.get_parser()
+
+ args = parser.parse_args(['--tailscale', 'tskey-auth-dummy'])
+ self.assertEqual(args.tailscale, 'tskey-auth-dummy')
+ self.assertFalse(hasattr(args, 'tailscale_auth_key'))
+
+ args = parser.parse_args(['-ts', 'tskey-auth-dummy', '-tst', 'tag:commander-service'])
+ self.assertEqual(args.tailscale, 'tskey-auth-dummy')
+ self.assertEqual(args.tailscale_advertise_tags, 'tag:commander-service')
+
+ def test_tailscale_streamlined_configuration(self):
+ """Test streamlined configuration with Tailscale, -ts alone enabling it."""
+ config_data = self.command.service_config.create_default_config()
+ args = StreamlineArgs(
+ port=8080,
+ commands='record-list',
+ ngrok=None,
+ allowedip='0.0.0.0',
+ deniedip='',
+ ngrok_custom_domain=None,
+ cloudflare=None,
+ cloudflare_custom_domain=None,
+ tailscale='tskey-auth-dummy',
+ tailscale_advertise_tags=None,
+ certfile='',
+ certpassword='',
+ fileformat='json',
+ run_mode='foreground',
+ queue_enabled='y',
+ update_vault_record=None,
+ ratelimit=None,
+ encryption_key=None,
+ token_expiration=None
+ )
+
+ self.command.config_handler.handle_streamlined_config(config_data, args, self.params)
+ self.assertEqual(config_data['tailscale'], 'y')
+ self.assertEqual(config_data['tailscale_auth_key'], 'tskey-auth-dummy')
+
+ def test_tailscale_advertise_tags_streamlined(self):
+ """Test that -tst is threaded through to the internal config alongside -ts."""
+ config_data = self.command.service_config.create_default_config()
+ args = StreamlineArgs(
+ port=8080,
+ commands='record-list',
+ ngrok=None,
+ allowedip='0.0.0.0',
+ deniedip='',
+ ngrok_custom_domain=None,
+ cloudflare=None,
+ cloudflare_custom_domain=None,
+ tailscale='tskey-client-dummy',
+ tailscale_advertise_tags='tag:commander-service',
+ certfile='',
+ certpassword='',
+ fileformat='json',
+ run_mode='foreground',
+ queue_enabled='y',
+ update_vault_record=None,
+ ratelimit=None,
+ encryption_key=None,
+ token_expiration=None
+ )
+
+ self.command.config_handler.handle_streamlined_config(config_data, args, self.params)
+ self.assertEqual(config_data['tailscale_advertise_tags'], 'tag:commander-service')
+
+ def test_tailscale_omitted_disables_it(self):
+ """Test that omitting -ts disables Tailscale without requiring any other flag."""
+ config_data = self.command.service_config.create_default_config()
+ args = StreamlineArgs(
+ port=8080,
+ commands='record-list',
+ ngrok=None,
+ allowedip='0.0.0.0',
+ deniedip='',
+ ngrok_custom_domain=None,
+ cloudflare=None,
+ cloudflare_custom_domain=None,
+ tailscale=None,
+ tailscale_advertise_tags=None,
+ certfile='',
+ certpassword='',
+ fileformat='json',
+ run_mode='foreground',
+ queue_enabled='y',
+ update_vault_record=None,
+ ratelimit=None,
+ encryption_key=None,
+ token_expiration=None
+ )
+
+ self.command.config_handler.handle_streamlined_config(config_data, args, self.params)
+ self.assertEqual(config_data['tailscale'], 'n')
+ self.assertEqual(config_data['tailscale_auth_key'], '')
+
+ def test_tailscale_and_ngrok_mutual_exclusion(self):
+ """Test that Ngrok takes priority and disables Tailscale, matching the Cloudflare/Ngrok exclusion pattern."""
+ config_data = self.command.service_config.create_default_config()
+ args = StreamlineArgs(
+ port=8080,
+ commands='record-list',
+ ngrok='ngrok_token123',
+ allowedip='0.0.0.0',
+ deniedip='',
+ ngrok_custom_domain='ngrok.example.com',
+ cloudflare=None,
+ cloudflare_custom_domain=None,
+ tailscale='tskey-auth-dummy',
+ tailscale_advertise_tags=None,
+ certfile='',
+ certpassword='',
+ fileformat='json',
+ run_mode='foreground',
+ queue_enabled='y',
+ update_vault_record=None,
+ ratelimit=None,
+ encryption_key=None,
+ token_expiration=None
+ )
+
+ self.command.config_handler.handle_streamlined_config(config_data, args, self.params)
+ self.assertEqual(config_data['ngrok'], 'y')
+ self.assertEqual(config_data['tailscale'], 'n')
+ self.assertEqual(config_data['tailscale_auth_key'], '')
+
+
+class TestGetServiceUrl(unittest.TestCase):
+ """_get_service_url (restored, release-branch-matching, plus one additive
+ tailscale branch): ngrok > cloudflare > tailscale > localhost."""
+
+ def setUp(self):
+ self.command = CreateService()
+
+ def test_tailscale_url_used_when_no_ngrok_or_cloudflare(self):
+ config_data = {'tailscale_public_url': 'https://node.example.ts.net', 'queue_enabled': 'y'}
+ self.assertEqual(self.command._get_service_url(config_data), 'https://node.example.ts.net/api/v2')
+
+ def test_ngrok_still_takes_priority_over_tailscale(self):
+ config_data = {
+ 'ngrok_public_url': 'https://abc.ngrok.io',
+ 'tailscale_public_url': 'https://node.example.ts.net',
+ 'queue_enabled': 'y',
+ }
+ self.assertEqual(self.command._get_service_url(config_data), 'https://abc.ngrok.io/api/v2')
+
+ def test_falls_back_to_localhost_when_nothing_set(self):
+ config_data = {'port': 8080, 'queue_enabled': 'y'}
+ self.assertEqual(self.command._get_service_url(config_data), 'http://localhost:8080/api/v2')
+
+
+class TestVaultMetadataWriteTiming(unittest.TestCase):
+ """Matches the release branch exactly (confirmed via `git show release:...`):
+ vault metadata is written synchronously, unconditionally, for every tunnel
+ type right after record creation - no deferred/pending-metadata mechanism."""
+
+ def setUp(self):
+ self.params = Mock(spec=KeeperParams)
+ self.command = CreateService()
+
+ _FULL_EXECUTE_KWARGS = dict(
+ port=8080, allowedip=None, deniedip=None, commands='record-list', ngrok=None,
+ ngrok_custom_domain=None, cloudflare=None, cloudflare_custom_domain=None,
+ tailscale=None, tailscale_advertise_tags=None, certfile=None, certpassword=None,
+ fileformat='json', run_mode='foreground', queue_enabled='y', update_vault_record=None,
+ ratelimit=None, encryption_key=None, token_expiration=None,
+ )
+
+ def test_execute_writes_ngrok_metadata_synchronously(self):
+ with patch('keepercommander.service.core.service_manager.ServiceManager') as mock_sm, \
+ patch.object(self.command, '_handle_configuration'), \
+ patch.object(self.command, '_create_and_save_record', return_value='test-key'), \
+ patch.object(self.command, '_get_service_url', return_value='https://abc.ngrok.io/api/v2'), \
+ patch('keepercommander.service.commands.integrations.vault_metadata.write_service_metadata') as mock_write, \
+ patch.object(self.command, '_upload_and_start_service'), \
+ patch.object(self.command.service_config, 'create_default_config') as mock_config:
+ mock_sm.get_status.return_value = "No Commander Service is running currently"
+ mock_config.return_value = {'tailscale': 'n'}
+ kwargs = dict(self._FULL_EXECUTE_KWARGS, ngrok='tok', update_vault_record='record-uid-1')
+ self.command.execute(self.params, **kwargs)
+ mock_write.assert_called_once_with(self.params, 'record-uid-1', 'https://abc.ngrok.io/api/v2', 'test-key')
+
+ def test_execute_writes_tailscale_metadata_synchronously_too(self):
+ """Unconditional, matching release - no tailscale-specific gating."""
+ with patch('keepercommander.service.core.service_manager.ServiceManager') as mock_sm, \
+ patch.object(self.command, '_handle_configuration'), \
+ patch.object(self.command, '_create_and_save_record', return_value='test-key'), \
+ patch.object(self.command, '_get_service_url', return_value='http://localhost:8080/api/v2'), \
+ patch('keepercommander.service.commands.integrations.vault_metadata.write_service_metadata') as mock_write, \
+ patch.object(self.command, '_upload_and_start_service'), \
+ patch.object(self.command.service_config, 'create_default_config') as mock_config:
+ mock_sm.get_status.return_value = "No Commander Service is running currently"
+ mock_config.return_value = {'tailscale': 'y'}
+ kwargs = dict(self._FULL_EXECUTE_KWARGS, tailscale='tskey-auth-xxx', update_vault_record='record-uid-1')
+ self.command.execute(self.params, **kwargs)
+ mock_write.assert_called_once_with(self.params, 'record-uid-1', 'http://localhost:8080/api/v2', 'test-key')
+
+ def test_create_and_save_record_has_no_metadata_side_effects(self):
+ """No set_pending_vault_metadata or any other global state - record
+ creation/saving only, matching release exactly."""
+ config_data = self.command.service_config.create_default_config()
+ args = StreamlineArgs(
+ port=8080, commands='record-list', ngrok=None, allowedip='0.0.0.0', deniedip='',
+ ngrok_custom_domain=None, cloudflare=None, cloudflare_custom_domain=None,
+ tailscale='tskey-auth-xxx', tailscale_advertise_tags=None, certfile='', certpassword='',
+ fileformat='json', run_mode='foreground', queue_enabled='y',
+ update_vault_record='record-uid-1', ratelimit=None, encryption_key=None,
+ token_expiration=None,
+ )
+ with patch.object(self.command.service_config, 'create_record') as mock_create_record, \
+ patch.object(self.command.service_config, 'save_config'):
+ mock_create_record.return_value = {'api-key': 'test-key'}
+ result = self.command._create_and_save_record(config_data, self.params, args)
+ self.assertEqual(result, 'test-key')
+
+
if __name__ == '__main__':
unittest.main()
\ No newline at end of file
diff --git a/unit-tests/service/test_service_manager.py b/unit-tests/service/test_service_manager.py
index aad1e049b..71182d6a3 100644
--- a/unit-tests/service/test_service_manager.py
+++ b/unit-tests/service/test_service_manager.py
@@ -14,6 +14,7 @@
_PROCESS_INFO_ENV_KEYS = (
'KEEPER_SERVICE_PID', 'KEEPER_SERVICE_TERMINAL', 'KEEPER_SERVICE_IS_RUNNING',
'KEEPER_SERVICE_NGROK_PID', 'KEEPER_SERVICE_CLOUDFLARE_PID',
+ 'KEEPER_SERVICE_TAILSCALE_ENABLED', 'KEEPER_SERVICE_TAILSCALE_PORT',
)
@@ -326,3 +327,150 @@ def test_start_service_ngrok_configure_failure_is_handled_gracefully(self):
mock_configure_ngrok.assert_called_once()
mock_spawn.assert_not_called()
self.assertFalse(ProcessInfo._env_file.exists())
+
+ def test_start_service_tailscale_configure_failure_stops_already_live_funnel(self):
+ """Funnel may already be live before configure_tailscale raises (e.g. a
+ start-verification timeout) - tailscale_enabled/tailscale_port are now set
+ before the call specifically so this rollback can still happen, instead of
+ leaving a publicly-exposed Funnel target with nothing behind it."""
+ with mock.patch('keepercommander.service.core.service_manager.ServiceConfig') as mock_config, \
+ mock.patch('keepercommander.service.config.tailscale_config.TailscaleConfigurator.configure_tailscale',
+ side_effect=RuntimeError("Tailscale Funnel did not become active")) as mock_configure_tailscale, \
+ mock.patch('keepercommander.service.util.tunneling.get_tailscale_funnel_status', return_value=True), \
+ mock.patch('keepercommander.service.util.tunneling.stop_tailscale_funnel') as mock_stop_funnel, \
+ mock.patch('keepercommander.service.core.service_manager.spawn_detached_process') as mock_spawn:
+ mock_config.return_value.load_config.return_value = {
+ "port": 8000, "run_mode": "background", "tailscale": "y"
+ }
+
+ start_cmd = StartService()
+ start_cmd.execute(self.params) # must not raise
+
+ mock_configure_tailscale.assert_called_once()
+ mock_stop_funnel.assert_called_once_with(8000)
+ mock_spawn.assert_not_called()
+
+ def test_start_service_corrects_vault_metadata_with_real_tailscale_url(self):
+ """-ur writes a localhost fallback at record-creation time, before Funnel
+ starts. Once configure_tailscale discovers the real URL, start_service must
+ correct the vault record - the only in-process point where both are known."""
+ with mock.patch('keepercommander.service.core.service_manager.ServiceConfig') as mock_config, \
+ mock.patch('keepercommander.service.config.tailscale_config.TailscaleConfigurator.configure_tailscale',
+ side_effect=lambda config_data, _sc: config_data.update(
+ tailscale_public_url='https://my-host.ts.net')), \
+ mock.patch('keepercommander.service.core.service_manager.spawn_detached_process'), \
+ mock.patch('keepercommander.service.commands.integrations.vault_metadata.write_service_metadata'
+ ) as mock_write:
+ mock_config.return_value.load_config.return_value = {
+ "port": 8000, "run_mode": "background", "tailscale": "y", "queue_enabled": "y"
+ }
+
+ ServiceManager.start_service(params=self.params, vault_record_uid='rec-1', api_key='key-1')
+
+ mock_write.assert_called_once_with(
+ self.params, 'rec-1', 'https://my-host.ts.net/api/v2', 'key-1'
+ )
+
+ def test_start_service_skips_vault_correction_when_record_or_key_missing(self):
+ """service-start (resume) calls start_service with no vault context - must stay
+ inert, not raise, since there's nothing to correct."""
+ with mock.patch('keepercommander.service.core.service_manager.ServiceConfig') as mock_config, \
+ mock.patch('keepercommander.service.config.tailscale_config.TailscaleConfigurator.configure_tailscale',
+ side_effect=lambda config_data, _sc: config_data.update(
+ tailscale_public_url='https://my-host.ts.net')), \
+ mock.patch('keepercommander.service.core.service_manager.spawn_detached_process'), \
+ mock.patch('keepercommander.service.commands.integrations.vault_metadata.write_service_metadata'
+ ) as mock_write:
+ mock_config.return_value.load_config.return_value = {
+ "port": 8000, "run_mode": "background", "tailscale": "y", "queue_enabled": "y"
+ }
+
+ ServiceManager.start_service() # no params/vault_record_uid/api_key
+
+ mock_write.assert_not_called()
+
+ def test_start_service_tailscale_auth_failure_before_funnel_starts_skips_teardown(self):
+ """A bad-key/auth failure happens before Funnel is ever started - must not
+ call stop_tailscale_funnel (and risk its 'reset' fallback) when there's
+ nothing actually live to tear down."""
+ with mock.patch('keepercommander.service.core.service_manager.ServiceConfig') as mock_config, \
+ mock.patch('keepercommander.service.config.tailscale_config.TailscaleConfigurator.configure_tailscale',
+ side_effect=RuntimeError("Tailscale authentication failed")), \
+ mock.patch('keepercommander.service.util.tunneling.get_tailscale_funnel_status', return_value=False), \
+ mock.patch('keepercommander.service.util.tunneling.stop_tailscale_funnel') as mock_stop_funnel, \
+ mock.patch('keepercommander.service.core.service_manager.spawn_detached_process') as mock_spawn:
+ mock_config.return_value.load_config.return_value = {
+ "port": 8000, "run_mode": "background", "tailscale": "y"
+ }
+
+ start_cmd = StartService()
+ start_cmd.execute(self.params) # must not raise
+
+ mock_stop_funnel.assert_not_called()
+ mock_spawn.assert_not_called()
+ self.assertFalse(ProcessInfo._env_file.exists())
+
+ def test_service_status_reports_dangling_tailscale_funnel_without_stopping_it(self):
+ """A crashed/SIGKILLed Commander process can leave Funnel still live - a
+ read-only status check must report that, not tear it down itself; cleanup
+ is left to an explicit 'service-stop'."""
+ import psutil as psutil_module
+ ProcessInfo.save(pid=12345, is_running=True, tailscale_enabled=True, tailscale_port=8000)
+
+ with mock.patch('psutil.Process', side_effect=psutil_module.NoSuchProcess(12345)), \
+ mock.patch('keepercommander.service.util.tunneling.get_tailscale_funnel_status', return_value=True), \
+ mock.patch('keepercommander.service.util.tunneling.stop_tailscale_funnel') as mock_stop_funnel:
+ status = ServiceManager.get_status()
+
+ mock_stop_funnel.assert_not_called()
+ self.assertIn("Tailscale Funnel is still active", status)
+ self.assertIn("service-stop", status)
+
+ def test_service_status_keeps_process_info_when_funnel_still_dangling(self):
+ """The status message tells the user to run 'service-stop' - that's only
+ actionable if the tailscale_port record survives, so ProcessInfo must NOT
+ be cleared while a Funnel is still live."""
+ import psutil as psutil_module
+ ProcessInfo.save(pid=12345, is_running=True, tailscale_enabled=True, tailscale_port=8000)
+
+ with mock.patch('psutil.Process', side_effect=psutil_module.NoSuchProcess(12345)), \
+ mock.patch('keepercommander.service.util.tunneling.get_tailscale_funnel_status', return_value=True), \
+ mock.patch('keepercommander.service.util.tunneling.stop_tailscale_funnel'):
+ ServiceManager.get_status()
+
+ retained = ProcessInfo.load()
+ self.assertTrue(retained.tailscale_enabled)
+ self.assertEqual(retained.tailscale_port, 8000)
+
+ def test_service_status_clears_process_info_when_no_funnel_dangling(self):
+ """Without a live Funnel there's nothing left to clean up, so the stale
+ record should still be cleared as before."""
+ import psutil as psutil_module
+ ProcessInfo.save(pid=12345, is_running=True, tailscale_enabled=True, tailscale_port=8000)
+
+ with mock.patch('psutil.Process', side_effect=psutil_module.NoSuchProcess(12345)), \
+ mock.patch('keepercommander.service.util.tunneling.get_tailscale_funnel_status', return_value=False):
+ status = ServiceManager.get_status()
+
+ self.assertNotIn("still active", status)
+ self.assertIsNone(ProcessInfo.load().pid)
+
+ def test_stop_service_tears_down_dangling_funnel_after_status_saw_dead_process(self):
+ """End-to-end of the finding-1 fix: get_status() on a crashed service must leave
+ the record intact (stale pid and all) so the follow-up service-stop it advises
+ can still reach Tailscale teardown."""
+ import psutil as psutil_module
+ ProcessInfo.save(pid=12345, is_running=True, tailscale_enabled=True, tailscale_port=8000)
+
+ with mock.patch('psutil.Process', side_effect=psutil_module.NoSuchProcess(12345)), \
+ mock.patch('keepercommander.service.util.tunneling.get_tailscale_funnel_status', return_value=True), \
+ mock.patch('keepercommander.service.util.tunneling.stop_tailscale_funnel') as mock_status_stop:
+ status = ServiceManager.get_status()
+ self.assertIn("service-stop", status)
+ mock_status_stop.assert_not_called()
+
+ with mock.patch('keepercommander.service.util.tunneling.stop_tailscale_funnel',
+ return_value=True) as mock_stop_funnel, \
+ mock.patch.object(ServiceManager, 'kill_process_by_pid', return_value=False):
+ ServiceManager.stop_service()
+ mock_stop_funnel.assert_called_once_with(8000)
diff --git a/unit-tests/service/test_tailscale_config.py b/unit-tests/service/test_tailscale_config.py
new file mode 100644
index 000000000..dd8717069
--- /dev/null
+++ b/unit-tests/service/test_tailscale_config.py
@@ -0,0 +1,150 @@
+import unittest
+from unittest import mock
+
+from keepercommander.service.config.tailscale_config import TailscaleConfigurator
+from keepercommander.service.util.tunneling import TailscaleAccessDeniedError
+from keepercommander.service.util.exceptions import ValidationError
+
+
+def _base_config_data():
+ return {
+ "tailscale": "y",
+ "port": 8080,
+ "tailscale_auth_key": "tskey-auth-xxx",
+ "tailscale_advertise_tags": "",
+ "run_mode": "foreground",
+ "tailscale_public_url": "",
+ }
+
+
+class TestVerifyFunnelActive(unittest.TestCase):
+ def test_returns_true_immediately_when_already_active(self):
+ with mock.patch('keepercommander.service.config.tailscale_config.get_tailscale_funnel_status', return_value=True) as mock_status:
+ self.assertTrue(TailscaleConfigurator._verify_funnel_active(8080, max_retries=3, retry_delay=0))
+ mock_status.assert_called_once_with(8080)
+
+ def test_retries_before_succeeding(self):
+ """A slower daemon-side registration can take a beat after `--bg` returns -
+ the check must retry rather than declaring failure on the first miss."""
+ with mock.patch('keepercommander.service.config.tailscale_config.get_tailscale_funnel_status',
+ side_effect=[False, False, True]) as mock_status, \
+ mock.patch('time.sleep'):
+ self.assertTrue(TailscaleConfigurator._verify_funnel_active(8080, max_retries=3, retry_delay=0))
+ self.assertEqual(mock_status.call_count, 3)
+
+ def test_returns_false_after_exhausting_retries(self):
+ with mock.patch('keepercommander.service.config.tailscale_config.get_tailscale_funnel_status', return_value=False), \
+ mock.patch('time.sleep'):
+ self.assertFalse(TailscaleConfigurator._verify_funnel_active(8080, max_retries=3, retry_delay=0))
+
+
+class TestConfigureTailscaleVerification(unittest.TestCase):
+ """configure_tailscale's Funnel-active verification and rollback, added after a
+ live approval-pending case showed `tailscale funnel --bg` can exit 0 without the
+ target ever actually going live."""
+
+ def _patch_happy_path_prereqs(self):
+ return [
+ mock.patch('keepercommander.service.config.tailscale_config.reset_tailscale_log'),
+ mock.patch.object(TailscaleConfigurator, '_ensure_ready'),
+ mock.patch.object(TailscaleConfigurator, '_validate_tailscale_config'),
+ mock.patch('keepercommander.service.config.tailscale_config.tailscale_up'),
+ mock.patch('keepercommander.service.config.tailscale_config.start_tailscale_funnel'),
+ ]
+
+ def test_rolls_back_and_raises_when_funnel_never_becomes_active(self):
+ config_data = _base_config_data()
+ patches = self._patch_happy_path_prereqs()
+ with patches[0], patches[1], patches[2], patches[3], patches[4], \
+ mock.patch.object(TailscaleConfigurator, '_verify_funnel_active', return_value=False), \
+ mock.patch('keepercommander.service.config.tailscale_config.stop_tailscale_funnel') as mock_stop, \
+ mock.patch('keepercommander.service.config.tailscale_config.get_tailscale_funnel_url') as mock_get_url:
+ with self.assertRaises(Exception):
+ TailscaleConfigurator.configure_tailscale(config_data, mock.Mock())
+
+ mock_stop.assert_called_once_with(config_data["port"])
+ # Must fail before ever asking for the public URL - there isn't a live one.
+ mock_get_url.assert_not_called()
+
+ def test_rollback_failure_does_not_mask_the_original_error(self):
+ """stop_tailscale_funnel itself failing during rollback must not swallow or
+ replace the original 'Funnel never became active' error."""
+ config_data = _base_config_data()
+ patches = self._patch_happy_path_prereqs()
+ with patches[0], patches[1], patches[2], patches[3], patches[4], \
+ mock.patch.object(TailscaleConfigurator, '_verify_funnel_active', return_value=False), \
+ mock.patch('keepercommander.service.config.tailscale_config.stop_tailscale_funnel',
+ side_effect=Exception("reset failed too")):
+ with self.assertRaisesRegex(Exception, "did not become active"):
+ TailscaleConfigurator.configure_tailscale(config_data, mock.Mock())
+
+ def test_succeeds_and_fetches_url_when_funnel_is_verified_active(self):
+ config_data = _base_config_data()
+ patches = self._patch_happy_path_prereqs()
+ with patches[0], patches[1], patches[2], patches[3], patches[4], \
+ mock.patch.object(TailscaleConfigurator, '_verify_funnel_active', return_value=True), \
+ mock.patch('keepercommander.service.config.tailscale_config.get_tailscale_funnel_url',
+ return_value='https://node.example.ts.net') as mock_get_url:
+ result = TailscaleConfigurator.configure_tailscale(config_data, mock.Mock())
+
+ self.assertIsNone(result)
+ mock_get_url.assert_called_once_with(config_data["port"])
+ self.assertEqual(config_data["tailscale_public_url"], 'https://node.example.ts.net')
+
+
+class TestAuthenticate(unittest.TestCase):
+ """The one-time Tailscale operator-grant fallback for tailscale_up failing
+ with TailscaleAccessDeniedError - common on a fresh Linux install where the
+ control socket is root-owned by default."""
+
+ def test_retries_and_succeeds_after_operator_granted(self):
+ config_data = _base_config_data()
+ service_config = mock.Mock()
+ service_config._get_yes_no_input.return_value = 'y'
+
+ with mock.patch('keepercommander.service.config.tailscale_config.tailscale_up',
+ side_effect=[TailscaleAccessDeniedError("denied"), None]) as mock_up, \
+ mock.patch('keepercommander.service.config.tailscale_config.set_tailscale_operator') as mock_set_op:
+ TailscaleConfigurator._authenticate(config_data, service_config)
+
+ self.assertEqual(mock_up.call_count, 2)
+ mock_set_op.assert_called_once()
+
+ def test_declining_raises_validation_error_without_granting(self):
+ config_data = _base_config_data()
+ service_config = mock.Mock()
+ service_config._get_yes_no_input.return_value = 'n'
+
+ with mock.patch('keepercommander.service.config.tailscale_config.tailscale_up',
+ side_effect=TailscaleAccessDeniedError("denied")), \
+ mock.patch('keepercommander.service.config.tailscale_config.set_tailscale_operator') as mock_set_op:
+ with self.assertRaises(ValidationError):
+ TailscaleConfigurator._authenticate(config_data, service_config)
+ mock_set_op.assert_not_called()
+
+ def test_second_failure_after_operator_grant_propagates(self):
+ """If granting operator rights doesn't actually fix it, the retry's
+ failure must propagate rather than being silently swallowed or retried forever."""
+ config_data = _base_config_data()
+ service_config = mock.Mock()
+ service_config._get_yes_no_input.return_value = 'y'
+
+ with mock.patch('keepercommander.service.config.tailscale_config.tailscale_up',
+ side_effect=[TailscaleAccessDeniedError("denied"), Exception("still failing")]), \
+ mock.patch('keepercommander.service.config.tailscale_config.set_tailscale_operator'):
+ with self.assertRaisesRegex(Exception, "still failing"):
+ TailscaleConfigurator._authenticate(config_data, service_config)
+
+ def test_non_access_denied_failure_is_not_caught_here(self):
+ config_data = _base_config_data()
+ service_config = mock.Mock()
+
+ with mock.patch('keepercommander.service.config.tailscale_config.tailscale_up',
+ side_effect=Exception("some other failure")):
+ with self.assertRaisesRegex(Exception, "some other failure"):
+ TailscaleConfigurator._authenticate(config_data, service_config)
+ service_config._get_yes_no_input.assert_not_called()
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/unit-tests/service/test_terraform_app_setup.py b/unit-tests/service/test_terraform_app_setup.py
index 4607a40b9..9a39ff2f9 100644
--- a/unit-tests/service/test_terraform_app_setup.py
+++ b/unit-tests/service/test_terraform_app_setup.py
@@ -112,6 +112,7 @@ def test_execute_validates_allowlist_before_setup(self, _mock_validate, mock_set
mock_setup.assert_not_called()
@mock.patch.object(TerraformAppSetupCommand, '_get_advanced_security_config')
+ @mock.patch.object(TerraformAppSetupCommand, '_get_tailscale_config')
@mock.patch.object(TerraformAppSetupCommand, '_get_cloudflare_config')
@mock.patch.object(TerraformAppSetupCommand, '_get_ngrok_config')
@mock.patch.object(TerraformAppSetupCommand, '_get_port_config', return_value=8900)
@@ -126,6 +127,7 @@ def test_service_configuration_always_enables_queue(
_mock_port,
mock_ngrok,
mock_cf,
+ mock_ts,
mock_security,
):
mock_ngrok.return_value = {
@@ -140,6 +142,12 @@ def test_service_configuration_always_enables_queue(
'cloudflare_custom_domain': '',
'cloudflare_public_url': '',
}
+ mock_ts.return_value = {
+ 'tailscale_enabled': False,
+ 'tailscale_auth_key': '',
+ 'tailscale_advertise_tags': '',
+ 'tailscale_public_url': '',
+ }
mock_security.return_value = {
'allowed_ip': '0.0.0.0/0,::/0',
'denied_ip': '',
@@ -158,6 +166,106 @@ def test_service_configuration_always_enables_queue(
self.assertEqual(config.port, 8900)
mock_security.assert_called_once()
+ @mock.patch.object(TerraformAppSetupCommand, '_get_advanced_security_config')
+ @mock.patch.object(TerraformAppSetupCommand, '_get_tailscale_config')
+ @mock.patch.object(TerraformAppSetupCommand, '_get_cloudflare_config')
+ @mock.patch.object(TerraformAppSetupCommand, '_get_ngrok_config')
+ @mock.patch.object(TerraformAppSetupCommand, '_get_port_config', return_value=8900)
+ @mock.patch.object(
+ TerraformAppSetupCommand,
+ '_get_commands_config',
+ return_value=TerraformSetupConstants.SERVICE_COMMANDS,
+ )
+ def test_ngrok_enabled_skips_cloudflare_and_tailscale_prompts(
+ self,
+ _mock_commands,
+ _mock_port,
+ mock_ngrok,
+ mock_cf,
+ mock_ts,
+ mock_security,
+ ):
+ """Ngrok/Cloudflare/Tailscale are mutually exclusive - enabling Ngrok
+ must never prompt for the other two, matching pre-existing behavior."""
+ mock_ngrok.return_value = {
+ 'ngrok_enabled': True,
+ 'ngrok_auth_token': 'tok',
+ 'ngrok_custom_domain': '',
+ 'ngrok_public_url': '',
+ }
+ mock_security.return_value = {
+ 'allowed_ip': '0.0.0.0/0,::/0',
+ 'denied_ip': '',
+ 'rate_limit': '',
+ 'encryption_enabled': False,
+ 'encryption_key': '',
+ 'token_expiration': '',
+ }
+
+ cmd = TerraformAppSetupCommand()
+ config = cmd.get_service_configuration(params=mock.Mock())
+
+ mock_cf.assert_not_called()
+ mock_ts.assert_not_called()
+ self.assertTrue(config.ngrok_enabled)
+ self.assertFalse(config.cloudflare_enabled)
+ self.assertFalse(config.tailscale_enabled)
+
+ @mock.patch.object(TerraformAppSetupCommand, '_get_advanced_security_config')
+ @mock.patch.object(TerraformAppSetupCommand, '_get_tailscale_config')
+ @mock.patch.object(TerraformAppSetupCommand, '_get_cloudflare_config')
+ @mock.patch.object(TerraformAppSetupCommand, '_get_ngrok_config')
+ @mock.patch.object(TerraformAppSetupCommand, '_get_port_config', return_value=8900)
+ @mock.patch.object(
+ TerraformAppSetupCommand,
+ '_get_commands_config',
+ return_value=TerraformSetupConstants.SERVICE_COMMANDS,
+ )
+ def test_tailscale_enabled_populates_service_config(
+ self,
+ _mock_commands,
+ _mock_port,
+ mock_ngrok,
+ mock_cf,
+ mock_ts,
+ mock_security,
+ ):
+ mock_ngrok.return_value = {
+ 'ngrok_enabled': False,
+ 'ngrok_auth_token': '',
+ 'ngrok_custom_domain': '',
+ 'ngrok_public_url': '',
+ }
+ mock_cf.return_value = {
+ 'cloudflare_enabled': False,
+ 'cloudflare_tunnel_token': '',
+ 'cloudflare_custom_domain': '',
+ 'cloudflare_public_url': '',
+ }
+ mock_ts.return_value = {
+ 'tailscale_enabled': True,
+ 'tailscale_auth_key': 'tskey-auth-xxx',
+ 'tailscale_advertise_tags': 'tag:commander-service',
+ 'tailscale_public_url': '',
+ }
+ mock_security.return_value = {
+ 'allowed_ip': '0.0.0.0/0,::/0',
+ 'denied_ip': '',
+ 'rate_limit': '',
+ 'encryption_enabled': False,
+ 'encryption_key': '',
+ 'token_expiration': '',
+ }
+
+ cmd = TerraformAppSetupCommand()
+ config = cmd.get_service_configuration(params=mock.Mock())
+
+ self.assertTrue(config.tailscale_enabled)
+ self.assertEqual(config.tailscale_auth_key, 'tskey-auth-xxx')
+ self.assertEqual(config.tailscale_advertise_tags, 'tag:commander-service')
+ self.assertFalse(config.ngrok_enabled)
+ self.assertFalse(config.cloudflare_enabled)
+
class TestTerraformSetupConstants(TestCase):
def test_allowlist_entries_are_registered_commands(self):
diff --git a/unit-tests/service/test_tunneling.py b/unit-tests/service/test_tunneling.py
index 528fbe68e..cf0722777 100644
--- a/unit-tests/service/test_tunneling.py
+++ b/unit-tests/service/test_tunneling.py
@@ -1,3 +1,4 @@
+import json
import os
import tempfile
import unittest
@@ -272,5 +273,280 @@ def test_resolves_the_data_dir_at_call_time_not_import_time(self):
self.assertTrue(log_file.startswith(os.path.join(overridden_dir, 'service_logs')))
+class TestStartTailscaleFunnel(unittest.TestCase):
+ def test_rejects_port_not_in_allowed_set(self):
+ """Tailscale Funnel only accepts 443/8443/10000 as the external-facing port -
+ catch an invalid value before it ever reaches the CLI."""
+ with self.assertRaises(ValueError):
+ tunneling.start_tailscale_funnel(local_port=8080, funnel_port=9999)
+
+ def test_accepts_each_allowed_port(self):
+ with tempfile.NamedTemporaryFile() as tmp:
+ for allowed_port in tunneling.TAILSCALE_FUNNEL_ALLOWED_PORTS:
+ with mock.patch('keepercommander.service.util.tunneling._get_tailscale_log_path', return_value=tmp.name), \
+ mock.patch('keepercommander.service.util.tunneling.subprocess.run',
+ return_value=mock.Mock(returncode=0)) as mock_run:
+ tunneling.start_tailscale_funnel(local_port=8080, funnel_port=allowed_port)
+ cmd = mock_run.call_args[0][0]
+ self.assertIn(f"--https={allowed_port}", cmd)
+
+ def test_missing_local_port_raises(self):
+ with self.assertRaises(ValueError):
+ tunneling.start_tailscale_funnel(local_port=None)
+
+ def test_raises_with_guidance_when_cli_exits_nonzero(self):
+ with tempfile.NamedTemporaryFile() as tmp, \
+ mock.patch('keepercommander.service.util.tunneling._get_tailscale_log_path', return_value=tmp.name), \
+ mock.patch('keepercommander.service.util.tunneling.subprocess.run',
+ return_value=mock.Mock(returncode=1)):
+ with self.assertRaisesRegex(Exception, "Failed to start Tailscale Funnel"):
+ tunneling.start_tailscale_funnel(local_port=8080)
+
+
+class TestGetTailscaleFunnelStatus(unittest.TestCase):
+ """Schema verified live against a real `tailscale funnel status --json` while a
+ Funnel target was actually running: {"Web": {":": {"Handlers":
+ {"": {"Proxy": "http://localhost:"}}}}}."""
+
+ def test_true_when_local_port_is_an_active_proxy_target(self):
+ payload = {
+ "Web": {"example.ts.net:443": {"Handlers": {"/": {"Proxy": "http://localhost:8080"}}}},
+ "AllowFunnel": {"example.ts.net:443": True},
+ }
+ with mock.patch('keepercommander.service.util.tunneling.subprocess.run',
+ return_value=mock.Mock(returncode=0, stdout=json.dumps(payload))):
+ self.assertTrue(tunneling.get_tailscale_funnel_status(8080))
+
+ def test_false_when_no_matching_target(self):
+ payload = {
+ "Web": {"example.ts.net:443": {"Handlers": {"/": {"Proxy": "http://localhost:9999"}}}},
+ "AllowFunnel": {"example.ts.net:443": True},
+ }
+ with mock.patch('keepercommander.service.util.tunneling.subprocess.run',
+ return_value=mock.Mock(returncode=0, stdout=json.dumps(payload))):
+ self.assertFalse(tunneling.get_tailscale_funnel_status(8080))
+
+ def test_false_when_proxy_matches_but_allow_funnel_is_false(self):
+ """A tailnet-only `tailscale serve` on the same target shows up under the same
+ "Web" entry as Funnel - AllowFunnel must also be true, or this isn't actually
+ publicly exposed via Funnel, just privately via Serve."""
+ payload = {
+ "Web": {"example.ts.net:443": {"Handlers": {"/": {"Proxy": "http://localhost:8080"}}}},
+ "AllowFunnel": {"example.ts.net:443": False},
+ }
+ with mock.patch('keepercommander.service.util.tunneling.subprocess.run',
+ return_value=mock.Mock(returncode=0, stdout=json.dumps(payload))):
+ self.assertFalse(tunneling.get_tailscale_funnel_status(8080))
+
+ def test_false_when_no_funnel_configured_at_all(self):
+ with mock.patch('keepercommander.service.util.tunneling.subprocess.run',
+ return_value=mock.Mock(returncode=0, stdout='{}')):
+ self.assertFalse(tunneling.get_tailscale_funnel_status(8080))
+
+ def test_false_on_cli_failure_not_raised(self):
+ """A status check is diagnostic, not authoritative - a CLI/parsing error must
+ report 'not active' rather than bubbling up and crashing the caller."""
+ with mock.patch('keepercommander.service.util.tunneling.subprocess.run',
+ side_effect=Exception("boom")):
+ self.assertFalse(tunneling.get_tailscale_funnel_status(8080))
+
+
+class TestStopTailscaleFunnel(unittest.TestCase):
+ def test_returns_true_on_success(self):
+ with tempfile.NamedTemporaryFile() as tmp, \
+ mock.patch('keepercommander.service.util.tunneling._get_tailscale_log_path', return_value=tmp.name), \
+ mock.patch('keepercommander.service.util.tunneling.subprocess.run',
+ return_value=mock.Mock(returncode=0)):
+ self.assertTrue(tunneling.stop_tailscale_funnel(8080))
+
+ def test_returns_false_on_nonzero_exit(self):
+ """Both the scoped command and the reset fallback failing, with the target
+ confirmed still active, is a genuine failure."""
+ with tempfile.NamedTemporaryFile() as tmp, \
+ mock.patch('keepercommander.service.util.tunneling._get_tailscale_log_path', return_value=tmp.name), \
+ mock.patch('keepercommander.service.util.tunneling.get_tailscale_funnel_status', return_value=True), \
+ mock.patch('keepercommander.service.util.tunneling.subprocess.run',
+ return_value=mock.Mock(returncode=1)):
+ self.assertFalse(tunneling.stop_tailscale_funnel(8080))
+
+ def test_uses_scoped_teardown_first_not_broad_reset(self):
+ """Scoped `--https= off` must be tried before the broad `reset`, which
+ would wipe any other Serve/Funnel config a user set up outside Commander."""
+ with tempfile.NamedTemporaryFile() as tmp, \
+ mock.patch('keepercommander.service.util.tunneling._get_tailscale_log_path', return_value=tmp.name), \
+ mock.patch('keepercommander.service.util.tunneling.subprocess.run',
+ return_value=mock.Mock(returncode=0)) as mock_run:
+ self.assertTrue(tunneling.stop_tailscale_funnel(8080))
+ first_cmd = mock_run.call_args_list[0].args[0]
+ self.assertEqual(first_cmd, ["tailscale", "funnel", "--https=443", "off"])
+ self.assertEqual(mock_run.call_count, 1)
+
+ def test_falls_back_to_reset_when_scoped_teardown_fails(self):
+ with tempfile.NamedTemporaryFile() as tmp, \
+ mock.patch('keepercommander.service.util.tunneling._get_tailscale_log_path', return_value=tmp.name), \
+ mock.patch('keepercommander.service.util.tunneling.get_tailscale_funnel_status', return_value=True), \
+ mock.patch('keepercommander.service.util.tunneling.subprocess.run',
+ side_effect=[mock.Mock(returncode=1), mock.Mock(returncode=0)]) as mock_run:
+ self.assertTrue(tunneling.stop_tailscale_funnel(8080))
+ self.assertEqual(mock_run.call_count, 2)
+ self.assertEqual(mock_run.call_args_list[1].args[0], ["tailscale", "funnel", "reset"])
+
+ def test_already_inactive_target_does_not_escalate_to_destructive_reset(self):
+ """The scoped command exits non-zero for an already-removed target ("handler
+ does not exist"), which happens routinely on double teardown (service-stop
+ racing a foreground service's own exit handler). That must not escalate to
+ `funnel reset`, which would wipe unrelated Serve/Funnel config."""
+ with tempfile.NamedTemporaryFile() as tmp, \
+ mock.patch('keepercommander.service.util.tunneling._get_tailscale_log_path', return_value=tmp.name), \
+ mock.patch('keepercommander.service.util.tunneling.get_tailscale_funnel_status', return_value=False), \
+ mock.patch('keepercommander.service.util.tunneling.subprocess.run',
+ return_value=mock.Mock(returncode=1)) as mock_run:
+ self.assertTrue(tunneling.stop_tailscale_funnel(8080))
+ commands = [call.args[0] for call in mock_run.call_args_list]
+ self.assertNotIn(["tailscale", "funnel", "reset"], commands)
+
+
+class TestTailscaleUp(unittest.TestCase):
+ """A fresh Linux install leaves the tailscaled control socket root-owned by
+ default, so `tailscale up` fails there with a specific "checkprefs access
+ denied" message rather than a bad-key error - this must be distinguished
+ so callers can offer the one-time operator-grant fix instead of treating
+ it as an invalid auth key."""
+
+ def _run_with_log_content(self, log_content, returncode=1, advertise_tags=None):
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.log', delete=False) as tmp:
+ tmp.write(log_content)
+ tmp_path = tmp.name
+ try:
+ with mock.patch('keepercommander.service.util.tunneling._get_tailscale_log_path', return_value=tmp_path), \
+ mock.patch('keepercommander.service.util.tunneling.subprocess.run',
+ return_value=mock.Mock(returncode=returncode)):
+ tunneling.tailscale_up('tskey-auth-xxx', advertise_tags)
+ finally:
+ os.unlink(tmp_path)
+
+ def test_raises_access_denied_error_when_not_operator(self):
+ with self.assertRaises(tunneling.TailscaleAccessDeniedError):
+ self._run_with_log_content("Access denied: checkprefs access denied\n")
+
+ def test_raises_generic_exception_for_other_failures(self):
+ with self.assertRaises(Exception) as ctx:
+ self._run_with_log_content("backend error: invalid key\n")
+ self.assertNotIsInstance(ctx.exception, tunneling.TailscaleAccessDeniedError)
+
+ def test_missing_auth_key_raises_value_error(self):
+ with self.assertRaises(ValueError):
+ tunneling.tailscale_up(None)
+
+ def test_no_identity_check_before_authenticating(self):
+ """No pre-flight warning/check - tailscale_up goes straight to `up`, same as
+ before; blocking or warning here would be noise on every normal dev/test run
+ against an already-authenticated host, and headless invocations can't prompt."""
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.log', delete=False) as tmp:
+ tmp_path = tmp.name
+ try:
+ with mock.patch('keepercommander.service.util.tunneling._get_tailscale_log_path', return_value=tmp_path), \
+ mock.patch('keepercommander.service.util.tunneling.subprocess.run',
+ return_value=mock.Mock(returncode=0)) as mock_run, \
+ mock.patch('builtins.print') as mock_print:
+ tunneling.tailscale_up('tskey-auth-xxx')
+ self.assertEqual(mock_print.call_count, 0)
+ self.assertEqual(mock_run.call_count, 1)
+ finally:
+ os.unlink(tmp_path)
+
+
+class TestSetTailscaleOperator(unittest.TestCase):
+ def test_returns_false_when_username_unavailable(self):
+ with mock.patch.dict(os.environ, {}, clear=True):
+ self.assertFalse(tunneling.set_tailscale_operator())
+
+ def test_runs_sudo_tailscale_set_operator_with_current_user(self):
+ with mock.patch.dict(os.environ, {'USER': 'alice'}, clear=True), \
+ mock.patch('keepercommander.service.util.tunneling.subprocess.run',
+ return_value=mock.Mock(returncode=0)) as mock_run:
+ self.assertTrue(tunneling.set_tailscale_operator())
+ cmd = mock_run.call_args[0][0]
+ self.assertEqual(cmd, ["sudo", "tailscale", "set", "--operator=alice"])
+
+ def test_prefers_sudo_user_over_user_when_run_under_sudo(self):
+ """Under `sudo keeper ...`, $USER/$LOGNAME resolve to root - SUDO_USER holds
+ the actual invoking user, who is who should actually get operator rights."""
+ with mock.patch.dict(os.environ, {'USER': 'root', 'SUDO_USER': 'alice'}, clear=True), \
+ mock.patch('keepercommander.service.util.tunneling.subprocess.run',
+ return_value=mock.Mock(returncode=0)) as mock_run:
+ self.assertTrue(tunneling.set_tailscale_operator())
+ cmd = mock_run.call_args[0][0]
+ self.assertEqual(cmd, ["sudo", "tailscale", "set", "--operator=alice"])
+
+ def test_returns_false_when_command_fails(self):
+ with mock.patch.dict(os.environ, {'USER': 'alice'}), \
+ mock.patch('keepercommander.service.util.tunneling.subprocess.run',
+ return_value=mock.Mock(returncode=1)):
+ self.assertFalse(tunneling.set_tailscale_operator())
+
+
+class TestTailscaleUpUnattended(unittest.TestCase):
+ """Windows keeps a node up only while the GUI client runs, and Commander installs
+ the MSI with TS_NOLAUNCH=1 (no GUI). Without --unattended the backend sits at
+ NoState after a successful login, so Funnel has nothing to bind to."""
+
+ def _cmd_for_platform(self, system):
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.log', delete=False) as tmp:
+ tmp_path = tmp.name
+ try:
+ with mock.patch('keepercommander.service.util.tunneling._get_tailscale_log_path', return_value=tmp_path), \
+ mock.patch('platform.system', return_value=system), \
+ mock.patch('keepercommander.service.util.tunneling.subprocess.run',
+ return_value=mock.Mock(returncode=0)) as mock_run:
+ tunneling.tailscale_up('tskey-auth-xxx')
+ return mock_run.call_args[0][0]
+ finally:
+ os.unlink(tmp_path)
+
+ def test_windows_gets_unattended_flag(self):
+ self.assertIn('--unattended', self._cmd_for_platform('Windows'))
+
+ def test_macos_and_linux_do_not_get_unattended_flag(self):
+ for system in ('Darwin', 'Linux'):
+ self.assertNotIn('--unattended', self._cmd_for_platform(system),
+ f'--unattended is Windows-only, leaked into {system}')
+
+ def test_unattended_does_not_displace_the_existing_flags(self):
+ cmd = self._cmd_for_platform('Windows')
+ self.assertIn('--force-reauth', cmd)
+ self.assertIn('--advertise-tags=', cmd)
+ self.assertTrue(any(a.startswith('--auth-key=file:') for a in cmd))
+
+
+class TestInstallTailscaleLinux(unittest.TestCase):
+ def _run_with_script(self, content: bytes):
+ import tempfile
+ with tempfile.NamedTemporaryFile(delete=False) as tmp:
+ tmp.write(content)
+ tmp_path = tmp.name
+ try:
+ with mock.patch('keepercommander.service.util.tunneling._downloaded_to_tempfile') as mock_dl, \
+ mock.patch('keepercommander.service.util.tunneling._run_privileged_tailscale_command',
+ return_value=True) as mock_run:
+ mock_dl.return_value.__enter__.return_value = tmp_path
+ result = tunneling._install_tailscale_linux()
+ return result, mock_run
+ finally:
+ os.unlink(tmp_path)
+
+ def test_rejects_truncated_download(self):
+ result, mock_run = self._run_with_script(b'short')
+ self.assertFalse(result)
+ mock_run.assert_not_called()
+
+ def test_runs_install_and_logs_sha256_for_a_normal_script(self):
+ with self.assertLogs(level='INFO') as logs:
+ result, mock_run = self._run_with_script(b'#!/bin/sh\n' + b'x' * 2000)
+ self.assertTrue(result)
+ mock_run.assert_called_once()
+ self.assertTrue(any('SHA256' in m for m in logs.output))
+
+
if __name__ == '__main__':
unittest.main()
diff --git a/unit-tests/service/test_verified_command.py b/unit-tests/service/test_verified_command.py
index 2796a3cb5..f70a6ec75 100644
--- a/unit-tests/service/test_verified_command.py
+++ b/unit-tests/service/test_verified_command.py
@@ -1,4 +1,4 @@
-from unittest import TestCase
+from unittest import TestCase, mock
from html import unescape
import os
import shutil
@@ -6,16 +6,14 @@
import shlex
+from keepercommander.commands.base import normalize_output_param
from keepercommander.service.util.verified_command import Verifycommand
def _tokens(command: str):
"""Same normalization CommandExecutor uses before policy checks."""
command = unescape(command)
- try:
- return shlex.split(command.replace('\\', '\\\\'))
- except ValueError:
- return command.split()
+ return Verifycommand.tokenize_service_command(command)
class TestServiceModeCommandPolicy(TestCase):
@@ -108,6 +106,96 @@ def test_attachment_commands_blocked_for_remote_api(self):
)
self.assertIsNone(check(_tokens('record-add --title t -rt login login=user')))
+ def test_backslash_escaped_record_file_field_is_blocked(self):
+ """Escaped field names are checked after the same parsing as execution."""
+ check = Verifycommand.validate_service_mode_restrictions
+
+ # The HTTP JSON representation of this argument contains ``file\\=``;
+ # after JSON decoding there is one backslash, which shlex consumes and
+ # turns into the actual ``file=`` field name seen by record-add.
+ for field in (
+ r'file\=/etc/passwd',
+ r'f.file\=/etc/passwd',
+ r'fi\le=/etc/passwd',
+ r'f.fi\le.doc\=/etc/passwd',
+ ):
+ command = f'record-add -t f01 -rt login --self-destruct=1d {field}'
+ with self.subTest(field=field):
+ self.assertEqual(_tokens(command), shlex.split(command))
+ self.assertIsNotNone(check(_tokens(command)))
+
+ # The ordinary spelling remains blocked as before.
+ self.assertIsNotNone(check(_tokens(
+ 'record-add -t f01 -rt login --self-destruct=1d file=/etc/passwd'
+ )))
+ self.assertIsNone(check(_tokens(
+ r'record-add -t f01 -rt login lo\gin=user'
+ )))
+
+ def test_escaped_host_path_options_are_blocked(self):
+ """Filesystem flags and parser-driven file inputs use execution tokens."""
+ check = Verifycommand.validate_service_mode_restrictions
+ ban = 'Local filesystem access'
+ for cmd in (
+ r'generate --out\put=/tmp/sm-write-canary.txt',
+ r'generate --out\put /tmp/sm-write-canary.txt',
+ r'import --for\mat=json /etc/passwd',
+ r'import --file\-cache=/tmp/sm-cache',
+ r'pam project import --filen\ame=/etc/passwd',
+ r'audit-report --form\at=pdf',
+ ):
+ with self.subTest(cmd=cmd):
+ self.assertIn(ban, check(_tokens(cmd)))
+
+ def test_automator_local_file_access_is_blocked(self):
+ check = Verifycommand.validate_service_mode_restrictions
+ host_fs_msg = 'Local filesystem access'
+ for cmd in (
+ 'automator-certificate /etc/passwd',
+ 'automator certificate /etc/passwd',
+ ):
+ with self.subTest(cmd=cmd):
+ self.assertIn(host_fs_msg, check(_tokens(cmd)))
+
+ def test_automator_local_data_expansions_are_blocked(self):
+ check = Verifycommand.validate_service_mode_restrictions
+ expansion_msg = 'Automator file and environment expansions'
+ for cmd in (
+ 'automator edit TestAutomator --set debugUrl=${file:/etc/passwd}',
+ 'automator edit TestAutomator --set key=${base64:/etc/shadow}',
+ 'automator edit TestAutomator --set key=${env:AWS_SECRET_ACCESS_KEY}',
+ 'automator edit TestAutomator --set key=${FiLe:/etc/passwd}',
+ 'automator edit TestAutomator --set "debugUrl = ${file:/etc/passwd}"',
+ 'automator edit TestAutomator --set "debugUrl= ${file:/etc/passwd}"',
+ 'automator edit TestAutomator --set "debugUrl=${file:/etc/passwd} "',
+ 'automator edit TestAutomator --set="debugUrl = ${base64:/etc/shadow}"',
+ 'automator edit TestAutomator --se "debugUrl = ${file:/etc/passwd}"',
+ ):
+ with self.subTest(cmd=cmd):
+ self.assertIn(expansion_msg, check(_tokens(cmd)))
+
+ # Ordinary Automator edits remain usable through Service Mode.
+ self.assertIsNone(check(_tokens(
+ 'automator edit TestAutomator --set debugUrl=https://example.invalid'
+ )))
+ # The expansion syntax is only interpreted for --set values, not URL.
+ self.assertIsNone(check(_tokens(
+ 'automator edit TestAutomator --url=${file:/tmp/canary.txt}'
+ )))
+
+ def test_quoted_backslash_path_tokens_match_executor(self):
+ # The CLI normalizes --output before shlex on Windows; Service Mode
+ # must apply the same sequence and keep the path as one argument.
+ path = r'C:\service\files\payload.txt'
+ command = f'generate --output "{path}"'
+ with mock.patch('sys.platform', 'win32'):
+ expected_tokens = shlex.split(normalize_output_param(command))
+ actual_tokens = Verifycommand.tokenize_service_command(command)
+
+ self.assertEqual(actual_tokens, expected_tokens)
+ self.assertEqual(actual_tokens[-1], r'C:/service/files/payload.txt')
+ self.assertEqual(len(actual_tokens), 3)
+
def test_host_filesystem_commands_blocked(self):
check = Verifycommand.validate_service_mode_restrictions
ban = 'Local filesystem access'
@@ -221,16 +309,29 @@ def test_filename_allows_temp_filedata_paths_only(self):
temp_path = os.path.join(request_temp_dir, 'service_filedata_test.json')
self.assertIsNone(
- check(_tokens(f'pam project import --filename={temp_path}'), request_temp_dir)
+ check(
+ _tokens(f'pam project import --filename={shlex.quote(temp_path)}'),
+ request_temp_dir,
+ )
+ )
+ self.assertIsNone(
+ check(_tokens(f'pam project import -f {shlex.quote(temp_path)}'), request_temp_dir)
)
- self.assertIsNone(check(_tokens(f'pam project import -f {temp_path}'), request_temp_dir))
- self.assertIsNone(check(_tokens(f'import --format=json {temp_path}'), request_temp_dir))
self.assertIsNone(
- check(_tokens(f'enterprise-push {temp_path} --email user@example.com'), request_temp_dir)
+ check(_tokens(f'import --format=json {shlex.quote(temp_path)}'), request_temp_dir)
+ )
+ self.assertIsNone(
+ check(
+ _tokens(f'enterprise-push {shlex.quote(temp_path)} --email user@example.com'),
+ request_temp_dir,
+ )
)
# PAM --config is a vault UID, not a host path
self.assertIsNone(
- check(_tokens('pam project extend --config=SOME_UID -f ' + temp_path), request_temp_dir)
+ check(
+ _tokens(f'pam project extend --config=SOME_UID -f {shlex.quote(temp_path)}'),
+ request_temp_dir,
+ )
)
def test_command_aliases_do_not_bypass_host_path_checks(self):
@@ -254,8 +355,15 @@ def test_command_aliases_do_not_bypass_host_path_checks(self):
self.addCleanup(shutil.rmtree, request_temp_dir, ignore_errors=True)
temp_path = os.path.join(request_temp_dir, 'service_filedata_alias_test.json')
self.assertIsNone(check(_tokens('gen --output stdout')))
- self.assertIsNone(check(_tokens(f'pam p i -f {temp_path}'), request_temp_dir))
- self.assertIsNone(check(_tokens(f'pam p e --filename={temp_path}'), request_temp_dir))
+ self.assertIsNone(
+ check(_tokens(f'pam p i -f {shlex.quote(temp_path)}'), request_temp_dir)
+ )
+ self.assertIsNone(
+ check(
+ _tokens(f'pam p e --filename={shlex.quote(temp_path)}'),
+ request_temp_dir,
+ )
+ )
def test_non_request_temp_path_still_rejected(self):
"""A path under a DIFFERENT request's temp dir is not automatically safe,
@@ -270,11 +378,15 @@ def test_non_request_temp_path_still_rejected(self):
other_path = os.path.join(other_request_dir, 'someone_elses_file.json')
self.assertIn(
- ban, check(_tokens(f'pam project import -f {other_path}'), request_temp_dir)
+ ban,
+ check(
+ _tokens(f'pam project import -f {shlex.quote(other_path)}'),
+ request_temp_dir,
+ ),
)
# No request_temp_dir at all (e.g. a request with no FILEDATA) trusts nothing.
self.assertIn(
- ban, check(_tokens(f'pam project import -f {other_path}'))
+ ban, check(_tokens(f'pam project import -f {shlex.quote(other_path)}'))
)
def test_flag_abbreviations_do_not_bypass_host_path_checks(self):
@@ -321,7 +433,22 @@ def test_import_bare_name_positional_requires_format_awareness(self):
request_temp_dir = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, request_temp_dir, ignore_errors=True)
temp_path = os.path.join(request_temp_dir, 'import_data.json')
- self.assertIsNone(check(_tokens(f'import --format=json {temp_path}'), request_temp_dir))
+ self.assertIsNone(
+ check(_tokens(f'import --format=json {shlex.quote(temp_path)}'), request_temp_dir)
+ )
+
+ def test_windows_output_normalization_is_checked_as_a_file_path(self):
+ check = Verifycommand.validate_service_mode_restrictions
+ normalized_cases = []
+ with mock.patch('sys.platform', 'win32'):
+ for output_value in Verifycommand._NON_PATH_OUTPUT_VALUES:
+ command = rf'public-api-key list --format json --output \{output_value}'
+ normalized_cases.append((output_value, _tokens(command)))
+
+ for output_value, tokens in normalized_cases:
+ with self.subTest(output_value=output_value):
+ self.assertEqual(tokens[-1], f'/{output_value}')
+ self.assertIn('Local filesystem access', check(tokens))
def test_filedata_substitution_happens_before_validation(self):
"""process_file_data must run before validate_service_mode_restrictions,
@@ -336,6 +463,7 @@ def test_filedata_substitution_happens_before_validation(self):
self.assertTrue(temp_files, 'expected a temp file to be created')
self.assertNotIn('FILEDATA', processed_command)
+ self.assertTrue(any(token.endswith(temp_files[0]) for token in _tokens(processed_command)))
request_temp_dir = os.path.dirname(temp_files[0])
tokens = _tokens(processed_command)
@@ -343,6 +471,131 @@ def test_filedata_substitution_happens_before_validation(self):
Verifycommand.validate_service_mode_restrictions(tokens, request_temp_dir)
)
+ def test_filedata_positional_placeholder_is_shell_quoted(self):
+ """A generated path remains one executor token, including with spaces."""
+ from keepercommander.service.util.request_validation import RequestValidator
+ from unittest.mock import patch
+
+ request_data = {'filedata': {'some': 'data'}}
+ command = 'import --format=json FILEDATA'
+ request_temp_dir = tempfile.mkdtemp(prefix='service mode ')
+ self.addCleanup(shutil.rmtree, request_temp_dir, ignore_errors=True)
+ with patch(
+ 'keepercommander.service.util.request_validation.tempfile.mkdtemp',
+ return_value=request_temp_dir):
+ processed_command, temp_files = RequestValidator.process_file_data(
+ request_data, command
+ )
+ self.addCleanup(RequestValidator.cleanup_temp_files, temp_files)
+
+ self.assertEqual(
+ shlex.split(processed_command),
+ ['import', '--format=json', temp_files[0]],
+ )
+
+ def test_filedata_filename_placeholder_is_shell_quoted(self):
+ from keepercommander.service.util.request_validation import RequestValidator
+ from unittest.mock import patch
+
+ request_data = {'filedata': {'some': 'data'}}
+ command = 'pam project import --filename=FILEDATA'
+ request_temp_dir = tempfile.mkdtemp(prefix='service mode ')
+ self.addCleanup(shutil.rmtree, request_temp_dir, ignore_errors=True)
+ with patch(
+ 'keepercommander.service.util.request_validation.tempfile.mkdtemp',
+ return_value=request_temp_dir):
+ processed_command, temp_files = RequestValidator.process_file_data(
+ request_data, command
+ )
+ self.addCleanup(RequestValidator.cleanup_temp_files, temp_files)
+
+ tokens = shlex.split(processed_command)
+ self.assertEqual(tokens[-1], f'--filename={temp_files[0]}')
+ self.assertIsNone(
+ Verifycommand.validate_service_mode_restrictions(
+ tokens, request_temp_dir
+ )
+ )
+
+ def test_filedata_windows_path_survives_executor_tokenization(self):
+ from keepercommander.service.util.request_validation import RequestValidator
+ from unittest.mock import MagicMock, patch
+
+ request_data = {'filedata': {'some': 'data'}}
+ command = 'import --format=json FILEDATA'
+ temp_file_path = r'C:\service mode\payload.json'
+ temp_file = MagicMock()
+ temp_file.name = temp_file_path
+ with patch(
+ 'keepercommander.service.util.request_validation.tempfile.mkdtemp',
+ return_value=r'C:\service mode'), patch(
+ 'keepercommander.service.util.request_validation.tempfile.NamedTemporaryFile') as make_temp_file:
+ make_temp_file.return_value.__enter__.return_value = temp_file
+ processed_command, temp_files = RequestValidator.process_file_data(
+ request_data, command
+ )
+
+ self.assertEqual(temp_files, [temp_file_path])
+ self.assertEqual(
+ shlex.split(processed_command),
+ ['import', '--format=json', temp_file_path],
+ )
+
+ def test_filedata_quoted_positional_placeholder_is_replaced(self):
+ from keepercommander.service.util.request_validation import RequestValidator
+
+ request_data = {'filedata': {'some': 'data'}}
+ command = 'import --format=json "FILEDATA"'
+ processed_command, temp_files = RequestValidator.process_file_data(
+ request_data, command
+ )
+ self.addCleanup(RequestValidator.cleanup_temp_files, temp_files)
+
+ self.assertEqual(
+ shlex.split(processed_command),
+ ['import', '--format=json', temp_files[0]],
+ )
+
+ def test_quoted_filedata_placeholders_support_apostrophes_in_path(self):
+ from keepercommander.service.util.request_validation import RequestValidator
+ request_data = {'filedata': {'some': 'data'}}
+ commands = (
+ "import --format=json 'FILEDATA'",
+ "pam project import --filename='FILEDATA'",
+ )
+
+ for command in commands:
+ with self.subTest(command=command), tempfile.TemporaryDirectory() as parent_dir:
+ request_temp_dir = os.path.join(parent_dir, "service user's files")
+ os.mkdir(request_temp_dir)
+ with mock.patch(
+ 'keepercommander.service.util.request_validation.tempfile.mkdtemp',
+ return_value=request_temp_dir):
+ processed_command, temp_files = RequestValidator.process_file_data(
+ request_data, command
+ )
+ self.addCleanup(RequestValidator.cleanup_temp_files, temp_files)
+
+ expected_last_token = (
+ f'--filename={temp_files[0]}'
+ if '--filename' in command else temp_files[0]
+ )
+ self.assertEqual(_tokens(processed_command)[-1], expected_last_token)
+
+ def test_mismatched_filedata_quotes_remain_invalid_command_syntax(self):
+ """Malformed placeholder quoting must fail closed, not be repaired."""
+ from keepercommander.service.util.request_validation import RequestValidator
+
+ request_data = {'filedata': {'some': 'data'}}
+ command = 'pam project import --filename="FILEDATA\''
+ processed_command, temp_files = RequestValidator.process_file_data(
+ request_data, command
+ )
+ self.addCleanup(RequestValidator.cleanup_temp_files, temp_files)
+
+ with self.assertRaises(ValueError):
+ Verifycommand.tokenize_service_command(processed_command)
+
def test_option_values_yields_dash_leading_values(self):
is_file = Verifycommand._option_values
self.assertEqual(list(is_file(['generate', '--output', '-'], '--output')), ['-'])
diff --git a/unit-tests/test_audit_log_syslog.py b/unit-tests/test_audit_log_syslog.py
new file mode 100644
index 000000000..e74742cc2
--- /dev/null
+++ b/unit-tests/test_audit_log_syslog.py
@@ -0,0 +1,64 @@
+"""syslog export must emit a valid RFC 5424 HOSTNAME."""
+import pytest
+
+import keepercommander.commands.aram as aram
+
+
+@pytest.fixture(autouse=True)
+def seed_templates(monkeypatch):
+ # convert_event() reads the module-level template cache, normally loaded from the server.
+ monkeypatch.setattr(aram, 'syslog_templates',
+ {'device_approved': 'Device ${device_name} is approved for user ${username}'})
+
+
+BASE_EVENT = {
+ 'id': 24983911115,
+ 'created': 1788369452, # 2026-09-02T17:17:32Z
+ 'audit_event_type': 'device_approved',
+ 'device_name': 'Browser Extension',
+ 'username': 'test@example.com',
+}
+
+
+def header_hostname(line):
+ # HEADER = PRI VERSION SP TIMESTAMP SP HOSTNAME SP APP-NAME ...
+ parts = line.split(' ')
+ return parts[2], parts[3]
+
+
+@pytest.mark.parametrize('exporter_cls', [aram.AuditLogSyslogFileExport, aram.AuditLogSyslogPortExport])
+@pytest.mark.parametrize('ip_value, expected', [
+ (None, '-'), # key present with null
+ ('', '-'), # empty string — backend may return "" instead of omitting the key
+ (' ', '-'),
+ ('\t', '-'),
+ ('10.0.0.1', '-'), # non-printable / non-ASCII
+ ('10.0.0.1\x00', '-'),
+ ('10.0.12.64', '10.0.12.64'),
+ (' 10.0.12.64 ', '10.0.12.64'),
+ ('2001:db8::1', '2001:db8::1'),
+])
+def test_hostname_is_rfc5424_valid(exporter_cls, ip_value, expected):
+ event = {**BASE_EVENT, 'ip_address': ip_value}
+ line = exporter_cls().convert_event({}, event)
+ hostname, app_name = header_hostname(line)
+ assert hostname == expected
+ assert app_name == 'Keeper'
+ assert ' ' not in line.split(' [', 1)[0]
+
+
+def test_missing_key_still_uses_nilvalue():
+ line = aram.AuditLogSyslogFileExport().convert_event({}, dict(BASE_EVENT))
+ assert line.startswith('<110>1 2026-09-02T17:17:32Z - Keeper - 24983911115 ')
+
+
+def test_hostname_truncated_to_255():
+ event = {**BASE_EVENT, 'ip_address': 'a' * 300}
+ hostname, _ = header_hostname(aram.AuditLogSyslogFileExport().convert_event({}, event))
+ assert len(hostname) == 255
+
+
+def test_ip_address_not_duplicated_in_structured_data():
+ event = {**BASE_EVENT, 'ip_address': ''}
+ line = aram.AuditLogSyslogFileExport().convert_event({}, event)
+ assert 'ip_address=' not in line
diff --git a/unit-tests/test_command_enterprise.py b/unit-tests/test_command_enterprise.py
index 6f298135f..80048fbfc 100644
--- a/unit-tests/test_command_enterprise.py
+++ b/unit-tests/test_command_enterprise.py
@@ -7,10 +7,11 @@
from data_enterprise import EnterpriseEnvironment, get_enterprise_data, enterprise_allocate_ids
from keepercommander import api, crypto, enterprise as enterprise_data, utils, vault
from keepercommander.params import KeeperParams, PublicKeys
-from keepercommander.error import CommandError
+from keepercommander.error import CommandError, KeeperApiError
from keepercommander.proto import enterprise_pb2
from data_vault import VaultEnvironment, get_connected_params
from keepercommander.commands import enterprise, aram
+from keepercommander.commands.helpers.enterprise import user_has_privilege
vault_env = VaultEnvironment()
@@ -756,6 +757,109 @@ def test_audit_log_splunk_properties_cancel(self):
with self.assertRaises(KeyboardInterrupt):
splunk.get_properties(record, props)
+ def test_audit_log_permission_probe_fails_before_setup_prompts(self):
+ params = get_connected_params()
+ cmd = aram.AuditLogCommand()
+ with mock.patch('keepercommander.commands.aram.api.communicate',
+ side_effect=KeeperApiError('access_denied', 'Reports are not permitted')), \
+ mock.patch('keepercommander.commands.aram.load_syslog_templates') as load_templates, \
+ mock.patch('keepercommander.commands.aram.user_choice') as user_choice:
+ with self.assertRaises(CommandError) as context:
+ cmd.execute(params, target='json')
+
+ self.assertEqual(context.exception.command, 'audit-log')
+ self.assertEqual(context.exception.message, 'Reports are not permitted')
+ load_templates.assert_not_called()
+ user_choice.assert_not_called()
+
+ def test_audit_log_server_authorization_allows_flow_to_continue(self):
+ params = get_connected_params()
+ cmd = aram.AuditLogCommand()
+ with mock.patch('keepercommander.commands.aram.api.communicate',
+ return_value={'result': 'success'}), \
+ mock.patch('keepercommander.commands.aram.load_syslog_templates') as load_templates, \
+ mock.patch('keepercommander.commands.aram.user_choice', return_value='n') as user_choice:
+ with self.assertRaises(CommandError) as context:
+ cmd.execute(params, target='json')
+
+ self.assertEqual(context.exception.message, 'Record not found')
+ load_templates.assert_called_once_with(params)
+ user_choice.assert_called_once()
+
+ def test_user_has_privilege_is_case_insensitive_and_fails_closed(self):
+ params = mock.Mock()
+ params.msp_tree_key = None
+ params.user = 'First.Last@Corp.com'
+ params.enterprise = {
+ 'users': [{'username': 'first.last@corp.com', 'enterprise_user_id': 17}],
+ 'role_users': [{'role_id': 23, 'enterprise_user_id': 17}],
+ 'role_privileges': [{'role_id': 23, 'privilege': 'run_reports'}],
+ }
+ self.assertTrue(user_has_privilege(params, 'run_reports'))
+
+ params.enterprise.pop('role_privileges')
+ self.assertFalse(user_has_privilege(params, 'run_reports'))
+ params.enterprise.pop('role_users')
+ self.assertFalse(user_has_privilege(params, 'run_reports'))
+ params.enterprise.pop('users')
+ self.assertFalse(user_has_privilege(params, 'run_reports'))
+
+ def test_fetch_audit_events_raises_backend_failure(self):
+ params = get_connected_params()
+ with mock.patch('keepercommander.commands.aram.api.execute_batch', return_value=[{
+ 'result': 'fail',
+ 'result_code': 'access_denied',
+ 'message': 'Reports are not permitted'
+ }]):
+ with self.assertRaises(CommandError) as context:
+ aram.fetch_audit_events(params, {}, cmd='action-report')
+
+ self.assertEqual(context.exception.command, 'action-report')
+ self.assertEqual(context.exception.message, 'Reports are not permitted')
+
+ def test_fetch_audit_events_later_page_failure_discards_partial_results(self):
+ params = get_connected_params()
+ first_page = {
+ 'result': 'success',
+ 'audit_event_overview_report_rows': [
+ {'last_created': i, 'username': f'user{i}@example.com'}
+ for i in range(aram.API_EVENT_SUMMARY_ROW_LIMIT)
+ ]
+ }
+ failed_page = {
+ 'result': 'fail',
+ 'result_code': 'access_denied',
+ 'message': 'Reports are not permitted'
+ }
+ with mock.patch('keepercommander.commands.aram.api.execute_batch',
+ side_effect=[[first_page], [failed_page]]):
+ with self.assertRaises(CommandError) as context:
+ aram.fetch_audit_events(
+ params,
+ {'created': {'min': 0, 'max': 10_000}},
+ report_type='span',
+ limit=aram.API_EVENT_SUMMARY_ROW_LIMIT + 1,
+ cmd='action-report'
+ )
+
+ self.assertEqual(context.exception.command, 'action-report')
+
+ def test_audit_report_raw_response_failure_is_reported(self):
+ params = get_connected_params()
+ params.enterprise = {'licenses': []}
+ failed_response = {
+ 'result': 'fail',
+ 'result_code': 'access_denied',
+ 'message': 'Reports are not permitted'
+ }
+ with mock.patch('keepercommander.commands.aram.load_syslog_templates'), \
+ mock.patch('keepercommander.commands.aram.api.execute_batch', return_value=[failed_response]):
+ with self.assertRaises(CommandError) as context:
+ aram.AuditReportCommand().execute(params, report_type='raw')
+
+ self.assertEqual(context.exception.command, 'audit-report')
+ self.assertEqual(context.exception.message, 'Reports are not permitted')
+
def test_audit_log_splunk_convert_event(self):
splunk = aram.AuditLogSplunkExport()
props = {
diff --git a/unit-tests/test_command_ksm.py b/unit-tests/test_command_ksm.py
index ab0c98716..bd6c3db04 100644
--- a/unit-tests/test_command_ksm.py
+++ b/unit-tests/test_command_ksm.py
@@ -297,6 +297,13 @@ def test_default_top_application_usage_no_exist_column(self):
self.assertEqual(headers, ['Application Owner', 'Count'])
self.assertEqual(table, [['a@x.com', 15], ['b@x.com', 3]])
+ def test_usage_metrics_uses_secrets_manager_command_label(self):
+ params = self._make_params()
+ with patch('keepercommander.commands.aram.fetch_audit_events', return_value=[]) as mock_fetch, \
+ patch('keepercommander.commands.ksm.dump_report_data'):
+ KSMCommand._usage_metrics(params, format='json')
+ self.assertEqual(mock_fetch.call_args.kwargs.get('cmd'), 'secrets-manager usage')
+
def test_by_device_no_exist_column_by_default(self):
# Without --exists there is NO Exist column - just Device/Count. Names shared by multiple apps
# are still split with a UID suffix so each row maps to one app.
@@ -429,6 +436,7 @@ def test_timeline_export_all_row_shape(self):
_fargs, fkwargs = mock_fetch.call_args
self.assertEqual(_fargs[1]['audit_event_type'], KSM_EVENT_TYPES)
self.assertEqual(fkwargs.get('report_type'), 'day')
+ self.assertEqual(fkwargs.get('cmd'), 'secrets-manager usage')
_args, kwargs = mock_dump.call_args
table = _args[0]
headers = kwargs.get('headers')
diff --git a/unit-tests/test_download_membership.py b/unit-tests/test_download_membership.py
new file mode 100644
index 000000000..d93697497
--- /dev/null
+++ b/unit-tests/test_download_membership.py
@@ -0,0 +1,176 @@
+"""Tests for ``download-membership`` --timeout / --api-page-size-limit (MM-11)."""
+
+from contextlib import contextmanager
+from unittest import TestCase, mock
+
+from keepercommander.commands.base import ParseError
+from keepercommander.importer import commands
+from keepercommander.importer.cyberark.cyberark import CyberArkMembershipDownload
+
+def _page(values, count):
+ response = mock.MagicMock()
+ response.status_code = 200
+ response.json.return_value = {'value': values, 'count': count}
+ return response
+
+@contextmanager
+def _api_safes_source():
+ """Force ``fetch_all_safes`` onto the PVWA request path."""
+ with mock.patch('keepercommander.importer.cyberark.cyberark.path.isfile', return_value=False), \
+ mock.patch.dict('keepercommander.importer.cyberark.cyberark.environ', {}, clear=True):
+ yield
+
+class TestMembershipDownloadParser(TestCase):
+
+ parser = commands.download_membership_parser
+
+ def test_defaults(self):
+ opts = self.parser.parse_args(['--source', 'cyberark'])
+ self.assertEqual(opts.timeout, 10)
+ self.assertEqual(opts.api_page_size_limit, 200)
+
+ def test_custom_values(self):
+ opts = self.parser.parse_args(['--source', 'cyberark', '--timeout', '60', '--api-page-size-limit', '50'])
+ self.assertEqual(opts.timeout, 60)
+ self.assertEqual(opts.api_page_size_limit, 50)
+
+ def test_non_integer_timeout_rejected(self):
+ with self.assertRaises(ParseError):
+ self.parser.parse_args(['--source', 'cyberark', '--timeout', 'abc'])
+
+ def test_non_integer_page_size_rejected(self):
+ with self.assertRaises(ParseError):
+ self.parser.parse_args(['--source', 'cyberark', '--api-page-size-limit', '1.5'])
+
+ def test_negative_timeout_accepted_by_parser(self):
+ opts = self.parser.parse_args(['--source', 'cyberark', '--timeout', '-5'])
+ self.assertEqual(opts.timeout, -5)
+
+ def test_negative_page_size_accepted_by_parser(self):
+ opts = self.parser.parse_args(['--source', 'cyberark', '--api-page-size-limit', '-10'])
+ self.assertEqual(opts.api_page_size_limit, -10)
+
+class TestCyberArkMembershipDownload(TestCase):
+
+ HOST = 'pvwa.example.com'
+
+ def setUp(self):
+ mock.patch('keepercommander.importer.cyberark.cyberark.print_formatted_text').start()
+ mock.patch('keepercommander.importer.cyberark.cyberark.sleep').start()
+ self.plugin = CyberArkMembershipDownload()
+
+ def tearDown(self):
+ mock.patch.stopall()
+
+ def test_ispositive_int(self):
+ f = CyberArkMembershipDownload._ispositive_int
+ self.assertEqual(f(5), 5)
+ self.assertEqual(f('42'), 42)
+ for value in (0, -1, '0', 'abc', None, '', '1.5'):
+ with self.subTest(value=value):
+ self.assertIsNone(f(value))
+
+ def test_set_timeout_none_keeps_current(self):
+ self.plugin.timeout = 33
+ self.assertTrue(self.plugin.set_timeout(None))
+ self.assertEqual(self.plugin.timeout, 33)
+
+ def test_set_timeout_invalid(self):
+ for value in (9, 1, 0, -5, 'abc'):
+ with self.subTest(value=value):
+ self.plugin.timeout = 10
+ self.assertFalse(self.plugin.set_timeout(value))
+ self.assertEqual(self.plugin.timeout, 10)
+
+ def test_negative_timeout_rejected(self):
+ self.assertFalse(self.plugin.set_timeout(-5))
+ self.assertEqual(self.plugin.timeout, 10)
+
+ def test_set_page_size_none_keeps_current(self):
+ self.assertTrue(self.plugin.set_api_page_size_limit(None))
+ self.assertEqual(self.plugin.api_page_size_limit, 200)
+
+ def test_set_page_size_invalid(self):
+ for value in (9, 0, -1, 'abc'):
+ with self.subTest(value=value):
+ self.plugin.api_page_size_limit = 200
+ self.assertFalse(self.plugin.set_api_page_size_limit(value))
+ self.assertEqual(self.plugin.api_page_size_limit, 200)
+
+ def test_negative_page_size_rejected(self):
+ self.assertFalse(self.plugin.set_api_page_size_limit(-10))
+ self.assertEqual(self.plugin.api_page_size_limit, 200)
+
+ def _fetch_safes(self, pages, page_size):
+ self.plugin.api_page_size_limit = page_size
+ with _api_safes_source(), mock.patch.object(self.plugin, 'get_response', side_effect=pages) as get_response:
+ safes = self.plugin.fetch_all_safes(self.HOST, 'token')
+ return safes, get_response
+
+ def test_fetch_all_safes_uses_page_size(self):
+ pages = [_page([{'safeName': 'a'}, {'safeName': 'b'}], 3), _page([{'safeName': 'c'}], 3)]
+ safes, get_response = self._fetch_safes(pages, page_size=2)
+ self.assertEqual([s['safeName'] for s in safes], ['a', 'b', 'c'])
+ queries = [c.args[2] for c in get_response.call_args_list]
+ self.assertEqual(queries, [{'offset': 0, 'limit': 2}, {'offset': 2, 'limit': 2}])
+
+ def test_fetch_all_safes_empty_response(self):
+ cases = (
+ ('count zero', [], 0),
+ ('empty value list', [], 5),
+ )
+ for name, values, count in cases:
+ with self.subTest(case=name):
+ safes, get_response = self._fetch_safes([_page(values, count)], page_size=2)
+ self.assertEqual(safes, [])
+ self.assertEqual(get_response.call_count, 1)
+ self.assertEqual(get_response.call_args.args[2], {'offset': 0, 'limit': 2})
+
+ def test_fetch_all_safes_exact_page_multiple(self):
+ pages = [
+ _page([{'safeName': 'a'}, {'safeName': 'b'}], 4),
+ _page([{'safeName': 'c'}, {'safeName': 'd'}], 4),
+ ]
+ safes, get_response = self._fetch_safes(pages, page_size=2)
+ self.assertEqual([s['safeName'] for s in safes], ['a', 'b', 'c', 'd'])
+ self.assertEqual(get_response.call_count, 2)
+ queries = [c.args[2] for c in get_response.call_args_list]
+ self.assertEqual(queries, [{'offset': 0, 'limit': 2}, {'offset': 2, 'limit': 2}])
+
+ def test_fetch_all_safes_single_page(self):
+ for count in (1, 2):
+ with self.subTest(count=count):
+ values = [{'safeName': f's{i}'} for i in range(count)]
+ safes, get_response = self._fetch_safes([_page(values, count)], page_size=2)
+ self.assertEqual([s['safeName'] for s in safes], [f's{i}' for i in range(count)])
+ self.assertEqual(get_response.call_count, 1)
+ self.assertEqual(get_response.call_args.args[2], {'offset': 0, 'limit': 2})
+
+ def test_fetch_safe_members_uses_page_size(self):
+ self.plugin.api_page_size_limit = 15
+ page = _page([{'memberName': 'alice'}, {'memberName': 'Master', 'isPredefinedUser': True}], 2)
+ with mock.patch.object(self.plugin, 'get_response', return_value=page) as get_response:
+ members = self.plugin.fetch_safe_members(self.HOST, 'token', 'Safe1')
+ self.assertEqual(members, [{'memberName': 'alice'}])
+ self.assertEqual(get_response.call_args.args[2], {'offset': 0, 'limit': 15})
+
+ def _assert_request_timeout(self, fetch):
+ """Fail when ``get_response`` sends ``TIMEOUT`` instead of ``timeout``."""
+ self.plugin.TIMEOUT = 10
+ self.plugin.timeout = 45
+ page = _page([{'safeName': 'a', 'memberName': 'alice'}], 1)
+ with _api_safes_source(), mock.patch.object(self.plugin, '_request', return_value=page) as request:
+ fetch()
+ self.assertGreaterEqual(request.call_count, 1)
+ for call in request.call_args_list:
+ self.assertEqual(call.kwargs['timeout'], 45)
+
+ def test_fetch_all_safes_forwards_timeout(self):
+ self.plugin.api_page_size_limit = 2
+ self._assert_request_timeout(lambda: self.plugin.fetch_all_safes(self.HOST, 'token'))
+
+ def test_fetch_safe_members_forwards_timeout(self):
+ self.plugin.api_page_size_limit = 15
+ self._assert_request_timeout(
+ lambda: self.plugin.fetch_safe_members(self.HOST, 'token', 'Safe1')
+ )
\ No newline at end of file
diff --git a/unit-tests/test_notification_center.py b/unit-tests/test_notification_center.py
new file mode 100644
index 000000000..81f945de3
--- /dev/null
+++ b/unit-tests/test_notification_center.py
@@ -0,0 +1,118 @@
+import json
+import unittest
+
+from keepercommander import crypto, notification_center, utils
+from keepercommander.params import KeeperParams
+from keepercommander.proto import GraphSync_pb2, NotificationCenter_pb2 as NC
+
+
+def _wrapper(uid: bytes, timestamp: int, **content):
+ w = NC.NotificationWrapper()
+ w.uid = uid
+ w.timestamp = timestamp
+ for key, value in content.items():
+ if key == 'notification':
+ w.content.notification.CopyFrom(value)
+ else:
+ setattr(w.content, key, value)
+ return w
+
+
+def _notification(n_type, **kwargs):
+ n = NC.Notification()
+ n.type = n_type
+ n.sender.type = GraphSync_pb2.RFT_USER
+ n.sender.name = kwargs.get('sender', 'someone@company.com')
+ for key, data in (kwargs.get('parameters') or {}).items():
+ p = n.parameters.add()
+ p.key = key
+ p.data = data
+ for ref_type, value in kwargs.get('refs') or []:
+ r = n.refs.add()
+ r.type = ref_type
+ r.value = value
+ return n
+
+
+class TestNotificationReplay(unittest.TestCase):
+ def setUp(self):
+ self.params = KeeperParams()
+ self.uid1, self.uid2, self.uid3 = (bytes([i]) * 16 for i in (1, 2, 3))
+
+ def _sync(self, *wrappers):
+ notification_center.process_notification_sync(self.params, wrappers)
+
+ def _entry(self, uid: bytes):
+ return self.params.notification_cache[utils.base64_url_encode(uid)]
+
+ def test_statuses_apply_by_uid_last_write_wins(self):
+ self._sync(_wrapper(self.uid1, 100, notification=_notification(NC.NT_SHARE_APPROVAL)),
+ _wrapper(self.uid1, 110, readStatus=NC.NRS_READ),
+ _wrapper(self.uid1, 120, readStatus=NC.NRS_UNREAD),
+ _wrapper(self.uid1, 130, approvalStatus=NC.NAS_APPROVED))
+ entry = self._entry(self.uid1)
+ self.assertFalse(entry.is_read)
+ self.assertEqual(NC.NAS_APPROVED, entry.approval_status)
+ self.assertFalse(notification_center.is_actionable(entry))
+
+ def test_last_read_marks_older_notifications_read(self):
+ self._sync(_wrapper(self.uid1, 100, notification=_notification(NC.NT_SHARED)),
+ _wrapper(self.uid2, 200, notification=_notification(NC.NT_SHARED)),
+ _wrapper(self.uid3, 300, notification=_notification(NC.NT_SHARED)),
+ _wrapper(self.uid2, 250, readStatus=NC.NRS_LAST))
+ self.assertTrue(self._entry(self.uid1).is_read)
+ self.assertTrue(self._entry(self.uid2).is_read)
+ self.assertFalse(self._entry(self.uid3).is_read)
+
+ def test_unread_after_last_read_stays_unread(self):
+ self._sync(_wrapper(self.uid1, 100, notification=_notification(NC.NT_SHARED)),
+ _wrapper(self.uid2, 200, notification=_notification(NC.NT_SHARED)),
+ _wrapper(self.uid2, 250, readStatus=NC.NRS_LAST),
+ _wrapper(self.uid1, 260, readStatus=NC.NRS_UNREAD))
+ self.assertFalse(self._entry(self.uid1).is_read)
+
+ def test_trimming_point_drops_it_and_older(self):
+ self._sync(_wrapper(self.uid1, 100, notification=_notification(NC.NT_SHARED)),
+ _wrapper(self.uid2, 200, notification=_notification(NC.NT_SHARED)),
+ _wrapper(self.uid3, 300, notification=_notification(NC.NT_SHARED)),
+ _wrapper(self.uid2, 400, trimmingPoint=True))
+ self.assertEqual([utils.base64_url_encode(self.uid3)], list(self.params.notification_cache.keys()))
+
+ def test_unsupported_type_is_dropped(self):
+ self._sync(_wrapper(self.uid1, 100, notification=_notification(999)))
+ self.assertNotIn(utils.base64_url_encode(self.uid1), self.params.notification_cache)
+ self.assertEqual([], notification_center.get_notifications(self.params))
+
+ def test_wrapper_without_uid_is_skipped(self):
+ self._sync(_wrapper(b'', 100, notification=_notification(NC.NT_SHARED)),
+ _wrapper(self.uid1, 100, notification=_notification(NC.NT_SHARED)))
+ self.assertEqual(1, len(self.params.notification_cache))
+
+ def test_local_read_status(self):
+ self._sync(_wrapper(self.uid1, 100, notification=_notification(NC.NT_SHARED)))
+ notification_center.apply_local_read_status(self.params, [utils.base64_url_encode(self.uid1)], NC.NRS_READ)
+ self.assertTrue(self._entry(self.uid1).is_read)
+
+
+class TestNotificationPayloads(unittest.TestCase):
+ def setUp(self):
+ self.params = KeeperParams()
+
+ def test_device_approval_decrypts_with_user_ecc_key(self):
+ private_key, public_key = crypto.generate_ec_key()
+ self.params.ecc_key = private_key
+ data = {'notification_device': 'Commander', 'location': 'Paris', 'ip_address': '1.2.3.4', 'publicKey': 'AAAA'}
+ n = _notification(NC.NT_DEVICE_APPROVAL)
+ n.encryptedData.data = crypto.encrypt_ec(json.dumps(data).encode(), public_key)
+ entry = notification_center.NotificationEntry('x')
+ entry.notification = n
+ self.assertIn('Paris', notification_center.get_message(self.params, entry))
+
+ def test_workflow_reason_decrypts_with_record_key(self):
+ record_uid = utils.generate_uid()
+ r_key = utils.generate_aes_key()
+ self.params.record_cache[record_uid] = {'record_key_unencrypted': r_key}
+ n = _notification(NC.NT_APPROVAL_REQUEST,
+ parameters={'reason': crypto.encrypt_aes_v2(b'need access', r_key)},
+ refs=[(GraphSync_pb2.RFT_REC, utils.base64_url_decode(record_uid))])
+ self.assertEqual('need access', notification_center.decrypt_record_parameter(self.params, n, 'reason'))