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/facade.py ADDED
@@ -0,0 +1,2237 @@
1
+ """SusOpsManager — the single public API for all SusOps frontends."""
2
+ from __future__ import annotations
3
+
4
+ import dataclasses
5
+ import datetime
6
+ import json
7
+ import subprocess
8
+ import sys
9
+ import threading
10
+ import time
11
+ import urllib.request
12
+ from collections import deque
13
+ from pathlib import Path
14
+ from typing import Callable
15
+
16
+ from susops.core.config import (
17
+ Connection,
18
+ FileShare,
19
+ PortForward,
20
+ SusOpsConfig,
21
+ get_connection,
22
+ get_default_connection,
23
+ load_config,
24
+ save_config,
25
+ )
26
+ from susops.core.pac import PacServer, write_pac_file
27
+ from susops.core.ports import get_random_free_port, is_port_free
28
+ from susops.core.process import ProcessManager
29
+ from susops.core.share import ShareServer, fetch_file, generate_password
30
+ from susops.core.socat import (
31
+ UDP_PROCESS_PREFIX,
32
+ start_udp_forward,
33
+ stop_udp_forward,
34
+ stop_all_udp_forwards_for_connection,
35
+ is_udp_forward_running as _is_udp_forward_running,
36
+ )
37
+ from susops.core.ssh import (
38
+ FWD_PROCESS_PREFIX,
39
+ SSH_PROCESS_PREFIX,
40
+ cancel_forward,
41
+ find_master_pid,
42
+ is_socket_alive,
43
+ is_tunnel_running,
44
+ socket_path,
45
+ start_forward,
46
+ start_master,
47
+ stop_tunnel,
48
+ test_ssh_connectivity,
49
+ )
50
+ from susops.core.status import StatusServer
51
+ from susops.core.types import (
52
+ ConnectionStatus,
53
+ ProcessState,
54
+ ShareInfo,
55
+ StartResult,
56
+ StatusResult,
57
+ StopResult,
58
+ TestResult,
59
+ )
60
+
61
+ __all__ = ["SusOpsManager"]
62
+
63
+ _WORKSPACE_DEFAULT = Path.home() / ".susops"
64
+
65
+
66
+ class _BandwidthSampler:
67
+ """Background thread that samples per-connection bandwidth every second."""
68
+
69
+ INTERVAL = 1.0
70
+ _HISTORY_MAX = 60
71
+ _HISTORY_FILE = "bandwidth_history.json"
72
+
73
+ def __init__(
74
+ self,
75
+ process_mgr: ProcessManager,
76
+ workspace: "Path | None" = None,
77
+ on_sample: Callable[[str, float, float], None] | None = None,
78
+ ) -> None:
79
+ self._mgr = process_mgr
80
+ self._workspace = workspace
81
+ self._rates: dict[str, tuple[float, float]] = {}
82
+ self._totals: dict[str, tuple[float, float]] = {} # tag -> (rx_total_bytes, tx_total_bytes)
83
+ self._prev_net: tuple[float, float, float] | None = None
84
+ self._prev_chars: dict[str, float] = {}
85
+ # macOS-only: cumulative bytes_in/bytes_out per pid from `nettop`.
86
+ # Used to compute per-sample deltas → per-tag rates. None until
87
+ # nettop is confirmed available (probe on first sample).
88
+ self._prev_nettop: dict[int, tuple[int, int]] | None = None
89
+ self._prev_nettop_t: float | None = None
90
+ self._nettop_available: bool | None = None # tri-state probe cache
91
+ self._lock = threading.Lock()
92
+ self._on_sample = on_sample
93
+ # Per-tag rolling history: tag -> list of [rx_bps, tx_bps] (up to _HISTORY_MAX samples)
94
+ self._history: dict[str, list[list[float]]] = {}
95
+ self._load_history()
96
+ self._thread = threading.Thread(
97
+ target=self._run, daemon=True, name="susops-bw-sampler"
98
+ )
99
+ self._thread.start()
100
+
101
+ def _history_path(self) -> "Path | None":
102
+ if self._workspace is None:
103
+ return None
104
+ return self._workspace / self._HISTORY_FILE
105
+
106
+ def _load_history(self) -> None:
107
+ """Load persisted bandwidth history from disk if available."""
108
+ path = self._history_path()
109
+ if path is None or not path.exists():
110
+ return
111
+ try:
112
+ data = json.loads(path.read_text())
113
+ if isinstance(data, dict):
114
+ for tag, samples in data.items():
115
+ if isinstance(samples, list):
116
+ self._history[tag] = samples[-self._HISTORY_MAX:]
117
+ except Exception:
118
+ pass
119
+
120
+ def _save_history(self) -> None:
121
+ """Persist the current bandwidth history to disk (called under self._lock)."""
122
+ path = self._history_path()
123
+ if path is None:
124
+ return
125
+ try:
126
+ path.write_text(json.dumps(self._history))
127
+ except Exception:
128
+ pass
129
+
130
+ def _run(self) -> None:
131
+ while True:
132
+ try:
133
+ self._sample()
134
+ except Exception:
135
+ pass
136
+ time.sleep(self.INTERVAL)
137
+
138
+ @staticmethod
139
+ def _parse_nettop_line(line: str) -> tuple[int, int, int] | None:
140
+ """Parse one `nettop -P -x -J bytes_in,bytes_out` data row.
141
+
142
+ Format (whitespace-aligned, NOT comma-separated):
143
+ <process name>.<pid> <bytes_in> <bytes_out>
144
+
145
+ Process names can contain spaces (e.g. "Brave Browser H.879"), so we
146
+ split on whitespace and take the last two tokens as the numeric
147
+ columns and everything before as the name+pid field.
148
+ """
149
+ parts = line.split()
150
+ if len(parts) < 3:
151
+ return None
152
+ try:
153
+ b_in = int(parts[-2])
154
+ b_out = int(parts[-1])
155
+ except ValueError:
156
+ return None
157
+ name_field = " ".join(parts[:-2])
158
+ if "." not in name_field:
159
+ return None
160
+ try:
161
+ pid = int(name_field.rsplit(".", 1)[1])
162
+ except ValueError:
163
+ return None
164
+ return pid, b_in, b_out
165
+
166
+ def _sample_macos_nettop(self, tag_pids: dict[str, list[int]], now: float) -> bool:
167
+ """Sample per-tunnel bandwidth on macOS via the `nettop` CLI tool.
168
+
169
+ Returns True if rates were published (caller skips the Linux path).
170
+ Returns False if nettop is unavailable or the first sample (we need
171
+ a baseline to compute deltas against); caller falls back / waits.
172
+
173
+ Why nettop: macOS doesn't expose per-process network bytes through
174
+ psutil or any public C API (no /proc, no proc_pidinfo network
175
+ counters). `nettop` is shipped with macOS, runs without sudo, and
176
+ reports cumulative bytes_in/bytes_out per process. We run it
177
+ non-interactively (`-l 1 -s 1`) per sample tick, compute deltas
178
+ against the previous snapshot, and divide by elapsed time.
179
+ """
180
+ if self._nettop_available is False:
181
+ return False
182
+ if not tag_pids:
183
+ return False
184
+
185
+ try:
186
+ # `-t external` excludes loopback traffic. Without it nettop
187
+ # counts every proxied byte twice — once on the Chrome ↔ ssh
188
+ # loopback leg and once on the ssh ↔ remote external socket —
189
+ # producing artificially symmetric bytes_in/bytes_out (e.g. a
190
+ # 40 MB YouTube stream shows as 40 MB in + 40 MB out instead
191
+ # of 40 MB in + ~150 KB out). External-only matches the real
192
+ # SSH-to-remote throughput.
193
+ result = subprocess.run(
194
+ ["nettop", "-P", "-l", "1", "-s", "1", "-x",
195
+ "-t", "external", "-J", "bytes_in,bytes_out"],
196
+ capture_output=True, text=True, timeout=5,
197
+ )
198
+ except (FileNotFoundError, OSError):
199
+ self._nettop_available = False
200
+ return False
201
+ except subprocess.TimeoutExpired:
202
+ # Don't disable permanently — could be transient under load.
203
+ return False
204
+ self._nettop_available = True
205
+
206
+ # Parse: collect per-PID cumulative bytes for the PIDs we care about.
207
+ wanted: set[int] = {pid for pids in tag_pids.values() for pid in pids}
208
+ per_pid: dict[int, tuple[int, int]] = {}
209
+ for line in result.stdout.splitlines():
210
+ parsed = self._parse_nettop_line(line)
211
+ if parsed is None:
212
+ continue
213
+ pid, b_in, b_out = parsed
214
+ if pid in wanted:
215
+ per_pid[pid] = (b_in, b_out)
216
+
217
+ with self._lock:
218
+ prev = self._prev_nettop
219
+ prev_t = self._prev_nettop_t
220
+ # First sample — store baseline, no rates yet.
221
+ if prev is None or prev_t is None:
222
+ self._prev_nettop = per_pid
223
+ self._prev_nettop_t = now
224
+ return True
225
+
226
+ dt = now - prev_t
227
+ if dt <= 0:
228
+ dt = self.INTERVAL
229
+
230
+ new_rates: dict[str, tuple[float, float]] = {}
231
+ for tag, pids in tag_pids.items():
232
+ rx_delta = 0
233
+ tx_delta = 0
234
+ for pid in pids:
235
+ cur = per_pid.get(pid)
236
+ if cur is None:
237
+ continue
238
+ p = prev.get(pid)
239
+ if p is None:
240
+ # New PID — no baseline, contribute 0 this sample.
241
+ continue
242
+ rx_delta += max(0, cur[0] - p[0])
243
+ tx_delta += max(0, cur[1] - p[1])
244
+ rx_rate = rx_delta / dt
245
+ tx_rate = tx_delta / dt
246
+ new_rates[tag] = (rx_rate, tx_rate)
247
+ if self._on_sample:
248
+ try:
249
+ self._on_sample(tag, rx_rate, tx_rate)
250
+ except Exception:
251
+ pass
252
+ # Accumulate cumulative byte totals (actual bytes, not rate).
253
+ prev_total_rx, prev_total_tx = self._totals.get(tag, (0.0, 0.0))
254
+ self._totals[tag] = (
255
+ prev_total_rx + float(rx_delta),
256
+ prev_total_tx + float(tx_delta),
257
+ )
258
+ # Per-tag rolling rate history.
259
+ tag_hist = self._history.setdefault(tag, [])
260
+ tag_hist.append([rx_rate, tx_rate])
261
+ if len(tag_hist) > self._HISTORY_MAX:
262
+ del tag_hist[:-self._HISTORY_MAX]
263
+
264
+ self._rates = new_rates
265
+ self._prev_nettop = per_pid
266
+ self._prev_nettop_t = now
267
+ self._save_history()
268
+ return True
269
+
270
+ def _sample(self) -> None:
271
+ try:
272
+ import psutil
273
+ except ImportError:
274
+ return
275
+
276
+ now = time.monotonic()
277
+ net = psutil.net_io_counters()
278
+ if net is None:
279
+ return
280
+ sys_rx = float(net.bytes_recv)
281
+ sys_tx = float(net.bytes_sent)
282
+
283
+ # Build tag → list[pid] covering master + all slave processes.
284
+ # Forward slaves are NOT OS children of the master (start_new_session=True),
285
+ # so proc.children() misses them.
286
+ all_entries = self._mgr.status_all()
287
+ master_tags: dict[str, int] = {}
288
+ for key in all_entries:
289
+ if key.startswith(SSH_PROCESS_PREFIX + "-"):
290
+ tag = key[len(SSH_PROCESS_PREFIX) + 1:]
291
+ pid = self._mgr.get_pid(key)
292
+ if pid:
293
+ master_tags[tag] = pid
294
+
295
+ tag_pids: dict[str, list[int]] = {tag: [pid] for tag, pid in master_tags.items()}
296
+ for key in all_entries:
297
+ if key.startswith(FWD_PROCESS_PREFIX + "-"):
298
+ remainder = key[len(FWD_PROCESS_PREFIX) + 1:]
299
+ for tag in master_tags:
300
+ if remainder.startswith(tag + "-"):
301
+ pid = self._mgr.get_pid(key)
302
+ if pid:
303
+ tag_pids[tag].append(pid)
304
+ break
305
+
306
+ # macOS: psutil.Process.io_counters() doesn't exist (Linux/Windows only),
307
+ # so the read_chars-weighted attribution below collapses to all-zero.
308
+ # Use `nettop` instead — it's the only macOS userspace tool that
309
+ # exposes per-process network bytes. Values are cumulative since the
310
+ # OS process started, stable across invocations, monotonic.
311
+ if sys.platform == "darwin":
312
+ macos_done = self._sample_macos_nettop(tag_pids, now)
313
+ if macos_done:
314
+ return
315
+
316
+ proc_chars: dict[str, float] = {}
317
+ for tag, pids in tag_pids.items():
318
+ chars = 0.0
319
+ for pid in pids:
320
+ try:
321
+ proc = psutil.Process(pid)
322
+ chars += float(getattr(proc.io_counters(), "read_chars", 0))
323
+ except (psutil.NoSuchProcess, psutil.AccessDenied, AttributeError):
324
+ pass
325
+ proc_chars[tag] = chars
326
+
327
+ with self._lock:
328
+ if self._prev_net is not None:
329
+ prev_rx, prev_tx, prev_t = self._prev_net
330
+ dt = now - prev_t
331
+ if dt > 0:
332
+ delta_rx = max(0.0, sys_rx - prev_rx) / dt
333
+ delta_tx = max(0.0, sys_tx - prev_tx) / dt
334
+
335
+ deltas: dict[str, float] = {}
336
+ for tag, chars in proc_chars.items():
337
+ prev = self._prev_chars.get(tag, chars)
338
+ deltas[tag] = max(0.0, chars - prev)
339
+ total_delta = sum(deltas.values()) or 1.0
340
+
341
+ new_rates: dict[str, tuple[float, float]] = {}
342
+ for tag in proc_chars:
343
+ weight = deltas.get(tag, 0.0) / total_delta
344
+ rx = delta_rx * weight
345
+ tx = delta_tx * weight
346
+ new_rates[tag] = (rx, tx)
347
+ if self._on_sample:
348
+ try:
349
+ self._on_sample(tag, rx, tx)
350
+ except Exception:
351
+ pass
352
+ self._rates = new_rates
353
+
354
+ # Accumulate cumulative byte totals (rate × elapsed time = bytes this interval)
355
+ for tag, (rx, tx) in new_rates.items():
356
+ prev_rx, prev_tx = self._totals.get(tag, (0.0, 0.0))
357
+ self._totals[tag] = (prev_rx + rx * dt, prev_tx + tx * dt)
358
+
359
+ # Append to rolling per-tag history and persist
360
+ for tag, (rx, tx) in new_rates.items():
361
+ tag_hist = self._history.setdefault(tag, [])
362
+ tag_hist.append([rx, tx])
363
+ if len(tag_hist) > self._HISTORY_MAX:
364
+ del tag_hist[:-self._HISTORY_MAX]
365
+ self._save_history()
366
+
367
+ self._prev_net = (sys_rx, sys_tx, now)
368
+ self._prev_chars = dict(proc_chars)
369
+
370
+ def get_rate(self, tag: str) -> tuple[float, float]:
371
+ with self._lock:
372
+ return self._rates.get(tag, (0.0, 0.0))
373
+
374
+ def get_totals(self, tag: str) -> tuple[float, float]:
375
+ """Return (rx_total_bytes, tx_total_bytes) accumulated since last reset."""
376
+ with self._lock:
377
+ return self._totals.get(tag, (0.0, 0.0))
378
+
379
+ def reset_totals(self, tag: str | None = None) -> None:
380
+ """Reset cumulative counters. Pass tag=None to reset all."""
381
+ with self._lock:
382
+ if tag is None:
383
+ self._totals.clear()
384
+ else:
385
+ self._totals.pop(tag, None)
386
+
387
+
388
+ class _ReconnectMonitor:
389
+ """Background thread that monitors and restarts dropped SSH connections.
390
+
391
+ Tracks which connection tags were intentionally started. Every 5 seconds
392
+ it checks socket liveness per tag. When a socket goes down it attempts to
393
+ restart the ControlMaster immediately and on every subsequent poll until it
394
+ succeeds. Once the master is back, all enabled forwards are re-registered.
395
+ """
396
+
397
+ INTERVAL = 5.0
398
+
399
+ def __init__(self, mgr: "SusOpsManager") -> None:
400
+ self._mgr = mgr
401
+ self._intended: set[str] = set()
402
+ self._socket_was_alive: dict[str, bool] = {}
403
+ self._lock = threading.Lock()
404
+ self._stop_event: threading.Event | None = None
405
+ self._thread: threading.Thread | None = None
406
+
407
+ def start(self) -> None:
408
+ """Start (or restart) the monitor thread. Idempotent.
409
+
410
+ Race-safe across stop()/start() cycles: each thread receives its own
411
+ Event reference (passed as an argument) so a freshly-stopped thread on
412
+ its way out can't be confused with a running one — and the new thread
413
+ gets a fresh Event, not a cleared-after-set one.
414
+ """
415
+ # If a thread is actually running (event exists and not set), keep it.
416
+ if (
417
+ self._thread is not None
418
+ and self._thread.is_alive()
419
+ and self._stop_event is not None
420
+ and not self._stop_event.is_set()
421
+ ):
422
+ return
423
+ # Spin up a fresh thread with its own fresh stop event. Any prior
424
+ # thread is still draining its own (already-set) event and will exit
425
+ # naturally — it can't be confused with this new one.
426
+ new_event = threading.Event()
427
+ self._stop_event = new_event
428
+ self._thread = threading.Thread(
429
+ target=self._run, args=(new_event,), daemon=True, name="susops-reconnect"
430
+ )
431
+ self._thread.start()
432
+
433
+ def stop(self) -> None:
434
+ """Signal the monitor thread to exit on its next poll."""
435
+ if self._stop_event is not None:
436
+ self._stop_event.set()
437
+
438
+ def mark_running(self, tag: str) -> None:
439
+ with self._lock:
440
+ self._intended.add(tag)
441
+ self._socket_was_alive[tag] = True # assume alive at start
442
+
443
+ def mark_stopped(self, tag: str) -> None:
444
+ with self._lock:
445
+ self._intended.discard(tag)
446
+ self._socket_was_alive.pop(tag, None)
447
+
448
+ def _run(self, stop_event: threading.Event) -> None:
449
+ # Bound to *this* thread's event so a subsequent rebind on the
450
+ # manager doesn't accidentally keep us alive after stop().
451
+ while not stop_event.wait(timeout=self.INTERVAL):
452
+ with self._lock:
453
+ tags = list(self._intended)
454
+ for tag in tags:
455
+ try:
456
+ self._check(tag)
457
+ except Exception:
458
+ pass
459
+
460
+ def _check(self, tag: str) -> None:
461
+ alive = is_socket_alive(tag, self._mgr.workspace)
462
+ with self._lock:
463
+ was_alive = self._socket_was_alive.get(tag, True)
464
+ self._socket_was_alive[tag] = alive
465
+
466
+ if alive:
467
+ if not was_alive:
468
+ # Socket came back (reconnect succeeded on a previous poll).
469
+ self._mgr._log(f"[{tag}] Connection restored — re-registering forwards...")
470
+ self._mgr._reregister_forwards(tag)
471
+ return
472
+
473
+ # Socket is down. If a stopped-marker file exists, the user
474
+ # intentionally stopped this tag from THIS or ANOTHER process — do
475
+ # not reconnect. The marker is shared across processes so a TUI stop
476
+ # is honoured by the tray's monitor (and vice versa).
477
+ if _stopped_marker_path(self._mgr.workspace, tag).exists():
478
+ # Treat the tag as stopped for our local intended set so we stop
479
+ # emitting reconnect notifications on every poll.
480
+ with self._lock:
481
+ self._intended.discard(tag)
482
+ self._socket_was_alive.pop(tag, None)
483
+ return
484
+
485
+ if was_alive:
486
+ self._mgr._log(f"[{tag}] Connection lost — reconnecting...")
487
+ self._mgr._emit("state", {"tag": tag, "running": False, "pid": None, "reconnecting": True})
488
+ self._mgr._notify(f"{self._mgr._process_name} [{tag}]", "Connection lost — reconnecting...")
489
+ # Attempt to restart the master on every poll while the socket is down.
490
+ if self._mgr._try_reconnect(tag):
491
+ with self._lock:
492
+ self._socket_was_alive[tag] = True
493
+ self._mgr._reregister_forwards(tag)
494
+
495
+
496
+ def _stopped_marker_path(workspace: Path, tag: str) -> Path:
497
+ """File whose existence means a tag was *intentionally* stopped.
498
+
499
+ Written by any process that calls stop() for a tag; checked by every
500
+ in-process _ReconnectMonitor before
501
+ attempting a reconnect. Cleared by start().
502
+
503
+ Without this, a tray and a TUI running side-by-side each have their own
504
+ in-process monitor with its own _intended set — when the user stops a
505
+ tag in one, the other's monitor sees a dead socket and silently brings
506
+ the tunnel back up.
507
+ """
508
+ return workspace / "pids" / f"susops-stopped-{tag}.marker"
509
+
510
+
511
+ def _write_stopped_marker(workspace: Path, tag: str) -> None:
512
+ p = _stopped_marker_path(workspace, tag)
513
+ try:
514
+ p.parent.mkdir(parents=True, exist_ok=True)
515
+ p.touch()
516
+ except Exception:
517
+ pass
518
+
519
+
520
+ def _clear_stopped_marker(workspace: Path, tag: str) -> None:
521
+ try:
522
+ _stopped_marker_path(workspace, tag).unlink(missing_ok=True)
523
+ except Exception:
524
+ pass
525
+
526
+
527
+ class SusOpsManager:
528
+ """Unified manager for SSH tunnels, PAC server, and file sharing."""
529
+
530
+ def __init__(
531
+ self,
532
+ workspace: Path = _WORKSPACE_DEFAULT,
533
+ verbose: bool = False,
534
+ _enable_background_threads: bool = True,
535
+ _skip_restore: bool = False,
536
+ process_name: str = "SusOps",
537
+ ) -> None:
538
+ self.workspace = workspace
539
+ self.workspace.mkdir(parents=True, exist_ok=True)
540
+ self._process_name = process_name
541
+ self._verbose = verbose
542
+
543
+ self.config: SusOpsConfig = load_config(workspace)
544
+ self._process_mgr = ProcessManager(workspace)
545
+ self._pac_server = PacServer()
546
+ self._status_server = StatusServer()
547
+ self._share_servers: dict[int, tuple[ShareServer, ShareInfo]] = {}
548
+ self._log_buffer: deque[str] = deque(maxlen=500)
549
+ self._bw_sampler = _BandwidthSampler(
550
+ self._process_mgr, workspace=workspace, on_sample=self._on_bandwidth
551
+ )
552
+ self._start_times: dict[str, float] = {} # tag -> time.monotonic() when started
553
+ self._reconnect_monitor = _ReconnectMonitor(self)
554
+ if _enable_background_threads:
555
+ self._reconnect_monitor.start()
556
+
557
+ self.on_state_change: Callable[[ProcessState], None] | None = None
558
+ self.on_log: Callable[[str], None] | None = None
559
+ self.on_error: Callable[[str], None] | None = None
560
+
561
+ if not _skip_restore:
562
+ # Auto-restart PAC server when tunnels are running but this is a
563
+ # fresh process (e.g. TUI restarted without stop_on_quit).
564
+ self._restore_pac()
565
+
566
+ if self.config.susops_app.restore_shares_on_start:
567
+ self._restore_shares()
568
+
569
+ # Mark already-running connections so the reconnect monitor watches them.
570
+ # Needed when the TUI restarts with connections still live (stop_on_quit=False).
571
+ self._restore_reconnect_monitor()
572
+
573
+ # ------------------------------------------------------------------ #
574
+ # Internal helpers
575
+ # ------------------------------------------------------------------ #
576
+
577
+ def _log(self, msg: str) -> None:
578
+ # Store the raw, human-readable message. Markup-escaping (so Rich-based
579
+ # frontends like the TUI's RichLog don't interpret "[pi3]" as a tag) is
580
+ # the consumer's job — the tray + plain-text consumers want the raw
581
+ # text so users can copy it and read it normally.
582
+ #
583
+ # Every line gets a `[HH:MM:SS]` prefix so logs are temporally
584
+ # navigable in the TUI + tray viewers, where the user can't easily
585
+ # tell when an entry landed. The shared log_style parser has a
586
+ # dedicated rule that colours the timestamp dim so it doesn't
587
+ # crowd the message.
588
+ stamped = f"[{datetime.datetime.now().strftime('%H:%M:%S')}] {msg}"
589
+ self._log_buffer.append(stamped)
590
+ if self.on_log:
591
+ self.on_log(stamped)
592
+
593
+ def _error(self, msg: str) -> None:
594
+ """Log an error to the log buffer and fire the on_error callback.
595
+
596
+ Use this instead of _log() for failures that the user must see
597
+ immediately (connection failures, forward failures, share errors).
598
+ on_error is wired to the TUI's notify() toast in dashboard.py.
599
+ """
600
+ self._log(msg)
601
+ if self.on_error:
602
+ try:
603
+ self.on_error(msg)
604
+ except Exception:
605
+ pass
606
+
607
+ def _debug(self, msg: str) -> None:
608
+ """Log a debug message. Only active when verbose=True.
609
+
610
+ In TUI/tray mode the message goes to the Logs tab via on_log.
611
+ In CLI mode (no on_log handler) it is printed to stderr.
612
+ """
613
+ if not self._verbose:
614
+ return
615
+ full = f"[{datetime.datetime.now().strftime('%H:%M:%S')}] [debug] {msg}"
616
+ self._log_buffer.append(full)
617
+ if self.on_log:
618
+ self.on_log(full)
619
+ else:
620
+ print(full, file=sys.stderr)
621
+
622
+ def _notify(self, title: str, body: str) -> None:
623
+ """Send a desktop notification. Best-effort — fails silently."""
624
+ import platform
625
+ import subprocess
626
+ try:
627
+ if platform.system() == "Darwin":
628
+ subprocess.Popen(
629
+ ["osascript", "-e",
630
+ f'display notification "{body}" with title "{title}"'],
631
+ stdout=subprocess.DEVNULL,
632
+ stderr=subprocess.DEVNULL,
633
+ )
634
+ elif platform.system() == "Linux":
635
+ subprocess.Popen(
636
+ ["notify-send", title, body],
637
+ stdout=subprocess.DEVNULL,
638
+ stderr=subprocess.DEVNULL,
639
+ )
640
+ except Exception:
641
+ pass
642
+
643
+ def _emit(self, event: str, data: dict) -> None:
644
+ """Emit an SSE event and log it when verbose (bandwidth excluded — too noisy)."""
645
+ self._status_server.emit(event, data)
646
+ if event != "bandwidth":
647
+ self._debug(f"event:{event} {data}")
648
+
649
+ def _emit_state(self, state: ProcessState) -> None:
650
+ if self.on_state_change:
651
+ self.on_state_change(state)
652
+
653
+ def _reload_config(self) -> None:
654
+ self.config = load_config(self.workspace)
655
+
656
+ def _save(self) -> None:
657
+ save_config(self.config, self.workspace)
658
+
659
+ def _replace_connection(self, updated: Connection) -> None:
660
+ """Swap the connection with the same tag in self.config (does not save)."""
661
+ self.config = self.config.model_copy(
662
+ update={"connections": [updated if c.tag == updated.tag else c for c in self.config.connections]}
663
+ )
664
+
665
+ def _register_forwards(self, conn: Connection, error_suffix: str = "") -> None:
666
+ """Register all enabled forwards for conn through its ControlMaster.
667
+
668
+ Iterates local then remote. TCP forwards use ``ssh -O forward``; UDP
669
+ forwards start socat processes. Errors are user-visible but do not
670
+ abort registration of other forwards.
671
+ """
672
+ for direction, fwds in (("local", conn.forwards.local), ("remote", conn.forwards.remote)):
673
+ for fw in fwds:
674
+ if not fw.enabled:
675
+ continue
676
+ try:
677
+ if fw.tcp:
678
+ start_forward(conn, fw, direction, self.workspace)
679
+ if fw.udp:
680
+ start_udp_forward(conn, fw, direction, self._process_mgr, self.workspace)
681
+ except Exception as exc:
682
+ suffix = f" {error_suffix}" if error_suffix else ""
683
+ self._error(f"[{conn.tag}] Forward {fw.src_port} failed{suffix}: {exc}")
684
+
685
+ def _maybe_start_forward_live(self, conn_tag: str, fw: PortForward, direction: str) -> None:
686
+ """Start a forward immediately if the connection master is currently running."""
687
+ conn = get_connection(self.config, conn_tag)
688
+ if not conn:
689
+ return
690
+ if not (is_tunnel_running(conn_tag, self._process_mgr) or is_socket_alive(conn_tag, self.workspace)):
691
+ return
692
+ try:
693
+ if fw.tcp:
694
+ start_forward(conn, fw, direction, self.workspace)
695
+ if fw.udp:
696
+ start_udp_forward(conn, fw, direction, self._process_mgr, self.workspace)
697
+ self._emit("forward", {
698
+ "tag": conn_tag,
699
+ "fw_tag": fw.tag or f"{direction}-{fw.src_port}",
700
+ "direction": direction,
701
+ "running": True,
702
+ })
703
+ except Exception as exc:
704
+ self._log(f"[{conn_tag}] Could not start forward: {exc}")
705
+
706
+ def _on_bandwidth(self, tag: str, rx: float, tx: float) -> None:
707
+ self._status_server.emit("bandwidth", {"tag": tag, "rx_bps": rx, "tx_bps": tx})
708
+
709
+ def _connection_status(self, conn: Connection) -> ConnectionStatus:
710
+ running = is_tunnel_running(conn.tag, self._process_mgr)
711
+ # Fall back to socket liveness when PID file is stale (zombie reaped,
712
+ # or master restarted outside our control).
713
+ if not running and is_socket_alive(conn.tag, self.workspace):
714
+ running = True
715
+ # Try to recover the PID from /proc so the dashboard can show it.
716
+ recovered = find_master_pid(conn.tag, self.workspace)
717
+ if recovered:
718
+ name = f"{SSH_PROCESS_PREFIX}-{conn.tag}"
719
+ self._process_mgr._pid_file(name).write_text(str(recovered))
720
+ pid = self._process_mgr.get_pid(f"{SSH_PROCESS_PREFIX}-{conn.tag}")
721
+ return ConnectionStatus(
722
+ tag=conn.tag,
723
+ running=running,
724
+ pid=pid,
725
+ socks_port=conn.socks_proxy_port,
726
+ enabled=conn.enabled,
727
+ )
728
+
729
+ def _ensure_socks_port(self, conn: Connection) -> Connection:
730
+ if conn.socks_proxy_port != 0:
731
+ return conn
732
+ port = get_random_free_port()
733
+ updated = conn.model_copy(update={"socks_proxy_port": port})
734
+ self._replace_connection(updated)
735
+ self._save()
736
+ self._log(f"[{conn.tag}] Assigned SOCKS port {port}")
737
+ return updated
738
+
739
+ # ------------------------------------------------------------------ #
740
+ # PAC port-file helpers (cross-process PAC status detection)
741
+ # ------------------------------------------------------------------ #
742
+
743
+ @property
744
+ def _pac_port_file(self) -> "Path":
745
+ return self.workspace / "pids" / "susops-pac.port"
746
+
747
+ def _write_pac_port_file(self, port: int) -> None:
748
+ self._pac_port_file.parent.mkdir(parents=True, exist_ok=True)
749
+ self._pac_port_file.write_text(str(port))
750
+
751
+ def _remove_pac_port_file(self) -> None:
752
+ self._pac_port_file.unlink(missing_ok=True)
753
+
754
+ def _read_pac_port_file(self) -> int:
755
+ try:
756
+ return int(self._pac_port_file.read_text().strip())
757
+ except Exception:
758
+ return 0
759
+
760
+ @staticmethod
761
+ def _probe_port(port: int) -> bool:
762
+ import socket
763
+ try:
764
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
765
+ s.settimeout(0.2)
766
+ return s.connect_ex(("127.0.0.1", port)) == 0
767
+ except OSError:
768
+ return False
769
+
770
+ @staticmethod
771
+ def _fire_http(url: str, timeout: int = 2) -> None:
772
+ """POST to url, ignoring all errors (fire-and-forget)."""
773
+ try:
774
+ urllib.request.urlopen(url, data=b"", timeout=timeout)
775
+ except Exception:
776
+ pass
777
+
778
+ def _ensure_pac_port(self) -> int:
779
+ if self.config.pac_server_port != 0:
780
+ return self.config.pac_server_port
781
+ port = get_random_free_port()
782
+ self.config = self.config.model_copy(update={"pac_server_port": port})
783
+ self._save()
784
+ return port
785
+
786
+ def _compute_state(
787
+ self,
788
+ statuses: tuple[ConnectionStatus, ...] | None = None,
789
+ pac_running: bool | None = None,
790
+ ) -> ProcessState:
791
+ if statuses is None:
792
+ statuses = tuple(self._connection_status(c) for c in self.config.connections)
793
+ if pac_running is None:
794
+ pac_running = self._pac_server.is_running()
795
+ if not self.config.connections:
796
+ return ProcessState.STOPPED
797
+ # Disabled connections shouldn't count toward "partial" — the user
798
+ # explicitly took them out of rotation. State is computed only over
799
+ # connections the user wants up.
800
+ enabled_statuses = [s for s in statuses if s.enabled]
801
+ if not enabled_statuses:
802
+ # All connections are disabled — there's nothing to run, so we're
803
+ # stopped (PAC is irrelevant in this case but we don't actively
804
+ # demote RUNNING → STOPPED if it happens to be up).
805
+ return ProcessState.STOPPED if not pac_running else ProcessState.STOPPED_PARTIALLY
806
+ running_count = sum(1 for s in enabled_statuses if s.running)
807
+ total = len(enabled_statuses)
808
+ if running_count == total and pac_running:
809
+ return ProcessState.RUNNING
810
+ if running_count == 0 and not pac_running:
811
+ return ProcessState.STOPPED
812
+ return ProcessState.STOPPED_PARTIALLY
813
+
814
+ # ------------------------------------------------------------------ #
815
+ # Share persistence helpers
816
+ # ------------------------------------------------------------------ #
817
+
818
+ def _restore_pac(self) -> None:
819
+ """Restart the PAC server if SSH tunnels are running but PAC is dead.
820
+
821
+ Called on __init__ so the PAC server is recovered after a TUI restart
822
+ without stop_on_quit (the daemon thread died with the previous process).
823
+ Uses both PID-file and socket-liveness checks so a stale PID file
824
+ (daemon thread deleted it mid-shutdown) doesn't prevent PAC restore.
825
+ """
826
+ any_tunnel = False
827
+ for conn in self.config.connections:
828
+ if is_tunnel_running(conn.tag, self._process_mgr):
829
+ any_tunnel = True
830
+ elif is_socket_alive(conn.tag, self.workspace):
831
+ any_tunnel = True
832
+ # PID file is stale — recover PID so future checks don't re-enter here
833
+ recovered = find_master_pid(conn.tag, self.workspace)
834
+ if recovered:
835
+ name = f"{SSH_PROCESS_PREFIX}-{conn.tag}"
836
+ self._process_mgr._pid_file(name).write_text(str(recovered))
837
+ if not any_tunnel:
838
+ return
839
+ port = self.config.pac_server_port
840
+ if not port:
841
+ # Port unknown — let start() allocate one when user next calls start
842
+ return
843
+ if self._probe_port(port):
844
+ # A cross-process PAC server is still serving (e.g. tray app)
845
+ self._log(f"PAC server already running (cross-process) on port {port}")
846
+ return
847
+ try:
848
+ pac_path = write_pac_file(self.config, self.workspace, active_tags=self._active_tags())
849
+ self._pac_server.start(port, pac_path)
850
+ self._write_pac_port_file(port)
851
+ self._log(f"PAC server restored on port {port}")
852
+ except Exception as exc:
853
+ self._log(f"PAC restore failed: {exc}")
854
+
855
+ def _restore_reconnect_monitor(self) -> None:
856
+ """Mark already-live connections so _ReconnectMonitor watches them on startup.
857
+
858
+ Called after __init__ when connections may already be running from a
859
+ previous session (stop_on_quit=False). Without this, mark_running() is
860
+ never called for adopted connections and the monitor's _intended set
861
+ stays empty — watching nothing until the next explicit start().
862
+ """
863
+ for conn in self.config.connections:
864
+ if not conn.enabled:
865
+ continue
866
+ if is_tunnel_running(conn.tag, self._process_mgr) or is_socket_alive(conn.tag, self.workspace):
867
+ self._reconnect_monitor.mark_running(conn.tag)
868
+
869
+ def _restore_shares(self) -> None:
870
+ """Restart share servers for persisted FileShare entries whose connection is running.
871
+
872
+ Skips entries the user manually stopped (stopped=True).
873
+ """
874
+ for conn in self.config.connections:
875
+ if not is_tunnel_running(conn.tag, self._process_mgr):
876
+ continue # shares are meaningless without a live tunnel
877
+ for fs in conn.file_shares:
878
+ if fs.stopped:
879
+ continue # user manually stopped this share — don't auto-restart
880
+ file_path = Path(fs.file_path)
881
+ if not file_path.exists():
882
+ self._log(
883
+ f"[{conn.tag}] Share '{fs.file_path}' not found on disk — skipping restore"
884
+ )
885
+ continue
886
+ try:
887
+ server = ShareServer()
888
+ info_raw = server.start(
889
+ file_path=file_path,
890
+ password=fs.password,
891
+ port=fs.port,
892
+ workspace=self.workspace,
893
+ )
894
+ # Write back the actual port if it changed
895
+ actual_port = info_raw.port
896
+ info = ShareInfo(
897
+ file_path=str(file_path),
898
+ port=actual_port,
899
+ password=fs.password,
900
+ url=f"http://localhost:{actual_port}",
901
+ conn_tag=conn.tag,
902
+ running=True,
903
+ )
904
+ self._share_servers[actual_port] = (server, info)
905
+ if actual_port != fs.port:
906
+ self._update_file_share_port(conn.tag, fs, actual_port)
907
+ self._log(f"[{conn.tag}] Restored share '{file_path.name}' on port {actual_port}")
908
+ except Exception as exc:
909
+ self._log(f"[{conn.tag}] Failed to restore share '{fs.file_path}': {exc}")
910
+
911
+ def _update_file_share_port(
912
+ self, conn_tag: str, fs: FileShare, new_port: int
913
+ ) -> None:
914
+ """Update the stored port for a FileShare entry in config."""
915
+ conn = get_connection(self.config, conn_tag)
916
+ if conn is None:
917
+ return
918
+ updated_shares = [
919
+ fs.model_copy(update={"port": new_port}) if f.file_path == fs.file_path else f
920
+ for f in conn.file_shares
921
+ ]
922
+ self._replace_connection(conn.model_copy(update={"file_shares": updated_shares}))
923
+ self._save()
924
+
925
+ def _add_file_share_to_config(
926
+ self, conn_tag: str, file_path: str, password: str, port: int
927
+ ) -> None:
928
+ conn = get_connection(self.config, conn_tag)
929
+ if conn is None:
930
+ return
931
+ # Update existing entry (clear stopped flag on re-share) or append new
932
+ existing = [f for f in conn.file_shares if f.file_path == file_path]
933
+ if existing:
934
+ new_shares = [
935
+ fs.model_copy(update={"password": password, "port": port, "stopped": False})
936
+ if fs.file_path == file_path else fs
937
+ for fs in conn.file_shares
938
+ ]
939
+ else:
940
+ new_shares = list(conn.file_shares) + [
941
+ FileShare(file_path=file_path, password=password, port=port)
942
+ ]
943
+ self._replace_connection(conn.model_copy(update={"file_shares": new_shares}))
944
+ self._save()
945
+
946
+ def _remove_file_share_from_config(self, port: int) -> None:
947
+ new_conns = []
948
+ for conn in self.config.connections:
949
+ updated_shares = [f for f in conn.file_shares if f.port != port]
950
+ if len(updated_shares) != len(conn.file_shares):
951
+ new_conns.append(conn.model_copy(update={"file_shares": updated_shares}))
952
+ else:
953
+ new_conns.append(conn)
954
+ self.config = self.config.model_copy(update={"connections": new_conns})
955
+ self._save()
956
+
957
+ def _set_file_share_stopped(self, port: int, stopped: bool) -> None:
958
+ """Update the stopped flag on a persisted FileShare entry."""
959
+ new_conns = []
960
+ for conn in self.config.connections:
961
+ updated = [
962
+ fs.model_copy(update={"stopped": stopped}) if fs.port == port else fs
963
+ for fs in conn.file_shares
964
+ ]
965
+ new_conns.append(conn.model_copy(update={"file_shares": updated}))
966
+ self.config = self.config.model_copy(update={"connections": new_conns})
967
+ self._save()
968
+
969
+ # ------------------------------------------------------------------ #
970
+ # Lifecycle
971
+ # ------------------------------------------------------------------ #
972
+
973
+ def start(self, tag: str | None = None) -> StartResult:
974
+ # Re-spin the in-process monitor in case a prior stop() halted it.
975
+ self._reconnect_monitor.start()
976
+ self._reload_config()
977
+ connections = (
978
+ [get_connection(self.config, tag)] if tag
979
+ else list(self.config.connections)
980
+ )
981
+ connections = [c for c in connections if c is not None]
982
+
983
+ if not connections:
984
+ return StartResult(success=False, message="No connections configured")
985
+
986
+ statuses = []
987
+ errors = []
988
+
989
+ for conn in connections:
990
+ # User asked to start this — clear any "intentionally stopped"
991
+ # marker so reconnect monitors (this process and any other) will
992
+ # watch it again.
993
+ _clear_stopped_marker(self.workspace, conn.tag)
994
+ if not conn.enabled:
995
+ self._log(f"[{conn.tag}] Disabled — skipping")
996
+ statuses.append(self._connection_status(conn))
997
+ continue
998
+ if is_tunnel_running(conn.tag, self._process_mgr):
999
+ self._log(f"[{conn.tag}] Already running")
1000
+ statuses.append(self._connection_status(conn))
1001
+ continue
1002
+ if is_socket_alive(conn.tag, self.workspace):
1003
+ # ControlMaster is alive but our PID file is stale (e.g.
1004
+ # process was a zombie that was reaped). Don't start a
1005
+ # second master — re-adopt by re-tracking the socket owner.
1006
+ self._log(f"[{conn.tag}] Socket alive but PID stale — skipping new master")
1007
+ statuses.append(self._connection_status(conn))
1008
+ continue
1009
+ try:
1010
+ conn = self._ensure_socks_port(conn)
1011
+ pid = start_master(conn, self._process_mgr, self.workspace)
1012
+ self._log(f"[{conn.tag}] Master started (PID {pid})")
1013
+
1014
+ self._register_forwards(conn)
1015
+
1016
+ # Start HTTP servers for config-only (stopped) shares, then forward slaves
1017
+ # for all running share servers belonging to this connection.
1018
+ for fs in conn.file_shares:
1019
+ if fs.stopped:
1020
+ continue # user manually stopped — do not auto-restart
1021
+ if fs.port in self._share_servers:
1022
+ continue # already running
1023
+ fp = Path(fs.file_path)
1024
+ if not fp.exists():
1025
+ self._log(f"[{conn.tag}] Share '{fs.file_path}' not found — skipping")
1026
+ continue
1027
+ try:
1028
+ srv = ShareServer()
1029
+ raw = srv.start(file_path=fp, password=fs.password,
1030
+ port=fs.port, workspace=self.workspace)
1031
+ si = ShareInfo(
1032
+ file_path=str(fp), port=raw.port, password=fs.password,
1033
+ url=f"http://localhost:{raw.port}", conn_tag=conn.tag, running=True,
1034
+ )
1035
+ self._share_servers[raw.port] = (srv, si)
1036
+ if raw.port != fs.port:
1037
+ self._update_file_share_port(conn.tag, fs, raw.port)
1038
+ self._log(f"[{conn.tag}] Started share '{fp.name}' on port {raw.port}")
1039
+ self._emit("share", {
1040
+ "port": raw.port,
1041
+ "file": fp.name,
1042
+ "running": True,
1043
+ "conn_tag": conn.tag,
1044
+ })
1045
+ except Exception as exc:
1046
+ self._error(f"[{conn.tag}] Failed to start share '{fs.file_path}': {exc}")
1047
+
1048
+ for share_port, (_server, share_info) in list(self._share_servers.items()):
1049
+ if share_info.conn_tag == conn.tag:
1050
+ fw = PortForward(
1051
+ src_port=share_port,
1052
+ dst_port=share_port,
1053
+ src_addr="localhost",
1054
+ dst_addr="localhost",
1055
+ tag=f"share-{share_port}",
1056
+ )
1057
+ try:
1058
+ start_forward(conn, fw, "remote", self.workspace)
1059
+ except Exception as exc:
1060
+ self._log(f"[{conn.tag}] Share forward {share_port} failed: {exc}")
1061
+
1062
+ statuses.append(ConnectionStatus(
1063
+ tag=conn.tag, running=True, pid=pid,
1064
+ socks_port=conn.socks_proxy_port,
1065
+ ))
1066
+ self._start_times[conn.tag] = time.monotonic()
1067
+ self._emit("state", {"tag": conn.tag, "running": True, "pid": pid})
1068
+ self._reconnect_monitor.mark_running(conn.tag)
1069
+ except Exception as exc:
1070
+ log_path = self.workspace / "logs" / f"susops-ssh-{conn.tag}.log"
1071
+ tail = ""
1072
+ if log_path.exists():
1073
+ lines = [l for l in log_path.read_text().splitlines() if l.strip()]
1074
+ if lines:
1075
+ tail = "\n " + "\n ".join(lines[-5:])
1076
+ msg = f"[{conn.tag}] Failed: {exc}{tail}"
1077
+ self._error(msg)
1078
+ errors.append(msg)
1079
+ statuses.append(ConnectionStatus(tag=conn.tag, running=False))
1080
+ self._emit("state", {"tag": conn.tag, "running": False, "pid": None})
1081
+
1082
+ if not self._pac_server.is_running():
1083
+ cross_port = self._read_pac_port_file()
1084
+ if cross_port and self._probe_port(cross_port):
1085
+ self._log(f"PAC server already running (cross-process) on port {cross_port}")
1086
+ # Regenerate PAC file so cross-process server picks up the new connection
1087
+ self._update_pac()
1088
+ else:
1089
+ if cross_port:
1090
+ self._remove_pac_port_file()
1091
+ try:
1092
+ self._reload_config()
1093
+ pac_port = self._ensure_pac_port()
1094
+ pac_path = write_pac_file(self.config, self.workspace, active_tags=self._active_tags())
1095
+ self._pac_server.start(pac_port, pac_path)
1096
+ self._write_pac_port_file(self._pac_server.get_port())
1097
+ self._log(f"PAC server started on port {pac_port}")
1098
+ except Exception as exc:
1099
+ self._error(f"PAC server failed: {exc}")
1100
+ errors.append(f"PAC server failed: {exc}")
1101
+ else:
1102
+ # PAC already running in-process — regenerate to include new connection's hosts
1103
+ self._update_pac()
1104
+
1105
+ # Start status server if not already running
1106
+ self.ensure_sse_status_server()
1107
+
1108
+ self._emit_state(self._compute_state())
1109
+ return StartResult(
1110
+ success=not errors,
1111
+ message="; ".join(errors) if errors else "Started",
1112
+ connection_statuses=tuple(statuses),
1113
+ )
1114
+
1115
+ def stop_quick(self) -> None:
1116
+ """Non-blocking stop for TUI quit: signal all external processes immediately.
1117
+
1118
+ Sends SIGTERM to every tracked PID at once (no per-process waiting),
1119
+ then shuts down in-process async servers. Used by the TUI so that all
1120
+ connections are signaled before the Python process exits, regardless
1121
+ of how many connections are configured.
1122
+ """
1123
+ self._reconnect_monitor.stop()
1124
+ self._process_mgr.kill_all()
1125
+ for server, _ in list(self._share_servers.values()):
1126
+ try:
1127
+ server.stop()
1128
+ except Exception:
1129
+ pass
1130
+ self._share_servers.clear()
1131
+ if self._pac_server.is_running():
1132
+ try:
1133
+ self._pac_server.stop()
1134
+ except Exception:
1135
+ pass
1136
+
1137
+ def ensure_sse_status_server(self) -> int | None:
1138
+ """Start the SSE status server if not already running.
1139
+
1140
+ Returns the bound port on success, or None if startup failed. Persists
1141
+ an auto-allocated port back to config so subsequent daemon spawns
1142
+ reuse it (which matters when frontends cache the URL).
1143
+ """
1144
+ if self._status_server.is_running():
1145
+ return self._status_server.get_port()
1146
+ try:
1147
+ status_port = self.config.status_server_port
1148
+ actual_port = self._status_server.start(port=status_port)
1149
+ if actual_port != status_port and status_port == 0:
1150
+ self.config = self.config.model_copy(
1151
+ update={"status_server_port": actual_port}
1152
+ )
1153
+ self._save()
1154
+ return actual_port
1155
+ except Exception as exc:
1156
+ self._log(f"SSE status server failed: {exc}")
1157
+ return None
1158
+
1159
+ def is_idle(self) -> bool:
1160
+ """Return True when the daemon has no work to do.
1161
+
1162
+ Used to drive the services daemon's idle-shutdown logic: when the last
1163
+ SSE client disconnects AND the daemon is idle, the process exits and
1164
+ the next frontend respawns it (<1 s).
1165
+
1166
+ Idle means:
1167
+ - no SSH masters or forwards tracked under our pids/ dir (anything
1168
+ except the daemon's own susops-services.pid)
1169
+ - no share servers running
1170
+ - PAC server not running
1171
+ - reconnect monitor not watching any connection
1172
+ """
1173
+ from susops.core.process import ProcessManager
1174
+
1175
+ blacklist = ProcessManager._KILL_ALL_BLACKLIST # type: ignore[attr-defined]
1176
+ try:
1177
+ tracked = [
1178
+ p for p in self._process_mgr._pids_dir.glob("*.pid")
1179
+ if p.stem not in blacklist
1180
+ ]
1181
+ except Exception:
1182
+ tracked = []
1183
+ if tracked:
1184
+ return False
1185
+ if self._share_servers:
1186
+ return False
1187
+ if self._pac_server.is_running():
1188
+ return False
1189
+ try:
1190
+ with self._reconnect_monitor._lock:
1191
+ if self._reconnect_monitor._intended:
1192
+ return False
1193
+ except Exception:
1194
+ pass
1195
+ return True
1196
+
1197
+ def reconnect_monitor_info(self) -> dict:
1198
+ """Return display info about the current reconnect monitor state.
1199
+
1200
+ Returns a dict with:
1201
+ thread_alive – in-process _ReconnectMonitor thread is running
1202
+ daemon_running – always False (background reconnect daemon removed)
1203
+ watching – set of connection tags currently being monitored
1204
+ """
1205
+ t = self._reconnect_monitor._thread
1206
+ thread_alive = t is not None and t.is_alive()
1207
+ with self._reconnect_monitor._lock:
1208
+ watching = set(self._reconnect_monitor._intended)
1209
+ return {
1210
+ "thread_alive": thread_alive,
1211
+ "daemon_running": False,
1212
+ "watching": watching,
1213
+ }
1214
+
1215
+ def process_info(self) -> dict:
1216
+ """Return subprocess info grouped by connection for ``susops ps`` display.
1217
+
1218
+ TCP forwards have no separate processes — they are port bindings on the
1219
+ ControlMaster. They are sourced from config so they always appear.
1220
+ UDP forwards have socat processes; their ``lsocat`` PID is shown.
1221
+
1222
+ Returns a dict with:
1223
+ conn_children – {tag: [{display, pid, running}, ...]}
1224
+ reconnect – {pid, running, thread_alive, daemon_running}
1225
+ """
1226
+ # Index UDP lsocat processes by (conn_tag, fw_tag) for fast lookup.
1227
+ # Only lsocat is shown — it is the entry-point process for both local
1228
+ # and remote UDP forwards and best represents "is this forward active".
1229
+ udp_lsocat: dict[tuple, dict] = {}
1230
+ for name, proc_running in self._process_mgr.status_all().items():
1231
+ if not name.startswith(UDP_PROCESS_PREFIX + "-"):
1232
+ continue
1233
+ remainder = name[len(UDP_PROCESS_PREFIX) + 1:]
1234
+ if not remainder.endswith("-lsocat"):
1235
+ continue
1236
+ for conn in self.config.connections:
1237
+ if remainder.startswith(conn.tag + "-"):
1238
+ fw_tag = remainder[len(conn.tag) + 1: -len("-lsocat")]
1239
+ pid = self._process_mgr.get_pid(name)
1240
+ udp_lsocat[(conn.tag, fw_tag)] = {"pid": pid, "running": proc_running}
1241
+ break
1242
+
1243
+ conn_children: dict[str, list[dict]] = {}
1244
+ for conn in self.config.connections:
1245
+ # TCP forwards are bound to the ControlMaster and never have their
1246
+ # own PID files. Mark them running whenever the master is up.
1247
+ master_up = is_tunnel_running(conn.tag, self._process_mgr)
1248
+ children: list[dict] = []
1249
+ for direction, fwds in (("local", conn.forwards.local), ("remote", conn.forwards.remote)):
1250
+ for fw in fwds:
1251
+ if not fw.enabled:
1252
+ continue
1253
+ src = f"{fw.src_addr}:{fw.src_port}" if fw.src_addr != "localhost" else str(fw.src_port)
1254
+ dst = f"{fw.dst_addr}:{fw.dst_port}"
1255
+ label = f" [{fw.tag}]" if fw.tag else ""
1256
+ fw_tag = fw.tag if fw.tag else f"{direction[0]}-{fw.src_port}"
1257
+ if fw.tcp:
1258
+ children.append({
1259
+ "display": f"fwd {direction} {src} → {dst}{label}",
1260
+ "pid": None,
1261
+ "running": master_up,
1262
+ })
1263
+ if fw.udp:
1264
+ proc = udp_lsocat.get((conn.tag, fw_tag))
1265
+ children.append({
1266
+ "display": f"udp {direction} {src} → {dst}{label}",
1267
+ "pid": proc["pid"] if proc else None,
1268
+ "running": proc["running"] if proc else False,
1269
+ })
1270
+ if children:
1271
+ conn_children[conn.tag] = children
1272
+
1273
+ reconnect_info = self.reconnect_monitor_info()
1274
+ return {
1275
+ "conn_children": conn_children,
1276
+ "reconnect": {
1277
+ "pid": None,
1278
+ "running": False,
1279
+ "thread_alive": reconnect_info["thread_alive"],
1280
+ "daemon_running": False,
1281
+ },
1282
+ }
1283
+
1284
+ def _active_tags(self) -> set[str]:
1285
+ """Return the set of connection tags that are currently running."""
1286
+ return {
1287
+ conn.tag for conn in self.config.connections
1288
+ if is_tunnel_running(conn.tag, self._process_mgr) or is_socket_alive(conn.tag, self.workspace)
1289
+ }
1290
+
1291
+ def _start_master_only(self, conn_tag: str) -> None:
1292
+ """Start only the SSH ControlMaster for conn_tag — no forwards, PAC, or shares.
1293
+
1294
+ Used by fetch() to establish connectivity without touching any other
1295
+ configured services. Returns immediately once the socket appears.
1296
+ """
1297
+ conn = get_connection(self.config, conn_tag)
1298
+ if conn is None:
1299
+ raise ValueError(f"Connection '{conn_tag}' not found")
1300
+ if is_tunnel_running(conn_tag, self._process_mgr) or is_socket_alive(conn_tag, self.workspace):
1301
+ return # already up
1302
+ conn = self._ensure_socks_port(conn)
1303
+ pid = start_master(conn, self._process_mgr, self.workspace)
1304
+ self._log(f"[{conn_tag}] Master started for fetch (PID {pid})")
1305
+ sock = socket_path(conn_tag, self.workspace)
1306
+ for _ in range(50):
1307
+ if sock.exists():
1308
+ break
1309
+ time.sleep(0.1)
1310
+
1311
+ def _try_reconnect(self, tag: str) -> bool:
1312
+ """Attempt to restart the ControlMaster for a connection that went down.
1313
+
1314
+ Returns True only once the socket is confirmed alive — not merely when
1315
+ the SSH process has spawned. This prevents the monitor from calling
1316
+ _reregister_forwards (and sending "Connection restored") for a master
1317
+ that started but immediately failed (e.g. WiFi off, host unreachable).
1318
+
1319
+ Called by _ReconnectMonitor on every poll while the socket is down.
1320
+ """
1321
+ if is_tunnel_running(tag, self._process_mgr) or is_socket_alive(tag, self.workspace):
1322
+ return True
1323
+ self._reload_config()
1324
+ conn = get_connection(self.config, tag)
1325
+ if conn is None or not conn.enabled:
1326
+ return False
1327
+ try:
1328
+ conn = self._ensure_socks_port(conn)
1329
+ pid = start_master(conn, self._process_mgr, self.workspace)
1330
+ self._log(f"[{tag}] Reconnect started (PID {pid}), waiting for socket…")
1331
+ # Wait up to 10 s for the socket file to appear, then verify it is
1332
+ # actually responding. If SSH fails (host unreachable, auth error)
1333
+ # the process exits and the socket never becomes alive — return False
1334
+ # so the caller does not prematurely declare success.
1335
+ sock = socket_path(tag, self.workspace)
1336
+ for _ in range(100):
1337
+ if sock.exists():
1338
+ break
1339
+ time.sleep(0.1)
1340
+ if not is_socket_alive(tag, self.workspace):
1341
+ self._log(f"[{tag}] Reconnect started but socket not ready — will retry")
1342
+ return False
1343
+ self._log(f"[{tag}] Reconnected (PID {pid})")
1344
+ return True
1345
+ except Exception as exc:
1346
+ self._log(f"[{tag}] Reconnect attempt failed: {exc}")
1347
+ return False
1348
+
1349
+ def _reregister_forwards(self, tag: str) -> None:
1350
+ """Re-register all enabled forwards
1351
+
1352
+ Called by _ReconnectMonitor when the ControlMaster socket comes back
1353
+ alive. The fresh master starts with no forwards registered — all enabled
1354
+ TCP forwards are re-registered via ``ssh -O forward``, stale UDP
1355
+ processes are restarted, and active share forwards are re-registered.
1356
+ """
1357
+ self._reload_config()
1358
+ conn = get_connection(self.config, tag)
1359
+ if conn is None:
1360
+ return
1361
+
1362
+ # Clean up stale UDP processes — they died with the previous master.
1363
+ stop_all_udp_forwards_for_connection(tag, self._process_mgr)
1364
+
1365
+ self._register_forwards(conn, error_suffix="to re-register")
1366
+
1367
+ for share_port, (_server, share_info) in list(self._share_servers.items()):
1368
+ if share_info.conn_tag != tag:
1369
+ continue
1370
+ fw = PortForward(
1371
+ src_port=share_port, dst_port=share_port,
1372
+ src_addr="localhost", dst_addr="localhost",
1373
+ tag=f"share-{share_port}",
1374
+ )
1375
+ try:
1376
+ start_forward(conn, fw, "remote", self.workspace)
1377
+ except Exception as exc:
1378
+ self._log(f"[{tag}] Share forward {share_port} failed to re-register: {exc}")
1379
+
1380
+ pid = self._process_mgr.get_pid(f"{SSH_PROCESS_PREFIX}-{tag}")
1381
+ self._emit("state", {"tag": tag, "running": True, "pid": pid})
1382
+ self._notify(f"{self._process_name} [{tag}]", "Connection restored")
1383
+
1384
+ def stop(self, keep_ports: bool = False, tag: str | None = None) -> StopResult:
1385
+ self._reload_config()
1386
+ errors = []
1387
+
1388
+ ephemeral = self.config.susops_app.ephemeral_ports
1389
+ connections = (
1390
+ [get_connection(self.config, tag)] if tag
1391
+ else list(self.config.connections)
1392
+ )
1393
+ connections = [c for c in connections if c is not None]
1394
+ for conn in connections:
1395
+ try:
1396
+ # Write the stopped marker BEFORE killing the tunnel — that
1397
+ # way a parallel process's monitor (e.g. tray watching while
1398
+ # TUI is stopping) sees the marker the moment the socket dies
1399
+ # and won't try to reconnect.
1400
+ _write_stopped_marker(self.workspace, conn.tag)
1401
+ # Always tell the reconnect monitor we don't want this tag
1402
+ # watched anymore — even if stop_tunnel returns False because
1403
+ # the socket was already down (e.g. the monitor was
1404
+ # mid-reconnect-attempt when the user clicked Stop). Without
1405
+ # this, _intended still contains the tag and the monitor
1406
+ # keeps reviving the connection after stop.
1407
+ self._reconnect_monitor.mark_stopped(conn.tag)
1408
+ if stop_tunnel(conn.tag, self._process_mgr, self.workspace, conn.ssh_host):
1409
+ self._log(f"[{conn.tag}] Stopped")
1410
+ self._bw_sampler.reset_totals(conn.tag)
1411
+ self._start_times.pop(conn.tag, None)
1412
+ self._emit("state", {"tag": conn.tag, "running": False, "pid": None})
1413
+ stop_all_udp_forwards_for_connection(conn.tag, self._process_mgr)
1414
+ if not keep_ports and ephemeral and conn.socks_proxy_port != 0:
1415
+ self._replace_connection(conn.model_copy(update={"socks_proxy_port": 0}))
1416
+ except Exception as exc:
1417
+ errors.append(f"[{conn.tag}] {exc}")
1418
+
1419
+ # Stop share servers for the affected connections
1420
+ stopped_tags = {c.tag for c in connections}
1421
+ for p, (server, info) in list(self._share_servers.items()):
1422
+ if info.conn_tag in stopped_tags:
1423
+ try:
1424
+ server.stop()
1425
+ self._log(f"File share on port {p} stopped")
1426
+ self._emit("share", {
1427
+ "port": p,
1428
+ "file": Path(info.file_path).name,
1429
+ "running": False,
1430
+ "conn_tag": info.conn_tag,
1431
+ })
1432
+ del self._share_servers[p]
1433
+ except Exception as exc:
1434
+ errors.append(f"Share {p}: {exc}")
1435
+
1436
+ if tag is None:
1437
+ self._stop_pac_server(errors, keep_ports, ephemeral)
1438
+
1439
+ # Regenerate PAC (or stop it) when stopping a single connection
1440
+ if tag is not None:
1441
+ remaining = self._active_tags()
1442
+ if not remaining:
1443
+ # Last connection stopped — write empty PAC first for consistency, then shut down
1444
+ write_pac_file(self.config, self.workspace, active_tags=set())
1445
+ self._stop_pac_server(errors, keep_ports, ephemeral, context="no active connections")
1446
+ else:
1447
+ self._update_pac()
1448
+
1449
+ # Halt the in-process monitor if there's nothing left to watch.
1450
+ # Covers two cases: a full stop (tag=None), and a per-tag stop that
1451
+ # happened to be the last live tag. Without this, the daemon's
1452
+ # status would keep showing "● Reconnect" with an empty intended
1453
+ # set, polling every 5 s for nothing.
1454
+ with self._reconnect_monitor._lock:
1455
+ still_watching = bool(self._reconnect_monitor._intended)
1456
+ if not still_watching:
1457
+ self._reconnect_monitor.stop()
1458
+
1459
+ self._save()
1460
+ self._emit_state(self._compute_state())
1461
+ return StopResult(
1462
+ success=not errors,
1463
+ message="; ".join(errors) if errors else "Stopped",
1464
+ )
1465
+
1466
+ def restart(self, tag: str | None = None) -> StartResult:
1467
+ self.stop(keep_ports=True, tag=tag)
1468
+ time.sleep(0.5)
1469
+ return self.start(tag)
1470
+
1471
+ def status(self) -> StatusResult:
1472
+ self._reload_config()
1473
+ statuses = tuple(self._connection_status(c) for c in self.config.connections)
1474
+ pac_running = self._pac_server.is_running()
1475
+ pac_port = self._pac_server.get_port()
1476
+ if not pac_running:
1477
+ pac_port = pac_port or self._read_pac_port_file()
1478
+ if pac_port:
1479
+ pac_running = self._probe_port(pac_port)
1480
+ pac_port = pac_port or self.config.pac_server_port
1481
+ return StatusResult(
1482
+ state=self._compute_state(statuses, pac_running),
1483
+ connection_statuses=statuses,
1484
+ pac_running=pac_running,
1485
+ pac_port=pac_port,
1486
+ )
1487
+
1488
+ # ------------------------------------------------------------------ #
1489
+ # Connection CRUD
1490
+ # ------------------------------------------------------------------ #
1491
+
1492
+ def add_connection(self, tag: str, ssh_host: str, socks_port: int = 0) -> Connection:
1493
+ self._reload_config()
1494
+ if get_connection(self.config, tag) is not None:
1495
+ raise ValueError(f"Connection '{tag}' already exists")
1496
+ conn = Connection(tag=tag, ssh_host=ssh_host, socks_proxy_port=socks_port)
1497
+ self.config = self.config.model_copy(
1498
+ update={"connections": list(self.config.connections) + [conn]}
1499
+ )
1500
+ self._save()
1501
+ # Clear any leftover stopped-marker from a previous connection with
1502
+ # the same tag — otherwise the new connection would never reconnect.
1503
+ _clear_stopped_marker(self.workspace, tag)
1504
+ self._log(f"Added connection '{tag}' → {ssh_host}")
1505
+ return conn
1506
+
1507
+ def remove_connection(self, tag: str) -> None:
1508
+ self._reload_config()
1509
+ conn = get_connection(self.config, tag)
1510
+ if conn is None:
1511
+ raise ValueError(f"Connection '{tag}' not found")
1512
+ # Write the stopped marker before killing the tunnel so any other
1513
+ # process's reconnect monitor doesn't bring it back up while we're
1514
+ # mid-remove.
1515
+ _write_stopped_marker(self.workspace, tag)
1516
+ stop_tunnel(tag, self._process_mgr, self.workspace, conn.ssh_host)
1517
+ stop_all_udp_forwards_for_connection(tag, self._process_mgr)
1518
+ self._reconnect_monitor.mark_stopped(tag)
1519
+ self.config = self.config.model_copy(
1520
+ update={"connections": [c for c in self.config.connections if c.tag != tag]}
1521
+ )
1522
+ self._save()
1523
+ self._log(f"Removed connection '{tag}'")
1524
+
1525
+ def set_connection_enabled(self, tag: str, enabled: bool) -> None:
1526
+ self._reload_config()
1527
+ conn = get_connection(self.config, tag)
1528
+ if conn is None:
1529
+ raise ValueError(f"Connection '{tag}' not found")
1530
+ self._replace_connection(conn.model_copy(update={"enabled": enabled}))
1531
+ self._save()
1532
+ self._log(f"[{tag}] {'enabled' if enabled else 'disabled'}")
1533
+ if enabled:
1534
+ # Re-enabling clears any leftover "stopped" marker so reconnect
1535
+ # monitors will watch this tag again.
1536
+ _clear_stopped_marker(self.workspace, tag)
1537
+ return
1538
+ # Disabling = intentional stop. Write the marker BEFORE killing the
1539
+ # tunnel so a parallel process's monitor doesn't try to revive it.
1540
+ _write_stopped_marker(self.workspace, tag)
1541
+ if is_tunnel_running(tag, self._process_mgr) or is_socket_alive(tag, self.workspace):
1542
+ stop_tunnel(tag, self._process_mgr, self.workspace, conn.ssh_host)
1543
+ stop_all_udp_forwards_for_connection(tag, self._process_mgr)
1544
+ self._bw_sampler.reset_totals(tag)
1545
+ self._start_times.pop(tag, None)
1546
+ self._reconnect_monitor.mark_stopped(tag)
1547
+ self._emit("state", {"tag": tag, "running": False, "pid": None})
1548
+ # Regenerate PAC after stopping so this connection's hosts are removed
1549
+ self._update_pac()
1550
+
1551
+ def _update_pac(self) -> None:
1552
+ """Write the PAC file and reload the in-process server if running."""
1553
+ pac_path = write_pac_file(self.config, self.workspace, active_tags=self._active_tags())
1554
+ if self._pac_server.is_running():
1555
+ self._pac_server.reload(pac_path)
1556
+
1557
+ def _stop_pac_server(self, errors: list[str], keep_ports: bool, ephemeral: bool, context: str = "") -> None:
1558
+ """Stop the PAC server (in-process or cross-process) and clean up.
1559
+
1560
+ context is appended to the log message in parentheses when non-empty,
1561
+ e.g. ``context="no active connections"`` → ``"PAC server stopped (no active connections)"``.
1562
+ """
1563
+ suffix = f" ({context})" if context else ""
1564
+ if self._pac_server.is_running():
1565
+ try:
1566
+ self._pac_server.stop()
1567
+ self._remove_pac_port_file()
1568
+ self._log(f"PAC server stopped{suffix}")
1569
+ if not keep_ports and ephemeral:
1570
+ self.config = self.config.model_copy(update={"pac_server_port": 0})
1571
+ except Exception as exc:
1572
+ errors.append(f"PAC: {exc}")
1573
+ else:
1574
+ cross_port = self._read_pac_port_file()
1575
+ if cross_port:
1576
+ self._fire_http(f"http://127.0.0.1:{cross_port}/stop")
1577
+ self._remove_pac_port_file()
1578
+ self._log(f"PAC server stopped (remote{suffix})")
1579
+
1580
+ def test_ssh(self, ssh_host: str) -> bool:
1581
+ return test_ssh_connectivity(ssh_host)
1582
+
1583
+ # ------------------------------------------------------------------ #
1584
+ # PAC hosts
1585
+ # ------------------------------------------------------------------ #
1586
+
1587
+ def add_pac_host(self, host: str, conn_tag: str | None = None) -> None:
1588
+ self._reload_config()
1589
+ default = get_default_connection(self.config)
1590
+ tag = conn_tag or (default.tag if default else None)
1591
+ if tag is None:
1592
+ raise ValueError("No connections configured")
1593
+ conn = get_connection(self.config, tag)
1594
+ if conn is None:
1595
+ raise ValueError(f"Connection '{tag}' not found")
1596
+ if host in conn.pac_hosts:
1597
+ raise ValueError(f"Host '{host}' already in PAC list for '{tag}'")
1598
+ self._replace_connection(conn.model_copy(update={"pac_hosts": list(conn.pac_hosts) + [host]}))
1599
+ self._save()
1600
+ self._update_pac()
1601
+ self._log(f"[{tag}] Added PAC host '{host}'")
1602
+ self._emit_state(self._compute_state())
1603
+
1604
+ def remove_pac_host(self, host: str, conn_tag: str | None = None) -> None:
1605
+ self._reload_config()
1606
+ found = False
1607
+ new_conns = []
1608
+ for conn in self.config.connections:
1609
+ if host in conn.pac_hosts and (conn_tag is None or conn.tag == conn_tag):
1610
+ found = True
1611
+ new_conns.append(
1612
+ conn.model_copy(update={"pac_hosts": [h for h in conn.pac_hosts if h != host]})
1613
+ )
1614
+ else:
1615
+ new_conns.append(conn)
1616
+ if not found:
1617
+ scope = f" in connection '{conn_tag}'" if conn_tag else " in any PAC list"
1618
+ raise ValueError(f"Host '{host}' not found{scope}")
1619
+ self.config = self.config.model_copy(update={"connections": new_conns})
1620
+ self._save()
1621
+ self._update_pac()
1622
+ self._log(f"Removed PAC host '{host}'")
1623
+ self._emit_state(self._compute_state())
1624
+
1625
+ def set_pac_host_enabled(self, host: str, enabled: bool, conn_tag: str | None = None) -> None:
1626
+ self._reload_config()
1627
+ found = False
1628
+ new_conns = []
1629
+ conn_tag_label = f"[{conn_tag}] " if conn_tag else ""
1630
+ for conn in self.config.connections:
1631
+ if conn_tag and conn.tag != conn_tag:
1632
+ new_conns.append(conn)
1633
+ continue
1634
+ if enabled and host in conn.pac_hosts_disabled:
1635
+ found = True
1636
+ new_conns.append(conn.model_copy(update={
1637
+ "pac_hosts": list(conn.pac_hosts) + [host],
1638
+ "pac_hosts_disabled": [h for h in conn.pac_hosts_disabled if h != host],
1639
+ }))
1640
+ elif not enabled and host in conn.pac_hosts:
1641
+ found = True
1642
+ new_conns.append(conn.model_copy(update={
1643
+ "pac_hosts": [h for h in conn.pac_hosts if h != host],
1644
+ "pac_hosts_disabled": list(conn.pac_hosts_disabled) + [host],
1645
+ }))
1646
+ else:
1647
+ new_conns.append(conn)
1648
+ if not found:
1649
+ raise ValueError(f"{conn_tag_label}PAC host '{host}' not found")
1650
+ self.config = self.config.model_copy(update={"connections": new_conns})
1651
+ self._save()
1652
+ self._update_pac()
1653
+ self._log(f"{conn_tag_label}PAC host '{host}' {'enabled' if enabled else 'disabled'}")
1654
+ self._emit_state(self._compute_state())
1655
+
1656
+ # ------------------------------------------------------------------ #
1657
+ # Port forwards
1658
+ # ------------------------------------------------------------------ #
1659
+
1660
+ def _add_forward(self, conn_tag: str, fw: PortForward, direction: str) -> None:
1661
+ self._reload_config()
1662
+ conn = get_connection(self.config, conn_tag)
1663
+ if conn is None:
1664
+ raise ValueError(f"Connection '{conn_tag}' not found")
1665
+ if direction == "local":
1666
+ if any(f.src_port == fw.src_port for f in conn.forwards.local):
1667
+ raise ValueError(f"Local forward on port {fw.src_port} already exists")
1668
+ new_fwds = conn.forwards.model_copy(
1669
+ update={"local": list(conn.forwards.local) + [fw]}
1670
+ )
1671
+ else:
1672
+ if any(f.src_port == fw.src_port for f in conn.forwards.remote):
1673
+ raise ValueError(f"Remote forward on port {fw.src_port} already exists")
1674
+ new_fwds = conn.forwards.model_copy(
1675
+ update={"remote": list(conn.forwards.remote) + [fw]}
1676
+ )
1677
+ self._replace_connection(conn.model_copy(update={"forwards": new_fwds}))
1678
+ self._save()
1679
+ self._log(f"[{conn_tag}] Added {direction} forward {fw.src_port}→{fw.dst_port}")
1680
+
1681
+ def add_local_forward(self, conn_tag: str, fw: PortForward) -> None:
1682
+ self._add_forward(conn_tag, fw, "local")
1683
+ self._maybe_start_forward_live(conn_tag, fw, "local")
1684
+
1685
+ def add_remote_forward(self, conn_tag: str, fw: PortForward) -> None:
1686
+ self._add_forward(conn_tag, fw, "remote")
1687
+ self._maybe_start_forward_live(conn_tag, fw, "remote")
1688
+
1689
+ def _remove_forward(self, src_port: int, direction: str) -> None:
1690
+ self._reload_config()
1691
+ found = False
1692
+ new_conns = []
1693
+ for conn in self.config.connections:
1694
+ fwds = conn.forwards.local if direction == "local" else conn.forwards.remote
1695
+ updated_fwds = [f for f in fwds if f.src_port != src_port]
1696
+ if len(updated_fwds) != len(fwds):
1697
+ found = True
1698
+ removed_fw = next(f for f in fwds if f.src_port == src_port)
1699
+ key = "local" if direction == "local" else "remote"
1700
+ new_fwds = conn.forwards.model_copy(update={key: updated_fwds})
1701
+ new_conns.append(conn.model_copy(update={"forwards": new_fwds}))
1702
+ fw_tag = removed_fw.tag or f"{direction}-{src_port}"
1703
+ if removed_fw.tcp:
1704
+ cancel_forward(conn, removed_fw, direction, self.workspace)
1705
+ stop_udp_forward(conn.tag, fw_tag, self._process_mgr)
1706
+ self._emit("forward", {
1707
+ "tag": conn.tag, "fw_tag": fw_tag,
1708
+ "direction": direction, "running": False,
1709
+ })
1710
+ else:
1711
+ new_conns.append(conn)
1712
+ if not found:
1713
+ raise ValueError(f"{direction.capitalize()} forward on port {src_port} not found")
1714
+ self.config = self.config.model_copy(update={"connections": new_conns})
1715
+ self._save()
1716
+ self._log(f"Removed {direction} forward on port {src_port}")
1717
+
1718
+ def remove_local_forward(self, src_port: int) -> None:
1719
+ self._remove_forward(src_port, "local")
1720
+
1721
+ def remove_remote_forward(self, src_port: int) -> None:
1722
+ self._remove_forward(src_port, "remote")
1723
+
1724
+ def set_forward_enabled(self, conn_tag: str, src_port: int, direction: str, enabled: bool) -> None:
1725
+ """Set the enabled flag on a forward and start/stop the live process accordingly.
1726
+
1727
+ If enabling and the connection is running, the forward slave is started immediately.
1728
+ If disabling, the forward slave is stopped (if running).
1729
+ """
1730
+ conn = get_connection(self.config, conn_tag)
1731
+ if conn is None:
1732
+ raise ValueError(f"Connection {conn_tag!r} not found")
1733
+ forwards = conn.forwards.local if direction == "local" else conn.forwards.remote
1734
+ for fw in forwards:
1735
+ if fw.src_port == src_port:
1736
+ fw.enabled = enabled
1737
+ self._save()
1738
+ fw_tag = fw.tag or f"{direction}-{src_port}"
1739
+ self._log(f"[{conn_tag}] Forward {fw_tag} {'enabled' if enabled else 'disabled'}")
1740
+ if enabled and (
1741
+ is_tunnel_running(conn_tag, self._process_mgr) or is_socket_alive(conn_tag, self.workspace)):
1742
+ try:
1743
+ if fw.tcp:
1744
+ start_forward(conn, fw, direction, self.workspace)
1745
+ if fw.udp:
1746
+ start_udp_forward(conn, fw, direction, self._process_mgr, self.workspace)
1747
+ except Exception as exc:
1748
+ self._error(f"[{conn_tag}] Forward {src_port} failed to start: {exc}")
1749
+ elif not enabled:
1750
+ if fw.tcp:
1751
+ cancel_forward(conn, fw, direction, self.workspace)
1752
+ stop_udp_forward(conn_tag, fw_tag, self._process_mgr)
1753
+ self._emit("forward", {
1754
+ "conn_tag": conn_tag,
1755
+ "src_port": src_port,
1756
+ "direction": direction,
1757
+ "enabled": enabled,
1758
+ })
1759
+ return
1760
+ raise ValueError(f"Forward {src_port} not found in {conn_tag} {direction}")
1761
+
1762
+ def toggle_forward_enabled(self, conn_tag: str, src_port: int, direction: str) -> bool:
1763
+ """Toggle enabled on a forward. Returns the new enabled state."""
1764
+ conn = get_connection(self.config, conn_tag)
1765
+ if conn is None:
1766
+ raise ValueError(f"Connection {conn_tag!r} not found")
1767
+ forwards = conn.forwards.local if direction == "local" else conn.forwards.remote
1768
+ for fw in forwards:
1769
+ if fw.src_port == src_port:
1770
+ new_enabled = not fw.enabled
1771
+ self.set_forward_enabled(conn_tag, src_port, direction, new_enabled)
1772
+ return new_enabled
1773
+ raise ValueError(f"Forward {src_port} not found in {conn_tag} {direction}")
1774
+
1775
+ def is_udp_forward_running(self, conn_tag: str, src_port: int, direction: str) -> bool:
1776
+ """Return True if the UDP socat process for this forward is alive."""
1777
+ self._reload_config()
1778
+ conn = get_connection(self.config, conn_tag)
1779
+ if conn is None:
1780
+ return False
1781
+ forwards = conn.forwards.local if direction == "local" else conn.forwards.remote
1782
+ fw = next((f for f in forwards if f.src_port == src_port), None)
1783
+ if fw is None or not fw.udp:
1784
+ return False
1785
+ return _is_udp_forward_running(conn_tag, fw, direction, self._process_mgr)
1786
+
1787
+ # ------------------------------------------------------------------ #
1788
+ # File sharing
1789
+ # ------------------------------------------------------------------ #
1790
+
1791
+ def share(
1792
+ self,
1793
+ file: Path,
1794
+ conn_tag: str,
1795
+ password: str | None = None,
1796
+ port: int | None = None,
1797
+ ) -> ShareInfo:
1798
+ """Start serving an encrypted file share and persist it to config.
1799
+
1800
+ If the connection's SSH tunnel is not running it is started automatically
1801
+ so the remote forward slave can be established immediately.
1802
+ """
1803
+ if not file.exists():
1804
+ raise FileNotFoundError(f"File not found: {file}")
1805
+ self._reload_config()
1806
+ if get_connection(self.config, conn_tag) is None:
1807
+ raise ValueError(f"Connection '{conn_tag}' not found")
1808
+
1809
+ pw = password or generate_password()
1810
+ server = ShareServer()
1811
+ _raw = server.start(file_path=file, password=pw, port=port or 0, workspace=self.workspace)
1812
+
1813
+ info = ShareInfo(
1814
+ file_path=_raw.file_path,
1815
+ port=_raw.port,
1816
+ password=_raw.password,
1817
+ url=_raw.url,
1818
+ conn_tag=conn_tag,
1819
+ running=True,
1820
+ )
1821
+ # Register in memory and config BEFORE checking tunnel state so that
1822
+ # self.start() (below) can pick up this share when iterating _share_servers.
1823
+ self._share_servers[info.port] = (server, info)
1824
+ self._log(f"Sharing '{file.name}' on port {info.port}")
1825
+ self._add_file_share_to_config(conn_tag, str(file), pw, info.port)
1826
+
1827
+ conn = get_connection(self.config, conn_tag)
1828
+ _tunnel_up = conn and (
1829
+ is_tunnel_running(conn_tag, self._process_mgr) or is_socket_alive(conn_tag, self.workspace))
1830
+ if conn and not _tunnel_up:
1831
+ # Tunnel not running — start it; start() will also launch the remote forward slave.
1832
+ self.start(conn_tag)
1833
+ elif _tunnel_up:
1834
+ # Tunnel already running — start the slave directly.
1835
+ fw = PortForward(
1836
+ src_port=info.port,
1837
+ dst_port=info.port,
1838
+ src_addr="localhost",
1839
+ dst_addr="localhost",
1840
+ tag=f"share-{info.port}",
1841
+ )
1842
+ try:
1843
+ start_forward(conn, fw, "remote", self.workspace)
1844
+ except Exception as exc:
1845
+ self._log(f"[{conn_tag}] Share forward {info.port} failed: {exc}")
1846
+
1847
+ self._emit("share", {
1848
+ "port": info.port,
1849
+ "file": file.name,
1850
+ "running": True,
1851
+ "conn_tag": conn_tag,
1852
+ })
1853
+ return info
1854
+
1855
+ def stop_share(self, port: int | None = None) -> None:
1856
+ """Stop share server(s) without removing from config (entry shows as stopped).
1857
+
1858
+ Sets stopped=True on the config entry so the share is not auto-restarted
1859
+ on the next start() or restore cycle.
1860
+ """
1861
+ if port is not None:
1862
+ entry = self._share_servers.pop(port, None)
1863
+ if entry:
1864
+ entry[0].stop()
1865
+ info = entry[1]
1866
+ self._log(f"File share on port {port} stopped")
1867
+ if info.conn_tag:
1868
+ conn = get_connection(self.config, info.conn_tag)
1869
+ if conn:
1870
+ fw = PortForward(src_port=port, dst_port=port, src_addr="localhost", dst_addr="localhost")
1871
+ cancel_forward(conn, fw, "remote", self.workspace)
1872
+ self._set_file_share_stopped(port, True)
1873
+ self._emit("share", {
1874
+ "port": port,
1875
+ "file": Path(info.file_path).name,
1876
+ "running": False,
1877
+ "conn_tag": info.conn_tag,
1878
+ })
1879
+ else:
1880
+ # Offline share (not in _share_servers): mark as manually stopped in config
1881
+ self._set_file_share_stopped(port, True)
1882
+ else:
1883
+ for p, (server, info) in list(self._share_servers.items()):
1884
+ server.stop()
1885
+ self._log(f"File share on port {p} stopped")
1886
+ if info.conn_tag:
1887
+ conn = get_connection(self.config, info.conn_tag)
1888
+ if conn:
1889
+ fw = PortForward(src_port=p, dst_port=p, src_addr="localhost", dst_addr="localhost")
1890
+ cancel_forward(conn, fw, "remote", self.workspace)
1891
+ self._emit("share", {
1892
+ "port": p,
1893
+ "file": Path(info.file_path).name,
1894
+ "running": False,
1895
+ "conn_tag": info.conn_tag,
1896
+ })
1897
+ self._share_servers.clear()
1898
+
1899
+ def delete_share(self, port: int) -> None:
1900
+ """Stop and permanently remove a share from config."""
1901
+ self.stop_share(port)
1902
+ self._remove_file_share_from_config(port)
1903
+ self._emit("share", {
1904
+ "port": port,
1905
+ "file": "",
1906
+ "running": False,
1907
+ "conn_tag": None,
1908
+ })
1909
+
1910
+ def list_shares(self) -> list[ShareInfo]:
1911
+ """Return info for all shares: running (in-memory) and stopped (config-only)."""
1912
+ # Clean up dead in-memory servers
1913
+ dead = [p for p, (s, _) in self._share_servers.items() if not s.is_running()]
1914
+ for p in dead:
1915
+ del self._share_servers[p]
1916
+
1917
+ # In-memory running shares — attach live access counters
1918
+ running_ports = set(self._share_servers.keys())
1919
+ result: list[ShareInfo] = []
1920
+ for server, info in self._share_servers.values():
1921
+ result.append(dataclasses.replace(
1922
+ info,
1923
+ access_count=server.access_count,
1924
+ failed_count=server.failed_count,
1925
+ ))
1926
+
1927
+ # Config-only stopped shares (persisted but server not running in this process)
1928
+ self._reload_config()
1929
+ for conn in self.config.connections:
1930
+ for fs in conn.file_shares:
1931
+ if fs.port not in running_ports:
1932
+ result.append(ShareInfo(
1933
+ file_path=fs.file_path,
1934
+ port=fs.port,
1935
+ password=fs.password,
1936
+ url=f"http://localhost:{fs.port}",
1937
+ conn_tag=conn.tag,
1938
+ running=False,
1939
+ stopped=fs.stopped,
1940
+ ))
1941
+
1942
+ return result
1943
+
1944
+ def share_is_running(self) -> bool:
1945
+ return bool(self._share_servers)
1946
+
1947
+ def fetch(
1948
+ self,
1949
+ port: int,
1950
+ password: str,
1951
+ conn_tag: str,
1952
+ outfile: Path | None = None,
1953
+ ) -> Path:
1954
+ """Download and decrypt a shared file via a transient local forward slave.
1955
+
1956
+ The local forward slave is started, the file is downloaded through
1957
+ localhost, then the slave is stopped. No tunnel restart required.
1958
+ """
1959
+ self._reload_config()
1960
+ if get_connection(self.config, conn_tag) is None:
1961
+ raise ValueError(f"Connection '{conn_tag}' not found")
1962
+
1963
+ local_port = get_random_free_port()
1964
+ fw = PortForward(
1965
+ src_port=local_port,
1966
+ dst_port=port,
1967
+ src_addr="localhost",
1968
+ dst_addr="localhost",
1969
+ tag=f"fetch-{port}",
1970
+ )
1971
+
1972
+ forward_started = False
1973
+
1974
+ # Record whether the tunnel was already running so we know whether to
1975
+ # tear it down after the fetch. Then always call _start_master_only:
1976
+ # it is a no-op when the master is already up, but crucially it waits
1977
+ # for the socket to appear — which the running-connection path previously
1978
+ # skipped, causing the forward to be silently omitted.
1979
+ tunnel_was_running = is_tunnel_running(conn_tag, self._process_mgr) or is_socket_alive(conn_tag, self.workspace)
1980
+ self._start_master_only(conn_tag)
1981
+ conn = get_connection(self.config, conn_tag) # refresh after potential port assignment
1982
+
1983
+ # Use a transient forward slave if ControlMaster socket is alive
1984
+ sock = socket_path(conn_tag, self.workspace) if conn else None
1985
+ if conn and sock is not None and sock.exists():
1986
+ try:
1987
+ start_forward(conn, fw, "local", self.workspace)
1988
+ # Poll until local port is accessible (up to 5 s)
1989
+ for _ in range(50):
1990
+ if self._probe_port(local_port):
1991
+ break
1992
+ time.sleep(0.1)
1993
+ forward_started = True
1994
+ except Exception as exc:
1995
+ self._log(f"[{conn_tag}] Fetch forward {port} failed: {exc}")
1996
+
1997
+ try:
1998
+ # If forward was started, fetch from local_port; otherwise fall back to original port
1999
+ # (useful in test/dev scenarios where share server is running locally)
2000
+ fetch_port = local_port if forward_started else port
2001
+ result = fetch_file(host="localhost", port=fetch_port, password=password, outfile=outfile)
2002
+ finally:
2003
+ if forward_started:
2004
+ cancel_forward(conn, fw, "local", self.workspace)
2005
+ if not tunnel_was_running:
2006
+ stop_tunnel(conn_tag, self._process_mgr, self.workspace, conn.ssh_host if conn else None)
2007
+ self._emit("state", {"tag": conn_tag, "running": False, "pid": None})
2008
+
2009
+ self._log(f"Fetched file to {result}")
2010
+ return result
2011
+
2012
+ # ------------------------------------------------------------------ #
2013
+ # Testing
2014
+ # ------------------------------------------------------------------ #
2015
+
2016
+ def test(self, target: str) -> TestResult:
2017
+ conn = get_default_connection(self.config)
2018
+ if conn is None or conn.socks_proxy_port == 0:
2019
+ return TestResult(target=target, success=False, message="No active SOCKS proxy")
2020
+ proxy = f"socks5h://127.0.0.1:{conn.socks_proxy_port}"
2021
+ start = time.monotonic()
2022
+ try:
2023
+ result = subprocess.run(
2024
+ ["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
2025
+ "--proxy", proxy, "--max-time", "10", f"http://{target}"],
2026
+ capture_output=True, timeout=15, text=True,
2027
+ )
2028
+ latency = (time.monotonic() - start) * 1000
2029
+ success = result.returncode == 0
2030
+ return TestResult(
2031
+ target=target, success=success,
2032
+ message=f"HTTP {result.stdout.strip()}" if success else result.stderr.strip(),
2033
+ latency_ms=latency if success else None,
2034
+ )
2035
+ except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
2036
+ return TestResult(target=target, success=False, message=str(exc))
2037
+
2038
+ def test_all(self) -> list[TestResult]:
2039
+ return [self.test(host) for conn in self.config.connections for host in conn.pac_hosts]
2040
+
2041
+ def test_connection(self, conn_tag: str) -> TestResult:
2042
+ """Test SSH reachability for a specific connection."""
2043
+ self._reload_config()
2044
+ conn = get_connection(self.config, conn_tag)
2045
+ if conn is None:
2046
+ return TestResult(target=conn_tag, success=False, message="Connection not found")
2047
+ start = time.monotonic()
2048
+ ok = test_ssh_connectivity(conn.ssh_host)
2049
+ latency = (time.monotonic() - start) * 1000
2050
+ return TestResult(
2051
+ target=conn.ssh_host,
2052
+ success=ok,
2053
+ message="SSH reachable" if ok else "SSH unreachable",
2054
+ latency_ms=latency if ok else None,
2055
+ )
2056
+
2057
+ def test_domain(self, host: str, conn_tag: str) -> TestResult:
2058
+ """Test domain reachability via the specified connection's SOCKS proxy."""
2059
+ self._reload_config()
2060
+ conn = get_connection(self.config, conn_tag)
2061
+ if conn is None or conn.socks_proxy_port == 0:
2062
+ return TestResult(target=host, success=False, message="No active SOCKS proxy")
2063
+ proxy = f"socks5h://127.0.0.1:{conn.socks_proxy_port}"
2064
+ clean_host = host.lstrip("*.")
2065
+ start = time.monotonic()
2066
+ try:
2067
+ result = subprocess.run(
2068
+ ["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
2069
+ "--proxy", proxy, "--max-time", "10", f"http://{clean_host}"],
2070
+ capture_output=True, timeout=15, text=True,
2071
+ )
2072
+ latency = (time.monotonic() - start) * 1000
2073
+ success = result.returncode == 0
2074
+ return TestResult(
2075
+ target=host, success=success,
2076
+ message=f"HTTP {result.stdout.strip()}" if success else result.stderr.strip(),
2077
+ latency_ms=latency if success else None,
2078
+ )
2079
+ except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
2080
+ return TestResult(target=host, success=False, message=str(exc))
2081
+
2082
+ def test_forward(self, conn_tag: str, src_port: int, direction: str) -> dict[str, bool]:
2083
+ """Check if a port forward is active.
2084
+
2085
+ Returns a dict with keys "tcp" and/or "udp" mapped to True/False.
2086
+ For local TCP: checks whether src_port is bound (not free).
2087
+ For remote TCP: checks whether the ControlMaster socket is alive.
2088
+ For UDP (either direction): checks whether the socat lsocat process is alive.
2089
+ """
2090
+ self._reload_config()
2091
+ conn = get_connection(self.config, conn_tag)
2092
+ if conn is None:
2093
+ raise ValueError(f"Connection '{conn_tag}' not found")
2094
+ forwards = conn.forwards.local if direction == "local" else conn.forwards.remote
2095
+ fw = next((f for f in forwards if f.src_port == src_port), None)
2096
+ if fw is None:
2097
+ raise ValueError(f"Forward {src_port} not found in {conn_tag} {direction}")
2098
+ results: dict[str, bool] = {}
2099
+ if fw.tcp:
2100
+ if direction == "local":
2101
+ results["tcp"] = not is_port_free(src_port)
2102
+ else:
2103
+ results["tcp"] = is_socket_alive(conn_tag, self.workspace)
2104
+ if fw.udp:
2105
+ results["udp"] = _is_udp_forward_running(conn_tag, fw, direction, self._process_mgr)
2106
+ return results
2107
+
2108
+ # ------------------------------------------------------------------ #
2109
+ # Utilities
2110
+ # ------------------------------------------------------------------ #
2111
+
2112
+ def list_config(self) -> SusOpsConfig:
2113
+ self._reload_config()
2114
+ return self.config
2115
+
2116
+ def reset(self) -> None:
2117
+ self.stop()
2118
+ self.stop_share()
2119
+ import shutil
2120
+ shutil.rmtree(self.workspace, ignore_errors=True)
2121
+ self.workspace.mkdir(parents=True, exist_ok=True)
2122
+ self.config = SusOpsConfig()
2123
+ self._save()
2124
+ self._log("Workspace reset")
2125
+
2126
+ def get_logs(self, n: int = 100) -> list[str]:
2127
+ return list(self._log_buffer)[-n:]
2128
+
2129
+ def log_message(self, msg: str) -> None:
2130
+ """Push an arbitrary line into the daemon's in-memory log buffer.
2131
+
2132
+ Exposed over RPC so frontends (TUI, tray) can route their own
2133
+ operational notes (e.g. browser launches) to the same place the
2134
+ daemon's own logs land — visible in the TUI Logs tab and the
2135
+ tray Logs window.
2136
+ """
2137
+ self._log(msg)
2138
+
2139
+ def get_bandwidth(self, tag: str) -> tuple[float, float]:
2140
+ return self._bw_sampler.get_rate(tag)
2141
+
2142
+ def get_bandwidth_totals(self, tag: str) -> tuple[float, float]:
2143
+ """Return cumulative (rx_bytes, tx_bytes) since last start. Resets on stop."""
2144
+ return self._bw_sampler.get_totals(tag)
2145
+
2146
+ def get_bandwidth_global(self) -> tuple[float, float]:
2147
+ """Return (rx_bps, tx_bps) summed across every connection."""
2148
+ rx_total = 0.0
2149
+ tx_total = 0.0
2150
+ for conn in self.config.connections:
2151
+ rx, tx = self._bw_sampler.get_rate(conn.tag)
2152
+ rx_total += rx
2153
+ tx_total += tx
2154
+ return rx_total, tx_total
2155
+
2156
+ def get_uptime(self, tag: str) -> float | None:
2157
+ """Return seconds since connection started, or None if not recorded."""
2158
+ start = self._start_times.get(tag)
2159
+ return time.monotonic() - start if start is not None else None
2160
+
2161
+ def get_process_info(self, tag: str) -> dict:
2162
+ try:
2163
+ import psutil
2164
+ except ImportError:
2165
+ return {}
2166
+
2167
+ pid = self._process_mgr.get_pid(f"{SSH_PROCESS_PREFIX}-{tag}")
2168
+ if pid is None:
2169
+ return {}
2170
+
2171
+ self._reload_config()
2172
+ conn = get_connection(self.config, tag)
2173
+ socks_port = conn.socks_proxy_port if conn else 0
2174
+
2175
+ # Collect master PID + all forward slave PIDs for this tag.
2176
+ # Slaves are not OS children (start_new_session=True), so children() misses them.
2177
+ all_pids = [pid]
2178
+ prefix = f"{FWD_PROCESS_PREFIX}-{tag}-"
2179
+ for key in self._process_mgr.status_all():
2180
+ if key.startswith(prefix):
2181
+ slave_pid = self._process_mgr.get_pid(key)
2182
+ if slave_pid:
2183
+ all_pids.append(slave_pid)
2184
+
2185
+ cpu = 0.0
2186
+ mem_mb = 0.0
2187
+ for p_pid in all_pids:
2188
+ try:
2189
+ proc = psutil.Process(p_pid)
2190
+ cpu += proc.cpu_percent(interval=None)
2191
+ mem_mb += proc.memory_info().rss / 1_048_576
2192
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
2193
+ pass
2194
+
2195
+ active_conns = 0
2196
+ if socks_port:
2197
+ try:
2198
+ active_conns = sum(
2199
+ 1 for c in psutil.net_connections("tcp")
2200
+ if c.laddr.port == socks_port and c.status == "ESTABLISHED"
2201
+ )
2202
+ except (psutil.AccessDenied, OSError):
2203
+ pass
2204
+ return {"cpu": cpu, "mem_mb": mem_mb, "conns": active_conns}
2205
+
2206
+ def get_pac_url(self) -> str:
2207
+ port = self._pac_server.get_port() or self.config.pac_server_port
2208
+ return f"http://localhost:{port}/susops.pac" if port else ""
2209
+
2210
+ def get_status_url(self) -> str:
2211
+ port = self._status_server.get_port()
2212
+ return f"http://localhost:{port}/events" if port else ""
2213
+
2214
+ @property
2215
+ def app_config(self):
2216
+ return self.config.susops_app
2217
+
2218
+ def update_app_config(self, **kwargs) -> None:
2219
+ self._reload_config()
2220
+ self.config = self.config.model_copy(
2221
+ update={"susops_app": self.config.susops_app.model_copy(update=kwargs)}
2222
+ )
2223
+ self._save()
2224
+
2225
+ def update_config(self, **kwargs) -> None:
2226
+ """Update top-level SusOpsConfig fields (e.g. pac_server_port).
2227
+
2228
+ Public counterpart to the in-process pattern
2229
+ mgr._reload_config()
2230
+ mgr.config = mgr.config.model_copy(update={...})
2231
+ mgr._save()
2232
+ — needed by RPC clients that can't touch private methods or rebind
2233
+ the config attribute directly.
2234
+ """
2235
+ self._reload_config()
2236
+ self.config = self.config.model_copy(update=kwargs)
2237
+ self._save()