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/core/status.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""SSE StatusServer — real-time event push for TUI and tray apps.
|
|
2
|
+
|
|
3
|
+
Provides a Server-Sent Events endpoint at GET /events that broadcasts
|
|
4
|
+
state, share, bandwidth, and forward events to all connected clients.
|
|
5
|
+
|
|
6
|
+
Event types:
|
|
7
|
+
state — {"tag": "work", "running": true, "pid": 1234}
|
|
8
|
+
share — {"port": 52100, "file": "report.pdf", "running": true, "conn_tag": "work"}
|
|
9
|
+
bandwidth — {"tag": "work", "rx_bps": 1234.5, "tx_bps": 567.8}
|
|
10
|
+
forward — {"tag": "work", "fw_tag": "db", "direction": "local", "running": true}
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
import time
|
|
17
|
+
from typing import Any, Callable
|
|
18
|
+
|
|
19
|
+
__all__ = ["StatusServer"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class StatusServer:
|
|
23
|
+
"""Async SSE server that broadcasts events to connected clients.
|
|
24
|
+
|
|
25
|
+
Reuses the shared event loop from shares.py so both servers run on the
|
|
26
|
+
same daemon thread.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
self._runner = None
|
|
31
|
+
self._port: int = 0
|
|
32
|
+
self._queues: list[asyncio.Queue[str | None]] = []
|
|
33
|
+
self._queues_lock: asyncio.Lock | None = None
|
|
34
|
+
# Set by the services daemon to react to client-count changes — used
|
|
35
|
+
# to drive idle-shutdown.
|
|
36
|
+
self.on_clients_changed: Callable[[int], None] | None = None
|
|
37
|
+
# Set by the services daemon (wired to mgr._log) to record connect /
|
|
38
|
+
# disconnect events in the in-memory log buffer.
|
|
39
|
+
self.on_log: Callable[[str], None] | None = None
|
|
40
|
+
|
|
41
|
+
def client_count(self) -> int:
|
|
42
|
+
return len(self._queues)
|
|
43
|
+
|
|
44
|
+
def start(self, port: int = 0) -> int:
|
|
45
|
+
"""Start the SSE server. Returns the bound port."""
|
|
46
|
+
from susops.core.share import _get_loop
|
|
47
|
+
|
|
48
|
+
if self._runner is not None:
|
|
49
|
+
return self._port
|
|
50
|
+
|
|
51
|
+
loop = _get_loop()
|
|
52
|
+
future = asyncio.run_coroutine_threadsafe(
|
|
53
|
+
self._start_async(port), loop
|
|
54
|
+
)
|
|
55
|
+
self._port = future.result(timeout=10)
|
|
56
|
+
return self._port
|
|
57
|
+
|
|
58
|
+
async def _start_async(self, port: int) -> int:
|
|
59
|
+
from aiohttp import web
|
|
60
|
+
|
|
61
|
+
self._queues_lock = asyncio.Lock()
|
|
62
|
+
|
|
63
|
+
# SSE comment lines (lines starting with ":") are valid no-ops per the
|
|
64
|
+
# spec; we use them as a periodic keepalive so a write actually
|
|
65
|
+
# happens even when the daemon has no events to broadcast. Without
|
|
66
|
+
# this, a frontend that closes its TCP connection is never noticed —
|
|
67
|
+
# `queue.get()` blocks forever and `_fire_clients_changed(0)` never
|
|
68
|
+
# fires, which means idle-shutdown can't trigger.
|
|
69
|
+
HEARTBEAT_INTERVAL_S = 5.0
|
|
70
|
+
|
|
71
|
+
async def handle_events(request: web.Request) -> web.StreamResponse:
|
|
72
|
+
resp = web.StreamResponse(
|
|
73
|
+
status=200,
|
|
74
|
+
headers={
|
|
75
|
+
"Content-Type": "text/event-stream",
|
|
76
|
+
"Cache-Control": "no-cache",
|
|
77
|
+
"Connection": "keep-alive",
|
|
78
|
+
"Access-Control-Allow-Origin": "*",
|
|
79
|
+
},
|
|
80
|
+
)
|
|
81
|
+
await resp.prepare(request)
|
|
82
|
+
|
|
83
|
+
# Identify the client. Frontends set `X-Susops-Client` to one of
|
|
84
|
+
# tui / tray-mac / tray-linux / cli; anything else is "other".
|
|
85
|
+
client_type = request.headers.get("X-Susops-Client", "other")
|
|
86
|
+
client_version = request.headers.get("X-Susops-Client-Version", "?")
|
|
87
|
+
client_pid = request.headers.get("X-Susops-Pid", "?")
|
|
88
|
+
# Optional per-client event filter: comma-separated list of event
|
|
89
|
+
# names (e.g. "state,share"). When set, the broadcaster only
|
|
90
|
+
# pushes matching events to this client — useful for clients
|
|
91
|
+
# like the tray that don't render bandwidth charts.
|
|
92
|
+
events_header = request.headers.get("X-Susops-Events", "").strip()
|
|
93
|
+
event_filter: set[str] | None = (
|
|
94
|
+
{e.strip() for e in events_header.split(",") if e.strip()}
|
|
95
|
+
if events_header else None
|
|
96
|
+
)
|
|
97
|
+
peer = request.transport.get_extra_info("peername") if request.transport else None
|
|
98
|
+
peer_str = f"{peer[0]}:{peer[1]}" if peer else "?"
|
|
99
|
+
connect_t = time.monotonic()
|
|
100
|
+
|
|
101
|
+
queue: asyncio.Queue[str | None] = asyncio.Queue()
|
|
102
|
+
queue._susops_events = event_filter # type: ignore[attr-defined]
|
|
103
|
+
async with self._queues_lock:
|
|
104
|
+
self._queues.append(queue)
|
|
105
|
+
count = len(self._queues)
|
|
106
|
+
self._fire_clients_changed(count)
|
|
107
|
+
# Always include the subscribed event set in the connect line —
|
|
108
|
+
# "all" when the client sent no X-Susops-Events filter, the
|
|
109
|
+
# explicit list otherwise. Useful to confirm what each client
|
|
110
|
+
# actually subscribed to (e.g. tray opts out of bandwidth).
|
|
111
|
+
events_str = (
|
|
112
|
+
",".join(sorted(event_filter))
|
|
113
|
+
if event_filter is not None else "all"
|
|
114
|
+
)
|
|
115
|
+
self._fire_log(
|
|
116
|
+
f"SSE client connected: {client_type} v{client_version} "
|
|
117
|
+
f"(pid {client_pid}, peer {peer_str}, events: {events_str}) — {count} active"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
while True:
|
|
122
|
+
try:
|
|
123
|
+
msg = await asyncio.wait_for(
|
|
124
|
+
queue.get(), timeout=HEARTBEAT_INTERVAL_S,
|
|
125
|
+
)
|
|
126
|
+
except asyncio.TimeoutError:
|
|
127
|
+
# No event in HEARTBEAT_INTERVAL_S — send a comment
|
|
128
|
+
# line. If the client is gone the write will raise
|
|
129
|
+
# ConnectionResetError and we'll fall into the
|
|
130
|
+
# finally block, decrementing the client count.
|
|
131
|
+
await resp.write(b": keepalive\n\n")
|
|
132
|
+
continue
|
|
133
|
+
if msg is None:
|
|
134
|
+
break
|
|
135
|
+
await resp.write(msg.encode())
|
|
136
|
+
except (ConnectionResetError, asyncio.CancelledError):
|
|
137
|
+
pass
|
|
138
|
+
except Exception:
|
|
139
|
+
# Any other write failure also implies the client is gone.
|
|
140
|
+
pass
|
|
141
|
+
finally:
|
|
142
|
+
async with self._queues_lock:
|
|
143
|
+
try:
|
|
144
|
+
self._queues.remove(queue)
|
|
145
|
+
except ValueError:
|
|
146
|
+
pass
|
|
147
|
+
count = len(self._queues)
|
|
148
|
+
self._fire_clients_changed(count)
|
|
149
|
+
duration = time.monotonic() - connect_t
|
|
150
|
+
self._fire_log(
|
|
151
|
+
f"SSE client disconnected: {client_type} "
|
|
152
|
+
f"(pid {client_pid}, after {duration:.1f}s) — {count} active"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
return resp
|
|
156
|
+
|
|
157
|
+
app = web.Application()
|
|
158
|
+
app.router.add_get("/events", handle_events)
|
|
159
|
+
|
|
160
|
+
runner = web.AppRunner(app)
|
|
161
|
+
await runner.setup()
|
|
162
|
+
site = web.TCPSite(runner, "127.0.0.1", port)
|
|
163
|
+
await site.start()
|
|
164
|
+
self._runner = runner
|
|
165
|
+
return site._server.sockets[0].getsockname()[1] # type: ignore[union-attr]
|
|
166
|
+
|
|
167
|
+
def stop(self) -> None:
|
|
168
|
+
"""Stop the SSE server and disconnect all clients."""
|
|
169
|
+
if self._runner is None:
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
from susops.core.share import _get_loop
|
|
173
|
+
|
|
174
|
+
loop = _get_loop()
|
|
175
|
+
future = asyncio.run_coroutine_threadsafe(
|
|
176
|
+
self._stop_async(), loop
|
|
177
|
+
)
|
|
178
|
+
try:
|
|
179
|
+
future.result(timeout=5)
|
|
180
|
+
except Exception:
|
|
181
|
+
pass
|
|
182
|
+
self._runner = None
|
|
183
|
+
self._port = 0
|
|
184
|
+
|
|
185
|
+
async def _stop_async(self) -> None:
|
|
186
|
+
# Signal all clients to disconnect
|
|
187
|
+
if self._queues_lock is not None:
|
|
188
|
+
async with self._queues_lock:
|
|
189
|
+
for q in self._queues:
|
|
190
|
+
await q.put(None)
|
|
191
|
+
if self._runner is not None:
|
|
192
|
+
await self._runner.cleanup()
|
|
193
|
+
|
|
194
|
+
def emit(self, event: str, data: dict[str, Any]) -> None:
|
|
195
|
+
"""Broadcast an SSE event to all connected clients (thread-safe).
|
|
196
|
+
|
|
197
|
+
Honours each client's per-connection event filter (set via the
|
|
198
|
+
``X-Susops-Events`` header). Clients with no filter receive every
|
|
199
|
+
event; clients with a filter only receive events whose name is in
|
|
200
|
+
their set.
|
|
201
|
+
"""
|
|
202
|
+
if self._runner is None:
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
from susops.core.share import _get_loop
|
|
206
|
+
|
|
207
|
+
payload = f"event: {event}\ndata: {json.dumps(data)}\n\n"
|
|
208
|
+
loop = _get_loop()
|
|
209
|
+
asyncio.run_coroutine_threadsafe(
|
|
210
|
+
self._broadcast(event, payload), loop
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
async def _broadcast(self, event: str, payload: str) -> None:
|
|
214
|
+
if self._queues_lock is None:
|
|
215
|
+
return
|
|
216
|
+
async with self._queues_lock:
|
|
217
|
+
for q in list(self._queues):
|
|
218
|
+
allowed: set[str] | None = getattr(q, "_susops_events", None)
|
|
219
|
+
if allowed is not None and event not in allowed:
|
|
220
|
+
continue
|
|
221
|
+
await q.put(payload)
|
|
222
|
+
|
|
223
|
+
def is_running(self) -> bool:
|
|
224
|
+
return self._runner is not None
|
|
225
|
+
|
|
226
|
+
def get_port(self) -> int:
|
|
227
|
+
return self._port
|
|
228
|
+
|
|
229
|
+
def _fire_clients_changed(self, count: int) -> None:
|
|
230
|
+
cb = self.on_clients_changed
|
|
231
|
+
if cb is None:
|
|
232
|
+
return
|
|
233
|
+
try:
|
|
234
|
+
cb(count)
|
|
235
|
+
except Exception:
|
|
236
|
+
pass
|
|
237
|
+
|
|
238
|
+
def _fire_log(self, msg: str) -> None:
|
|
239
|
+
cb = self.on_log
|
|
240
|
+
if cb is None:
|
|
241
|
+
return
|
|
242
|
+
try:
|
|
243
|
+
cb(msg)
|
|
244
|
+
except Exception:
|
|
245
|
+
pass
|
susops/core/types.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Types module for SusOps.
|
|
2
|
+
|
|
3
|
+
Defines all shared enums and result dataclasses used throughout the susops package.
|
|
4
|
+
This module is the single source of truth for type definitions — no other module
|
|
5
|
+
should define these types.
|
|
6
|
+
|
|
7
|
+
Enums:
|
|
8
|
+
- ProcessState: Enumeration of SSH/PAC process states
|
|
9
|
+
- LogoStyle: Enumeration of logo style options
|
|
10
|
+
|
|
11
|
+
Result Dataclasses:
|
|
12
|
+
- ConnectionStatus: Status of a single SSH connection
|
|
13
|
+
- StartResult: Result of starting connections/PAC
|
|
14
|
+
- StopResult: Result of stopping connections/PAC
|
|
15
|
+
- StatusResult: Overall status query result
|
|
16
|
+
- TestResult: Result of testing a connection
|
|
17
|
+
- ShareInfo: Information about an active file share
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import dataclasses
|
|
21
|
+
import enum
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"ProcessState",
|
|
26
|
+
"LogoStyle",
|
|
27
|
+
"ConnectionStatus",
|
|
28
|
+
"StartResult",
|
|
29
|
+
"StopResult",
|
|
30
|
+
"StatusResult",
|
|
31
|
+
"TestResult",
|
|
32
|
+
"ShareInfo",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ProcessState(enum.Enum):
|
|
37
|
+
"""Enumeration of possible process states for SSH and PAC services."""
|
|
38
|
+
|
|
39
|
+
INITIAL = "initial"
|
|
40
|
+
"""Not yet checked — initial state before status inquiry."""
|
|
41
|
+
|
|
42
|
+
RUNNING = "running"
|
|
43
|
+
"""All services are up and running."""
|
|
44
|
+
|
|
45
|
+
STOPPED_PARTIALLY = "stopped_partially"
|
|
46
|
+
"""Some services are down, others are running."""
|
|
47
|
+
|
|
48
|
+
STOPPED = "stopped"
|
|
49
|
+
"""All services are down."""
|
|
50
|
+
|
|
51
|
+
ERROR = "error"
|
|
52
|
+
"""Unexpected state or error occurred."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class LogoStyle(enum.Enum):
|
|
56
|
+
"""Enumeration of logo style options for UI display."""
|
|
57
|
+
|
|
58
|
+
COLORED_GLASSES = "COLORED_GLASSES"
|
|
59
|
+
"""Colored glasses logo variant."""
|
|
60
|
+
|
|
61
|
+
COLORED_S = "COLORED_S"
|
|
62
|
+
"""Colored S logo variant."""
|
|
63
|
+
|
|
64
|
+
GEAR = "GEAR"
|
|
65
|
+
"""Gear logo variant."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclasses.dataclass(frozen=True)
|
|
69
|
+
class ConnectionStatus:
|
|
70
|
+
"""Status information for a single SSH connection.
|
|
71
|
+
|
|
72
|
+
Attributes:
|
|
73
|
+
tag: Unique identifier for the connection (e.g., 'proxy1', 'vpn')
|
|
74
|
+
running: Whether the connection is currently active
|
|
75
|
+
pid: Process ID of the ssh process, or None if not running
|
|
76
|
+
socks_port: Port number on which the SOCKS proxy is listening (0 if not running)
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
tag: str
|
|
80
|
+
running: bool
|
|
81
|
+
pid: Optional[int] = None
|
|
82
|
+
socks_port: int = 0
|
|
83
|
+
enabled: bool = True
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclasses.dataclass(frozen=True)
|
|
87
|
+
class StartResult:
|
|
88
|
+
"""Result of attempting to start connections and/or PAC service.
|
|
89
|
+
|
|
90
|
+
Attributes:
|
|
91
|
+
success: Whether the operation succeeded
|
|
92
|
+
message: Human-readable status message
|
|
93
|
+
connection_statuses: Tuple of ConnectionStatus objects for each connection
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
success: bool
|
|
97
|
+
message: str
|
|
98
|
+
connection_statuses: tuple[ConnectionStatus, ...] = ()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclasses.dataclass(frozen=True)
|
|
102
|
+
class StopResult:
|
|
103
|
+
"""Result of attempting to stop connections and/or PAC service.
|
|
104
|
+
|
|
105
|
+
Attributes:
|
|
106
|
+
success: Whether the operation succeeded
|
|
107
|
+
message: Human-readable status message
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
success: bool
|
|
111
|
+
message: str
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclasses.dataclass(frozen=True)
|
|
115
|
+
class StatusResult:
|
|
116
|
+
"""Result of querying overall status of connections and services.
|
|
117
|
+
|
|
118
|
+
Attributes:
|
|
119
|
+
state: The overall ProcessState
|
|
120
|
+
connection_statuses: Tuple of ConnectionStatus objects for each connection
|
|
121
|
+
pac_running: Whether the PAC (Proxy Auto-Config) service is running
|
|
122
|
+
pac_port: Port number on which PAC HTTP server is listening
|
|
123
|
+
message: Optional human-readable status message
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
state: ProcessState
|
|
127
|
+
connection_statuses: tuple[ConnectionStatus, ...]
|
|
128
|
+
pac_running: bool
|
|
129
|
+
pac_port: int
|
|
130
|
+
message: str = ""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclasses.dataclass(frozen=True)
|
|
134
|
+
class TestResult:
|
|
135
|
+
"""Result of testing connectivity through a connection.
|
|
136
|
+
|
|
137
|
+
Attributes:
|
|
138
|
+
target: Description of the target that was tested (e.g., hostname, URL)
|
|
139
|
+
success: Whether the test succeeded
|
|
140
|
+
message: Human-readable result or error message
|
|
141
|
+
latency_ms: Measured round-trip latency in milliseconds, or None if test failed
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
target: str
|
|
145
|
+
success: bool
|
|
146
|
+
message: str
|
|
147
|
+
latency_ms: Optional[float] = None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclasses.dataclass(frozen=True)
|
|
151
|
+
class ShareInfo:
|
|
152
|
+
"""Information about an active file share session.
|
|
153
|
+
|
|
154
|
+
Attributes:
|
|
155
|
+
file_path: Absolute path to the file being shared
|
|
156
|
+
port: Port number on which the file server is listening
|
|
157
|
+
password: Authentication password for accessing the share
|
|
158
|
+
url: Full HTTP URL for accessing the share (e.g., 'http://localhost:8080')
|
|
159
|
+
conn_tag: Connection tag used for port forwarding, or None for local-only share
|
|
160
|
+
running: Whether the share server is currently active
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
file_path: str
|
|
164
|
+
port: int
|
|
165
|
+
password: str
|
|
166
|
+
url: str
|
|
167
|
+
conn_tag: str | None = None
|
|
168
|
+
running: bool = True
|
|
169
|
+
stopped: bool = False # True = manually stopped; False + not running = offline (conn down)
|
|
170
|
+
access_count: int = 0
|
|
171
|
+
failed_count: int = 0
|