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/__init__.py +15 -0
- dibs/_sources.py +153 -0
- dibs/_sshsig.py +87 -0
- dibs/_store.py +327 -0
- dibs/_users.py +251 -0
- dibs/app.py +344 -0
- dibs/cli.py +727 -0
- pixie_lab_dibs-0.1.0.dist-info/METADATA +253 -0
- pixie_lab_dibs-0.1.0.dist-info/RECORD +12 -0
- pixie_lab_dibs-0.1.0.dist-info/WHEEL +4 -0
- pixie_lab_dibs-0.1.0.dist-info/entry_points.txt +2 -0
- pixie_lab_dibs-0.1.0.dist-info/licenses/LICENSE +28 -0
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 <name> --for <purpose></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
|
+
"""
|