Skip to content

Commit 0c72e1c

Browse files
committed
hub: named long-lived machine tokens, minted + revoked from the Machines page
'slmk_' tokens are hashed at rest in the hub's tokens.json, accepted anywhere a bearer is, individually revocable, and survive restarts — so a worker can run unattended past the 12h session-token TTL. The raw value is shown exactly once.
1 parent 0d8c732 commit 0c72e1c

8 files changed

Lines changed: 214 additions & 44 deletions

File tree

frontend/src/api.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,13 @@ export interface WorkerInfo {
175175
last_seen: number; online: boolean; queued: number;
176176
}
177177
export const getWorkers = () => api<{ workers: WorkerInfo[] }>("/v1/workers");
178+
export interface MachineToken { name: string; created: number }
179+
export const getTokens = () => api<{ tokens: MachineToken[] }>("/v1/tokens");
180+
export const createToken = (name: string) =>
181+
api<{ name: string; token: string }>("/v1/tokens", {
182+
method: "POST", body: JSON.stringify({ name }) });
183+
export const revokeToken = (name: string) =>
184+
api<{ ok: boolean }>(`/v1/tokens/${encodeURIComponent(name)}`, { method: "DELETE" });
178185
export const getJobs = () => api<{ jobs: JobSummary[] }>("/v1/finetunes");
179186
export const getJob = (id: string) => api<JobDetail>(`/v1/finetunes/${id}`);
180187
export const getMetrics = (id: string) =>

frontend/src/pages/Machines.tsx

Lines changed: 81 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,92 @@
11
// Machines — every device serving this hub via `shadowlm worker`.
22
import { useEffect, useState } from "react";
3-
import { Check, Copy, MonitorSmartphone } from "lucide-react";
4-
import { apiKey, getWorkers } from "../api";
5-
import type { WorkerInfo } from "../api";
3+
import { Check, Copy, KeyRound, MonitorSmartphone, Trash2 } from "lucide-react";
4+
import { createToken, getTokens, getWorkers, revokeToken } from "../api";
5+
import type { MachineToken, WorkerInfo } from "../api";
66
import { PageHeader } from "../ui";
77

