pixie-lab-dibs 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.
dibs/_users.py ADDED
@@ -0,0 +1,251 @@
1
+ # SPDX-FileCopyrightText: Simon A. F. Lund <os@safl.dk>
2
+ #
3
+ # SPDX-License-Identifier: BSD-3-Clause
4
+
5
+ """Users, their login tokens, and the one-time login challenges.
6
+
7
+ A user registers a name and an SSH public key, an admin approves them
8
+ (``dibs admin approve``), and the user then logs in by signing a
9
+ one-time challenge with that key to get a token. Tokens are
10
+ stored as SHA-256 hashes, so a copy of the database does not hand out
11
+ working tokens. A token does not expire; it lasts until the user logs
12
+ out, an admin revokes it, or the user is removed.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import contextlib
18
+ import hashlib
19
+ import re
20
+ import secrets
21
+ import sqlite3
22
+ import threading
23
+ from collections.abc import Generator
24
+ from dataclasses import dataclass
25
+ from datetime import UTC, datetime, timedelta
26
+ from pathlib import Path
27
+
28
+ from dibs._sshsig import parse_pubkey, verify
29
+
30
+ _WRITE_LOCK = threading.Lock()
31
+
32
+ # Lowercase, like machine names, and safe to write as an
33
+ # ``allowed_signers`` principal (no spaces, quotes or commas).
34
+ USER_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,31}$")
35
+
36
+ CHALLENGE_TTL = timedelta(seconds=60)
37
+
38
+ _SCHEMA = """
39
+ CREATE TABLE IF NOT EXISTS users (
40
+ name TEXT PRIMARY KEY,
41
+ pubkey TEXT NOT NULL,
42
+ fingerprint TEXT NOT NULL,
43
+ status TEXT NOT NULL,
44
+ registered_at TEXT NOT NULL,
45
+ approved_at TEXT NOT NULL DEFAULT ''
46
+ );
47
+ CREATE TABLE IF NOT EXISTS user_tokens (
48
+ token_sha256 TEXT PRIMARY KEY,
49
+ user TEXT NOT NULL,
50
+ created_at TEXT NOT NULL
51
+ );
52
+ CREATE TABLE IF NOT EXISTS login_challenges (
53
+ nonce TEXT PRIMARY KEY,
54
+ user TEXT NOT NULL,
55
+ expires_at TEXT NOT NULL
56
+ );
57
+ """
58
+
59
+ PENDING = "pending"
60
+ APPROVED = "approved"
61
+
62
+
63
+ class DuplicateUser(ValueError):
64
+ pass
65
+
66
+
67
+ class NotApproved(PermissionError):
68
+ pass
69
+
70
+
71
+ class LoginFailed(PermissionError):
72
+ pass
73
+
74
+
75
+ @dataclass
76
+ class User:
77
+ name: str
78
+ pubkey: str
79
+ fingerprint: str
80
+ status: str
81
+ registered_at: str
82
+ approved_at: str
83
+ tokens: int = 0
84
+
85
+
86
+ def validate_user_name(raw: str) -> str:
87
+ name = (raw or "").strip().lower()
88
+ if not USER_NAME_RE.match(name):
89
+ raise ValueError(
90
+ f"user name {name!r} must be a-z / 0-9 / . _ -, start with a letter or digit, "
91
+ "and be at most 32 chars"
92
+ )
93
+ return name
94
+
95
+
96
+ def login_message(name: str, nonce: str) -> str:
97
+ """What the client signs: the purpose and the user as well as the
98
+ nonce, so the signed text says what it authorises. Moving a login
99
+ between users is already ruled out by each challenge belonging to
100
+ one user (see :meth:`UsersStore.login`)."""
101
+ return f"dibs-login {name} {nonce}"
102
+
103
+
104
+ def _ts(at: datetime) -> str:
105
+ return at.strftime("%Y-%m-%dT%H:%M:%SZ")
106
+
107
+
108
+ def _hash(token: str) -> str:
109
+ return hashlib.sha256(token.encode()).hexdigest()
110
+
111
+
112
+ class UsersStore:
113
+ def __init__(self, db_path: Path) -> None:
114
+ self.db_path = Path(db_path)
115
+ with self._conn() as conn:
116
+ conn.executescript(_SCHEMA)
117
+
118
+ @contextlib.contextmanager
119
+ def _conn(self) -> Generator[sqlite3.Connection]:
120
+ conn = sqlite3.connect(str(self.db_path))
121
+ conn.execute("PRAGMA busy_timeout = 5000")
122
+ conn.row_factory = sqlite3.Row
123
+ try:
124
+ yield conn
125
+ conn.commit()
126
+ finally:
127
+ conn.close()
128
+
129
+ def _user(self, conn: sqlite3.Connection, name: str) -> User | None:
130
+ row = conn.execute("SELECT * FROM users WHERE name = ?", (name,)).fetchone()
131
+ if row is None:
132
+ return None
133
+ tokens = conn.execute(
134
+ "SELECT COUNT(*) FROM user_tokens WHERE user = ?", (name,)
135
+ ).fetchone()[0]
136
+ return User(**dict(row), tokens=tokens)
137
+
138
+ def get(self, name: str) -> User | None:
139
+ with self._conn() as conn:
140
+ return self._user(conn, (name or "").strip().lower())
141
+
142
+ def list_users(self) -> list[User]:
143
+ with self._conn() as conn:
144
+ names = [r[0] for r in conn.execute("SELECT name FROM users ORDER BY name")]
145
+ return [u for u in (self._user(conn, n) for n in names) if u is not None]
146
+
147
+ def register(self, name: str, pubkey: str, *, now: datetime | None = None) -> User:
148
+ """Add a pending user. Raises :class:`DuplicateUser` when the
149
+ name is taken, pending or not."""
150
+ name = validate_user_name(name)
151
+ key, fingerprint = parse_pubkey(pubkey)
152
+ at = _ts(now or datetime.now(UTC))
153
+ with _WRITE_LOCK, self._conn() as conn:
154
+ if conn.execute("SELECT 1 FROM users WHERE name = ?", (name,)).fetchone():
155
+ raise DuplicateUser(f"user {name!r} is already registered")
156
+ conn.execute(
157
+ "INSERT INTO users (name, pubkey, fingerprint, status, registered_at) "
158
+ "VALUES (?, ?, ?, ?, ?)",
159
+ (name, key, fingerprint, PENDING, at),
160
+ )
161
+ user = self._user(conn, name)
162
+ assert user is not None
163
+ return user
164
+
165
+ def approve(self, name: str, *, now: datetime | None = None) -> User:
166
+ at = _ts(now or datetime.now(UTC))
167
+ with _WRITE_LOCK, self._conn() as conn:
168
+ cur = conn.execute(
169
+ "UPDATE users SET status = ?, approved_at = ? WHERE name = ? AND status = ?",
170
+ (APPROVED, at, name, PENDING),
171
+ )
172
+ if cur.rowcount == 0:
173
+ raise LookupError(f"no pending user {name!r}")
174
+ user = self._user(conn, name)
175
+ assert user is not None
176
+ return user
177
+
178
+ def remove(self, name: str) -> bool:
179
+ """Delete a user, pending or approved, with all their tokens."""
180
+ with _WRITE_LOCK, self._conn() as conn:
181
+ conn.execute("DELETE FROM user_tokens WHERE user = ?", (name,))
182
+ conn.execute("DELETE FROM login_challenges WHERE user = ?", (name,))
183
+ return conn.execute("DELETE FROM users WHERE name = ?", (name,)).rowcount > 0
184
+
185
+ def revoke_tokens(self, name: str) -> int:
186
+ with _WRITE_LOCK, self._conn() as conn:
187
+ return conn.execute("DELETE FROM user_tokens WHERE user = ?", (name,)).rowcount
188
+
189
+ def challenge(self, name: str, *, now: datetime | None = None) -> tuple[str, str]:
190
+ """``(nonce, message)`` for an approved user to sign. Raises
191
+ :class:`LookupError` for an unknown user, :class:`NotApproved`
192
+ for a pending one."""
193
+ name = validate_user_name(name)
194
+ at = now or datetime.now(UTC)
195
+ nonce = secrets.token_urlsafe(32)
196
+ with _WRITE_LOCK, self._conn() as conn:
197
+ user = self._user(conn, name)
198
+ if user is None:
199
+ raise LookupError(f"no user {name!r}; register first")
200
+ if user.status != APPROVED:
201
+ raise NotApproved(f"user {name!r} is waiting for admin approval")
202
+ conn.execute("DELETE FROM login_challenges WHERE expires_at <= ?", (_ts(at),))
203
+ conn.execute(
204
+ "INSERT INTO login_challenges (nonce, user, expires_at) VALUES (?, ?, ?)",
205
+ (nonce, name, _ts(at + CHALLENGE_TTL)),
206
+ )
207
+ return nonce, login_message(name, nonce)
208
+
209
+ def login(self, name: str, nonce: str, signature: str, *, now: datetime | None = None) -> str:
210
+ """Check ``signature`` over the challenge and return a new token.
211
+ The challenge is used up by the attempt, good or bad, so a
212
+ captured signature cannot be replayed. Raises
213
+ :class:`LoginFailed` with a reason the caller may show."""
214
+ name = validate_user_name(name)
215
+ at = _ts(now or datetime.now(UTC))
216
+ with _WRITE_LOCK, self._conn() as conn:
217
+ row = conn.execute(
218
+ "DELETE FROM login_challenges WHERE nonce = ? AND user = ? RETURNING expires_at",
219
+ (nonce, name),
220
+ ).fetchone()
221
+ user = self._user(conn, name)
222
+ if row is None or row["expires_at"] <= at:
223
+ raise LoginFailed("challenge unknown, used or expired; log in again")
224
+ if user is None or user.status != APPROVED:
225
+ raise LoginFailed(f"user {name!r} is not approved")
226
+ message = login_message(name, nonce)
227
+ if not verify(principal=name, key=user.pubkey, message=message, signature=signature):
228
+ raise LoginFailed(f"signature does not match the key registered for {name!r}")
229
+ token = "dibs_" + secrets.token_urlsafe(32)
230
+ with _WRITE_LOCK, self._conn() as conn:
231
+ conn.execute(
232
+ "INSERT INTO user_tokens (token_sha256, user, created_at) VALUES (?, ?, ?)",
233
+ (_hash(token), name, at),
234
+ )
235
+ return token
236
+
237
+ def user_for_token(self, token: str) -> str | None:
238
+ if not token:
239
+ return None
240
+ with self._conn() as conn:
241
+ row = conn.execute(
242
+ "SELECT t.user FROM user_tokens t JOIN users u ON u.name = t.user "
243
+ "WHERE t.token_sha256 = ? AND u.status = ?",
244
+ (_hash(token), APPROVED),
245
+ ).fetchone()
246
+ return row[0] if row else None
247
+
248
+ def logout(self, token: str) -> bool:
249
+ with _WRITE_LOCK, self._conn() as conn:
250
+ cur = conn.execute("DELETE FROM user_tokens WHERE token_sha256 = ?", (_hash(token),))
251
+ return cur.rowcount > 0
dibs/app.py ADDED
@@ -0,0 +1,344 @@
1
+ # SPDX-FileCopyrightText: Simon A. F. Lund <os@safl.dk>
2
+ #
3
+ # SPDX-License-Identifier: BSD-3-Clause
4
+
5
+ """The dibs server: things, claims, users, and the login hook.
6
+
7
+ Reads are open to anyone who can reach the server. Claiming and
8
+ releasing take a user's bearer token, which ``/auth/login`` hands out
9
+ for a signature by the user's registered SSH key. Approving users is
10
+ not an HTTP route at all: it is ``dibs admin approve`` on the server
11
+ host, so dibs has no admin password to share or leak.
12
+
13
+ Claims, takeovers and logins are logged to the ``dibs`` logger, which
14
+ the systemd unit sends to the journal.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import html
21
+ import logging
22
+ from collections.abc import AsyncGenerator, Callable
23
+ from contextlib import asynccontextmanager
24
+ from datetime import UTC, datetime
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ from fastapi import Depends, FastAPI, HTTPException, Request
29
+ from fastapi.responses import HTMLResponse, PlainTextResponse, Response
30
+ from pydantic import BaseModel, Field
31
+
32
+ import dibs
33
+ from dibs._sources import Config, PixieSource, load_config, sync
34
+ from dibs._store import Claim, ClaimConflict, Store, Thing, sentence
35
+ from dibs._users import DuplicateUser, LoginFailed, NotApproved, UsersStore
36
+
37
+ log = logging.getLogger("dibs")
38
+
39
+
40
+ class ClaimBody(BaseModel):
41
+ things: list[str] = Field(..., min_length=1)
42
+ purpose: str
43
+ assisted_by: str = Field(default="", description="Agent:model, as in an Assisted-by trailer")
44
+ expires_in: int | None = Field(default=None, description="Seconds; null never expires")
45
+ force: bool = False
46
+
47
+
48
+ class ReleaseBody(BaseModel):
49
+ things: list[str] = Field(default_factory=list)
50
+ assisted_by: str = ""
51
+ mine: bool = Field(default=False, description="Every live claim you hold through assisted_by")
52
+ force: bool = False
53
+
54
+
55
+ class RegisterBody(BaseModel):
56
+ user: str
57
+ pubkey: str
58
+
59
+
60
+ class ChallengeBody(BaseModel):
61
+ user: str
62
+
63
+
64
+ class LoginBody(BaseModel):
65
+ user: str
66
+ nonce: str
67
+ signature: str
68
+
69
+
70
+ def _bearer(request: Request) -> str:
71
+ scheme, _, token = request.headers.get("authorization", "").partition(" ")
72
+ return token.strip() if scheme.lower() == "bearer" else ""
73
+
74
+
75
+ def current_user(request: Request) -> str:
76
+ """Dependency: the user a bearer token belongs to, else 401."""
77
+ user = request.app.state.users.user_for_token(_bearer(request))
78
+ if user is None:
79
+ raise HTTPException(status_code=401, detail="not logged in; run: dibs login <user>")
80
+ return str(user)
81
+
82
+
83
+ def _view(name: str, thing: Thing | None, claim: Claim | None, now: datetime) -> dict[str, Any]:
84
+ """One thing as the API shows it. A claim on a thing no source lists
85
+ any more still shows, with ``source`` ``gone``, so it can be released."""
86
+ out: dict[str, Any] = {
87
+ "name": name,
88
+ "kind": thing.kind if thing else "unknown",
89
+ "source": thing.source if thing else "gone",
90
+ "sentence": sentence(name, claim, now),
91
+ }
92
+ if thing and thing.attrs:
93
+ out["attrs"] = thing.attrs
94
+ if claim is not None and claim.is_live(now):
95
+ out["claim"] = claim.to_dict()
96
+ return out
97
+
98
+
99
+ def _conflict(exc: ClaimConflict) -> HTTPException:
100
+ return HTTPException(
101
+ status_code=409, detail={"message": "claimed by someone else", "claimed": exc.sentences}
102
+ )
103
+
104
+
105
+ def create_app(
106
+ config_path: Path,
107
+ data_dir: Path,
108
+ *,
109
+ pixie_sources: list[PixieSource] | None = None,
110
+ background_sync: bool = True,
111
+ ) -> FastAPI:
112
+ data_dir.mkdir(parents=True, exist_ok=True)
113
+ store = Store(data_dir / "dibs.db")
114
+ users = UsersStore(data_dir / "dibs.db")
115
+ config = load_config(config_path)
116
+ pixies = pixie_sources if pixie_sources is not None else [PixieSource(u) for u in config.pixies]
117
+
118
+ def sync_now() -> None:
119
+ sync(store, config, pixies)
120
+
121
+ @asynccontextmanager
122
+ async def lifespan(_app: FastAPI) -> AsyncGenerator[None]:
123
+ sync_now()
124
+ task = asyncio.create_task(_sync_loop(config, sync_now)) if background_sync else None
125
+ try:
126
+ yield
127
+ finally:
128
+ if task is not None:
129
+ task.cancel()
130
+
131
+ app = FastAPI(title="dibs", version=dibs.__version__, lifespan=lifespan)
132
+ app.state.store = store
133
+ app.state.users = users
134
+ app.state.config = config
135
+ app.state.pixies = {p.id: p for p in pixies}
136
+ app.state.sync = sync_now
137
+
138
+ def resolve(names: list[str]) -> list[str]:
139
+ known = {t.name for t in store.list_things()}
140
+ wanted = [n.strip().lower() for n in names]
141
+ unknown = [n for n in wanted if n not in known]
142
+ if unknown:
143
+ raise HTTPException(status_code=404, detail=f"no such thing: {', '.join(unknown)}")
144
+ return wanted
145
+
146
+ @app.get("/healthz")
147
+ def healthz() -> dict[str, str]:
148
+ return {"status": "ok", "version": dibs.__version__}
149
+
150
+ @app.get("/things")
151
+ def list_things() -> dict[str, list[dict[str, Any]]]:
152
+ now = datetime.now(UTC)
153
+ things = {t.name: t for t in store.list_things()}
154
+ claims = store.list_claims()
155
+ names = sorted(set(things) | {n for n, c in claims.items() if c.is_live(now)})
156
+ return {"things": [_view(n, things.get(n), claims.get(n), now) for n in names]}
157
+
158
+ @app.get("/things/{name}")
159
+ def get_thing(name: str) -> dict[str, Any]:
160
+ now = datetime.now(UTC)
161
+ thing = store.get_thing(name)
162
+ claim = store.get_claim(name.strip().lower())
163
+ if thing is None and claim is None:
164
+ raise HTTPException(status_code=404, detail=f"no such thing: {name}")
165
+ out = _view(name.strip().lower(), thing, claim, now)
166
+ if thing is not None and thing.source in app.state.pixies:
167
+ info = app.state.pixies[thing.source].info(thing.name)
168
+ if info is not None:
169
+ out["pixie"] = info
170
+ return out
171
+
172
+ @app.get("/things/{name}/claim", response_class=PlainTextResponse)
173
+ def thing_claim_text(name: str) -> PlainTextResponse:
174
+ name = name.strip().lower()
175
+ if store.get_thing(name) is None:
176
+ raise HTTPException(status_code=404, detail=f"no such thing: {name}")
177
+ return PlainTextResponse(sentence(name, store.get_claim(name), datetime.now(UTC)) + "\n")
178
+
179
+ @app.get("/claim", response_class=PlainTextResponse)
180
+ def claim_for_host(mac: str = "", host: str = "") -> PlainTextResponse:
181
+ """The claim sentence for the machine asking, found by any of its
182
+ MACs (comma-separated) or else its hostname. For the login hook."""
183
+ thing = store.thing_by_mac(mac.split(",")) or (store.get_thing(host) if host else None)
184
+ if thing is None:
185
+ raise HTTPException(status_code=404, detail="not a thing dibs knows")
186
+ text = sentence(thing.name, store.get_claim(thing.name), datetime.now(UTC))
187
+ return PlainTextResponse(text + "\n")
188
+
189
+ @app.post("/claims")
190
+ def claim(body: ClaimBody, user: str = Depends(current_user)) -> dict[str, Any]:
191
+ names = resolve(body.things)
192
+ now = datetime.now(UTC)
193
+ try:
194
+ pairs = store.claim(
195
+ names,
196
+ holder=user,
197
+ purpose=body.purpose,
198
+ assisted_by=body.assisted_by,
199
+ expires_in=body.expires_in,
200
+ force=body.force,
201
+ now=now,
202
+ )
203
+ except ClaimConflict as exc:
204
+ raise _conflict(exc) from exc
205
+ except ValueError as exc:
206
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
207
+ for before, after in pairs:
208
+ if before is not None and before.holder != after.holder:
209
+ log.warning("%s took %s from %s", user, after.thing, before.holder)
210
+ log.info("%s", sentence(after.thing, after, now))
211
+ things = {t.name: t for t in store.list_things()}
212
+ return {"things": [_view(a.thing, things.get(a.thing), a, now) for _, a in pairs]}
213
+
214
+ @app.post("/claims/release")
215
+ def release(body: ReleaseBody, user: str = Depends(current_user)) -> dict[str, Any]:
216
+ now = datetime.now(UTC)
217
+ if body.mine == bool(body.things):
218
+ raise HTTPException(status_code=422, detail="name things to release, or set mine")
219
+ if body.mine:
220
+ names = [c.thing for c in store.claimed_by(user, body.assisted_by, now=now)]
221
+ else:
222
+ names = [n.strip().lower() for n in body.things]
223
+ try:
224
+ released = store.release(names, holder=user, force=body.force, now=now)
225
+ except ClaimConflict as exc:
226
+ raise _conflict(exc) from exc
227
+ things = {t.name: t for t in store.list_things()}
228
+ for c in released:
229
+ log.info("%s released %s (held by %s)", user, c.thing, c.holder)
230
+ return {"things": [_view(c.thing, things.get(c.thing), None, now) for c in released]}
231
+
232
+ @app.post("/auth/register", status_code=202)
233
+ def register(body: RegisterBody) -> dict[str, str]:
234
+ try:
235
+ u = users.register(body.user, body.pubkey)
236
+ except DuplicateUser as exc:
237
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
238
+ except ValueError as exc:
239
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
240
+ log.info(
241
+ "%s registered with key %s; approve with: dibs admin approve %s",
242
+ u.name,
243
+ u.fingerprint,
244
+ u.name,
245
+ )
246
+ return {"user": u.name, "status": u.status, "fingerprint": u.fingerprint}
247
+
248
+ @app.post("/auth/challenge")
249
+ def challenge(body: ChallengeBody) -> dict[str, str]:
250
+ try:
251
+ nonce, message = users.challenge(body.user)
252
+ except NotApproved as exc:
253
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
254
+ except LookupError as exc:
255
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
256
+ except ValueError as exc:
257
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
258
+ return {"nonce": nonce, "message": message}
259
+
260
+ @app.post("/auth/login")
261
+ def login(body: LoginBody) -> dict[str, str]:
262
+ try:
263
+ token = users.login(body.user, body.nonce, body.signature)
264
+ except LoginFailed as exc:
265
+ raise HTTPException(status_code=401, detail=str(exc)) from exc
266
+ except ValueError as exc:
267
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
268
+ name = body.user.strip().lower()
269
+ log.info("%s logged in", name)
270
+ return {"user": name, "token": token}
271
+
272
+ @app.post("/auth/logout", status_code=204)
273
+ def logout(request: Request) -> Response:
274
+ users.logout(_bearer(request))
275
+ return Response(status_code=204)
276
+
277
+ @app.get("/auth/whoami")
278
+ def whoami(user: str = Depends(current_user)) -> dict[str, str]:
279
+ return {"user": user}
280
+
281
+ @app.get("/login.sh", response_class=PlainTextResponse)
282
+ def login_hook(request: Request) -> PlainTextResponse:
283
+ """A ``profile.d`` snippet that prints this machine's claim at
284
+ login. It points back at whichever dibs URL fetched it."""
285
+ return PlainTextResponse(LOGIN_HOOK.format(base=str(request.base_url)))
286
+
287
+ @app.get("/", response_class=HTMLResponse)
288
+ def index() -> HTMLResponse:
289
+ rows = list_things()["things"]
290
+ body = "\n".join(
291
+ f"<tr><td>{html.escape(r['name'])}</td><td>{html.escape(r['kind'])}</td>"
292
+ f"<td>{html.escape(r['sentence'])}</td></tr>"
293
+ for r in rows
294
+ )
295
+ return HTMLResponse(PAGE.format(version=dibs.__version__, rows=body))
296
+
297
+ return app
298
+
299
+
300
+ async def _sync_loop(config: Config, sync_now: Callable[[], None]) -> None:
301
+ while True:
302
+ await asyncio.sleep(config.sync_interval)
303
+ try:
304
+ await asyncio.to_thread(sync_now)
305
+ except Exception: # keep syncing; one bad round must not stop the loop
306
+ log.exception("sync failed")
307
+
308
+
309
+ # Sourced from /etc/profile.d by login shells on the machine. It must
310
+ # never slow down or break a login: interactive shells only, one request
311
+ # with a short timeout, and silence when dibs is unreachable.
312
+ LOGIN_HOOK = """\
313
+ # dibs claim notice, from {base}login.sh
314
+ case $- in *i*)
315
+ if command -v curl >/dev/null 2>&1; then
316
+ _dibs_macs=$(cat /sys/class/net/*/address 2>/dev/null \\
317
+ | grep -v '^00:00:00:00:00:00$' | tr '\\n' ',')
318
+ _dibs_line=$(curl -fsS --connect-timeout 1 --max-time 2 -G \\
319
+ --data-urlencode "mac=$_dibs_macs" --data-urlencode "host=$(hostname -s)" \\
320
+ "{base}claim" 2>/dev/null) && printf '*** %s ***\\n' "$_dibs_line"
321
+ unset _dibs_macs _dibs_line
322
+ fi
323
+ ;;
324
+ esac
325
+ """
326
+
327
+ PAGE = """<!doctype html>
328
+ <html lang="en"><head><meta charset="utf-8"><title>dibs</title>
329
+ <meta name="viewport" content="width=device-width, initial-scale=1">
330
+ <style>
331
+ body {{ font-family: system-ui, sans-serif; margin: 2rem auto; max-width: 60rem; padding: 0 1rem; }}
332
+ table {{ border-collapse: collapse; width: 100%; }}
333
+ td, th {{ text-align: left; padding: .4rem .6rem; border-bottom: 1px solid #ddd; }}
334
+ </style></head><body>
335
+ <h1>dibs</h1>
336
+ <p>Who is using which thing. Claim one with
337
+ <code>dibs claim &lt;name&gt; --for &lt;purpose&gt;</code>.</p>
338
+ <table><thead><tr><th>Name</th><th>Kind</th><th>Claim</th></tr></thead>
339
+ <tbody>
340
+ {rows}
341
+ </tbody></table>
342
+ <p><small>dibs {version}</small></p>
343
+ </body></html>
344
+ """