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 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' 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 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' 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 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' 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 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' -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'))