torrus 0.1.0__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.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anudeep Dhavaleswarapu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
torrus-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: torrus
3
+ Version: 0.1.0
4
+ Summary: Web-based SSH terminal that works behind any reverse proxy
5
+ Author-email: Anudeep Dhavaleswarapu <anudeepd2@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/anudeepd/torrus
8
+ Project-URL: Repository, https://github.com/anudeepd/torrus
9
+ Project-URL: Issues, https://github.com/anudeepd/torrus/issues
10
+ Keywords: ssh,terminal,web,xterm,remote
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Web Environment
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: System Administrators
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: System :: Networking
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: fastapi>=0.110.0
24
+ Requires-Dist: python-socketio>=5.11.0
25
+ Requires-Dist: uvicorn[standard]>=0.29.0
26
+ Requires-Dist: paramiko>=3.4.0
27
+ Requires-Dist: aiofiles>=23.2.0
28
+ Requires-Dist: click>=8.1.0
29
+ Dynamic: license-file
30
+
31
+ <p align="center">
32
+ <img src="https://raw.githubusercontent.com/anudeepd/torrus/main/assets/logo.svg" alt="Torrus" width="120"/>
33
+ </p>
34
+
35
+ <h1 align="center">Torrus</h1>
36
+
37
+ <p align="center">A web-based SSH terminal that works behind any reverse proxy. Install it, run it, use it.</p>
38
+
39
+ ## Features
40
+
41
+ - **Web-based SSH terminal** with full xterm.js emulation
42
+ - **Multi-tab support** — open multiple SSH sessions side by side
43
+ - **Saved servers** — save, edit, import, and export connection configs
44
+ - **Works behind reverse proxies** — uses Socket.IO for reliable transport
45
+ - **Session sidebar** — quick-connect to saved servers
46
+
47
+ ## Install
48
+
49
+ ```bash
50
+ pip install torrus
51
+ ```
52
+
53
+ ## Usage
54
+
55
+ ```bash
56
+ torrus serve
57
+ ```
58
+
59
+ Opens the terminal in your browser. Connect to any SSH server from there.
60
+
61
+ Options:
62
+
63
+ ```
64
+ --host TEXT Bind host. [default: 0.0.0.0]
65
+ --port INTEGER Bind port. [default: 8080]
66
+ --no-browser Don't open the browser automatically.
67
+ ```
68
+
69
+ ## Development
70
+
71
+ Requires [uv](https://github.com/astral-sh/uv).
72
+
73
+ ```bash
74
+ git clone https://github.com/anudeepd/torrus
75
+ cd torrus
76
+ uv sync
77
+ make dev
78
+ ```
79
+
80
+ ## License
81
+
82
+ MIT
torrus-0.1.0/README.md ADDED
@@ -0,0 +1,52 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/anudeepd/torrus/main/assets/logo.svg" alt="Torrus" width="120"/>
3
+ </p>
4
+
5
+ <h1 align="center">Torrus</h1>
6
+
7
+ <p align="center">A web-based SSH terminal that works behind any reverse proxy. Install it, run it, use it.</p>
8
+
9
+ ## Features
10
+
11
+ - **Web-based SSH terminal** with full xterm.js emulation
12
+ - **Multi-tab support** — open multiple SSH sessions side by side
13
+ - **Saved servers** — save, edit, import, and export connection configs
14
+ - **Works behind reverse proxies** — uses Socket.IO for reliable transport
15
+ - **Session sidebar** — quick-connect to saved servers
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pip install torrus
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ```bash
26
+ torrus serve
27
+ ```
28
+
29
+ Opens the terminal in your browser. Connect to any SSH server from there.
30
+
31
+ Options:
32
+
33
+ ```
34
+ --host TEXT Bind host. [default: 0.0.0.0]
35
+ --port INTEGER Bind port. [default: 8080]
36
+ --no-browser Don't open the browser automatically.
37
+ ```
38
+
39
+ ## Development
40
+
41
+ Requires [uv](https://github.com/astral-sh/uv).
42
+
43
+ ```bash
44
+ git clone https://github.com/anudeepd/torrus
45
+ cd torrus
46
+ uv sync
47
+ make dev
48
+ ```
49
+
50
+ ## License
51
+
52
+ MIT
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "torrus"
7
+ version = "0.1.0"
8
+ description = "Web-based SSH terminal that works behind any reverse proxy"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [{ name = "Anudeep Dhavaleswarapu", email = "anudeepd2@gmail.com" }]
13
+ keywords = ["ssh", "terminal", "web", "xterm", "remote"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Web Environment",
17
+ "Intended Audience :: Developers",
18
+ "Intended Audience :: System Administrators",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Topic :: System :: Networking",
24
+ ]
25
+ dependencies = [
26
+ "fastapi>=0.110.0",
27
+ "python-socketio>=5.11.0",
28
+ "uvicorn[standard]>=0.29.0",
29
+ "paramiko>=3.4.0",
30
+ "aiofiles>=23.2.0",
31
+ "click>=8.1.0",
32
+ ]
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/anudeepd/torrus"
36
+ Repository = "https://github.com/anudeepd/torrus"
37
+ Issues = "https://github.com/anudeepd/torrus/issues"
38
+
39
+ [project.scripts]
40
+ torrus = "torrus.cli:main"
41
+
42
+ [tool.setuptools.packages.find]
43
+ where = ["src"]
44
+
45
+ [tool.setuptools.package-data]
46
+ torrus = ["static/**/*", "static/*"]
torrus-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """torrus — web-based SSH terminal that works behind any reverse proxy."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,40 @@
1
+ import logging
2
+ import threading
3
+ import webbrowser
4
+
5
+ import click
6
+ import uvicorn
7
+
8
+
9
+ @click.group()
10
+ def main():
11
+ """torrus — web-based SSH terminal."""
12
+ pass
13
+
14
+
15
+ @main.command()
16
+ @click.option("--host", default="0.0.0.0", show_default=True, help="Bind host")
17
+ @click.option("--port", default=8080, show_default=True, help="Bind port")
18
+ @click.option("--no-browser", is_flag=True, default=False, help="Don't open browser on startup")
19
+ @click.option("--reload", is_flag=True, default=False, hidden=True, help="Dev auto-reload")
20
+ def serve(host, port, no_browser, reload):
21
+ """Start the torrus SSH web terminal."""
22
+ logging.basicConfig(
23
+ level=logging.INFO,
24
+ format="%(asctime)s %(levelname)-8s %(name)s %(message)s",
25
+ datefmt="%H:%M:%S",
26
+ )
27
+
28
+ if not no_browser:
29
+ browse_host = "127.0.0.1" if host == "0.0.0.0" else host
30
+ url = f"http://{browse_host}:{port}"
31
+ # Open after a short delay so uvicorn has time to bind
32
+ threading.Timer(1.5, webbrowser.open, args=[url]).start()
33
+
34
+ uvicorn.run(
35
+ "torrus.server:app",
36
+ host=host,
37
+ port=port,
38
+ reload=reload,
39
+ log_level="info",
40
+ )
@@ -0,0 +1,249 @@
1
+ """FastAPI + Socket.IO ASGI application."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import re
8
+ from contextlib import asynccontextmanager
9
+ from importlib.resources import files
10
+ from pathlib import Path
11
+
12
+ import socketio
13
+ from fastapi import FastAPI
14
+ from fastapi.middleware.cors import CORSMiddleware
15
+ from fastapi.responses import FileResponse, JSONResponse
16
+ from fastapi.staticfiles import StaticFiles
17
+
18
+ from torrus.ssh_manager import SSHManager
19
+
20
+ logger = logging.getLogger("torrus.server")
21
+
22
+ _SAFE_ID = re.compile(r'^[a-zA-Z0-9_\-]+$')
23
+ _DEV_MODE = bool(os.getenv("TORRUS_DEV"))
24
+
25
+
26
+ def _safe_int(value, default: int) -> int:
27
+ """Parse an integer from untrusted input, returning default on failure."""
28
+ try:
29
+ return int(value)
30
+ except (TypeError, ValueError):
31
+ return default
32
+
33
+
34
+ def _valid_id(value: str) -> bool:
35
+ """Check that an ID contains only safe characters."""
36
+ return bool(value and _SAFE_ID.match(value))
37
+
38
+
39
+ def _static_dir() -> Path | None:
40
+ try:
41
+ p = Path(str(files("torrus").joinpath("static")))
42
+ return p if p.exists() else None
43
+ except Exception:
44
+ return None
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Socket.IO + FastAPI setup
49
+ # ---------------------------------------------------------------------------
50
+
51
+ sio = socketio.AsyncServer(
52
+ async_mode="asgi",
53
+ cors_allowed_origins=(
54
+ ["http://localhost:5173", "http://127.0.0.1:5173"]
55
+ if _DEV_MODE
56
+ else []
57
+ ),
58
+ max_http_buffer_size=10_000_000,
59
+ ping_timeout=60,
60
+ ping_interval=25,
61
+ logger=False,
62
+ engineio_logger=False,
63
+ )
64
+
65
+ fastapi_app = FastAPI(title="torrus", docs_url=None, redoc_url=None)
66
+
67
+ # CORS for Vite dev server
68
+ if _DEV_MODE:
69
+ fastapi_app.add_middleware(
70
+ CORSMiddleware,
71
+ allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
72
+ allow_credentials=True,
73
+ allow_methods=["*"],
74
+ allow_headers=["*"],
75
+ )
76
+
77
+ # Combined ASGI app — uvicorn runs this
78
+ app = socketio.ASGIApp(sio, other_asgi_app=fastapi_app)
79
+
80
+ ssh_manager = SSHManager(sio)
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # Static files + SPA fallback (only when built frontend exists)
85
+ # ---------------------------------------------------------------------------
86
+
87
+ _static = _static_dir()
88
+
89
+ if _static:
90
+ # Serve /assets/ from Vite build output
91
+ assets_dir = _static / "assets"
92
+ if assets_dir.exists():
93
+ fastapi_app.mount("/assets", StaticFiles(directory=str(assets_dir)), name="assets")
94
+
95
+ # Serve /fonts/ from Python static dir (JetBrains Mono)
96
+ fonts_dir = _static / "fonts"
97
+ if fonts_dir.exists():
98
+ fastapi_app.mount("/fonts", StaticFiles(directory=str(fonts_dir)), name="fonts")
99
+
100
+
101
+ @fastapi_app.get("/{full_path:path}", include_in_schema=False)
102
+ async def spa_fallback(full_path: str):
103
+ if full_path.startswith("socket.io"):
104
+ return JSONResponse(status_code=404, content={"detail": "Not found"})
105
+ if _static:
106
+ index = _static / "index.html"
107
+ if index.exists():
108
+ return FileResponse(str(index))
109
+ return JSONResponse(
110
+ status_code=503,
111
+ content={"detail": "Frontend not built. Run: cd frontend && npm run build"},
112
+ )
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # Socket.IO lifecycle
117
+ # ---------------------------------------------------------------------------
118
+
119
+ @sio.on("connect")
120
+ async def on_connect(sid, environ):
121
+ remote = environ.get("REMOTE_ADDR", "unknown")
122
+ logger.info("Client connected: %s (from %s)", sid, remote)
123
+
124
+
125
+ @sio.on("disconnect")
126
+ async def on_disconnect(sid):
127
+ ssh_manager.unmap_sid(sid)
128
+ logger.info("Client disconnected: %s", sid)
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # Session registration / recovery
133
+ # ---------------------------------------------------------------------------
134
+
135
+ @sio.on("session:register")
136
+ async def on_session_register(sid, data):
137
+ session_id = data.get("session_id", "")
138
+ tab_id = data.get("tab_id", "")
139
+ if not _valid_id(session_id) or not _valid_id(tab_id):
140
+ return
141
+
142
+ status = await ssh_manager.restore_session(sid, session_id, tab_id)
143
+ await sio.emit("session:restored", {"tab_id": tab_id, "status": status}, to=sid)
144
+ # Force shell redraw AFTER session:restored so the frontend clears the
145
+ # viewport first, then the fresh prompt from SIGWINCH overwrites it.
146
+ if status == "active":
147
+ await ssh_manager.force_redraw(session_id, tab_id)
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # SSH events
152
+ # ---------------------------------------------------------------------------
153
+
154
+ @sio.on("ssh:connect")
155
+ async def on_ssh_connect(sid, data):
156
+ host = data.get("host", "").strip()
157
+ port = _safe_int(data.get("port", 22), 22)
158
+ username = data.get("username", "").strip()
159
+ password = data.get("password", "")
160
+ session_id = data.get("session_id", "")
161
+ tab_id = data.get("tab_id", "")
162
+ cols = _safe_int(data.get("cols", 220), 220)
163
+ rows = _safe_int(data.get("rows", 50), 50)
164
+
165
+ if not host or not username or not _valid_id(session_id) or not _valid_id(tab_id):
166
+ await sio.emit(
167
+ "ssh:error",
168
+ {"tab_id": tab_id, "message": "Missing required fields.", "code": "invalid_request"},
169
+ to=sid,
170
+ )
171
+ return
172
+
173
+ await ssh_manager.connect(
174
+ sid=sid,
175
+ session_id=session_id,
176
+ tab_id=tab_id,
177
+ host=host,
178
+ port=port,
179
+ username=username,
180
+ password=password,
181
+ cols=cols,
182
+ rows=rows,
183
+ )
184
+
185
+
186
+ @sio.on("ssh:input")
187
+ async def on_ssh_input(sid, data):
188
+ await ssh_manager.handle_input(
189
+ data.get("session_id", ""),
190
+ data.get("tab_id", ""),
191
+ data.get("data", ""),
192
+ )
193
+
194
+
195
+ @sio.on("terminal:resize")
196
+ async def on_terminal_resize(sid, data):
197
+ await ssh_manager.handle_resize(
198
+ data.get("session_id", ""),
199
+ data.get("tab_id", ""),
200
+ _safe_int(data.get("cols", 80), 80),
201
+ _safe_int(data.get("rows", 24), 24),
202
+ )
203
+
204
+
205
+ @sio.on("ssh:disconnect")
206
+ async def on_ssh_disconnect(sid, data):
207
+ await ssh_manager.disconnect_session(
208
+ data.get("session_id", ""),
209
+ data.get("tab_id", ""),
210
+ )
211
+
212
+
213
+ @sio.on("ssh:clone")
214
+ async def on_ssh_clone(sid, data):
215
+ session_id = data.get("session_id", "")
216
+ source_tab_id = data.get("source_tab_id", "")
217
+ new_tab_id = data.get("new_tab_id", "")
218
+ cols = _safe_int(data.get("cols", 220), 220)
219
+ rows = _safe_int(data.get("rows", 50), 50)
220
+
221
+ if not _valid_id(session_id) or not _valid_id(source_tab_id) or not _valid_id(new_tab_id):
222
+ await sio.emit(
223
+ "ssh:error",
224
+ {"tab_id": new_tab_id, "message": "Missing required fields.", "code": "invalid_request"},
225
+ to=sid,
226
+ )
227
+ return
228
+
229
+ await ssh_manager.clone(
230
+ sid=sid,
231
+ session_id=session_id,
232
+ source_tab_id=source_tab_id,
233
+ new_tab_id=new_tab_id,
234
+ cols=cols,
235
+ rows=rows,
236
+ )
237
+
238
+
239
+ # ---------------------------------------------------------------------------
240
+ # Startup
241
+ # ---------------------------------------------------------------------------
242
+
243
+ @asynccontextmanager
244
+ async def lifespan(app):
245
+ ssh_manager.start_background_tasks()
246
+ yield
247
+
248
+
249
+ fastapi_app.router.lifespan_context = lifespan