susops 3.0.0rc3.dev1__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.
- susops/__init__.py +4 -0
- susops/client.py +230 -0
- susops/core/__init__.py +0 -0
- susops/core/browsers.py +330 -0
- susops/core/config.py +253 -0
- susops/core/log_style.py +92 -0
- susops/core/pac.py +185 -0
- susops/core/ports.py +57 -0
- susops/core/process.py +167 -0
- susops/core/rpc_protocol.py +186 -0
- susops/core/rpc_server.py +131 -0
- susops/core/services_daemon.py +312 -0
- susops/core/share.py +323 -0
- susops/core/socat.py +200 -0
- susops/core/ssh.py +330 -0
- susops/core/ssh_config.py +40 -0
- susops/core/status.py +245 -0
- susops/core/types.py +171 -0
- susops/facade.py +2237 -0
- susops/tray/__init__.py +20 -0
- susops/tray/base.py +650 -0
- susops/tray/linux.py +1623 -0
- susops/tray/mac.py +3105 -0
- susops/tui/__init__.py +0 -0
- susops/tui/__main__.py +44 -0
- susops/tui/app.py +191 -0
- susops/tui/cli.py +665 -0
- susops/tui/screens/__init__.py +114 -0
- susops/tui/screens/connections.py +871 -0
- susops/tui/screens/dashboard.py +935 -0
- susops/tui/screens/shares.py +357 -0
- susops/tui/widgets/__init__.py +0 -0
- susops/tui/widgets/connection_card.py +137 -0
- susops/version.py +12 -0
- susops-3.0.0rc3.dev1.dist-info/METADATA +977 -0
- susops-3.0.0rc3.dev1.dist-info/RECORD +40 -0
- susops-3.0.0rc3.dev1.dist-info/WHEEL +5 -0
- susops-3.0.0rc3.dev1.dist-info/entry_points.txt +7 -0
- susops-3.0.0rc3.dev1.dist-info/licenses/LICENSE +674 -0
- susops-3.0.0rc3.dev1.dist-info/top_level.txt +1 -0
susops/tui/cli.py
ADDED
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
"""Non-interactive CLI for SusOps.
|
|
2
|
+
|
|
3
|
+
Used when stdout is not a TTY (e.g., scripts, cron, tray app subprocess calls).
|
|
4
|
+
Provides all susops commands with text output and semantic exit codes:
|
|
5
|
+
0 = success / all running
|
|
6
|
+
1 = error
|
|
7
|
+
2 = partial (some services stopped)
|
|
8
|
+
3 = all stopped
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import susops
|
|
17
|
+
from susops.core.types import ProcessState
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _manager(args=None):
|
|
21
|
+
"""Lazy import to avoid circular imports and slow startup when not needed."""
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from susops.client import SusOpsClient
|
|
24
|
+
workspace = Path.home() / ".susops"
|
|
25
|
+
# `verbose` is daemon-side now; client doesn't pass it through.
|
|
26
|
+
return SusOpsClient(workspace=workspace, process_name="susops-cli")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _state_exit_code(state) -> int:
|
|
30
|
+
if state == ProcessState.RUNNING:
|
|
31
|
+
return 0
|
|
32
|
+
if state == ProcessState.STOPPED_PARTIALLY:
|
|
33
|
+
return 2
|
|
34
|
+
if state == ProcessState.STOPPED:
|
|
35
|
+
return 3
|
|
36
|
+
return 1
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cmd_start(args, m) -> int:
|
|
40
|
+
result = m.start(tag=args.connection)
|
|
41
|
+
print(result.message)
|
|
42
|
+
for cs in result.connection_statuses:
|
|
43
|
+
icon = "+" if cs.running else "!"
|
|
44
|
+
print(f" [{icon}] {cs.tag}" + (f" port {cs.socks_port}" if cs.socks_port else ""))
|
|
45
|
+
return 0 if result.success else 1
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def cmd_stop(args, m) -> int:
|
|
49
|
+
result = m.stop(tag=args.connection, keep_ports=args.keep_ports)
|
|
50
|
+
print(result.message)
|
|
51
|
+
return 0 if result.success else 1
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def cmd_restart(args, m) -> int:
|
|
55
|
+
result = m.restart(tag=getattr(args, "connection", None))
|
|
56
|
+
print(result.message)
|
|
57
|
+
return 0 if result.success else 1
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def cmd_ps(args, m) -> int:
|
|
61
|
+
"""Print the same status information as the tray's 'Show Status' dialog.
|
|
62
|
+
|
|
63
|
+
Sections:
|
|
64
|
+
SusOps <state> · N of M running [· K disabled]
|
|
65
|
+
CONNECTIONS
|
|
66
|
+
PAC SERVER
|
|
67
|
+
SERVICES (Daemon RPC + Reconnect monitor + SSE)
|
|
68
|
+
"""
|
|
69
|
+
result = m.status()
|
|
70
|
+
info = m.process_info()
|
|
71
|
+
|
|
72
|
+
# ── Header ────────────────────────────────────────────────────────
|
|
73
|
+
state_glyph = {
|
|
74
|
+
"running": "●",
|
|
75
|
+
"stopped_partially": "◐",
|
|
76
|
+
"stopped": "○",
|
|
77
|
+
"error": "✕",
|
|
78
|
+
"initial": "○",
|
|
79
|
+
}.get(result.state.value, "○")
|
|
80
|
+
enabled = [c for c in result.connection_statuses if c.enabled]
|
|
81
|
+
running_n = sum(1 for c in enabled if c.running)
|
|
82
|
+
disabled_n = sum(1 for c in result.connection_statuses if not c.enabled)
|
|
83
|
+
summary = f"{running_n} of {len(enabled)} running"
|
|
84
|
+
if disabled_n:
|
|
85
|
+
summary += f" · {disabled_n} disabled"
|
|
86
|
+
print(f"{state_glyph} SusOps {result.state.value}")
|
|
87
|
+
print(f" {summary}")
|
|
88
|
+
|
|
89
|
+
# ── CONNECTIONS ───────────────────────────────────────────────────
|
|
90
|
+
print()
|
|
91
|
+
print("CONNECTIONS")
|
|
92
|
+
if not result.connection_statuses:
|
|
93
|
+
print(" (no connections configured)")
|
|
94
|
+
else:
|
|
95
|
+
tag_w = max(max(len(c.tag) for c in result.connection_statuses), 8)
|
|
96
|
+
for cs in result.connection_statuses:
|
|
97
|
+
if not cs.enabled:
|
|
98
|
+
dot, detail = "─", "disabled"
|
|
99
|
+
elif cs.running:
|
|
100
|
+
dot = "●"
|
|
101
|
+
bits = []
|
|
102
|
+
if cs.socks_port:
|
|
103
|
+
bits.append(f"SOCKS {cs.socks_port}")
|
|
104
|
+
if cs.pid:
|
|
105
|
+
bits.append(f"pid {cs.pid}")
|
|
106
|
+
detail = " ".join(bits) if bits else "running"
|
|
107
|
+
else:
|
|
108
|
+
dot, detail = "○", "stopped"
|
|
109
|
+
print(f" {dot} {cs.tag:<{tag_w}} {detail}")
|
|
110
|
+
# Tracked child processes (forward slaves, UDP socats) — keeps
|
|
111
|
+
# parity with the legacy `susops ps` tree view.
|
|
112
|
+
children = info["conn_children"].get(cs.tag, [])
|
|
113
|
+
for i, child in enumerate(children):
|
|
114
|
+
branch = "└" if i == len(children) - 1 else "├"
|
|
115
|
+
ch_icon = "●" if child["running"] else "○"
|
|
116
|
+
ch_pid = f" pid={child['pid']}" if child["pid"] else ""
|
|
117
|
+
print(f" {branch} {ch_icon} {child['display']}{ch_pid}")
|
|
118
|
+
|
|
119
|
+
# ── PAC SERVER ────────────────────────────────────────────────────
|
|
120
|
+
print()
|
|
121
|
+
print("PAC SERVER")
|
|
122
|
+
if result.pac_running and result.pac_port:
|
|
123
|
+
print(f" ● http://localhost:{result.pac_port}/susops.pac")
|
|
124
|
+
else:
|
|
125
|
+
print(" ○ stopped")
|
|
126
|
+
|
|
127
|
+
# ── SERVICES (Daemon RPC + Reconnect + SSE) ───────────────────────
|
|
128
|
+
print()
|
|
129
|
+
print("SERVICES")
|
|
130
|
+
workspace = m.workspace
|
|
131
|
+
try:
|
|
132
|
+
rpc_port = int((workspace / "pids" / "susops-services.port")
|
|
133
|
+
.read_text().strip())
|
|
134
|
+
print(f" ● Daemon RPC http://localhost:{rpc_port}/rpc")
|
|
135
|
+
except (OSError, ValueError):
|
|
136
|
+
print(" ○ Daemon RPC (port file unavailable)")
|
|
137
|
+
try:
|
|
138
|
+
pid = int((workspace / "pids" / "susops-services.pid")
|
|
139
|
+
.read_text().strip())
|
|
140
|
+
print(f" Daemon PID {pid}")
|
|
141
|
+
except (OSError, ValueError):
|
|
142
|
+
pass
|
|
143
|
+
try:
|
|
144
|
+
sse_url = m.get_status_url() or ""
|
|
145
|
+
except Exception:
|
|
146
|
+
sse_url = ""
|
|
147
|
+
if sse_url:
|
|
148
|
+
print(f" Daemon SSE {sse_url}")
|
|
149
|
+
print(f" Workspace {workspace}")
|
|
150
|
+
|
|
151
|
+
rec = info["reconnect"]
|
|
152
|
+
if rec.get("daemon_running"):
|
|
153
|
+
rec_pid = f" pid={rec['pid']}" if rec.get("pid") else ""
|
|
154
|
+
print(f" ● Reconnect daemon{rec_pid}")
|
|
155
|
+
elif rec.get("thread_alive") and rec.get("watching"):
|
|
156
|
+
n = len(rec["watching"])
|
|
157
|
+
print(f" ● Reconnect watching {n} connection{'s' if n != 1 else ''}")
|
|
158
|
+
else:
|
|
159
|
+
print(" ○ Reconnect stopped")
|
|
160
|
+
|
|
161
|
+
return _state_exit_code(result.state)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def cmd_ls(args, m) -> int:
|
|
165
|
+
config = m.list_config()
|
|
166
|
+
print(f"pac_server_port: {config.pac_server_port}")
|
|
167
|
+
for conn in config.connections:
|
|
168
|
+
print(f"\nconnection: {conn.tag}")
|
|
169
|
+
print(f" ssh_host: {conn.ssh_host}")
|
|
170
|
+
print(f" socks_port: {conn.socks_proxy_port}")
|
|
171
|
+
if conn.pac_hosts:
|
|
172
|
+
print(f" pac_hosts:")
|
|
173
|
+
for host in conn.pac_hosts:
|
|
174
|
+
print(f" - {host}")
|
|
175
|
+
if conn.forwards.local:
|
|
176
|
+
print(f" local_forwards:")
|
|
177
|
+
for forward in conn.forwards.local:
|
|
178
|
+
print(f" - {forward.src_addr}:{forward.src_port} → {forward.dst_addr}:{forward.dst_port}" +
|
|
179
|
+
(f" [{forward.tag}]" if forward.tag else ""))
|
|
180
|
+
if conn.forwards.remote:
|
|
181
|
+
print(f" remote_forwards:")
|
|
182
|
+
for forward in conn.forwards.remote:
|
|
183
|
+
print(f" - {forward.src_addr}:{forward.src_port} → {forward.dst_addr}:{forward.dst_port}" +
|
|
184
|
+
(f" [{forward.tag}]" if forward.tag else ""))
|
|
185
|
+
if conn.file_shares:
|
|
186
|
+
print(" file_shares:")
|
|
187
|
+
for share in conn.file_shares:
|
|
188
|
+
print(f" - file_path: {share.file_path}")
|
|
189
|
+
print(f" password: {share.password}")
|
|
190
|
+
print(f" port: {share.port}")
|
|
191
|
+
print(f" stopped: {share.stopped}")
|
|
192
|
+
return 0
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def cmd_add_connection(args, m) -> int:
|
|
196
|
+
try:
|
|
197
|
+
conn = m.add_connection(args.tag, args.ssh_host, socks_port=args.socks_port or 0)
|
|
198
|
+
print(f"Added connection '{conn.tag}' → {conn.ssh_host}")
|
|
199
|
+
return 0
|
|
200
|
+
except ValueError as e:
|
|
201
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
202
|
+
return 1
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def cmd_rm_connection(args, m) -> int:
|
|
206
|
+
try:
|
|
207
|
+
m.remove_connection(args.tag)
|
|
208
|
+
print(f"Removed connection '{args.tag}'")
|
|
209
|
+
return 0
|
|
210
|
+
except ValueError as e:
|
|
211
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
212
|
+
return 1
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def cmd_add(args, m) -> int:
|
|
216
|
+
try:
|
|
217
|
+
if args.local or args.remote:
|
|
218
|
+
# argparse fills positionals left-to-right, so 'host' grabs the
|
|
219
|
+
# first integer; shift everything back when adding forwards.
|
|
220
|
+
from susops.core.config import PortForward
|
|
221
|
+
try:
|
|
222
|
+
local_port = int(args.host) if args.local_port is None else args.local_port
|
|
223
|
+
remote_port = args.local_port if args.remote_port is None and args.local_port != local_port else args.remote_port
|
|
224
|
+
except (TypeError, ValueError):
|
|
225
|
+
local_port = args.local_port
|
|
226
|
+
remote_port = args.remote_port
|
|
227
|
+
conn_tag = args.connection or m.config.connections[0].tag
|
|
228
|
+
if args.local:
|
|
229
|
+
fw = PortForward(
|
|
230
|
+
src_port=local_port,
|
|
231
|
+
dst_port=remote_port if remote_port is not None else local_port,
|
|
232
|
+
src_addr=args.local_addr or "localhost",
|
|
233
|
+
dst_addr=args.remote_addr or "localhost",
|
|
234
|
+
tag=args.forward_tag or "",
|
|
235
|
+
)
|
|
236
|
+
m.add_local_forward(conn_tag, fw)
|
|
237
|
+
print(f"Added local forward {fw.src_port} → {fw.dst_port}")
|
|
238
|
+
else:
|
|
239
|
+
fw = PortForward(
|
|
240
|
+
src_port=remote_port if remote_port is not None else local_port,
|
|
241
|
+
dst_port=local_port,
|
|
242
|
+
src_addr=args.remote_addr or "localhost",
|
|
243
|
+
dst_addr=args.local_addr or "localhost",
|
|
244
|
+
tag=args.forward_tag or "",
|
|
245
|
+
)
|
|
246
|
+
m.add_remote_forward(conn_tag, fw)
|
|
247
|
+
print(f"Added remote forward {fw.src_port} → {fw.dst_port}")
|
|
248
|
+
else:
|
|
249
|
+
m.add_pac_host(args.host, conn_tag=args.connection)
|
|
250
|
+
print(f"Added PAC host '{args.host}'")
|
|
251
|
+
return 0
|
|
252
|
+
except (ValueError, IndexError) as e:
|
|
253
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
254
|
+
return 1
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def cmd_rm(args, m) -> int:
|
|
258
|
+
try:
|
|
259
|
+
if args.local or args.remote:
|
|
260
|
+
# 'host' positional grabs the port value; shift back
|
|
261
|
+
try:
|
|
262
|
+
port = int(args.host) if args.port is None else args.port
|
|
263
|
+
except (TypeError, ValueError):
|
|
264
|
+
port = args.port
|
|
265
|
+
if args.local:
|
|
266
|
+
m.remove_local_forward(port)
|
|
267
|
+
print(f"Removed local forward on port {port}")
|
|
268
|
+
else:
|
|
269
|
+
m.remove_remote_forward(port)
|
|
270
|
+
print(f"Removed remote forward on port {port}")
|
|
271
|
+
else:
|
|
272
|
+
m.remove_pac_host(args.host)
|
|
273
|
+
print(f"Removed PAC host '{args.host}'")
|
|
274
|
+
return 0
|
|
275
|
+
except ValueError as e:
|
|
276
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
277
|
+
return 1
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def cmd_test(args, m) -> int:
|
|
281
|
+
if args.all:
|
|
282
|
+
results = m.test_all()
|
|
283
|
+
ok = all(r.success for r in results)
|
|
284
|
+
for r in results:
|
|
285
|
+
icon = "✓" if r.success else "✗"
|
|
286
|
+
latency = f" ({r.latency_ms:.0f}ms)" if r.latency_ms else ""
|
|
287
|
+
print(f" {icon} {r.target}{latency}: {r.message}")
|
|
288
|
+
return 0 if ok else 1
|
|
289
|
+
else:
|
|
290
|
+
result = m.test(args.target)
|
|
291
|
+
icon = "✓" if result.success else "✗"
|
|
292
|
+
latency = f" ({result.latency_ms:.0f}ms)" if result.latency_ms else ""
|
|
293
|
+
print(f"{icon} {result.target}{latency}: {result.message}")
|
|
294
|
+
return 0 if result.success else 1
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def cmd_share(args, m) -> int:
|
|
298
|
+
try:
|
|
299
|
+
conn_tag = args.connection
|
|
300
|
+
if not conn_tag:
|
|
301
|
+
conns = m.list_config().connections
|
|
302
|
+
if not conns:
|
|
303
|
+
print("Error: no connections configured", file=sys.stderr)
|
|
304
|
+
return 1
|
|
305
|
+
conn_tag = conns[0].tag
|
|
306
|
+
info = m.share(Path(args.file), conn_tag=conn_tag, password=args.password or None, port=args.port or None)
|
|
307
|
+
print(f"Sharing: {info.file_path}")
|
|
308
|
+
print(f"URL: {info.url}")
|
|
309
|
+
print(f"Password: {info.password}")
|
|
310
|
+
print(f"Port: {info.port}")
|
|
311
|
+
print("\nFetch with:")
|
|
312
|
+
print(f" susops fetch {info.port} {info.password}")
|
|
313
|
+
return 0
|
|
314
|
+
except (FileNotFoundError, RuntimeError, ValueError) as e:
|
|
315
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
316
|
+
return 1
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def cmd_fetch(args, m) -> int:
|
|
320
|
+
try:
|
|
321
|
+
conn_tag = args.connection
|
|
322
|
+
if not conn_tag:
|
|
323
|
+
conns = m.list_config().connections
|
|
324
|
+
if not conns:
|
|
325
|
+
print("Error: no connections configured", file=sys.stderr)
|
|
326
|
+
return 1
|
|
327
|
+
conn_tag = conns[0].tag
|
|
328
|
+
outfile = Path(args.outfile) if args.outfile else None
|
|
329
|
+
result = m.fetch(port=args.port, password=args.password, conn_tag=conn_tag, outfile=outfile)
|
|
330
|
+
print(f"Downloaded to: {result}")
|
|
331
|
+
return 0
|
|
332
|
+
except Exception as e:
|
|
333
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
334
|
+
return 1
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def cmd_reset(args, m) -> int:
|
|
338
|
+
if not args.force:
|
|
339
|
+
answer = input("This will reset the entire workspace. Continue? [y/N] ")
|
|
340
|
+
if answer.lower() != "y":
|
|
341
|
+
print("Aborted.")
|
|
342
|
+
return 1
|
|
343
|
+
m.reset()
|
|
344
|
+
print("Workspace reset.")
|
|
345
|
+
return 0
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def cmd_config(args, m) -> int:
|
|
349
|
+
import subprocess, shutil, os
|
|
350
|
+
config_path = m.workspace / "config.yaml"
|
|
351
|
+
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or "nano"
|
|
352
|
+
for candidate in (editor, "nano", "vim", "vi"):
|
|
353
|
+
if shutil.which(candidate):
|
|
354
|
+
subprocess.call([candidate, str(config_path)])
|
|
355
|
+
return 0
|
|
356
|
+
print(f"Config file: {config_path}", file=sys.stderr)
|
|
357
|
+
print("Error: no editor found (set $EDITOR)", file=sys.stderr)
|
|
358
|
+
return 1
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def cmd_chrome(args, m) -> int:
|
|
362
|
+
import subprocess, shutil
|
|
363
|
+
pac_url = m.get_pac_url()
|
|
364
|
+
if not pac_url:
|
|
365
|
+
print("Error: PAC server is not running", file=sys.stderr)
|
|
366
|
+
return 1
|
|
367
|
+
for browser in ("google-chrome-stable", "google-chrome", "chromium", "chromium-browser", "brave-browser"):
|
|
368
|
+
if shutil.which(browser):
|
|
369
|
+
subprocess.Popen([browser, f"--proxy-pac-url={pac_url}"])
|
|
370
|
+
return 0
|
|
371
|
+
print("Error: No Chrome/Chromium browser found", file=sys.stderr)
|
|
372
|
+
return 1
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def cmd_chrome_proxy_settings(args, m) -> int:
|
|
376
|
+
import subprocess, shutil
|
|
377
|
+
url = "chrome://settings/system"
|
|
378
|
+
for browser in ("google-chrome-stable", "google-chrome", "chromium", "chromium-browser", "brave-browser"):
|
|
379
|
+
if shutil.which(browser):
|
|
380
|
+
subprocess.Popen([browser, url])
|
|
381
|
+
return 0
|
|
382
|
+
print("Error: No Chrome/Chromium browser found", file=sys.stderr)
|
|
383
|
+
return 1
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def cmd_firefox(args, m) -> int:
|
|
387
|
+
import subprocess, shutil
|
|
388
|
+
pac_url = m.get_pac_url()
|
|
389
|
+
if not pac_url:
|
|
390
|
+
print("Error: PAC server is not running", file=sys.stderr)
|
|
391
|
+
return 1
|
|
392
|
+
profile_dir = m.workspace / "firefox_profile"
|
|
393
|
+
profile_dir.mkdir(exist_ok=True)
|
|
394
|
+
user_js = profile_dir / "user.js"
|
|
395
|
+
user_js.write_text(
|
|
396
|
+
f'user_pref("network.proxy.type", 2);\n'
|
|
397
|
+
f'user_pref("network.proxy.autoconfig_url", "{pac_url}");\n'
|
|
398
|
+
f'user_pref("network.proxy.no_proxies_on", "localhost, 127.0.0.1");\n'
|
|
399
|
+
)
|
|
400
|
+
if shutil.which("firefox"):
|
|
401
|
+
subprocess.Popen(["firefox", "-profile", str(profile_dir), "-no-remote"])
|
|
402
|
+
return 0
|
|
403
|
+
print("Error: Firefox not found", file=sys.stderr)
|
|
404
|
+
return 1
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def cmd_guide(args, m) -> int:
|
|
408
|
+
config = m.list_config()
|
|
409
|
+
|
|
410
|
+
# Resolve target connection
|
|
411
|
+
if args.connection:
|
|
412
|
+
conn = next((c for c in config.connections if c.tag == args.connection), None)
|
|
413
|
+
if conn is None:
|
|
414
|
+
print(f"Error: connection '{args.connection}' not found", file=sys.stderr)
|
|
415
|
+
return 1
|
|
416
|
+
elif config.connections:
|
|
417
|
+
conn = config.connections[0]
|
|
418
|
+
else:
|
|
419
|
+
print("Error: no connections configured", file=sys.stderr)
|
|
420
|
+
return 1
|
|
421
|
+
|
|
422
|
+
# Get live SOCKS port from a running process; fall back to saved config value
|
|
423
|
+
port = 0
|
|
424
|
+
running = False
|
|
425
|
+
for cs in m.status().connection_statuses:
|
|
426
|
+
if cs.tag == conn.tag:
|
|
427
|
+
port = cs.socks_port
|
|
428
|
+
running = cs.running
|
|
429
|
+
break
|
|
430
|
+
if port == 0:
|
|
431
|
+
port = conn.socks_proxy_port
|
|
432
|
+
|
|
433
|
+
proxy = f"socks5h://127.0.0.1:{port}" if port else "socks5h://127.0.0.1:<port>"
|
|
434
|
+
port_label = str(port) if port else "<port>"
|
|
435
|
+
|
|
436
|
+
is_macos = sys.platform == "darwin"
|
|
437
|
+
# socksify (dante) is the macOS equivalent of proxychains4; it needs SOCKS_SERVER=host:port
|
|
438
|
+
proxy_cmd = f"SOCKS_SERVER=127.0.0.1:{port_label} socksify" if is_macos else "proxychains4"
|
|
439
|
+
|
|
440
|
+
# Warning banner when tunnel is not live
|
|
441
|
+
if not running:
|
|
442
|
+
print(f"Warning: tunnel '{conn.tag}' is not running — start with: susops start -c {conn.tag}")
|
|
443
|
+
if port:
|
|
444
|
+
print(" (port shown below is the configured value, not a live port)")
|
|
445
|
+
else:
|
|
446
|
+
print(" (start the tunnel first to see the assigned port)")
|
|
447
|
+
print()
|
|
448
|
+
|
|
449
|
+
header = f"susops guide — {conn.tag} ({proxy})"
|
|
450
|
+
print(header)
|
|
451
|
+
print("─" * len(header))
|
|
452
|
+
print()
|
|
453
|
+
|
|
454
|
+
# Shell env vars
|
|
455
|
+
print("# Shell (bash/zsh — add to ~/.zshrc or ~/.bash_profile)")
|
|
456
|
+
print(f"export ALL_PROXY={proxy}")
|
|
457
|
+
print("export NO_PROXY=localhost,127.0.0.1")
|
|
458
|
+
print()
|
|
459
|
+
|
|
460
|
+
# Homebrew
|
|
461
|
+
print("# Homebrew")
|
|
462
|
+
print(f"ALL_PROXY={proxy} brew install <package>")
|
|
463
|
+
print("# permanent alias (add to ~/.zshrc):")
|
|
464
|
+
print(f"alias susops-brew='ALL_PROXY={proxy} brew'")
|
|
465
|
+
print("# or set Homebrew's own env var:")
|
|
466
|
+
print(f"export HOMEBREW_ALL_PROXY={proxy}")
|
|
467
|
+
print()
|
|
468
|
+
|
|
469
|
+
# pip
|
|
470
|
+
print("# pip")
|
|
471
|
+
print(f"pip install --proxy {proxy} <package>")
|
|
472
|
+
print("# permanent alias (add to ~/.zshrc):")
|
|
473
|
+
print(f"alias susops-pip='pip --proxy {proxy}'")
|
|
474
|
+
print(f"alias susops-pip3='pip3 --proxy {proxy}'")
|
|
475
|
+
print("# or permanently via config (~/.config/pip/pip.conf):")
|
|
476
|
+
print("# [global]")
|
|
477
|
+
print(f"# proxy = {proxy}")
|
|
478
|
+
print()
|
|
479
|
+
|
|
480
|
+
# npm / yarn / pnpm (Node.js has no native SOCKS5 support)
|
|
481
|
+
print("# npm / yarn / pnpm")
|
|
482
|
+
print(f"{proxy_cmd} npm install <package>")
|
|
483
|
+
print("# permanent alias (add to ~/.zshrc):")
|
|
484
|
+
print(f"alias susops-npm='{proxy_cmd} npm'")
|
|
485
|
+
print(f"alias susops-yarn='{proxy_cmd} yarn'")
|
|
486
|
+
print(f"alias susops-pnpm='{proxy_cmd} pnpm'")
|
|
487
|
+
print()
|
|
488
|
+
|
|
489
|
+
# git
|
|
490
|
+
print("# git")
|
|
491
|
+
print(f"git config --global http.proxy {proxy}")
|
|
492
|
+
print("# undo: git config --global --unset http.proxy")
|
|
493
|
+
print()
|
|
494
|
+
|
|
495
|
+
# curl
|
|
496
|
+
print("# curl")
|
|
497
|
+
print(f"curl --proxy {proxy} <url>")
|
|
498
|
+
print("# permanent alias (add to ~/.zshrc):")
|
|
499
|
+
print(f"alias susops-curl='curl --proxy {proxy}'")
|
|
500
|
+
print(f"# or permanently via ~/.curlrc: proxy = {proxy}")
|
|
501
|
+
print()
|
|
502
|
+
|
|
503
|
+
# wget (no native SOCKS5 support)
|
|
504
|
+
print("# wget")
|
|
505
|
+
print(f"{proxy_cmd} wget <url>")
|
|
506
|
+
print("# permanent alias (add to ~/.zshrc):")
|
|
507
|
+
print(f"alias susops-wget='{proxy_cmd} wget'")
|
|
508
|
+
print()
|
|
509
|
+
|
|
510
|
+
# apt / apt-get (Linux only — no native SOCKS5 support)
|
|
511
|
+
if not is_macos:
|
|
512
|
+
print("# apt / apt-get")
|
|
513
|
+
print(f"sudo {proxy_cmd} apt-get install <pkg>")
|
|
514
|
+
print("# permanent alias (add to ~/.zshrc):")
|
|
515
|
+
print("# alias sudo='sudo ' # allows sudo to expand aliases")
|
|
516
|
+
print(f"alias susops-apt='{proxy_cmd} apt-get'")
|
|
517
|
+
print()
|
|
518
|
+
|
|
519
|
+
# Docker (Go's net/http supports SOCKS5 via ALL_PROXY natively)
|
|
520
|
+
print("# Docker")
|
|
521
|
+
print(f"ALL_PROXY={proxy} docker pull <image>")
|
|
522
|
+
print("# permanent alias (add to ~/.zshrc):")
|
|
523
|
+
print(f"alias susops-docker='ALL_PROXY={proxy} docker'")
|
|
524
|
+
if is_macos:
|
|
525
|
+
print("# permanent for daemon: Docker Desktop → Settings → Resources → Proxies")
|
|
526
|
+
print(f"# set SOCKS proxy to 127.0.0.1:{port_label}")
|
|
527
|
+
else:
|
|
528
|
+
print("# permanent for daemon (/etc/systemd/system/docker.service.d/proxy.conf):")
|
|
529
|
+
print("# [Service]")
|
|
530
|
+
print(f'# Environment="ALL_PROXY={proxy}"')
|
|
531
|
+
print('# Environment="NO_PROXY=localhost,127.0.0.1"')
|
|
532
|
+
print("# Then: sudo systemctl daemon-reload && sudo systemctl restart docker")
|
|
533
|
+
print()
|
|
534
|
+
|
|
535
|
+
# Generic wrapper
|
|
536
|
+
if is_macos:
|
|
537
|
+
print("# Generic (socksify — install: brew install dante)")
|
|
538
|
+
print(f"SOCKS_SERVER=127.0.0.1:{port_label} socksify <any-command>")
|
|
539
|
+
else:
|
|
540
|
+
print("# Generic (proxychains4)")
|
|
541
|
+
print("proxychains4 <any-command>")
|
|
542
|
+
print("# configure /etc/proxychains4.conf:")
|
|
543
|
+
print(f"# socks5 127.0.0.1 {port_label}")
|
|
544
|
+
|
|
545
|
+
return 0
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
549
|
+
parser = argparse.ArgumentParser(
|
|
550
|
+
prog="susops",
|
|
551
|
+
description="SusOps — SSH SOCKS5 proxy manager",
|
|
552
|
+
)
|
|
553
|
+
parser.add_argument(
|
|
554
|
+
"-c", "--connection", metavar="TAG",
|
|
555
|
+
help="Target a specific connection by tag",
|
|
556
|
+
)
|
|
557
|
+
parser.add_argument(
|
|
558
|
+
"--version", action="version", version=f"susops {susops.__version__}",
|
|
559
|
+
)
|
|
560
|
+
parser.add_argument(
|
|
561
|
+
"-v", "--verbose", action="store_true",
|
|
562
|
+
help="Enable debug logging (events, state changes)",
|
|
563
|
+
)
|
|
564
|
+
sub = parser.add_subparsers(dest="command", metavar="COMMAND")
|
|
565
|
+
|
|
566
|
+
# start
|
|
567
|
+
p = sub.add_parser("start", help="Start SSH tunnel(s) and PAC server")
|
|
568
|
+
p.set_defaults(func=cmd_start)
|
|
569
|
+
|
|
570
|
+
# stop
|
|
571
|
+
p = sub.add_parser("stop", help="Stop SSH tunnel(s) and PAC server")
|
|
572
|
+
p.add_argument("--keep-ports", action="store_true", help="Preserve port assignments")
|
|
573
|
+
p.set_defaults(func=cmd_stop)
|
|
574
|
+
|
|
575
|
+
# restart
|
|
576
|
+
p = sub.add_parser("restart", help="Restart tunnel(s)")
|
|
577
|
+
p.set_defaults(func=cmd_restart)
|
|
578
|
+
|
|
579
|
+
# ps
|
|
580
|
+
p = sub.add_parser("ps", help="Show process status")
|
|
581
|
+
p.set_defaults(func=cmd_ps)
|
|
582
|
+
|
|
583
|
+
# ls
|
|
584
|
+
p = sub.add_parser("ls", help="List all config")
|
|
585
|
+
p.set_defaults(func=cmd_ls)
|
|
586
|
+
|
|
587
|
+
# add-connection
|
|
588
|
+
p = sub.add_parser("add-connection", help="Add a new SSH connection")
|
|
589
|
+
p.add_argument("tag", help="Unique identifier for this connection")
|
|
590
|
+
p.add_argument("ssh_host", metavar="SSH_HOST", help="SSH host string (user@host)")
|
|
591
|
+
p.add_argument("socks_port", metavar="SOCKS_PORT", type=int, nargs="?", default=0,
|
|
592
|
+
help="SOCKS port (0 = auto-assign)")
|
|
593
|
+
p.set_defaults(func=cmd_add_connection)
|
|
594
|
+
|
|
595
|
+
# rm-connection
|
|
596
|
+
p = sub.add_parser("rm-connection", help="Remove an SSH connection")
|
|
597
|
+
p.add_argument("tag", help="Connection tag to remove")
|
|
598
|
+
p.set_defaults(func=cmd_rm_connection)
|
|
599
|
+
|
|
600
|
+
# add (PAC host or port forward)
|
|
601
|
+
p = sub.add_parser("add", help="Add PAC host or port forward")
|
|
602
|
+
p.add_argument("host", nargs="?", help="Hostname, wildcard, or CIDR")
|
|
603
|
+
p.add_argument("-l", "--local", action="store_true", help="Add local forward")
|
|
604
|
+
p.add_argument("-r", "--remote", action="store_true", help="Add remote forward")
|
|
605
|
+
p.add_argument("local_port", type=int, nargs="?", metavar="LOCAL_PORT")
|
|
606
|
+
p.add_argument("remote_port", type=int, nargs="?", metavar="REMOTE_PORT")
|
|
607
|
+
p.add_argument("forward_tag", nargs="?", metavar="TAG", help="Forward label")
|
|
608
|
+
p.add_argument("local_addr", nargs="?", metavar="LOCAL_ADDR", default=None)
|
|
609
|
+
p.add_argument("remote_addr", nargs="?", metavar="REMOTE_ADDR", default=None)
|
|
610
|
+
p.set_defaults(func=cmd_add)
|
|
611
|
+
|
|
612
|
+
# rm (PAC host or port forward)
|
|
613
|
+
p = sub.add_parser("rm", help="Remove PAC host or port forward")
|
|
614
|
+
p.add_argument("host", nargs="?", help="Hostname to remove")
|
|
615
|
+
p.add_argument("-l", "--local", action="store_true")
|
|
616
|
+
p.add_argument("-r", "--remote", action="store_true")
|
|
617
|
+
p.add_argument("port", type=int, nargs="?", metavar="PORT")
|
|
618
|
+
p.set_defaults(func=cmd_rm)
|
|
619
|
+
|
|
620
|
+
# test
|
|
621
|
+
p = sub.add_parser("test", help="Test connectivity")
|
|
622
|
+
p.add_argument("target", nargs="?", default="", help="Hostname or port to test")
|
|
623
|
+
p.add_argument("--all", action="store_true", help="Test all PAC hosts")
|
|
624
|
+
p.set_defaults(func=cmd_test)
|
|
625
|
+
|
|
626
|
+
# share
|
|
627
|
+
p = sub.add_parser("share", help="Share an encrypted file")
|
|
628
|
+
p.add_argument("file", help="File to share")
|
|
629
|
+
p.add_argument("password", nargs="?", default=None, help="Encryption password (auto-generated if omitted)")
|
|
630
|
+
p.add_argument("port", type=int, nargs="?", default=0, help="Port to listen on (0 = auto)")
|
|
631
|
+
p.set_defaults(func=cmd_share)
|
|
632
|
+
|
|
633
|
+
# fetch
|
|
634
|
+
p = sub.add_parser("fetch", help="Fetch an encrypted shared file")
|
|
635
|
+
p.add_argument("port", type=int, help="Port the share is on")
|
|
636
|
+
p.add_argument("password", help="Decryption password")
|
|
637
|
+
p.add_argument("outfile", nargs="?", default=None, help="Output file path")
|
|
638
|
+
p.set_defaults(func=cmd_fetch)
|
|
639
|
+
|
|
640
|
+
# reset
|
|
641
|
+
p = sub.add_parser("reset", help="Reset workspace (destructive)")
|
|
642
|
+
p.add_argument("--force", action="store_true", help="Skip confirmation prompt")
|
|
643
|
+
p.set_defaults(func=cmd_reset)
|
|
644
|
+
|
|
645
|
+
# config
|
|
646
|
+
sub.add_parser("config", help="Open config file in $EDITOR").set_defaults(func=cmd_config)
|
|
647
|
+
|
|
648
|
+
# chrome / firefox
|
|
649
|
+
sub.add_parser("chrome", help="Launch Chrome with PAC proxy").set_defaults(func=cmd_chrome)
|
|
650
|
+
sub.add_parser("chrome-proxy-settings", help="Open Chrome proxy settings").set_defaults(
|
|
651
|
+
func=cmd_chrome_proxy_settings)
|
|
652
|
+
sub.add_parser("firefox", help="Launch Firefox with PAC proxy").set_defaults(func=cmd_firefox)
|
|
653
|
+
|
|
654
|
+
# guide
|
|
655
|
+
sub.add_parser("guide", help="Print proxy setup guide for common tools").set_defaults(func=cmd_guide)
|
|
656
|
+
|
|
657
|
+
return parser
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def dispatch(args: argparse.Namespace) -> int:
|
|
661
|
+
"""Dispatch a parsed command to its handler. Returns exit code."""
|
|
662
|
+
if not hasattr(args, "func") or args.func is None:
|
|
663
|
+
return 1
|
|
664
|
+
m = _manager(args)
|
|
665
|
+
return args.func(args, m)
|