torrus 0.2.2__tar.gz → 0.2.3__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.
- {torrus-0.2.2/src/torrus.egg-info → torrus-0.2.3}/PKG-INFO +1 -1
- {torrus-0.2.2 → torrus-0.2.3}/pyproject.toml +1 -1
- {torrus-0.2.2 → torrus-0.2.3}/src/torrus/__init__.py +1 -1
- {torrus-0.2.2 → torrus-0.2.3}/src/torrus/server.py +44 -7
- {torrus-0.2.2 → torrus-0.2.3}/src/torrus/sftp_manager.py +11 -0
- {torrus-0.2.2 → torrus-0.2.3}/src/torrus/ssh_manager.py +31 -4
- torrus-0.2.3/src/torrus/static/assets/index-CZMqCzPy.css +32 -0
- torrus-0.2.3/src/torrus/static/assets/index-VgassYuV.js +11 -0
- torrus-0.2.2/src/torrus/static/assets/vendor-react-DG_sLUEz.js → torrus-0.2.3/src/torrus/static/assets/vendor-react-BvhOlc_D.js +1 -1
- torrus-0.2.2/src/torrus/static/assets/vendor-ui-Dd7ReRS2.js → torrus-0.2.3/src/torrus/static/assets/vendor-ui-DJBqw5q0.js +49 -34
- {torrus-0.2.2 → torrus-0.2.3}/src/torrus/static/index.html +4 -4
- {torrus-0.2.2 → torrus-0.2.3/src/torrus.egg-info}/PKG-INFO +1 -1
- {torrus-0.2.2 → torrus-0.2.3}/src/torrus.egg-info/SOURCES.txt +4 -4
- {torrus-0.2.2 → torrus-0.2.3}/tests/test_sftp_events.py +53 -3
- {torrus-0.2.2 → torrus-0.2.3}/tests/test_sftp_manager.py +27 -0
- {torrus-0.2.2 → torrus-0.2.3}/tests/test_ssh_manager.py +43 -0
- torrus-0.2.2/src/torrus/static/assets/index-3I8Usu3L.js +0 -11
- torrus-0.2.2/src/torrus/static/assets/index-DvLcuccd.css +0 -32
- {torrus-0.2.2 → torrus-0.2.3}/LICENSE +0 -0
- {torrus-0.2.2 → torrus-0.2.3}/README.md +0 -0
- {torrus-0.2.2 → torrus-0.2.3}/setup.cfg +0 -0
- {torrus-0.2.2 → torrus-0.2.3}/src/torrus/audit_store.py +0 -0
- {torrus-0.2.2 → torrus-0.2.3}/src/torrus/cli.py +0 -0
- {torrus-0.2.2 → torrus-0.2.3}/src/torrus/logging_utils.py +0 -0
- {torrus-0.2.2 → torrus-0.2.3}/src/torrus/static/assets/vendor-socket-QyAzXPig.js +0 -0
- {torrus-0.2.2 → torrus-0.2.3}/src/torrus/static/assets/vendor-xterm-BCk906R6.js +0 -0
- {torrus-0.2.2 → torrus-0.2.3}/src/torrus/static/favicon.svg +0 -0
- {torrus-0.2.2 → torrus-0.2.3}/src/torrus/templates/login.html +0 -0
- {torrus-0.2.2 → torrus-0.2.3}/src/torrus.egg-info/dependency_links.txt +0 -0
- {torrus-0.2.2 → torrus-0.2.3}/src/torrus.egg-info/entry_points.txt +0 -0
- {torrus-0.2.2 → torrus-0.2.3}/src/torrus.egg-info/requires.txt +0 -0
- {torrus-0.2.2 → torrus-0.2.3}/src/torrus.egg-info/top_level.txt +0 -0
- {torrus-0.2.2 → torrus-0.2.3}/tests/test_audit_store.py +0 -0
- {torrus-0.2.2 → torrus-0.2.3}/tests/test_cache_headers.py +0 -0
- {torrus-0.2.2 → torrus-0.2.3}/tests/test_cli.py +0 -0
- {torrus-0.2.2 → torrus-0.2.3}/tests/test_ldap_integration.py +0 -0
- {torrus-0.2.2 → torrus-0.2.3}/tests/test_logging_utils.py +0 -0
- {torrus-0.2.2 → torrus-0.2.3}/tests/test_server.py +0 -0
|
@@ -35,6 +35,11 @@ else:
|
|
|
35
35
|
|
|
36
36
|
_SAFE_ID = re.compile(r'^[a-zA-Z0-9_\-]+$')
|
|
37
37
|
_DEV_MODE = bool(os.getenv("TORRUS_DEV"))
|
|
38
|
+
_DEV_ORIGINS = [
|
|
39
|
+
f"http://{host}:{port}"
|
|
40
|
+
for host in ("localhost", "127.0.0.1")
|
|
41
|
+
for port in range(5173, 5184)
|
|
42
|
+
] if _DEV_MODE else []
|
|
38
43
|
_ALLOW_PRIVATE_HOSTS_WITHOUT_LDAP = os.getenv(
|
|
39
44
|
"TORRUS_ALLOW_PRIVATE_HOSTS_WITHOUT_LDAP", "true"
|
|
40
45
|
).lower() in {"1", "true", "yes", "on"}
|
|
@@ -88,11 +93,7 @@ def _static_dir() -> Path | None:
|
|
|
88
93
|
|
|
89
94
|
sio = socketio.AsyncServer(
|
|
90
95
|
async_mode="asgi",
|
|
91
|
-
cors_allowed_origins=
|
|
92
|
-
["http://localhost:5173", "http://127.0.0.1:5173"]
|
|
93
|
-
if _DEV_MODE
|
|
94
|
-
else []
|
|
95
|
-
),
|
|
96
|
+
cors_allowed_origins=_DEV_ORIGINS,
|
|
96
97
|
max_http_buffer_size=10_000_000,
|
|
97
98
|
ping_timeout=60,
|
|
98
99
|
ping_interval=25,
|
|
@@ -126,7 +127,7 @@ async def add_app_security_headers(request: Request, call_next):
|
|
|
126
127
|
if _DEV_MODE:
|
|
127
128
|
fastapi_app.add_middleware(
|
|
128
129
|
CORSMiddleware,
|
|
129
|
-
allow_origins=
|
|
130
|
+
allow_origins=_DEV_ORIGINS,
|
|
130
131
|
allow_credentials=True,
|
|
131
132
|
allow_methods=["*"],
|
|
132
133
|
allow_headers=["*"],
|
|
@@ -802,7 +803,16 @@ async def on_sftp_open(sid, data):
|
|
|
802
803
|
except Exception:
|
|
803
804
|
logger.warning("Could not determine SFTP username for %s/%s", session_id, source_tab_id, exc_info=True)
|
|
804
805
|
username = None
|
|
805
|
-
|
|
806
|
+
try:
|
|
807
|
+
is_root = await ssh_manager.is_root_session(session_id, source_tab_id)
|
|
808
|
+
except Exception:
|
|
809
|
+
logger.warning("Could not determine SFTP root status for %s/%s", session_id, source_tab_id, exc_info=True)
|
|
810
|
+
is_root = False
|
|
811
|
+
await sio.emit(
|
|
812
|
+
"sftp:open:result",
|
|
813
|
+
{"tab_id": tab_id, "username": username, "is_root": is_root, **result},
|
|
814
|
+
to=sid,
|
|
815
|
+
)
|
|
806
816
|
|
|
807
817
|
|
|
808
818
|
@sio.on("sftp:list")
|
|
@@ -901,3 +911,30 @@ async def on_sftp_mkdir(sid, data):
|
|
|
901
911
|
await sio.emit("sftp:mkdir:result", {"tab_id": tab_id, **result}, to=sid)
|
|
902
912
|
except SFTPError as exc:
|
|
903
913
|
await _emit_sftp_error(sid, tab_id, exc)
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
@sio.on("sftp:chmod")
|
|
917
|
+
async def on_sftp_chmod(sid, data):
|
|
918
|
+
session_id, tab_id = _sftp_request_ids(data)
|
|
919
|
+
if not _valid_id(session_id) or not _valid_id(tab_id):
|
|
920
|
+
return
|
|
921
|
+
if not await _require_auth(sid, tab_id):
|
|
922
|
+
return
|
|
923
|
+
mode = data.get("mode")
|
|
924
|
+
if isinstance(mode, bool) or not isinstance(mode, int) or not 0 <= mode <= 0o7777:
|
|
925
|
+
await sio.emit(
|
|
926
|
+
"sftp:chmod:result",
|
|
927
|
+
{"tab_id": tab_id, "ok": False, "code": "invalid_request", "message": "Invalid permission mode."},
|
|
928
|
+
to=sid,
|
|
929
|
+
)
|
|
930
|
+
return
|
|
931
|
+
try:
|
|
932
|
+
result = await sftp_manager.chmod(tab_id, data.get("path", ""), mode)
|
|
933
|
+
except SFTPError as exc:
|
|
934
|
+
await sio.emit(
|
|
935
|
+
"sftp:chmod:result",
|
|
936
|
+
{"tab_id": tab_id, "ok": False, "code": exc.code, "message": exc.message},
|
|
937
|
+
to=sid,
|
|
938
|
+
)
|
|
939
|
+
return
|
|
940
|
+
await sio.emit("sftp:chmod:result", {"tab_id": tab_id, **result}, to=sid)
|
|
@@ -134,6 +134,9 @@ class SFTPManager:
|
|
|
134
134
|
async def mkdir(self, tab_id: str, path: str) -> dict[str, Any]:
|
|
135
135
|
return await self._locked(tab_id, lambda session: self._mkdir_sync(session, path))
|
|
136
136
|
|
|
137
|
+
async def chmod(self, tab_id: str, path: str, mode: int) -> dict[str, Any]:
|
|
138
|
+
return await self._locked(tab_id, lambda session: self._chmod_sync(session, path, mode))
|
|
139
|
+
|
|
137
140
|
async def stream_upload(
|
|
138
141
|
self,
|
|
139
142
|
tab_id: str,
|
|
@@ -382,6 +385,14 @@ class SFTPManager:
|
|
|
382
385
|
except Exception as exc:
|
|
383
386
|
raise _map_error(exc, old_resolved) from exc
|
|
384
387
|
|
|
388
|
+
def _chmod_sync(self, session: SFTPSession, path: str, mode: int) -> dict[str, Any]:
|
|
389
|
+
resolved = _resolve_remote_path(session.cwd, path, session.home)
|
|
390
|
+
try:
|
|
391
|
+
session.client.chmod(resolved, mode)
|
|
392
|
+
return {"ok": True, "path": resolved, "mode": mode}
|
|
393
|
+
except Exception as exc:
|
|
394
|
+
raise _map_error(exc, resolved) from exc
|
|
395
|
+
|
|
385
396
|
def _mkdir_sync(self, session: SFTPSession, path: str) -> dict[str, Any]:
|
|
386
397
|
resolved = _resolve_remote_path(session.cwd, path, session.home)
|
|
387
398
|
try:
|
|
@@ -67,6 +67,8 @@ class SSHSession:
|
|
|
67
67
|
write_task: Optional[asyncio.Task] = None
|
|
68
68
|
input_queue: asyncio.Queue[bytes] = field(default_factory=lambda: asyncio.Queue(maxsize=65536))
|
|
69
69
|
output_buffer: bytearray = field(default_factory=bytearray)
|
|
70
|
+
# Probed at connection time; SFTP identity is scoped to the SSH transport.
|
|
71
|
+
is_root: bool = False
|
|
70
72
|
# Recomputed during teardown: close the client only when no remaining
|
|
71
73
|
# session still shares its transport.
|
|
72
74
|
owns_client: bool = True
|
|
@@ -158,7 +160,7 @@ class SSHManager:
|
|
|
158
160
|
logger.info("Connecting to %s (session=%s, tab=%s)", target, session_id, tab_id)
|
|
159
161
|
|
|
160
162
|
try:
|
|
161
|
-
has_tmux = await loop.run_in_executor(
|
|
163
|
+
has_tmux, is_root = await loop.run_in_executor(
|
|
162
164
|
self._ssh_executor,
|
|
163
165
|
lambda: _connect_and_check_tmux(client, host, port, username, password),
|
|
164
166
|
)
|
|
@@ -229,6 +231,7 @@ class SSHManager:
|
|
|
229
231
|
host=host,
|
|
230
232
|
port=port,
|
|
231
233
|
username=username,
|
|
234
|
+
is_root=is_root,
|
|
232
235
|
cols=cols,
|
|
233
236
|
rows=rows,
|
|
234
237
|
)
|
|
@@ -324,6 +327,14 @@ class SSHManager:
|
|
|
324
327
|
return None
|
|
325
328
|
return session.host, session.port, session.username
|
|
326
329
|
|
|
330
|
+
async def is_root_session(self, session_id: str, tab_id: str) -> bool:
|
|
331
|
+
"""Return whether the active SSH session has an effective UID of zero."""
|
|
332
|
+
async with self._lock:
|
|
333
|
+
session = self._sessions.get((session_id, tab_id))
|
|
334
|
+
if session is None or session.channel.closed:
|
|
335
|
+
return False
|
|
336
|
+
return session.is_root
|
|
337
|
+
|
|
327
338
|
async def has_session(self, session_id: str) -> bool:
|
|
328
339
|
"""Return True when any active tab exists for this browser session."""
|
|
329
340
|
async with self._lock:
|
|
@@ -426,6 +437,7 @@ class SSHManager:
|
|
|
426
437
|
host=source.host,
|
|
427
438
|
port=source.port,
|
|
428
439
|
username=source.username,
|
|
440
|
+
is_root=source.is_root,
|
|
429
441
|
cols=cols,
|
|
430
442
|
rows=rows,
|
|
431
443
|
owns_client=False, # shared transport — do not close on destroy
|
|
@@ -636,7 +648,7 @@ def _connect_and_check_tmux(
|
|
|
636
648
|
port: int,
|
|
637
649
|
username: str,
|
|
638
650
|
password: str,
|
|
639
|
-
) -> bool:
|
|
651
|
+
) -> tuple[bool, bool]:
|
|
640
652
|
client.connect(
|
|
641
653
|
hostname=host,
|
|
642
654
|
port=port,
|
|
@@ -649,9 +661,9 @@ def _connect_and_check_tmux(
|
|
|
649
661
|
allow_agent=False,
|
|
650
662
|
)
|
|
651
663
|
try:
|
|
652
|
-
return
|
|
664
|
+
return _connection_probes_blocking(client)
|
|
653
665
|
except Exception:
|
|
654
|
-
return False
|
|
666
|
+
return False, False
|
|
655
667
|
|
|
656
668
|
|
|
657
669
|
def _check_tmux_blocking(client: paramiko.SSHClient) -> bool:
|
|
@@ -666,6 +678,21 @@ def _check_tmux_blocking(client: paramiko.SSHClient) -> bool:
|
|
|
666
678
|
channel.close()
|
|
667
679
|
|
|
668
680
|
|
|
681
|
+
def _connection_probes_blocking(client: paramiko.SSHClient) -> tuple[bool, bool]:
|
|
682
|
+
command = "if command -v tmux >/dev/null 2>&1; then echo 1; else echo 0; fi; id -u"
|
|
683
|
+
stdin, stdout, stderr = client.exec_command(command, timeout=5)
|
|
684
|
+
try:
|
|
685
|
+
lines = stdout.read().decode(errors="replace").splitlines()
|
|
686
|
+
if len(lines) != 2:
|
|
687
|
+
return False, False
|
|
688
|
+
return lines[0].strip() == "1", lines[1].strip() == "0"
|
|
689
|
+
finally:
|
|
690
|
+
for stream in (stdin, stdout, stderr):
|
|
691
|
+
channel = getattr(stream, "channel", None)
|
|
692
|
+
if channel is not None:
|
|
693
|
+
channel.close()
|
|
694
|
+
|
|
695
|
+
|
|
669
696
|
def _sanitize_replay_buffer(buf: bytes) -> bytes:
|
|
670
697
|
"""Strip orphaned alternate-screen content from a truncated replay buffer.
|
|
671
698
|
|