@vibelord/lanes 1.0.3 → 1.1.0

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.
Files changed (37) hide show
  1. package/README.md +55 -15
  2. package/bin/lanes-install.mjs +8 -2
  3. package/lib/plan.mjs +34 -1
  4. package/package.json +2 -2
  5. package/payload/lanes/README.md +91 -33
  6. package/payload/lanes/VERSION +1 -1
  7. package/payload/lanes/bin/lanes +994 -49
  8. package/payload/lanes/client/lanes-launch +160 -14
  9. package/payload/lanes/client/ssh.stanza.in +11 -2
  10. package/payload/lanes/docs/ARCHITECTURE.md +88 -3
  11. package/payload/lanes/docs/INSTALL.md +297 -43
  12. package/payload/lanes/docs/RUNBOOK.md +458 -31
  13. package/payload/lanes/docs/USERGUIDE.md +1612 -0
  14. package/payload/lanes/install/enable-phone.sh +8 -20
  15. package/payload/lanes/install/enable-web.sh +2 -2
  16. package/payload/lanes/install/host-install.sh +6 -0
  17. package/payload/lanes/install.sh +888 -52
  18. package/payload/lanes/libexec/enrol.py +695 -0
  19. package/payload/lanes/libexec/enrold.py +1264 -0
  20. package/payload/lanes/libexec/keys.py +644 -0
  21. package/payload/lanes/libexec/lanes_lib.py +14 -6
  22. package/payload/lanes/libexec/members.py +297 -0
  23. package/payload/lanes/libexec/notify.py +146 -21
  24. package/payload/lanes/libexec/qr.py +217 -0
  25. package/payload/lanes/libexec/site_edit.py +237 -0
  26. package/payload/lanes/libexec/web_tokens.py +315 -0
  27. package/payload/lanes/site/README.md +13 -1
  28. package/payload/lanes/site/beyondwork/site.conf +3 -0
  29. package/payload/lanes/site/site.example.conf +5 -0
  30. package/payload/lanes/sudo/one-time-root.sh +1 -1
  31. package/payload/lanes/systemd/lanes-enrold.service +22 -0
  32. package/payload/lanes/systemd/lanes-notify.service +4 -4
  33. package/payload/lanes/systemd/lanes-web.service +4 -0
  34. package/payload/lanes/web/Caddyfile +10 -0
  35. package/payload/lanes/web/README.md +8 -2
  36. package/payload/lanes/workspaces.example/README.md +6 -1
  37. package/payload/manifest.json +7 -4
