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/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()
|