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
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""SusOps services daemon — single long-running process that owns the
|
|
2
|
+
PAC server, status SSE endpoint, reconnect monitor, and bandwidth sampler.
|
|
3
|
+
|
|
4
|
+
Frontends (tray, TUI, CLI) talk to it over JSON-over-HTTP RPC.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import signal
|
|
12
|
+
import sys
|
|
13
|
+
import threading
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
WORKSPACE_DEFAULT = Path.home() / ".susops"
|
|
17
|
+
_PID_FILENAME = "susops-services.pid"
|
|
18
|
+
_PORT_FILENAME = "susops-services.port"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _pid_path(workspace: Path) -> Path:
|
|
22
|
+
return workspace / "pids" / _PID_FILENAME
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _port_path(workspace: Path) -> Path:
|
|
26
|
+
return workspace / "pids" / _PORT_FILENAME
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _write_pid_file(workspace: Path) -> None:
|
|
30
|
+
p = _pid_path(workspace)
|
|
31
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
p.write_text(str(os.getpid()))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _claim_pid_file(workspace: Path) -> bool:
|
|
36
|
+
"""Atomically create the PID file with this process's PID.
|
|
37
|
+
|
|
38
|
+
Uses O_CREAT | O_EXCL so two daemons racing each other can't both
|
|
39
|
+
succeed — at most one will create the file, the other gets
|
|
40
|
+
FileExistsError. Returns True if we claimed it, False otherwise.
|
|
41
|
+
"""
|
|
42
|
+
p = _pid_path(workspace)
|
|
43
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
try:
|
|
45
|
+
fd = os.open(str(p), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
|
46
|
+
except FileExistsError:
|
|
47
|
+
return False
|
|
48
|
+
try:
|
|
49
|
+
os.write(fd, str(os.getpid()).encode())
|
|
50
|
+
finally:
|
|
51
|
+
os.close(fd)
|
|
52
|
+
return True
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _remove_pid_file(workspace: Path) -> None:
|
|
56
|
+
try:
|
|
57
|
+
_pid_path(workspace).unlink(missing_ok=True)
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _remove_port_file(workspace: Path) -> None:
|
|
63
|
+
try:
|
|
64
|
+
_port_path(workspace).unlink(missing_ok=True)
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
_EXIT_ANOTHER_DAEMON_ALIVE = 2
|
|
70
|
+
_EXIT_PAC_PORT_SQUATTED = 3
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _preflight(workspace: Path, log: "logging.Logger") -> None:
|
|
74
|
+
"""Refuse to start if another daemon is alive or PAC port is squatted.
|
|
75
|
+
|
|
76
|
+
Atomic via O_EXCL on the PID file — two daemons racing each other
|
|
77
|
+
can't both pass this check. Converts the silent-failure modes that
|
|
78
|
+
bit us during development (orphan accumulation, double-spawn races)
|
|
79
|
+
into loud, actionable exits.
|
|
80
|
+
|
|
81
|
+
On success the PID file is written with our pid (which means the
|
|
82
|
+
daemon's shutdown finally block MUST clean it up).
|
|
83
|
+
"""
|
|
84
|
+
# Try to claim the PID file atomically.
|
|
85
|
+
if _claim_pid_file(workspace):
|
|
86
|
+
# Won the race uncontested.
|
|
87
|
+
pass
|
|
88
|
+
else:
|
|
89
|
+
# File exists. Is the holder alive?
|
|
90
|
+
pid_file = _pid_path(workspace)
|
|
91
|
+
try:
|
|
92
|
+
existing_pid = int(pid_file.read_text().strip())
|
|
93
|
+
os.kill(existing_pid, 0) # liveness probe
|
|
94
|
+
except (OSError, ValueError):
|
|
95
|
+
# Stale PID file. Remove + retry the atomic claim once.
|
|
96
|
+
pid_file.unlink(missing_ok=True)
|
|
97
|
+
_port_path(workspace).unlink(missing_ok=True)
|
|
98
|
+
if not _claim_pid_file(workspace):
|
|
99
|
+
# Some other daemon claimed it between our cleanup and retry.
|
|
100
|
+
log.error(
|
|
101
|
+
"another susops-services daemon raced us to start. "
|
|
102
|
+
"Try again — or run "
|
|
103
|
+
"`pgrep -lf susops-services` to inspect."
|
|
104
|
+
)
|
|
105
|
+
sys.exit(_EXIT_ANOTHER_DAEMON_ALIVE)
|
|
106
|
+
else:
|
|
107
|
+
log.error(
|
|
108
|
+
"another susops-services daemon is already running (pid=%d). "
|
|
109
|
+
"Stop it first with `kill %d` (or `kill -9 %d` if it's wedged) "
|
|
110
|
+
"before starting a new one.",
|
|
111
|
+
existing_pid, existing_pid, existing_pid,
|
|
112
|
+
)
|
|
113
|
+
sys.exit(_EXIT_ANOTHER_DAEMON_ALIVE)
|
|
114
|
+
|
|
115
|
+
# 2. Is the configured PAC port held by an untracked process?
|
|
116
|
+
try:
|
|
117
|
+
from susops.core.config import load_config
|
|
118
|
+
cfg = load_config(workspace)
|
|
119
|
+
pac_port = cfg.pac_server_port
|
|
120
|
+
except Exception:
|
|
121
|
+
pac_port = 0
|
|
122
|
+
if not pac_port:
|
|
123
|
+
return # 0 means auto-allocate, no port to preflight
|
|
124
|
+
|
|
125
|
+
import socket as _socket
|
|
126
|
+
probe = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)
|
|
127
|
+
probe.setsockopt(_socket.SOL_SOCKET, _socket.SO_REUSEADDR, 1)
|
|
128
|
+
try:
|
|
129
|
+
probe.bind(("127.0.0.1", pac_port))
|
|
130
|
+
except OSError as exc:
|
|
131
|
+
log.error(
|
|
132
|
+
"PAC port %d is bound by another process (%s). "
|
|
133
|
+
"Likely an orphan susops-services or susops.core.pac from a "
|
|
134
|
+
"previous run. Recover with:\n"
|
|
135
|
+
" pkill -9 -f 'susops-services|susops.core.services_daemon|susops.core.pac'\n"
|
|
136
|
+
" rm -f %s/pids/susops-services.{pid,port}\n"
|
|
137
|
+
"Then start this daemon again.",
|
|
138
|
+
pac_port, exc, workspace,
|
|
139
|
+
)
|
|
140
|
+
# We claimed the PID file above; clean it up so the next attempt
|
|
141
|
+
# isn't blocked by us.
|
|
142
|
+
_remove_pid_file(workspace)
|
|
143
|
+
sys.exit(_EXIT_PAC_PORT_SQUATTED)
|
|
144
|
+
finally:
|
|
145
|
+
probe.close()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def main() -> int:
|
|
149
|
+
parser = argparse.ArgumentParser(description="SusOps services daemon")
|
|
150
|
+
parser.add_argument("--workspace", default=str(WORKSPACE_DEFAULT))
|
|
151
|
+
parser.add_argument("--port", type=int, default=0,
|
|
152
|
+
help="RPC port; 0 = auto-allocate")
|
|
153
|
+
args = parser.parse_args()
|
|
154
|
+
workspace = Path(args.workspace)
|
|
155
|
+
|
|
156
|
+
logging.basicConfig(level=logging.INFO,
|
|
157
|
+
format="%(asctime)s [services] %(message)s")
|
|
158
|
+
log = logging.getLogger("susops.services")
|
|
159
|
+
|
|
160
|
+
# Install signal handlers BEFORE preflight: SIGTERM arriving in the
|
|
161
|
+
# window between claiming the PID file and entering the try block
|
|
162
|
+
# otherwise triggers Python's default handler, bypassing cleanup.
|
|
163
|
+
stop_event = threading.Event()
|
|
164
|
+
|
|
165
|
+
def _shutdown(signum, _frame) -> None:
|
|
166
|
+
log.info("Received signal %d, shutting down", signum)
|
|
167
|
+
stop_event.set()
|
|
168
|
+
|
|
169
|
+
signal.signal(signal.SIGTERM, _shutdown)
|
|
170
|
+
signal.signal(signal.SIGINT, _shutdown)
|
|
171
|
+
|
|
172
|
+
_preflight(workspace, log)
|
|
173
|
+
|
|
174
|
+
mgr = None
|
|
175
|
+
try:
|
|
176
|
+
import time as _time
|
|
177
|
+
from susops.core.rpc_server import serve
|
|
178
|
+
from susops.facade import SusOpsManager
|
|
179
|
+
|
|
180
|
+
mgr = SusOpsManager(workspace=workspace, _enable_background_threads=True)
|
|
181
|
+
# CLI `--port` wins. Fall back to the configured `rpc_server_port`,
|
|
182
|
+
# then 0 (auto-allocate). When we auto-allocate, persist the bound
|
|
183
|
+
# port back to config so subsequent spawns reuse it.
|
|
184
|
+
requested_port = args.port or mgr.config.rpc_server_port or 0
|
|
185
|
+
runner, actual_port = serve(mgr, port=requested_port)
|
|
186
|
+
if requested_port == 0 and actual_port != mgr.config.rpc_server_port:
|
|
187
|
+
mgr.config = mgr.config.model_copy(
|
|
188
|
+
update={"rpc_server_port": actual_port}
|
|
189
|
+
)
|
|
190
|
+
mgr._save()
|
|
191
|
+
_port_path(workspace).write_text(str(actual_port))
|
|
192
|
+
log.info("RPC listening on 127.0.0.1:%d", actual_port)
|
|
193
|
+
|
|
194
|
+
# Start the SSE status server eagerly so frontends can connect
|
|
195
|
+
# immediately on daemon spawn, and so the daemon-startup log can
|
|
196
|
+
# report the SSE port too. Previously this was lazy (started on the
|
|
197
|
+
# first tunnel `start()` call), which left SSE listeners spinning in
|
|
198
|
+
# backoff for several seconds after a fresh daemon spawn.
|
|
199
|
+
sse_port = mgr.ensure_sse_status_server()
|
|
200
|
+
|
|
201
|
+
# Surface daemon-startup details in the in-memory log buffer too so
|
|
202
|
+
# they show up in the TUI Logs tab and the tray Logs window — the
|
|
203
|
+
# `log.info` calls only land on the daemon process's stderr.
|
|
204
|
+
try:
|
|
205
|
+
sse_str = f"SSE port {sse_port}" if sse_port else "SSE port unavailable"
|
|
206
|
+
mgr._log(
|
|
207
|
+
f"Daemon started — RPC port {actual_port}, {sse_str} (PID {os.getpid()})"
|
|
208
|
+
)
|
|
209
|
+
mgr._log(f"Workspace: {workspace}")
|
|
210
|
+
except Exception:
|
|
211
|
+
pass
|
|
212
|
+
|
|
213
|
+
# Idle-shutdown. Exit when the last SSE client disconnects AND there's
|
|
214
|
+
# no in-flight work (no SSH masters, no shares, no PAC, no watched
|
|
215
|
+
# connections). Startup gets a small grace so a frontend that calls
|
|
216
|
+
# ensure_daemon_running() has time to make its first SSE connection
|
|
217
|
+
# before the periodic check fires.
|
|
218
|
+
_IDLE_STARTUP_GRACE_S = 3.0
|
|
219
|
+
_IDLE_CHECK_INTERVAL_S = 5.0
|
|
220
|
+
startup_time = _time.monotonic()
|
|
221
|
+
|
|
222
|
+
def _should_exit() -> bool:
|
|
223
|
+
if _time.monotonic() - startup_time < _IDLE_STARTUP_GRACE_S:
|
|
224
|
+
return False
|
|
225
|
+
if mgr is None:
|
|
226
|
+
return False
|
|
227
|
+
if mgr._status_server.client_count() > 0:
|
|
228
|
+
return False
|
|
229
|
+
return mgr.is_idle()
|
|
230
|
+
|
|
231
|
+
def _on_clients_changed(count: int) -> None:
|
|
232
|
+
# Fast path — react immediately when the last SSE client drops.
|
|
233
|
+
# The periodic check below handles the "no SSE was ever opened"
|
|
234
|
+
# case (e.g. `susops ps` fires one RPC and exits).
|
|
235
|
+
if count > 0:
|
|
236
|
+
return
|
|
237
|
+
if _should_exit():
|
|
238
|
+
msg = "Last client disconnected and no work pending — shutting down"
|
|
239
|
+
log.info(msg)
|
|
240
|
+
try:
|
|
241
|
+
mgr._log(msg)
|
|
242
|
+
except Exception:
|
|
243
|
+
pass
|
|
244
|
+
stop_event.set()
|
|
245
|
+
|
|
246
|
+
mgr._status_server.on_clients_changed = _on_clients_changed
|
|
247
|
+
# Surface SSE connect/disconnect in the in-memory log buffer so the
|
|
248
|
+
# TUI Logs tab and tray Logs window can show "tui connected" etc.
|
|
249
|
+
mgr._status_server.on_log = mgr._log
|
|
250
|
+
|
|
251
|
+
def _idle_watcher() -> None:
|
|
252
|
+
while not stop_event.is_set():
|
|
253
|
+
if stop_event.wait(_IDLE_CHECK_INTERVAL_S):
|
|
254
|
+
return
|
|
255
|
+
if _should_exit():
|
|
256
|
+
msg = (f"Idle for {_IDLE_CHECK_INTERVAL_S:.0f}s with "
|
|
257
|
+
f"no clients — shutting down")
|
|
258
|
+
log.info(msg)
|
|
259
|
+
try:
|
|
260
|
+
mgr._log(msg)
|
|
261
|
+
except Exception:
|
|
262
|
+
pass
|
|
263
|
+
stop_event.set()
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
threading.Thread(
|
|
267
|
+
target=_idle_watcher, daemon=True, name="susops-idle-watcher",
|
|
268
|
+
).start()
|
|
269
|
+
|
|
270
|
+
log.info("Daemon started, pid=%d, workspace=%s", os.getpid(), workspace)
|
|
271
|
+
stop_event.wait()
|
|
272
|
+
# If we land here it's because the idle-watcher or a signal asked us
|
|
273
|
+
# to exit — surface it in the in-memory log so frontends can see
|
|
274
|
+
# *why* the daemon went away rather than just noticing it's gone.
|
|
275
|
+
if mgr is not None:
|
|
276
|
+
try:
|
|
277
|
+
mgr._log("Daemon shutting down")
|
|
278
|
+
except Exception:
|
|
279
|
+
pass
|
|
280
|
+
finally:
|
|
281
|
+
# Remove PID + port files FIRST so subsequent ensure_daemon_running()
|
|
282
|
+
# calls don't think we're still alive while we're shutting down.
|
|
283
|
+
# Anything that hangs below can't strand us with a stale PID file.
|
|
284
|
+
_remove_pid_file(workspace)
|
|
285
|
+
_remove_port_file(workspace)
|
|
286
|
+
|
|
287
|
+
# Stop the manager (kills SSH masters, stops PAC/status threads).
|
|
288
|
+
# Wrapped in a watchdog timer — if a child won't die we still exit.
|
|
289
|
+
def _watchdog():
|
|
290
|
+
import time as _t
|
|
291
|
+
_t.sleep(5.0)
|
|
292
|
+
log.error("Shutdown watchdog tripped, force-exiting")
|
|
293
|
+
os._exit(1)
|
|
294
|
+
|
|
295
|
+
threading.Thread(target=_watchdog, daemon=True, name="susops-services-watchdog").start()
|
|
296
|
+
|
|
297
|
+
if mgr is not None:
|
|
298
|
+
try:
|
|
299
|
+
mgr.stop_quick()
|
|
300
|
+
except Exception:
|
|
301
|
+
log.exception("Error during manager stop")
|
|
302
|
+
# NOTE: we intentionally do NOT call `asyncio.run(runner.cleanup())`.
|
|
303
|
+
# The aiohttp runner lives on a separate event loop (see serve() in
|
|
304
|
+
# rpc_server.py) running on a daemon thread. Spawning a fresh loop
|
|
305
|
+
# here to await an object from a different loop deadlocks. Process
|
|
306
|
+
# exit closes the listening sockets cleanly anyway.
|
|
307
|
+
log.info("Daemon stopped")
|
|
308
|
+
return 0
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
if __name__ == "__main__":
|
|
312
|
+
sys.exit(main())
|
susops/core/share.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Encrypted file sharing server and client for SusOps.
|
|
2
|
+
|
|
3
|
+
Replaces the bash implementation that used nc + openssl + gzip.
|
|
4
|
+
Uses aiohttp for async HTTP serving + cryptography library for AES-256-CTR encryption.
|
|
5
|
+
|
|
6
|
+
Protocol (same as original):
|
|
7
|
+
- HTTP Basic auth with credentials ":password" (empty username)
|
|
8
|
+
- File is gzip-compressed then AES-256-CTR encrypted with PBKDF2 key derivation
|
|
9
|
+
- Original filename is AES-encrypted and base64-encoded in Content-Disposition
|
|
10
|
+
- Served as application/octet-stream
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import base64
|
|
16
|
+
import gzip
|
|
17
|
+
import os
|
|
18
|
+
import secrets
|
|
19
|
+
import string
|
|
20
|
+
import threading
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from susops.core.types import ShareInfo
|
|
24
|
+
|
|
25
|
+
__all__ = ["ShareServer", "fetch_file", "generate_password", "ShareInfo"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _derive_key(password: str, salt: bytes) -> bytes:
|
|
29
|
+
"""Derive a 32-byte AES key from a password using PBKDF2-HMAC-SHA256."""
|
|
30
|
+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
31
|
+
from cryptography.hazmat.primitives import hashes
|
|
32
|
+
|
|
33
|
+
kdf = PBKDF2HMAC(
|
|
34
|
+
algorithm=hashes.SHA256(),
|
|
35
|
+
length=32,
|
|
36
|
+
salt=salt,
|
|
37
|
+
iterations=600_000,
|
|
38
|
+
)
|
|
39
|
+
return kdf.derive(password.encode())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _encrypt(data: bytes, password: str) -> bytes:
|
|
43
|
+
"""Encrypt bytes using AES-256-CTR.
|
|
44
|
+
|
|
45
|
+
Format: magic(8) + salt(8) + iv(16) + ciphertext
|
|
46
|
+
"""
|
|
47
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
48
|
+
|
|
49
|
+
salt = os.urandom(8)
|
|
50
|
+
key = _derive_key(password, salt)
|
|
51
|
+
iv = os.urandom(16)
|
|
52
|
+
|
|
53
|
+
cipher = Cipher(algorithms.AES(key), modes.CTR(iv))
|
|
54
|
+
encryptor = cipher.encryptor()
|
|
55
|
+
ciphertext = encryptor.update(data) + encryptor.finalize()
|
|
56
|
+
|
|
57
|
+
return b"Salted__" + salt + iv + ciphertext
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _decrypt(data: bytes, password: str) -> bytes:
|
|
61
|
+
"""Decrypt bytes produced by _encrypt()."""
|
|
62
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
63
|
+
|
|
64
|
+
if not data.startswith(b"Salted__"):
|
|
65
|
+
raise ValueError("Invalid encrypted data: missing 'Salted__' header")
|
|
66
|
+
salt = data[8:16]
|
|
67
|
+
iv = data[16:32]
|
|
68
|
+
ciphertext = data[32:]
|
|
69
|
+
|
|
70
|
+
key = _derive_key(password, salt)
|
|
71
|
+
cipher = Cipher(algorithms.AES(key), modes.CTR(iv))
|
|
72
|
+
decryptor = cipher.decryptor()
|
|
73
|
+
return decryptor.update(ciphertext) + decryptor.finalize()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _compress_and_encrypt(file_path: Path, password: str) -> bytes:
|
|
77
|
+
"""Gzip-compress then AES-256-CTR encrypt a file."""
|
|
78
|
+
raw = file_path.read_bytes()
|
|
79
|
+
compressed = gzip.compress(raw)
|
|
80
|
+
return _encrypt(compressed, password)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _decrypt_and_decompress(data: bytes, password: str) -> bytes:
|
|
84
|
+
"""AES-256-CTR decrypt then gunzip."""
|
|
85
|
+
compressed = _decrypt(data, password)
|
|
86
|
+
return gzip.decompress(compressed)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _encrypt_filename(filename: str, password: str) -> str:
|
|
90
|
+
"""Encrypt a filename string and return base64-encoded ciphertext."""
|
|
91
|
+
encrypted = _encrypt(filename.encode(), password)
|
|
92
|
+
return base64.urlsafe_b64encode(encrypted).decode()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _decrypt_filename(encrypted_b64: str, password: str) -> str:
|
|
96
|
+
"""Decrypt a base64-encoded encrypted filename."""
|
|
97
|
+
encrypted = base64.urlsafe_b64decode(encrypted_b64.encode())
|
|
98
|
+
return _decrypt(encrypted, password).decode()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def generate_password(length: int = 24) -> str:
|
|
102
|
+
"""Generate a secure random password (alphanumeric)."""
|
|
103
|
+
alphabet = string.ascii_letters + string.digits
|
|
104
|
+
return "".join(secrets.choice(alphabet) for _ in range(length))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
# Shared event loop — all ShareServer instances and the StatusServer reuse one
|
|
109
|
+
# daemon thread + asyncio loop to avoid creating multiple threads.
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
_loop: asyncio.AbstractEventLoop | None = None
|
|
113
|
+
_loop_lock = threading.Lock()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _get_loop() -> asyncio.AbstractEventLoop:
|
|
117
|
+
"""Return (and lazily initialise) the shared daemon event loop."""
|
|
118
|
+
global _loop
|
|
119
|
+
with _loop_lock:
|
|
120
|
+
if _loop is None or _loop.is_closed():
|
|
121
|
+
_loop = asyncio.new_event_loop()
|
|
122
|
+
t = threading.Thread(
|
|
123
|
+
target=_loop.run_forever,
|
|
124
|
+
name="susops-async-loop",
|
|
125
|
+
daemon=True,
|
|
126
|
+
)
|
|
127
|
+
t.start()
|
|
128
|
+
return _loop
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class ShareServer:
|
|
132
|
+
"""Async HTTP file share server with AES-256-CTR encryption.
|
|
133
|
+
|
|
134
|
+
The file is encrypted in memory and served via HTTP Basic auth using aiohttp.
|
|
135
|
+
Multiple ShareServer instances share one background event loop thread.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
def __init__(self) -> None:
|
|
139
|
+
self._runner = None
|
|
140
|
+
self._port: int = 0
|
|
141
|
+
self._encrypted_data: bytes = b""
|
|
142
|
+
self._encrypted_filename: str = ""
|
|
143
|
+
self._password: str = ""
|
|
144
|
+
self._access_count: int = 0
|
|
145
|
+
self._failed_count: int = 0
|
|
146
|
+
|
|
147
|
+
def start(
|
|
148
|
+
self,
|
|
149
|
+
file_path: Path,
|
|
150
|
+
password: str,
|
|
151
|
+
port: int = 0,
|
|
152
|
+
workspace: Path | None = None,
|
|
153
|
+
) -> ShareInfo:
|
|
154
|
+
"""Encrypt and start serving the file asynchronously.
|
|
155
|
+
|
|
156
|
+
Returns ShareInfo with the URL and credentials.
|
|
157
|
+
"""
|
|
158
|
+
if self._runner is not None:
|
|
159
|
+
raise RuntimeError("Share server is already running")
|
|
160
|
+
|
|
161
|
+
self._encrypted_data = _compress_and_encrypt(file_path, password)
|
|
162
|
+
self._encrypted_filename = _encrypt_filename(file_path.name, password)
|
|
163
|
+
self._password = password
|
|
164
|
+
|
|
165
|
+
loop = _get_loop()
|
|
166
|
+
future = asyncio.run_coroutine_threadsafe(
|
|
167
|
+
self._start_async(port), loop
|
|
168
|
+
)
|
|
169
|
+
self._port = future.result(timeout=10)
|
|
170
|
+
|
|
171
|
+
return ShareInfo(
|
|
172
|
+
file_path=str(file_path),
|
|
173
|
+
port=self._port,
|
|
174
|
+
password=password,
|
|
175
|
+
url=f"http://localhost:{self._port}",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
async def _start_async(self, port: int) -> int:
|
|
179
|
+
from aiohttp import web
|
|
180
|
+
|
|
181
|
+
async def handle(request: web.Request) -> web.Response:
|
|
182
|
+
auth = request.headers.get("Authorization", "")
|
|
183
|
+
if not auth.startswith("Basic "):
|
|
184
|
+
self._failed_count += 1
|
|
185
|
+
return web.Response(
|
|
186
|
+
status=401,
|
|
187
|
+
headers={"WWW-Authenticate": 'Basic realm="susops share"'},
|
|
188
|
+
)
|
|
189
|
+
try:
|
|
190
|
+
decoded = base64.b64decode(auth[6:]).decode()
|
|
191
|
+
_, _, pw = decoded.partition(":")
|
|
192
|
+
except Exception:
|
|
193
|
+
self._failed_count += 1
|
|
194
|
+
return web.Response(status=401)
|
|
195
|
+
if pw != self._password:
|
|
196
|
+
self._failed_count += 1
|
|
197
|
+
return web.Response(
|
|
198
|
+
status=401,
|
|
199
|
+
headers={"WWW-Authenticate": 'Basic realm="susops share"'},
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
self._access_count += 1
|
|
203
|
+
return web.Response(
|
|
204
|
+
body=self._encrypted_data,
|
|
205
|
+
content_type="application/octet-stream",
|
|
206
|
+
headers={
|
|
207
|
+
"Content-Disposition": f'attachment; filename="{self._encrypted_filename}"',
|
|
208
|
+
"Connection": "close",
|
|
209
|
+
},
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
app = web.Application()
|
|
213
|
+
app.router.add_get("/", handle)
|
|
214
|
+
|
|
215
|
+
runner = web.AppRunner(app)
|
|
216
|
+
await runner.setup()
|
|
217
|
+
site = web.TCPSite(runner, "127.0.0.1", port)
|
|
218
|
+
await site.start()
|
|
219
|
+
self._runner = runner
|
|
220
|
+
# Resolve the actual bound port (important when port=0)
|
|
221
|
+
return site._server.sockets[0].getsockname()[1] # type: ignore[union-attr]
|
|
222
|
+
|
|
223
|
+
def stop(self) -> None:
|
|
224
|
+
"""Stop the share server."""
|
|
225
|
+
if self._runner is not None:
|
|
226
|
+
loop = _get_loop()
|
|
227
|
+
future = asyncio.run_coroutine_threadsafe(
|
|
228
|
+
self._runner.cleanup(), loop
|
|
229
|
+
)
|
|
230
|
+
try:
|
|
231
|
+
future.result(timeout=5)
|
|
232
|
+
except Exception:
|
|
233
|
+
pass
|
|
234
|
+
self._runner = None
|
|
235
|
+
self._port = 0
|
|
236
|
+
|
|
237
|
+
def is_running(self) -> bool:
|
|
238
|
+
return self._runner is not None
|
|
239
|
+
|
|
240
|
+
def get_port(self) -> int:
|
|
241
|
+
return self._port
|
|
242
|
+
|
|
243
|
+
@property
|
|
244
|
+
def access_count(self) -> int:
|
|
245
|
+
return self._access_count
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def failed_count(self) -> int:
|
|
249
|
+
return self._failed_count
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def fetch_file(
|
|
253
|
+
host: str,
|
|
254
|
+
port: int,
|
|
255
|
+
password: str,
|
|
256
|
+
outfile: Path | None = None,
|
|
257
|
+
) -> Path:
|
|
258
|
+
"""Download and decrypt a file from a ShareServer.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
host: Hostname or IP (e.g. "localhost").
|
|
262
|
+
port: Port the share server is listening on.
|
|
263
|
+
password: Decryption password.
|
|
264
|
+
outfile: Where to save the file. Defaults to ~/Downloads/<original_name>.
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
Path to the saved decrypted file.
|
|
268
|
+
"""
|
|
269
|
+
loop = _get_loop()
|
|
270
|
+
future = asyncio.run_coroutine_threadsafe(
|
|
271
|
+
_fetch_async(host, port, password, outfile), loop
|
|
272
|
+
)
|
|
273
|
+
return future.result(timeout=60)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
async def _fetch_async(
|
|
277
|
+
host: str,
|
|
278
|
+
port: int,
|
|
279
|
+
password: str,
|
|
280
|
+
outfile: Path | None,
|
|
281
|
+
) -> Path:
|
|
282
|
+
import aiohttp
|
|
283
|
+
|
|
284
|
+
url = f"http://{host}:{port}"
|
|
285
|
+
credentials = base64.b64encode(f":{password}".encode()).decode()
|
|
286
|
+
|
|
287
|
+
async with aiohttp.ClientSession() as session:
|
|
288
|
+
async with session.get(
|
|
289
|
+
url,
|
|
290
|
+
headers={"Authorization": f"Basic {credentials}"},
|
|
291
|
+
timeout=aiohttp.ClientTimeout(total=30),
|
|
292
|
+
) as resp:
|
|
293
|
+
if resp.status != 200:
|
|
294
|
+
raise RuntimeError(f"Share server returned HTTP {resp.status}")
|
|
295
|
+
|
|
296
|
+
content_disp = resp.headers.get("Content-Disposition", "")
|
|
297
|
+
encrypted_filename = ""
|
|
298
|
+
for part in content_disp.split(";"):
|
|
299
|
+
part = part.strip()
|
|
300
|
+
if part.startswith("filename="):
|
|
301
|
+
encrypted_filename = part[9:].strip('"')
|
|
302
|
+
break
|
|
303
|
+
|
|
304
|
+
encrypted_data = await resp.read()
|
|
305
|
+
|
|
306
|
+
decrypted = _decrypt_and_decompress(encrypted_data, password)
|
|
307
|
+
|
|
308
|
+
if outfile is None:
|
|
309
|
+
if encrypted_filename:
|
|
310
|
+
try:
|
|
311
|
+
original_name = _decrypt_filename(encrypted_filename, password)
|
|
312
|
+
except Exception:
|
|
313
|
+
original_name = f"download_{secrets.token_hex(4)}"
|
|
314
|
+
else:
|
|
315
|
+
original_name = f"download_{secrets.token_hex(4)}"
|
|
316
|
+
|
|
317
|
+
downloads = Path.home() / "Downloads"
|
|
318
|
+
downloads.mkdir(exist_ok=True)
|
|
319
|
+
outfile = downloads / original_name
|
|
320
|
+
|
|
321
|
+
outfile.parent.mkdir(parents=True, exist_ok=True)
|
|
322
|
+
outfile.write_bytes(decrypted)
|
|
323
|
+
return outfile
|