praetorian-cli 2.2.1__py3-none-any.whl → 2.2.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- praetorian_cli/handlers/add.py +25 -7
- praetorian_cli/handlers/aegis.py +107 -0
- praetorian_cli/handlers/delete.py +3 -2
- praetorian_cli/handlers/get.py +48 -2
- praetorian_cli/handlers/list.py +41 -9
- praetorian_cli/handlers/ssh_utils.py +154 -0
- praetorian_cli/handlers/test.py +7 -2
- praetorian_cli/handlers/update.py +3 -3
- praetorian_cli/main.py +1 -0
- praetorian_cli/sdk/chariot.py +71 -12
- praetorian_cli/sdk/entities/aegis.py +437 -0
- praetorian_cli/sdk/entities/assets.py +30 -12
- praetorian_cli/sdk/entities/scanners.py +13 -0
- praetorian_cli/sdk/entities/schema.py +27 -0
- praetorian_cli/sdk/entities/seeds.py +108 -56
- praetorian_cli/sdk/mcp_server.py +2 -3
- praetorian_cli/sdk/model/aegis.py +156 -0
- praetorian_cli/sdk/model/query.py +1 -1
- praetorian_cli/sdk/model/utils.py +2 -8
- praetorian_cli/sdk/test/pytest.ini +1 -0
- praetorian_cli/sdk/test/test_asset.py +2 -2
- praetorian_cli/sdk/test/test_seed.py +13 -14
- praetorian_cli/sdk/test/test_z_cli.py +22 -24
- praetorian_cli/sdk/test/ui_mocks.py +133 -0
- praetorian_cli/sdk/test/utils.py +16 -4
- praetorian_cli/ui/__init__.py +3 -0
- praetorian_cli/ui/aegis/__init__.py +5 -0
- praetorian_cli/ui/aegis/commands/__init__.py +2 -0
- praetorian_cli/ui/aegis/commands/help.py +81 -0
- praetorian_cli/ui/aegis/commands/info.py +136 -0
- praetorian_cli/ui/aegis/commands/job.py +381 -0
- praetorian_cli/ui/aegis/commands/list.py +14 -0
- praetorian_cli/ui/aegis/commands/set.py +32 -0
- praetorian_cli/ui/aegis/commands/ssh.py +87 -0
- praetorian_cli/ui/aegis/constants.py +20 -0
- praetorian_cli/ui/aegis/menu.py +395 -0
- praetorian_cli/ui/aegis/utils.py +162 -0
- {praetorian_cli-2.2.1.dist-info → praetorian_cli-2.2.3.dist-info}/METADATA +4 -1
- {praetorian_cli-2.2.1.dist-info → praetorian_cli-2.2.3.dist-info}/RECORD +43 -24
- {praetorian_cli-2.2.1.dist-info → praetorian_cli-2.2.3.dist-info}/WHEEL +0 -0
- {praetorian_cli-2.2.1.dist-info → praetorian_cli-2.2.3.dist-info}/entry_points.txt +0 -0
- {praetorian_cli-2.2.1.dist-info → praetorian_cli-2.2.3.dist-info}/licenses/LICENSE +0 -0
- {praetorian_cli-2.2.1.dist-info → praetorian_cli-2.2.3.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
class MockConsole:
|
|
2
|
+
def __init__(self):
|
|
3
|
+
self.lines = []
|
|
4
|
+
|
|
5
|
+
def print(self, msg=""):
|
|
6
|
+
self.lines.append(str(msg))
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MockAegis:
|
|
10
|
+
def __init__(self, responses=None):
|
|
11
|
+
self.calls = []
|
|
12
|
+
self._responses = responses or {}
|
|
13
|
+
|
|
14
|
+
def ssh_to_agent(self, agent, options, user, display_info=True):
|
|
15
|
+
self.calls.append({
|
|
16
|
+
'method': 'ssh_to_agent',
|
|
17
|
+
'agent': agent,
|
|
18
|
+
'options': list(options),
|
|
19
|
+
'user': user,
|
|
20
|
+
'display_info': display_info,
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
def run_job(self, agent, capabilities=None, config=None):
|
|
24
|
+
self.calls.append({
|
|
25
|
+
'method': 'run_job',
|
|
26
|
+
'agent': agent,
|
|
27
|
+
'capabilities': capabilities,
|
|
28
|
+
'config': config,
|
|
29
|
+
})
|
|
30
|
+
if capabilities is None:
|
|
31
|
+
return self._responses.get('list_caps', {'capabilities': []})
|
|
32
|
+
return self._responses.get('run', {'success': True, 'job_id': 'abc123', 'job_key': 'k', 'status': 'queued'})
|
|
33
|
+
|
|
34
|
+
# Newer TUI code uses these helpers
|
|
35
|
+
def validate_capability(self, name):
|
|
36
|
+
caps = self._responses.get('capabilities', {
|
|
37
|
+
'windows-smb': {'name': 'windows-smb', 'description': 'Windows SMB capability', 'target': 'asset'},
|
|
38
|
+
'linux-enum': {'name': 'linux-enum', 'description': 'Linux enum capability', 'target': 'asset'},
|
|
39
|
+
})
|
|
40
|
+
return caps.get(name)
|
|
41
|
+
|
|
42
|
+
def create_job_config(self, agent, credentials=None):
|
|
43
|
+
# Return provided credentials or an empty config as JSON-ready dict
|
|
44
|
+
return credentials or self._responses.get('config', {})
|
|
45
|
+
|
|
46
|
+
def get_available_ad_domains(self):
|
|
47
|
+
return self._responses.get('domains', ['example.local'])
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class MockSDK:
|
|
51
|
+
def __init__(self, responses=None):
|
|
52
|
+
self.aegis = MockAegis(responses=responses)
|
|
53
|
+
self.jobs = MockJobs(responses=responses)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class MockJobs:
|
|
57
|
+
def __init__(self, responses=None):
|
|
58
|
+
self._responses = responses or {}
|
|
59
|
+
self.calls = []
|
|
60
|
+
|
|
61
|
+
def add(self, target_key, capabilities, config_json):
|
|
62
|
+
self.calls.append({
|
|
63
|
+
'method': 'add',
|
|
64
|
+
'target_key': target_key,
|
|
65
|
+
'capabilities': capabilities,
|
|
66
|
+
'config': config_json,
|
|
67
|
+
})
|
|
68
|
+
# Return a minimal job-like record the UI expects
|
|
69
|
+
return [self._responses.get('job', {
|
|
70
|
+
'key': 'jobs#abc123deadbeef',
|
|
71
|
+
'status': 'queued',
|
|
72
|
+
})]
|
|
73
|
+
|
|
74
|
+
def list(self, prefix_filter=None):
|
|
75
|
+
# Return (jobs, next_page_token)
|
|
76
|
+
jobs = self._responses.get('jobs', [])
|
|
77
|
+
return jobs, None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class MockCloudflaredStatus:
|
|
81
|
+
def __init__(self, hostname='cf.example.com', tunnel_name='tunnel-1', authorized_users=''):
|
|
82
|
+
self.hostname = hostname
|
|
83
|
+
self.tunnel_name = tunnel_name
|
|
84
|
+
self.authorized_users = authorized_users
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class MockHealthCheck:
|
|
88
|
+
def __init__(self, cf_status=None):
|
|
89
|
+
self.cloudflared_status = cf_status or MockCloudflaredStatus()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class MockAgent:
|
|
93
|
+
def __init__(self, hostname="agent01", client_id="C.1"):
|
|
94
|
+
# Basic identity
|
|
95
|
+
self.hostname = hostname
|
|
96
|
+
self.client_id = client_id
|
|
97
|
+
# System info (optional in UI)
|
|
98
|
+
self.os = None
|
|
99
|
+
self.os_version = None
|
|
100
|
+
self.architecture = None
|
|
101
|
+
self.fqdn = None
|
|
102
|
+
# Activity/timestamps
|
|
103
|
+
self.last_seen_at = 0
|
|
104
|
+
# Networking
|
|
105
|
+
self.network_interfaces = []
|
|
106
|
+
# Tunnel/health
|
|
107
|
+
self.has_tunnel = True
|
|
108
|
+
self.health_check = MockHealthCheck()
|
|
109
|
+
|
|
110
|
+
def to_detailed_string(self):
|
|
111
|
+
return f"Agent {self.hostname} ({self.client_id})"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class MockMenuBase:
|
|
115
|
+
def __init__(self):
|
|
116
|
+
self.console = MockConsole()
|
|
117
|
+
self.paused = False
|
|
118
|
+
# Minimal color map used by the UI (optional)
|
|
119
|
+
self.colors = {
|
|
120
|
+
'primary': 'cyan',
|
|
121
|
+
'accent': 'magenta',
|
|
122
|
+
'dim': 'dim',
|
|
123
|
+
'success': 'green',
|
|
124
|
+
'warning': 'yellow',
|
|
125
|
+
'error': 'red',
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
def pause(self):
|
|
129
|
+
self.paused = True
|
|
130
|
+
|
|
131
|
+
def clear_screen(self):
|
|
132
|
+
# No-op for tests; add a blank line like the real UI would
|
|
133
|
+
self.console.print()
|
praetorian_cli/sdk/test/utils.py
CHANGED
|
@@ -5,7 +5,7 @@ from random import randint
|
|
|
5
5
|
from praetorian_cli.sdk.chariot import Chariot
|
|
6
6
|
from praetorian_cli.sdk.keychain import Keychain
|
|
7
7
|
from praetorian_cli.sdk.model.globals import Risk, Preseed
|
|
8
|
-
from praetorian_cli.sdk.model.utils import risk_key, asset_key, ad_domain_key, attribute_key,
|
|
8
|
+
from praetorian_cli.sdk.model.utils import risk_key, asset_key, ad_domain_key, attribute_key, seed_asset_key, preseed_key, setting_key, configuration_key
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
def epoch_micro():
|
|
@@ -27,14 +27,26 @@ def random_dns():
|
|
|
27
27
|
def random_ad_domain():
|
|
28
28
|
return f'test-{epoch_micro()}.local'
|
|
29
29
|
|
|
30
|
+
def random_object_id():
|
|
31
|
+
domain_id_1 = randint(1000000000, 4294967295) # Start from 1 billion for realism
|
|
32
|
+
domain_id_2 = randint(1000000000, 4294967295)
|
|
33
|
+
domain_id_3 = randint(1000000000, 4294967295)
|
|
34
|
+
|
|
35
|
+
# Generate a random relative identifier (RID)
|
|
36
|
+
# Common ranges: 500-999 for built-in accounts, 1000+ for user accounts
|
|
37
|
+
relative_id = randint(1000, 999999)
|
|
38
|
+
return f"S-1-5-21-{domain_id_1}-{domain_id_2}-{domain_id_3}-{relative_id}"
|
|
39
|
+
|
|
40
|
+
|
|
30
41
|
def make_test_values(o):
|
|
31
42
|
o.asset_dns = random_dns()
|
|
32
43
|
o.asset_name = random_ip()
|
|
33
44
|
o.asset_key = asset_key(o.asset_dns, o.asset_name)
|
|
34
45
|
o.ad_domain_name = random_ad_domain()
|
|
35
|
-
o.
|
|
36
|
-
o.
|
|
37
|
-
o.
|
|
46
|
+
o.ad_object_id = random_object_id()
|
|
47
|
+
o.ad_domain_key = ad_domain_key(o.ad_domain_name, o.ad_object_id)
|
|
48
|
+
o.seed_asset_dns = random_dns()
|
|
49
|
+
o.seed_asset_key = seed_asset_key(o.seed_asset_dns)
|
|
38
50
|
o.risk_name = f'test-risk-name-{epoch_micro()}'
|
|
39
51
|
o.risk_key = risk_key(o.asset_dns, o.risk_name)
|
|
40
52
|
o.comment = f'Test comment {epoch_micro()}'
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from rich.table import Table
|
|
2
|
+
from rich.box import MINIMAL
|
|
3
|
+
from ..constants import DEFAULT_COLORS
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def handle_help(menu, args):
|
|
10
|
+
"""Show help for commands or a specific command."""
|
|
11
|
+
colors = getattr(menu, 'colors', DEFAULT_COLORS)
|
|
12
|
+
if args and args[0] in ['ssh', 'list', 'info', 'job', 'set']:
|
|
13
|
+
menu.console.print(f"\nHelp for '{args[0]}' command - see main help for details\n")
|
|
14
|
+
menu.pause()
|
|
15
|
+
return
|
|
16
|
+
|
|
17
|
+
commands_table = Table(
|
|
18
|
+
show_header=True,
|
|
19
|
+
header_style=f"bold {colors['primary']}",
|
|
20
|
+
border_style=colors['dim'],
|
|
21
|
+
box=MINIMAL,
|
|
22
|
+
show_lines=False,
|
|
23
|
+
padding=(0, 2),
|
|
24
|
+
pad_edge=False
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
commands_table.add_column("COMMAND", style=f"bold {colors['success']}", min_width=20, no_wrap=True)
|
|
28
|
+
commands_table.add_column("DESCRIPTION", style="white", no_wrap=False)
|
|
29
|
+
|
|
30
|
+
commands_table.add_row("set <id>", "Select an agent by number, client_id, or hostname")
|
|
31
|
+
commands_table.add_row("list [--all]", "List online agents (--all shows offline too)")
|
|
32
|
+
commands_table.add_row("ssh [options]", "SSH to selected agent (use 'ssh --help' for options)")
|
|
33
|
+
commands_table.add_row("info [--raw]", "Show detailed information for selected agent")
|
|
34
|
+
commands_table.add_row("job list", "List recent jobs for selected agent")
|
|
35
|
+
commands_table.add_row("job capabilities [--details]", "List available capabilities")
|
|
36
|
+
commands_table.add_row("job run <capability>", "Run capability on selected agent")
|
|
37
|
+
commands_table.add_row("reload", "Refresh agent list from server")
|
|
38
|
+
commands_table.add_row("help [command]", "Show this help or command-specific help")
|
|
39
|
+
commands_table.add_row("clear", "Clear terminal screen")
|
|
40
|
+
commands_table.add_row("quit / exit", "Exit the interface")
|
|
41
|
+
|
|
42
|
+
menu.console.print()
|
|
43
|
+
menu.console.print(" Available Commands")
|
|
44
|
+
menu.console.print()
|
|
45
|
+
menu.console.print(commands_table)
|
|
46
|
+
|
|
47
|
+
examples_table = Table(
|
|
48
|
+
show_header=True,
|
|
49
|
+
header_style=f"bold {colors['warning']}",
|
|
50
|
+
border_style=colors['dim'],
|
|
51
|
+
box=MINIMAL,
|
|
52
|
+
show_lines=False,
|
|
53
|
+
padding=(0, 2),
|
|
54
|
+
pad_edge=False
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
examples_table.add_column("EXAMPLE", style=f"bold {colors['accent']}", min_width=25, no_wrap=True)
|
|
58
|
+
examples_table.add_column("DESCRIPTION", style=f"{colors['dim']}", no_wrap=False)
|
|
59
|
+
|
|
60
|
+
examples_table.add_row("set 1", "Select first agent")
|
|
61
|
+
examples_table.add_row("set abc", "Select agent by hostname")
|
|
62
|
+
examples_table.add_row("ssh -D 1080", "SSH with SOCKS proxy on port 1080")
|
|
63
|
+
examples_table.add_row("list --all", "Show all agents including offline")
|
|
64
|
+
examples_table.add_row("job list", "List recent jobs")
|
|
65
|
+
examples_table.add_row("job capabilities", "List available capabilities")
|
|
66
|
+
examples_table.add_row("job caps --details", "Show full capability descriptions")
|
|
67
|
+
examples_table.add_row("job run windows-enum", "Run capability on selected agent")
|
|
68
|
+
examples_table.add_row("info", "Show agent details")
|
|
69
|
+
examples_table.add_row("info --raw", "Show raw agent data (JSON format)")
|
|
70
|
+
|
|
71
|
+
menu.console.print()
|
|
72
|
+
menu.console.print(" Examples")
|
|
73
|
+
menu.console.print()
|
|
74
|
+
menu.console.print(examples_table)
|
|
75
|
+
menu.console.print()
|
|
76
|
+
menu.pause()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def complete(menu, text, tokens):
|
|
80
|
+
# Suggest commands after 'help '
|
|
81
|
+
return [c for c in menu.commands if c.startswith(text)]
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
def handle_info(menu, args):
|
|
2
|
+
"""Show detailed information for the selected agent."""
|
|
3
|
+
if not menu.selected_agent:
|
|
4
|
+
menu.console.print("\n No agent selected. Use 'set <id>' to select one.\n")
|
|
5
|
+
menu.pause()
|
|
6
|
+
return
|
|
7
|
+
|
|
8
|
+
# Check for raw flag
|
|
9
|
+
raw = ('--raw' in args) or ('-r' in args)
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
_show_agent_info(menu, menu.selected_agent, raw=raw)
|
|
13
|
+
except Exception as e:
|
|
14
|
+
menu.console.print(f"[red]Error getting agent info: {e}[/red]")
|
|
15
|
+
menu.pause()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _show_agent_info(menu, agent, raw=False):
|
|
19
|
+
"""Show detailed agent info with clean formatting"""
|
|
20
|
+
import json
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
|
|
23
|
+
colors = getattr(menu, 'colors', {})
|
|
24
|
+
hostname = agent.hostname or 'Unknown'
|
|
25
|
+
|
|
26
|
+
# Clear screen and show header
|
|
27
|
+
menu.clear_screen()
|
|
28
|
+
menu.console.print()
|
|
29
|
+
menu.console.print(f" [{colors.get('primary', 'cyan')}]Agent Details[/{colors.get('primary', 'cyan')}]")
|
|
30
|
+
menu.console.print()
|
|
31
|
+
|
|
32
|
+
if raw:
|
|
33
|
+
# Raw JSON dump with minimal styling
|
|
34
|
+
menu.console.print(f" [{colors.get('dim', 'dim')}]Raw agent data:[/{colors.get('dim', 'dim')}]")
|
|
35
|
+
menu.console.print()
|
|
36
|
+
# Convert agent to dict for JSON serialization
|
|
37
|
+
agent_dict = agent.to_dict() if hasattr(agent, 'to_dict') else agent.__dict__
|
|
38
|
+
json_lines = json.dumps(agent_dict, default=str, indent=2).split('\n')
|
|
39
|
+
for line in json_lines:
|
|
40
|
+
menu.console.print(f" {line}")
|
|
41
|
+
menu.pause()
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
# Gather agent info
|
|
45
|
+
os_info = (agent.os or 'unknown').lower()
|
|
46
|
+
os_version = agent.os_version or ''
|
|
47
|
+
architecture = agent.architecture or 'Unknown'
|
|
48
|
+
fqdn = agent.fqdn or 'N/A'
|
|
49
|
+
client_id = agent.client_id or 'N/A'
|
|
50
|
+
last_seen = agent.last_seen_at or 0
|
|
51
|
+
health = agent.health_check
|
|
52
|
+
cf_status = health.cloudflared_status if health else None
|
|
53
|
+
|
|
54
|
+
# Get network interfaces and extract IP addresses
|
|
55
|
+
network_interfaces = agent.network_interfaces or []
|
|
56
|
+
ip_info = []
|
|
57
|
+
|
|
58
|
+
# Extract IPs from network interfaces
|
|
59
|
+
if network_interfaces:
|
|
60
|
+
for interface in network_interfaces:
|
|
61
|
+
if hasattr(interface, 'name'): # NetworkInterface object
|
|
62
|
+
# Get interface name
|
|
63
|
+
iface_name = interface.name or ''
|
|
64
|
+
|
|
65
|
+
# Get IP addresses from the ip_addresses field (it's a list)
|
|
66
|
+
ip_addresses = interface.ip_addresses or []
|
|
67
|
+
|
|
68
|
+
# Add each IP with interface name
|
|
69
|
+
for ip in ip_addresses:
|
|
70
|
+
if ip: # Skip empty strings
|
|
71
|
+
if iface_name and iface_name != 'lo': # Skip loopback
|
|
72
|
+
ip_info.append(f"{ip} ({iface_name})")
|
|
73
|
+
elif iface_name != 'lo':
|
|
74
|
+
ip_info.append(ip)
|
|
75
|
+
|
|
76
|
+
# Compute status
|
|
77
|
+
current_time = datetime.now().timestamp()
|
|
78
|
+
if last_seen > 0:
|
|
79
|
+
last_seen_seconds = last_seen / 1000000 if last_seen > 1000000000000 else last_seen
|
|
80
|
+
is_online = (current_time - last_seen_seconds) < 60
|
|
81
|
+
last_seen_str = datetime.fromtimestamp(last_seen_seconds).strftime("%Y-%m-%d %H:%M:%S")
|
|
82
|
+
if is_online:
|
|
83
|
+
status_text = f"[{colors.get('success', 'green')}]● online[/{colors.get('success', 'green')}]"
|
|
84
|
+
else:
|
|
85
|
+
status_text = f"[{colors.get('error', 'red')}]○ offline[/{colors.get('error', 'red')}]"
|
|
86
|
+
else:
|
|
87
|
+
last_seen_str = "never"
|
|
88
|
+
status_text = f"[{colors.get('error', 'red')}]○ offline[/{colors.get('error', 'red')}]"
|
|
89
|
+
is_online = False
|
|
90
|
+
|
|
91
|
+
# Simple, clean output
|
|
92
|
+
menu.console.print(f" [bold white]{hostname}[/bold white] {status_text}")
|
|
93
|
+
menu.console.print(f" [{colors.get('dim', 'dim')}]{fqdn}[/{colors.get('dim', 'dim')}]")
|
|
94
|
+
menu.console.print()
|
|
95
|
+
|
|
96
|
+
# System info
|
|
97
|
+
menu.console.print(f" [{colors.get('dim', 'dim')}]System[/{colors.get('dim', 'dim')}]")
|
|
98
|
+
menu.console.print(f" OS: {os_info} {os_version}")
|
|
99
|
+
menu.console.print(f" Architecture: {architecture}")
|
|
100
|
+
if ip_info:
|
|
101
|
+
if len(ip_info) == 1:
|
|
102
|
+
menu.console.print(f" IP: {ip_info[0]}")
|
|
103
|
+
else:
|
|
104
|
+
menu.console.print(f" IPs: {ip_info[0]}")
|
|
105
|
+
for ip in ip_info[1:]:
|
|
106
|
+
menu.console.print(f" {ip}")
|
|
107
|
+
menu.console.print(f" Client ID: {client_id[:40]}...")
|
|
108
|
+
menu.console.print(f" Last seen: {last_seen_str}")
|
|
109
|
+
menu.console.print()
|
|
110
|
+
|
|
111
|
+
# Tunnel info
|
|
112
|
+
if cf_status:
|
|
113
|
+
tunnel_name = cf_status.tunnel_name or 'N/A'
|
|
114
|
+
public_hostname = cf_status.hostname or 'N/A'
|
|
115
|
+
authorized_users = cf_status.authorized_users or ''
|
|
116
|
+
|
|
117
|
+
menu.console.print(f" [{colors.get('warning', 'yellow')}]Tunnel active[/{colors.get('warning', 'yellow')}]")
|
|
118
|
+
menu.console.print(f" Name: {tunnel_name}")
|
|
119
|
+
menu.console.print(f" Public: {public_hostname}")
|
|
120
|
+
|
|
121
|
+
if authorized_users:
|
|
122
|
+
users_list = [u.strip() for u in authorized_users.split(',')]
|
|
123
|
+
menu.console.print(f" Authorized: {', '.join(users_list)}")
|
|
124
|
+
else:
|
|
125
|
+
menu.console.print(f" [{colors.get('dim', 'dim')}]No tunnel configured[/{colors.get('dim', 'dim')}]")
|
|
126
|
+
|
|
127
|
+
menu.console.print()
|
|
128
|
+
menu.pause()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def complete(menu, text, tokens):
|
|
132
|
+
"""Command completion for info command"""
|
|
133
|
+
opts = ['--raw', '-r']
|
|
134
|
+
if len(tokens) <= 2:
|
|
135
|
+
return [o for o in opts if o.startswith(text)]
|
|
136
|
+
return []
|