@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,695 @@
1
+ """lanes invite | enrol | offboard — the operator's half of W8 enrolment (A4, LNS-43). bin/lanes loads this module
2
+ through libexec_module('enrol'); the enrolment service itself is enrold.py (A3), whose store helpers, code hash and
3
+ decide() this module calls, so the CLI and the service agree on one definition of each.
4
+
5
+ lanes invite <name> [--email ADDR] [--expires 7d] [--workspaces a,b] [--tailscale-link URL] [--md]
6
+ mints a one-time code `LN-<id4>-<secret12>` over enrold.CODE_ALPHABET (31 characters, no 0/1/i/l/o; ~59 bits of
7
+ secret from the `secrets` module; case-insensitive) and stores ONLY its hash in invites/<id>.json:
8
+ {id, code_sha256, minted_at, minted_by {device, login, unix_user}, expires_at, name, email, workspaces,
9
+ redeemed_by: null, revoked_at: null}
10
+ `minted_by.device` is $LANES_DEVICE when it is a device name (a claim: nothing sets it today), `login` is null —
11
+ over SSH the one Unix account names nobody, so the CLI never claims a tailnet login — and `unix_user` the
12
+ account. The code is shown once, in the welcome kit, and only when stdin AND stdout are terminals (otherwise
13
+ exit 2 and nothing is minted). `--md` prints the kit as Markdown. Two opt-in extras, each behind its OWN
14
+ Lanes key file (never another tool's key, never the environment):
15
+ --email ADDR the invitee's address (recorded: on an invite-only server it is the member's owner login),
16
+ and, with ~/.config/lanes/resend.key, the kit is sent through Resend (key on the first line;
17
+ a `from=Name <addr>` line on a Resend-verified domain names the sender — without it Resend's
18
+ test sender is used, which delivers only to the Resend account's own address, and Resend's
19
+ 403 for anyone else becomes a note naming the missing from= line)
20
+ step 1's link --tailscale-link URL, else with ~/.config/lanes/tailscale-api.key (a user-owned API access
21
+ token) a Tailscale user invite is minted WITHOUT the email, so Tailscale returns the link and
22
+ sends nothing itself: the kit stays the one message
23
+ Every request is bounded (HTTP_TIMEOUT for the whole exchange); a failure is a sentence, never a lost kit.
24
+ lanes invite list | revoke <id>
25
+ every invite with its state (live, redeemed, revoked, expired); revoke sets revoked_at, after which enrold
26
+ answers that code like an unknown one. A redeemed invite is not revoked: `lanes offboard` removes the machine.
27
+ lanes enrol mode [request|invite|auto]
28
+ pins whether lanes-enrold takes access requests (enrold.write_mode: STATE/enrol-mode {version: 1, mode, set_at,
29
+ set_by}; auto removes it), read by the service on every request — no restart, and it survives restarts and
30
+ reinstalls, so request mode no longer rests on which request arrives first after a start (DEF-1725). Without a
31
+ word it says the pin. A unit drop-in's LANES_ENROL_REQUEST_MODE wins over the pin: said when systemd reports one.
32
+ lanes enrol list [--all] | approve <id> [--as <device>] | deny <id> [--as <device>]
33
+ the requests enrold's POST /request filed; approve/deny call enrold.decide() exactly as /enrol/pending does
34
+ (approve writes the member and its line through keys.py). The approver recorded is `cli[:<device>]@<address>`:
35
+ the SSH client address from $SSH_CLIENT (`local` without one, `unknown` when it is not an address) and the
36
+ member device `--as` claims. That is where the approval came from, not who made it — every SSH login is the
37
+ same account — and it is never a tailnet login, so it grants nothing on /enrol/pending.
38
+ lanes offboard <device> --yes
39
+ in your own terminal only: 1. keys revoke (the managed block, then members/<device>.json), 2. device forget
40
+ (devices/<device>.json, so `lanes ensure` does not bring the view back), 3. its browsers and phones signed out:
41
+ the web login tokens web-tokens.json attributes to <device>, to its windows (devices/<view>.json `base`) or to
42
+ a view minted with <device> as its machine (`lanes-launch web phone|laptop`), revoked through web_tokens.offboard
43
+ in the pinned token environment (zellij 0.45.1 names every token token_<n>: the record says whose it is),
44
+ 4. the view session ends (no lane is touched). A failed step 1 or 2 stops there; a failed step 3 or a view that
45
+ cannot be ended makes the offboard incomplete (exit 1), and running it again finishes it. Steps 2-4 are
46
+ bin/lanes's (the zellij helpers and `lanes device forget`), handed in as callables.
47
+
48
+ Every change to invites/ holds keys.members_lock() — the lock enrold and `lanes keys` hold — so a revoke and a
49
+ redemption never interleave. enrol.log (enrold.audit) gains `invite`, `invite-revoke` and `offboard` lines; a code is
50
+ never written anywhere.
51
+ """
52
+ import ipaddress
53
+ import json
54
+ import os
55
+ import pwd
56
+ import re
57
+ import secrets
58
+ import shutil
59
+ import sys
60
+ import threading
61
+ import time
62
+ import urllib.error
63
+ import urllib.request
64
+ from pathlib import Path
65
+
66
+ import lanes_lib as L
67
+ import keys as K
68
+ import members as M
69
+ import enrold as E
70
+
71
+ CODE_PREFIX = 'LN'
72
+ ID_LENGTH = 4
73
+ SECRET_LENGTH = 12
74
+ DEFAULT_EXPIRES = 7 * 86400
75
+ MIN_EXPIRES = 3600
76
+ MAX_EXPIRES = 30 * 86400
77
+ HTTP_TIMEOUT = 10 # seconds for a whole Resend or Tailscale exchange
78
+ MAX_RESPONSE = 64 * 1024
79
+ MAX_KEY_FILE = 4096
80
+ NOT_A_TERMINAL = 2
81
+ RESEND_URL = 'https://api.resend.com/emails'
82
+ RESEND_SENDER = 'Lanes <onboarding@resend.dev>' # Resend's test sender: delivers to the Resend account's own address
83
+ RESEND_SUBJECT = 'Your Lanes invite'
84
+ TAILSCALE_INVITES_URL = 'https://api.tailscale.com/api/v2/tailnet/-/user-invites'
85
+ NO_LINK = 'ask your admin for the Tailscale invite'
86
+ INVITE_USAGE = ('usage: lanes invite <name> [--email ADDR] [--expires 7d] [--workspaces a,b] [--tailscale-link URL] '
87
+ '[--md] | lanes invite list | lanes invite revoke <id>')
88
+ ENROL_USAGE = ('usage: lanes enrol list [--all] | lanes enrol approve <id> [--as <device>] | '
89
+ 'lanes enrol deny <id> [--as <device>] | lanes enrol mode [request|invite|auto]')
90
+ UNIT_ENVIRONMENT = ['systemctl', '--user', 'show', '-p', 'Environment', 'lanes-enrold.service']
91
+ OFFBOARD_USAGE = 'usage: lanes offboard <device> --yes'
92
+ VALUE_OPTIONS = ('--email', '--expires', '--workspaces', '--tailscale-link')
93
+ _EXPIRES = re.compile(r'([1-9][0-9]{0,3})([dh])', re.ASCII)
94
+ _LINK = re.compile(r'https://[A-Za-z0-9._~:/?#@!$&+,;=%-]{1,500}', re.ASCII)
95
+ _KEY = re.compile(r'[\x21-\x7e]{8,512}', re.ASCII)
96
+ _MARKDOWN = re.compile(r'([\\`*_\[\]<>|])')
97
+
98
+
99
+ # ------------------------------------------------------------------------------------------------------- basics
100
+
101
+ def _on_terminal():
102
+ try:
103
+ return sys.stdin.isatty() and sys.stdout.isatty()
104
+ except (AttributeError, ValueError):
105
+ return False
106
+
107
+
108
+ def _note(text):
109
+ print(text, file=sys.stderr, flush=True)
110
+
111
+
112
+ def _pick(length):
113
+ return ''.join(secrets.choice(E.CODE_ALPHABET) for _ in range(length))
114
+
115
+
116
+ def _shown(value, check):
117
+ """`value` as check() normalizes it for a terminal, '-' for none, '?' for anything check refuses."""
118
+ if value is None:
119
+ return '-'
120
+ try:
121
+ return check(value) or '-'
122
+ except (L.LanesError, TypeError, AttributeError):
123
+ return '?'
124
+
125
+
126
+ def _name(value):
127
+ return M.validate_owner({'login': 'x', 'name': value})['name']
128
+
129
+
130
+ def _email(value):
131
+ return M.validate_owner({'login': 'x', 'email': value})['email']
132
+
133
+
134
+ def _stamp_shown(value):
135
+ return value if E.parse_stamp(value) is not None else '?'
136
+
137
+
138
+ # ------------------------------------------------------------------------------------------------------- invites
139
+
140
+ def parse_expires(text):
141
+ """`7d` / `12h` -> seconds, between MIN_EXPIRES and MAX_EXPIRES."""
142
+ found = _EXPIRES.fullmatch(text or '')
143
+ seconds = int(found.group(1)) * (86400 if found.group(2) == 'd' else 3600) if found else 0
144
+ if not MIN_EXPIRES <= seconds <= MAX_EXPIRES:
145
+ raise L.LanesError('--expires takes <n>d or <n>h, from 1h to 30d')
146
+ return seconds
147
+
148
+
149
+ def valid_for(seconds):
150
+ return f'{seconds // 86400} d' if seconds % 86400 == 0 else f'{seconds // 3600} h'
151
+
152
+
153
+ def check_name(value):
154
+ try:
155
+ name = _name(L._title(value))
156
+ except L.LanesError:
157
+ name = None
158
+ if not name:
159
+ raise L.LanesError('the invitee name is not valid (one printable line of at most 128 characters)')
160
+ return name
161
+
162
+
163
+ def check_email(value):
164
+ try:
165
+ email = _email(value)
166
+ except L.LanesError:
167
+ email = None
168
+ if not email or not E._address(email):
169
+ raise L.LanesError('--email is not an address')
170
+ return email
171
+
172
+
173
+ def check_workspaces(value):
174
+ kept = []
175
+ for part in (value or '').split(','):
176
+ part = part.strip()
177
+ if not part:
178
+ continue
179
+ if not L.IDENT.match(part) or len(part) > M.MAX_ID:
180
+ raise L.LanesError('--workspaces takes board ids separated by commas (`lanes workspaces` lists them)')
181
+ if part not in kept:
182
+ kept.append(part)
183
+ return kept
184
+
185
+
186
+ def check_link(value):
187
+ if not isinstance(value, str) or not _LINK.fullmatch(value):
188
+ raise L.LanesError('--tailscale-link takes the https:// invite link from the Tailscale admin console')
189
+ return value
190
+
191
+
192
+ def minted_by():
193
+ device = os.environ.get('LANES_DEVICE')
194
+ try:
195
+ device = M.device_name(device) if device else None
196
+ except L.LanesError:
197
+ device = None
198
+ try:
199
+ user = pwd.getpwuid(os.getuid()).pw_name
200
+ except KeyError:
201
+ user = None
202
+ return {'device': device, 'login': None, 'unix_user': user}
203
+
204
+
205
+ def mint(name, email=None, workspaces=(), expires=DEFAULT_EXPIRES, now=None):
206
+ """Store a new invite -> (code, document). The code exists only in the return value: the file holds its hash."""
207
+ now = time.time() if now is None else now
208
+ with K.members_lock():
209
+ E._private_dir(E.invites_dir())
210
+ for _ in range(1000):
211
+ invite_id = _pick(ID_LENGTH)
212
+ if not (E.invites_dir() / f'{invite_id}.json').exists():
213
+ break
214
+ else:
215
+ raise L.LanesError('no free invite id: clear out old invites/ files')
216
+ code = f'{CODE_PREFIX}-{invite_id}-{_pick(SECRET_LENGTH)}'
217
+ document = {'id': invite_id, 'code_sha256': E.code_sha256(code), 'minted_at': E.stamp(now),
218
+ 'minted_by': minted_by(), 'expires_at': E.stamp(now + expires), 'name': name, 'email': email,
219
+ 'workspaces': list(workspaces), 'redeemed_by': None, 'revoked_at': None}
220
+ L.atomic_write(E.invites_dir() / f'{invite_id}.json', E._json_bytes(document), mode=0o600)
221
+ E.audit('invite', invite_id, None, None, None, 'minted', now)
222
+ return code, document
223
+
224
+
225
+ def kit_text(name, valid, code, link):
226
+ return (f'Invite for {name} — valid {valid}, one redemption\n'
227
+ f' 1. Join the tailnet (laptop and phone): {link or NO_LINK}\n'
228
+ f' 2. On your Mac/Linux: npx @vibelord/lanes --invite {code}\n'
229
+ ' 3. Then: lanes-launch · lanes-launch web phone · lanes-launch web laptop\n'
230
+ 'Takes ~5 min.\n')
231
+
232
+
233
+ def kit_markdown(name, valid, code, link):
234
+ escaped = _MARKDOWN.sub(r'\\\1', name) # Markdown's own characters in a name show as written
235
+ step = f'<{link}>' if link else NO_LINK
236
+ return (f'**Invite for {escaped}** — valid {valid}, one redemption\n\n'
237
+ f'1. Join the tailnet (laptop and phone): {step}\n'
238
+ f'2. On your Mac/Linux: `npx @vibelord/lanes --invite {code}`\n'
239
+ '3. Then: `lanes-launch` · `lanes-launch web phone` · `lanes-launch web laptop`\n\n'
240
+ 'Takes ~5 min.\n')
241
+
242
+
243
+ # --------------------------------------------------------------------------------------------- opt-in extras
244
+
245
+ def read_key_file(path):
246
+ """(key, settings) from a Lanes-owned key file — the key on the first line that is neither blank, a `#` comment
247
+ nor `name=value`; `from=…` and other settings beside it — or (None, {}) when there is no file. Raises
248
+ L.LanesError, never echoing the file, when it holds no usable key. Warns when others can read it."""
249
+ try:
250
+ mode = path.stat().st_mode
251
+ with open(path, encoding='utf-8') as handle:
252
+ text = handle.read(MAX_KEY_FILE)
253
+ except FileNotFoundError:
254
+ return None, {}
255
+ except (OSError, UnicodeDecodeError) as error:
256
+ raise L.LanesError(f'cannot read {path} ({getattr(error, "strerror", None) or error.__class__.__name__})')
257
+ if mode & 0o077:
258
+ _note(f'⚠ {path} can be read by other accounts: chmod 600 {path}')
259
+ key, settings = None, {}
260
+ for line in text.splitlines():
261
+ line = line.strip()
262
+ if not line or line.startswith('#'):
263
+ continue
264
+ if re.match(r'[a-z_]+=', line):
265
+ settings[line.split('=', 1)[0]] = line.split('=', 1)[1].strip()
266
+ elif key is None:
267
+ key = line
268
+ if key is None or not _KEY.fullmatch(key):
269
+ raise L.LanesError(f'{path} holds no usable key (one line, no blanks)')
270
+ return key, settings
271
+
272
+
273
+ def post_json(url, key, payload):
274
+ """POST `payload` as JSON with `Authorization: Bearer <key>` -> (status | None, document | None, reason). The
275
+ whole exchange runs in a daemon thread that is waited for HTTP_TIMEOUT seconds at most (urlopen's own timeout
276
+ bounds each socket operation, not the total). Never raises; `reason` never holds the key or the response body."""
277
+ timeout = HTTP_TIMEOUT
278
+ request = urllib.request.Request(url, data=json.dumps(payload).encode('utf-8'), method='POST', headers={
279
+ 'Authorization': f'Bearer {key}', 'Content-Type': 'application/json', 'Accept': 'application/json',
280
+ 'User-Agent': f'lanes/{E.version()}'})
281
+ result = {}
282
+
283
+ def exchange():
284
+ try:
285
+ with urllib.request.urlopen(request, timeout=timeout) as response:
286
+ result['status'] = response.status
287
+ result['body'] = response.read(MAX_RESPONSE)
288
+ except urllib.error.HTTPError as error:
289
+ result['status'] = error.code
290
+ error.close()
291
+ except urllib.error.URLError as error:
292
+ result['reason'] = f'connection failed: {E._log_field(getattr(error, "reason", error))[:80]}'
293
+ except Exception as error: # noqa: BLE001 — an optional extra never costs the kit
294
+ result['reason'] = f'connection failed: {error.__class__.__name__}'
295
+
296
+ worker = threading.Thread(target=exchange, name='lanes-invite-http', daemon=True)
297
+ worker.start()
298
+ worker.join(timeout)
299
+ if worker.is_alive():
300
+ return None, None, f'no answer in {timeout:g} s'
301
+ status = result.get('status')
302
+ if status is None:
303
+ return None, None, result.get('reason', 'connection failed')
304
+ try:
305
+ document = json.loads(result.get('body', b'').decode('utf-8'))
306
+ except (UnicodeDecodeError, ValueError):
307
+ document = None
308
+ return status, document, f'HTTP {status}'
309
+
310
+
311
+ def tailscale_link():
312
+ """(link | None, note): a Tailscale user invite minted with ~/.config/lanes/tailscale-api.key, without the
313
+ invitee's email (Tailscale then returns `inviteUrl` and sends nothing; it omits the URL for an address in the
314
+ tailnet's own domain), so the kit is the one message."""
315
+ path = Path.home() / '.config/lanes/tailscale-api.key'
316
+ try:
317
+ key, _settings = read_key_file(path)
318
+ except L.LanesError as error:
319
+ return None, f'Tailscale invite: not minted ({error}) — step 1 asks the admin'
320
+ if key is None:
321
+ return None, (f'Tailscale invite: no {path} — step 1 asks the admin; paste a console invite with '
322
+ '--tailscale-link, or put a user-owned API access token there')
323
+ status, document, reason = post_json(TAILSCALE_INVITES_URL, key, [{'role': 'member'}])
324
+ first = document[0] if status == 200 and isinstance(document, list) and document else None
325
+ link = first.get('inviteUrl') if isinstance(first, dict) else None
326
+ if isinstance(link, str) and _LINK.fullmatch(link):
327
+ return link, 'Tailscale invite: minted (api.tailscale.com) — the link is step 1'
328
+ why = reason if status != 200 else 'no invite link in the answer'
329
+ return None, f'Tailscale invite: not minted ({why}) — step 1 asks the admin'
330
+
331
+
332
+ def email_kit(address, text):
333
+ """-> note: the kit sent to `address` through Resend with ~/.config/lanes/resend.key, or why it was not. Resend
334
+ answers 403 both when its test sender (no from= line) writes to anyone but the account's own address and when a
335
+ from= sender's domain is not verified (resend.com/docs/api-reference/errors), so a 403 note names the from= line
336
+ that decides it; the response body is never read or echoed."""
337
+ path = Path.home() / '.config/lanes/resend.key'
338
+ try:
339
+ key, settings = read_key_file(path)
340
+ except L.LanesError as error:
341
+ return f'email: not emailed ({error}) — send the kit yourself'
342
+ if key is None:
343
+ return f'email: not emailed — no {path}; send the kit yourself'
344
+ sender = settings.get('from') or RESEND_SENDER
345
+ if len(sender) > M.MAX_LOGIN or M.unsafe(sender) or '@' not in sender:
346
+ return f'email: not emailed — the from= line in {path} is not a sender; send the kit yourself'
347
+ status, _document, reason = post_json(RESEND_URL, key, {'from': sender, 'to': [address],
348
+ 'subject': RESEND_SUBJECT, 'text': text})
349
+ if status in (200, 201, 202):
350
+ return f'email: emailed to {address} (Resend)'
351
+ if status == 403 and not settings.get('from'):
352
+ return (f"email: not emailed ({reason}) — with no from= line the kit goes from Resend's test sender, which "
353
+ f"delivers only to the Resend account's own address: add a `from=Name <addr>` line on a domain "
354
+ f'verified in Resend to {path} (or check the key is active); send the kit yourself')
355
+ if status == 403:
356
+ return (f'email: not emailed ({reason}) — the from= sender in {path} must be on a domain verified in Resend '
357
+ '(or check the key is active); send the kit yourself')
358
+ return f'email: not emailed ({reason}) — send the kit yourself'
359
+
360
+
361
+ # ----------------------------------------------------------------------------------------------- `lanes invite`
362
+
363
+ def parse_invite(args):
364
+ options, words, i = {}, [], 0
365
+ while i < len(args):
366
+ arg = args[i]
367
+ if arg in VALUE_OPTIONS:
368
+ if arg in options or i + 1 >= len(args):
369
+ raise L.LanesError(INVITE_USAGE)
370
+ options[arg] = args[i + 1]
371
+ i += 2
372
+ continue
373
+ if arg == '--md':
374
+ if arg in options:
375
+ raise L.LanesError(INVITE_USAGE)
376
+ options[arg] = True
377
+ elif arg.startswith('-'):
378
+ raise L.LanesError(INVITE_USAGE)
379
+ else:
380
+ words.append(arg)
381
+ i += 1
382
+ if len(words) != 1:
383
+ raise L.LanesError(INVITE_USAGE)
384
+ return {'name': check_name(words[0]),
385
+ 'email': check_email(options['--email']) if '--email' in options else None,
386
+ 'expires': parse_expires(options['--expires']) if '--expires' in options else DEFAULT_EXPIRES,
387
+ 'workspaces': check_workspaces(options.get('--workspaces')),
388
+ 'link': check_link(options['--tailscale-link']) if '--tailscale-link' in options else None,
389
+ 'md': '--md' in options}
390
+
391
+
392
+ def unknown_workspaces(workspaces):
393
+ """The ids the site catalog does not list ([] when there is no readable catalog to compare with)."""
394
+ try:
395
+ listed = {w.get('id') for w in L.read_site_json(L.workspaces_dir() / 'catalog.json')['workspaces']}
396
+ except (OSError, ValueError, KeyError, TypeError, AttributeError, L.LanesError):
397
+ return []
398
+ return [workspace for workspace in workspaces if workspace not in listed]
399
+
400
+
401
+ def cmd_invite(args, now=None):
402
+ options = parse_invite(args)
403
+ if not _on_terminal():
404
+ _note('lanes invite: run it in your own terminal — the invite code is shown once, on a terminal only; '
405
+ 'nothing was minted')
406
+ return NOT_A_TERMINAL
407
+ code, document = mint(options['name'], options['email'], options['workspaces'], options['expires'], now)
408
+ notes = [f"invite {document['id']}: stored as invites/{document['id']}.json (its hash, never the code); expires "
409
+ f"{document['expires_at']}; the code is shown once, here — `lanes invite revoke {document['id']}` "
410
+ 'cancels it']
411
+ link = options['link']
412
+ if link is None:
413
+ link, note = tailscale_link()
414
+ notes.append(note)
415
+ valid = valid_for(options['expires'])
416
+ plain = kit_text(options['name'], valid, code, link)
417
+ print(kit_markdown(options['name'], valid, code, link) if options['md'] else plain, end='', flush=True)
418
+ if options['email']:
419
+ notes.append(email_kit(options['email'], plain))
420
+ elif not E._valid_login(options['name']):
421
+ notes.append(f"no --email, and {options['name']!r} is not a login: on an invite-only server the member is "
422
+ 'recorded without an owner — `lanes keys owner <device> <login>` names one later')
423
+ for workspace in unknown_workspaces(options['workspaces']):
424
+ notes.append(f'⚠ workspace {workspace} is not in the site catalog (`lanes workspaces`); recorded anyway')
425
+ for note in notes:
426
+ _note(note)
427
+ return 0
428
+
429
+
430
+ def invite_state(document, now):
431
+ if document.get('redeemed_by') is not None:
432
+ return 'redeemed'
433
+ if document.get('revoked_at') is not None:
434
+ return 'revoked'
435
+ if E.invite_live(document, now):
436
+ return 'live'
437
+ return 'expired' if E.parse_stamp(document.get('expires_at')) is not None else 'invalid'
438
+
439
+
440
+ def cmd_invite_list(now=None):
441
+ now = time.time() if now is None else now
442
+ invites = E.load_invites()
443
+ for invite_id, _path, document in invites:
444
+ state = invite_state(document, now)
445
+ line = (f"{invite_id:<6} {state:<9} {_shown(document.get('name'), _name):<24} "
446
+ f"{_shown(document.get('email'), _email):<28} expires {_stamp_shown(document.get('expires_at'))}")
447
+ if state == 'redeemed':
448
+ redeemed = document['redeemed_by'] if isinstance(document['redeemed_by'], dict) else {}
449
+ line += (f" redeemed by {_shown(redeemed.get('device'), M.device_name)} "
450
+ f"{_stamp_shown(document.get('redeemed_at'))}")
451
+ elif state == 'revoked':
452
+ line += f" revoked {_stamp_shown(document.get('revoked_at'))}"
453
+ print(line)
454
+ if not invites:
455
+ print('no invites')
456
+ return 0
457
+
458
+
459
+ def cmd_invite_revoke(invite_id, now=None):
460
+ now = time.time() if now is None else now
461
+ shown = invite_id.lower() if isinstance(invite_id, str) and L.IDENT.match(invite_id) else '?'
462
+ if shown == '?' or len(shown) > M.MAX_ID:
463
+ raise L.LanesError(f'no invite {shown[:M.MAX_ID]} (`lanes invite list` shows them)')
464
+ with K.members_lock():
465
+ path = E.invites_dir() / f'{shown}.json'
466
+ document = E._read_doc(path)
467
+ if document is None:
468
+ raise L.LanesError(f'no invite {shown} (`lanes invite list` shows them)')
469
+ if document.get('redeemed_by') is not None:
470
+ redeemed = document['redeemed_by'] if isinstance(document['redeemed_by'], dict) else {}
471
+ device = _shown(redeemed.get('device'), M.device_name)
472
+ print(f'invite {shown} was already redeemed by {device}: revoking it changes nothing — '
473
+ f'`lanes offboard {device} --yes` removes that machine')
474
+ return 1
475
+ if document.get('revoked_at') is not None:
476
+ print(f"invite {shown} is already revoked ({_stamp_shown(document['revoked_at'])}); nothing changed")
477
+ return 0
478
+ L.atomic_write(path, E._json_bytes(dict(document, revoked_at=E.stamp(now))), mode=0o600)
479
+ E.audit('invite-revoke', shown, None, None, None, 'ok', now)
480
+ print(f'revoked invite {shown}: its code is refused from now on, like an unknown one')
481
+ return 0
482
+
483
+
484
+ def invite_main(args, now=None):
485
+ if args[:1] == ['list']:
486
+ if len(args) != 1:
487
+ raise L.LanesError(INVITE_USAGE)
488
+ return cmd_invite_list(now)
489
+ if args[:1] == ['revoke']:
490
+ if len(args) != 2:
491
+ raise L.LanesError(INVITE_USAGE)
492
+ return cmd_invite_revoke(args[1], now)
493
+ return cmd_invite(args, now)
494
+
495
+
496
+ # ------------------------------------------------------------------------------------------------ `lanes enrol`
497
+
498
+ def ssh_source(value):
499
+ """The client address of an SSH session's $SSH_CLIENT (`<address> <port> <port>`), `local` without one,
500
+ `unknown` when it does not start with an address."""
501
+ if not value or not value.strip():
502
+ return 'local'
503
+ try:
504
+ return str(ipaddress.ip_address(value.split()[0]))
505
+ except ValueError:
506
+ return 'unknown'
507
+
508
+
509
+ def cli_approver(as_device=None, environ=None):
510
+ """The `approved_by` / `decided_by` a CLI decision records: `cli[:<device>]@<address>` (see the module docstring)."""
511
+ environ = os.environ if environ is None else environ
512
+ who = f'cli:{M.device_name(as_device)}' if as_device else 'cli'
513
+ return f"{who}@{ssh_source(environ.get('SSH_CLIENT'))}"
514
+
515
+
516
+ def cmd_enrol_list(show_all, now=None):
517
+ now = time.time() if now is None else now
518
+ shown = 0
519
+ for request in E.load_requests():
520
+ state = E.request_state(request, now)
521
+ if state != 'pending' and not show_all:
522
+ continue
523
+ shown += 1
524
+ print(f"{request['id']:<9} {state:<8} {request['device']:<16} {request['login']:<28} "
525
+ f"{K.fingerprint(request['pubkey'])} asked {_stamp_shown(request.get('created_at'))} expires "
526
+ f"{_stamp_shown(request['expires_at'])}" + (f" {request['name']}" if request.get('name') else '')
527
+ + (f" by {_shown(request.get('decided_by'), M.check_login)}" if state in ('approved', 'denied')
528
+ else ''))
529
+ if not shown:
530
+ print('no pending enrolment requests' + ('' if show_all else
531
+ ' (`lanes enrol list --all` shows the others)'))
532
+ return 0
533
+
534
+
535
+ def cmd_decide(action, request_id, as_device, now=None):
536
+ if as_device is not None:
537
+ device = M.device_name(as_device)
538
+ members, _problems = K.load_members()
539
+ if device not in {member['device'] for member in members}:
540
+ raise L.LanesError(f'--as {device}: no member {device} (`lanes keys list` shows them); nothing changed')
541
+ approver = cli_approver(as_device)
542
+ document = E.decide(request_id, action, approver, now)
543
+ if action == 'approve':
544
+ print(f"approved {document['id']}: {document['device']} for {document['login']} "
545
+ f"({K.fingerprint(document['pubkey'])}) — its key is in the lanes-managed block of "
546
+ f'{K.authorized_keys_path()}; recorded as approved by {approver}')
547
+ else:
548
+ print(f"denied {document['id']}: {document['device']} for {document['login']}; nothing was authorized "
549
+ f'(recorded as denied by {approver})')
550
+ return 0
551
+
552
+
553
+ def unit_forced():
554
+ """The LANES_ENROL_REQUEST_MODE lanes-enrold.service runs with ('0' | '1'), from `systemctl --user show -p
555
+ Environment` (bounded); None when it sets none or systemd cannot say. Read-only."""
556
+ if not shutil.which('systemctl'):
557
+ return None
558
+ rc, out, _err = L.run_bounded(UNIT_ENVIRONMENT, 10)
559
+ if rc != 0:
560
+ return None
561
+ found = re.search(r'(?:^Environment=|\s)LANES_ENROL_REQUEST_MODE=(\S*)', out, re.M)
562
+ value = found.group(1).strip().strip('"') if found else ''
563
+ return value if value in ('0', '1') else None
564
+
565
+
566
+ def cmd_mode(mode, now=None):
567
+ """`lanes enrol mode [request|invite|auto]`: pin (or show) whether lanes-enrold takes access requests (DEF-1725)."""
568
+ if mode is not None:
569
+ setter = cli_approver()
570
+ document = E.write_mode(mode, setter, now)
571
+ E.audit('mode', None, None, setter, None, mode, now)
572
+ print({'request': 'pinned: request mode — lanes-enrold takes access requests (tailnet logins in its domains) '
573
+ 'whatever request arrives first after a start, across restarts and reinstalls',
574
+ 'invite': 'pinned: invite only — lanes-enrold refuses every access request, whatever arrives first',
575
+ 'auto': 'unpinned: auto — after each start, the first request through Tailscale Serve decides (a '
576
+ 'loopback probe does not)'}[mode]
577
+ + (f" ({E.mode_path()}, by {document['set_by']}); the running service reads it at its next request"
578
+ if document else '; the running service reads that at its next request'))
579
+ else:
580
+ pinned, problem = E.read_mode()
581
+ if problem:
582
+ print(f'mode: invite only — {problem}; `lanes enrol mode request|invite|auto` writes it again')
583
+ elif pinned:
584
+ print(f"mode: {'request' if pinned == 'request' else 'invite only'} (pinned in {E.mode_path()})")
585
+ else:
586
+ print('mode: auto — after each start, the first request through Tailscale Serve decides; pin it once the '
587
+ 'Serve header check passes: lanes enrol mode request (RUNBOOK "Operator verification")')
588
+ forced = unit_forced()
589
+ if forced is not None:
590
+ _note(f"⚠ lanes-enrold.service sets LANES_ENROL_REQUEST_MODE={forced} (a unit drop-in), which wins over this "
591
+ f"pin: request mode is {'on' if forced == '1' else 'off'} — `systemctl --user edit lanes-enrold.service` "
592
+ 'removes it, then `systemctl --user restart lanes-enrold.service`')
593
+ return 0
594
+
595
+
596
+ def enrol_main(args, now=None):
597
+ verb, rest = (args[0], args[1:]) if args else (None, [])
598
+ if verb == 'list' and rest in ([], ['--all']):
599
+ return cmd_enrol_list(bool(rest), now)
600
+ if verb == 'mode' and len(rest) <= 1 and all(word in ('request', 'invite', 'auto') for word in rest):
601
+ return cmd_mode(rest[0] if rest else None, now)
602
+ if verb in E.ACTIONS:
603
+ if len(rest) == 1 and not rest[0].startswith('-'):
604
+ return cmd_decide(verb, rest[0], None, now)
605
+ if len(rest) == 3 and not rest[0].startswith('-') and rest[1] == '--as':
606
+ return cmd_decide(verb, rest[0], rest[2], now)
607
+ raise L.LanesError(ENROL_USAGE)
608
+
609
+
610
+ # --------------------------------------------------------------------------------------------- `lanes offboard`
611
+
612
+ def _windows_of(device):
613
+ """Other known devices that are windows of `device` (devices/<d>.json `base`), sorted."""
614
+ found = []
615
+ for path in sorted((L.STATE / 'devices').glob('*.json')):
616
+ meta = L.read_json(path)
617
+ if path.stem != device and isinstance(meta, dict) and meta.get('base') == device:
618
+ found.append(path.stem)
619
+ return found
620
+
621
+
622
+ def offboard_main(args, forget, revoke_token, end_view, now=None):
623
+ """`lanes offboard <device> --yes`. `forget(device)` is `lanes device forget`; `revoke_token(device, windows)` and
624
+ `end_view(device)` -> (status, sentence) are bin/lanes's zellij steps: 'done' or 'none' is said, 'warn' is said as
625
+ a warning, 'failed' is a warning that leaves the offboard incomplete (exit 1; the next run finishes it)."""
626
+ words = [arg for arg in args if not arg.startswith('-')]
627
+ flags = [arg for arg in args if arg.startswith('-')]
628
+ if len(words) != 1 or any(flag != '--yes' for flag in flags) or len(flags) > 1:
629
+ raise L.LanesError(OFFBOARD_USAGE)
630
+ device = M.device_name(words[0])
631
+ if not flags or not _on_terminal():
632
+ _note(f'lanes offboard {device}: run it in your own terminal with --yes; it\n'
633
+ f' 1. revokes its key (`lanes keys revoke {device}`: the lanes-managed block of ~/.ssh/authorized_keys, '
634
+ f'then members/{device}.json)\n'
635
+ f' 2. forgets the device (`lanes device forget {device}`): `lanes ensure` stops recreating its view\n'
636
+ f' 3. signs its browsers and phones out: revokes the web login tokens `lanes web --token` recorded for '
637
+ f'{device}, its windows and the views `lanes-launch web` opened for it (`lanes token list`)\n'
638
+ f' 4. ends its view session {device} (no lane is touched)\n'
639
+ 'nothing changed')
640
+ return NOT_A_TERMINAL
641
+ now = time.time() if now is None else now
642
+ members, _problems = K.load_members()
643
+ fingerprint = next((K.fingerprint(m['pubkey']) for m in members if m['device'] == device), None)
644
+ windows = _windows_of(device)
645
+ incomplete = []
646
+
647
+ print(f'1/4 key: {device}')
648
+ try:
649
+ if K.cmd_revoke(K.authorized_keys_path(), device):
650
+ incomplete.append('a line outside the lanes-managed block still lets the key in (above)')
651
+ except L.LanesError as error:
652
+ E.audit('offboard', None, device, None, fingerprint, 'stopped-key', now)
653
+ raise L.LanesError(f'offboard of {device} stopped at step 1 (its key): {error} — nothing after it ran; '
654
+ f'`lanes offboard {device} --yes` again finishes it') from None
655
+
656
+ print(f'2/4 device: {device}')
657
+ if (L.STATE / 'devices' / f'{device}.json').exists():
658
+ try:
659
+ forget(device)
660
+ except (L.LanesError, OSError) as error:
661
+ E.audit('offboard', None, device, None, fingerprint, 'stopped-device', now)
662
+ raise L.LanesError(f'offboard of {device} stopped at step 2 (devices/{device}.json): '
663
+ f'{getattr(error, "strerror", None) or error} — the key is revoked; '
664
+ f'`lanes offboard {device} --yes` again finishes it') from None
665
+ else:
666
+ print(f'no devices/{device}.json: `lanes ensure` keeps no view for it')
667
+
668
+ print(f'3/4 web tokens: {device}' + (f" and its views {', '.join(windows)}" if windows else ''))
669
+ status, sentence = revoke_token(device, windows)
670
+ if status in ('done', 'none'):
671
+ print(sentence)
672
+ else:
673
+ _note(f'⚠ {sentence}')
674
+ if status == 'failed':
675
+ incomplete.append(sentence)
676
+
677
+ print(f'4/4 view: {device}')
678
+ status, sentence = end_view(device)
679
+ if status == 'failed':
680
+ _note(f'⚠ {sentence}')
681
+ incomplete.append(sentence)
682
+ else:
683
+ print(sentence)
684
+
685
+ if windows:
686
+ print(f"still known: {', '.join(windows)} (window{'s' if len(windows) > 1 else ''} of {device}, sharing its "
687
+ f'key, now revoked, and signed out above) — ' + '; '.join(f'`lanes offboard {name} --yes`'
688
+ for name in windows)
689
+ + ' ends each view')
690
+ E.audit('offboard', None, device, None, fingerprint, 'incomplete' if incomplete else 'ok', now)
691
+ if incomplete:
692
+ print(f'offboard of {device} incomplete: ' + '; '.join(incomplete))
693
+ return 1
694
+ print(f'offboarded {device}')
695
+ return 0