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/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
|