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.
Files changed (38) hide show
  1. {torrus-0.2.2/src/torrus.egg-info → torrus-0.2.3}/PKG-INFO +1 -1
  2. {torrus-0.2.2 → torrus-0.2.3}/pyproject.toml +1 -1
  3. {torrus-0.2.2 → torrus-0.2.3}/src/torrus/__init__.py +1 -1
  4. {torrus-0.2.2 → torrus-0.2.3}/src/torrus/server.py +44 -7
  5. {torrus-0.2.2 → torrus-0.2.3}/src/torrus/sftp_manager.py +11 -0
  6. {torrus-0.2.2 → torrus-0.2.3}/src/torrus/ssh_manager.py +31 -4
  7. torrus-0.2.3/src/torrus/static/assets/index-CZMqCzPy.css +32 -0
  8. torrus-0.2.3/src/torrus/static/assets/index-VgassYuV.js +11 -0
  9. 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
  10. 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
  11. {torrus-0.2.2 → torrus-0.2.3}/src/torrus/static/index.html +4 -4
  12. {torrus-0.2.2 → torrus-0.2.3/src/torrus.egg-info}/PKG-INFO +1 -1
  13. {torrus-0.2.2 → torrus-0.2.3}/src/torrus.egg-info/SOURCES.txt +4 -4
  14. {torrus-0.2.2 → torrus-0.2.3}/tests/test_sftp_events.py +53 -3
  15. {torrus-0.2.2 → torrus-0.2.3}/tests/test_sftp_manager.py +27 -0
  16. {torrus-0.2.2 → torrus-0.2.3}/tests/test_ssh_manager.py +43 -0
  17. torrus-0.2.2/src/torrus/static/assets/index-3I8Usu3L.js +0 -11
  18. torrus-0.2.2/src/torrus/static/assets/index-DvLcuccd.css +0 -32
  19. {torrus-0.2.2 → torrus-0.2.3}/LICENSE +0 -0
  20. {torrus-0.2.2 → torrus-0.2.3}/README.md +0 -0
  21. {torrus-0.2.2 → torrus-0.2.3}/setup.cfg +0 -0
  22. {torrus-0.2.2 → torrus-0.2.3}/src/torrus/audit_store.py +0 -0
  23. {torrus-0.2.2 → torrus-0.2.3}/src/torrus/cli.py +0 -0
  24. {torrus-0.2.2 → torrus-0.2.3}/src/torrus/logging_utils.py +0 -0
  25. {torrus-0.2.2 → torrus-0.2.3}/src/torrus/static/assets/vendor-socket-QyAzXPig.js +0 -0
  26. {torrus-0.2.2 → torrus-0.2.3}/src/torrus/static/assets/vendor-xterm-BCk906R6.js +0 -0
  27. {torrus-0.2.2 → torrus-0.2.3}/src/torrus/static/favicon.svg +0 -0
  28. {torrus-0.2.2 → torrus-0.2.3}/src/torrus/templates/login.html +0 -0
  29. {torrus-0.2.2 → torrus-0.2.3}/src/torrus.egg-info/dependency_links.txt +0 -0
  30. {torrus-0.2.2 → torrus-0.2.3}/src/torrus.egg-info/entry_points.txt +0 -0
  31. {torrus-0.2.2 → torrus-0.2.3}/src/torrus.egg-info/requires.txt +0 -0
  32. {torrus-0.2.2 → torrus-0.2.3}/src/torrus.egg-info/top_level.txt +0 -0
  33. {torrus-0.2.2 → torrus-0.2.3}/tests/test_audit_store.py +0 -0
  34. {torrus-0.2.2 → torrus-0.2.3}/tests/test_cache_headers.py +0 -0
  35. {torrus-0.2.2 → torrus-0.2.3}/tests/test_cli.py +0 -0
  36. {torrus-0.2.2 → torrus-0.2.3}/tests/test_ldap_integration.py +0 -0
  37. {torrus-0.2.2 → torrus-0.2.3}/tests/test_logging_utils.py +0 -0
  38. {torrus-0.2.2 → torrus-0.2.3}/tests/test_server.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: torrus
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Web-based SSH terminal that works behind any reverse proxy
5
5
  Author-email: Anudeep Dhavaleswarapu <anudeepd2@gmail.com>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "torrus"
7
- version = "0.2.2"
7
+ version = "0.2.3"
8
8
  description = "Web-based SSH terminal that works behind any reverse proxy"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,3 +1,3 @@
1
1
  """torrus — web-based SSH terminal that works behind any reverse proxy."""
2
2
 
3
- __version__ = "0.2.2"
3
+ __version__ = "0.2.3"
@@ -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=["http://localhost:5173", "http://127.0.0.1:5173"],
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
- await sio.emit("sftp:open:result", {"tab_id": tab_id, "username": username, **result}, to=sid)
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 _check_tmux_blocking(client)
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