8-
function ConnectCmd() {
8+
function CopyBtn({ text }: { text: string }) {
99
const [copied, setCopied] = useState(false);
10-
const token = apiKey.get();
11-
// your own studio session token — same credential, ~12h validity; reconnects
12-
// after expiry need a fresh one from a new login
13-
const cmd = `shadowlm worker --hub ${window.location.origin} --name my-machine` +
14-
(token ? ` --api-key ${token}` : "");
15-
const copy = () => {
16-
navigator.clipboard.writeText(cmd).then(() => {
17-
setCopied(true);
18-
setTimeout(() => setCopied(false), 1500);
19-
});
20-
};
2110
return (
22-
<div className="space-y-1.5">
23-
<div className="flex items-start gap-2">
24-
<pre className="flex-1 overflow-x-auto text-left text-xs font-mono bg-accent/40 border border-border rounded-md px-4 py-2.5">
25-
{cmd}
26-
</pre>
27-
<button onClick={copy} title="copy"
28-
className="shrink-0 p-2 rounded-md border border-border text-muted-foreground hover:text-foreground hover:bg-accent/40">
29-
{copied ? <Check className="size-3.5 text-emerald-500" /> : <Copy className="size-3.5" />}
11+
<button title="copy"
12+
onClick={() => navigator.clipboard.writeText(text).then(() => {
13+
setCopied(true); setTimeout(() => setCopied(false), 1500);
14+
})}
15+
className="shrink-0 p-2 rounded-md border border-border text-muted-foreground hover:text-foreground hover:bg-accent/40">
16+
{copied ? <Check className="size-3.5 text-emerald-500" /> : <Copy className="size-3.5" />}
17+
</button>
18+
);
19+
}
20+
21+
/** Mint + manage long-lived machine tokens; the raw token is shown exactly once. */
22+
function ConnectCmd() {
23+
const [tokens, setTokens] = useState<MachineToken[]>([]);
24+
const [name, setName] = useState("");
25+
const [minted, setMinted] = useState<{ name: string; token: string } | null>(null);
26+
const [err, setErr] = useState("");
27+
28+
const refresh = () => { getTokens().then((t) => setTokens(t.tokens)).catch(() => {}); };
29+
useEffect(refresh, []);
30+
31+
async function mint() {
32+
const n = name.trim() || "my-machine";
33+
setErr("");
34+
try {
35+
setMinted(await createToken(n));
36+
setName("");
37+
refresh();
38+
} catch (ex) { setErr((ex as Error).message); }
39+
}
40+
41+
const cmd = minted
42+
? `shadowlm worker --hub ${window.location.origin} --name ${minted.name} --api-key ${minted.token}`
43+
: null;
44+
45+
return (
46+
<div className="space-y-3 text-left">
47+
<div className="flex items-center gap-2">
48+
<input value={name} onChange={(e) => setName(e.target.value)}
49+
onKeyDown={(e) => e.key === "Enter" && mint()}
50+
placeholder="machine name — e.g. macbook"
51+
className="flex-1 font-mono text-sm" />
52+
<button onClick={mint}
53+
className="shrink-0 inline-flex items-center gap-1.5 text-xs px-3 py-2 rounded-md border border-border hover:bg-accent/40">
54+
<KeyRound className="size-3.5" /> Create machine token
3055
</button>
3156
</div>
32-
{token && (
33-
<p className="text-[11px] text-muted-foreground">
34-
includes your session token (valid ~12h) — the machine connects with the same access you have here.
35-
</p>
57+
{err && <p className="text-xs text-red-500">{err}</p>}
58+
59+
{cmd && (
60+
<div className="space-y-1.5">
61+
<div className="flex items-start gap-2">
62+
<pre className="flex-1 overflow-x-auto text-xs font-mono bg-accent/40 border border-border rounded-md px-4 py-2.5">
63+
{cmd}
64+
</pre>
65+
<CopyBtn text={cmd} />
66+
</div>
67+
<p className="text-[11px] text-muted-foreground">
68+
long-lived token, shown once — copy it now. Revoke it here any time.
69+
</p>
70+
</div>
71+
)}
72+
73+
{tokens.length > 0 && (
74+
<div className="divide-y divide-border border border-border rounded-md">
75+
{tokens.map((t) => (
76+
<div key={t.name} className="px-3 py-2 flex items-center gap-2 text-xs">
77+
<KeyRound className="size-3 text-muted-foreground" />
78+
<span className="font-mono font-medium">{t.name}</span>
79+
<span className="text-muted-foreground font-mono ml-auto">
80+
created {new Date(t.created * 1000).toLocaleDateString()}
81+
</span>
82+
<button title="revoke"
83+
onClick={() => revokeToken(t.name).then(refresh)}
84+
className="p-1 rounded text-muted-foreground hover:text-red-500">
85+
<Trash2 className="size-3.5" />
86+
</button>
87+
</div>
88+
))}
89+
</div>
3690
)}
3791
</div>
3892
);

shadowlm/_static/assets/index-Bg6O8DuN.js

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

shadowlm/_static/assets/index-CRBLqQg9.css renamed to shadowlm/_static/assets/index-CTi3GkyQ.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

shadowlm/_static/assets/index-DS_pTSs3.js

Lines changed: 0 additions & 13 deletions
This file was deleted.

shadowlm/_static/index.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
<link rel="icon" type="image/png" href="/logo.png" />
66
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
77
<title>ShadowLM · slm♥</title>
8-
<script type="module" crossorigin src="./assets/index-DS_pTSs3.js"></script>
9-
<link rel="stylesheet" crossorigin href="./assets/index-CRBLqQg9.css">
8+
<script type="module" crossorigin src="./assets/index-Bg6O8DuN.js"></script>
9+
<link rel="stylesheet" crossorigin href="./assets/index-CTi3GkyQ.css">
1010
</head>
1111
<body>
1212
<div id="root"></div>

shadowlm/serve.py

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -418,7 +418,9 @@ def __init__(self, *, backend: str, accelerator: str, device: str,
418418
self._downloads: dict[str, dict] = {} # model id → prefetch status
419419
self._settings_path = work_root / "settings.json"
420420
self._custom_path = work_root / "custom_models.json"
421+
self._tokens_path = work_root / "tokens.json"
421422
self._custom_models = self._load_custom_models() # user-added HF repos
423+
self._machine_tokens = self._load_tokens() # name → {hash, created}
422424
self._load_settings()
423425
self._load_jobs()
424426
threading.Thread(target=self._worker, daemon=True).start()
@@ -457,6 +459,56 @@ def set_hf_token(self, token: str | None) -> None:
457459
except OSError:
458460
pass # in-memory still works for this process
459461

462+
# ---- machine tokens: long-lived credentials for `shadowlm worker` --------
463+
# Named + individually revocable, hashed at rest in tokens.json — the same
464+
# single-box persistence every other piece of hub state uses.
465+
# ponytail: a JSON file, not a database — swap the store when a hub outgrows
466+
# one box, which is also when this whole tier hands off to Studio.
467+
def _load_tokens(self) -> dict:
468+
try:
469+
data = json.loads(self._tokens_path.read_text())
470+
return data if isinstance(data, dict) else {}
471+
except (OSError, ValueError):
472+
return {}
473+
474+
def _save_tokens(self) -> None:
475+
try:
476+
self._tokens_path.write_text(json.dumps(self._machine_tokens, indent=1))
477+
except OSError:
478+
pass # in-memory still works for this process
479+
480+
def mint_machine_token(self, name: str) -> str:
481+
"""A long-lived worker credential; the raw value is shown exactly once."""
482+
import secrets # noqa: PLC0415
483+
484+
raw = "slmk_" + secrets.token_urlsafe(32)
485+
with self._lock:
486+
self._machine_tokens[name] = {
487+
"hash": hashlib.sha256(raw.encode()).hexdigest(),
488+
"created": int(time.time())}
489+
self._save_tokens()
490+
return raw
491+
492+
def revoke_machine_token(self, name: str) -> bool:
493+
with self._lock:
494+
if name not in self._machine_tokens:
495+
return False
496+
del self._machine_tokens[name]
497+
self._save_tokens()
498+
return True
499+
500+
def valid_machine_token(self, raw: str) -> bool:
501+
if not raw.startswith("slmk_"):
502+
return False
503+
digest = hashlib.sha256(raw.encode()).hexdigest()
504+
return any(hmac.compare_digest(digest, t.get("hash", ""))
505+
for t in self._machine_tokens.values())
506+
507+
def machine_tokens(self) -> list[dict]:
508+
with self._lock:
509+
return [{"name": n, "created": t.get("created", 0)}
510+
for n, t in sorted(self._machine_tokens.items())]
511+
460512
# ---- custom models: user-added HF repos beyond the curated catalog -------
461513
def _load_custom_models(self) -> list:
462514
try:
@@ -981,7 +1033,9 @@ def _authed(self) -> bool:
9811033
if not auth.enabled:
9821034
return True
9831035
got = self.headers.get("Authorization", "")
984-
if got.startswith("Bearer ") and auth.valid_bearer(got[7:]):
1036+
if got.startswith("Bearer ") and (
1037+
auth.valid_bearer(got[7:])
1038+
or server.valid_machine_token(got[7:])):
9851039
return True
9861040
self._error(401, "authentication required")
9871041
return False
@@ -1093,6 +1147,8 @@ def do_GET(self): # noqa: N802
10931147
with server._lock:
10941148
infos = [w.info() for w in server.workers.values()]
10951149
self._send(200, {"workers": sorted(infos, key=lambda w: w["name"])})
1150+
elif parts == ["v1", "tokens"]:
1151+
self._send(200, {"tokens": server.machine_tokens()})
10961152
elif len(parts) == 4 and parts[:2] == ["v1", "workers"] \
10971153
and parts[3] == "socket":
10981154
from . import ws # noqa: PLC0415
@@ -1209,6 +1265,13 @@ def do_POST(self): # noqa: N802
12091265
job.cancel.set()
12101266
server.push_cancel(job) # instant over the socket
12111267
self._send(200, {"ok": True})
1268+
elif parts == ["v1", "tokens"]:
1269+
b = self._body()
1270+
tname = (b.get("name") or "").strip()
1271+
if not tname:
1272+
return self._error(422, "provide a token 'name'")
1273+
self._send(201, {"name": tname,
1274+
"token": server.mint_machine_token(tname)})
12121275
elif len(parts) == 6 and parts[:2] == ["v1", "workers"] \
12131276
and parts[3] == "jobs" and parts[5] == "artifact":
12141277
if (job := self._job_or_404(parts[4])):
@@ -1249,6 +1312,11 @@ def do_DELETE(self): # noqa: N802
12491312
self._send(200, {"ok": True})
12501313
else:
12511314
self._error(404, f"unknown dataset {parts[2]!r}")
1315+
elif len(parts) == 3 and parts[:2] == ["v1", "tokens"]:
1316+
if server.revoke_machine_token(parts[2]):
1317+
self._send(200, {"ok": True})
1318+
else:
1319+
self._error(404, f"unknown token {parts[2]!r}")
12521320
else:
12531321
self._error(404, f"no route: DELETE {self.path}")
12541322

tests/test_worker_hub.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,47 @@ def load(self, name, **kw):
209209
assert j.status == "failed" and "no such model" in j.error
210210

211211

212+
def test_machine_tokens_mint_authenticate_revoke(tmp_path):
213+
"""Against an auth-enabled hub: a minted machine token opens the worker
214+
socket, survives a hub restart (tokens.json), and dies on revoke."""
215+
server = Server(backend="auto", accelerator="auto", device="auto",
216+
work_root=tmp_path)
217+
auth = Auth(user="admin", password=None, api_key="ADMIN_KEY")
218+
httpd = ThreadingHTTPServer(("127.0.0.1", 0), make_handler(server, auth))
219+
threading.Thread(target=httpd.serve_forever, daemon=True).start()
220+
url = f"http://127.0.0.1:{httpd.server_address[1]}"
221+
try:
222+
admin = RemoteClient(url, "ADMIN_KEY")
223+
with pytest.raises(Exception, match="401|auth"):
224+
RemoteClient(url, "slmk_forged").health() # unknown token: rejected
225+
226+
out = admin._request("POST", "/v1/tokens", {"name": "macbook"})
227+
token = out["token"]
228+
assert token.startswith("slmk_")
229+
assert RemoteClient(url, token).health()["ok"] # HTTP accepts it
230+
231+
conn = ws.connect(url, "/v1/workers/macbook/socket", api_key=token)
232+
conn.send_json({"type": "register", "backend": "mlx",
233+
"device": "t", "gpus": 0}) # socket accepts it
234+
deadline = time.time() + 5
235+
while not admin.workers() and time.time() < deadline:
236+
time.sleep(0.05)
237+
assert admin.workers()[0]["name"] == "macbook"
238+
conn.close()
239+
240+
# raw value is never stored — only the hash is on disk
241+
assert token not in (tmp_path / "tokens.json").read_text()
242+
# a fresh Server over the same work_root still honors it (persistence)
243+
assert Server(backend="auto", accelerator="auto", device="auto",
244+
work_root=tmp_path).valid_machine_token(token)
245+
246+
admin._request("DELETE", "/v1/tokens/macbook")
247+
with pytest.raises(Exception, match="401|auth"):
248+
RemoteClient(url, token).health() # revoked: rejected
249+
finally:
250+
httpd.shutdown()
251+
252+
212253
def test_cancelled_before_pickup_never_dispatches(hub):
213254
server, client, url = hub
214255
job_id = _submit(client, worker="ghost") # no such worker connected

0 commit comments

Comments
 (0)