diff --git a/keepercommander/commands/discover/__init__.py b/keepercommander/commands/discover/__init__.py index 6ef3c4b10..5086dfce5 100644 --- a/keepercommander/commands/discover/__init__.py +++ b/keepercommander/commands/discover/__init__.py @@ -270,8 +270,11 @@ def from_gateway(params: KeeperParams, gateway: str, configuration_uid: str | No if application is None: logging.debug(f"cannot find application for gateway {gateway}, skipping.") - if (utils.base64_url_encode(found_gateway.controllerUid) == gateway or - found_gateway.controllerName.lower() == gateway.lower()): + # When --configuration-uid selected the config, trust that selection. + # Otherwise require the gateway name/UID to match. + if (configuration_uid is not None + or utils.base64_url_encode(found_gateway.controllerUid) == gateway + or found_gateway.controllerName.lower() == gateway.lower()): return GatewayContext( configuration=configuration_record, facade=configuration_facade, diff --git a/keepercommander/commands/discover/job_status.py b/keepercommander/commands/discover/job_status.py index 25450489a..fe04492c1 100644 --- a/keepercommander/commands/discover/job_status.py +++ b/keepercommander/commands/discover/job_status.py @@ -1,5 +1,6 @@ from __future__ import annotations import argparse +import json import logging from . import PAMGatewayActionDiscoverCommandBase, GatewayContext from ..pam.router_helper import router_get_connected_gateways @@ -48,6 +49,8 @@ class PAMGatewayActionDiscoverJobStatusCommand(PAMGatewayActionDiscoverCommandBa help='Show history') parser.add_argument('--configuration-uid', '-c', required=False, dest='configuration_uid', action='store', help='PAM configuration UID is using --history') + parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], + default='table', help='Output format (table, json)') def get_parser(self): return PAMGatewayActionDiscoverJobStatusCommand.parser @@ -155,9 +158,9 @@ def print_job_table(jobs: List[Dict], print("") @staticmethod - def print_job_detail(params: KeeperParams, - all_gateways: List, - job_id: str): + def _build_job_detail(params: KeeperParams, + all_gateways: List, + job_id: str) -> Optional[Dict]: def _find_job(configuration_record) -> Optional[Dict]: jobs_obj = Jobs(record=configuration_record, params=params) @@ -172,96 +175,162 @@ def _find_job(configuration_record) -> Optional[Dict]: find_func=_find_job, gateways=all_gateways) - if gateway_context is not None: - jobs = payload["jobs"] - job = jobs.get_job(job_id) # type: JobItem - infra = Infrastructure(record=gateway_context.configuration, params=params) - - color = bcolors.OKBLUE - status = "RUNNING" - if job.end_ts is not None and not job.error: - if job.success is None: - color = bcolors.WHITE - status = "CANCELLED" + if gateway_context is None: + return None + + jobs = payload["jobs"] + job = jobs.get_job(job_id) + infra = Infrastructure(record=gateway_context.configuration, params=params) + + status = "RUNNING" + if job.end_ts is not None and not job.error: + if job.success is None: + status = "CANCELLED" + else: + status = "COMPLETE" + elif job.error: + status = "FAILED" + + detail = { + "job_id": job.job_id, + "sync_point": job.sync_point, + "gateway": gateway_context.gateway_name, + "gateway_uid": gateway_context.gateway_uid, + "configuration_uid": gateway_context.configuration_uid, + "status": status, + "resource_uid": job.resource_uid or "", + "started": job.start_ts_str or "", + "completed": job.end_ts_str or "", + "duration": job.duration_sec_str or "", + } + + if status == "FAILED": + detail["error"] = job.error + detail["stacktrace"] = job.stacktrace + elif job.end_ts is not None: + try: + infra.load(sync_point=0) + delta_json = job.delta + if delta_json is not None: + delta = DiscoveryDelta.model_validate(delta_json) + added = [] + for item in delta.added: + vertex = infra.dag.get_vertex(item.uid) + if vertex is None or vertex.active is False or vertex.has_data is False: + logging.debug("added: vertex is none, inactive or has no data") + continue + discovery_object = DiscoveryObject.get_discovery_object(vertex) + added.append({ + "uid": item.uid, + "description": discovery_object.description, + }) + + changed = [] + for item in delta.changed: + vertex = infra.dag.get_vertex(item.uid) + if vertex is None or vertex.active is False or vertex.has_data is False: + logging.debug("changed: vertex is none, inactive or has no data") + continue + discovery_object = DiscoveryObject.get_discovery_object(vertex) + changed.append({ + "uid": item.uid, + "description": discovery_object.description, + "changes": item.changes, + }) + + deleted = [{"uid": item.uid} for item in delta.deleted] + detail["delta"] = { + "added": added, + "changed": changed, + "deleted": deleted, + } else: - color = bcolors.OKGREEN - status = "COMPLETE" - elif job.error: - color = bcolors.FAIL - status = "FAILED" + detail["delta"] = None + detail["message"] = "There are no available delta changes for this job." + except Exception as err: + detail["delta_error"] = str(err) + try: + dag = DAG(conn=infra.conn, record=infra.record, + graph_id=PamGraphId.INFRASTRUCTURE) + detail["raw_graph_dot"] = dag.to_dot_raw(sync_point=job.sync_point, rank_dir="RL") + except Exception: + pass - color_status = f"{color}{status}{bcolors.ENDC}" + return detail + @staticmethod + def print_job_detail(params: KeeperParams, + all_gateways: List, + job_id: str): + + detail = PAMGatewayActionDiscoverJobStatusCommand._build_job_detail( + params=params, all_gateways=all_gateways, job_id=job_id) + + if detail is None: + print(f"{bcolors.FAIL}Could not find the gateway with job {job_id}.") + return + + status = detail["status"] + color = bcolors.OKBLUE + if status == "CANCELLED": + color = bcolors.WHITE + elif status == "COMPLETE": + color = bcolors.OKGREEN + elif status == "FAILED": + color = bcolors.FAIL + + color_status = f"{color}{status}{bcolors.ENDC}" + + print("") + print(f"{_h('Job ID')}: {detail['job_id']}") + print(f"{_h('Sync Point')}: {detail['sync_point']}") + print(f"{_h('Gateway Name')}: {detail['gateway']}") + print(f"{_h('Gateway UID')}: {detail['gateway_uid']}") + print(f"{_h('Configuration UID')}: {detail['configuration_uid']}") + print(f"{_h('Status')}: {color_status}") + print(f"{_h('Resource UID')}: {detail['resource_uid'] or 'NA'}") + print(f"{_h('Started')}: {detail['started']}") + print(f"{_h('Completed')}: {detail['completed']}") + print(f"{_h('Duration')}: {detail['duration']}") + + if status == "FAILED": print("") - print(f"{_h('Job ID')}: {job.job_id}") - print(f"{_h('Sync Point')}: {job.sync_point}") - print(f"{_h('Gateway Name')}: {gateway_context.gateway_name}") - print(f"{_h('Gateway UID')}: {gateway_context.gateway_uid}") - print(f"{_h('Configuration UID')}: {gateway_context.configuration_uid}") - print(f"{_h('Status')}: {color_status}") - print(f"{_h('Resource UID')}: {job.resource_uid or 'NA'}") - print(f"{_h('Started')}: {job.start_ts_str}") - print(f"{_h('Completed')}: {job.end_ts_str}") - print(f"{_h('Duration')}: {job.duration_sec_str}") - - # If it failed, show the error and stacktrace. - if status == "FAILED": + print(f"{_h('Gateway Error')}:") + print(f"{color}{detail.get('error')}{bcolors.ENDC}") + print("") + print(f"{_h('Gateway Stacktrace')}:") + print(f"{color}{detail.get('stacktrace')}{bcolors.ENDC}") + elif detail.get("completed"): + if detail.get("delta_error"): + print(f"{_f('Could not load delta from infrastructure: ' + detail['delta_error'])}") + if detail.get("raw_graph_dot"): + print("Fall back to raw graph.") + print("") + print(detail["raw_graph_dot"]) + elif detail.get("delta") is None: print("") - print(f"{_h('Gateway Error')}:") - print(f"{color}{job.error}{bcolors.ENDC}") + print(f"{_f(detail.get('message') or 'There are no available delta changes for this job.')}") + else: + delta = detail["delta"] print("") - print(f"{_h('Gateway Stacktrace')}:") - print(f"{color}{job.stacktrace}{bcolors.ENDC}") - # If it finished, show information about what was discovered. - elif job.end_ts is not None: + print(f"{_h('Added')} - {len(delta['added'])} count") + for item in delta["added"]: + print(f" * {item['description']}") - try: - infra.load(sync_point=0) - print("") - delta_json = job.delta - if delta_json is not None: - delta = DiscoveryDelta.model_validate(delta_json) - print(f"{_h('Added')} - {len(delta.added)} count") - for item in delta.added: - vertex = infra.dag.get_vertex(item.uid) - if vertex is None or vertex.active is False or vertex.has_data is False: - logging.debug("added: vertex is none, inactive or has no data") - continue - discovery_object = DiscoveryObject.get_discovery_object(vertex) - print(f" * {discovery_object.description}") - - print("") - print(f"{_h('Changed')} - {len(delta.changed)} count") - for item in delta.changed: - vertex = infra.dag.get_vertex(item.uid) - if vertex is None or vertex.active is False or vertex.has_data is False: - logging.debug("changed: vertex is none, inactive or has no data") - continue - discovery_object = DiscoveryObject.get_discovery_object(vertex) - print(f" * {discovery_object.description}") - if item.changes is None: - print(" no changed, may be a object not added in prior discoveries.") - else: - for key, value in item.changes.items(): - print(f" - {key} = {value}") - - print("") - print(f"{_h('Deleted')} - {len(delta.deleted)} count") - for item in delta.deleted: - print(f" * discovery vertex {item.uid}") + print("") + print(f"{_h('Changed')} - {len(delta['changed'])} count") + for item in delta["changed"]: + print(f" * {item['description']}") + if item.get("changes") is None: + print(" no changed, may be a object not added in prior discoveries.") else: - print(f"{_f('There are no available delta changes for this job.')}") + for key, value in item["changes"].items(): + print(f" - {key} = {value}") - except Exception as err: - print(f"{_f('Could not load delta from infrastructure: ' + str(err))}") - print("Fall back to raw graph.") - print("") - dag = DAG(conn=infra.conn, record=infra.record, - graph_id=PamGraphId.INFRASTRUCTURE) - print(dag.to_dot_raw(sync_point=job.sync_point, rank_dir="RL")) - - else: - print(f"{bcolors.FAIL}Could not find the gateway with job {job_id}.") + print("") + print(f"{_h('Deleted')} - {len(delta['deleted'])} count") + for item in delta["deleted"]: + print(f" * discovery vertex {item['uid']}") def execute(self, params, **kwargs): @@ -277,6 +346,7 @@ def execute(self, params, **kwargs): # Show the history for the gateway. # gateway_filter needs to be set for show_history = kwargs.get("show_history") + format_type = kwargs.get("format") or "table" # Get all the gateways here so we don't have to keep calling this method. # It gets passed into find_gateway, and find_gateway will pass it around. @@ -292,6 +362,19 @@ def execute(self, params, **kwargs): # If we have a job id, only display information about the one job if job_id: + if format_type == "json": + detail = self._build_job_detail(params=params, + all_gateways=all_gateways, + job_id=job_id) + if detail is None: + print(json.dumps({ + "job": None, + "message": f"Could not find the gateway with job {job_id}." + }, indent=2)) + else: + print(json.dumps({"job": detail}, indent=2)) + return + self.print_job_detail(params=params, all_gateways=all_gateways, job_id=job_id) @@ -301,6 +384,7 @@ def execute(self, params, **kwargs): # Based on parameters set by user, select specific jobs to be displayed. selected_jobs = [] # type: List[Dict] + json_jobs = [] # type: List[Dict] # For each configuration/ gateway, we are going to get all jobs. # We are going to query the gateway for any updated status. @@ -357,10 +441,29 @@ def execute(self, params, **kwargs): job["status"] = "FAILED" selected_jobs.append(job) + json_jobs.append({ + "job_id": job_item.job_id, + "gateway": gateway_context.gateway_name, + "gateway_uid": gateway_context.gateway_uid, + "configuration_uid": gateway_context.configuration_uid, + "status": job["status"], + "resource_uid": getattr(job_item, "resource_uid", None) or "", + "started": job_item.start_ts_str if job_item.start_ts is not None else "", + "completed": job_item.end_ts_str if job_item.end_ts is not None else "", + "duration": job_item.duration_sec_str, + }) if len(selected_jobs) == 0: - print(f"{bcolors.FAIL}There are no discovery jobs. Use 'pam action discover start' to start a " - f"discovery job.{bcolors.ENDC}") + message = ("There are no discovery jobs. Use 'pam action discover start' to start a " + "discovery job.") + if format_type == "json": + print(json.dumps({"jobs": [], "message": message}, indent=2)) + else: + print(f"{bcolors.FAIL}{message}{bcolors.ENDC}") + return + + if format_type == "json": + print(json.dumps({"jobs": json_jobs}, indent=2)) return self.print_job_table(jobs=selected_jobs, diff --git a/keepercommander/commands/discover/rule_list.py b/keepercommander/commands/discover/rule_list.py index 9819fc361..139d8e26d 100644 --- a/keepercommander/commands/discover/rule_list.py +++ b/keepercommander/commands/discover/rule_list.py @@ -1,5 +1,6 @@ from __future__ import annotations import argparse +import json from . import PAMGatewayActionDiscoverCommandBase, GatewayContext, MultiConfigurationException, multi_conf_msg from ...display import bcolors from ..pam.router_helper import router_get_connected_gateways @@ -20,10 +21,29 @@ class PAMGatewayActionDiscoverRuleListCommand(PAMGatewayActionDiscoverCommandBas parser.add_argument('--search', '-s', required=False, dest='search', action='store', help='Search for rules.') + parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], + default='table', help='Output format (table, json)') def get_parser(self): return PAMGatewayActionDiscoverRuleListCommand.parser + @staticmethod + def _rule_to_dict(rule: RuleItem): + action_value = None + if getattr(rule, 'action', None) is not None: + action_value = rule.action.value + return { + 'rule_id': rule.rule_id, + 'name': rule.name or '', + 'action': action_value, + 'priority': rule.priority, + 'case_sensitive': bool(rule.case_sensitive), + 'added': rule.added_ts_str if rule.added_ts else '', + 'shared_folder_uid': getattr(rule, 'shared_folder_uid', None) or '', + 'admin_uid': getattr(rule, 'admin_uid', None) or '', + 'rule': Rules.make_action_rule_statement_str(rule.statement), + } + @staticmethod def print_rule_table(rule_list: List[RuleItem]): @@ -90,21 +110,43 @@ def execute(self, params, **kwargs): gateway = kwargs.get("gateway") configuration_uid = kwargs.get('configuration_uid') + format_type = kwargs.get('format') or 'table' try: gateway_context = GatewayContext.from_gateway(params=params, gateway=gateway, configuration_uid=configuration_uid) if gateway_context is None: - print(f"{bcolors.FAIL}Could not find the gateway configuration for {gateway}.{bcolors.ENDC}") + message = f'Could not find the gateway configuration for {gateway}.' + if format_type == 'json': + print(json.dumps({'message': message}, indent=2)) + else: + print(f"{bcolors.FAIL}{message}{bcolors.ENDC}") return except MultiConfigurationException as err: - multi_conf_msg(gateway, err) + if format_type == 'json': + configs = [] + for item in (err.items or []): + record = item.get('configuration_record') + if record is not None: + configs.append({ + 'uid': getattr(record, 'record_uid', ''), + 'title': getattr(record, 'title', ''), + }) + print(json.dumps({ + 'message': f'Found multiple configuration records for gateway {gateway}.', + 'configurations': configs, + }, indent=2)) + else: + multi_conf_msg(gateway, err) return rules = Rules(record=gateway_context.configuration, params=params) rule_list = rules.rule_list(rule_type=RuleTypeEnum.ACTION, search=kwargs.get("search")) # type: List[RuleItem] if len(rule_list) == 0: + if format_type == 'json': + print(json.dumps({'rules': []}, indent=2)) + return print("") text = f"{bcolors.FAIL}There are no rules. " \ f"Use 'pam action discover rule add -g {gateway_context.gateway_uid} " @@ -114,4 +156,13 @@ def execute(self, params, **kwargs): print(text) return + if format_type == 'json': + print(json.dumps({ + 'gateway': gateway_context.gateway_name, + 'gateway_uid': gateway_context.gateway_uid, + 'configuration_uid': gateway_context.configuration_uid, + 'rules': [self._rule_to_dict(rule) for rule in rule_list], + }, indent=2)) + return + self.print_rule_table(rule_list=rule_list) diff --git a/keepercommander/commands/discoveryrotation.py b/keepercommander/commands/discoveryrotation.py index 569c9e0bd..86cc90146 100644 --- a/keepercommander/commands/discoveryrotation.py +++ b/keepercommander/commands/discoveryrotation.py @@ -1670,6 +1670,8 @@ class PAMListRecordRotationCommand(Command): parser = argparse.ArgumentParser(prog='pam rotation list') parser.add_argument('--verbose', '-v', required=False, default=False, dest='is_verbose', action='store_true', help='Verbose output') + parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], default='table', + help='Output format (table, json)') def get_parser(self): return PAMListRecordRotationCommand.parser @@ -1677,6 +1679,7 @@ def get_parser(self): def execute(self, params, **kwargs): is_verbose = kwargs.get('is_verbose') + format_type = kwargs.get('format') or 'table' rq = pam_pb2.PAMGenericUidsRequest() schedules_proto = router_get_rotation_schedules(params, rq) @@ -1695,19 +1698,27 @@ def execute(self, params, **kwargs): all_pam_config_records = pam_configurations_get_all(params) table = [] - headers = [] - headers.append('Record UID') - headers.append('Record Title') - headers.append('Record Type') - headers.append('Schedule') + if format_type == 'json': + headers = ['record_uid', 'record_title', 'record_type', 'schedule', 'gateway'] + if is_verbose: + headers.append('gateway_uid') + headers.append('pam_configuration') + if is_verbose: + headers.append('pam_configuration_uid') + else: + headers = [] + headers.append('Record UID') + headers.append('Record Title') + headers.append('Record Type') + headers.append('Schedule') - headers.append('Gateway') - if is_verbose: - headers.append('Gateway UID') + headers.append('Gateway') + if is_verbose: + headers.append('Gateway UID') - headers.append('PAM Configuration (Type)') - if is_verbose: - headers.append('PAM Configuration UID') + headers.append('PAM Configuration (Type)') + if is_verbose: + headers.append('PAM Configuration UID') for s in schedules: row = [] @@ -1724,12 +1735,11 @@ def execute(self, params, **kwargs): is_controller_online = any( (poc for poc in enterprise_controllers_connected_uids_bytes if poc == controller_uid)) - row_color = '' if record_exists_in_vault(params, record_uid): - row_color = bcolors.HIGHINTENSITYWHITE + record_accessible = True record_title, record_type = get_vault_record_title_type(params, record_uid) else: - row_color = bcolors.WHITE + record_accessible = False record_title = '[record inaccessible]' record_type = '[record inaccessible]' @@ -1737,9 +1747,15 @@ def execute(self, params, **kwargs): # only pamUser records are supported for rotation continue - row.append(f'{row_color}{record_uid}') - row.append(record_title or '[untitled]') - row.append(record_type or '[unknown]') + if format_type == 'json': + row.append(record_uid) + row.append(record_title or '[untitled]') + row.append(record_type or '[unknown]') + else: + row_color = bcolors.HIGHINTENSITYWHITE if record_accessible else bcolors.WHITE + row.append(f'{row_color}{record_uid}') + row.append(record_title or '[untitled]') + row.append(record_type or '[unknown]') if s.noSchedule is True: # Per Sergey A: @@ -1756,9 +1772,15 @@ def execute(self, params, **kwargs): else: schedule_str = s.scheduleData else: - schedule_str = f'{bcolors.FAIL}[empty]' + schedule_str = '[empty]' - row.append(f'{schedule_str}') + if format_type == 'json': + row.append(schedule_str) + else: + if schedule_str == '[empty]': + row.append(f'{bcolors.FAIL}[empty]') + else: + row.append(f'{schedule_str}') # Controller Info connected_controller = None @@ -1767,29 +1789,45 @@ def execute(self, params, **kwargs): list(enterprise_controllers_connected_resp.controllers)} connected_controller = router_controllers.get(controller_details.controllerUid) - if connected_controller: - controller_stat_color = bcolors.OKGREEN + if format_type == 'json': + if controller_details: + row.append(controller_details.controllerName) + else: + row.append('[Does not exist]') + if is_verbose: + row.append(utils.base64_url_encode(controller_uid)) else: - controller_stat_color = bcolors.WHITE + if connected_controller: + controller_stat_color = bcolors.OKGREEN + else: + controller_stat_color = bcolors.WHITE - controller_color = bcolors.WHITE - if is_controller_online: - controller_color = bcolors.OKGREEN + controller_color = bcolors.WHITE + if is_controller_online: + controller_color = bcolors.OKGREEN - if controller_details: - row.append(f'{controller_stat_color}{controller_details.controllerName}{bcolors.ENDC}') - else: - row.append(f'{controller_stat_color}[Does not exist]{bcolors.ENDC}') + if controller_details: + row.append(f'{controller_stat_color}{controller_details.controllerName}{bcolors.ENDC}') + else: + row.append(f'{controller_stat_color}[Does not exist]{bcolors.ENDC}') - if is_verbose: - row.append(f'{controller_color}{utils.base64_url_encode(controller_uid)}{bcolors.ENDC}') + if is_verbose: + row.append(f'{controller_color}{utils.base64_url_encode(controller_uid)}{bcolors.ENDC}') if not pam_configuration: - if not is_verbose: - row.append(f"{bcolors.FAIL}[No config found]{bcolors.ENDC}") + if format_type == 'json': + if not is_verbose: + row.append('[No config found]') + else: + row.append( + f'[No config found. Looks like configuration {configuration_uid_str} was removed ' + f'but rotation schedule was not modified]') else: - row.append( - f"{bcolors.FAIL}[No config found. Looks like configuration {configuration_uid_str} was removed but rotation schedule was not modified{bcolors.ENDC}") + if not is_verbose: + row.append(f"{bcolors.FAIL}[No config found]{bcolors.ENDC}") + else: + row.append( + f"{bcolors.FAIL}[No config found. Looks like configuration {configuration_uid_str} was removed but rotation schedule was not modified{bcolors.ENDC}") else: pam_config_name, pam_config_type = get_vault_record_title_type(params, configuration_uid_str) @@ -1813,13 +1851,19 @@ def execute(self, params, **kwargs): row.append(f"{pam_config_name or '[untitled]'} ({pam_config_type or '[unknown]'})") if is_verbose: - row.append(f'{utils.base64_url_encode(configuration_uid)}{bcolors.ENDC}') + if format_type == 'json': + row.append(utils.base64_url_encode(configuration_uid)) + else: + row.append(f'{utils.base64_url_encode(configuration_uid)}{bcolors.ENDC}') table.append(row) table.sort(key=lambda x: (x[1] or '')) - dump_report_data(table, headers, fmt='table', filename="", row_number=False, column_width=None) + report = dump_report_data(table, headers, fmt=format_type, filename="", + row_number=False, column_width=None) + if format_type == 'json': + return report print(f"\n{bcolors.OKBLUE}----------------------------------------------------------{bcolors.ENDC}") print(f"{bcolors.OKBLUE}Example to rotate record to which this user has access to:{bcolors.ENDC}") @@ -4204,13 +4248,18 @@ class PAMGatewayActionServerInfoCommand(Command): parser = argparse.ArgumentParser(prog='dr-info-command') parser.add_argument('--gateway', '-g', required=False, dest='gateway_uid', action='store', help='Gateway UID') parser.add_argument('--verbose', '-v', required=False, dest='verbose', action='store_true', help='Verbose Output') + parser.add_argument('--format', dest='format', action='store', choices=['text', 'json'], + default='text', help='Output format (text, json)') def get_parser(self): return PAMGatewayActionServerInfoCommand.parser def execute(self, params, **kwargs): + from .pam.router_helper import get_response_payload + destination_gateway_uid_str = kwargs.get('gateway_uid') is_verbose = kwargs.get('verbose') + format_type = kwargs.get('format') or 'text' router_response = router_send_action_to_gateway( params=params, gateway_action=GatewayActionGatewayInfo(is_scheduled=False), @@ -4219,6 +4268,23 @@ def execute(self, params, **kwargs): destination_gateway_uid_str=destination_gateway_uid_str ) + if format_type == 'json': + if not router_response: + print(json.dumps({"gateway_info": None, "message": "No response from gateway."}, indent=2)) + return + payload = get_response_payload(router_response) + if not (payload.get('is_ok') or payload.get('isOk')): + print(json.dumps({"ok": False, "response": payload}, indent=2)) + return + result = { + "ok": True, + "gateway_info": payload.get('data'), + } + if payload.get('warnings'): + result['warnings'] = payload.get('warnings') + print(json.dumps(result, indent=2)) + return + print_router_response(router_response, 'gateway_info', is_verbose=is_verbose, gateway_uid=destination_gateway_uid_str) diff --git a/keepercommander/commands/pam_cloud/pam_privileged_access.py b/keepercommander/commands/pam_cloud/pam_privileged_access.py index 768302c17..d783057d0 100644 --- a/keepercommander/commands/pam_cloud/pam_privileged_access.py +++ b/keepercommander/commands/pam_cloud/pam_privileged_access.py @@ -417,16 +417,111 @@ class PAMAccessUserListCommand(Command): description='List users in the Identity Provider') parser.add_argument('--config', '-c', required=True, dest='config_uid', help='PAM configuration UID') + parser.add_argument('--format', '-f', dest='output_format', choices=['table', 'json'], + default='table', help='Output format (default: table)') parser.add_argument('--gateway', '-g', dest='gateway', help='Gateway UID or name') def get_parser(self): return PAMAccessUserListCommand.parser + @staticmethod + def _normalize_user(entry): + """Normalize a group member entry into a dict with id/name keys.""" + if isinstance(entry, dict): + user_id = entry.get('id') or entry.get('userId') or entry.get('uid') or '' + name = (entry.get('name') or entry.get('primaryEmail') or entry.get('username') + or entry.get('mail') or entry.get('userPrincipalName') or '') + return {'id': user_id, 'name': name, 'raw': entry} + if isinstance(entry, str): + return {'id': '', 'name': entry, 'raw': {'name': entry}} + return None + def execute(self, params, **kwargs): - raise CommandError('pam-privileged-access', - 'User listing is not yet implemented. ' - 'Use "pam idp group list" to list groups, or check the IdP portal directly.') + config_uid = kwargs['config_uid'] + idp_config_uid = resolve_pam_idp_config(params, config_uid) + + # There is no dedicated IdP user-list gateway action; reuse group list with members. + inputs = GatewayActionIdpInputs( + configuration_uid=config_uid, + idp_config_uid=idp_config_uid, + includeUsers=True, + ) + action = GatewayActionIdpGroupList(inputs=inputs) + + payload = _dispatch_idp_action(params, action, kwargs.get('gateway')) + + response_data = payload.get('data', {}) + if isinstance(response_data, str): + try: + response_data = json.loads(response_data) + except (json.JSONDecodeError, TypeError): + response_data = {} + + if not isinstance(response_data, dict) or not response_data.get('success'): + error = response_data.get('error', 'Unknown error') if isinstance(response_data, dict) else str(response_data) + raise CommandError('pam-privileged-access', f'Gateway reported failure: {error}') + + encrypted_content = response_data.get('data') + if not encrypted_content: + if kwargs.get('output_format') == 'json': + print(json.dumps([], indent=2)) + else: + print('No users found.') + return + + groups = _decrypt_gateway_data(params, config_uid, encrypted_content) + + users_by_key = {} + if isinstance(groups, list): + for group in groups: + if not isinstance(group, dict): + continue + group_info = { + 'id': group.get('id', ''), + 'name': group.get('name', ''), + } + members = group.get('users', []) + if not isinstance(members, list): + continue + for member in members: + normalized = self._normalize_user(member) + if not normalized: + continue + key = (normalized['id'] or normalized['name'] or '').lower() + if not key: + continue + if key not in users_by_key: + users_by_key[key] = { + 'id': normalized['id'], + 'name': normalized['name'], + 'groups': [], + } + users_by_key[key]['groups'].append(group_info) + + users = sorted(users_by_key.values(), key=lambda u: (u.get('name') or '').lower()) + + if kwargs.get('output_format') == 'json': + print(json.dumps(users, indent=2)) + return + + if not users: + print('No users found.') + return + + from keepercommander.commands.base import dump_report_data + headers = ['User ID', 'Name', 'Groups'] + table = [] + for user in users: + group_names = ', '.join( + g.get('name') or g.get('id') or '' for g in user.get('groups', []) + ) + table.append([ + user.get('id', ''), + user.get('name', ''), + group_names, + ]) + dump_report_data(table, headers=headers) # --- Group Commands --- diff --git a/keepercommander/commands/pam_saas/user.py b/keepercommander/commands/pam_saas/user.py index d006c13f0..ecae1f016 100644 --- a/keepercommander/commands/pam_saas/user.py +++ b/keepercommander/commands/pam_saas/user.py @@ -1,5 +1,6 @@ from __future__ import annotations import argparse +import json from ..discover import PAMGatewayActionDiscoverCommandBase, GatewayContext from ...display import bcolors from ... import vault @@ -19,6 +20,8 @@ class PAMActionSaasUserCommand(PAMGatewayActionDiscoverCommandBase): parser.add_argument('--user-record-uid', '-u', required=True, dest='user_uid', action='store', help='The UID of the User record') + parser.add_argument('--format', dest='format', action='store', choices=['text', 'json'], + default='text', help='Output format (text, json)') def get_parser(self): return PAMActionSaasUserCommand.parser @@ -26,35 +29,45 @@ def get_parser(self): def execute(self, params: KeeperParams, **kwargs): user_uid = kwargs.get("user_uid") # type: str + format_type = kwargs.get("format") or "text" + as_json = format_type == "json" - print("") + def _fail(message: str): + if as_json: + print(json.dumps({"user": None, "message": message}, indent=2)) + else: + print("") + print(self._f(message)) + + if not as_json: + print("") # Check to see if the record exists. user_record = vault.KeeperRecord.load(params, user_uid) # type: Optional[TypedRecord] if user_record is None: - print(self._f("The user record does not exists.")) + _fail("The user record does not exists.") return # Make sure this user is a PAM User. if user_record.record_type != PAM_USER: - print(self._f("The user record is not a PAM User.")) + _fail("The user record is not a PAM User.") return record_rotation = params.record_rotation_cache.get(user_record.record_uid) if record_rotation is not None: configuration_uid = record_rotation.get("configuration_uid") else: - print(self._f("The user record does not have any rotation settings.")) + _fail("The user record does not have any rotation settings.") return if configuration_uid is None: - print(self._f("The user record does not have the configuration record set in the rotation settings.")) + _fail("The user record does not have the configuration record set in the rotation settings.") return gateway_context = GatewayContext.from_configuration_uid(params, configuration_uid) if gateway_context is None: - print(self._f("The user record does not have the set gateway")) + _fail("The user record does not have the set gateway") return plugins = get_plugins_map(params, gateway_context) @@ -62,12 +75,19 @@ def execute(self, params: KeeperParams, **kwargs): record_link = RecordLink(record=gateway_context.configuration, params=params, fail_on_corrupt=False) user_vertex = record_link.get_record_link(user_uid) if user_vertex is None: - print(self._f("Cannot find the user in the record link graph.")) + _fail("Cannot find the user in the record link graph.") return - print(self._h(user_record.title)) + result = { + "user": { + "uid": user_record.record_uid, + "title": user_record.title, + }, + "parents": [], + } - missing_configs = [] + if not as_json: + print(self._h(user_record.title)) # User's can have multiple ACL edges to different parents. # One of those ACL edges, in the rotation settings, may a populated saas_record_uid_list @@ -76,27 +96,61 @@ def execute(self, params: KeeperParams, **kwargs): # Check to see if the record exists. parent_record = vault.KeeperRecord.load(params, parent_vertex.uid) # type: Optional[TypedRecord] if parent_record is None: - print(self._f(f"* Parent record UID {parent_vertex.uid} does not exists.")) - print(" The record may have been deleted, however the relationship still exists.") - print("") + parent_entry = { + "uid": parent_vertex.uid, + "title": None, + "record_type": None, + "message": "Parent record does not exist. The record may have been deleted, " + "however the relationship still exists.", + "saas_configs": [], + } + result["parents"].append(parent_entry) + if not as_json: + print(self._f(f"* Parent record UID {parent_vertex.uid} does not exists.")) + print(" The record may have been deleted, however the relationship still exists.") + print("") continue - print(self._b(f" * {parent_record.title}, {parent_record.record_type}")) - print("") + parent_entry = { + "uid": parent_record.record_uid, + "title": parent_record.title, + "record_type": parent_record.record_type, + "saas_configs": [], + } + + if not as_json: + print(self._b(f" * {parent_record.title}, {parent_record.record_type}")) + print("") acl = record_link.get_acl(user_uid, parent_vertex.uid) if acl is not None and acl.rotation_settings is not None: saas_record_uid_list = acl.rotation_settings.saas_record_uid_list if saas_record_uid_list is None or len(saas_record_uid_list) == 0: - print(f"{bcolors.WARNING} The user does not have any SaaS service rotations.{bcolors.ENDC}") + message = "The user does not have any SaaS service rotations." + parent_entry["message"] = message + result["parents"].append(parent_entry) + if as_json: + print(json.dumps(result, indent=2)) + else: + print(f"{bcolors.WARNING} {message}{bcolors.ENDC}") return for config_record_uid in saas_record_uid_list: config_record = vault.KeeperRecord.load(params, config_record_uid) # type: Optional[TypedRecord] if config_record is None: - print(f"{bcolors.WARNING} * Record UID {config_record_uid} not longer exists.{bcolors.ENDC}") + missing = { + "uid": config_record_uid, + "title": None, + "message": "Record no longer exists.", + } + parent_entry["saas_configs"].append(missing) + if not as_json: + print(f"{bcolors.WARNING} * Record UID {config_record_uid} not longer exists." + f"{bcolors.ENDC}") continue - print(self._gr(f" {config_record.title}")) + + if not as_json: + print(self._gr(f" {config_record.title}")) plugin_name = "" saas_type_field = next((x for x in config_record.custom if x.label == "SaaS Type"), None) @@ -105,27 +159,20 @@ def execute(self, params: KeeperParams, **kwargs): plugin_name = saas_type_field.value[0] plugin = plugins.get(plugin_name) + supported = plugin is not None - # This might have been a valid plugin, or the name is mistyped, so it's not supported. - if plugin is None: - plugin_name += " (" + self._f("Not Supported") + ")" - - rotation_active = self._gr("Active") + is_active = True rotation_active_field = next((x for x in config_record.custom if x.label == "Active"), None) if (rotation_active_field is not None and rotation_active_field.value is not None and len(rotation_active_field.value) > 0): is_active = value_to_boolean(rotation_active_field.value[0]) - if is_active is False: - rotation_active = self._f("Inactive") - - print(f" {bcolors.BOLD}SaaS Type{bcolors.ENDC}: {plugin_name}") - print(f" {bcolors.BOLD}Config Record UID{bcolors.ENDC}: {config_record.record_uid}") - print(f" {bcolors.BOLD}Active{bcolors.ENDC}: {rotation_active}") + if is_active is None: + is_active = True + fields = {} if plugin is not None: - for field in plugin.fields: value = next((x.value for x in config_record.custom if x.label == field.label), None) if value is not None: @@ -133,10 +180,48 @@ def execute(self, params: KeeperParams, **kwargs): value = value[0] else: value = None - if value is None: - if field.default_value is not None: - value = f"{field.default_value} ({bcolors.OKBLUE}Default{bcolors.ENDC})" - else: + field_info = { + "value": value, + "default": False, + "set": value is not None, + } + if value is None and field.default_value is not None: + field_info["value"] = field.default_value + field_info["default"] = True + field_info["set"] = False + fields[field.label] = field_info + + saas_entry = { + "uid": config_record.record_uid, + "title": config_record.title, + "saas_type": plugin_name, + "supported": supported, + "active": bool(is_active), + "fields": fields, + } + parent_entry["saas_configs"].append(saas_entry) + + if not as_json: + display_plugin = plugin_name + if not supported: + display_plugin += " (" + self._f("Not Supported") + ")" + rotation_active = self._gr("Active") if is_active else self._f("Inactive") + print(f" {bcolors.BOLD}SaaS Type{bcolors.ENDC}: {display_plugin}") + print(f" {bcolors.BOLD}Config Record UID{bcolors.ENDC}: {config_record.record_uid}") + print(f" {bcolors.BOLD}Active{bcolors.ENDC}: {rotation_active}") + + if plugin is not None: + for field in plugin.fields: + field_info = fields[field.label] + value = field_info["value"] + if not field_info["set"] and field_info["default"]: + value = f"{value} ({bcolors.OKBLUE}Default{bcolors.ENDC})" + elif not field_info["set"]: value = f"{bcolors.FAIL}Not Set{bcolors.ENDC}" - print(f" {bcolors.BOLD}{field.label}{bcolors.ENDC}: {value}") - print("") + print(f" {bcolors.BOLD}{field.label}{bcolors.ENDC}: {value}") + print("") + + result["parents"].append(parent_entry) + + if as_json: + print(json.dumps(result, indent=2)) diff --git a/keepercommander/commands/pam_service/list.py b/keepercommander/commands/pam_service/list.py index df571a3d9..bdb5e99c2 100644 --- a/keepercommander/commands/pam_service/list.py +++ b/keepercommander/commands/pam_service/list.py @@ -1,5 +1,6 @@ from __future__ import annotations import argparse +import json from ..discover import PAMGatewayActionDiscoverCommandBase, GatewayContext, MultiConfigurationException, multi_conf_msg from ...display import bcolors from ... import vault @@ -26,11 +27,13 @@ class PAMActionServiceListCommand(PAMGatewayActionDiscoverCommandBase): action='store', help='PAM configuration UID, if gateway has multiple.') parser.add_argument('--by-machine', '-m', required=False, dest='do_by_machine', action='store_true', help='List by machine') + parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], + default='table', help='Output format (table, json)') def get_parser(self): return PAMActionServiceListCommand.parser - def _by_user(self, params: KeeperParams, record_link: RecordLink, user_service: UserService): + def _collect_by_user(self, params: KeeperParams, record_link: RecordLink, user_service: UserService): service_map = {} for resource_vertex in record_link.dag.get_root.has_vertices(edge_type=EdgeType.LINK): @@ -63,31 +66,18 @@ def _by_user(self, params: KeeperParams, record_link: RecordLink, user_service: if user_record.record_uid not in service_map: service_map[user_record.record_uid] = { "title": user_record.title, + "uid": user_record.record_uid, "active": user_active, "machines": [] } - text = f"{resource_record.title} ({resource_record.record_uid})" - if not resource_active: - text += f" : {bcolors.FAIL}Disabled{bcolors.ENDC}" - service_map[user_record.record_uid]["machines"].append(text) - - print("") - printed_something = False - print(self._h("User Mapping")) - for user_uid in service_map: - user = service_map[user_uid] - printed_something = True - active_text = "" - if not user['active']: - active_text = f" {bcolors.FAIL}Disabled{bcolors.ENDC}" - print(f" {self._b(user['title'])} ({user_uid}){active_text}") - for machine in user["machines"]: - print(f" * {machine}") - print("") - if not printed_something: - print(f" {bcolors.FAIL}There are no service mappings.{bcolors.ENDC}") - - def _by_machine(self, params: KeeperParams, record_link: RecordLink, user_service: UserService): + service_map[user_record.record_uid]["machines"].append({ + "title": resource_record.title, + "uid": resource_record.record_uid, + "active": resource_active, + }) + return service_map + + def _collect_by_machine(self, params: KeeperParams, record_link: RecordLink, user_service: UserService): service_map = {} for resource_vertex in record_link.dag.get_root.has_vertices(edge_type=EdgeType.LINK): resource_record = vault.KeeperRecord.load(params, resource_vertex.uid) # type: Optional[TypedRecord] @@ -119,26 +109,53 @@ def _by_machine(self, params: KeeperParams, record_link: RecordLink, user_servic if resource_record.record_uid not in service_map: service_map[resource_record.record_uid] = { "title": resource_record.title, + "uid": resource_record.record_uid, "active": resource_active, "users": [] } - text = f"{user_record.title} ({user_record.record_uid})" - if not user_active: - text += f" : {bcolors.FAIL}Disabled{bcolors.ENDC}" - service_map[resource_record.record_uid]["users"].append(text) + service_map[resource_record.record_uid]["users"].append({ + "title": user_record.title, + "uid": user_record.record_uid, + "active": user_active, + }) + return service_map + + def _print_by_user(self, service_map): + print("") + printed_something = False + print(self._h("User Mapping")) + for user_uid in service_map: + user = service_map[user_uid] + printed_something = True + active_text = "" + if not user['active']: + active_text = f" {bcolors.FAIL}Disabled{bcolors.ENDC}" + print(f" {self._b(user['title'])} ({user_uid}){active_text}") + for machine in user["machines"]: + text = f"{machine['title']} ({machine['uid']})" + if not machine['active']: + text += f" : {bcolors.FAIL}Disabled{bcolors.ENDC}" + print(f" * {text}") + print("") + if not printed_something: + print(f" {bcolors.FAIL}There are no service mappings.{bcolors.ENDC}") + def _print_by_machine(self, service_map): print("") printed_something = False print(self._h("Machine Mapping")) for resource_uid in service_map: - user = service_map[resource_uid] + resource = service_map[resource_uid] printed_something = True active_text = "" - if not user['active']: + if not resource['active']: active_text = f" {bcolors.FAIL}Disabled{bcolors.ENDC}" - print(f" {self._b(user['title'])} ({resource_uid}){active_text}") - for user in user["users"]: - print(f" * {user}") + print(f" {self._b(resource['title'])} ({resource_uid}){active_text}") + for user in resource["users"]: + text = f"{user['title']} ({user['uid']})" + if not user['active']: + text += f" : {bcolors.FAIL}Disabled{bcolors.ENDC}" + print(f" * {text}") print("") if not printed_something: print(f" {bcolors.FAIL}There are no service mappings.{bcolors.ENDC}") @@ -146,16 +163,35 @@ def _by_machine(self, params: KeeperParams, record_link: RecordLink, user_servic def execute(self, params: KeeperParams, **kwargs): gateway = kwargs.get("gateway", "none_set") + format_type = kwargs.get('format') or 'table' try: gateway_context = GatewayContext.from_gateway(params=params, gateway=gateway, configuration_uid=kwargs.get('configuration_uid')) if gateway_context is None: - print(f"{bcolors.FAIL}Could not find the gateway configuration for {gateway}.{bcolors.ENDC}") + message = f'Could not find the gateway configuration for {gateway}.' + if format_type == 'json': + print(json.dumps({'message': message}, indent=2)) + else: + print(f"{bcolors.FAIL}{message}{bcolors.ENDC}") return except MultiConfigurationException as err: - multi_conf_msg(gateway, err) + if format_type == 'json': + configs = [] + for item in (err.items or []): + record = item.get('configuration_record') + if record is not None: + configs.append({ + 'uid': getattr(record, 'record_uid', ''), + 'title': getattr(record, 'title', ''), + }) + print(json.dumps({ + 'message': f'Found multiple configuration records for gateway {gateway}.', + 'configurations': configs, + }, indent=2)) + else: + multi_conf_msg(gateway, err) return record_link = RecordLink(record=gateway_context.configuration, @@ -171,11 +207,28 @@ def execute(self, params: KeeperParams, **kwargs): fail_on_corrupt=False, agent=f"Cmdr/{__version__}") - if kwargs.get("do_by_machine"): - self._by_machine(params=params, - record_link=record_link, - user_service=user_service) + by_machine = bool(kwargs.get("do_by_machine")) + if by_machine: + service_map = self._collect_by_machine(params=params, + record_link=record_link, + user_service=user_service) + else: + service_map = self._collect_by_user(params=params, + record_link=record_link, + user_service=user_service) + + if format_type == 'json': + payload = { + 'gateway': gateway_context.gateway_name, + 'gateway_uid': gateway_context.gateway_uid, + 'configuration_uid': gateway_context.configuration_uid, + 'group_by': 'machine' if by_machine else 'user', + 'mappings': list(service_map.values()), + } + print(json.dumps(payload, indent=2)) + return + + if by_machine: + self._print_by_machine(service_map) else: - self._by_user(params=params, - record_link=record_link, - user_service=user_service) + self._print_by_user(service_map) diff --git a/keepercommander/commands/record.py b/keepercommander/commands/record.py index c8c87fc93..2fa86f0df 100644 --- a/keepercommander/commands/record.py +++ b/keepercommander/commands/record.py @@ -1990,6 +1990,8 @@ def fetch_members(team_uid): # type: (str) -> List[str] trash_get_parser = argparse.ArgumentParser(prog='trash get', description='Get the details of a deleted record') +trash_get_parser.add_argument('--format', dest='format', action='store', choices=['detail', 'json'], + default='detail', help='output format') trash_get_parser.add_argument('record', action='store', help='Deleted record UID') trash_restore_parser = argparse.ArgumentParser(prog='trash restore', description='Restores deleted records') @@ -2308,13 +2310,20 @@ def get_parser(self): def execute(self, params, **kwargs): deleted_records = self.get_deleted_records(params) orphaned_records = self.get_orphaned_records(params) + fmt = kwargs.get('format') or 'detail' if len(deleted_records) == 0 and len(orphaned_records) == 0: - logging.info('Trash is empty') + if fmt == 'json': + print(json.dumps({'message': 'Trash is empty'}, indent=2)) + else: + logging.info('Trash is empty') return record_uid = kwargs.get('record') if not record_uid: - logging.info('Record UID parameter is required') + if fmt == 'json': + print(json.dumps({'message': 'Record UID parameter is required'}, indent=2)) + else: + logging.info('Record UID parameter is required') return is_shared = False @@ -2323,12 +2332,68 @@ def execute(self, params, **kwargs): rec = orphaned_records.get(record_uid) is_shared = True if not rec: - logging.info('%s is not a valid deleted record UID', record_uid) + message = f'{record_uid} is not a valid deleted record UID' + if fmt == 'json': + print(json.dumps({'message': message}, indent=2)) + else: + logging.info('%s is not a valid deleted record UID', record_uid) return record = vault.KeeperRecord.load(params, rec) if not record: - logging.info('Cannot restore record %s', record_uid) + message = f'Cannot restore record {record_uid}' + if fmt == 'json': + print(json.dumps({'message': message}, indent=2)) + else: + logging.info('Cannot restore record %s', record_uid) + return + + if fmt == 'json': + payload = { + 'record_uid': record.record_uid, + 'title': record.title, + 'record_type': record.record_type, + 'status': 'Share' if is_shared else 'Record', + 'fields': {}, + } + for name, value in record.enumerate_fields(): + if value: + if isinstance(value, list): + payload['fields'][name] = value + elif len(value) > 100: + payload['fields'][name] = value[:99] + '...' + else: + payload['fields'][name] = value + + if is_shared: + if 'shares' not in rec: + rec['shares'] = {} + shares = api.get_record_shares(params, (record_uid,), True) + if isinstance(shares, list): + record_shares = next( + (x.get('shares') for x in shares if x.get('record_uid') == record_uid), None) + if isinstance(record_shares, dict): + rec['shares'] = record_shares + + user_shares = [] + if 'shares' in rec and 'user_permissions' in rec['shares']: + for uo in rec['shares']['user_permissions']: + if uo.get('owner'): + continue + flags = [] + if uo.get('editable'): + flags.append('Can Edit') + if uo.get('shareable'): + flags.append('Can Share') + user_shares.append({ + 'username': uo.get('username'), + 'permissions': ' & '.join(flags) if flags else 'Read Only', + 'self': uo.get('username') == params.user, + }) + if user_shares: + payload['direct_user_shares'] = user_shares + + print(json.dumps(payload, indent=2, default=base.json_serialized)) return for name, value in record.enumerate_fields(): diff --git a/keepercommander/commands/tunnel_and_connections.py b/keepercommander/commands/tunnel_and_connections.py index 2b478e83e..32cb55b9a 100644 --- a/keepercommander/commands/tunnel_and_connections.py +++ b/keepercommander/commands/tunnel_and_connections.py @@ -124,13 +124,19 @@ def __init__(self): # Individual Commands class PAMTunnelListCommand(Command): pam_cmd_parser = argparse.ArgumentParser(prog='pam tunnel list') + pam_cmd_parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], + default='table', help='Output format (table, json)') def get_parser(self): return PAMTunnelListCommand.pam_cmd_parser def execute(self, params, **kwargs): + format_type = kwargs.get('format') or 'table' table = [] - headers = ['Record', 'Remote Target', 'Local Address', 'Tunnel ID', 'Conversation ID', 'Status'] + if format_type == 'json': + headers = ['record', 'remote_target', 'local_address', 'tunnel_id', 'conversation_id', 'status'] + else: + headers = ['Record', 'Remote Target', 'Local Address', 'Tunnel ID', 'Conversation ID', 'Status'] # In-process tunnels from the Rust PyTubeRegistry tube_registry = get_or_create_tube_registry(params) @@ -142,28 +148,39 @@ def execute(self, params, **kwargs): conversation_ids = tube_registry.get_conversation_ids_by_tube_id(tube_id) tunnel_session = get_tunnel_session(tube_id) - record_title = tunnel_session.record_title if tunnel_session and tunnel_session.record_title else f"{bcolors.WARNING}unknown{bcolors.ENDC}" + if tunnel_session and tunnel_session.record_title: + record_title = tunnel_session.record_title + else: + record_title = 'unknown' if tunnel_session and tunnel_session.target_host and tunnel_session.target_port: remote_target = f"{tunnel_session.target_host}:{tunnel_session.target_port}" else: - remote_target = f"{bcolors.WARNING}unknown{bcolors.ENDC}" + remote_target = 'unknown' if tunnel_session and tunnel_session.host and tunnel_session.port: - local_addr = f"{bcolors.OKGREEN}{tunnel_session.host}:{tunnel_session.port}{bcolors.ENDC}" + local_addr = f"{tunnel_session.host}:{tunnel_session.port}" else: - local_addr = f"{bcolors.WARNING}unknown{bcolors.ENDC}" + local_addr = 'unknown' conv_id = conversation_ids[0] if conversation_ids else (tunnel_session.conversation_id if tunnel_session else 'none') try: state = tube_registry.get_connection_state(tube_id) - status_color = f"{bcolors.OKGREEN}" if state.lower() == "connected" else f"{bcolors.WARNING}" - status = f"{status_color}{state}{bcolors.ENDC}" + status = state except Exception: - status = f"{bcolors.WARNING}unknown{bcolors.ENDC}" + status = 'unknown' - table.append([record_title, remote_target, local_addr, tube_id, conv_id, status]) + if format_type == 'json': + table.append([record_title, remote_target, local_addr, tube_id, conv_id, status]) + else: + rec_disp = record_title if record_title != 'unknown' else f"{bcolors.WARNING}unknown{bcolors.ENDC}" + rem_disp = remote_target if remote_target != 'unknown' else f"{bcolors.WARNING}unknown{bcolors.ENDC}" + loc_disp = (f"{bcolors.OKGREEN}{local_addr}{bcolors.ENDC}" + if local_addr != 'unknown' else f"{bcolors.WARNING}unknown{bcolors.ENDC}") + status_color = f"{bcolors.OKGREEN}" if status.lower() == "connected" else f"{bcolors.WARNING}" + status_disp = f"{status_color}{status}{bcolors.ENDC}" + table.append([rec_disp, rem_disp, loc_disp, tube_id, conv_id, status_disp]) # Cross-process tunnels from the file-based registry for entry in list_registered_tunnels(): @@ -173,20 +190,31 @@ def execute(self, params, **kwargs): rec = entry.get('record_title') or entry.get('record_uid', '?') th = entry.get('target_host') tp = entry.get('target_port') - remote = f"{th}:{tp}" if th and tp else f"{bcolors.WARNING}n/a{bcolors.ENDC}" + remote = f"{th}:{tp}" if th and tp else 'n/a' h = entry.get('host', '127.0.0.1') p = entry.get('port', '?') - local = f"{bcolors.OKGREEN}{h}:{p}{bcolors.ENDC}" + local = f"{h}:{p}" tid = entry.get('tube_id', '') mode = entry.get('mode', '?') - status = f"{bcolors.OKGREEN}{mode} (PID {pid}){bcolors.ENDC}" - table.append([rec, remote, local, tid, '', status]) + status = f"{mode} (PID {pid})" + if format_type == 'json': + table.append([rec, remote, local, tid, '', status]) + else: + rem_disp = remote if remote != 'n/a' else f"{bcolors.WARNING}n/a{bcolors.ENDC}" + table.append([rec, rem_disp, f"{bcolors.OKGREEN}{local}{bcolors.ENDC}", tid, '', + f"{bcolors.OKGREEN}{status}{bcolors.ENDC}"]) if not table: - logging.warning(f"{bcolors.OKBLUE}No Tunnels running{bcolors.ENDC}") + message = "No Tunnels running" + if format_type == 'json': + print(json.dumps({"tunnels": [], "message": message}, indent=2)) + else: + logging.warning(f"{bcolors.OKBLUE}{message}{bcolors.ENDC}") return - dump_report_data(table, headers, fmt='table', filename="", row_number=False, column_width=None) + report = dump_report_data(table, headers, fmt=format_type, filename="", row_number=False, column_width=None) + if format_type == 'json': + return report class PAMTunnelStopCommand(Command):