@@ -0,0 +1,1264 @@
1
+ #!/usr/bin/env python3
2
+ """lanes-enrold — the tailnet enrolment service (W8 A3, LNS-42).
3
+
4
+ A new machine gets its SSH key into the workspace account without an admin editing authorized_keys: it redeems an
5
+ invite code, or files a request a member approves. `lanes-enrold.service` runs this file on 127.0.0.1:8184
6
+ (`LANES_ENROL_PORT`; `--port`), behind the loopback Caddy of `lanes-mobile.service` (`handle_path /enrol/*`, which
7
+ strips `/enrol`) behind Tailscale Serve (`https://<host>.<tailnet>.ts.net:8443/enrol/...`). Stdlib only.
8
+
9
+ Wire contract v1 (JSON; additive only — a client ignores keys it does not know). Request bodies are at most 8 KiB
10
+ (413 above, 411 without a Content-Length):
11
+
12
+ POST /redeem {code, pubkey, device, client_version}
13
+ 200 {device, owner, ssh_ready: true} the key is in the lanes-managed block
14
+ 403 {error: "invalid-or-expired"} ONE answer for an unknown, expired, revoked or used code
15
+ and for a key that is not one ssh-ed25519 public key
16
+ 400 {error: "invalid-device"|"bad-request"}, 409 {error: "device-taken"|"key-taken"} (only after a
17
+ valid code: nobody learns device names without one), 429 {error: "rate-limited"} + Retry-After
18
+ (5 failures per source in 10 minutes; the source is the client's tailnet address, from the
19
+ X-Forwarded-For Serve sets and Caddy keeps — see source_key; a 409 is not a failure: only a valid
20
+ code reaches it, and its holder retries with another --device), 503 {error: "server-error"|"busy"}
21
+ POST /request {pubkey, device, name?, client_version} needs request mode, a tailnet login and an allowed domain
22
+ 202 {id, expires_at} 24 h; the same login+device+key again returns the same one
23
+ 403 {error: "requests-disabled"|"no-identity"|"domain-not-allowed"}, 400 {error: "invalid-device"|
24
+ "invalid-pubkey"|"invalid-name"|"bad-request"}, 409 device-taken|key-taken, 429 too-many-requests
25
+ (3 open requests per login)
26
+ GET /request/<id> {id, state: pending|approved|denied|expired, expires_at}; 404 for another login's request
27
+ GET /site {alias, host_name, host_key, version, request_mode, caller, source} (`caller` = the login received,
28
+ `source` = the rate-limit source derived: both are the operator's live checks of the proxy chain)
29
+ GET|POST /pending HTML: the open requests with Approve/Deny forms, for a caller whose login owns a member record
30
+ (and passes the domain gate, in request mode; a key added by hand has no owner until `lanes keys
31
+ owner <device> <login>` names one). A form carries an HMAC token bound to the caller and the
32
+ request (per process: a restart asks for a reload), and a POST is decided only when its
33
+ Sec-Fetch-Site is `same-origin` or `none`: cross-site, same-site and ABSENT are refused (every
34
+ browser that reaches it through Serve sends the header; curl is not a way to decide —
35
+ `lanes enrol approve` is).
36
+
37
+ Identity is what Tailscale Serve injects for a user's device: `Tailscale-User-Login` / `Tailscale-User-Name` (a
38
+ non-ASCII value RFC 2047-encoded). Whether Serve replaces a header a client sends is the operator's live check
39
+ (RUNBOOK "Enrolment service"): `/site` echoes the login it received as `caller`. `request_mode` comes from, in this
40
+ order: LANES_ENROL_REQUEST_MODE in the environment (1 on, 0 invite-only); the pin `lanes enrol mode request|invite`
41
+ writes to STATE/enrol-mode (read on every request; a pin that cannot be read is invite-only); else the latch — on when
42
+ the FIRST request that came through Tailscale Serve (through_serve: its X-Forwarded-For names a tailnet address) carried
43
+ that header set. A loopback probe never decides it (DEF-1725). The operator pins the mode once the header check
44
+ passes. Serve replaces a forged header only on the tailnet path: a process on this host that reaches loopback can
45
+ send one itself (Caddy and this service cannot tell it from Serve), so request mode is safe only where no account but
46
+ the workspace account and root runs in the host's network namespace (DEF-1645, open; RUNBOOK "Trust boundary").
47
+ In request mode a redeemed invite belongs to the trusted caller: the owner's login and email are the caller's (its
48
+ display name too), whoever the invite was minted for, and the invite's own name and email are kept beside it as the
49
+ member's `invited_as` (invite_owner, invited_as). Without request mode a login is not trusted: /request and /pending
50
+ refuse, and a redeemed invite takes its owner from the invite (invite_owner: its email when that is an address, else
51
+ its name when that is itself a login — a display name never is, and then the member has no owner; no `invited_as`).
52
+ LANES_ENROL_DOMAINS (default beyondwork.ai; comma/space separated, `*` = any) gates /request and /pending.
53
+
54
+ Stores under ~/.local/state/lanes (L.STATE), every change under keys.members_lock() — the lock `lanes keys` holds for
55
+ members/ and authorized_keys, so the CLI and this service never interleave:
56
+ invites/<id>.json written by `lanes invite` (enrol.py, W8 A4): {id, code_sha256, minted_at, minted_by {device,
57
+ login, unix_user}, expires_at, name, email, workspaces, redeemed_by, revoked_at};
58
+ code_sha256 = sha256 of canonical_code(code); minted_by.login is null from the CLI, so the
59
+ member's approved_by is too (minter_login). A redemption adds redeemed_by {device, login,
60
+ pubkey_sha256} and redeemed_at; `lanes invite revoke` sets revoked_at. A redeem
61
+ compares sha256(code) against EVERY live invite with hmac.compare_digest (no early exit).
62
+ For an invite-only server `lanes invite` should record an `email` (or a handle as
63
+ `name`): that is where a redeemed member's owner login comes from.
64
+ enrol-requests/<id>.json {id, state, login, name, email, device, pubkey, pubkey_sha256, client_version,
65
+ created_at, expires_at, decided_by, decided_at}; `expired` is derived, never written.
66
+ The opt-in lanes-notify service (notify.py, W8 A5) reads them through load_requests and
67
+ pushes one note per new pending request.
68
+ members/<device>.json members.py's schema, via invite|request, written through keys.py (save_member, then
69
+ write_authorized_keys) — never devices/.
70
+ enrol-mode {version: 1, mode: request|invite, set_at, set_by}, written by `lanes enrol mode` (enrol.py,
71
+ through write_mode; `auto` removes it); read on every request, never written here.
72
+ enrol.log append-only, one line per attempt: `<UTC> <event> <id> <device> <login> fp=<SHA256:…> <result>`
73
+ (`-` for an unknown field). A code is never written anywhere, not even its id half on a miss.
74
+ A redeem or approval re-renders the whole block from the members keys.load_members accepts,
75
+ so a member file it cannot use loses its line (fail closed): each such file is one line
76
+ `<event> <id> <its device> <login> fp=- dropped-member` before the attempt's own, and the
77
+ journal names it as `lanes keys` does (report_dropped).
78
+ """
79
+ import argparse
80
+ import calendar
81
+ import collections
82
+ import email.errors
83
+ import email.header
84
+ import hashlib
85
+ import hmac
86
+ import html
87
+ import ipaddress
88
+ import itertools
89
+ import json
90
+ import os
91
+ import re
92
+ import secrets
93
+ import shutil
94
+ import socket
95
+ import socketserver
96
+ import sys
97
+ import threading
98
+ import time
99
+ import traceback
100
+ import urllib.parse
101
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
102
+ from pathlib import Path
103
+
104
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
105
+ import lanes_lib as L # noqa: E402
106
+ import keys as K # noqa: E402
107
+ import members as M # noqa: E402
108
+
109
+ WIRE_VERSION = 1
110
+ DEFAULT_PORT = 8184 # web/Caddyfile `handle_path /enrol/*` forwards here
111
+ MAX_BODY = 8 * 1024
112
+ DISCARD_LIMIT = 1024 * 1024 # an oversize body is read away up to this, so the 413 reaches the proxy
113
+ CODE_ALPHABET = '23456789abcdefghjkmnpqrstuvwxyz' # 31 characters: no 0/1/i/l/o; codes are LN-<id4>-<secret12>
114
+ MAX_CODE = 64
115
+ INVITE_TTL = 7 * 86400 # what `lanes invite` mints (A4); enrold reads expires_at, never this
116
+ REQUEST_TTL = 24 * 3600
117
+ REQUEST_ID_LENGTH = 8
118
+ REDEEM_FAILURES = 5
119
+ REDEEM_WINDOW = 600
120
+ MAX_SOURCES = 4096
121
+ OPEN_REQUESTS = 3
122
+ DEFAULT_DOMAINS = ('beyondwork.ai',)
123
+ HOST_KEY = Path('/etc/ssh/ssh_host_ed25519_key.pub')
124
+ TAILNET_TTL = 300
125
+ INVALID = 'invalid-or-expired'
126
+ LOGIN_HEADER = 'Tailscale-User-Login'
127
+ NAME_HEADER = 'Tailscale-User-Name'
128
+ STATES = ('pending', 'approved', 'denied') # stored; `expired` is derived from expires_at
129
+ PINNED_MODES = ('request', 'invite') # STATE/enrol-mode (`lanes enrol mode`); no file = auto
130
+ MODE_VERSION = 1
131
+ ACTIONS = ('approve', 'deny')
132
+ HTML_POLICY = "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'"
133
+ _HEX64 = re.compile(r'[0-9a-f]{64}')
134
+ _REQUEST_ID = re.compile(r'[a-z0-9]{1,32}')
135
+ _CLIENT_VERSION = re.compile(r'[A-Za-z0-9._+-]{1,32}')
136
+ _STAMP = re.compile(r'\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ', re.ASCII)
137
+ _LOG_UNSAFE = re.compile(r'[^A-Za-z0-9_.@:+=/-]')
138
+ _SITE_LINE = re.compile(r'^(LANES_[A-Z_]+)=([A-Za-z0-9_.:@/-]*)$')
139
+ _ADDRESS = re.compile(r'[^@\s]+@[^@\s]+\.[^@\s]+')
140
+
141
+ Caller = collections.namedtuple('Caller', 'login name present')
142
+ NOBODY = Caller(None, None, False)
143
+
144
+
145
+ # ------------------------------------------------------------------------------------------------------- basics
146
+
147
+ def stamp(t):
148
+ return time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(t))
149
+
150
+
151
+ def parse_stamp(value):
152
+ """A UTC `YYYY-MM-DDTHH:MM:SSZ` -> epoch seconds, else None (an unreadable expiry is an expired one)."""
153
+ if not isinstance(value, str) or not _STAMP.fullmatch(value):
154
+ return None
155
+ try:
156
+ return calendar.timegm(time.strptime(value, '%Y-%m-%dT%H:%M:%SZ'))
157
+ except ValueError:
158
+ return None
159
+
160
+
161
+ def invites_dir():
162
+ return L.STATE / 'invites'
163
+
164
+
165
+ def requests_dir():
166
+ return L.STATE / 'enrol-requests'
167
+
168
+
169
+ def log_path():
170
+ return L.STATE / 'enrol.log'
171
+
172
+
173
+ def mode_path():
174
+ return L.STATE / 'enrol-mode'
175
+
176
+
177
+ # ----------------------------------------------------------------------------------------------- the pinned mode
178
+ # `lanes enrol mode request|invite` (enrol.py) pins request mode in STATE/enrol-mode, so it no longer rests on which
179
+ # request happens to arrive first after each start; `auto` removes the file. The service reads it on every request (a
180
+ # change needs no restart). LANES_ENROL_REQUEST_MODE in the unit's environment still wins over it.
181
+
182
+ def read_mode():
183
+ """-> (mode, problem): mode 'request' | 'invite' | None (no pin: auto). A pin that cannot be read as
184
+ {version: 1, mode: request|invite, set_at, set_by} is 'invite' with the reason — fail closed, never auto: the
185
+ operator pinned something, and requests stay off until `lanes enrol mode` writes it again."""
186
+ path = mode_path()
187
+ try:
188
+ data = path.read_bytes()
189
+ except FileNotFoundError:
190
+ return None, None
191
+ except OSError as error:
192
+ return 'invite', f'{path} is unreadable ({error.strerror or error})'
193
+ document = _decoded(data)
194
+ if not (isinstance(document, dict) and document.get('version') == MODE_VERSION
195
+ and document.get('mode') in PINNED_MODES):
196
+ return 'invite', f'{path} is not a version {MODE_VERSION} mode pin'
197
+ return document['mode'], None
198
+
199
+
200
+ def write_mode(mode, set_by, now=None):
201
+ """Pin `mode` ('request' | 'invite'), or remove the pin for 'auto'. -> the document written (None for auto)."""
202
+ if mode == 'auto':
203
+ mode_path().unlink(missing_ok=True)
204
+ return None
205
+ if mode not in PINNED_MODES:
206
+ raise L.LanesError(f'unknown mode {mode}')
207
+ document = {'version': MODE_VERSION, 'mode': mode, 'set_at': stamp(time.time() if now is None else now),
208
+ 'set_by': set_by}
209
+ L.STATE.mkdir(parents=True, exist_ok=True)
210
+ L.atomic_write(mode_path(), _json_bytes(document), mode=0o600)
211
+ return document
212
+
213
+
214
+ def _decoded(data):
215
+ try:
216
+ return json.loads(data.decode('utf-8'))
217
+ except (UnicodeDecodeError, ValueError):
218
+ return None
219
+
220
+
221
+ def version():
222
+ try:
223
+ return (L.REPO / 'VERSION').read_text().strip() or 'dev'
224
+ except OSError:
225
+ return 'dev'
226
+
227
+
228
+ def canonical_code(code):
229
+ """The form a code is hashed in: blanks removed, lower case (codes are case-insensitive)."""
230
+ return ''.join(code.split()).lower()
231
+
232
+
233
+ def code_sha256(code):
234
+ """invites/<id>.json `code_sha256`: hex sha256 of canonical_code(code). `lanes invite` (A4) stores this."""
235
+ return hashlib.sha256(canonical_code(code).encode('utf-8')).hexdigest()
236
+
237
+
238
+ def parse_domains(value):
239
+ """LANES_ENROL_DOMAINS -> a tuple of lower-case domains (`*` = any); unset or blank = DEFAULT_DOMAINS."""
240
+ domains = tuple(part.lower() for part in re.split(r'[,\s]+', value or '') if part)
241
+ return domains or DEFAULT_DOMAINS
242
+
243
+
244
+ def through_serve(forwarded, peer):
245
+ """Whether a request came the Tailscale Serve way: its rate-limit source (source_key) is an address that is not
246
+ loopback — Serve sets X-Forwarded-For to the client's tailnet address and Caddy keeps it. A probe of 127.0.0.1:8184,
247
+ or of Caddy from this host, has none: it says nothing about what Serve injects, so it never decides request mode."""
248
+ try:
249
+ return not ipaddress.ip_address(source_key(forwarded, peer)).is_loopback
250
+ except ValueError:
251
+ return False
252
+
253
+
254
+ def source_key(forwarded, peer):
255
+ """The rate-limit source: the last X-Forwarded-For hop before the loopback proxies, else the socket peer. Tailscale
256
+ Serve SETS X-Forwarded-For to the client's tailnet address and connects from 127.0.0.1; the shipped Caddyfile
257
+ trusts that loopback peer (`trusted_proxies 127.0.0.0/8` on the /enrol/* route), so Caddy keeps the header and
258
+ appends its peer — `<100.x client>, 127.0.0.1` — and each tailnet device is its own bucket. A Caddy that does not
259
+ trust the peer replaces the header with `127.0.0.1`: then every device shares one bucket (fail closed, DEF-1641).
260
+ `/site` echoes the result as `source`, the operator's live check."""
261
+ hops = [part.strip() for part in (forwarded or '').split(',') if part.strip()]
262
+ while hops:
263
+ try:
264
+ if not ipaddress.ip_address(hops[-1]).is_loopback:
265
+ break
266
+ except ValueError:
267
+ break
268
+ hops.pop()
269
+ return (hops[-1] if hops else peer)[:64]
270
+
271
+
272
+ def _read_doc(path):
273
+ try:
274
+ document = json.loads(Path(path).read_bytes().decode('utf-8'))
275
+ except (OSError, UnicodeDecodeError, ValueError):
276
+ return None
277
+ return document if isinstance(document, dict) else None
278
+
279
+
280
+ def _json_bytes(document):
281
+ try:
282
+ return (json.dumps(document, indent=2, ensure_ascii=False) + '\n').encode('utf-8')
283
+ except UnicodeEncodeError:
284
+ return (json.dumps(document, indent=2) + '\n').encode('ascii')
285
+
286
+
287
+ def _private_dir(directory):
288
+ directory.mkdir(mode=0o700, parents=True, exist_ok=True)
289
+ if directory.stat().st_mode & 0o777 != 0o700:
290
+ os.chmod(directory, 0o700)
291
+
292
+
293
+ def _log_field(value):
294
+ return _LOG_UNSAFE.sub('?', str(value))[:128] if value else '-'
295
+
296
+
297
+ def audit(event, ident, device, login, fingerprint, result, now=None):
298
+ """Append one line to enrol.log (0600). Never raises: a log that cannot be written is said on stderr."""
299
+ line = ' '.join((stamp(time.time() if now is None else now), _log_field(event), _log_field(ident),
300
+ _log_field(device), _log_field(login), 'fp=' + _log_field(fingerprint), _log_field(result)))
301
+ try:
302
+ L.STATE.mkdir(parents=True, exist_ok=True)
303
+ handle = os.open(log_path(), os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600)
304
+ try:
305
+ os.write(handle, (line + '\n').encode('ascii', 'replace'))
306
+ finally:
307
+ os.close(handle)
308
+ except OSError as error:
309
+ print(f'lanes-enrold: cannot append to {log_path()}: {error.strerror or error}', file=sys.stderr, flush=True)
310
+
311
+
312
+ def report_dropped(event, ident, login, problems, now):
313
+ """Before a write that re-renders the block from keys.load_members' valid members: each member file it could not
314
+ use (`problems`, [(file name, reason)]) is left out — fail closed, its line goes — and that is said, never silent
315
+ (DEF-1729): on stderr as `lanes keys` says it (keys._warn_problems), and one enrol.log line per file with its
316
+ device and the result `dropped-member`. The reasons are members.py's and keys.py's, which never echo a value."""
317
+ for name, reason in problems:
318
+ device = name[:-len('.json')] if name.endswith('.json') else name
319
+ print(f'lanes-enrold: ⚠ members/{_log_field(name)}: {reason} — NOT authorized (fix or remove the file); '
320
+ f'{event} {_log_field(ident)} re-rendered the lanes-managed block without it', file=sys.stderr,
321
+ flush=True)
322
+ audit(event, ident, device, login, None, 'dropped-member', now)
323
+
324
+
325
+ # ----------------------------------------------------------------------------------------------------- identity
326
+
327
+ def _header_text(raw):
328
+ """A header value as text: http.server hands values over as latin-1, Tailscale sends UTF-8 or RFC 2047."""
329
+ if raw is None:
330
+ return None
331
+ try:
332
+ text = raw.encode('latin-1').decode('utf-8')
333
+ except (UnicodeEncodeError, UnicodeDecodeError):
334
+ return None
335
+ if '=?' in text:
336
+ try:
337
+ text = str(email.header.make_header(email.header.decode_header(text)))
338
+ except (ValueError, LookupError, UnicodeError, email.errors.HeaderParseError):
339
+ return None
340
+ return text.strip() or None
341
+
342
+
343
+ def _valid_name(name):
344
+ if name is None:
345
+ return None
346
+ try:
347
+ return M.validate_owner({'login': 'x', 'name': name})['name']
348
+ except L.LanesError:
349
+ return None
350
+
351
+
352
+ def _valid_email(value):
353
+ """`value` when members.py would store it as an owner's email (an address, printable, no blanks), else None."""
354
+ if _address(value) is None:
355
+ return None
356
+ try:
357
+ return M.validate_owner({'login': 'x', 'email': value})['email']
358
+ except L.LanesError:
359
+ return None
360
+
361
+
362
+ def caller_of(headers):
363
+ """The tailnet identity a request carries -> Caller(login, name, present). `present` = the header set Serve
364
+ injects (a non-empty login AND a name header, even an empty one); login/name are None when they do not validate
365
+ as members.py would store them."""
366
+ raw_login = headers.get(LOGIN_HEADER)
367
+ present = bool(raw_login and raw_login.strip()) and headers.get(NAME_HEADER) is not None
368
+ login = _header_text(raw_login)
369
+ try:
370
+ login = M.check_login(login) if login else None
371
+ except L.LanesError:
372
+ login = None
373
+ return Caller(login, _valid_name(_header_text(headers.get(NAME_HEADER))), present)
374
+
375
+
376
+ def _owner(*candidates):
377
+ for candidate in candidates:
378
+ try:
379
+ return M.validate_owner(candidate)
380
+ except L.LanesError:
381
+ continue
382
+ return None
383
+
384
+
385
+ def _address(login):
386
+ return login if isinstance(login, str) and _ADDRESS.fullmatch(login) else None
387
+
388
+
389
+ def _valid_login(value):
390
+ try:
391
+ return M.check_login(value) if value is not None else None
392
+ except L.LanesError:
393
+ return None
394
+
395
+
396
+ def _workspaces(value):
397
+ if not isinstance(value, list):
398
+ return []
399
+ kept = []
400
+ for workspace in value:
401
+ if isinstance(workspace, str) and L.IDENT.match(workspace) and len(workspace) <= M.MAX_ID and workspace not in kept:
402
+ kept.append(workspace)
403
+ return kept
404
+
405
+
406
+ # ------------------------------------------------------------------------------------------------------ invites
407
+
408
+ def load_invites():
409
+ """[(id, path, document)] for every readable invites/<id>.json whose stem is an id."""
410
+ found = []
411
+ for path in sorted(invites_dir().glob('*.json')):
412
+ if not L.IDENT.match(path.stem) or len(path.stem) > M.MAX_ID:
413
+ continue
414
+ document = _read_doc(path)
415
+ if document is not None:
416
+ found.append((path.stem, path, document))
417
+ return found
418
+
419
+
420
+ def invite_live(document, now):
421
+ """Usable now: a well-formed hash, not used, not revoked, not past expires_at (an unreadable expiry is past)."""
422
+ expires = parse_stamp(document.get('expires_at'))
423
+ return (isinstance(document.get('code_sha256'), str) and _HEX64.fullmatch(document['code_sha256']) is not None
424
+ and document.get('redeemed_by') is None and document.get('revoked_at') is None
425
+ and expires is not None and now < expires)
426
+
427
+
428
+ def match_invite(code, now):
429
+ """(id, path, digest) of the live invite whose hash is sha256(code), else None. The digest is compared against
430
+ EVERY live invite (hmac.compare_digest, the loop never stops early), so the time taken says nothing about which
431
+ invite, if any, matched; dead invites are never candidates, so expired/revoked/used look like unknown."""
432
+ presented = canonical_code(code) if isinstance(code, str) and len(code) <= MAX_CODE and code.isascii() else ''
433
+ digest = hashlib.sha256(presented.encode('ascii')).hexdigest()
434
+ found = None
435
+ for invite_id, path, document in load_invites():
436
+ if invite_live(document, now) and hmac.compare_digest(digest, document['code_sha256']) and found is None:
437
+ found = (invite_id, path, digest)
438
+ return found if presented else None
439
+
440
+
441
+ def minter_login(invite):
442
+ """The login that minted `invite`, as the member's `approved_by`, or None. `lanes invite` (A4) writes `minted_by`
443
+ as {device, login, unix_user} with `login` null: over SSH every login is the one Unix account, so the CLI claims
444
+ no tailnet login, and an invite it minted yields a member approved by nobody named (DEF-1646)."""
445
+ minted = invite.get('minted_by')
446
+ return _valid_login(minted.get('login')) if isinstance(minted, dict) else None
447
+
448
+
449
+ def invite_owner(invite, caller):
450
+ """The member owner a redeemed invite records, or None.
451
+ With a TRUSTED caller (request mode: a validated tailnet login) the member is the caller's: login, email (the login
452
+ when it is an address) and display name all come from the caller, never from the invite — an invite minted for
453
+ anna@ and redeemed by otto@ is otto@'s device, and the invite's own name and email go to `invited_as` beside it
454
+ (invited_as; DEF-1732). Without one, the login comes only from something that IS a login: the invite's email when
455
+ it is an address, else the invite's name when it is itself a login (a handle such as `anna`). A display name such
456
+ as "Anna Berg" is never a login (members.check_login refuses blanks), so an invite that names only that records no
457
+ owner: the member keeps its invite_id, the invite file keeps the name, and `lanes keys owner` names the owner later
458
+ (DEF-1644)."""
459
+ if caller.login:
460
+ login, email = caller.login, _address(caller.login)
461
+ return _owner({'login': login, 'name': caller.name, 'email': email},
462
+ {'login': login, 'name': None, 'email': email}, {'login': login, 'name': None, 'email': None})
463
+ login = next((value for value in (_address(invite.get('email')), invite.get('name')) if _valid_login(value)), None)
464
+ if login is None:
465
+ return None
466
+ name = invite.get('name') or caller.name
467
+ email = _address(invite.get('email'))
468
+ return _owner({'login': login, 'name': name, 'email': email}, {'login': login, 'name': name, 'email': None},
469
+ {'login': login, 'name': None, 'email': None})
470
+
471
+
472
+ def invited_as(invite):
473
+ """The invite's own {name, email} as a member records it beside a trusted caller's owner (`invited_as`): each the
474
+ value members.py would store, else null — an invite file's text never reaches a member record unchecked."""
475
+ return {'name': _valid_name(invite.get('name')), 'email': _valid_email(invite.get('email'))}
476
+
477
+
478
+ # ---------------------------------------------------------------------------------------------- shared checks
479
+
480
+ def device_taken(device, data):
481
+ """Whether `device` already names a member, a view device, or a `lanes:<device>` line in or outside the block of
482
+ authorized_keys `data` (malformed markers raise L.LanesError)."""
483
+ if (K.members_dir() / f'{device}.json').exists() or (L.STATE / 'devices' / f'{device}.json').exists():
484
+ return True
485
+ if K.block_entries(data, device):
486
+ return True
487
+ return any(K._lanes_device(parsed) == device for _, _, parsed in K.outside_entries(data))
488
+
489
+
490
+ def _authorized_keys():
491
+ """(path, bytes) of authorized_keys, its markers checked: raises L.LanesError (malformed) or OSError."""
492
+ path = K.authorized_keys_path()
493
+ data = K._read(path)
494
+ K.split_block(data)
495
+ return path, data
496
+
497
+
498
+ def _block_lacks(path, members, device):
499
+ """After a failed block write: re-render without `device` (best effort) and read back whether the block is free of
500
+ it. True only when that is known — the caller then may drop the member file without leaving a line behind it."""
501
+ try:
502
+ K.write_authorized_keys(path, members)
503
+ except (OSError, L.LanesError):
504
+ pass
505
+ try:
506
+ return not K.block_entries(K._read(path), device)
507
+ except (OSError, L.LanesError):
508
+ return False
509
+
510
+
511
+ # ------------------------------------------------------------------------------------------------------- redeem
512
+
513
+ def redeem(fields, caller=None, now=None):
514
+ """POST /redeem -> (status, payload). `caller` is the TRUSTED tailnet identity (None outside request mode)."""
515
+ now = time.time() if now is None else now
516
+ caller = caller or NOBODY
517
+ try:
518
+ device = M.device_name(fields.get('device'))
519
+ except L.LanesError:
520
+ audit('redeem', None, None, caller.login, None, 'invalid-device', now)
521
+ return 400, {'error': 'invalid-device'}
522
+ try:
523
+ pubkey = K.parse_pubkey(fields['pubkey']) if isinstance(fields.get('pubkey'), str) else None
524
+ except L.LanesError:
525
+ pubkey = None
526
+ fingerprint = K.fingerprint(pubkey) if pubkey else None
527
+ match = match_invite(fields.get('code'), now)
528
+ if match is None or pubkey is None:
529
+ audit('redeem', None, device, caller.login, fingerprint, INVALID, now)
530
+ return 403, {'error': INVALID}
531
+ try:
532
+ with K.members_lock():
533
+ return _redeem_locked(match, device, pubkey, fingerprint, caller, now)
534
+ except L.LanesError: # only the lock raises out of _redeem_locked
535
+ audit('redeem', match[0], device, caller.login, fingerprint, 'busy', now)
536
+ return 503, {'error': 'busy'}
537
+
538
+
539
+ def _redeem_locked(match, device, pubkey, fingerprint, caller, now):
540
+ invite_id, path, digest = match
541
+ invite = _read_doc(path)
542
+ if invite is None or not invite_live(invite, now) or not hmac.compare_digest(digest, invite['code_sha256']):
543
+ audit('redeem', None, device, caller.login, fingerprint, INVALID, now) # used while we waited
544
+ return 403, {'error': INVALID}
545
+ try:
546
+ ak, data = _authorized_keys()
547
+ taken = device_taken(device, data)
548
+ members, problems = K.load_members()
549
+ owner = invite_owner(invite, caller)
550
+ record = K.member_record(device, pubkey, via='invite', now=now, owner=owner,
551
+ approved_by=minter_login(invite), invite_id=invite_id,
552
+ workspaces=_workspaces(invite.get('workspaces')),
553
+ **({'invited_as': invited_as(invite)} if caller.login else {}))
554
+ original = path.read_bytes()
555
+ except (OSError, L.LanesError) as error:
556
+ print(f'lanes-enrold: redeem {invite_id}: {error}', file=sys.stderr, flush=True)
557
+ audit('redeem', invite_id, device, caller.login, fingerprint, 'server-error', now)
558
+ return 503, {'error': 'server-error'}
559
+ who = owner['login'] if owner else caller.login
560
+ if taken:
561
+ audit('redeem', invite_id, device, who, fingerprint, 'device-taken', now)
562
+ return 409, {'error': 'device-taken'}
563
+ if any(member['pubkey'] == pubkey for member in members):
564
+ audit('redeem', invite_id, device, who, fingerprint, 'key-taken', now)
565
+ return 409, {'error': 'key-taken'}
566
+ consumed = dict(invite, redeemed_by={'device': device, 'login': who, 'pubkey_sha256': fingerprint},
567
+ redeemed_at=stamp(now))
568
+ try:
569
+ L.atomic_write(path, _json_bytes(consumed), mode=0o600) # used up FIRST: a crash never leaves it reusable
570
+ except OSError as error:
571
+ print(f'lanes-enrold: cannot mark invite {invite_id} used: {error.strerror}', file=sys.stderr, flush=True)
572
+ audit('redeem', invite_id, device, who, fingerprint, 'server-error', now)
573
+ return 503, {'error': 'server-error'}
574
+ report_dropped('redeem', invite_id, who, problems, now) # the writes below re-render the block
575
+ try:
576
+ K.save_member(record) # the record before its line (keys.py's order)
577
+ K.write_authorized_keys(ak, members + [record])
578
+ except (OSError, L.LanesError) as error:
579
+ print(f'lanes-enrold: redeem {invite_id} for {device}: {error}', file=sys.stderr, flush=True)
580
+ if _block_lacks(ak, members, device): # nothing lets it in: undo the record and the use
581
+ try:
582
+ (K.members_dir() / f'{device}.json').unlink(missing_ok=True)
583
+ L.atomic_write(path, original, mode=0o600)
584
+ except OSError as undo:
585
+ print(f'lanes-enrold: undo of {invite_id} incomplete: {undo.strerror}', file=sys.stderr, flush=True)
586
+ audit('redeem', invite_id, device, who, fingerprint, 'server-error', now)
587
+ return 503, {'error': 'server-error'}
588
+ if owner is None: # said, never silent (DEF-1644)
589
+ print(f'lanes-enrold: invite {invite_id} names no login (no trusted tailnet login, no email address, and its '
590
+ f'name is not a login): members/{device}.json has no owner — `lanes keys owner {device} <login>` '
591
+ 'names one', file=sys.stderr, flush=True)
592
+ audit('redeem', invite_id, device, who, fingerprint, 'ok', now)
593
+ return 200, {'device': device, 'owner': owner, 'ssh_ready': True}
594
+
595
+
596
+ # ----------------------------------------------------------------------------------------------------- requests
597
+
598
+ def _valid_request(document, stem):
599
+ try:
600
+ return bool(document.get('id') == stem and _REQUEST_ID.fullmatch(stem) and document.get('state') in STATES
601
+ and M.device_name(document.get('device'))
602
+ and M.validate_owner({'login': document.get('login'), 'name': document.get('name'),
603
+ 'email': document.get('email')})
604
+ and isinstance(document.get('pubkey'), str)
605
+ and K.parse_pubkey(document['pubkey']) == document['pubkey']
606
+ and parse_stamp(document.get('expires_at')) is not None)
607
+ except L.LanesError:
608
+ return False
609
+
610
+
611
+ def load_requests(directory=None):
612
+ """Every valid enrol-requests/<id>.json, oldest first (invalid files are skipped). `directory` defaults to
613
+ requests_dir(); notify.py (W8 A5) passes the enrol-requests/ of the STATE it watches."""
614
+ found = []
615
+ for path in sorted(Path(directory if directory is not None else requests_dir()).glob('*.json')):
616
+ document = _read_doc(path)
617
+ if document is not None and _valid_request(document, path.stem):
618
+ found.append(document)
619
+ return sorted(found, key=lambda document: (document.get('created_at') or '', document['id']))
620
+
621
+
622
+ def read_request(request_id):
623
+ """The valid request `request_id` (case-insensitive), else None."""
624
+ if not isinstance(request_id, str):
625
+ return None
626
+ request_id = request_id.lower()
627
+ if not _REQUEST_ID.fullmatch(request_id):
628
+ return None
629
+ document = _read_doc(requests_dir() / f'{request_id}.json')
630
+ return document if document is not None and _valid_request(document, request_id) else None
631
+
632
+
633
+ def request_state(document, now):
634
+ if document['state'] == 'pending' and now >= parse_stamp(document['expires_at']):
635
+ return 'expired'
636
+ return document['state']
637
+
638
+
639
+ def _write_request(document):
640
+ _private_dir(requests_dir())
641
+ L.atomic_write(requests_dir() / f"{document['id']}.json", _json_bytes(document), mode=0o600)
642
+
643
+
644
+ def _new_request_id():
645
+ while True:
646
+ candidate = ''.join(secrets.choice(CODE_ALPHABET) for _ in range(REQUEST_ID_LENGTH))
647
+ if not (requests_dir() / f'{candidate}.json').exists():
648
+ return candidate
649
+
650
+
651
+ def create_request(fields, caller, now=None):
652
+ """POST /request -> (status, payload). `caller` is the TRUSTED identity: the HTTP layer has checked request mode,
653
+ the login and the domain."""
654
+ now = time.time() if now is None else now
655
+ login = caller.login
656
+ try:
657
+ device = M.device_name(fields.get('device'))
658
+ except L.LanesError:
659
+ audit('request', None, None, login, None, 'invalid-device', now)
660
+ return 400, {'error': 'invalid-device'}
661
+ try:
662
+ pubkey = K.parse_pubkey(fields['pubkey']) if isinstance(fields.get('pubkey'), str) else None
663
+ except L.LanesError:
664
+ pubkey = None
665
+ if pubkey is None:
666
+ audit('request', None, device, login, None, 'invalid-pubkey', now)
667
+ return 400, {'error': 'invalid-pubkey'}
668
+ fingerprint = K.fingerprint(pubkey)
669
+ name = fields.get('name')
670
+ if name is not None:
671
+ try:
672
+ name = M.validate_owner({'login': login, 'name': L._title(name)})['name']
673
+ except L.LanesError:
674
+ audit('request', None, device, login, fingerprint, 'invalid-name', now)
675
+ return 400, {'error': 'invalid-name'}
676
+ owner = _owner({'login': login, 'name': name or caller.name, 'email': _address(login)},
677
+ {'login': login, 'name': None, 'email': _address(login)})
678
+ if owner is None:
679
+ return 403, {'error': 'no-identity'}
680
+ client = fields.get('client_version')
681
+ client = client if isinstance(client, str) and _CLIENT_VERSION.fullmatch(client) else None
682
+ try:
683
+ with K.members_lock():
684
+ pending = [r for r in load_requests() if request_state(r, now) == 'pending']
685
+ mine = [r for r in pending if r['login'].casefold() == login.casefold()]
686
+ for request in mine:
687
+ if request['device'] == device and request['pubkey'] == pubkey:
688
+ return 202, {'id': request['id'], 'expires_at': request['expires_at']}
689
+ if len(mine) >= OPEN_REQUESTS:
690
+ audit('request', None, device, login, fingerprint, 'too-many-requests', now)
691
+ return 429, {'error': 'too-many-requests'}
692
+ try:
693
+ _, data = _authorized_keys()
694
+ taken = device_taken(device, data) or any(r['device'] == device for r in pending)
695
+ members, _problems = K.load_members()
696
+ except (OSError, L.LanesError) as error:
697
+ print(f'lanes-enrold: request for {device}: {error}', file=sys.stderr, flush=True)
698
+ audit('request', None, device, login, fingerprint, 'server-error', now)
699
+ return 503, {'error': 'server-error'}
700
+ if taken:
701
+ audit('request', None, device, login, fingerprint, 'device-taken', now)
702
+ return 409, {'error': 'device-taken'}
703
+ if any(m['pubkey'] == pubkey for m in members) or any(r['pubkey'] == pubkey for r in pending):
704
+ audit('request', None, device, login, fingerprint, 'key-taken', now)
705
+ return 409, {'error': 'key-taken'}
706
+ document = {'id': _new_request_id(), 'state': 'pending', 'login': owner['login'], 'name': owner['name'],
707
+ 'email': owner['email'], 'device': device, 'pubkey': pubkey, 'pubkey_sha256': fingerprint,
708
+ 'client_version': client, 'created_at': stamp(now), 'expires_at': stamp(now + REQUEST_TTL),
709
+ 'decided_by': None, 'decided_at': None}
710
+ try:
711
+ _write_request(document)
712
+ except OSError as error:
713
+ print(f'lanes-enrold: cannot write a request: {error.strerror}', file=sys.stderr, flush=True)
714
+ audit('request', None, device, login, fingerprint, 'server-error', now)
715
+ return 503, {'error': 'server-error'}
716
+ except L.LanesError: # the lock
717
+ return 503, {'error': 'busy'}
718
+ audit('request', document['id'], device, login, fingerprint, 'pending', now)
719
+ return 202, {'id': document['id'], 'expires_at': document['expires_at']}
720
+
721
+
722
+ def decide(request_id, action, approver, now=None):
723
+ """Approve or deny a pending request as `approver` (a login) -> the updated request document. Approval writes the
724
+ member (via request, approved_by) and its line through keys.py. Raises L.LanesError with a sentence on every
725
+ refusal: no such request, not pending, the device or key taken since, a write that failed (nothing is left
726
+ authorized by a failed approval). `lanes enrol approve|deny` (A4) and POST /pending both call this."""
727
+ now = time.time() if now is None else now
728
+ if action not in ACTIONS:
729
+ raise L.LanesError(f'unknown action {action!r}: approve or deny')
730
+ approver = M.check_login(approver, 'approver')
731
+ shown = request_id.lower() if isinstance(request_id, str) and _REQUEST_ID.fullmatch(request_id.lower()) else '?'
732
+ with K.members_lock():
733
+ document = read_request(request_id)
734
+ if document is None:
735
+ raise L.LanesError(f'no enrolment request {shown}')
736
+ try:
737
+ state = request_state(document, now)
738
+ if state != 'pending':
739
+ raise L.LanesError(f"request {document['id']} is {state}, not pending")
740
+ if action == 'approve':
741
+ _approve_locked(document, approver, now)
742
+ document = dict(document, state='approved' if action == 'approve' else 'denied', decided_by=approver,
743
+ decided_at=stamp(now))
744
+ try:
745
+ _write_request(document)
746
+ except OSError as error:
747
+ raise L.LanesError(f"request {document['id']}: the decision could not be recorded "
748
+ f'({error.strerror}); ' + ('the key IS authorized — run the approval again to '
749
+ 'record it' if action == 'approve' else
750
+ 'run the denial again')) from None
751
+ except L.LanesError as error:
752
+ audit(action, document['id'], document['device'], approver, document['pubkey_sha256'], 'refused', now)
753
+ raise error
754
+ audit(action, document['id'], document['device'], approver, document['pubkey_sha256'], 'ok', now)
755
+ return document
756
+
757
+
758
+ def _approve_locked(document, approver, now):
759
+ device, pubkey = document['device'], document['pubkey']
760
+ try:
761
+ ak, data = _authorized_keys()
762
+ except OSError as error:
763
+ raise L.LanesError(f'cannot read authorized_keys ({error.strerror}); nothing changed') from None
764
+ members, problems = K.load_members()
765
+ current = next((m for m in members if m['device'] == device), None)
766
+ if current is not None and current['pubkey'] == pubkey and current.get('request_id') == document['id']:
767
+ report_dropped('approve', document['id'], approver, problems, now)
768
+ try:
769
+ K.write_authorized_keys(ak, members) # an approval interrupted after its member write: finish it
770
+ except OSError as error:
771
+ raise L.LanesError(f'cannot write {ak} ({error.strerror}); members/{device}.json is kept — the same '
772
+ 'approval again finishes it') from None
773
+ return
774
+ if device_taken(device, data):
775
+ raise L.LanesError(f'device {device} is already enrolled or in use since the request; deny it and ask for '
776
+ 'another device name')
777
+ holder = next((m['device'] for m in members if m['pubkey'] == pubkey), None)
778
+ if holder:
779
+ raise L.LanesError(f'that key is already enrolled as {holder}')
780
+ owner = M.validate_owner({'login': document['login'], 'name': document.get('name'),
781
+ 'email': document.get('email')})
782
+ record = K.member_record(device, pubkey, via='request', now=now, owner=owner, approved_by=approver,
783
+ request_id=document['id'])
784
+ try:
785
+ K.save_member(record)
786
+ except OSError as error:
787
+ (K.members_dir() / f'{device}.json').unlink(missing_ok=True)
788
+ raise L.LanesError(f'cannot write members/{device}.json ({error.strerror}); nothing was authorized') from None
789
+ report_dropped('approve', document['id'], approver, problems, now) # the write below re-renders the block
790
+ try:
791
+ K.write_authorized_keys(ak, members + [record])
792
+ except (OSError, L.LanesError) as error:
793
+ reason = getattr(error, 'strerror', None) or str(error)
794
+ if _block_lacks(ak, members, device):
795
+ (K.members_dir() / f'{device}.json').unlink(missing_ok=True)
796
+ raise L.LanesError(f'cannot write {ak} ({reason}); nothing was authorized') from None
797
+ raise L.LanesError(f'cannot write {ak} ({reason}); members/{device}.json is kept — `lanes keys revoke '
798
+ f'{device}` or the same approval again finishes it') from None
799
+
800
+
801
+ def member_logins():
802
+ """The logins (case-folded) that own a valid member record: who may see and decide requests."""
803
+ members, _problems = K.load_members()
804
+ return {m['owner']['login'].casefold() for m in members if isinstance(m.get('owner'), dict)}
805
+
806
+
807
+ # ------------------------------------------------------------------------------------------------------- /site
808
+
809
+ _TAILNET = {'at': None, 'name': None}
810
+ _TAILNET_LOCK = threading.Lock()
811
+
812
+
813
+ def server_site():
814
+ """The server's own ~/.config/lanes/site.conf LANES_* facts (the file `lanes client-config` serves)."""
815
+ try:
816
+ lines = (Path.home() / '.config/lanes/site.conf').read_text().splitlines()
817
+ except (OSError, UnicodeDecodeError):
818
+ return {}
819
+ return dict(found.groups() for found in map(_SITE_LINE.match, lines) if found)
820
+
821
+
822
+ def tailnet_name():
823
+ """This node's MagicDNS name from `tailscale status --json` (bounded, cached for TAILNET_TTL s), else None."""
824
+ with _TAILNET_LOCK:
825
+ if _TAILNET['at'] is not None and time.monotonic() - _TAILNET['at'] < TAILNET_TTL:
826
+ return _TAILNET['name']
827
+ name = None
828
+ if shutil.which('tailscale'):
829
+ code, out, _err = L.run_bounded(['tailscale', 'status', '--json'], 10)
830
+ if code == 0:
831
+ try:
832
+ name = ((json.loads(out).get('Self') or {}).get('DNSName') or '').rstrip('.') or None
833
+ except (ValueError, AttributeError, TypeError):
834
+ name = None
835
+ _TAILNET.update(at=time.monotonic(), name=name)
836
+ return name
837
+
838
+
839
+ def host_key():
840
+ """The server's ed25519 host key as `ssh-ed25519 <base64>` (comment dropped), best effort: None when absent."""
841
+ try:
842
+ return K.parse_pubkey(HOST_KEY.read_text()[:K.MAX_PUBKEY_FILE])
843
+ except (OSError, UnicodeDecodeError, L.LanesError):
844
+ return None
845
+
846
+
847
+ def site_facts(caller, request_mode, source=None):
848
+ site = server_site()
849
+ return {'alias': site.get('LANES_SERVER_ALIAS') or socket.gethostname().split('.')[0] or None,
850
+ 'host_name': site.get('LANES_SERVER_NAME') or tailnet_name(),
851
+ 'host_key': host_key(), 'version': version(), 'request_mode': request_mode, 'caller': caller.login,
852
+ 'source': source}
853
+
854
+
855
+ # --------------------------------------------------------------------------------------------------- the service
856
+
857
+ class Service:
858
+ """Process-wide state: configuration, the request-mode latch, the redeem rate limiter and the form secret."""
859
+
860
+ def __init__(self, domains=None, request_mode=None, clock=time.time):
861
+ self.domains = tuple(domains) if domains else DEFAULT_DOMAINS
862
+ self.forced = request_mode
863
+ self.clock = clock
864
+ self.secret = secrets.token_bytes(32)
865
+ self._first = None
866
+ self._pin_problem = None
867
+ self._lock = threading.Lock()
868
+ self._attempts = {}
869
+ self._tokens = itertools.count(1)
870
+
871
+ @classmethod
872
+ def from_environ(cls, environ):
873
+ forced = {'1': True, '0': False}.get((environ.get('LANES_ENROL_REQUEST_MODE') or '').strip())
874
+ return cls(domains=parse_domains(environ.get('LANES_ENROL_DOMAINS')), request_mode=forced)
875
+
876
+ def observe(self, caller, via_serve=True):
877
+ """The auto latch: the first request that came through Serve (through_serve) decides, for the life of the
878
+ process, whether Serve injects identity. Anything else — a loopback probe — decides nothing."""
879
+ if not via_serve:
880
+ return
881
+ with self._lock:
882
+ if self._first is None:
883
+ self._first = bool(caller.present)
884
+
885
+ @property
886
+ def request_mode(self):
887
+ """The unit's LANES_ENROL_REQUEST_MODE, else the pin `lanes enrol mode` wrote, else the latch."""
888
+ if self.forced is not None:
889
+ return self.forced
890
+ pinned = self.pinned()
891
+ return pinned == 'request' if pinned is not None else bool(self._first)
892
+
893
+ def pinned(self):
894
+ mode, problem = read_mode()
895
+ if problem and problem != self._pin_problem: # said once per new problem, not on every request
896
+ print(f'lanes-enrold: {problem}: requests are refused until `lanes enrol mode request|invite|auto` '
897
+ 'writes it again', file=sys.stderr, flush=True)
898
+ self._pin_problem = problem
899
+ return mode
900
+
901
+ def mode_source(self):
902
+ """How request mode is decided now, in words (the start line; `lanes enrol mode` says the same)."""
903
+ if self.forced is not None:
904
+ return f"{'on' if self.forced else 'off'} (LANES_ENROL_REQUEST_MODE in the environment)"
905
+ pinned = self.pinned()
906
+ if pinned is not None:
907
+ return f"{'on' if pinned == 'request' else 'off'} (pinned: {mode_path()})"
908
+ return {None: 'decided by the first request through Tailscale Serve', True: 'on (the first request through '
909
+ 'Serve carried an identity)', False: 'off (the first request through Serve carried no identity)'}[
910
+ self._first]
911
+
912
+ def domain_ok(self, login):
913
+ if '*' in self.domains:
914
+ return True
915
+ return '@' in login and login.rpartition('@')[2].lower() in self.domains
916
+
917
+ def gate(self, caller):
918
+ """None when `caller` may file or decide requests, else the refusal's error word."""
919
+ if not self.request_mode:
920
+ return 'requests-disabled'
921
+ if not caller.login:
922
+ return 'no-identity'
923
+ if not self.domain_ok(caller.login):
924
+ return 'domain-not-allowed'
925
+ return None
926
+
927
+ # The limiter counts an attempt when it STARTS (check and count are one step, so a burst cannot run five wrong
928
+ # codes in parallel past it) and forgets it when it succeeds or ends in a 409, which only a valid code reaches.
929
+ def _recent(self, source, now):
930
+ kept = [entry for entry in self._attempts.get(source, ()) if now - entry[1] < REDEEM_WINDOW]
931
+ if kept:
932
+ self._attempts[source] = kept
933
+ else:
934
+ self._attempts.pop(source, None)
935
+ return kept
936
+
937
+ def attempt(self, source):
938
+ """-> (token, 0) when `source` may try a code now, else (None, seconds to wait)."""
939
+ now = self.clock()
940
+ with self._lock:
941
+ recent = self._recent(source, now)
942
+ if len(recent) >= REDEEM_FAILURES:
943
+ return None, int(recent[0][1] + REDEEM_WINDOW - now) + 1
944
+ token = next(self._tokens)
945
+ self._attempts[source] = recent + [(token, now)]
946
+ if len(self._attempts) > MAX_SOURCES:
947
+ for other in list(self._attempts):
948
+ self._recent(other, now)
949
+ while len(self._attempts) > MAX_SOURCES:
950
+ self._attempts.pop(min(self._attempts, key=lambda key: self._attempts[key][-1][1]))
951
+ return token, 0
952
+
953
+ def succeeded(self, source, token):
954
+ with self._lock:
955
+ kept = [entry for entry in self._attempts.get(source, ()) if entry[0] != token]
956
+ if kept:
957
+ self._attempts[source] = kept
958
+ else:
959
+ self._attempts.pop(source, None)
960
+
961
+ def token(self, login, request_id):
962
+ """The form token for `login` deciding `request_id` (HMAC under this process's secret)."""
963
+ message = f'{login.casefold()}\0{request_id}'.encode('utf-8')
964
+ return hmac.new(self.secret, message, hashlib.sha256).hexdigest()[:32]
965
+
966
+
967
+ # ---------------------------------------------------------------------------------------------------- the page
968
+
969
+ def pending_page(service, caller, now, message=None):
970
+ rows = []
971
+ for request in load_requests():
972
+ if request_state(request, now) != 'pending':
973
+ continue
974
+ e = {key: html.escape(str(request.get(key) or '-')) for key in
975
+ ('id', 'login', 'name', 'device', 'pubkey_sha256', 'created_at', 'expires_at', 'client_version')}
976
+ rows.append(
977
+ f'<li><p><b>{e["name"]}</b> &lt;{e["login"]}&gt;<br>device <code>{e["device"]}</code> · key '
978
+ f'<code>{e["pubkey_sha256"]}</code><br><small>asked {e["created_at"]} · expires {e["expires_at"]} · '
979
+ f'client {e["client_version"]} · request <code>{e["id"]}</code></small></p>'
980
+ f'<form method="post" action="pending"><input type="hidden" name="id" value="{e["id"]}">'
981
+ f'<input type="hidden" name="token" value="{service.token(caller.login, request["id"])}">'
982
+ '<button name="action" value="approve">Approve</button> '
983
+ '<button name="action" value="deny">Deny</button></form></li>')
984
+ body = f'<ul>{"".join(rows)}</ul>' if rows else '<p>No pending requests.</p>'
985
+ note = f'<p class="note">{html.escape(message)}</p>' if message else ''
986
+ return _page('Enrolment requests', f'<p class="who">Signed in as {html.escape(caller.login)}</p>{note}{body}'
987
+ '<p class="who">On the server: <code>lanes enrol list</code> · <code>lanes enrol approve '
988
+ '&lt;id&gt;</code></p>')
989
+
990
+
991
+ def _page(title, content):
992
+ return ('<!doctype html><html lang="en"><head><meta charset="utf-8">'
993
+ '<meta name="viewport" content="width=device-width, initial-scale=1">'
994
+ f'<title>{html.escape(title)}</title><style>'
995
+ ':root{color-scheme:light dark;font:16px/1.45 system-ui,sans-serif}'
996
+ 'body{margin:0 auto;max-width:40rem;padding:1rem}ul{list-style:none;padding:0}'
997
+ 'li{border:1px solid #8885;border-radius:.5rem;margin:.75rem 0;padding:.25rem .75rem .75rem}'
998
+ 'code{font-size:.85em;word-break:break-all}button{font:inherit;padding:.4rem 1rem;margin-right:.5rem}'
999
+ '.who{opacity:.7}.note{border-left:3px solid #888;padding-left:.5rem}'
1000
+ f'</style></head><body><h1>{html.escape(title)}</h1>{content}</body></html>').encode('utf-8')
1001
+
1002
+
1003
+ # -------------------------------------------------------------------------------------------------------- HTTP
1004
+
1005
+ class _Reply(Exception):
1006
+ def __init__(self, status, error):
1007
+ super().__init__(error)
1008
+ self.status, self.error = status, error
1009
+
1010
+
1011
+ class Handler(BaseHTTPRequestHandler):
1012
+ server_version = 'lanes-enrold'
1013
+ timeout = 15
1014
+ ROUTES = {'/site': ('GET',), '/redeem': ('POST',), '/request': ('POST',), '/pending': ('GET', 'POST')}
1015
+
1016
+ def version_string(self):
1017
+ return self.server_version
1018
+
1019
+ def log_message(self, format, *args): # no access log: enrol.log is the record, the journal the errors
1020
+ pass
1021
+
1022
+ def do_GET(self):
1023
+ self._route('GET')
1024
+
1025
+ def do_POST(self):
1026
+ self._route('POST')
1027
+
1028
+ # ------------------------------------------------------------------------------------------- plumbing
1029
+
1030
+ def _send(self, status, body, content_type, headers=()):
1031
+ if self.command == 'POST' and not self._body_read:
1032
+ self._discard() # an unread body would reset the proxy's connection
1033
+ self.send_response(status)
1034
+ self.send_header('Content-Type', content_type)
1035
+ self.send_header('Content-Length', str(len(body)))
1036
+ self.send_header('Cache-Control', 'no-store')
1037
+ self.send_header('X-Content-Type-Options', 'nosniff')
1038
+ for name, value in headers:
1039
+ self.send_header(name, value)
1040
+ self.end_headers()
1041
+ self.wfile.write(body)
1042
+
1043
+ def _json(self, status, payload, headers=()):
1044
+ self._send(status, json.dumps(payload).encode('utf-8'), 'application/json; charset=utf-8', headers)
1045
+
1046
+ def _html(self, status, body):
1047
+ self._send(status, body, 'text/html; charset=utf-8',
1048
+ (('Content-Security-Policy', HTML_POLICY), ('X-Frame-Options', 'DENY'),
1049
+ ('Referrer-Policy', 'no-referrer')))
1050
+
1051
+ def _discard(self):
1052
+ self._body_read = True
1053
+ try:
1054
+ left = min(max(int(self.headers.get('Content-Length') or 0), 0), DISCARD_LIMIT)
1055
+ except ValueError:
1056
+ left = 0
1057
+ try:
1058
+ while left > 0:
1059
+ chunk = self.rfile.read(min(left, 65536))
1060
+ if not chunk:
1061
+ break
1062
+ left -= len(chunk)
1063
+ except OSError:
1064
+ pass
1065
+ self.close_connection = True
1066
+
1067
+ def _body(self):
1068
+ if self.headers.get('Transfer-Encoding') is not None:
1069
+ self._body_read = True # chunked bodies are not read: answer and close
1070
+ raise _Reply(411, 'length-required')
1071
+ length = self.headers.get('Content-Length')
1072
+ if length is None:
1073
+ raise _Reply(411, 'length-required')
1074
+ try:
1075
+ size = int(length)
1076
+ except ValueError:
1077
+ size = -1
1078
+ if size < 0:
1079
+ self._body_read = True
1080
+ raise _Reply(400, 'bad-request')
1081
+ if size > MAX_BODY:
1082
+ self._discard()
1083
+ raise _Reply(413, 'too-large')
1084
+ data = self.rfile.read(size)
1085
+ self._body_read = True
1086
+ if len(data) != size:
1087
+ raise _Reply(400, 'bad-request')
1088
+ return data
1089
+
1090
+ def _json_body(self):
1091
+ data = self._body()
1092
+ try:
1093
+ document = json.loads(data.decode('utf-8'))
1094
+ except (UnicodeDecodeError, ValueError):
1095
+ raise _Reply(400, 'bad-request') from None
1096
+ if not isinstance(document, dict):
1097
+ raise _Reply(400, 'bad-request')
1098
+ return document
1099
+
1100
+ # --------------------------------------------------------------------------------------------- routes
1101
+
1102
+ def _route(self, method):
1103
+ self._body_read = False
1104
+ service = self.server.service
1105
+ try:
1106
+ caller = caller_of(self.headers)
1107
+ service.observe(caller, through_serve(self.headers.get('X-Forwarded-For'), self.client_address[0]))
1108
+ path = urllib.parse.urlsplit(self.path).path
1109
+ request_id = path[len('/request/'):] if path.startswith('/request/') else None
1110
+ allowed = ('GET',) if request_id is not None else self.ROUTES.get(path)
1111
+ if allowed is None:
1112
+ return self._json(404, {'error': 'not-found'})
1113
+ if method not in allowed:
1114
+ return self._json(405, {'error': 'method-not-allowed'}, (('Allow', ', '.join(allowed)),))
1115
+ if request_id is not None:
1116
+ return self._request_state(service, caller, request_id)
1117
+ if path == '/site':
1118
+ return self._json(200, site_facts(caller, service.request_mode, self._source()))
1119
+ if path == '/redeem':
1120
+ return self._redeem(service, caller)
1121
+ if path == '/request':
1122
+ return self._request(service, caller)
1123
+ return self._pending(service, caller, method)
1124
+ except _Reply as reply:
1125
+ return self._json(reply.status, {'error': reply.error})
1126
+ except Exception: # noqa: BLE001 — one request's bug never stops the service
1127
+ traceback.print_exc(file=sys.stderr)
1128
+ try:
1129
+ self._json(500, {'error': 'server-error'})
1130
+ except OSError:
1131
+ pass
1132
+
1133
+ def _source(self):
1134
+ """The rate-limit source of this request (source_key); `/site` echoes it."""
1135
+ return source_key(self.headers.get('X-Forwarded-For'), self.client_address[0])
1136
+
1137
+ def _redeem(self, service, caller):
1138
+ now = service.clock()
1139
+ trusted = caller if service.request_mode else NOBODY
1140
+ source = self._source()
1141
+ token, wait = service.attempt(source)
1142
+ if token is None:
1143
+ audit('redeem', None, None, trusted.login, None, 'rate-limited', now)
1144
+ return self._json(429, {'error': 'rate-limited'}, (('Retry-After', str(wait)),))
1145
+ try:
1146
+ fields = self._json_body()
1147
+ except _Reply as reply:
1148
+ audit('redeem', None, None, trusted.login, None, reply.error, now)
1149
+ raise
1150
+ status, payload = redeem(fields, trusted, now)
1151
+ # A 409 (device-taken | key-taken) is answered only after a valid, live code: its holder is retrying with
1152
+ # another --device, as install.sh says to, not guessing codes, so it is not charged (DEF-1731). Everything
1153
+ # else that is not a 200 — an invalid code, a malformed body, an invalid device — still counts.
1154
+ if status in (200, 409):
1155
+ service.succeeded(source, token)
1156
+ return self._json(status, payload)
1157
+
1158
+ def _request(self, service, caller):
1159
+ fields = self._json_body()
1160
+ refusal = service.gate(caller)
1161
+ if refusal:
1162
+ audit('request', None, None, caller.login, None, refusal, service.clock())
1163
+ return self._json(403, {'error': refusal})
1164
+ status, payload = create_request(fields, caller, service.clock())
1165
+ return self._json(status, payload)
1166
+
1167
+ def _request_state(self, service, caller, request_id):
1168
+ document = read_request(request_id) if service.request_mode and caller.login else None
1169
+ if document is None or document['login'].casefold() != caller.login.casefold():
1170
+ return self._json(404, {'error': 'not-found'})
1171
+ return self._json(200, {'id': document['id'], 'state': request_state(document, service.clock()),
1172
+ 'expires_at': document['expires_at']})
1173
+
1174
+ def _pending(self, service, caller, method):
1175
+ form = {}
1176
+ if method == 'POST':
1177
+ try:
1178
+ form = urllib.parse.parse_qs(self._body().decode('utf-8', 'replace'), max_num_fields=8)
1179
+ except ValueError: # more fields than an approve/deny form carries
1180
+ raise _Reply(400, 'bad-request') from None
1181
+ refusal = service.gate(caller)
1182
+ if refusal is None and caller.login.casefold() not in member_logins():
1183
+ refusal = 'not-a-member'
1184
+ if refusal:
1185
+ sentence = {'requests-disabled': 'Requests are off on this server (invite-only), so nobody is signed in '
1186
+ 'here.',
1187
+ 'no-identity': 'No tailnet identity reached this page: open it over the tailnet.',
1188
+ 'domain-not-allowed': 'Your tailnet login is not in a domain this server accepts.',
1189
+ 'not-a-member': 'Your tailnet login is not a member of this server, so you cannot approve '
1190
+ 'requests. A member record names its owner: on the server, run '
1191
+ f'`lanes keys owner <device> {caller.login}` for a device of yours '
1192
+ '(`lanes keys list` shows them).'}[refusal]
1193
+ return self._html(403, _page('Enrolment requests', f'<p>{html.escape(sentence)}</p>'))
1194
+ now = service.clock()
1195
+ if method == 'GET':
1196
+ return self._html(200, pending_page(service, caller, now))
1197
+ # A decision is made only on the browser's own word that the form came from this page (`same-origin`) or from
1198
+ # the user (`none`). An ABSENT header is refused too (DEF-1730): every browser that reaches /pending through
1199
+ # Serve's HTTPS sends it, and a client that does not (curl, a script) has `lanes enrol approve` instead.
1200
+ fetch_site = self.headers.get('Sec-Fetch-Site')
1201
+ if fetch_site not in ('same-origin', 'none'):
1202
+ reason = ('the form came from another site' if fetch_site else
1203
+ 'the browser did not say where the form came from (no Sec-Fetch-Site): use a current browser, '
1204
+ 'or on the server `lanes enrol approve <id>`')
1205
+ return self._html(403, _page('Enrolment requests', f'<p>Refused: {html.escape(reason)}.</p>'))
1206
+ one = {key: (form.get(key) or [''])[0] for key in ('id', 'action', 'token')}
1207
+ request_id = one['id'].lower()
1208
+ if (not _REQUEST_ID.fullmatch(request_id) or one['action'] not in ACTIONS
1209
+ or not hmac.compare_digest(one['token'].encode('utf-8', 'replace'),
1210
+ service.token(caller.login, request_id).encode())):
1211
+ audit(one['action'] if one['action'] in ACTIONS else 'decide', None, None, caller.login, None,
1212
+ 'bad-form', now)
1213
+ return self._html(403, _page('Enrolment requests', '<p>Refused: that form is stale or was not issued '
1214
+ 'here. Reload the page and try again.</p>'))
1215
+ try:
1216
+ document = decide(request_id, one['action'], caller.login, now)
1217
+ except L.LanesError as error:
1218
+ return self._html(409, pending_page(service, caller, now, f'Not done: {error}'))
1219
+ word = 'Approved' if document['state'] == 'approved' else 'Denied'
1220
+ message = f"{word} {document['device']} for {document['login']}."
1221
+ return self._html(200, pending_page(service, caller, now, message))
1222
+
1223
+
1224
+ class Server(ThreadingHTTPServer):
1225
+ daemon_threads = True
1226
+ request_queue_size = 32
1227
+
1228
+ def __init__(self, address, service):
1229
+ self.service = service
1230
+ super().__init__(address, Handler)
1231
+
1232
+ def server_bind(self): # HTTPServer's getfqdn() lookup is not needed on loopback
1233
+ socketserver.TCPServer.server_bind(self)
1234
+ self.server_name, self.server_port = self.server_address[:2]
1235
+
1236
+
1237
+ def main(argv=None):
1238
+ parser = argparse.ArgumentParser(prog='lanes-enrold', description='Lanes enrolment service (loopback only).')
1239
+ parser.add_argument('--port', type=int, default=None, help=f'default $LANES_ENROL_PORT or {DEFAULT_PORT}')
1240
+ args = parser.parse_args(argv)
1241
+ port = args.port
1242
+ if port is None:
1243
+ try:
1244
+ port = int(os.environ.get('LANES_ENROL_PORT') or DEFAULT_PORT)
1245
+ except ValueError:
1246
+ parser.error('LANES_ENROL_PORT is not a port number')
1247
+ if not 0 <= port <= 65535:
1248
+ parser.error(f'{port} is not a port number')
1249
+ service = Service.from_environ(os.environ)
1250
+ server = Server(('127.0.0.1', port), service)
1251
+ print(f'lanes-enrold {version()}: listening on 127.0.0.1:{server.server_address[1]}; request mode '
1252
+ f'{service.mode_source()}; '
1253
+ f'domains {",".join(service.domains)}', file=sys.stderr, flush=True)
1254
+ try:
1255
+ server.serve_forever()
1256
+ except KeyboardInterrupt:
1257
+ pass
1258
+ finally:
1259
+ server.server_close()
1260
+ return 0
1261
+
1262
+
1263
+ if __name__ == '__main__':
1264
+ sys.exit(main())