@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.
- package/README.md +52 -15
- package/bin/lanes-install.mjs +8 -2
- package/lib/plan.mjs +34 -1
- package/package.json +2 -2
- package/payload/lanes/README.md +39 -18
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +994 -49
- package/payload/lanes/client/lanes-launch +160 -14
- package/payload/lanes/client/ssh.stanza.in +11 -2
- package/payload/lanes/docs/ARCHITECTURE.md +86 -2
- package/payload/lanes/docs/INSTALL.md +296 -43
- package/payload/lanes/docs/RUNBOOK.md +453 -27
- package/payload/lanes/docs/USERGUIDE.md +449 -76
- package/payload/lanes/install/enable-phone.sh +8 -20
- package/payload/lanes/install/enable-web.sh +2 -2
- package/payload/lanes/install/host-install.sh +6 -0
- package/payload/lanes/install.sh +888 -52
- package/payload/lanes/libexec/enrol.py +695 -0
- package/payload/lanes/libexec/enrold.py +1264 -0
- package/payload/lanes/libexec/keys.py +644 -0
- package/payload/lanes/libexec/lanes_lib.py +14 -6
- package/payload/lanes/libexec/members.py +297 -0
- package/payload/lanes/libexec/notify.py +146 -21
- package/payload/lanes/libexec/qr.py +217 -0
- package/payload/lanes/libexec/site_edit.py +237 -0
- package/payload/lanes/libexec/web_tokens.py +315 -0
- package/payload/lanes/site/README.md +13 -1
- package/payload/lanes/site/beyondwork/site.conf +3 -0
- package/payload/lanes/site/site.example.conf +5 -0
- package/payload/lanes/sudo/one-time-root.sh +1 -1
- package/payload/lanes/systemd/lanes-enrold.service +22 -0
- package/payload/lanes/systemd/lanes-notify.service +4 -4
- package/payload/lanes/systemd/lanes-web.service +4 -0
- package/payload/lanes/web/Caddyfile +10 -0
- package/payload/lanes/web/README.md +8 -2
- package/payload/lanes/workspaces.example/README.md +6 -1
- 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
|