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 +3 -0
- sshpeek/__main__.py +38 -0
- sshpeek/app.py +400 -0
- sshpeek/config.py +160 -0
- sshpeek/pool.py +175 -0
- sshpeek/proxy.py +199 -0
- sshpeek/s3.py +110 -0
- sshpeek/static/index.html +535 -0
- sshpeek/static/viewer.html +212 -0
- sshpeek-0.1.0.dist-info/METADATA +170 -0
- sshpeek-0.1.0.dist-info/RECORD +14 -0
- sshpeek-0.1.0.dist-info/WHEEL +4 -0
- sshpeek-0.1.0.dist-info/entry_points.txt +2 -0
- sshpeek-0.1.0.dist-info/licenses/LICENSE +21 -0
sshpeek/__init__.py
ADDED
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()
|