@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,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}')
|