@vibelord/lanes 1.0.4 → 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 +52 -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 +39 -18
  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 +86 -2
  11. package/payload/lanes/docs/INSTALL.md +296 -43
  12. package/payload/lanes/docs/RUNBOOK.md +453 -27
  13. package/payload/lanes/docs/USERGUIDE.md +449 -76
  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,644 @@
1
+ """lanes keys — who may log in to the workspace account, as a projection of members/<device>.json (W8 A1, LNS-40).
2
+
3
+ The account's ~/.ssh/authorized_keys carries ONE managed block, re-rendered whole from
4
+ ~/.local/state/lanes/members/<device>.json on every change:
5
+
6
+ # lanes-managed BEGIN
7
+ # generated by `lanes keys` from ~/.local/state/lanes/members/ - change it with lanes keys, never by hand
8
+ restrict,pty ssh-ed25519 AAAA... lanes:<device>
9
+ # lanes-managed END
10
+
11
+ * Every byte outside the two marker lines is kept exactly: the file is read and written as bytes. Only
12
+ `lanes keys adopt` removes lines outside the block — the plain `lanes:<device>` ed25519 lines it moves in, after
13
+ keeping a `authorized_keys.bak-<UTC>Z` copy of the file (first time only, the rename_registered idiom).
14
+ * A write happens only when the bytes change (L.atomic_write_if_changed: temp file + rename, so sshd never reads a
15
+ half-written file); the file is kept 0600 and ~/.ssh 0700 (sshd's StrictModes).
16
+ * The options come from what the client does over this key (client/ssh.stanza.in, client/lanes-launch,
17
+ client/lanes-upload, install.sh): a forced TTY for attach/open (`RequestTTY force`), plain commands with stdin
18
+ pipes and no TTY (the upload tar stream, terminfo, hello, client-config, upload-requests), and no agent, X11 or
19
+ port forwarding (`ForwardAgent no`, `ForwardX11 no`, no -L/-R/-W anywhere). So `restrict,pty`: `restrict` turns
20
+ off port, agent and X11 forwarding and ~/.ssh/rc (and every restriction OpenSSH adds to it later), `pty` gives the
21
+ TTY back; commands and stdin are untouched. No `command=` (the client runs a dozen different `lanes` verbs) and no
22
+ `from=` (the `lanes-public` fallback reaches the same sshd from the public internet with the same key).
23
+ * Only ssh-ed25519 keys — what the installer makes (`ssh-keygen -t ed25519 -C lanes:<device>`). The key blob is
24
+ checked byte for byte and the device is an identifier, so nothing but a type, a key and `lanes:<device>` can
25
+ reach a line (a newline or an option in a member file would otherwise be a new authorization).
26
+ * A member file that is unreadable or invalid is NOT rendered (fail closed) and is named on stderr: its identity
27
+ fields (owner, via, approver, ids, workspaces) as members.py defines them (W8 A2), its key fields as this module
28
+ does. The store's file I/O is members.py's; save_member checks the key before it writes.
29
+ * Malformed markers (one without the other, twice, END before BEGIN) refuse every write before anything — members/
30
+ included — is touched: fix the file by hand.
31
+ * The block never names a device members/ does not hold (DEF-1700): add and adopt write the record before its line,
32
+ revoke takes the line out of the block before it removes the record, so a failure at any step leaves at most a
33
+ record without a line (shut), the refusal says what it left, and the same verb run again finishes the job. revoke
34
+ also removes a `lanes:<device>` line it finds in the block without a valid record.
35
+ * Writers hold `members.lock` (flock) across the member files and the authorized_keys write; enrolment (W8 A3)
36
+ uses the same functions under the same lock.
37
+ * `lanes keys owner <device> <login> [--name NAME] [--email ADDR]` names the person behind a member (`add`/`adopt`
38
+ write owner null): the one change to a record's owner, and how a key added by hand makes its person an
39
+ `/enrol/pending` approver. It writes only the member file — the block carries no owner.
40
+
41
+ `pubkey_sha256` is OpenSSH's own fingerprint form (`SHA256:` + unpadded base64 of the blob's SHA-256): what
42
+ `ssh-keygen -lf` prints and what sshd logs on `Accepted publickey`, so a log line names its device.
43
+ """
44
+ import base64
45
+ import binascii
46
+ import contextlib
47
+ import fcntl
48
+ import hashlib
49
+ import os
50
+ import re
51
+ import struct
52
+ import sys
53
+ import time
54
+ from pathlib import Path
55
+
56
+ import lanes_lib as L
57
+ import members as M
58
+
59
+ BEGIN = '# lanes-managed BEGIN'
60
+ END = '# lanes-managed END'
61
+ NOTE = '# generated by `lanes keys` from ~/.local/state/lanes/members/ - change it with lanes keys, never by hand'
62
+ OPTIONS = 'restrict,pty'
63
+ KEY_TYPE = 'ssh-ed25519'
64
+ MAX_DEVICE = M.MAX_DEVICE # bin/lanes device_name()
65
+ MAX_PUBKEY_FILE = 8192
66
+ LOCK_TIMEOUT = 5
67
+ USAGE = ('usage: lanes keys list | lanes keys add <device> <pubkey-file|-> | lanes keys revoke <device> | '
68
+ 'lanes keys adopt [--dry-run] | lanes keys owner <device> <login> [--name NAME] [--email ADDR]')
69
+ _ED25519_HEAD = struct.pack('>I', len(KEY_TYPE)) + KEY_TYPE.encode() + struct.pack('>I', 32)
70
+ _BASE64 = re.compile(r'[A-Za-z0-9+/]+={0,2}')
71
+ _KEY_TYPE_WORD = re.compile(r'(?:ssh-|ecdsa-|sk-)[A-Za-z0-9@._-]+')
72
+ _LANES_COMMENT = re.compile(r'lanes:(\S+)')
73
+
74
+
75
+ # ------------------------------------------------------------------------------------------------- keys and lines
76
+
77
+ device_name = M.device_name
78
+
79
+
80
+ def _ed25519_blob(text):
81
+ """The decoded key blob when `text` is the canonical base64 of an ssh-ed25519 public key (string "ssh-ed25519",
82
+ then a string of exactly 32 bytes, nothing after), else None."""
83
+ if not isinstance(text, str) or not _BASE64.fullmatch(text):
84
+ return None
85
+ try:
86
+ blob = base64.b64decode(text, validate=True)
87
+ except (binascii.Error, ValueError):
88
+ return None
89
+ if len(blob) != len(_ED25519_HEAD) + 32 or not blob.startswith(_ED25519_HEAD):
90
+ return None
91
+ return blob if base64.b64encode(blob).decode() == text else None
92
+
93
+
94
+ def parse_pubkey(text):
95
+ """One OpenSSH public key line (a .pub file: type, key, optional comment) -> its canonical `ssh-ed25519 <base64>`.
96
+ Refuses options, every other key type, a malformed key and more than one line — without echoing the input: a
97
+ private key pasted by mistake must reach neither a terminal nor a log."""
98
+ lines = [line for line in (text if isinstance(text, str) else '').splitlines()
99
+ if line.strip() and not line.lstrip().startswith('#')]
100
+ if len(lines) != 1:
101
+ raise L.LanesError('expected exactly one ssh-ed25519 public key line (the .pub file, never the private key)')
102
+ fields = lines[0].split()
103
+ if len(fields) < 2 or fields[0] != KEY_TYPE or _ed25519_blob(fields[1]) is None:
104
+ raise L.LanesError('not an ssh-ed25519 public key: only ed25519 keys are accepted (ssh-keygen -t ed25519)')
105
+ return f'{KEY_TYPE} {fields[1]}'
106
+
107
+
108
+ def fingerprint(pubkey):
109
+ """OpenSSH's fingerprint of a canonical key: `SHA256:` + unpadded base64 (what `ssh-keygen -lf` prints)."""
110
+ blob = base64.b64decode(pubkey.split()[1])
111
+ return 'SHA256:' + base64.b64encode(hashlib.sha256(blob).digest()).decode().rstrip('=')
112
+
113
+
114
+ def parse_line(text):
115
+ """One authorized_keys line -> {options, type, key, comment}, or None for a blank, comment or unparsable line.
116
+ Options are everything before the first unquoted blank (sshd's own rule; `\\"` escapes inside quotes)."""
117
+ rest = text.strip()
118
+ if not rest or rest.startswith('#'):
119
+ return None
120
+ options = ''
121
+ if not _KEY_TYPE_WORD.fullmatch(rest.split(None, 1)[0]):
122
+ i, quoted = 0, False
123
+ while i < len(rest) and (quoted or rest[i] not in ' \t'):
124
+ if rest[i] == '\\' and quoted and i + 1 < len(rest):
125
+ i += 2
126
+ continue
127
+ if rest[i] == '"':
128
+ quoted = not quoted
129
+ i += 1
130
+ options, rest = rest[:i], rest[i:].strip()
131
+ fields = rest.split(None, 2)
132
+ if len(fields) < 2:
133
+ return None
134
+ return {'options': options, 'type': fields[0], 'key': fields[1],
135
+ 'comment': fields[2].strip() if len(fields) > 2 else ''}
136
+
137
+
138
+ def _member_line(member):
139
+ if not isinstance(member, dict):
140
+ raise L.LanesError('a member must be an object')
141
+ device = device_name(member.get('device'))
142
+ pubkey = member.get('pubkey')
143
+ if not isinstance(pubkey, str) or '\n' in pubkey or parse_pubkey(pubkey) != pubkey:
144
+ raise L.LanesError(f'member {device}: pubkey is not a canonical ssh-ed25519 key')
145
+ return device, f'{OPTIONS} {pubkey} lanes:{device}'
146
+
147
+
148
+ def render_block(members):
149
+ """The managed block as lines (no newlines), markers included: one `restrict,pty ssh-ed25519 <key> lanes:<device>`
150
+ per member, sorted by device. Pure; raises on any member that could put more than that on a line."""
151
+ return [BEGIN, NOTE, *(line for _, line in sorted(_member_line(m) for m in members)), END]
152
+
153
+
154
+ # --------------------------------------------------------------------------------------------- the authorized_keys file
155
+
156
+ def authorized_keys_path():
157
+ return Path.home() / '.ssh' / 'authorized_keys'
158
+
159
+
160
+ def split_block(data):
161
+ """bytes -> (before, block lines with their line ends | None, after). A marker is a line that is exactly the marker
162
+ once surrounding blanks are stripped. Anything but no markers or one BEGIN followed by one END is refused."""
163
+ lines = data.splitlines(keepends=True)
164
+ marks = [(i, line.strip().decode('ascii', 'replace')) for i, line in enumerate(lines)
165
+ if line.strip() in (BEGIN.encode(), END.encode())]
166
+ if not marks:
167
+ return data, None, b''
168
+ if [kind for _, kind in marks] != [BEGIN, END]:
169
+ found = ', '.join(f'line {i + 1}: {kind}' for i, kind in marks)
170
+ raise L.LanesError(f'authorized_keys: the lanes-managed markers are malformed ({found}); fix the file by hand '
171
+ '— nothing was written')
172
+ (first, _), (last, _) = marks
173
+ return b''.join(lines[:first]), lines[first:last + 1], b''.join(lines[last + 1:])
174
+
175
+
176
+ def outside_entries(data):
177
+ """(line number, raw line bytes, parsed line) for every key line OUTSIDE the managed block."""
178
+ before, block, after = split_block(data)
179
+ entries, number = [], 0
180
+ for chunk, skip in ((before, 0), (after, len(block or ()))):
181
+ number += skip
182
+ for raw in chunk.splitlines(keepends=True):
183
+ number += 1
184
+ parsed = parse_line(raw.decode('utf-8', 'surrogateescape'))
185
+ if parsed:
186
+ entries.append((number, raw, parsed))
187
+ return entries
188
+
189
+
190
+ def _lanes_device(parsed):
191
+ found = _LANES_COMMENT.fullmatch(parsed['comment'])
192
+ return found.group(1) if found else None
193
+
194
+
195
+ def block_entries(data, device):
196
+ """The parsed key lines INSIDE the managed block whose comment is `lanes:<device>` (malformed markers raise)."""
197
+ _, block, _ = split_block(data)
198
+ parsed = (parse_line(raw.decode('utf-8', 'surrogateescape')) for raw in block or ())
199
+ return [entry for entry in parsed if entry and _lanes_device(entry) == device]
200
+
201
+
202
+ def _without(chunk, drop):
203
+ kept, dropped = [], 0
204
+ for raw in chunk.splitlines(keepends=True):
205
+ if drop(raw):
206
+ dropped += 1
207
+ else:
208
+ kept.append(raw)
209
+ return b''.join(kept), dropped
210
+
211
+
212
+ def compose(data, members, drop=None):
213
+ """(new bytes, number of outside lines dropped): `data` with its managed block re-rendered from `members` (appended
214
+ at the end when there is none and `members` is not empty). `drop(raw line) -> bool` removes outside lines (adopt
215
+ only); every other byte outside the block is kept."""
216
+ before, block, after = split_block(data)
217
+ dropped = 0
218
+ if drop:
219
+ before, d1 = _without(before, drop)
220
+ after, d2 = _without(after, drop)
221
+ dropped = d1 + d2
222
+ if block is None and not members:
223
+ return before + after, dropped
224
+ if block is None and before and not before.endswith((b'\n', b'\r')):
225
+ before += b'\n' # the BEGIN marker never joins a foreign line
226
+ return before + ('\n'.join(render_block(members)) + '\n').encode() + after, dropped
227
+
228
+
229
+ def _private_dir(directory):
230
+ directory.mkdir(mode=0o700, parents=True, exist_ok=True)
231
+ if directory.stat().st_mode & 0o777 != 0o700:
232
+ os.chmod(directory, 0o700)
233
+
234
+
235
+ def write_authorized_keys(path, members, drop=None):
236
+ """Re-render the managed block of `path` from `members` -> True when the file's bytes changed. Byte-exact outside
237
+ the markers, atomic, and only on a change; ~/.ssh is made 0700 and the file 0600 (also when nothing else changes).
238
+ A symlinked file is written through, the link stays. With `drop` (adopt), a backup of the previous bytes is kept
239
+ first, once, beside the file."""
240
+ path = Path(path)
241
+ target = Path(os.path.realpath(path))
242
+ try:
243
+ data = target.read_bytes()
244
+ except FileNotFoundError:
245
+ data = None
246
+ new, dropped = compose(data or b'', members, drop) # malformed markers raise here, before anything is touched
247
+ if data is None and not new:
248
+ return False
249
+ _private_dir(path.parent)
250
+ if dropped and new != data and not any(path.parent.glob(path.name + '.bak-*')):
251
+ L.atomic_write(path.parent / (path.name + '.bak-' + time.strftime('%Y%m%dT%H%M%SZ', time.gmtime())),
252
+ data, mode=0o600)
253
+ wrote = L.atomic_write_if_changed(target, new, mode=0o600)
254
+ if target.stat().st_mode & 0o777 != 0o600:
255
+ os.chmod(target, 0o600)
256
+ return wrote
257
+
258
+
259
+ def _read(path):
260
+ try:
261
+ return Path(path).read_bytes()
262
+ except FileNotFoundError:
263
+ return b''
264
+
265
+
266
+ def _reason(error):
267
+ return error.strerror or error.__class__.__name__
268
+
269
+
270
+ def _project(path, members, left, drop=None):
271
+ """write_authorized_keys, with an OSError (a read-only mount, a full disk) turned into a refusal that says what
272
+ the failure `left` behind and how to finish — the caller has already changed members/ only in the direction that
273
+ keeps the block a subset of it."""
274
+ try:
275
+ return write_authorized_keys(path, members, drop=drop)
276
+ except OSError as error:
277
+ raise L.LanesError(f'cannot write {path} ({_reason(error)}): {left()}') from None
278
+
279
+
280
+ # ----------------------------------------------------------------------------------------------------- members store
281
+
282
+ members_dir = M.members_dir
283
+
284
+
285
+ @contextlib.contextmanager
286
+ def members_lock(timeout=None):
287
+ """Serialize every change to members/ and the managed block (CLI and enrolment)."""
288
+ timeout = LOCK_TIMEOUT if timeout is None else timeout
289
+ L.STATE.mkdir(parents=True, exist_ok=True)
290
+ with (L.STATE / 'members.lock').open('a') as handle:
291
+ deadline = time.monotonic() + timeout
292
+ while True:
293
+ try:
294
+ fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
295
+ break
296
+ except BlockingIOError:
297
+ if time.monotonic() >= deadline:
298
+ raise L.LanesError('another change to members/ or authorized_keys is running; try again shortly')
299
+ time.sleep(0.05)
300
+ try:
301
+ yield
302
+ finally:
303
+ fcntl.flock(handle, fcntl.LOCK_UN)
304
+
305
+
306
+ def load_members(directory=None):
307
+ """(members sorted by device, [(file name, reason)] for each file NOT rendered). A member is a record whose identity
308
+ fields members.validate accepts (a JSON object whose `device` is its file's stem and a device name, whose owner,
309
+ via, approver, ids and workspaces are well formed), whose `pubkey` is a canonical ssh-ed25519 key, whose
310
+ `pubkey_sha256` (when set) is that key's fingerprint, and whose key no earlier member holds."""
311
+ documents, problems = M.load(directory or members_dir())
312
+ members, holders = [], {}
313
+ for document in documents:
314
+ try:
315
+ _member_line(document)
316
+ if document.get('pubkey_sha256') not in (None, fingerprint(document['pubkey'])):
317
+ raise L.LanesError('pubkey_sha256 is not the fingerprint of its key')
318
+ if document['pubkey'] in holders:
319
+ raise L.LanesError(f"the same key as member {holders[document['pubkey']]}")
320
+ except L.LanesError as error:
321
+ problems.append((f"{document['device']}.json", str(error)))
322
+ continue
323
+ holders[document['pubkey']] = document['device']
324
+ members.append(document)
325
+ return members, sorted(problems)
326
+
327
+
328
+ def member_record(device, pubkey, via='manual', now=None, **identity):
329
+ """A members/<device>.json document (members.record) with the key's fingerprint: `lanes keys add|adopt` write
330
+ via manual, owner null; enrolment passes owner, approved_by, its invite_id|request_id and workspaces."""
331
+ return M.record(device, pubkey, fingerprint(pubkey), via=via, now=now, **identity)
332
+
333
+
334
+ def save_member(record):
335
+ """members.write after the key checks a load would make, so a record this module writes is one it renders."""
336
+ _member_line(record)
337
+ if record.get('pubkey_sha256') != fingerprint(record['pubkey']):
338
+ raise L.LanesError(f"member {record['device']}: pubkey_sha256 is not the fingerprint of its key")
339
+ M.write(record, members_dir())
340
+
341
+
342
+ # ---------------------------------------------------------------------------------------------------------- verbs
343
+
344
+ def _warn(text):
345
+ print(text, file=sys.stderr)
346
+
347
+
348
+ def _warn_problems(problems):
349
+ for name, reason in problems:
350
+ _warn(f'⚠ members/{name}: {reason} — NOT authorized (fix or remove the file)')
351
+
352
+
353
+ def _read_pubkey_file(name):
354
+ try:
355
+ if name == '-':
356
+ text = sys.stdin.read(MAX_PUBKEY_FILE + 1)
357
+ else:
358
+ with open(name) as handle:
359
+ text = handle.read(MAX_PUBKEY_FILE + 1)
360
+ except (OSError, UnicodeDecodeError) as error:
361
+ raise L.LanesError(f'cannot read {name}: {getattr(error, "strerror", None) or error.__class__.__name__}')
362
+ if len(text) > MAX_PUBKEY_FILE:
363
+ raise L.LanesError(f'{name} is too large to be a public key')
364
+ return text
365
+
366
+
367
+ def _outside_holders(data, pubkey=None, device=None):
368
+ """Key lines outside the block that let `pubkey` (any comment) or `lanes:<device>` in."""
369
+ blob = pubkey.split()[1] if pubkey else None
370
+ return [(number, parsed) for number, _, parsed in outside_entries(data)
371
+ if (blob and parsed['type'] == KEY_TYPE and parsed['key'] == blob)
372
+ or (device and _lanes_device(parsed) == device)]
373
+
374
+
375
+ def cmd_list(path):
376
+ members, problems = load_members()
377
+ data = _read(path)
378
+ for record in members:
379
+ owner = record.get('owner')
380
+ login = owner.get('login') if isinstance(owner, dict) else None
381
+ print(f"{record['device']:<16} {fingerprint(record['pubkey']):<51} {login or '-':<24} "
382
+ f"{record.get('via') or '-':<8} {record.get('enrolled_at') or '-'}")
383
+ if not members:
384
+ print(f'no members ({members_dir()} holds none)')
385
+ _warn_problems(problems)
386
+ try:
387
+ _, block, _ = split_block(data)
388
+ entries = outside_entries(data)
389
+ except L.LanesError as error:
390
+ print(f'⚠ {error}')
391
+ return 1
392
+ expected = render_block(members)
393
+ if block is None:
394
+ print(f'block: none in {path}' + (' — `lanes keys adopt` writes it' if members else ''))
395
+ elif [raw.decode('utf-8', 'replace').rstrip('\r\n') for raw in block] == expected:
396
+ print(f'block: {len(members)} key(s) in {path}, in sync with members/')
397
+ else:
398
+ print(f'block: out of date in {path} — `lanes keys adopt` re-renders it from members/')
399
+ hand = [(number, _lanes_device(parsed)) for number, _, parsed in entries if _lanes_device(parsed)]
400
+ if hand:
401
+ print(f'outside the block: {len(hand)} hand-added lanes: line(s) — '
402
+ + ', '.join(f'{device} (line {number})' for number, device in hand)
403
+ + ' — `lanes keys adopt` moves them in')
404
+ blobs = {record['pubkey'].split()[1]: record['device'] for record in members}
405
+ for number, _, parsed in entries:
406
+ if parsed['type'] == KEY_TYPE and parsed['key'] in blobs and not _lanes_device(parsed):
407
+ print(f"⚠ line {number}: {blobs[parsed['key']]}'s key also authorizes outside the block "
408
+ f"({parsed['comment'] or 'no comment'}); sshd uses the first matching line")
409
+ return 0
410
+
411
+
412
+ def _listed(path, device):
413
+ """Whether the block on disk carries a `lanes:<device>` line now: True/False, None when it cannot be read back."""
414
+ try:
415
+ return bool(block_entries(_read(path), device))
416
+ except (L.LanesError, OSError):
417
+ return None
418
+
419
+
420
+ def cmd_add(path, device, name):
421
+ device = device_name(device)
422
+ pubkey = parse_pubkey(_read_pubkey_file(name))
423
+ with members_lock():
424
+ members, problems = load_members()
425
+ _warn_problems(problems)
426
+ split_block(_read(path)) # malformed markers refuse HERE, before members/ changes: nothing was written
427
+ current = next((m for m in members if m['device'] == device), None)
428
+ holder = next((m['device'] for m in members if m['pubkey'] == pubkey), None)
429
+ if current is not None and current['pubkey'] != pubkey:
430
+ raise L.LanesError(f"{device} is already enrolled with another key ({fingerprint(current['pubkey'])}); "
431
+ f'`lanes keys revoke {device}` first')
432
+ if current is None and holder is not None:
433
+ raise L.LanesError(f'that key is already enrolled as {holder} (one key, one device)')
434
+ if current is None:
435
+ save_member(member_record(device, pubkey)) # the record before its line (see cmd_revoke)
436
+ members, _ = load_members()
437
+
438
+ def left():
439
+ state = {True: f'the block already has a line for {device}', False: f'{device} is NOT authorized',
440
+ None: 'the block could not be read back'}[_listed(path, device)]
441
+ return (f'{state}; members/{device}.json is kept, so `lanes keys add {device}` with the same key (or '
442
+ '`lanes keys adopt`) renders its line once that is fixed')
443
+
444
+ wrote = _project(path, members, left)
445
+ if current is None:
446
+ print(f'authorized {device} ({fingerprint(pubkey)}): `{OPTIONS}` line in the lanes-managed block of {path}')
447
+ elif wrote:
448
+ print(f'{device} was already a member with that key ({fingerprint(pubkey)}); the lanes-managed block of {path} '
449
+ f'did not match members/ and was re-rendered: it authorizes {device} now')
450
+ else:
451
+ print(f'{device} is already authorized with that key ({fingerprint(pubkey)}); nothing changed')
452
+ for number, parsed in _outside_holders(_read(path), pubkey=pubkey):
453
+ _warn(f"⚠ line {number} outside the lanes-managed block also authorizes the same key "
454
+ f"({parsed['comment'] or 'no comment'}) with its own options; sshd uses the first matching line — "
455
+ '`lanes keys adopt` moves a plain `lanes:<device>` line in, any other remove by hand')
456
+ return 0
457
+
458
+
459
+ def cmd_revoke(path, device):
460
+ """Take `device` out of the block, then out of members/ — in that order, so a failure in between leaves a record
461
+ without a line (shut, named, and finished by running revoke again), never a line without a record (open, and
462
+ invisible to every later revoke). A `lanes:<device>` line in the block without a valid record is revoked too."""
463
+ device = device_name(device)
464
+ with members_lock():
465
+ members, problems = load_members()
466
+ _warn_problems(problems)
467
+ member_file = members_dir() / f'{device}.json'
468
+ had_file = member_file.exists()
469
+ target = next((m for m in members if m['device'] == device), None)
470
+ stale = block_entries(_read(path), device) # malformed markers refuse HERE: nothing was written
471
+ pubkey = target['pubkey'] if target else next(
472
+ (f"{KEY_TYPE} {entry['key']}" for entry in stale
473
+ if entry['type'] == KEY_TYPE and _ed25519_blob(entry['key']) is not None), None)
474
+ if target is None and not had_file and not stale:
475
+ print(f'no member {device}: nothing to revoke')
476
+ else:
477
+ def left():
478
+ state = {True: f'the block still lets {device} in', False: f'the block has no line for {device}',
479
+ None: 'the block could not be read back'}[_listed(path, device)]
480
+ kept = f'members/{device}.json is kept; ' if member_file.exists() else ''
481
+ return f'{state}; {kept}`lanes keys revoke {device}` finishes the revoke once that is fixed'
482
+
483
+ _project(path, [m for m in members if m['device'] != device], left)
484
+ try:
485
+ member_file.unlink(missing_ok=True)
486
+ except OSError as error:
487
+ raise L.LanesError(f'{device} has no line in the lanes-managed block of {path} any more, but '
488
+ f'members/{device}.json could not be removed ({_reason(error)}): remove it by hand '
489
+ f'— while it exists, the next `lanes keys` change authorizes {device} again') from None
490
+ if pubkey:
491
+ what = fingerprint(pubkey)
492
+ else:
493
+ what = 'its file was invalid and never rendered' if not stale else 'not an ed25519 key'
494
+ print(f'revoked {device} ({what}): no line for it in the lanes-managed block of {path}')
495
+ if target is None and stale:
496
+ print('its line was still in the block ' + (f'beside an invalid members/{device}.json' if had_file
497
+ else f'without members/{device}.json'))
498
+ print('an ssh session already open with that key keeps running until it ends')
499
+ leftovers = _outside_holders(_read(path), pubkey=pubkey, device=device)
500
+ for number, parsed in leftovers:
501
+ _warn(f"⚠ line {number} outside the lanes-managed block still lets {device}'s key or `lanes:{device}` in "
502
+ f"({parsed['comment'] or 'no comment'}); `lanes keys` never edits lines outside the block on revoke — "
503
+ 'remove it by hand')
504
+ return 1 if leftovers else 0
505
+
506
+
507
+ def plan_adopt(data, members):
508
+ """(new member records by device, [(pubkey, device)] whose plain outside lines go, [report lines]) for the
509
+ `lanes:<device>` lines outside the block. Taken: a plain (no options) ssh-ed25519 line whose device holds no other
510
+ key and whose key no other device holds; a line for an existing member with the same key is a duplicate and goes.
511
+ Everything else stays byte for byte and is named."""
512
+ by_device = {m['device']: m for m in members}
513
+ holders = {m['pubkey']: m['device'] for m in members}
514
+ new, drop, report = {}, set(), []
515
+ for number, _, parsed in outside_entries(data):
516
+ device = _lanes_device(parsed)
517
+ if device is None:
518
+ continue
519
+ where = f'line {number} (lanes:{device})'
520
+ if not L.IDENT.match(device) or len(device) > MAX_DEVICE:
521
+ report.append(f'kept {where}: not a device name')
522
+ continue
523
+ if parsed['options']:
524
+ report.append(f"kept {where}: it has options ({parsed['options']}) — adopting would change what it may "
525
+ 'do; review it by hand')
526
+ continue
527
+ if parsed['type'] != KEY_TYPE or _ed25519_blob(parsed['key']) is None:
528
+ report.append(f"kept {where}: not an ssh-ed25519 key ({parsed['type']})")
529
+ continue
530
+ pubkey = f"{KEY_TYPE} {parsed['key']}"
531
+ current = by_device.get(device) or new.get(device)
532
+ if current is not None:
533
+ if current['pubkey'] == pubkey:
534
+ drop.add((pubkey, device))
535
+ report.append(f'{where}: duplicate of member {device}, removed')
536
+ else:
537
+ report.append(f'kept {where}: {device} already holds another key ({fingerprint(current["pubkey"])})')
538
+ continue
539
+ if pubkey in holders:
540
+ report.append(f'kept {where}: that key is already enrolled as {holders[pubkey]}')
541
+ continue
542
+ new[device] = member_record(device, pubkey)
543
+ holders[pubkey] = device
544
+ drop.add((pubkey, device))
545
+ report.append(f'adopted {device} ({fingerprint(pubkey)}) from line {number}')
546
+ return new, drop, report
547
+
548
+
549
+ def cmd_adopt(path, dry_run=False):
550
+ with members_lock():
551
+ members, problems = load_members()
552
+ _warn_problems(problems)
553
+ data = _read(path)
554
+ new, drop, report = plan_adopt(data, members)
555
+
556
+ def goes(raw):
557
+ parsed = parse_line(raw.decode('utf-8', 'surrogateescape'))
558
+ return bool(parsed) and not parsed['options'] and parsed['type'] == KEY_TYPE and \
559
+ (f"{KEY_TYPE} {parsed['key']}", _lanes_device(parsed)) in drop
560
+
561
+ if dry_run:
562
+ for line in report:
563
+ print('would: ' + line if not line.startswith('adopted') else 'would adopt ' + line[len('adopted '):])
564
+ if not drop:
565
+ print('nothing to adopt')
566
+ return 0
567
+ for record in new.values():
568
+ save_member(record) # the records before their lines (see cmd_revoke)
569
+ kept = f'the {len(new)} member file(s) it adopted are kept in members/; ' if new else ''
570
+ changed = _project(path, members + list(new.values()),
571
+ lambda: f'{kept}run `lanes keys adopt` again once that is fixed: it finishes from members/',
572
+ drop=goes if drop else None)
573
+ for line in report:
574
+ print(line)
575
+ if not drop:
576
+ print('nothing to adopt' + ('; the block was re-rendered from members/' if changed else ''))
577
+ else:
578
+ print(f'{len(new)} adopted; the lanes-managed block of {path} holds {len(members) + len(new)} key(s); '
579
+ 'the file before the first adopt is kept as authorized_keys.bak-<UTC>')
580
+ return 0
581
+
582
+
583
+ def cmd_owner(device, login, name=None, email=None):
584
+ """Name the person behind member `device`: its owner becomes {login, name, email}. The only change to who owns a key
585
+ added by hand (`add`/`adopt` write owner null), and so how its person becomes a `/enrol/pending` approver, which
586
+ matches the tailnet login a member record's owner names (DEF-1643). The record keeps every other field and its
587
+ order; the block carries no owner, so authorized_keys is not touched. Refuses an owner members.py would not store,
588
+ a device with no record, and a record that does not load (fix it first) — never echoing a value."""
589
+ device = device_name(device)
590
+ owner = M.validate_owner({'login': login, 'name': name, 'email': email})
591
+ with members_lock():
592
+ members, problems = load_members()
593
+ current = next((m for m in members if m['device'] == device), None)
594
+ if current is None:
595
+ reason = next((why for file, why in problems if file == f'{device}.json'), None)
596
+ if reason is not None:
597
+ raise L.LanesError(f'members/{device}.json: {reason} — fix or remove the file first; nothing changed')
598
+ raise L.LanesError(f'no member {device} (`lanes keys list` shows the members); nothing changed')
599
+ before = M.valid_owner(current.get('owner'))
600
+ if before == owner:
601
+ print(f"{device} is already owned by {owner['login']}; nothing changed")
602
+ return 0
603
+ record = dict(current, owner=owner)
604
+ if record.get('pubkey_sha256') is None: # a record written before A2 carries no fingerprint
605
+ record['pubkey_sha256'] = fingerprint(record['pubkey'])
606
+ save_member(record)
607
+ print(f"owner of {device}: {before['login'] if before else 'none'} -> {owner['login']}"
608
+ + (f" ({owner['name']})" if owner['name'] else ''))
609
+ return 0
610
+
611
+
612
+ def _owner_args(rest):
613
+ """[device, login, (--name NAME | --email ADDR)...] -> (device, login, name, email), each option at most once."""
614
+ if len(rest) < 2 or len(rest) % 2:
615
+ raise L.LanesError(USAGE)
616
+ options = {}
617
+ for flag, value in zip(rest[2::2], rest[3::2]):
618
+ if flag not in ('--name', '--email') or flag in options:
619
+ raise L.LanesError(USAGE)
620
+ options[flag] = value
621
+ return rest[0], rest[1], options.get('--name'), options.get('--email')
622
+
623
+
624
+ def main(args):
625
+ path = authorized_keys_path()
626
+ verb, rest = (args[0], args[1:]) if args else (None, [])
627
+ if verb == 'list' and not rest:
628
+ return cmd_list(path)
629
+ if verb == 'add' and len(rest) == 2:
630
+ return cmd_add(path, *rest)
631
+ if verb == 'revoke' and len(rest) == 1:
632
+ return cmd_revoke(path, rest[0])
633
+ if verb == 'adopt' and rest in ([], ['--dry-run']):
634
+ return cmd_adopt(path, dry_run=bool(rest))
635
+ if verb == 'owner':
636
+ return cmd_owner(*_owner_args(rest))
637
+ raise L.LanesError(USAGE)
638
+
639
+
640
+ if __name__ == '__main__':
641
+ try:
642
+ sys.exit(main(sys.argv[1:]))
643
+ except L.LanesError as error:
644
+ sys.exit(f'lanes keys: {error}')