susops 3.0.0rc4.dev2__tar.gz → 3.0.0rc6.dev1__tar.gz
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-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/PKG-INFO +74 -7
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/README.md +73 -6
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/pyproject.toml +1 -1
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/client.py +24 -2
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/core/config.py +12 -18
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/core/pac.py +12 -2
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/core/rpc_server.py +1 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/core/services_daemon.py +8 -4
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/core/ssh.py +35 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/facade.py +388 -228
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/tray/base.py +36 -11
- susops-3.0.0rc6.dev1/src/susops/tray/config_window_model.py +533 -0
- susops-3.0.0rc6.dev1/src/susops/tray/debug_server.py +72 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/tray/linux.py +10 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/tray/mac.py +1286 -1204
- susops-3.0.0rc6.dev1/src/susops/tray/mac_config_window.py +3208 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/tui/cli.py +9 -7
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/tui/screens/dashboard.py +18 -2
- susops-3.0.0rc6.dev1/src/susops/version.py +15 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops.egg-info/PKG-INFO +74 -7
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops.egg-info/SOURCES.txt +3 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_client.py +26 -6
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_config.py +37 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_facade.py +264 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_pac.py +24 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_services_daemon.py +50 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_update_homebrew_sha.py +2 -2
- susops-3.0.0rc4.dev2/src/susops/version.py +0 -12
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/LICENSE +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/setup.cfg +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/__init__.py +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icon.png +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_glasses/dark/error.svg +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_glasses/dark/running.svg +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_glasses/dark/stopped.svg +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_glasses/dark/stopped_partially.svg +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_glasses/light/error.svg +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_glasses/light/running.svg +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_glasses/light/stopped.svg +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_glasses/light/stopped_partially.svg +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_s/dark/error.svg +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_s/dark/running.svg +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_s/dark/stopped.svg +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_s/dark/stopped_partially.svg +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_s/light/error.svg +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_s/light/running.svg +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_s/light/stopped.svg +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/colored_s/light/stopped_partially.svg +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/gear/dark/error.svg +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/gear/dark/running.svg +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/gear/dark/stopped.svg +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/gear/dark/stopped_partially.svg +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/gear/light/error.svg +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/gear/light/running.svg +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/gear/light/stopped.svg +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/gear/light/stopped_partially.svg +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/status/error.svg +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/status/running.svg +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/status/stopped.svg +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/assets/icons/status/stopped_partially.svg +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/core/__init__.py +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/core/browsers.py +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/core/log_style.py +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/core/ports.py +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/core/process.py +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/core/rpc_protocol.py +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/core/share.py +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/core/socat.py +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/core/ssh_config.py +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/core/status.py +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/core/types.py +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/tray/__init__.py +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/tui/__init__.py +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/tui/__main__.py +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/tui/app.py +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/tui/app.tcss +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/tui/screens/__init__.py +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/tui/screens/connections.py +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/tui/screens/shares.py +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/tui/widgets/__init__.py +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops/tui/widgets/connection_card.py +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops.egg-info/dependency_links.txt +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops.egg-info/entry_points.txt +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops.egg-info/requires.txt +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/src/susops.egg-info/top_level.txt +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_bw_totals.py +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_cli.py +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_conftest_smoke.py +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_openapi.py +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_packaging.py +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_process.py +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_rpc_protocol.py +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_rpc_server.py +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_scripts.py +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_share.py +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_share_aiohttp.py +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_socat.py +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_ssh.py +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_status.py +0 -0
- {susops-3.0.0rc4.dev2 → susops-3.0.0rc6.dev1}/tests/test_version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: susops
|
|
3
|
-
Version: 3.0.
|
|
3
|
+
Version: 3.0.0rc6.dev1
|
|
4
4
|
Summary: SusOps — SSH SOCKS5 proxy manager with PAC server, Textual TUI, and system tray apps
|
|
5
5
|
License: MIT
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -315,7 +315,7 @@ susops
|
|
|
315
315
|
|
|
316
316
|
### Screens
|
|
317
317
|
|
|
318
|
-
**Dashboard** (default) — split-pane view. Left sidebar shows all connections (status dot, SOCKS port, live throughput), PAC server status, and active file shares. Right panel is tabbed:
|
|
318
|
+
**Dashboard** (default) — split-pane view. Left sidebar shows all connections (status dot — `●` green running, `◐` orange pending, `○` dim stopped — SOCKS port, live throughput), PAC server status, and active file shares. Right panel is tabbed:
|
|
319
319
|
- **Stats** — CPU%, memory, active connections, PID for the selected connection
|
|
320
320
|
- **Bandwidth** — live RX and TX line charts (PlotextPlot, 60-sample rolling window, auto-scaled units)
|
|
321
321
|
- **Forwards** — DataTable of all port forwards (direction, local port, local bind, remote port, remote bind, label)
|
|
@@ -436,9 +436,11 @@ susops reset [--force] # Kill all processes, wipe ~/.susops workspace
|
|
|
436
436
|
|------|---------|
|
|
437
437
|
| `0` | Success / all running |
|
|
438
438
|
| `1` | Error |
|
|
439
|
-
| `2` | Partial (some services stopped) |
|
|
439
|
+
| `2` | Partial (some services stopped, or any connection pending) |
|
|
440
440
|
| `3` | All stopped |
|
|
441
441
|
|
|
442
|
+
A connection is **pending** when its SSH master is alive but the ControlMaster socket isn't up yet — typically while ssh-agent is waiting on a key unlock, or between reconnect attempts. `start` returns as soon as the master spawns; auth then completes asynchronously up to 60 s later. Slow agent prompts (Vaultwarden, 1Password, hardware keys) no longer freeze the UI.
|
|
443
|
+
|
|
442
444
|
---
|
|
443
445
|
|
|
444
446
|
## System Tray
|
|
@@ -461,7 +463,35 @@ susops-tray
|
|
|
461
463
|
|
|
462
464
|
Requires `rumps`: `pip install "susops[tray-mac]"`
|
|
463
465
|
|
|
464
|
-
The tray icon reflects the current state (running/partial/stopped). State changes arrive over the daemon's SSE `/events` stream. If the stream drops, the listener reconnects within at most 5 seconds.
|
|
466
|
+
The tray icon reflects the current state (running / partial-or-pending / stopped). State changes arrive over the daemon's SSE `/events` stream. If the stream drops, the listener reconnects within at most 5 seconds. When TUI + tray are both attached to the same daemon, quitting one keeps the other running — `stop_on_quit` is skipped if any other frontend is still connected.
|
|
467
|
+
|
|
468
|
+
**Note: the macOS and Linux tray apps diverge in their UI structure.** The 3-column config window described below is macOS-only for now; the Linux tray keeps the classic submenu structure.
|
|
469
|
+
|
|
470
|
+
### macOS tray
|
|
471
|
+
|
|
472
|
+
The macOS tray has a slim menu with a unified **config window** (open with Settings… ⌘, or from the tray menu):
|
|
473
|
+
|
|
474
|
+
Slim menu items:
|
|
475
|
+
- **Status line** — shows current connection state
|
|
476
|
+
- **Settings… ⌘,** — opens the unified config window
|
|
477
|
+
- **Start / Stop / Restart Proxy** — bulk lifecycle for all connections
|
|
478
|
+
- **Show Status** — print current state to stdout
|
|
479
|
+
- **Show Logs** — open the log directory
|
|
480
|
+
- **Launch Browser ▸** — submenu: Chrome or Firefox with the PAC URL pre-configured
|
|
481
|
+
- **Reset All** — kill all processes and wipe the workspace
|
|
482
|
+
- **About SusOps**
|
|
483
|
+
- **Quit ⌘Q**
|
|
484
|
+
|
|
485
|
+
The **config window** is a 3-column Tailscale-style editor (nav / list / detail) with a dark skin and instant-apply settings:
|
|
486
|
+
|
|
487
|
+
- **Column 1 — nav.** Categories with live counts: **Connections / Domains / Forwards / Shares**, plus **Settings** pinned at the bottom. Selecting a category drives the list.
|
|
488
|
+
- **Column 2 — list.** A search field on top filters the rows for the selected category. Forwards split into **Local** and **Remote** sections (each with a one-line explainer); shares show download counts. Each row carries a colored run-state dot (green active, amber pending, gray stopped/inactive, red error/connection-down) and a connection badge; disabled rows render dimmed. A context-aware add button sits at the bottom (`+ Add Connection`, `+ Add Domain / IP / CIDR`, `+ Add Forward`, or `Share File…` / `Fetch…`).
|
|
489
|
+
- **Column 3 — detail / editor.** A header with the title, a colored status line, and an **Enabled** toggle in the top-right (applies instantly). The body is a card holding the form: forwards, domains, and shares are **edited inline** (change a field and **Save**) — no remove-and-re-enter. The same `+` buttons open inline **create forms** in this column (file/folder pickers still use the native chooser). Shares expose a copyable `http://localhost:PORT` URL and password. Per-item actions live in the action row (**Delete…** on the left, **Test** / **Save** on the right).
|
|
490
|
+
- **Settings** spans columns 2–3: grouped toggles (launch at login, stop on quit, random SSH ports, restore shares, show bandwidth, notifications, logo style) that **apply instantly**, the RPC / SSE / PAC server ports behind a single **Apply** button, and an **Open Config File…** button.
|
|
491
|
+
|
|
492
|
+
### Linux tray
|
|
493
|
+
|
|
494
|
+
The Linux tray keeps the classic submenu menu structure:
|
|
465
495
|
|
|
466
496
|
- **Manage** — toggle connection/PAC host/forward enabled state; start, stop, or restart a specific connection
|
|
467
497
|
- **Start / Stop / Restart All** — bulk lifecycle operations across all connections
|
|
@@ -552,7 +582,11 @@ Both can be `true` simultaneously — susops will start an SSH slave for TCP and
|
|
|
552
582
|
|
|
553
583
|
### Port assignment
|
|
554
584
|
|
|
555
|
-
Ports default to `0` (auto-assign). SusOps picks a random free port from the ephemeral range (49152–65535) at start time and saves it back to `config.yaml`. Pass a specific port to `add-connection` or set it in the config to pin it.
|
|
585
|
+
Ports default to `0` (auto-assign). SusOps picks a random free port from the ephemeral range (49152–65535) at start time and saves it back to `config.yaml`. Pass a specific port to `add-connection` or set it in the config to pin it. Ports outside `1–65535` are rejected on `add`.
|
|
586
|
+
|
|
587
|
+
### Tag format
|
|
588
|
+
|
|
589
|
+
Connection tags must match `[A-Za-z0-9][A-Za-z0-9._-]{0,63}` (no slashes, no `..`, no leading punctuation). Tags appear in PID-file names, socket paths, log lines, and PAC keys, so the format is intentionally strict.
|
|
556
590
|
|
|
557
591
|
### Workspace
|
|
558
592
|
|
|
@@ -668,7 +702,7 @@ sudo apt install socat # Ubuntu / Debian
|
|
|
668
702
|
|
|
669
703
|
### Enabling UDP on a forward
|
|
670
704
|
|
|
671
|
-
Set `udp: true` in the forward config, or check "UDP" in the TUI/tray
|
|
705
|
+
Set `udp: true` in the forward config, or check "UDP" in the TUI/tray forward form:
|
|
672
706
|
|
|
673
707
|
```yaml
|
|
674
708
|
forwards:
|
|
@@ -860,8 +894,41 @@ susops
|
|
|
860
894
|
# Run the CLI
|
|
861
895
|
susops ps
|
|
862
896
|
susops ls
|
|
897
|
+
|
|
898
|
+
# Build local pypi + brew artifacts (mirrors CI, no upload)
|
|
899
|
+
scripts/build-local.sh pypi # wheel + sdist
|
|
900
|
+
scripts/build-local.sh brew # SusOps.app + .dmg (macOS only)
|
|
901
|
+
scripts/build-local.sh install-pypi # install wheel into a throwaway venv
|
|
902
|
+
scripts/build-local.sh install-brew # copy SusOps.app to /Applications
|
|
863
903
|
```
|
|
864
904
|
|
|
905
|
+
### Tray development (macOS)
|
|
906
|
+
|
|
907
|
+
Run a dev tray instance against an isolated workspace without touching your live `~/.susops` setup:
|
|
908
|
+
|
|
909
|
+
```bash
|
|
910
|
+
# Isolated workspace (avoids any conflict with a running user tray)
|
|
911
|
+
WS=$(mktemp -d /tmp/susops-dev.XXXX)
|
|
912
|
+
SUSOPS_TRAY_WORKSPACE=$WS SUSOPS_TRAY_DEBUG_PORT=7799 .venv/bin/susops-tray &
|
|
913
|
+
|
|
914
|
+
# Drive the tray via the debug command server
|
|
915
|
+
.venv/bin/python tools/tray_debug.py 7799 ping
|
|
916
|
+
.venv/bin/python tools/tray_debug.py 7799 dump-menu # full menu JSON
|
|
917
|
+
.venv/bin/python tools/tray_debug.py 7799 open-config # open the Settings window
|
|
918
|
+
.venv/bin/python tools/tray_debug.py 7799 screenshot /tmp/tray.png
|
|
919
|
+
.venv/bin/python tools/tray_debug.py 7799 quit
|
|
920
|
+
```
|
|
921
|
+
|
|
922
|
+
`SUSOPS_TRAY_WORKSPACE` points the tray at a different workspace directory (separate config, PID files, sockets) so it never touches `~/.susops`. `SUSOPS_TRAY_DEBUG_PORT` starts a localhost command server on that port; `tools/tray_debug.py` is the client. Supported commands: `ping`, `dump-menu`, `open-config [gear]`, `select <conn> <section> <index>`, `dump-window`, `screenshot <path>`, `action <name>`, `open-about`, `quit`.
|
|
923
|
+
|
|
924
|
+
### GUI smoke tests (macOS)
|
|
925
|
+
|
|
926
|
+
```bash
|
|
927
|
+
SUSOPS_RUN_GUI_TESTS=1 .venv/bin/pytest -m gui -v
|
|
928
|
+
```
|
|
929
|
+
|
|
930
|
+
Seven smoke tests launch a real tray instance (using `SUSOPS_TRAY_WORKSPACE` + `SUSOPS_TRAY_DEBUG_PORT` internally), drive it through the debug server, and assert menu structure and window content. Skipped automatically on Linux and when `SUSOPS_RUN_GUI_TESTS` is unset.
|
|
931
|
+
|
|
865
932
|
### Project layout
|
|
866
933
|
|
|
867
934
|
```
|
|
@@ -937,7 +1004,7 @@ brew tap mashb1t/susops
|
|
|
937
1004
|
brew install susops
|
|
938
1005
|
```
|
|
939
1006
|
|
|
940
|
-
The formula at `packaging/homebrew/Formula/susops.rb` uses `virtualenv_install_with_resources` to create an isolated Python environment
|
|
1007
|
+
The formula at `packaging/homebrew/Formula/susops.rb` uses `virtualenv_install_with_resources` to create an isolated Python environment for the CLI + TUI. The cask at `packaging/homebrew/Casks/susops.rb` installs `SusOps.app` (PyInstaller bundle) for the macOS tray — `brew install --cask susops`.
|
|
941
1008
|
|
|
942
1009
|
**Note:** Resource sha256 checksums in the formula must be updated for each release. Generate them with:
|
|
943
1010
|
|
|
@@ -291,7 +291,7 @@ susops
|
|
|
291
291
|
|
|
292
292
|
### Screens
|
|
293
293
|
|
|
294
|
-
**Dashboard** (default) — split-pane view. Left sidebar shows all connections (status dot, SOCKS port, live throughput), PAC server status, and active file shares. Right panel is tabbed:
|
|
294
|
+
**Dashboard** (default) — split-pane view. Left sidebar shows all connections (status dot — `●` green running, `◐` orange pending, `○` dim stopped — SOCKS port, live throughput), PAC server status, and active file shares. Right panel is tabbed:
|
|
295
295
|
- **Stats** — CPU%, memory, active connections, PID for the selected connection
|
|
296
296
|
- **Bandwidth** — live RX and TX line charts (PlotextPlot, 60-sample rolling window, auto-scaled units)
|
|
297
297
|
- **Forwards** — DataTable of all port forwards (direction, local port, local bind, remote port, remote bind, label)
|
|
@@ -412,9 +412,11 @@ susops reset [--force] # Kill all processes, wipe ~/.susops workspace
|
|
|
412
412
|
|------|---------|
|
|
413
413
|
| `0` | Success / all running |
|
|
414
414
|
| `1` | Error |
|
|
415
|
-
| `2` | Partial (some services stopped) |
|
|
415
|
+
| `2` | Partial (some services stopped, or any connection pending) |
|
|
416
416
|
| `3` | All stopped |
|
|
417
417
|
|
|
418
|
+
A connection is **pending** when its SSH master is alive but the ControlMaster socket isn't up yet — typically while ssh-agent is waiting on a key unlock, or between reconnect attempts. `start` returns as soon as the master spawns; auth then completes asynchronously up to 60 s later. Slow agent prompts (Vaultwarden, 1Password, hardware keys) no longer freeze the UI.
|
|
419
|
+
|
|
418
420
|
---
|
|
419
421
|
|
|
420
422
|
## System Tray
|
|
@@ -437,7 +439,35 @@ susops-tray
|
|
|
437
439
|
|
|
438
440
|
Requires `rumps`: `pip install "susops[tray-mac]"`
|
|
439
441
|
|
|
440
|
-
The tray icon reflects the current state (running/partial/stopped). State changes arrive over the daemon's SSE `/events` stream. If the stream drops, the listener reconnects within at most 5 seconds.
|
|
442
|
+
The tray icon reflects the current state (running / partial-or-pending / stopped). State changes arrive over the daemon's SSE `/events` stream. If the stream drops, the listener reconnects within at most 5 seconds. When TUI + tray are both attached to the same daemon, quitting one keeps the other running — `stop_on_quit` is skipped if any other frontend is still connected.
|
|
443
|
+
|
|
444
|
+
**Note: the macOS and Linux tray apps diverge in their UI structure.** The 3-column config window described below is macOS-only for now; the Linux tray keeps the classic submenu structure.
|
|
445
|
+
|
|
446
|
+
### macOS tray
|
|
447
|
+
|
|
448
|
+
The macOS tray has a slim menu with a unified **config window** (open with Settings… ⌘, or from the tray menu):
|
|
449
|
+
|
|
450
|
+
Slim menu items:
|
|
451
|
+
- **Status line** — shows current connection state
|
|
452
|
+
- **Settings… ⌘,** — opens the unified config window
|
|
453
|
+
- **Start / Stop / Restart Proxy** — bulk lifecycle for all connections
|
|
454
|
+
- **Show Status** — print current state to stdout
|
|
455
|
+
- **Show Logs** — open the log directory
|
|
456
|
+
- **Launch Browser ▸** — submenu: Chrome or Firefox with the PAC URL pre-configured
|
|
457
|
+
- **Reset All** — kill all processes and wipe the workspace
|
|
458
|
+
- **About SusOps**
|
|
459
|
+
- **Quit ⌘Q**
|
|
460
|
+
|
|
461
|
+
The **config window** is a 3-column Tailscale-style editor (nav / list / detail) with a dark skin and instant-apply settings:
|
|
462
|
+
|
|
463
|
+
- **Column 1 — nav.** Categories with live counts: **Connections / Domains / Forwards / Shares**, plus **Settings** pinned at the bottom. Selecting a category drives the list.
|
|
464
|
+
- **Column 2 — list.** A search field on top filters the rows for the selected category. Forwards split into **Local** and **Remote** sections (each with a one-line explainer); shares show download counts. Each row carries a colored run-state dot (green active, amber pending, gray stopped/inactive, red error/connection-down) and a connection badge; disabled rows render dimmed. A context-aware add button sits at the bottom (`+ Add Connection`, `+ Add Domain / IP / CIDR`, `+ Add Forward`, or `Share File…` / `Fetch…`).
|
|
465
|
+
- **Column 3 — detail / editor.** A header with the title, a colored status line, and an **Enabled** toggle in the top-right (applies instantly). The body is a card holding the form: forwards, domains, and shares are **edited inline** (change a field and **Save**) — no remove-and-re-enter. The same `+` buttons open inline **create forms** in this column (file/folder pickers still use the native chooser). Shares expose a copyable `http://localhost:PORT` URL and password. Per-item actions live in the action row (**Delete…** on the left, **Test** / **Save** on the right).
|
|
466
|
+
- **Settings** spans columns 2–3: grouped toggles (launch at login, stop on quit, random SSH ports, restore shares, show bandwidth, notifications, logo style) that **apply instantly**, the RPC / SSE / PAC server ports behind a single **Apply** button, and an **Open Config File…** button.
|
|
467
|
+
|
|
468
|
+
### Linux tray
|
|
469
|
+
|
|
470
|
+
The Linux tray keeps the classic submenu menu structure:
|
|
441
471
|
|
|
442
472
|
- **Manage** — toggle connection/PAC host/forward enabled state; start, stop, or restart a specific connection
|
|
443
473
|
- **Start / Stop / Restart All** — bulk lifecycle operations across all connections
|
|
@@ -528,7 +558,11 @@ Both can be `true` simultaneously — susops will start an SSH slave for TCP and
|
|
|
528
558
|
|
|
529
559
|
### Port assignment
|
|
530
560
|
|
|
531
|
-
Ports default to `0` (auto-assign). SusOps picks a random free port from the ephemeral range (49152–65535) at start time and saves it back to `config.yaml`. Pass a specific port to `add-connection` or set it in the config to pin it.
|
|
561
|
+
Ports default to `0` (auto-assign). SusOps picks a random free port from the ephemeral range (49152–65535) at start time and saves it back to `config.yaml`. Pass a specific port to `add-connection` or set it in the config to pin it. Ports outside `1–65535` are rejected on `add`.
|
|
562
|
+
|
|
563
|
+
### Tag format
|
|
564
|
+
|
|
565
|
+
Connection tags must match `[A-Za-z0-9][A-Za-z0-9._-]{0,63}` (no slashes, no `..`, no leading punctuation). Tags appear in PID-file names, socket paths, log lines, and PAC keys, so the format is intentionally strict.
|
|
532
566
|
|
|
533
567
|
### Workspace
|
|
534
568
|
|
|
@@ -644,7 +678,7 @@ sudo apt install socat # Ubuntu / Debian
|
|
|
644
678
|
|
|
645
679
|
### Enabling UDP on a forward
|
|
646
680
|
|
|
647
|
-
Set `udp: true` in the forward config, or check "UDP" in the TUI/tray
|
|
681
|
+
Set `udp: true` in the forward config, or check "UDP" in the TUI/tray forward form:
|
|
648
682
|
|
|
649
683
|
```yaml
|
|
650
684
|
forwards:
|
|
@@ -836,8 +870,41 @@ susops
|
|
|
836
870
|
# Run the CLI
|
|
837
871
|
susops ps
|
|
838
872
|
susops ls
|
|
873
|
+
|
|
874
|
+
# Build local pypi + brew artifacts (mirrors CI, no upload)
|
|
875
|
+
scripts/build-local.sh pypi # wheel + sdist
|
|
876
|
+
scripts/build-local.sh brew # SusOps.app + .dmg (macOS only)
|
|
877
|
+
scripts/build-local.sh install-pypi # install wheel into a throwaway venv
|
|
878
|
+
scripts/build-local.sh install-brew # copy SusOps.app to /Applications
|
|
839
879
|
```
|
|
840
880
|
|
|
881
|
+
### Tray development (macOS)
|
|
882
|
+
|
|
883
|
+
Run a dev tray instance against an isolated workspace without touching your live `~/.susops` setup:
|
|
884
|
+
|
|
885
|
+
```bash
|
|
886
|
+
# Isolated workspace (avoids any conflict with a running user tray)
|
|
887
|
+
WS=$(mktemp -d /tmp/susops-dev.XXXX)
|
|
888
|
+
SUSOPS_TRAY_WORKSPACE=$WS SUSOPS_TRAY_DEBUG_PORT=7799 .venv/bin/susops-tray &
|
|
889
|
+
|
|
890
|
+
# Drive the tray via the debug command server
|
|
891
|
+
.venv/bin/python tools/tray_debug.py 7799 ping
|
|
892
|
+
.venv/bin/python tools/tray_debug.py 7799 dump-menu # full menu JSON
|
|
893
|
+
.venv/bin/python tools/tray_debug.py 7799 open-config # open the Settings window
|
|
894
|
+
.venv/bin/python tools/tray_debug.py 7799 screenshot /tmp/tray.png
|
|
895
|
+
.venv/bin/python tools/tray_debug.py 7799 quit
|
|
896
|
+
```
|
|
897
|
+
|
|
898
|
+
`SUSOPS_TRAY_WORKSPACE` points the tray at a different workspace directory (separate config, PID files, sockets) so it never touches `~/.susops`. `SUSOPS_TRAY_DEBUG_PORT` starts a localhost command server on that port; `tools/tray_debug.py` is the client. Supported commands: `ping`, `dump-menu`, `open-config [gear]`, `select <conn> <section> <index>`, `dump-window`, `screenshot <path>`, `action <name>`, `open-about`, `quit`.
|
|
899
|
+
|
|
900
|
+
### GUI smoke tests (macOS)
|
|
901
|
+
|
|
902
|
+
```bash
|
|
903
|
+
SUSOPS_RUN_GUI_TESTS=1 .venv/bin/pytest -m gui -v
|
|
904
|
+
```
|
|
905
|
+
|
|
906
|
+
Seven smoke tests launch a real tray instance (using `SUSOPS_TRAY_WORKSPACE` + `SUSOPS_TRAY_DEBUG_PORT` internally), drive it through the debug server, and assert menu structure and window content. Skipped automatically on Linux and when `SUSOPS_RUN_GUI_TESTS` is unset.
|
|
907
|
+
|
|
841
908
|
### Project layout
|
|
842
909
|
|
|
843
910
|
```
|
|
@@ -913,7 +980,7 @@ brew tap mashb1t/susops
|
|
|
913
980
|
brew install susops
|
|
914
981
|
```
|
|
915
982
|
|
|
916
|
-
The formula at `packaging/homebrew/Formula/susops.rb` uses `virtualenv_install_with_resources` to create an isolated Python environment
|
|
983
|
+
The formula at `packaging/homebrew/Formula/susops.rb` uses `virtualenv_install_with_resources` to create an isolated Python environment for the CLI + TUI. The cask at `packaging/homebrew/Casks/susops.rb` installs `SusOps.app` (PyInstaller bundle) for the macOS tray — `brew install --cask susops`.
|
|
917
984
|
|
|
918
985
|
**Note:** Resource sha256 checksums in the formula must be updated for each release. Generate them with:
|
|
919
986
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "susops"
|
|
7
|
-
version = "3.0.0-
|
|
7
|
+
version = "3.0.0-rc6.dev1"
|
|
8
8
|
description = "SusOps — SSH SOCKS5 proxy manager with PAC server, Textual TUI, and system tray apps"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -60,16 +60,38 @@ def _read_port(workspace: Path) -> int | None:
|
|
|
60
60
|
return None
|
|
61
61
|
|
|
62
62
|
|
|
63
|
+
def _pid_is_susops_daemon(pid: int) -> bool:
|
|
64
|
+
"""True only if PID belongs to a live process whose cmdline matches a
|
|
65
|
+
services_daemon invocation. Defends against PID reuse: SIGKILLing the
|
|
66
|
+
daemon under load can let an ssh fork (or any other short-lived child)
|
|
67
|
+
inherit the freed PID, and a bare `os.kill(pid, 0)` liveness probe
|
|
68
|
+
would then falsely report the daemon as up.
|
|
69
|
+
"""
|
|
70
|
+
try:
|
|
71
|
+
import psutil
|
|
72
|
+
except ImportError:
|
|
73
|
+
try:
|
|
74
|
+
os.kill(pid, 0)
|
|
75
|
+
return True
|
|
76
|
+
except (OSError, ValueError):
|
|
77
|
+
return False
|
|
78
|
+
try:
|
|
79
|
+
proc = psutil.Process(pid)
|
|
80
|
+
cmdline = " ".join(proc.cmdline())
|
|
81
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
82
|
+
return False
|
|
83
|
+
return "susops.core.services_daemon" in cmdline or "susops-services" in cmdline
|
|
84
|
+
|
|
85
|
+
|
|
63
86
|
def _is_daemon_alive(workspace: Path) -> bool:
|
|
64
87
|
pid_file = _pid_path(workspace)
|
|
65
88
|
if not pid_file.exists():
|
|
66
89
|
return False
|
|
67
90
|
try:
|
|
68
91
|
pid = int(pid_file.read_text().strip())
|
|
69
|
-
os.kill(pid, 0) # signal 0 = liveness probe
|
|
70
|
-
return True
|
|
71
92
|
except (OSError, ValueError):
|
|
72
93
|
return False
|
|
94
|
+
return _pid_is_susops_daemon(pid)
|
|
73
95
|
|
|
74
96
|
|
|
75
97
|
def ensure_daemon_running(workspace: Path = _WORKSPACE_DEFAULT) -> int:
|
|
@@ -112,12 +112,14 @@ class AppConfig(BaseModel):
|
|
|
112
112
|
logo_style: LogoStyle = LogoStyle.COLORED_GLASSES
|
|
113
113
|
restore_shares_on_start: bool = True
|
|
114
114
|
tray_show_bandwidth: bool = False
|
|
115
|
+
notifications_enabled: bool = True
|
|
115
116
|
|
|
116
117
|
@field_validator(
|
|
117
118
|
"stop_on_quit",
|
|
118
119
|
"ephemeral_ports",
|
|
119
120
|
"restore_shares_on_start",
|
|
120
121
|
"tray_show_bandwidth",
|
|
122
|
+
"notifications_enabled",
|
|
121
123
|
mode="before",
|
|
122
124
|
)
|
|
123
125
|
@classmethod
|
|
@@ -193,39 +195,32 @@ def save_config(config: SusOpsConfig, workspace: Path = WORKSPACE_DEFAULT) -> No
|
|
|
193
195
|
clicks.
|
|
194
196
|
"""
|
|
195
197
|
import os
|
|
198
|
+
import tempfile
|
|
196
199
|
path = get_config_path(workspace)
|
|
197
200
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
198
201
|
yaml = YAML()
|
|
199
202
|
yaml.default_flow_style = False
|
|
200
203
|
yaml.indent(mapping=2, sequence=4, offset=2)
|
|
201
|
-
# Convert to plain dict via model_dump, then save
|
|
202
204
|
data = config.model_dump(mode='python')
|
|
203
|
-
# Convert enums to their values for serialization
|
|
204
205
|
data['susops_app']['logo_style'] = config.susops_app.logo_style.value
|
|
205
|
-
# Compute the target mode BEFORE writing: preserve any user-set restrictive
|
|
206
|
-
# permissions (e.g. chmod 600 on a hardened install) since Path.replace()
|
|
207
|
-
# would otherwise clobber them with the temp file's umask-derived mode.
|
|
208
|
-
# New configs default to 0o600 — the file holds share passwords.
|
|
209
206
|
try:
|
|
210
207
|
target_mode = path.stat().st_mode & 0o777
|
|
211
208
|
except OSError:
|
|
212
209
|
target_mode = 0o600
|
|
213
|
-
#
|
|
214
|
-
#
|
|
215
|
-
#
|
|
216
|
-
|
|
210
|
+
# Unique tmp file per call so concurrent saves (rapid TUI start/stop runs
|
|
211
|
+
# each handler in its own executor thread) don't fight over the same name
|
|
212
|
+
# and crash on tmp.replace(path) after a peer already renamed it away.
|
|
213
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
214
|
+
prefix=path.name + ".",
|
|
215
|
+
suffix=".tmp",
|
|
216
|
+
dir=str(path.parent),
|
|
217
|
+
)
|
|
218
|
+
tmp_path = Path(tmp_name)
|
|
217
219
|
try:
|
|
218
|
-
fd = os.open(
|
|
219
|
-
str(tmp_path),
|
|
220
|
-
os.O_WRONLY | os.O_CREAT | os.O_TRUNC,
|
|
221
|
-
target_mode,
|
|
222
|
-
)
|
|
223
220
|
try:
|
|
224
221
|
with os.fdopen(fd, 'w') as f:
|
|
225
222
|
yaml.dump(data, f)
|
|
226
223
|
except Exception:
|
|
227
|
-
# fdopen owns fd on success; on failure we may need to close it
|
|
228
|
-
# if fdopen never took ownership. Best-effort.
|
|
229
224
|
try:
|
|
230
225
|
os.close(fd)
|
|
231
226
|
except OSError:
|
|
@@ -235,7 +230,6 @@ def save_config(config: SusOpsConfig, workspace: Path = WORKSPACE_DEFAULT) -> No
|
|
|
235
230
|
os.chmod(tmp_path, target_mode)
|
|
236
231
|
tmp_path.replace(path)
|
|
237
232
|
except Exception:
|
|
238
|
-
# Best-effort cleanup of the temp file if writing failed.
|
|
239
233
|
try:
|
|
240
234
|
tmp_path.unlink(missing_ok=True)
|
|
241
235
|
except Exception:
|
|
@@ -134,10 +134,20 @@ class PacServer:
|
|
|
134
134
|
def start(self, port: int, pac_path: Path) -> None:
|
|
135
135
|
"""Start the PAC HTTP server on the given port.
|
|
136
136
|
|
|
137
|
-
|
|
137
|
+
No-op if already running on the same port (parallel start() callers
|
|
138
|
+
race here — facade's check-then-act on is_running() can let multiple
|
|
139
|
+
threads through). Raises RuntimeError if the port is in use by
|
|
140
|
+
something else.
|
|
138
141
|
"""
|
|
139
142
|
if self._server is not None:
|
|
140
|
-
|
|
143
|
+
if self._port == port:
|
|
144
|
+
# Same port + already up: caller's intent is already satisfied.
|
|
145
|
+
self._pac_path = pac_path
|
|
146
|
+
return
|
|
147
|
+
raise RuntimeError(
|
|
148
|
+
f"PAC server already running on port {self._port}, "
|
|
149
|
+
f"refusing to also bind {port}"
|
|
150
|
+
)
|
|
141
151
|
|
|
142
152
|
self._pac_path = pac_path
|
|
143
153
|
|
|
@@ -86,17 +86,21 @@ def _preflight(workspace: Path, log: "logging.Logger") -> None:
|
|
|
86
86
|
# Won the race uncontested.
|
|
87
87
|
pass
|
|
88
88
|
else:
|
|
89
|
-
# File exists. Is the holder alive?
|
|
89
|
+
# File exists. Is the holder alive AND actually our daemon?
|
|
90
|
+
# PID reuse (an ssh fork inheriting the freed PID after we kill -9
|
|
91
|
+
# the daemon) would otherwise wedge us in a refusal loop until the
|
|
92
|
+
# impostor exits. Reuse the same identity check the client does.
|
|
90
93
|
pid_file = _pid_path(workspace)
|
|
91
94
|
try:
|
|
92
95
|
existing_pid = int(pid_file.read_text().strip())
|
|
93
|
-
os.kill(existing_pid, 0) # liveness probe
|
|
94
96
|
except (OSError, ValueError):
|
|
95
|
-
|
|
97
|
+
existing_pid = None
|
|
98
|
+
from susops.client import _pid_is_susops_daemon
|
|
99
|
+
if existing_pid is None or not _pid_is_susops_daemon(existing_pid):
|
|
100
|
+
# Stale (or impostor) PID file. Remove + retry the atomic claim.
|
|
96
101
|
pid_file.unlink(missing_ok=True)
|
|
97
102
|
_port_path(workspace).unlink(missing_ok=True)
|
|
98
103
|
if not _claim_pid_file(workspace):
|
|
99
|
-
# Some other daemon claimed it between our cleanup and retry.
|
|
100
104
|
log.error(
|
|
101
105
|
"another susops-services daemon raced us to start. "
|
|
102
106
|
"Try again — or run "
|
|
@@ -8,6 +8,7 @@ Architecture:
|
|
|
8
8
|
"""
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import os
|
|
11
12
|
import subprocess
|
|
12
13
|
import time
|
|
13
14
|
from pathlib import Path
|
|
@@ -249,9 +250,43 @@ def stop_tunnel(
|
|
|
249
250
|
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
250
251
|
pass
|
|
251
252
|
|
|
253
|
+
# Last-resort sweep for orphan masters bound to our socket path.
|
|
254
|
+
# Under parallel start/stop the PID file can race (start writes PID Y while
|
|
255
|
+
# stop reads stale PID X, or the reconnect monitor spawns M2 between the
|
|
256
|
+
# read and the kill), leaving an ssh -N -T -D ... ControlPath=...sock
|
|
257
|
+
# process alive until its own keepalive timeout fires ~60s later.
|
|
258
|
+
if workspace is not None:
|
|
259
|
+
_sweep_orphan_masters(tag, workspace)
|
|
260
|
+
|
|
252
261
|
return stopped
|
|
253
262
|
|
|
254
263
|
|
|
264
|
+
def _sweep_orphan_masters(tag: str, workspace: Path) -> None:
|
|
265
|
+
"""Kill any ssh process whose argv references our ControlPath socket but
|
|
266
|
+
that ProcessManager isn't tracking. No-op if psutil is unavailable.
|
|
267
|
+
"""
|
|
268
|
+
try:
|
|
269
|
+
import psutil
|
|
270
|
+
except ImportError:
|
|
271
|
+
return
|
|
272
|
+
sock_str = str(socket_path(tag, workspace))
|
|
273
|
+
try:
|
|
274
|
+
own_pid = os.getpid()
|
|
275
|
+
except Exception:
|
|
276
|
+
own_pid = -1
|
|
277
|
+
for proc in psutil.process_iter(["pid", "name", "cmdline"]):
|
|
278
|
+
try:
|
|
279
|
+
if proc.info["pid"] == own_pid:
|
|
280
|
+
continue
|
|
281
|
+
if proc.info.get("name") != "ssh":
|
|
282
|
+
continue
|
|
283
|
+
cmdline = proc.info.get("cmdline") or []
|
|
284
|
+
if any(sock_str in arg for arg in cmdline):
|
|
285
|
+
proc.kill()
|
|
286
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
287
|
+
continue
|
|
288
|
+
|
|
289
|
+
|
|
255
290
|
def is_tunnel_running(tag: str, process_mgr: ProcessManager) -> bool:
|
|
256
291
|
"""Return True if the ControlMaster SSH process for tag is currently running."""
|
|
257
292
|
return process_mgr.is_running(_master_name(tag))
|