hoist-cli 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.
hoist/dash/server.py ADDED
@@ -0,0 +1,979 @@
1
+ """`hoist dash`: a web dashboard for everything hoist runs.
2
+
3
+ Standard library only. One token guards everything, because the dashboard
4
+ is meant to be published through the same tunnel as the apps -- and behind
5
+ a tunnel every request arrives from 127.0.0.1, so "local means trusted" is
6
+ not a rule this server can use.
7
+
8
+ Security model:
9
+ - the token lives in the state dir (0600) and is shown once by `hoist dash`;
10
+ - the browser exchanges it for an HttpOnly, SameSite=Strict cookie;
11
+ - every state-changing request must also carry `X-Hoist: 1`, which a
12
+ cross-site form cannot set, so the cookie alone is not enough to act.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import hmac
19
+ import json
20
+ import mimetypes
21
+ import os
22
+ import re
23
+ import secrets
24
+ import subprocess
25
+ import threading
26
+ import time
27
+ import urllib.parse
28
+ from http import HTTPStatus
29
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
30
+ from pathlib import Path
31
+
32
+ from .. import __version__ as VERSION
33
+ from .. import config, containers, detect, notify, qr, service, source
34
+ from . import collect, fleet, jobs, team, terminal
35
+
36
+ STATIC = Path(__file__).resolve().parent / "static"
37
+ COOKIE = "hoist_dash"
38
+ TOKEN_FILE_NAME = "dash-token"
39
+ _ENV_KEY = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
40
+ _NAME = re.compile(r"^[a-z0-9][a-z0-9-]{0,39}$")
41
+ _SHA = re.compile(r"^[0-9a-f]{7,40}$")
42
+
43
+
44
+ def token_path() -> Path:
45
+ return config.STATE_DIR / TOKEN_FILE_NAME
46
+
47
+
48
+ def load_token(reset: bool = False) -> str:
49
+ path = token_path()
50
+ if path.exists() and not reset:
51
+ value = path.read_text().strip()
52
+ if value:
53
+ return value
54
+ path.parent.mkdir(parents=True, exist_ok=True)
55
+ value = secrets.token_urlsafe(24)
56
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
57
+ with os.fdopen(fd, "w") as fh:
58
+ fh.write(value + "\n")
59
+ return value
60
+
61
+
62
+ class Dashboard:
63
+ def __init__(self, token: str) -> None:
64
+ self.token = token
65
+ self.seen: dict[str, float] = {}
66
+ self.monitor = collect.Monitor()
67
+ self.jobs = jobs.Jobs(on_finish=lambda _job: self.monitor.nudge())
68
+ self.monitor.start()
69
+
70
+ # -- helpers ---------------------------------------------------------------
71
+
72
+ def app(self, name: str) -> dict | None:
73
+ return config.get_app(config.load(), name)
74
+
75
+ def app_detail(self, name: str) -> dict | None:
76
+ state = config.load()
77
+ app = config.get_app(state, name)
78
+ if app is None:
79
+ return None
80
+ row = next((r for r in self.monitor.get().get("apps", []) if r["name"] == name), None)
81
+ repo = app.get("repo") or {}
82
+ return {
83
+ "row": row,
84
+ "env": app.get("env") or {},
85
+ "history": repo.get("history", []),
86
+ "series": self.monitor.series(name),
87
+ "probes": self.monitor.probes_for(name),
88
+ "jobs": [j for j in self.jobs.recent() if j["app"] == name][:10],
89
+ "sync_unit": f"{source.sync_unit_base(name)}.service" if repo else None,
90
+ "unit": service.unit_name(name),
91
+ }
92
+
93
+
94
+ def make_handler(dash: Dashboard):
95
+ class Handler(BaseHTTPRequestHandler):
96
+ server_version = f"hoist-dash/{VERSION}"
97
+ protocol_version = "HTTP/1.1"
98
+ # Headers and body used to leave as two small sends. Nagle's algorithm
99
+ # holds the second until the first is acknowledged, and a browser delays
100
+ # that ACK by ~40 ms, so every API call took ~45 ms (curl hides this: it
101
+ # does not trigger the delay). Buffer each response into one write and
102
+ # turn Nagle off.
103
+ wbufsize = 64 * 1024
104
+ disable_nagle_algorithm = True
105
+
106
+ # Quiet by default; errors still reach stderr.
107
+ def log_message(self, fmt, *args): # noqa: N802
108
+ if os.environ.get("HOIST_DASH_DEBUG"):
109
+ super().log_message(fmt, *args)
110
+
111
+ # -- plumbing -------------------------------------------------------------
112
+
113
+ def _secure(self) -> bool:
114
+ proto = self.headers.get("X-Forwarded-Proto", "")
115
+ return proto == "https" or bool(self.headers.get("Cf-Visitor"))
116
+
117
+ def _token(self) -> str:
118
+ for part in self.headers.get("Cookie", "").split(";"):
119
+ key, _, value = part.strip().partition("=")
120
+ if key == COOKIE and value:
121
+ return value
122
+ auth = self.headers.get("Authorization", "")
123
+ return auth[7:] if auth.startswith("Bearer ") else ""
124
+
125
+ def _authed(self) -> bool:
126
+ self.user = team.identify(self._token(), dash.token)
127
+ if self.user:
128
+ dash.seen[self.user] = time.time()
129
+ return self.user is not None
130
+
131
+ def _cookie(self, token: str) -> str:
132
+ return (f"{COOKIE}={token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=2592000"
133
+ + ("; Secure" if self._secure() else ""))
134
+
135
+ def _send(self, code: int, body: bytes, ctype: str, extra: dict | None = None) -> None:
136
+ self.send_response(code)
137
+ self.send_header("Content-Type", ctype)
138
+ self.send_header("Content-Length", str(len(body)))
139
+ self.send_header("Cache-Control", "no-store")
140
+ self.send_header("X-Content-Type-Options", "nosniff")
141
+ self.send_header("Referrer-Policy", "no-referrer")
142
+ self.send_header("X-Frame-Options", "DENY")
143
+ for key, value in (extra or {}).items():
144
+ self.send_header(key, value)
145
+ self.end_headers()
146
+ if self.command != "HEAD":
147
+ self.wfile.write(body)
148
+
149
+ def _json(self, data, code: int = 200) -> None:
150
+ self._send(code, json.dumps(data).encode(), "application/json")
151
+
152
+ def _error(self, code: int, message: str) -> None:
153
+ self._json({"error": message}, code)
154
+
155
+ def _body(self) -> dict:
156
+ raw = getattr(self, "_raw", b"")
157
+ if len(raw) > 1_000_000:
158
+ raise ValueError("request too large")
159
+ if not raw:
160
+ return {}
161
+ data = json.loads(raw)
162
+ if not isinstance(data, dict):
163
+ raise ValueError("expected a JSON object")
164
+ return data
165
+
166
+ def _static(self, rel: str) -> None:
167
+ path = (STATIC / rel).resolve()
168
+ if STATIC not in path.parents and path != STATIC or not path.is_file():
169
+ self._error(404, "not found")
170
+ return
171
+ ctype = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
172
+ if path.suffix == ".woff2":
173
+ ctype = "font/woff2"
174
+ extra = {"Cache-Control": "public, max-age=86400"} if "fonts/" in rel else None
175
+ body = path.read_bytes()
176
+ self.send_response(200)
177
+ self.send_header("Content-Type", ctype)
178
+ self.send_header("Content-Length", str(len(body)))
179
+ self.send_header("X-Content-Type-Options", "nosniff")
180
+ self.send_header("Cache-Control", (extra or {}).get("Cache-Control", "no-cache"))
181
+ self.end_headers()
182
+ self.wfile.write(body)
183
+
184
+ def _sse_start(self) -> None:
185
+ self.send_response(200)
186
+ self.send_header("Content-Type", "text/event-stream")
187
+ self.send_header("Cache-Control", "no-store")
188
+ self.send_header("X-Accel-Buffering", "no")
189
+ self.send_header("Connection", "close")
190
+ self.end_headers()
191
+ self.close_connection = True
192
+
193
+ def _sse(self, data, event: str | None = None) -> None:
194
+ chunk = (f"event: {event}\n" if event else "") + f"data: {json.dumps(data)}\n\n"
195
+ self.wfile.write(chunk.encode())
196
+ self.wfile.flush()
197
+
198
+ # -- routing ----------------------------------------------------------------
199
+
200
+ def do_HEAD(self): # noqa: N802
201
+ self.do_GET()
202
+
203
+ def do_GET(self): # noqa: N802
204
+ url = urllib.parse.urlsplit(self.path)
205
+ path, query = url.path, urllib.parse.parse_qs(url.query)
206
+
207
+ if path.startswith("/static/"):
208
+ return self._static(path[len("/static/"):])
209
+ if path == "/favicon.svg":
210
+ return self._static("favicon.svg")
211
+
212
+ if path in ("/", "/index.html"):
213
+ given = (query.get("token") or [""])[0]
214
+ if given:
215
+ if team.identify(given, dash.token):
216
+ cookie = self._cookie(given)
217
+ nxt = (query.get("next") or ["/"])[0]
218
+ if not nxt.startswith("/") or nxt.startswith("//"):
219
+ nxt = "/"
220
+ return self._send(303, b"", "text/plain",
221
+ {"Location": nxt, "Set-Cookie": cookie})
222
+ time.sleep(0.5)
223
+ page = "index.html" if self._authed() else "login.html"
224
+ return self._static(page)
225
+
226
+ if not path.startswith("/api/"):
227
+ # client-side routes fall back to the app shell
228
+ return self._static("index.html" if self._authed() else "login.html")
229
+ if not self._authed():
230
+ return self._error(401, "sign in first")
231
+
232
+ remote = re.fullmatch(r"/api/remote/([a-z0-9-]+)/(.+)", path)
233
+ if remote:
234
+ return self._proxy("GET", remote.group(1), remote.group(2), url.query)
235
+ if path == "/api/servers":
236
+ local = dash.monitor.get()
237
+ apps = [a for a in local.get("apps", []) if a.get("kind") != "db"]
238
+ broken = [a["name"] for a in apps if a.get("status") in ("down", "failed")
239
+ or (a.get("public") and a.get("probe") and not a["probe"].get("up"))]
240
+ return self._json({"local": {"host": local.get("host"), "apps": len(apps), "broken": broken,
241
+ "system": local.get("system")},
242
+ "servers": fleet.summary() if self.user == team.OWNER else []})
243
+ try:
244
+ return self._api_get(path, query)
245
+ except (BrokenPipeError, ConnectionResetError):
246
+ return None
247
+
248
+ def do_POST(self): # noqa: N802
249
+ url = urllib.parse.urlsplit(self.path)
250
+ path = url.path
251
+ # Read the body before anything can answer early. On a keep-alive
252
+ # connection an unread body is parsed as the start of the NEXT
253
+ # request, so a rejected click used to corrupt the one after it.
254
+ length = int(self.headers.get("Content-Length") or 0)
255
+ if length > 5_000_000:
256
+ self.close_connection = True
257
+ return self._error(413, "request too large")
258
+ self._raw = self.rfile.read(length) if length else b""
259
+ match = re.fullmatch(r"/hooks/([a-z0-9-]+)", path)
260
+ if match:
261
+ return self._webhook(match.group(1))
262
+ if path == "/api/login":
263
+ try:
264
+ given = str(self._body().get("token", ""))
265
+ except ValueError:
266
+ given = ""
267
+ if not team.identify(given, dash.token):
268
+ time.sleep(0.75)
269
+ return self._error(401, "that token is not right")
270
+ cookie = self._cookie(given)
271
+ return self._send(200, b'{"ok":true}', "application/json", {"Set-Cookie": cookie})
272
+ if path == "/api/logout":
273
+ return self._send(200, b'{"ok":true}', "application/json",
274
+ {"Set-Cookie": f"{COOKIE}=; Path=/; Max-Age=0; SameSite=Strict"})
275
+ if not self._authed():
276
+ return self._error(401, "sign in first")
277
+ if self.headers.get("X-Hoist") != "1":
278
+ return self._error(403, "missing X-Hoist header")
279
+ remote = re.fullmatch(r"/api/remote/([a-z0-9-]+)/(.+)", path)
280
+ if remote:
281
+ return self._proxy("POST", remote.group(1), remote.group(2), url.query)
282
+ need = _required(path)
283
+ if need is not None and not team.allows(self.user, *need):
284
+ return self._error(403, _denied(self.user, *need))
285
+ try:
286
+ body = self._body()
287
+ except (ValueError, json.JSONDecodeError) as exc:
288
+ return self._error(400, str(exc))
289
+ try:
290
+ return self._api_post(path, body)
291
+ except (ValueError, team.TeamError) as exc:
292
+ return self._error(400, str(exc))
293
+
294
+ # -- GET endpoints --------------------------------------------------------
295
+
296
+ def _api_get(self, path: str, query: dict) -> None:
297
+ if path == "/api/state":
298
+ dash.monitor.touch()
299
+ snap = dash.monitor.get()
300
+ seen = team.scope(self.user)
301
+ if seen is not None:
302
+ snap = dict(snap, apps=[a for a in snap.get("apps", []) if a["name"] in seen])
303
+ snap = redact_state(snap, lambda n: team.allows(self.user, "secrets", n))
304
+ snap["jobs"] = [j for j in dash.jobs.recent()[:40]
305
+ if seen is None or j.get("app") in seen][:12]
306
+ snap["version"] = VERSION
307
+ snap["me"] = self.user
308
+ snap["role"] = team.role_of(self.user)
309
+ access = team.access(self.user) or {}
310
+ snap["can"] = sorted(access.get("caps", ()))
311
+ snap["scope"] = sorted(seen) if seen is not None else None
312
+ return self._json(snap)
313
+
314
+ if path == "/api/deployments":
315
+ state = config.load()
316
+ rows = []
317
+ seen = team.scope(self.user)
318
+ for name, app in state.get("apps", {}).items():
319
+ if seen is not None and name not in seen:
320
+ continue
321
+ repo = app.get("repo") or {}
322
+ for entry in repo.get("history", []):
323
+ rows.append(dict(entry, app=name, live=entry.get("sha") == repo.get("sha")
324
+ and entry is next((h for h in repo["history"]
325
+ if h.get("status") != "failed"), None)))
326
+ rows.sort(key=lambda r: r.get("at", ""), reverse=True)
327
+ return self._json({"deployments": rows[:200]})
328
+
329
+ if path == "/api/notify":
330
+ url = (config.load().get("notify") or {}).get("url")
331
+ if url and not team.allows(self.user, "secrets"):
332
+ url = _URL_SECRET.sub(r"\1••••••@", url)
333
+ return self._json({"url": url, "kind": notify.kind(url) if url else None})
334
+
335
+ if path == "/api/templates":
336
+ return self._json({"templates": [{"id": k, "label": v["label"], "about": v["about"],
337
+ "image": v["image"], "user": bool(v.get("user"))}
338
+ for k, v in containers.catalogue().items()],
339
+ "docker": containers.available()})
340
+
341
+ if path == "/api/team":
342
+ people = [{"name": team.OWNER, "role": "owner", "added": None,
343
+ "seen": dash.seen.get(team.OWNER)}]
344
+ for name, info in sorted(team.users().items()):
345
+ people.append({"name": name, "role": info.get("role", "member"), "added": info.get("added"),
346
+ "seen": dash.seen.get(name), "can": info.get("can", []),
347
+ "apps": info.get("apps", []), "stages": info.get("stages", [])})
348
+ seen = team.scope(self.user)
349
+ audit = team.audit_tail(int((query.get("n") or ["200"])[0]))
350
+ if seen is not None: # a scoped person sees only their own apps' history
351
+ audit = [r for r in audit if r.get("target") in seen]
352
+ people = [p for p in people if p["name"] == self.user]
353
+ return self._json({"me": self.user, "people": people, "audit": audit,
354
+ "caps": team.CAPS, "stages": list(team.STAGES)})
355
+
356
+ if path == "/api/metrics":
357
+ return self._json({"system": dash.monitor.series()})
358
+
359
+ if path == "/api/doctor":
360
+ from ..cli import doctor_checks
361
+ return self._json({"checks": doctor_checks()})
362
+
363
+ if path == "/api/qr.svg":
364
+ text = (query.get("text") or [""])[0][:300]
365
+ if not text:
366
+ return self._error(400, "text is required")
367
+ return self._send(200, qr_svg(text).encode(), "image/svg+xml")
368
+
369
+ if path == "/api/routes":
370
+ rules = dash.monitor.get().get("routes", {}).get("rules", [])
371
+ data = []
372
+ seen = team.scope(self.user)
373
+ for rule in rules:
374
+ if seen is not None and rule.get("managed") not in seen:
375
+ continue
376
+ key = (f"route:{rule['hostname']}" if not rule.get("managed")
377
+ else rule.get("managed"))
378
+ data.append(dict(rule, probes=dash.monitor.probes_for(key or "")))
379
+ return self._json({"rules": data})
380
+
381
+ match = re.fullmatch(r"/api/jobs/(j\d+)(/stream)?", path)
382
+ if match:
383
+ job = dash.jobs.get(match.group(1))
384
+ if job is None:
385
+ return self._error(404, "no such job")
386
+ if job.app is not None and not team.allows(self.user, "view", job.app):
387
+ return self._error(404, "no such job")
388
+ if not team.allows(self.user, "secrets", job.app):
389
+ data = job.as_dict(with_lines=True)
390
+ data["lines"] = [re.sub(r"(password[^:]*:\s*)\S+", r"\1••••••", ln, flags=re.I)
391
+ for ln in data["lines"]]
392
+ return self._json(data)
393
+ if not match.group(2):
394
+ return self._json(job.as_dict(with_lines=True))
395
+ return self._stream_job(job)
396
+
397
+ match = re.fullmatch(r"/api/apps/([a-z0-9-]+)(/[a-z/]+(?:/[0-9]{8}-[0-9]{6}\.[a-z.]+)?)?", path)
398
+ if match:
399
+ name, rest = match.group(1), match.group(2) or ""
400
+ if not team.allows(self.user, "view", name):
401
+ return self._error(404, "no such app")
402
+ if rest == "":
403
+ detail = dash.app_detail(name)
404
+ if detail and not team.allows(self.user, "secrets", name):
405
+ detail = redact(detail)
406
+ return self._json(detail) if detail else self._error(404, "no such app")
407
+ app = dash.app(name)
408
+ if app is None:
409
+ return self._error(404, "no such app")
410
+ if rest == "/terminal":
411
+ return self._terminal(name, app)
412
+ if rest == "/backups":
413
+ if app.get("kind") not in ("db", "compose"):
414
+ return self._error(400, "only databases and Docker stacks have backups")
415
+ holder = app.get("db") or app.get("backup") or {}
416
+ return self._json({"backups": containers.list_backups(name),
417
+ "daily": bool(holder.get("daily_backup")),
418
+ "offsite": holder.get("offsite")})
419
+ match_dl = re.fullmatch(r"/backups/([0-9]{8}-[0-9]{6}\.[a-z.]+)", rest)
420
+ if match_dl and not team.allows(self.user, "secrets", name):
421
+ return self._error(403, "downloading a backup needs the 'secrets' permission")
422
+ if match_dl and app.get("kind") in ("db", "compose"):
423
+ path = containers.backup_dir(name) / match_dl.group(1)
424
+ if not path.is_file():
425
+ return self._error(404, "no such backup")
426
+ team.audit(self.user or "?", "download backup", name, path.name)
427
+ data = path.read_bytes()
428
+ return self._send(200, data, "application/octet-stream",
429
+ {"Content-Disposition": f'attachment; filename="{name}-{path.name}"'})
430
+ if rest in ("/logs", "/logs/stream"):
431
+ kind = (query.get("kind") or ["app"])[0]
432
+ if app.get("adopted"):
433
+ return self._error(409, "hoist does not manage this process, so it has no logs here")
434
+ unit = (f"{source.sync_unit_base(name)}.service" if kind == "deploys"
435
+ else service.unit_name(name))
436
+ if rest == "/logs":
437
+ lines = int((query.get("n") or ["400"])[0])
438
+ return self._json({"unit": unit, "lines": collect.journal(unit, min(lines, 5000))})
439
+ return self._stream_journal(unit)
440
+ return self._error(404, "unknown endpoint")
441
+
442
+ def _proxy(self, method: str, sid: str, rest: str, query: str) -> None:
443
+ """Forward an API call to a linked machine, streams included."""
444
+ if self.user != team.OWNER:
445
+ return self._error(403, "only the owner can manage other machines")
446
+ srv = fleet.servers().get(sid)
447
+ if srv is None:
448
+ return self._error(404, "no such machine")
449
+ if rest.endswith("/terminal"):
450
+ return self._error(501, "open the terminal on that machine's own dashboard")
451
+ body = None
452
+ if method == "POST":
453
+ body = self._raw or b"{}"
454
+ self._audit("remote " + rest.rsplit("/", 1)[-1], f"{sid}:{rest}")
455
+ stream = rest.endswith("/stream")
456
+ try:
457
+ resp = fleet.request(srv, method, rest + (f"?{query}" if query else ""), body, stream=stream)
458
+ except fleet.urllib.error.HTTPError as exc:
459
+ data = exc.read()
460
+ return self._send(exc.code, data, exc.headers.get("Content-Type", "application/json"))
461
+ except Exception as exc: # noqa: BLE001
462
+ return self._error(502, f"{srv['name']} did not answer: {getattr(exc, 'reason', exc)}")
463
+ with resp:
464
+ ctype = resp.headers.get("Content-Type", "application/json")
465
+ if not stream:
466
+ return self._send(resp.status, resp.read(), ctype,
467
+ {k: v for k, v in resp.headers.items() if k == "Content-Disposition"})
468
+ self._sse_start()
469
+ try:
470
+ while True:
471
+ chunk = resp.readline()
472
+ if not chunk:
473
+ return
474
+ self.wfile.write(chunk)
475
+ if chunk in (b"\n", b"\r\n"):
476
+ self.wfile.flush()
477
+ except (BrokenPipeError, ConnectionResetError, OSError):
478
+ return
479
+
480
+ def _terminal(self, name: str, app: dict) -> None:
481
+ if self.headers.get("Upgrade", "").lower() != "websocket":
482
+ return self._error(426, "this endpoint speaks WebSocket")
483
+ if not team.allows(self.user, "terminal", name):
484
+ return self._error(403, "your access does not include the terminal")
485
+ # Cross-site WebSocket hijacking: browsers send cookies with any
486
+ # WebSocket, so the page that opens it must be this dashboard.
487
+ origin = urllib.parse.urlsplit(self.headers.get("Origin", "")).netloc
488
+ if not origin or origin != self.headers.get("Host", ""):
489
+ return self._error(403, "terminal must be opened from this dashboard")
490
+ if app.get("adopted"):
491
+ return self._error(409, "hoist does not know where this process runs")
492
+ key = self.headers.get("Sec-WebSocket-Key", "")
493
+ if not key:
494
+ return self._error(400, "missing Sec-WebSocket-Key")
495
+ argv, env, cwd = terminal.shell_command(name, app)
496
+ team.audit(self.user, "open terminal", name)
497
+ self.send_response(101, "Switching Protocols")
498
+ self.send_header("Upgrade", "websocket")
499
+ self.send_header("Connection", "Upgrade")
500
+ self.send_header("Sec-WebSocket-Accept", terminal.accept_key(key))
501
+ self.end_headers()
502
+ self.wfile.flush()
503
+ self.close_connection = True
504
+ terminal.bridge(self.rfile, self.wfile, argv, env, cwd)
505
+
506
+ def _stream_job(self, job) -> None:
507
+ self._sse_start()
508
+ sent = 0
509
+ try:
510
+ while True:
511
+ with job.changed:
512
+ if sent >= len(job.lines) and job.status == "running":
513
+ job.changed.wait(15)
514
+ lines = job.lines[sent:]
515
+ status = job.status
516
+ for line in lines:
517
+ self._sse(line, "line")
518
+ sent += len(lines)
519
+ if status != "running" and sent >= len(job.lines):
520
+ self._sse(job.as_dict(), "done")
521
+ return
522
+ if not lines:
523
+ self.wfile.write(b": keep-alive\n\n")
524
+ self.wfile.flush()
525
+ except (BrokenPipeError, ConnectionResetError):
526
+ return
527
+
528
+ def _stream_journal(self, unit: str) -> None:
529
+ self._sse_start()
530
+ proc = subprocess.Popen(
531
+ ["journalctl", "--user", "-u", unit, "-f", "-n", "0", "-o", "json",
532
+ "--output-fields=MESSAGE,PRIORITY,_PID"],
533
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, errors="replace",
534
+ )
535
+ alive = threading.Event()
536
+ alive.set()
537
+
538
+ def heartbeat():
539
+ while alive.is_set():
540
+ time.sleep(15)
541
+ try:
542
+ self.wfile.write(b": keep-alive\n\n")
543
+ self.wfile.flush()
544
+ except OSError:
545
+ alive.clear()
546
+ proc.kill()
547
+ return
548
+
549
+ threading.Thread(target=heartbeat, daemon=True).start()
550
+ try:
551
+ assert proc.stdout is not None
552
+ for line in proc.stdout:
553
+ entry = collect.parse_journal_line(line)
554
+ if entry:
555
+ self._sse(entry, "line")
556
+ except (BrokenPipeError, ConnectionResetError, OSError):
557
+ pass
558
+ finally:
559
+ alive.clear()
560
+ proc.kill()
561
+
562
+ # -- POST endpoints -------------------------------------------------------
563
+
564
+ def _webhook(self, name: str) -> None:
565
+ """A push from GitHub or GitLab: verify it, then deploy right away.
566
+
567
+ No cookie here -- the forge cannot have one. The request must be
568
+ signed with the app's own secret (GitHub's X-Hub-Signature-256,
569
+ or GitLab's X-Gitlab-Token), compared in constant time.
570
+ """
571
+ raw = self._raw
572
+ app = dash.app(name)
573
+ secret = ((app or {}).get("repo") or {}).get("webhook_secret")
574
+ if not secret:
575
+ time.sleep(0.5)
576
+ return self._error(404, "no webhook for this app")
577
+ signature = self.headers.get("X-Hub-Signature-256", "")
578
+ expected = "sha256=" + hmac.new(secret.encode(), raw, hashlib.sha256).hexdigest()
579
+ gitlab = self.headers.get("X-Gitlab-Token", "")
580
+ if not (hmac.compare_digest(signature, expected) or (gitlab and hmac.compare_digest(gitlab, secret))):
581
+ time.sleep(0.5)
582
+ return self._error(401, "bad signature")
583
+ event = self.headers.get("X-GitHub-Event") or self.headers.get("X-Gitlab-Event") or ""
584
+ if event in ("ping",):
585
+ return self._json({"ok": True, "pong": True})
586
+ try:
587
+ payload = json.loads(raw or b"{}")
588
+ except json.JSONDecodeError:
589
+ return self._error(400, "payload is not JSON")
590
+ ref = str(payload.get("ref", ""))
591
+ branch = app["repo"]["branch"]
592
+ previews = bool(app["repo"].get("previews"))
593
+ if ref != f"refs/heads/{branch}" and not previews:
594
+ return self._json({"ok": True, "ignored": f"push to {ref}, watching {branch}"})
595
+ team.audit("webhook", "push received", name, ref)
596
+ job = dash.jobs.start(f"Deploy {name} (webhook)", ["sync", name, "--wait"], name, "webhook")
597
+ return self._json({"ok": True, "job": job.id}, 202)
598
+
599
+ def _audit(self, action: str, target: str | None = None, detail: str = "") -> None:
600
+ team.audit(self.user or "?", action, target, detail)
601
+
602
+ def _api_post(self, path: str, body: dict) -> None:
603
+ if path == "/api/apps":
604
+ self._audit("create app", str(body.get("name") or ""), str(body.get("target", ""))[:200])
605
+ return self._create(body)
606
+ if path == "/api/templates":
607
+ tid = str(body.get("id", ""))
608
+ catalogue = containers.catalogue()
609
+ if tid not in catalogue:
610
+ raise ValueError("pick a template")
611
+ name = str(body.get("name", "") or tid).strip().lower()
612
+ if not _NAME.match(name):
613
+ raise ValueError("name: lowercase letters, digits and dashes, up to 40")
614
+ if dash.app(name):
615
+ raise ValueError(f"{name} already exists")
616
+ args = ["template", tid, "--name", name, "--no-qr"] + (["--local"] if body.get("local") else [])
617
+ self._audit("create from template", name, tid)
618
+ job = dash.jobs.start(f"Start {catalogue[tid]['label']} as {name}", args, name, self.user)
619
+ return self._json({"job": job.as_dict(), "app": name}, 202)
620
+ if path == "/api/servers":
621
+ if self.user != team.OWNER:
622
+ return self._error(403, "only the owner can link machines")
623
+ try:
624
+ srv = fleet.add(str(body.get("name", "")), str(body.get("url", "")), str(body.get("token", "")))
625
+ except fleet.FleetError as exc:
626
+ raise ValueError(str(exc)) from exc
627
+ self._audit("link machine", srv["id"], srv["url"])
628
+ return self._json(srv)
629
+ match = re.fullmatch(r"/api/servers/([a-z0-9-]+)/remove", path)
630
+ if match:
631
+ if self.user != team.OWNER:
632
+ return self._error(403, "only the owner can unlink machines")
633
+ if not fleet.remove(match.group(1)):
634
+ return self._error(404, "no such machine")
635
+ self._audit("unlink machine", match.group(1))
636
+ return self._json({"ok": True})
637
+ if path == "/api/databases":
638
+ engine = str(body.get("engine", ""))
639
+ if engine not in containers.ENGINES:
640
+ raise ValueError("pick postgres, mysql, redis or mongo")
641
+ name = str(body.get("name", "") or engine).strip().lower()
642
+ if not _NAME.match(name):
643
+ raise ValueError("name: lowercase letters, digits and dashes, up to 40")
644
+ if dash.app(name):
645
+ raise ValueError(f"{name} already exists")
646
+ self._audit("create database", name, engine)
647
+ job = dash.jobs.start(f"Start {containers.ENGINES[engine]['label']} {name}",
648
+ ["db", engine, "--name", name], name, self.user)
649
+ return self._json({"job": job.as_dict(), "app": name}, 202)
650
+ if path == "/api/team":
651
+ if self.user != team.OWNER:
652
+ return self._error(403, "only the owner can add people")
653
+ name = str(body.get("name", ""))
654
+ role = str(body.get("role", "member"))
655
+ can, apps = body.get("can") or [], body.get("apps") or []
656
+ if not isinstance(can, list) or not isinstance(apps, list):
657
+ raise ValueError("can and apps must be lists")
658
+ stages = body.get("stages") or []
659
+ if not isinstance(stages, list):
660
+ raise ValueError("stages must be a list")
661
+ token = team.add(name, role, [str(c) for c in can], [str(a) for a in apps],
662
+ [str(s) for s in stages])
663
+ self._audit("add teammate", name.strip().lower(), _describe(role, can, apps, stages))
664
+ base = str(body.get("origin") or "").rstrip("/")
665
+ if not re.match(r"^https?://[^/\s]+$", base):
666
+ base = ""
667
+ return self._json({"name": name.strip().lower(), "link": f"{base}/?token={token}"})
668
+ match = re.fullmatch(r"/api/team/([a-z][a-z0-9_-]{0,23})/role", path)
669
+ if match:
670
+ if self.user != team.OWNER:
671
+ return self._error(403, "only the owner can change roles")
672
+ role = str(body.get("role", ""))
673
+ can, apps = body.get("can") or [], body.get("apps") or []
674
+ if not isinstance(can, list) or not isinstance(apps, list):
675
+ raise ValueError("can and apps must be lists")
676
+ stages = body.get("stages") or []
677
+ if not isinstance(stages, list):
678
+ raise ValueError("stages must be a list")
679
+ team.set_role(match.group(1), role, [str(c) for c in can], [str(a) for a in apps],
680
+ [str(s) for s in stages])
681
+ self._audit("change access", match.group(1), _describe(role, can, apps, stages))
682
+ return self._json({"ok": True})
683
+ match = re.fullmatch(r"/api/team/([a-z][a-z0-9_-]{0,23})/remove", path)
684
+ if match:
685
+ if self.user != team.OWNER:
686
+ return self._error(403, "only the owner can remove people")
687
+ if not team.remove(match.group(1)):
688
+ return self._error(404, "no such teammate")
689
+ self._audit("remove teammate", match.group(1))
690
+ return self._json({"ok": True})
691
+ if path == "/api/notify":
692
+ self._audit("set notifications", None, "webhook on" if body.get("url") else "off")
693
+ url = str(body.get("url") or "").strip()
694
+ if url and (not url.startswith(("https://", "http://", "smtp://", "smtps://")) or len(url) > 2000):
695
+ raise ValueError("expected a webhook URL (https://…) or email (smtps://user:pass@host:465/?to=…)")
696
+ with config.transaction() as state:
697
+ if url:
698
+ state["notify"] = {"url": url}
699
+ else:
700
+ state.pop("notify", None)
701
+ return self._json({"url": url or None, "kind": notify.kind(url) if url else None})
702
+ if path == "/api/notify/test":
703
+ good, detail = notify.send("hoist is connected",
704
+ "You will hear about deploys, failed builds and apps that stop answering.",
705
+ level="ok")
706
+ return self._json({"ok": good, "detail": detail}, 200 if good else 502)
707
+
708
+ match = re.fullmatch(r"/api/apps/([a-z0-9-]+)/([a-z-]+)", path)
709
+ if not match:
710
+ return self._error(404, "unknown endpoint")
711
+ name, action = match.groups()
712
+ app = dash.app(name)
713
+ if app is None:
714
+ return self._error(404, "no such app")
715
+ detail = {"env": f"{len(body.get('env') or {})} variables (values not logged)",
716
+ "rollback": f"to {str(body.get('sha', ''))[:12]}",
717
+ "limits": f"memory {body.get('memory') or 'none'}, cpu {body.get('cpu') or 'none'}",
718
+ "link": f"into {body.get('app', '')} as {body.get('var') or 'default'}"}.get(action, "")
719
+ self._audit(action, name, detail)
720
+ running = dash.jobs.busy(name)
721
+ long_actions = {"redeploy", "rollback", "delete", "backup", "restore"}
722
+ if action in long_actions and running:
723
+ return self._json({"error": "another action is still running", "job": running.as_dict()}, 409)
724
+
725
+ if action == "redeploy":
726
+ if not app.get("repo"):
727
+ return self._error(400, "only apps deployed from git can be redeployed")
728
+ job = dash.jobs.start(f"Redeploy {name}", ["sync", name, "--force"], name, self.user)
729
+ return self._json({"job": job.as_dict()}, 202)
730
+ if action == "rollback":
731
+ sha = str(body.get("sha", ""))
732
+ if not _SHA.match(sha):
733
+ return self._error(400, "a commit SHA is required")
734
+ job = dash.jobs.start(f"Roll back {name} to {sha[:7]}", ["sync", name, "--to", sha], name, self.user)
735
+ return self._json({"job": job.as_dict()}, 202)
736
+ if action == "delete":
737
+ job = dash.jobs.start(f"Take down {name}", ["down", name] + (["--purge"] if body.get("purge") else []), name, self.user)
738
+ return self._json({"job": job.as_dict()}, 202)
739
+
740
+ if action in ("restart", "stop", "start", "pause", "resume"):
741
+ return self._quick(name, app, action)
742
+ if action == "env":
743
+ return self._set_env(name, body)
744
+ if action == "link":
745
+ target = str(body.get("app", ""))
746
+ if app.get("kind") != "db":
747
+ return self._error(400, "only a database can be linked")
748
+ if not _NAME.match(target) or not dash.app(target):
749
+ raise ValueError("pick an app to link")
750
+ args = ["link", name, target]
751
+ var = str(body.get("var", "") or "").strip()
752
+ if var:
753
+ if not _ENV_KEY.match(var):
754
+ raise ValueError(f"{var!r} is not a valid variable name")
755
+ args += ["--var", var]
756
+ return self._run_quick(args)
757
+ if action == "exec":
758
+ command = str(body.get("command", "")).strip()
759
+ if not command or len(command) > 2000 or "\n" in command:
760
+ raise ValueError("one command, on one line")
761
+ if running:
762
+ return self._json({"error": "another action is still running", "job": running.as_dict()}, 409)
763
+ team.audit(self.user, "run command", name, command[:300])
764
+ job = dash.jobs.start(f"Run in {name}", ["exec", name, "--", command], name, self.user)
765
+ return self._json({"job": job.as_dict()}, 202)
766
+ if action in ("backup", "restore", "backup-schedule"):
767
+ if app.get("kind") not in ("db", "compose"):
768
+ return self._error(400, "only databases and Docker stacks have backups")
769
+ if action == "backup":
770
+ job = dash.jobs.start(f"Back up {name}", ["backup", name], name, self.user)
771
+ return self._json({"job": job.as_dict()}, 202)
772
+ if action == "restore":
773
+ file = str(body.get("file", ""))
774
+ if not re.fullmatch(r"[0-9]{8}-[0-9]{6}\.[a-z.]+", file):
775
+ raise ValueError("pick a backup to restore")
776
+ job = dash.jobs.start(f"Restore {name} from {file}", ["backup", name, "--restore", file],
777
+ name, self.user)
778
+ return self._json({"job": job.as_dict()}, 202)
779
+ return self._run_quick(["backup", name, "--daily" if body.get("on") else "--no-schedule"])
780
+ if action == "webhook":
781
+ if not app.get("repo"):
782
+ return self._error(400, "only apps deployed from git have webhooks")
783
+ if self.user != team.OWNER:
784
+ return self._error(403, "only the owner can issue a webhook secret")
785
+ secret = secrets.token_hex(20)
786
+ with config.transaction() as state:
787
+ state["apps"][name]["repo"]["webhook_secret"] = secret
788
+ base = str(body.get("origin") or "").rstrip("/")
789
+ if not re.match(r"^https?://[^/\s]+$", base):
790
+ base = ""
791
+ return self._json({"url": f"{base}/hooks/{name}", "secret": secret})
792
+ if action == "stage":
793
+ scoped = (team.access(self.user) or {}).get("stages")
794
+ if scoped is not None: # moving an app between environments is a governance change
795
+ return self._error(403, "people limited to some environments cannot change an app's environment")
796
+ stage = str(body.get("stage", ""))
797
+ if stage not in team.STAGES:
798
+ raise ValueError(f"pick one of {', '.join(team.STAGES)}")
799
+ return self._run_quick(["stage", name, stage])
800
+ if action == "previews":
801
+ return self._run_quick(["previews", name, "on" if body.get("on") else "off"])
802
+ if action == "limits":
803
+ args = ["limit", name]
804
+ for key in ("memory", "cpu"):
805
+ value = str(body.get(key, "") or "").strip() or "off"
806
+ if value != "off" and not re.match(getattr(service, f"LIMIT_{key.upper()}"), value.upper()):
807
+ raise ValueError(f"{key}: use something like {'512M' if key == 'memory' else '50%'}")
808
+ args += [f"--{key}", value]
809
+ return self._run_quick(args)
810
+ return self._error(404, "unknown action")
811
+
812
+ def _quick(self, name: str, app: dict, action: str) -> None:
813
+ if app.get("adopted") and action in ("restart", "stop", "start"):
814
+ return self._error(409, "hoist does not manage this process")
815
+ if action in ("pause", "resume") and not app.get("repo"):
816
+ return self._error(400, "auto-deploy only applies to apps deployed from git")
817
+ return self._run_quick([action, name])
818
+
819
+ def _run_quick(self, args: list[str]) -> None:
820
+ action = args[0]
821
+ proc = subprocess.run(
822
+ [os.sys.executable, "-m", "hoist", *args], capture_output=True, text=True,
823
+ env=dict(os.environ, NO_COLOR="1", HOIST_ACTOR=self.user or "",
824
+ PYTHONPATH=str(jobs._PKG_ROOT) + os.pathsep + os.environ.get("PYTHONPATH", "")),
825
+ timeout=60,
826
+ )
827
+ dash.monitor.nudge()
828
+ output = jobs._ANSI.sub("", (proc.stdout + proc.stderr).strip())
829
+ if proc.returncode != 0:
830
+ return self._json({"error": output.splitlines()[-1] if output else f"{action} failed"}, 500)
831
+ return self._json({"ok": True, "output": output})
832
+
833
+ def _set_env(self, name: str, body: dict) -> None:
834
+ env = body.get("env")
835
+ if not isinstance(env, dict):
836
+ raise ValueError("env must be an object of KEY: value")
837
+ clean: dict[str, str] = {}
838
+ for key, value in env.items():
839
+ key = str(key).strip()
840
+ if not _ENV_KEY.match(key):
841
+ raise ValueError(f"{key!r} is not a valid variable name")
842
+ if key in ("PORT", "HOIST_APP"):
843
+ raise ValueError(f"{key} is set by hoist")
844
+ value = "" if value is None else str(value)
845
+ if "\n" in value or "\r" in value:
846
+ raise ValueError(f"{key} has a line break; use one line")
847
+ clean[key] = value
848
+ with config.transaction() as state:
849
+ app = config.get_app(state, name)
850
+ if app is None:
851
+ return self._error(404, "no such app")
852
+ if app.get("adopted"):
853
+ return self._error(409, "hoist does not manage this process, so it cannot set its environment")
854
+ app["env"] = clean
855
+ app["updated"] = config.now()
856
+ service.write_unit(app)
857
+ containers.refresh_generated_compose(app) # so a container app actually receives it
858
+ restarted = False
859
+ if not app.get("stopped") and body.get("restart", True):
860
+ service.restart(name)
861
+ restarted = True
862
+ dash.monitor.nudge()
863
+ return self._json({"ok": True, "restarted": restarted, "count": len(clean)})
864
+
865
+ def _create(self, body: dict) -> None:
866
+ target = str(body.get("target", "")).strip()
867
+ if not target:
868
+ raise ValueError("a git URL or a folder is required")
869
+ name = str(body.get("name", "")).strip().lower()
870
+ if name and not _NAME.match(name):
871
+ raise ValueError("name: lowercase letters, digits and dashes, up to 40")
872
+ if not source.is_repo_url(target):
873
+ folder = Path(target).expanduser()
874
+ if not folder.is_dir():
875
+ raise ValueError(f"{target} is not a git URL or a folder on this machine")
876
+ target = str(folder.resolve())
877
+ args = ["up", target, "--no-qr"]
878
+ if name:
879
+ args += ["--name", name]
880
+ for key, flag in (("branch", "--branch"), ("build", "--build"), ("cmd", "--cmd")):
881
+ value = str(body.get(key, "") or "").strip()
882
+ if value:
883
+ if "\n" in value:
884
+ raise ValueError(f"{key} must be one line")
885
+ args += [flag, value]
886
+ if body.get("local"):
887
+ args.append("--local")
888
+ for key, value in (body.get("env") or {}).items():
889
+ if not _ENV_KEY.match(str(key)):
890
+ raise ValueError(f"{key!r} is not a valid variable name")
891
+ args += ["--env", f"{key}={value}"]
892
+ app_name = name or detect.slug(source.repo_name(target) if source.is_repo_url(target)
893
+ else Path(target).name)
894
+ if dash.jobs.busy(app_name):
895
+ return self._error(409, f"{app_name} is already being deployed")
896
+ job = dash.jobs.start(f"Deploy {app_name}", args, app_name, self.user)
897
+ return self._json({"job": job.as_dict(), "app": app_name}, 202)
898
+
899
+ return Handler
900
+
901
+
902
+ _URL_SECRET = re.compile(r"(://[^:/@]*:)[^@]+@")
903
+
904
+
905
+ def redact(detail: dict) -> dict:
906
+ """What a viewer may see of an app: everything but secrets."""
907
+ detail = dict(detail)
908
+ detail["env"] = {k: "••••••" for k in (detail.get("env") or {})}
909
+ if detail.get("row"):
910
+ detail["row"] = _redact_row(detail["row"])
911
+ return detail
912
+
913
+
914
+ def _redact_row(row: dict) -> dict:
915
+ row = dict(row)
916
+ if row.get("db"):
917
+ row["db"] = dict(row["db"], url=_URL_SECRET.sub(r"\1••••••@", row["db"].get("url") or ""))
918
+ return row
919
+
920
+
921
+ def redact_state(snap: dict, keep=lambda name: False) -> dict:
922
+ """Hide database passwords from anyone who lacks the 'secrets' permission on that app."""
923
+ return dict(snap, apps=[r if keep(r["name"]) else _redact_row(r) for r in snap.get("apps", [])])
924
+
925
+
926
+ def _required(path: str):
927
+ """(capability, app) a write to `path` needs, or None when the handler checks for itself."""
928
+ if path in ("/api/apps", "/api/templates", "/api/databases"):
929
+ return ("create", None)
930
+ if path in ("/api/notify", "/api/notify/test"):
931
+ return ("config", None)
932
+ match = re.fullmatch(r"/api/apps/([a-z0-9-]+)/([a-z-]+)", path)
933
+ if match:
934
+ name, action = match.groups()
935
+ cap = team.ACTION_CAP.get(action)
936
+ if cap is None: # exec and anything else unlisted: the owner only
937
+ return ("owner-only", name)
938
+ return (cap, name)
939
+ return None
940
+
941
+
942
+ def _describe(role: str, can: list, apps: list, stages: list | None = None) -> str:
943
+ if role != "custom":
944
+ return role
945
+ where = (f" on {', '.join(sorted(map(str, apps)))}" if apps else " on all apps")
946
+ if stages:
947
+ where += f" in {', '.join(sorted(map(str, stages)))}"
948
+ return f"custom: {', '.join(sorted(map(str, can))) or 'view only'}" + where
949
+
950
+
951
+ def _denied(user: str | None, cap: str, app: str | None) -> str:
952
+ access = team.access(user) or {}
953
+ if cap == "owner-only":
954
+ return "only the owner can do that"
955
+ if cap not in access.get("caps", ()):
956
+ what = team.CAPS.get(cap, cap)
957
+ return f"your access does not include this ({what.lower()})"
958
+ return f"your access is limited to some apps, and {app or 'this action'} is not one of them"
959
+
960
+
961
+ def qr_svg(text: str) -> str:
962
+ matrix = qr.encode(text)
963
+ size = len(matrix)
964
+ quiet = 2
965
+ full = size + quiet * 2
966
+ cells = "".join(
967
+ f"M{x + quiet} {y + quiet}h1v1h-1z"
968
+ for y, row in enumerate(matrix) for x, dark in enumerate(row) if dark
969
+ )
970
+ return (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {full} {full}" '
971
+ f'shape-rendering="crispEdges"><rect width="{full}" height="{full}" fill="#fff"/>'
972
+ f'<path d="{cells}" fill="#000"/></svg>')
973
+
974
+
975
+ def serve(host: str, port: int, token: str) -> ThreadingHTTPServer:
976
+ dash = Dashboard(token)
977
+ httpd = ThreadingHTTPServer((host, port), make_handler(dash))
978
+ httpd.daemon_threads = True
979
+ return httpd