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/pool.py ADDED
@@ -0,0 +1,175 @@
1
+ """SSH connection pool.
2
+
3
+ One multiplexed connection per host, created lazily and reconnected on
4
+ demand. Authentication, host keys, proxies etc. all come from the normal
5
+ OpenSSH machinery: asyncssh reads ~/.ssh/config, ~/.ssh/known_hosts and
6
+ talks to the ssh-agent (SSH_AUTH_SOCK), so anything you can reach with
7
+ plain `ssh <host>` works here too.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import logging
14
+ from dataclasses import dataclass, field
15
+
16
+ import asyncssh
17
+
18
+ log = logging.getLogger("sshpeek.pool")
19
+
20
+
21
+ @dataclass
22
+ class Forward:
23
+ """A local port forward: 127.0.0.1:<local> -> <host> ssh -> remote_host:remote_port."""
24
+
25
+ id: str
26
+ host: str
27
+ remote_host: str
28
+ remote_port: int
29
+ listener: asyncssh.SSHListener | None = None
30
+ desired_local: int = 0 # try to rebind the same local port after a reconnect
31
+ internal: bool = False # backing an HTTP service; hidden from the tunnels UI
32
+ declared: bool = False # comes from sshpeek.yaml; re-ensured periodically
33
+
34
+ @property
35
+ def local_port(self) -> int | None:
36
+ try:
37
+ return self.listener.get_port() if self.listener else None
38
+ except Exception:
39
+ return None
40
+
41
+
42
+ @dataclass
43
+ class HostState:
44
+ name: str
45
+ conn: asyncssh.SSHClientConnection
46
+ sftp: asyncssh.SFTPClient | None = None
47
+ forwards: dict[str, Forward] = field(default_factory=dict)
48
+ closed: bool = False
49
+ watcher: asyncio.Task | None = None
50
+
51
+
52
+ class SSHPool:
53
+ def __init__(self) -> None:
54
+ self._states: dict[str, HostState] = {}
55
+ self._locks: dict[str, asyncio.Lock] = {}
56
+
57
+ def _lock(self, host: str) -> asyncio.Lock:
58
+ return self._locks.setdefault(host, asyncio.Lock())
59
+
60
+ async def get(self, host: str) -> HostState:
61
+ """Return a live HostState, connecting or reconnecting if needed."""
62
+ async with self._lock(host):
63
+ st = self._states.get(host)
64
+ if st is not None and not st.closed:
65
+ return st
66
+ return await self._connect(host, previous=st)
67
+
68
+ async def _connect(self, host: str, previous: HostState | None) -> HostState:
69
+ log.info("connecting to %s ...", host)
70
+ conn = await asyncio.wait_for(
71
+ asyncssh.connect(
72
+ host,
73
+ keepalive_interval=30,
74
+ keepalive_count_max=3,
75
+ ),
76
+ timeout=20,
77
+ )
78
+ st = HostState(name=host, conn=conn)
79
+ st.sftp = await conn.start_sftp_client()
80
+
81
+ async def _watch() -> None:
82
+ await conn.wait_closed()
83
+ st.closed = True
84
+ log.warning("connection to %s closed", host)
85
+
86
+ st.watcher = asyncio.create_task(_watch())
87
+ self._states[host] = st
88
+ log.info("connected to %s", host)
89
+
90
+ # Re-establish forwards that existed on the previous connection.
91
+ if previous is not None:
92
+ for f in list(previous.forwards.values()):
93
+ try:
94
+ await self._open_forward(st, f)
95
+ log.info(
96
+ "restored forward %s on 127.0.0.1:%s", f.id, f.local_port
97
+ )
98
+ except Exception as e: # noqa: BLE001 - best effort restore
99
+ log.warning("could not restore forward %s: %s", f.id, e)
100
+ return st
101
+
102
+ async def _open_forward(self, st: HostState, f: Forward) -> Forward:
103
+ want = f.desired_local or 0
104
+ try:
105
+ f.listener = await st.conn.forward_local_port(
106
+ "127.0.0.1", want, f.remote_host, f.remote_port
107
+ )
108
+ except OSError:
109
+ # Preferred local port taken; let the OS pick one.
110
+ f.listener = await st.conn.forward_local_port(
111
+ "127.0.0.1", 0, f.remote_host, f.remote_port
112
+ )
113
+ f.desired_local = f.listener.get_port()
114
+ st.forwards[f.id] = f
115
+ return f
116
+
117
+ async def add_forward(
118
+ self,
119
+ host: str,
120
+ remote_port: int,
121
+ remote_host: str = "localhost",
122
+ desired_local: int = 0,
123
+ fid: str | None = None,
124
+ internal: bool = False,
125
+ declared: bool = False,
126
+ ) -> Forward:
127
+ st = await self.get(host)
128
+ fid = fid or f"{host}:{remote_host}:{remote_port}"
129
+ existing = st.forwards.get(fid)
130
+ if existing is not None and existing.listener is not None:
131
+ return existing
132
+ f = Forward(
133
+ id=fid,
134
+ host=host,
135
+ remote_host=remote_host,
136
+ remote_port=remote_port,
137
+ desired_local=desired_local,
138
+ internal=internal,
139
+ declared=declared,
140
+ )
141
+ return await self._open_forward(st, f)
142
+
143
+ def peek_state(self, host: str) -> HostState | None:
144
+ """Current state without connecting."""
145
+ return self._states.get(host)
146
+
147
+ async def remove_forward(self, host: str, fid: str) -> bool:
148
+ st = self._states.get(host)
149
+ if st is None:
150
+ return False
151
+ f = st.forwards.pop(fid, None)
152
+ if f is None:
153
+ return False
154
+ if f.listener is not None:
155
+ f.listener.close()
156
+ return True
157
+
158
+ def all_forwards(self) -> list[tuple[Forward, bool]]:
159
+ """All registered forwards as (forward, up) pairs."""
160
+ out: list[tuple[Forward, bool]] = []
161
+ for st in self._states.values():
162
+ for f in st.forwards.values():
163
+ out.append((f, not st.closed))
164
+ return out
165
+
166
+ def hosts(self) -> dict[str, bool]:
167
+ """Hosts we have talked to, mapped to whether the connection is up."""
168
+ return {name: not st.closed for name, st in self._states.items()}
169
+
170
+ async def close(self) -> None:
171
+ for st in self._states.values():
172
+ try:
173
+ st.conn.close()
174
+ except Exception: # noqa: BLE001
175
+ pass
sshpeek/proxy.py ADDED
@@ -0,0 +1,199 @@
1
+ """Reverse proxy for declared HTTP services.
2
+
3
+ Each service in sshpeek.yaml gets a stable URL:
4
+
5
+ http://<service>.<host>.localhost:<sshpeek port>/
6
+
7
+ Browsers resolve *.localhost to 127.0.0.1 natively, so no /etc/hosts edits.
8
+ Under the hood every service is backed by an (internal, ephemeral) SSH local
9
+ forward from the pool; the proxy just relays to 127.0.0.1:<forwarded port>.
10
+ Proxying to a real local port keeps things simple and gives WebSockets and
11
+ streaming for free -- no hand-rolled HTTP over SSH channels.
12
+
13
+ Path rewriting is deliberately absent: because every service lives on its own
14
+ (sub)domain at path /, apps that generate absolute paths (Jupyter, Grafana,
15
+ ...) work unmodified, and cookies/origins stay isolated per service.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import logging
22
+
23
+ import httpx
24
+ from starlette.background import BackgroundTask
25
+ from starlette.requests import Request
26
+ from starlette.responses import PlainTextResponse, StreamingResponse
27
+ from starlette.websockets import WebSocket
28
+ from websockets.asyncio.client import connect as ws_connect
29
+
30
+ from .config import Config, HostSpec, HttpService
31
+ from .pool import SSHPool
32
+
33
+ log = logging.getLogger("sshpeek.proxy")
34
+
35
+ # Hop-by-hop headers are between us and each peer, never forwarded.
36
+ HOP = {
37
+ "connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
38
+ "te", "trailer", "transfer-encoding", "upgrade", "host",
39
+ }
40
+
41
+
42
+ def service_fid(host: str, service: str) -> str:
43
+ return f"http:{host}:{service}"
44
+
45
+
46
+ class ProxyRouter:
47
+ """ASGI wrapper: routes *.localhost hosts to the proxy, everything else
48
+ to the inner app."""
49
+
50
+ def __init__(self, app, cfg: Config, pool: SSHPool) -> None:
51
+ self.app = app
52
+ self.cfg = cfg
53
+ self.pool = pool
54
+ self.client = httpx.AsyncClient(
55
+ timeout=httpx.Timeout(None, connect=10), follow_redirects=False
56
+ )
57
+
58
+ # -- routing -----------------------------------------------------------
59
+
60
+ def _match(self, scope) -> tuple[HostSpec, HttpService] | None:
61
+ headers = {k.decode().lower(): v.decode() for k, v in scope.get("headers") or []}
62
+ hostname = (headers.get("host") or "").split(":")[0].lower()
63
+ if not hostname.endswith(".localhost"):
64
+ return None
65
+ labels = hostname[: -len(".localhost")]
66
+ if "." not in labels:
67
+ return None
68
+ service, halias = labels.split(".", 1)
69
+ hs = self.cfg.hosts.get(halias)
70
+ if hs is None:
71
+ return None
72
+ for s in hs.http:
73
+ if s.name == service:
74
+ return hs, s
75
+ return None
76
+
77
+ async def _local_port(self, hs: HostSpec, s: HttpService) -> int:
78
+ f = await self.pool.add_forward(
79
+ hs.name, s.remote_port, s.remote_host,
80
+ fid=service_fid(hs.name, s.name), internal=True, declared=True,
81
+ )
82
+ if f.local_port is None:
83
+ raise OSError("forward has no bound port")
84
+ return f.local_port
85
+
86
+ async def __call__(self, scope, receive, send):
87
+ if scope["type"] in ("http", "websocket"):
88
+ m = self._match(scope)
89
+ if m is not None:
90
+ if scope["type"] == "http":
91
+ return await self._http(scope, receive, send, *m)
92
+ return await self._ws(scope, receive, send, *m)
93
+ await self.app(scope, receive, send)
94
+
95
+ # -- http --------------------------------------------------------------
96
+
97
+ async def _http(self, scope, receive, send, hs: HostSpec, s: HttpService):
98
+ request = Request(scope, receive)
99
+ try:
100
+ port = await self._local_port(hs, s)
101
+ except Exception as e: # noqa: BLE001
102
+ resp = PlainTextResponse(
103
+ f"sshpeek: cannot reach {s.name} on {hs.name}: {e}", status_code=502
104
+ )
105
+ return await resp(scope, receive, send)
106
+
107
+ url = f"http://127.0.0.1:{port}{scope['path']}"
108
+ if scope.get("query_string"):
109
+ url += "?" + scope["query_string"].decode()
110
+ headers = [(k, v) for k, v in request.headers.items() if k.lower() not in HOP]
111
+ req = self.client.build_request(
112
+ request.method, url, headers=headers, content=request.stream()
113
+ )
114
+ try:
115
+ r = await self.client.send(req, stream=True)
116
+ except httpx.HTTPError as e:
117
+ resp = PlainTextResponse(
118
+ f"sshpeek: {s.name} on {hs.name} not responding: {e}", status_code=502
119
+ )
120
+ return await resp(scope, receive, send)
121
+
122
+ resp = StreamingResponse(
123
+ r.aiter_raw(), status_code=r.status_code, background=BackgroundTask(r.aclose)
124
+ )
125
+ # Bypass Starlette's dict headers to preserve duplicates (Set-Cookie)
126
+ # and pass raw bytes through untouched (Content-Encoding intact).
127
+ resp.raw_headers = [
128
+ (k.encode(), v.encode())
129
+ for k, v in r.headers.multi_items()
130
+ if k.lower() not in HOP
131
+ ]
132
+ await resp(scope, receive, send)
133
+
134
+ # -- websocket ---------------------------------------------------------
135
+
136
+ async def _ws(self, scope, receive, send, hs: HostSpec, s: HttpService):
137
+ ws = WebSocket(scope, receive, send)
138
+ try:
139
+ port = await self._local_port(hs, s)
140
+ except Exception as e: # noqa: BLE001
141
+ log.warning("ws: cannot reach %s on %s: %s", s.name, hs.name, e)
142
+ await ws.close(code=1011)
143
+ return
144
+
145
+ url = f"ws://127.0.0.1:{port}{scope['path']}"
146
+ if scope.get("query_string"):
147
+ url += "?" + scope["query_string"].decode()
148
+ fwd_headers = {}
149
+ for k, v in scope.get("headers") or []:
150
+ if k.decode().lower() in ("cookie", "authorization"):
151
+ fwd_headers[k.decode()] = v.decode()
152
+
153
+ try:
154
+ upstream = await ws_connect(
155
+ url,
156
+ subprotocols=scope.get("subprotocols") or None,
157
+ additional_headers=fwd_headers,
158
+ max_size=None,
159
+ )
160
+ except Exception as e: # noqa: BLE001
161
+ log.warning("ws upstream connect failed for %s: %s", url, e)
162
+ await ws.close(code=1011)
163
+ return
164
+
165
+ await ws.accept(subprotocol=upstream.subprotocol)
166
+
167
+ async def client_to_upstream():
168
+ try:
169
+ while True:
170
+ msg = await ws.receive()
171
+ if msg["type"] == "websocket.disconnect":
172
+ return
173
+ if msg.get("text") is not None:
174
+ await upstream.send(msg["text"])
175
+ elif msg.get("bytes") is not None:
176
+ await upstream.send(msg["bytes"])
177
+ except Exception: # noqa: BLE001
178
+ return
179
+
180
+ async def upstream_to_client():
181
+ try:
182
+ async for m in upstream:
183
+ if isinstance(m, str):
184
+ await ws.send_text(m)
185
+ else:
186
+ await ws.send_bytes(m)
187
+ except Exception: # noqa: BLE001
188
+ return
189
+
190
+ t1 = asyncio.create_task(client_to_upstream())
191
+ t2 = asyncio.create_task(upstream_to_client())
192
+ _, pending = await asyncio.wait({t1, t2}, return_when=asyncio.FIRST_COMPLETED)
193
+ for p in pending:
194
+ p.cancel()
195
+ for closer in (upstream.close(), ws.close()):
196
+ try:
197
+ await closer
198
+ except Exception: # noqa: BLE001
199
+ pass
sshpeek/s3.py ADDED
@@ -0,0 +1,110 @@
1
+ """S3 sources.
2
+
3
+ Buckets declared under `s3:` in sshpeek.yaml show up as browsable sources
4
+ next to the SSH hosts. Object keys are presented as paths: "directories"
5
+ are the usual `/`-delimited common prefixes. Credentials come from the
6
+ normal boto3 chain (env vars, ~/.aws profiles, SSO, instance roles).
7
+
8
+ boto3 is an optional dependency (`pip install sshpeek[s3]`) and blocking,
9
+ so it is imported lazily and every call runs in a worker thread.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import logging
16
+
17
+ from .config import S3Spec
18
+
19
+ log = logging.getLogger("sshpeek.s3")
20
+
21
+
22
+ class S3Error(Exception):
23
+ """Normalized error for anything that goes wrong talking to S3."""
24
+
25
+
26
+ class S3Source:
27
+ def __init__(self, spec: S3Spec) -> None:
28
+ self.spec = spec
29
+ self.connected = False # true after the first successful call
30
+ self._client = None
31
+
32
+ # -- blocking helpers (always called via asyncio.to_thread) -------------
33
+
34
+ def _client_or_raise(self):
35
+ if self._client is None:
36
+ try:
37
+ import boto3
38
+ except ImportError as e:
39
+ raise S3Error(
40
+ "boto3 is not installed -- pip install 'sshpeek[s3]'"
41
+ ) from e
42
+ session = boto3.session.Session(
43
+ profile_name=self.spec.profile, region_name=self.spec.region
44
+ )
45
+ self._client = session.client("s3", endpoint_url=self.spec.endpoint)
46
+ return self._client
47
+
48
+ def _key(self, path: str) -> str:
49
+ """Map a browser path like /runs/2024 onto a full object key."""
50
+ return self.spec.prefix + path.strip("/")
51
+
52
+ def _listdir(self, path: str) -> list[dict]:
53
+ c = self._client_or_raise()
54
+ key = self._key(path)
55
+ pfx = key + "/" if key else ""
56
+ dirs: list[dict] = []
57
+ files: list[dict] = []
58
+ for page in c.get_paginator("list_objects_v2").paginate(
59
+ Bucket=self.spec.bucket, Prefix=pfx, Delimiter="/"
60
+ ):
61
+ for p in page.get("CommonPrefixes", []):
62
+ name = p["Prefix"][len(pfx):].rstrip("/")
63
+ dirs.append({"name": name, "dir": True, "size": None, "mtime": None})
64
+ for o in page.get("Contents", []):
65
+ if o["Key"] == pfx: # the "directory marker" object itself
66
+ continue
67
+ files.append(
68
+ {
69
+ "name": o["Key"][len(pfx):],
70
+ "dir": False,
71
+ "size": o["Size"],
72
+ "mtime": o["LastModified"].timestamp(),
73
+ }
74
+ )
75
+ self.connected = True
76
+ out = sorted(dirs, key=lambda e: e["name"].lower())
77
+ out += sorted(files, key=lambda e: e["name"].lower())
78
+ return out
79
+
80
+ def _stat(self, path: str) -> tuple[float, int]:
81
+ c = self._client_or_raise()
82
+ head = c.head_object(Bucket=self.spec.bucket, Key=self._key(path))
83
+ self.connected = True
84
+ return head["LastModified"].timestamp(), head["ContentLength"]
85
+
86
+ def _open(self, path: str):
87
+ c = self._client_or_raise()
88
+ obj = c.get_object(Bucket=self.spec.bucket, Key=self._key(path))
89
+ self.connected = True
90
+ return obj["ContentLength"], obj["Body"]
91
+
92
+ # -- async surface used by the endpoints ---------------------------------
93
+
94
+ async def listdir(self, path: str) -> list[dict]:
95
+ return await self._call(self._listdir, path)
96
+
97
+ async def stat(self, path: str) -> tuple[float, int]:
98
+ return await self._call(self._stat, path)
99
+
100
+ async def open(self, path: str):
101
+ """Return (size, blocking StreamingBody); read it with to_thread."""
102
+ return await self._call(self._open, path)
103
+
104
+ async def _call(self, fn, *args):
105
+ try:
106
+ return await asyncio.to_thread(fn, *args)
107
+ except S3Error:
108
+ raise
109
+ except Exception as e: # noqa: BLE001 - boto raises a small zoo
110
+ raise S3Error(f"{self.spec.bucket}: {e}") from e