sshpeek 0.1.0__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.
sshpeek/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """sshpeek: local web UI for peeking at remote files and ports over SSH."""
2
+
3
+ __version__ = "0.1.0"
sshpeek/__main__.py ADDED
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import logging
5
+
6
+ import uvicorn
7
+
8
+
9
+ def main() -> None:
10
+ from .config import load_config
11
+
12
+ cfg = load_config()
13
+ ap = argparse.ArgumentParser(
14
+ prog="sshpeek",
15
+ description="Local web UI for peeking at remote files and ports over SSH.",
16
+ )
17
+ ap.add_argument("--host", default=cfg.listen_host,
18
+ help=f"bind address (default: {cfg.listen_host})")
19
+ ap.add_argument("--port", type=int, default=cfg.listen_port,
20
+ help=f"bind port (default: {cfg.listen_port})")
21
+ ap.add_argument("--config", help="path to sshpeek.yaml (also: $SSHPEEK_CONFIG)")
22
+ ap.add_argument("-v", "--verbose", action="store_true", help="debug logging")
23
+ args = ap.parse_args()
24
+
25
+ if args.config:
26
+ import os
27
+ os.environ["SSHPEEK_CONFIG"] = args.config
28
+
29
+ logging.basicConfig(
30
+ level=logging.DEBUG if args.verbose else logging.INFO,
31
+ format="%(asctime)s %(name)s %(levelname)s %(message)s",
32
+ )
33
+ print(f"sshpeek: http://{args.host}:{args.port}")
34
+ uvicorn.run("sshpeek.app:app", host=args.host, port=args.port, log_level="warning")
35
+
36
+
37
+ if __name__ == "__main__":
38
+ main()
sshpeek/app.py ADDED
@@ -0,0 +1,400 @@
1
+ """HTTP API + static UI.
2
+
3
+ Endpoints
4
+ ---------
5
+ GET / UI: host picker, file browser, tunnels, services
6
+ GET /view?host=&path= UI: live PDF/image viewer (auto-refresh on change)
7
+ GET /api/hosts configured + connected hosts and s3 sources
8
+ GET /api/services declared HTTP services with their proxy URLs
9
+ GET /api/ls?host=&path= directory listing (path defaults to remote home)
10
+ GET /api/file?host=&path= stream file bytes (add &dl=1 to force download)
11
+ GET /api/events?host=&path= SSE: emits when the file's (mtime, size) changes
12
+ and the size has been stable for one poll interval
13
+ GET /api/views open live views (one per /api/events stream)
14
+ DELETE /api/views/{id} detach a live view (its tab closes itself)
15
+ GET /api/forwards list port forwards
16
+ POST /api/forwards {"host": ..., "port": ..., "remote_host": "localhost"}
17
+ DELETE /api/forwards/{id} drop a forward
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import contextlib
24
+ import itertools
25
+ import json
26
+ import logging
27
+ import mimetypes
28
+ import stat as statmod
29
+ import time
30
+ from pathlib import Path, PurePosixPath
31
+
32
+ import asyncssh
33
+ from starlette.applications import Starlette
34
+ from starlette.requests import Request
35
+ from starlette.responses import FileResponse, JSONResponse, Response, StreamingResponse
36
+ from starlette.routing import Route
37
+
38
+ from .config import load_config
39
+ from .pool import SSHPool
40
+ from .proxy import ProxyRouter, service_fid
41
+ from .s3 import S3Error, S3Source
42
+
43
+ log = logging.getLogger("sshpeek.app")
44
+
45
+ pool = SSHPool()
46
+ CONFIG = load_config()
47
+ S3 = {name: S3Source(spec) for name, spec in CONFIG.s3.items()}
48
+ STATIC = Path(__file__).parent / "static"
49
+
50
+ CHUNK = 256 * 1024
51
+
52
+ # Live viewer connections: one entry per open /api/events stream.
53
+ WATCHERS: dict[int, dict] = {}
54
+ _watcher_seq = itertools.count(1)
55
+
56
+ # Extensions we serve as text/plain when mimetypes has no (browser-friendly)
57
+ # answer, so "peeking" at them renders in the browser instead of downloading.
58
+ TEXT_EXT = {
59
+ ".log", ".out", ".err", ".toml", ".nix", ".yml", ".yaml", ".jl", ".tex",
60
+ ".bib", ".sty", ".cls", ".sh", ".zsh", ".conf", ".ini", ".service",
61
+ ".gitignore", ".sbatch", ".slurm", ".env", ".lock", ".sql", ".r", ".R",
62
+ }
63
+
64
+
65
+ def err(e: object, code: int = 502) -> JSONResponse:
66
+ return JSONResponse({"error": str(e)}, status_code=code)
67
+
68
+
69
+ def guess_type(path: str, dl: bool) -> str:
70
+ name = PurePosixPath(path).name
71
+ suffix = PurePosixPath(path).suffix.lower()
72
+ ctype = mimetypes.guess_type(name)[0]
73
+ # When peeking (not downloading), render known text-ish extensions and
74
+ # extensionless files (README, Makefile, ...) inline in the browser.
75
+ if not dl and (suffix in TEXT_EXT or (ctype is None and "." not in name)):
76
+ return "text/plain; charset=utf-8"
77
+ return ctype or "application/octet-stream"
78
+
79
+
80
+ # ---------------------------------------------------------------- pages ----
81
+
82
+ async def index(request: Request) -> FileResponse:
83
+ return FileResponse(STATIC / "index.html")
84
+
85
+
86
+ async def view(request: Request) -> FileResponse:
87
+ return FileResponse(STATIC / "viewer.html")
88
+
89
+
90
+ # ------------------------------------------------------------------ api ----
91
+
92
+ async def hosts(request: Request) -> JSONResponse:
93
+ live = pool.hosts()
94
+ names = list(dict.fromkeys(list(CONFIG.hosts) + list(live)))
95
+ out = [{"name": n, "connected": live.get(n, False), "kind": "ssh"} for n in names]
96
+ out += [{"name": n, "connected": s.connected, "kind": "s3"} for n, s in S3.items()]
97
+ return JSONResponse(out)
98
+
99
+
100
+ async def services(request: Request) -> JSONResponse:
101
+ out = []
102
+ for hs in CONFIG.hosts.values():
103
+ st = pool.peek_state(hs.name)
104
+ for s in hs.http:
105
+ f = st.forwards.get(service_fid(hs.name, s.name)) if st else None
106
+ up = bool(st and not st.closed and f and f.local_port)
107
+ out.append(
108
+ {
109
+ "name": s.name,
110
+ "host": hs.name,
111
+ "target": f"{s.remote_host}:{s.remote_port}",
112
+ "url": f"http://{s.name}.{hs.name}.localhost:{CONFIG.listen_port}/",
113
+ "up": up,
114
+ }
115
+ )
116
+ return JSONResponse(out)
117
+
118
+
119
+ async def ls(request: Request) -> JSONResponse:
120
+ host = request.query_params.get("host")
121
+ path = request.query_params.get("path") or "."
122
+ if not host:
123
+ return err("missing ?host=", 400)
124
+ src = S3.get(host)
125
+ if src is not None:
126
+ cwd = "/" if path in (".", "", "/") else "/" + path.strip("/")
127
+ try:
128
+ entries = await src.listdir(cwd)
129
+ return JSONResponse({"host": host, "path": cwd, "entries": entries})
130
+ except S3Error as e:
131
+ return err(e)
132
+ try:
133
+ st = await pool.get(host)
134
+ cwd = await st.sftp.realpath(path)
135
+ entries = []
136
+ for name in await st.sftp.readdir(cwd):
137
+ if name.filename in (".", ".."):
138
+ continue
139
+ a = name.attrs
140
+ is_dir = a.permissions is not None and statmod.S_ISDIR(a.permissions)
141
+ entries.append(
142
+ {
143
+ "name": name.filename,
144
+ "dir": is_dir,
145
+ "size": a.size,
146
+ "mtime": a.mtime,
147
+ }
148
+ )
149
+ entries.sort(key=lambda e: (not e["dir"], e["name"].lower()))
150
+ return JSONResponse({"host": host, "path": cwd, "entries": entries})
151
+ except (OSError, asyncssh.Error) as e:
152
+ return err(e)
153
+
154
+
155
+ async def file_(request: Request) -> Response:
156
+ host = request.query_params.get("host")
157
+ path = request.query_params.get("path")
158
+ dl = bool(request.query_params.get("dl"))
159
+ if not host or not path:
160
+ return err("missing ?host= or ?path=", 400)
161
+ src = S3.get(host)
162
+ if src is not None:
163
+ try:
164
+ size, stream = await src.open(path)
165
+ except S3Error as e:
166
+ return err(e)
167
+
168
+ async def s3body():
169
+ try:
170
+ while True:
171
+ chunk = await asyncio.to_thread(stream.read, CHUNK)
172
+ if not chunk:
173
+ break
174
+ yield chunk
175
+ except Exception as e: # noqa: BLE001 - died mid-stream
176
+ log.warning("stream of %s:%s aborted: %s", host, path, e)
177
+ finally:
178
+ with contextlib.suppress(Exception):
179
+ stream.close()
180
+
181
+ headers = {"Content-Length": str(size)}
182
+ if dl:
183
+ headers["Content-Disposition"] = (
184
+ f'attachment; filename="{PurePosixPath(path).name}"'
185
+ )
186
+ return StreamingResponse(s3body(), media_type=guess_type(path, dl), headers=headers)
187
+ try:
188
+ st = await pool.get(host)
189
+ attrs = await st.sftp.stat(path)
190
+ f = await st.sftp.open(path, "rb")
191
+ except (OSError, asyncssh.Error) as e:
192
+ return err(e)
193
+
194
+ async def body():
195
+ try:
196
+ while True:
197
+ chunk = await f.read(CHUNK)
198
+ if not chunk:
199
+ break
200
+ yield chunk
201
+ except (OSError, asyncssh.Error) as e: # connection died mid-stream
202
+ log.warning("stream of %s:%s aborted: %s", host, path, e)
203
+ finally:
204
+ try:
205
+ await f.close()
206
+ except Exception: # noqa: BLE001
207
+ pass
208
+
209
+ headers = {}
210
+ if attrs.size is not None:
211
+ headers["Content-Length"] = str(attrs.size)
212
+ if dl:
213
+ headers["Content-Disposition"] = (
214
+ f'attachment; filename="{PurePosixPath(path).name}"'
215
+ )
216
+ return StreamingResponse(body(), media_type=guess_type(path, dl), headers=headers)
217
+
218
+
219
+ async def events(request: Request) -> Response:
220
+ """SSE stream of change events for one remote file.
221
+
222
+ A change is only emitted once (mtime, size) differs from the last emitted
223
+ version AND is identical across two consecutive polls -- the stable-size
224
+ debounce that avoids firing while latexmk & co. are mid-write.
225
+ """
226
+ host = request.query_params.get("host")
227
+ path = request.query_params.get("path")
228
+ try:
229
+ interval = min(max(float(request.query_params.get("interval", "2")), 0.5), 60)
230
+ except ValueError:
231
+ interval = 2.0
232
+ if not host or not path:
233
+ return err("missing ?host= or ?path=", 400)
234
+ src = S3.get(host)
235
+
236
+ async def gen():
237
+ wid = next(_watcher_seq)
238
+ close = asyncio.Event()
239
+ WATCHERS[wid] = {"id": wid, "host": host, "path": path,
240
+ "started": time.time(), "close": close}
241
+ try:
242
+ last_emit: tuple | None = None
243
+ prev: tuple | None = None
244
+ first = True
245
+ while True:
246
+ cur: tuple | None = None
247
+ try:
248
+ if src is not None:
249
+ cur = await src.stat(path)
250
+ else:
251
+ st = await pool.get(host)
252
+ a = await st.sftp.stat(path)
253
+ cur = (a.mtime, a.size)
254
+ except (OSError, asyncssh.Error, S3Error) as e:
255
+ log.debug("stat %s:%s failed: %s", host, path, e)
256
+ if cur is not None and first:
257
+ first = False
258
+ last_emit = cur
259
+ payload = {"type": "init", "mtime": cur[0], "size": cur[1]}
260
+ yield f"data: {json.dumps(payload)}\n\n"
261
+ elif cur is not None and cur == prev and cur != last_emit:
262
+ last_emit = cur
263
+ payload = {"type": "change", "mtime": cur[0], "size": cur[1]}
264
+ yield f"data: {json.dumps(payload)}\n\n"
265
+ else:
266
+ # Comment line: ignored by EventSource, but forces a write so
267
+ # a vanished client cancels this generator promptly.
268
+ yield ": ping\n\n"
269
+ prev = cur
270
+ with contextlib.suppress(asyncio.TimeoutError):
271
+ await asyncio.wait_for(close.wait(), interval)
272
+ if close.is_set():
273
+ yield 'data: {"type": "bye"}\n\n'
274
+ return
275
+ finally:
276
+ WATCHERS.pop(wid, None)
277
+
278
+ return StreamingResponse(
279
+ gen(),
280
+ media_type="text/event-stream",
281
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
282
+ )
283
+
284
+
285
+ async def views_get(request: Request) -> JSONResponse:
286
+ return JSONResponse([
287
+ {"id": w["id"], "host": w["host"], "path": w["path"], "started": w["started"]}
288
+ for w in WATCHERS.values()
289
+ ])
290
+
291
+
292
+ async def views_delete(request: Request) -> JSONResponse:
293
+ w = WATCHERS.get(request.path_params["vid"])
294
+ if w is None:
295
+ return err("no such live view", 404)
296
+ w["close"].set()
297
+ return JSONResponse({"ok": True})
298
+
299
+
300
+ async def forwards_get(request: Request) -> JSONResponse:
301
+ out = []
302
+ for f, up in pool.all_forwards():
303
+ if f.internal:
304
+ continue # HTTP service backings show up under /api/services
305
+ out.append(
306
+ {
307
+ "id": f.id,
308
+ "host": f.host,
309
+ "remote_host": f.remote_host,
310
+ "remote_port": f.remote_port,
311
+ "local_port": f.local_port,
312
+ "declared": f.declared,
313
+ "up": up,
314
+ }
315
+ )
316
+ return JSONResponse(out)
317
+
318
+
319
+ async def forwards_post(request: Request) -> JSONResponse:
320
+ try:
321
+ data = await request.json()
322
+ except json.JSONDecodeError:
323
+ return err("body must be JSON", 400)
324
+ host = data.get("host")
325
+ port = data.get("port")
326
+ remote_host = data.get("remote_host") or "localhost"
327
+ local = data.get("local") or 0
328
+ if not host or not port:
329
+ return err('need {"host": ..., "port": ...}', 400)
330
+ try:
331
+ f = await pool.add_forward(host, int(port), remote_host, desired_local=int(local))
332
+ except (OSError, asyncssh.Error, ValueError, asyncio.TimeoutError) as e:
333
+ return err(e)
334
+ return JSONResponse(
335
+ {"id": f.id, "local_port": f.local_port, "url": f"http://127.0.0.1:{f.local_port}"}
336
+ )
337
+
338
+
339
+ async def forwards_delete(request: Request) -> JSONResponse:
340
+ fid = request.path_params["fid"]
341
+ host = fid.split(":", 1)[0]
342
+ ok = await pool.remove_forward(host, fid)
343
+ return JSONResponse({"ok": ok}, status_code=200 if ok else 404)
344
+
345
+
346
+ async def _ensure_host(hs) -> None:
347
+ for t in hs.tunnels:
348
+ try:
349
+ await pool.add_forward(
350
+ hs.name, t.remote_port, t.remote_host,
351
+ desired_local=t.local, declared=True,
352
+ )
353
+ except Exception as e: # noqa: BLE001
354
+ log.warning("tunnel %s:%s on %s: %s", t.remote_host, t.remote_port, hs.name, e)
355
+ for s in hs.http:
356
+ try:
357
+ await pool.add_forward(
358
+ hs.name, s.remote_port, s.remote_host,
359
+ fid=service_fid(hs.name, s.name), internal=True, declared=True,
360
+ )
361
+ except Exception as e: # noqa: BLE001
362
+ log.warning("service %s on %s: %s", s.name, hs.name, e)
363
+
364
+
365
+ async def _ensure_loop() -> None:
366
+ """Keep declared tunnels and services alive; self-heals after drops."""
367
+ while True:
368
+ targets = [hs for hs in CONFIG.hosts.values() if hs.tunnels or hs.http]
369
+ if targets:
370
+ await asyncio.gather(*(_ensure_host(hs) for hs in targets), return_exceptions=True)
371
+ await asyncio.sleep(20)
372
+
373
+
374
+ routes = [
375
+ Route("/", index),
376
+ Route("/view", view),
377
+ Route("/api/hosts", hosts),
378
+ Route("/api/services", services),
379
+ Route("/api/ls", ls),
380
+ Route("/api/file", file_),
381
+ Route("/api/events", events),
382
+ Route("/api/views", views_get),
383
+ Route("/api/views/{vid:int}", views_delete, methods=["DELETE"]),
384
+ Route("/api/forwards", forwards_get, methods=["GET"]),
385
+ Route("/api/forwards", forwards_post, methods=["POST"]),
386
+ Route("/api/forwards/{fid:path}", forwards_delete, methods=["DELETE"]),
387
+ ]
388
+
389
+
390
+ @contextlib.asynccontextmanager
391
+ async def lifespan(_app: Starlette):
392
+ ensure_task = asyncio.create_task(_ensure_loop())
393
+ yield
394
+ ensure_task.cancel()
395
+ await app.client.aclose()
396
+ await pool.close()
397
+
398
+
399
+ inner = Starlette(routes=routes, lifespan=lifespan)
400
+ app = ProxyRouter(inner, CONFIG, pool)
sshpeek/config.py ADDED
@@ -0,0 +1,160 @@
1
+ """YAML configuration.
2
+
3
+ sshpeek still works with zero config (type any ~/.ssh/config alias in the UI),
4
+ but hosts, tunnels and HTTP services can be declared in a sshpeek.yaml, looked
5
+ up in this order:
6
+
7
+ $SSHPEEK_CONFIG
8
+ ./sshpeek.yaml
9
+ ~/.config/sshpeek/sshpeek.yaml
10
+
11
+ Example:
12
+
13
+ listen:
14
+ host: 127.0.0.1
15
+ port: 8642
16
+
17
+ hosts:
18
+ mercury:
19
+ tunnels:
20
+ - 5432 # 127.0.0.1:5432 -> mercury localhost:5432
21
+ - local: 18888
22
+ remote: localhost:8888 # 127.0.0.1:18888 -> mercury localhost:8888
23
+ http:
24
+ jupyter: 8888 # http://jupyter.mercury.localhost:8642/
25
+ mlflow: localhost:5000
26
+ homebox:
27
+ http:
28
+ grafana: 3000
29
+ internultra: # no tunnels: just a host chip in the UI
30
+
31
+ s3:
32
+ data: my-bucket # shorthand: chip "data" -> bucket
33
+ results:
34
+ bucket: my-results-bucket
35
+ prefix: runs/ # browse under this key prefix
36
+ profile: work # ~/.aws profile (else default chain)
37
+ region: us-east-2
38
+ endpoint: https://... # for R2 / MinIO / other S3 clones
39
+
40
+ Tunnels are raw TCP binds on 127.0.0.1 (databases, anything). HTTP services
41
+ are reverse-proxied by sshpeek itself under <name>.<host>.localhost, which
42
+ browsers resolve to 127.0.0.1 natively. S3 buckets appear as browsable
43
+ sources next to the SSH hosts (requires `pip install sshpeek[s3]`).
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import logging
49
+ import os
50
+ from dataclasses import dataclass, field
51
+ from pathlib import Path
52
+
53
+ import yaml
54
+
55
+ log = logging.getLogger("sshpeek.config")
56
+
57
+
58
+ @dataclass
59
+ class Tunnel:
60
+ local: int
61
+ remote_host: str
62
+ remote_port: int
63
+
64
+
65
+ @dataclass
66
+ class HttpService:
67
+ name: str
68
+ remote_host: str
69
+ remote_port: int
70
+
71
+
72
+ @dataclass
73
+ class HostSpec:
74
+ name: str
75
+ tunnels: list[Tunnel] = field(default_factory=list)
76
+ http: list[HttpService] = field(default_factory=list)
77
+
78
+
79
+ @dataclass
80
+ class S3Spec:
81
+ name: str
82
+ bucket: str
83
+ prefix: str = ""
84
+ profile: str | None = None
85
+ region: str | None = None
86
+ endpoint: str | None = None
87
+
88
+
89
+ @dataclass
90
+ class Config:
91
+ listen_host: str = "127.0.0.1"
92
+ listen_port: int = 8642
93
+ hosts: dict[str, HostSpec] = field(default_factory=dict)
94
+ s3: dict[str, S3Spec] = field(default_factory=dict)
95
+ path: Path | None = None
96
+
97
+
98
+ def _parse_target(v: object, default_host: str = "localhost") -> tuple[str, int]:
99
+ """Accept 8888, "8888" or "somehost:8888"."""
100
+ if isinstance(v, int):
101
+ return default_host, v
102
+ s = str(v)
103
+ if ":" in s:
104
+ h, p = s.rsplit(":", 1)
105
+ return h, int(p)
106
+ return default_host, int(s)
107
+
108
+
109
+ def _parse(data: dict, path: Path) -> Config:
110
+ cfg = Config(path=path)
111
+ listen = data.get("listen") or {}
112
+ cfg.listen_host = str(listen.get("host", cfg.listen_host))
113
+ cfg.listen_port = int(listen.get("port", cfg.listen_port))
114
+
115
+ for name, spec in (data.get("hosts") or {}).items():
116
+ hs = HostSpec(name=str(name))
117
+ spec = spec or {}
118
+ for t in spec.get("tunnels") or []:
119
+ if isinstance(t, dict):
120
+ rh, rp = _parse_target(t.get("remote"))
121
+ local = int(t.get("local", rp))
122
+ else:
123
+ rh, rp = _parse_target(t)
124
+ local = rp
125
+ hs.tunnels.append(Tunnel(local=local, remote_host=rh, remote_port=rp))
126
+ for sname, target in (spec.get("http") or {}).items():
127
+ rh, rp = _parse_target(target)
128
+ hs.http.append(HttpService(name=str(sname), remote_host=rh, remote_port=rp))
129
+ cfg.hosts[hs.name] = hs
130
+
131
+ for name, spec in (data.get("s3") or {}).items():
132
+ if isinstance(spec, str): # shorthand: name -> bucket
133
+ spec = {"bucket": spec}
134
+ spec = spec or {}
135
+ prefix = str(spec.get("prefix", "")).strip("/")
136
+ cfg.s3[str(name)] = S3Spec(
137
+ name=str(name),
138
+ bucket=str(spec.get("bucket", name)),
139
+ prefix=prefix + "/" if prefix else "",
140
+ profile=spec.get("profile"),
141
+ region=spec.get("region"),
142
+ endpoint=spec.get("endpoint"),
143
+ )
144
+ return cfg
145
+
146
+
147
+ def load_config() -> Config:
148
+ candidates: list[Path] = []
149
+ if os.environ.get("SSHPEEK_CONFIG"):
150
+ candidates.append(Path(os.environ["SSHPEEK_CONFIG"]))
151
+ candidates += [Path("sshpeek.yaml"), Path.home() / ".config" / "sshpeek" / "sshpeek.yaml"]
152
+ for p in candidates:
153
+ try:
154
+ if p.exists():
155
+ cfg = _parse(yaml.safe_load(p.read_text()) or {}, p)
156
+ log.info("loaded config from %s (%d hosts)", p, len(cfg.hosts))
157
+ return cfg
158
+ except (OSError, yaml.YAMLError, ValueError, TypeError) as e:
159
+ log.warning("could not read %s: %s", p, e)
160
+ return Config()