@vibelord/lanes 1.1.1 → 1.3.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 +36 -5
- package/bin/lanes-install.mjs +166 -13
- package/lib/plan.mjs +113 -3
- package/package.json +1 -1
- package/payload/lanes/README.md +47 -23
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +2753 -202
- package/payload/lanes/client/ghostty/lanes-palette-light.conf +19 -19
- package/payload/lanes/client/ghostty/lanes-palette.conf +19 -19
- package/payload/lanes/client/lanes-launch +289 -40
- package/payload/lanes/client/lanes-window +15 -3
- package/payload/lanes/container/hooks/claude-settings.json +1 -0
- package/payload/lanes/container/lane +147 -92
- package/payload/lanes/container/lane-edit +100 -0
- package/payload/lanes/container/lane-mcp +1150 -0
- package/payload/lanes/container/lane-signal +109 -29
- package/payload/lanes/container/lane_core.py +1318 -0
- package/payload/lanes/container/vimrc +31 -0
- package/payload/lanes/docs/ARCHITECTURE.md +793 -16
- package/payload/lanes/docs/DESIGN.md +127 -46
- package/payload/lanes/docs/INSTALL.md +175 -18
- package/payload/lanes/docs/RUNBOOK.md +968 -76
- package/payload/lanes/docs/SHARING.md +3 -2
- package/payload/lanes/docs/USERGUIDE.md +967 -150
- package/payload/lanes/holders.lock +5 -0
- package/payload/lanes/install/enable-docs.sh +78 -0
- package/payload/lanes/install/enable-web.sh +2 -0
- package/payload/lanes/install/host-install.sh +197 -7
- package/payload/lanes/install/services.py +148 -18
- package/payload/lanes/install.sh +68 -7
- package/payload/lanes/libexec/agent_state.py +74 -1
- package/payload/lanes/libexec/cursor_sync.py +6 -0
- package/payload/lanes/libexec/device_sync.py +47 -10
- package/payload/lanes/libexec/docs_store.py +995 -0
- package/payload/lanes/libexec/docsd.py +2010 -0
- package/payload/lanes/libexec/economy_poll.py +1 -0
- package/payload/lanes/libexec/enrold.py +41 -22
- package/payload/lanes/libexec/keys.py +54 -23
- package/payload/lanes/libexec/lanes_lib.py +620 -22
- package/payload/lanes/libexec/mdlite.py +575 -0
- package/payload/lanes/libexec/members.py +21 -2
- package/payload/lanes/libexec/notify.py +169 -29
- package/payload/lanes/libexec/registrations.py +216 -18
- package/payload/lanes/libexec/session_actions.py +182 -14
- package/payload/lanes/libexec/sidebar +11 -3
- package/payload/lanes/libexec/sidebar.py +2232 -365
- package/payload/lanes/libexec/statusd.py +231 -12
- package/payload/lanes/libexec/studio_host.py +8 -1
- package/payload/lanes/libexec/tokens.py +148 -35
- package/payload/lanes/libexec/tokens.sh +86 -2
- package/payload/lanes/libexec/update.py +1044 -0
- package/payload/lanes/libexec/uploads.py +538 -0
- package/payload/lanes/libexec/users.py +614 -0
- package/payload/lanes/libexec/view.sh +34 -6
- package/payload/lanes/systemd/lanes-docsd.service +29 -0
- package/payload/lanes/systemd/lanes-ensure.service +3 -0
- package/payload/lanes/systemd/lanes-gc.service +1 -1
- package/payload/lanes/systemd/lanes-update.service +17 -0
- package/payload/lanes/systemd/lanes-update.timer +17 -0
- package/payload/lanes/web/Caddyfile +24 -1
- package/payload/lanes/web/README.md +165 -28
- package/payload/lanes/web/browser.js +4 -8
- package/payload/lanes/web/docs.css +52 -0
- package/payload/lanes/web/docs.js +36 -0
- package/payload/lanes/web/mobile.css +17 -17
- package/payload/lanes/web/mobile.js +45 -32
- package/payload/lanes/web/upload.css +17 -0
- package/payload/lanes/web/upload.js +84 -0
- package/payload/lanes/workspaces.example/README.md +3 -1
- package/payload/lanes/zellij/config.kdl +106 -56
- package/payload/manifest.json +6 -3
|
@@ -666,6 +666,7 @@ def main(argv=None):
|
|
|
666
666
|
report(diagnostics, None, always=True)
|
|
667
667
|
return 0
|
|
668
668
|
control_url(env) # a malformed URL stops the unit at start, not once per tick
|
|
669
|
+
L.record_tree('economy', state) # W9 U0: the unit's tree; `--once`/`--dry-run` (a person's diagnostic) never
|
|
669
670
|
last = None
|
|
670
671
|
while True:
|
|
671
672
|
diagnostics = {}
|
|
@@ -66,11 +66,15 @@ members/ and authorized_keys, so the CLI and this service never interleave:
|
|
|
66
66
|
The opt-in lanes-notify service (notify.py, W8 A5) reads them through load_requests and
|
|
67
67
|
pushes one note per new pending request.
|
|
68
68
|
members/<device>.json members.py's schema, via invite|request, written through keys.py (save_member, then
|
|
69
|
-
write_authorized_keys) — never devices/.
|
|
69
|
+
write_authorized_keys) — never devices/. `client_version` (W9 K1, LNS-86) is the
|
|
70
|
+
release the body named (client_version(): the redeem's, or on approval the request's);
|
|
71
|
+
absent when it named none.
|
|
70
72
|
enrol-mode {version: 1, mode: request|invite, set_at, set_by}, written by `lanes enrol mode` (enrol.py,
|
|
71
73
|
through write_mode; `auto` removes it); read on every request, never written here.
|
|
72
74
|
enrol.log append-only, one line per attempt: `<UTC> <event> <id> <device> <login> fp=<SHA256:…> <result>`
|
|
73
|
-
(`-` for an unknown field)
|
|
75
|
+
(`-` for an unknown field); every line redeem() writes for its attempt adds ` client=<v>`
|
|
76
|
+
when the body named a release (W9 K1). A code is never written anywhere, not even its id
|
|
77
|
+
half on a miss.
|
|
74
78
|
A redeem or approval re-renders the whole block from the members keys.load_members accepts,
|
|
75
79
|
so a member file it cannot use loses its line (fail closed): each such file is one line
|
|
76
80
|
`<event> <id> <its device> <login> fp=- dropped-member` before the attempt's own, and the
|
|
@@ -132,7 +136,7 @@ ACTIONS = ('approve', 'deny')
|
|
|
132
136
|
HTML_POLICY = "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'"
|
|
133
137
|
_HEX64 = re.compile(r'[0-9a-f]{64}')
|
|
134
138
|
_REQUEST_ID = re.compile(r'[a-z0-9]{1,32}')
|
|
135
|
-
_CLIENT_VERSION =
|
|
139
|
+
_CLIENT_VERSION = M.CLIENT_VERSION # a body's client_version: the rule the member record is validated by
|
|
136
140
|
_STAMP = re.compile(r'\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ', re.ASCII)
|
|
137
141
|
_LOG_UNSAFE = re.compile(r'[^A-Za-z0-9_.@:+=/-]')
|
|
138
142
|
_SITE_LINE = re.compile(r'^(LANES_[A-Z_]+)=([A-Za-z0-9_.:@/-]*)$')
|
|
@@ -294,10 +298,13 @@ def _log_field(value):
|
|
|
294
298
|
return _LOG_UNSAFE.sub('?', str(value))[:128] if value else '-'
|
|
295
299
|
|
|
296
300
|
|
|
297
|
-
def audit(event, ident, device, login, fingerprint, result, now=None):
|
|
298
|
-
"""Append one line to enrol.log (0600). Never raises: a log that cannot be written is said on stderr.
|
|
301
|
+
def audit(event, ident, device, login, fingerprint, result, now=None, client=None):
|
|
302
|
+
"""Append one line to enrol.log (0600). Never raises: a log that cannot be written is said on stderr. `client`
|
|
303
|
+
(a redeem attempt's client_version, W9 K1) ends the line as `client=<it>` when given; no field otherwise."""
|
|
299
304
|
line = ' '.join((stamp(time.time() if now is None else now), _log_field(event), _log_field(ident),
|
|
300
305
|
_log_field(device), _log_field(login), 'fp=' + _log_field(fingerprint), _log_field(result)))
|
|
306
|
+
if client is not None:
|
|
307
|
+
line += ' client=' + _log_field(client)
|
|
301
308
|
try:
|
|
302
309
|
L.STATE.mkdir(parents=True, exist_ok=True)
|
|
303
310
|
handle = os.open(log_path(), os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600)
|
|
@@ -510,14 +517,26 @@ def _block_lacks(path, members, device):
|
|
|
510
517
|
|
|
511
518
|
# ------------------------------------------------------------------------------------------------------- redeem
|
|
512
519
|
|
|
520
|
+
def client_version(value):
|
|
521
|
+
"""The client's release as an enrolment body states it (`client_version`; install.sh sends its VERSION, `dev`
|
|
522
|
+
without one), else None: a string matching members.CLIENT_VERSION. Anything else (absent, null, a number, a blank,
|
|
523
|
+
a terminal escape, over 32 characters) is not stored and refuses nothing. One rule for the request file
|
|
524
|
+
(create_request), the member record (redeem, and an approval from its request) and the redeem audit line."""
|
|
525
|
+
return value if isinstance(value, str) and _CLIENT_VERSION.fullmatch(value) else None
|
|
526
|
+
|
|
527
|
+
|
|
513
528
|
def redeem(fields, caller=None, now=None):
|
|
514
|
-
"""POST /redeem -> (status, payload). `caller` is the TRUSTED tailnet identity (None outside request mode).
|
|
529
|
+
"""POST /redeem -> (status, payload). `caller` is the TRUSTED tailnet identity (None outside request mode). Every
|
|
530
|
+
enrol.log line of the attempt ends `client=<release>` when the body named one (client_version, W9 K1, LNS-86), and
|
|
531
|
+
the member record keeps it; a line the HTTP layer writes before a body is in hand (rate-limited; a body refused as
|
|
532
|
+
bad-request, too-large or length-required) carries none."""
|
|
515
533
|
now = time.time() if now is None else now
|
|
516
534
|
caller = caller or NOBODY
|
|
535
|
+
client = client_version(fields.get('client_version'))
|
|
517
536
|
try:
|
|
518
537
|
device = M.device_name(fields.get('device'))
|
|
519
538
|
except L.LanesError:
|
|
520
|
-
audit('redeem', None, None, caller.login, None, 'invalid-device', now)
|
|
539
|
+
audit('redeem', None, None, caller.login, None, 'invalid-device', now, client)
|
|
521
540
|
return 400, {'error': 'invalid-device'}
|
|
522
541
|
try:
|
|
523
542
|
pubkey = K.parse_pubkey(fields['pubkey']) if isinstance(fields.get('pubkey'), str) else None
|
|
@@ -526,21 +545,21 @@ def redeem(fields, caller=None, now=None):
|
|
|
526
545
|
fingerprint = K.fingerprint(pubkey) if pubkey else None
|
|
527
546
|
match = match_invite(fields.get('code'), now)
|
|
528
547
|
if match is None or pubkey is None:
|
|
529
|
-
audit('redeem', None, device, caller.login, fingerprint, INVALID, now)
|
|
548
|
+
audit('redeem', None, device, caller.login, fingerprint, INVALID, now, client)
|
|
530
549
|
return 403, {'error': INVALID}
|
|
531
550
|
try:
|
|
532
551
|
with K.members_lock():
|
|
533
|
-
return _redeem_locked(match, device, pubkey, fingerprint, caller, now)
|
|
552
|
+
return _redeem_locked(match, device, pubkey, fingerprint, caller, now, client)
|
|
534
553
|
except L.LanesError: # only the lock raises out of _redeem_locked
|
|
535
|
-
audit('redeem', match[0], device, caller.login, fingerprint, 'busy', now)
|
|
554
|
+
audit('redeem', match[0], device, caller.login, fingerprint, 'busy', now, client)
|
|
536
555
|
return 503, {'error': 'busy'}
|
|
537
556
|
|
|
538
557
|
|
|
539
|
-
def _redeem_locked(match, device, pubkey, fingerprint, caller, now):
|
|
558
|
+
def _redeem_locked(match, device, pubkey, fingerprint, caller, now, client=None):
|
|
540
559
|
invite_id, path, digest = match
|
|
541
560
|
invite = _read_doc(path)
|
|
542
561
|
if invite is None or not invite_live(invite, now) or not hmac.compare_digest(digest, invite['code_sha256']):
|
|
543
|
-
audit('redeem', None, device, caller.login, fingerprint, INVALID, now) # used while we waited
|
|
562
|
+
audit('redeem', None, device, caller.login, fingerprint, INVALID, now, client) # used while we waited
|
|
544
563
|
return 403, {'error': INVALID}
|
|
545
564
|
try:
|
|
546
565
|
ak, data = _authorized_keys()
|
|
@@ -549,19 +568,19 @@ def _redeem_locked(match, device, pubkey, fingerprint, caller, now):
|
|
|
549
568
|
owner = invite_owner(invite, caller)
|
|
550
569
|
record = K.member_record(device, pubkey, via='invite', now=now, owner=owner,
|
|
551
570
|
approved_by=minter_login(invite), invite_id=invite_id,
|
|
552
|
-
workspaces=_workspaces(invite.get('workspaces')),
|
|
571
|
+
workspaces=_workspaces(invite.get('workspaces')), client_version=client,
|
|
553
572
|
**({'invited_as': invited_as(invite)} if caller.login else {}))
|
|
554
573
|
original = path.read_bytes()
|
|
555
574
|
except (OSError, L.LanesError) as error:
|
|
556
575
|
print(f'lanes-enrold: redeem {invite_id}: {error}', file=sys.stderr, flush=True)
|
|
557
|
-
audit('redeem', invite_id, device, caller.login, fingerprint, 'server-error', now)
|
|
576
|
+
audit('redeem', invite_id, device, caller.login, fingerprint, 'server-error', now, client)
|
|
558
577
|
return 503, {'error': 'server-error'}
|
|
559
578
|
who = owner['login'] if owner else caller.login
|
|
560
579
|
if taken:
|
|
561
|
-
audit('redeem', invite_id, device, who, fingerprint, 'device-taken', now)
|
|
580
|
+
audit('redeem', invite_id, device, who, fingerprint, 'device-taken', now, client)
|
|
562
581
|
return 409, {'error': 'device-taken'}
|
|
563
582
|
if any(member['pubkey'] == pubkey for member in members):
|
|
564
|
-
audit('redeem', invite_id, device, who, fingerprint, 'key-taken', now)
|
|
583
|
+
audit('redeem', invite_id, device, who, fingerprint, 'key-taken', now, client)
|
|
565
584
|
return 409, {'error': 'key-taken'}
|
|
566
585
|
consumed = dict(invite, redeemed_by={'device': device, 'login': who, 'pubkey_sha256': fingerprint},
|
|
567
586
|
redeemed_at=stamp(now))
|
|
@@ -569,7 +588,7 @@ def _redeem_locked(match, device, pubkey, fingerprint, caller, now):
|
|
|
569
588
|
L.atomic_write(path, _json_bytes(consumed), mode=0o600) # used up FIRST: a crash never leaves it reusable
|
|
570
589
|
except OSError as error:
|
|
571
590
|
print(f'lanes-enrold: cannot mark invite {invite_id} used: {error.strerror}', file=sys.stderr, flush=True)
|
|
572
|
-
audit('redeem', invite_id, device, who, fingerprint, 'server-error', now)
|
|
591
|
+
audit('redeem', invite_id, device, who, fingerprint, 'server-error', now, client)
|
|
573
592
|
return 503, {'error': 'server-error'}
|
|
574
593
|
report_dropped('redeem', invite_id, who, problems, now) # the writes below re-render the block
|
|
575
594
|
try:
|
|
@@ -583,13 +602,13 @@ def _redeem_locked(match, device, pubkey, fingerprint, caller, now):
|
|
|
583
602
|
L.atomic_write(path, original, mode=0o600)
|
|
584
603
|
except OSError as undo:
|
|
585
604
|
print(f'lanes-enrold: undo of {invite_id} incomplete: {undo.strerror}', file=sys.stderr, flush=True)
|
|
586
|
-
audit('redeem', invite_id, device, who, fingerprint, 'server-error', now)
|
|
605
|
+
audit('redeem', invite_id, device, who, fingerprint, 'server-error', now, client)
|
|
587
606
|
return 503, {'error': 'server-error'}
|
|
588
607
|
if owner is None: # said, never silent (DEF-1644)
|
|
589
608
|
print(f'lanes-enrold: invite {invite_id} names no login (no trusted tailnet login, no email address, and its '
|
|
590
609
|
f'name is not a login): members/{device}.json has no owner — `lanes keys owner {device} <login>` '
|
|
591
610
|
'names one', file=sys.stderr, flush=True)
|
|
592
|
-
audit('redeem', invite_id, device, who, fingerprint, 'ok', now)
|
|
611
|
+
audit('redeem', invite_id, device, who, fingerprint, 'ok', now, client)
|
|
593
612
|
return 200, {'device': device, 'owner': owner, 'ssh_ready': True}
|
|
594
613
|
|
|
595
614
|
|
|
@@ -677,8 +696,7 @@ def create_request(fields, caller, now=None):
|
|
|
677
696
|
{'login': login, 'name': None, 'email': _address(login)})
|
|
678
697
|
if owner is None:
|
|
679
698
|
return 403, {'error': 'no-identity'}
|
|
680
|
-
client = fields.get('client_version')
|
|
681
|
-
client = client if isinstance(client, str) and _CLIENT_VERSION.fullmatch(client) else None
|
|
699
|
+
client = client_version(fields.get('client_version'))
|
|
682
700
|
try:
|
|
683
701
|
with K.members_lock():
|
|
684
702
|
pending = [r for r in load_requests() if request_state(r, now) == 'pending']
|
|
@@ -780,7 +798,7 @@ def _approve_locked(document, approver, now):
|
|
|
780
798
|
owner = M.validate_owner({'login': document['login'], 'name': document.get('name'),
|
|
781
799
|
'email': document.get('email')})
|
|
782
800
|
record = K.member_record(device, pubkey, via='request', now=now, owner=owner, approved_by=approver,
|
|
783
|
-
request_id=document['id'])
|
|
801
|
+
request_id=document['id'], client_version=client_version(document.get('client_version')))
|
|
784
802
|
try:
|
|
785
803
|
K.save_member(record)
|
|
786
804
|
except OSError as error:
|
|
@@ -1248,6 +1266,7 @@ def main(argv=None):
|
|
|
1248
1266
|
parser.error(f'{port} is not a port number')
|
|
1249
1267
|
service = Service.from_environ(os.environ)
|
|
1250
1268
|
server = Server(('127.0.0.1', port), service)
|
|
1269
|
+
L.record_tree('enrold') # W9 U0: the tree this service runs from (`lanes version`, doctor)
|
|
1251
1270
|
print(f'lanes-enrold {version()}: listening on 127.0.0.1:{server.server_address[1]}; request mode '
|
|
1252
1271
|
f'{service.mode_source()}; '
|
|
1253
1272
|
f'domains {",".join(service.domains)}', file=sys.stderr, flush=True)
|
|
@@ -37,6 +37,11 @@ The account's ~/.ssh/authorized_keys carries ONE managed block, re-rendered whol
|
|
|
37
37
|
* `lanes keys owner <device> <login> [--name NAME] [--email ADDR]` names the person behind a member (`add`/`adopt`
|
|
38
38
|
write owner null): the one change to a record's owner, and how a key added by hand makes its person an
|
|
39
39
|
`/enrol/pending` approver. It writes only the member file — the block carries no owner.
|
|
40
|
+
* `lanes keys adopt --login LOGIN` (cockpit v3 R2, LNS-75) records {login: LOGIN} as the owner of every member it
|
|
41
|
+
adopts, in the same write — a machine from before enrolment and its person in one step; a member that exists keeps
|
|
42
|
+
its owner (`owner` changes that). `main(args, on_owned=…)`: whenever adopt --login or owner names an owner, the
|
|
43
|
+
caller is told (login, [devices]) after the write and its return value is the exit code — bin/lanes folds those
|
|
44
|
+
devices' views into the person's profile (users.py); this module never touches devices/ or users/.
|
|
40
45
|
|
|
41
46
|
`pubkey_sha256` is OpenSSH's own fingerprint form (`SHA256:` + unpadded base64 of the blob's SHA-256): what
|
|
42
47
|
`ssh-keygen -lf` prints and what sshd logs on `Accepted publickey`, so a log line names its device.
|
|
@@ -65,7 +70,7 @@ MAX_DEVICE = M.MAX_DEVICE # bin/lanes device_name()
|
|
|
65
70
|
MAX_PUBKEY_FILE = 8192
|
|
66
71
|
LOCK_TIMEOUT = 5
|
|
67
72
|
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]')
|
|
73
|
+
'lanes keys adopt [--dry-run] [--login LOGIN] | lanes keys owner <device> <login> [--name NAME] [--email ADDR]')
|
|
69
74
|
_ED25519_HEAD = struct.pack('>I', len(KEY_TYPE)) + KEY_TYPE.encode() + struct.pack('>I', 32)
|
|
70
75
|
_BASE64 = re.compile(r'[A-Za-z0-9+/]+={0,2}')
|
|
71
76
|
_KEY_TYPE_WORD = re.compile(r'(?:ssh-|ecdsa-|sk-)[A-Za-z0-9@._-]+')
|
|
@@ -504,11 +509,11 @@ def cmd_revoke(path, device):
|
|
|
504
509
|
return 1 if leftovers else 0
|
|
505
510
|
|
|
506
511
|
|
|
507
|
-
def plan_adopt(data, members):
|
|
512
|
+
def plan_adopt(data, members, owner=None):
|
|
508
513
|
"""(new member records by device, [(pubkey, device)] whose plain outside lines go, [report lines]) for the
|
|
509
514
|
`lanes:<device>` lines outside the block. Taken: a plain (no options) ssh-ed25519 line whose device holds no other
|
|
510
515
|
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."""
|
|
516
|
+
Everything else stays byte for byte and is named. A new record's owner is `owner` (validated; None = null)."""
|
|
512
517
|
by_device = {m['device']: m for m in members}
|
|
513
518
|
holders = {m['pubkey']: m['device'] for m in members}
|
|
514
519
|
new, drop, report = {}, set(), []
|
|
@@ -539,19 +544,24 @@ def plan_adopt(data, members):
|
|
|
539
544
|
if pubkey in holders:
|
|
540
545
|
report.append(f'kept {where}: that key is already enrolled as {holders[pubkey]}')
|
|
541
546
|
continue
|
|
542
|
-
new[device] = member_record(device, pubkey)
|
|
547
|
+
new[device] = member_record(device, pubkey, owner=owner)
|
|
543
548
|
holders[pubkey] = device
|
|
544
549
|
drop.add((pubkey, device))
|
|
545
|
-
report.append(f'adopted {device} ({fingerprint(pubkey)}) from line {number}'
|
|
550
|
+
report.append(f'adopted {device} ({fingerprint(pubkey)}) from line {number}'
|
|
551
|
+
+ (f", owner {owner['login']}" if owner else ''))
|
|
546
552
|
return new, drop, report
|
|
547
553
|
|
|
548
554
|
|
|
549
|
-
def cmd_adopt(path, dry_run=False):
|
|
555
|
+
def cmd_adopt(path, dry_run=False, owner=None, on_owned=None):
|
|
556
|
+
"""Move the plain hand-added `lanes:<device>` lines into members/ (owner `owner`, validated by the caller) and the
|
|
557
|
+
block. -> 0, or on_owned(login, adopted devices)'s answer when `owner` named the owner of any (never on a dry run)."""
|
|
558
|
+
unowned = ('; --login names the owner of the keys an adopt moves in — a member that exists keeps its own '
|
|
559
|
+
'(`lanes keys owner <device> <login>` changes it)') if owner else ''
|
|
550
560
|
with members_lock():
|
|
551
561
|
members, problems = load_members()
|
|
552
562
|
_warn_problems(problems)
|
|
553
563
|
data = _read(path)
|
|
554
|
-
new, drop, report = plan_adopt(data, members)
|
|
564
|
+
new, drop, report = plan_adopt(data, members, owner)
|
|
555
565
|
|
|
556
566
|
def goes(raw):
|
|
557
567
|
parsed = parse_line(raw.decode('utf-8', 'surrogateescape'))
|
|
@@ -562,7 +572,7 @@ def cmd_adopt(path, dry_run=False):
|
|
|
562
572
|
for line in report:
|
|
563
573
|
print('would: ' + line if not line.startswith('adopted') else 'would adopt ' + line[len('adopted '):])
|
|
564
574
|
if not drop:
|
|
565
|
-
print('nothing to adopt')
|
|
575
|
+
print('nothing to adopt' + unowned)
|
|
566
576
|
return 0
|
|
567
577
|
for record in new.values():
|
|
568
578
|
save_member(record) # the records before their lines (see cmd_revoke)
|
|
@@ -573,19 +583,22 @@ def cmd_adopt(path, dry_run=False):
|
|
|
573
583
|
for line in report:
|
|
574
584
|
print(line)
|
|
575
585
|
if not drop:
|
|
576
|
-
print('nothing to adopt' + ('; the block was re-rendered from members/' if changed else ''))
|
|
586
|
+
print('nothing to adopt' + ('; the block was re-rendered from members/' if changed else '') + unowned)
|
|
577
587
|
else:
|
|
578
588
|
print(f'{len(new)} adopted; the lanes-managed block of {path} holds {len(members) + len(new)} key(s); '
|
|
579
589
|
'the file before the first adopt is kept as authorized_keys.bak-<UTC>')
|
|
590
|
+
if owner and new and on_owned:
|
|
591
|
+
return on_owned(owner['login'], sorted(new))
|
|
580
592
|
return 0
|
|
581
593
|
|
|
582
594
|
|
|
583
|
-
def cmd_owner(device, login, name=None, email=None):
|
|
595
|
+
def cmd_owner(device, login, name=None, email=None, on_owned=None):
|
|
584
596
|
"""Name the person behind member `device`: its owner becomes {login, name, email}. The only change to who owns a key
|
|
585
597
|
added by hand (`add`/`adopt` write owner null), and so how its person becomes a `/enrol/pending` approver, which
|
|
586
598
|
matches the tailnet login a member record's owner names (DEF-1643). The record keeps every other field and its
|
|
587
599
|
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.
|
|
600
|
+
a device with no record, and a record that does not load (fix it first) — never echoing a value. Then, also when
|
|
601
|
+
nothing changed, -> on_owned(login, [device])'s answer (0 without one)."""
|
|
589
602
|
device = device_name(device)
|
|
590
603
|
owner = M.validate_owner({'login': login, 'name': name, 'email': email})
|
|
591
604
|
with members_lock():
|
|
@@ -599,14 +612,15 @@ def cmd_owner(device, login, name=None, email=None):
|
|
|
599
612
|
before = M.valid_owner(current.get('owner'))
|
|
600
613
|
if before == owner:
|
|
601
614
|
print(f"{device} is already owned by {owner['login']}; nothing changed")
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
615
|
+
else:
|
|
616
|
+
record = dict(current, owner=owner)
|
|
617
|
+
if record.get('pubkey_sha256') is None: # a record written before A2 carries no fingerprint
|
|
618
|
+
record['pubkey_sha256'] = fingerprint(record['pubkey'])
|
|
619
|
+
save_member(record)
|
|
620
|
+
if before != owner:
|
|
621
|
+
print(f"owner of {device}: {before['login'] if before else 'none'} -> {owner['login']}"
|
|
622
|
+
+ (f" ({owner['name']})" if owner['name'] else ''))
|
|
623
|
+
return on_owned(owner['login'], [device]) if on_owned else 0
|
|
610
624
|
|
|
611
625
|
|
|
612
626
|
def _owner_args(rest):
|
|
@@ -621,7 +635,22 @@ def _owner_args(rest):
|
|
|
621
635
|
return rest[0], rest[1], options.get('--name'), options.get('--email')
|
|
622
636
|
|
|
623
637
|
|
|
624
|
-
def
|
|
638
|
+
def _adopt_args(rest):
|
|
639
|
+
"""[--dry-run] [--login LOGIN], each at most once, in either order -> (dry_run, login or None)."""
|
|
640
|
+
dry_run, login, index = False, None, 0
|
|
641
|
+
while index < len(rest):
|
|
642
|
+
if rest[index] == '--dry-run' and not dry_run:
|
|
643
|
+
dry_run, index = True, index + 1
|
|
644
|
+
elif rest[index] == '--login' and login is None and index + 1 < len(rest):
|
|
645
|
+
login, index = rest[index + 1], index + 2
|
|
646
|
+
else:
|
|
647
|
+
raise L.LanesError(USAGE)
|
|
648
|
+
return dry_run, login
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def main(args, on_owned=None):
|
|
652
|
+
"""`lanes keys …`. `on_owned(login, devices) -> exit code` is called after adopt --login or owner named an owner
|
|
653
|
+
(bin/lanes passes its profile fold); None (this module run on its own) calls nothing."""
|
|
625
654
|
path = authorized_keys_path()
|
|
626
655
|
verb, rest = (args[0], args[1:]) if args else (None, [])
|
|
627
656
|
if verb == 'list' and not rest:
|
|
@@ -630,10 +659,12 @@ def main(args):
|
|
|
630
659
|
return cmd_add(path, *rest)
|
|
631
660
|
if verb == 'revoke' and len(rest) == 1:
|
|
632
661
|
return cmd_revoke(path, rest[0])
|
|
633
|
-
if verb == 'adopt'
|
|
634
|
-
|
|
662
|
+
if verb == 'adopt':
|
|
663
|
+
dry_run, login = _adopt_args(rest)
|
|
664
|
+
owner = None if login is None else M.validate_owner({'login': login}) # refused before anything is read
|
|
665
|
+
return cmd_adopt(path, dry_run=dry_run, owner=owner, on_owned=on_owned)
|
|
635
666
|
if verb == 'owner':
|
|
636
|
-
return cmd_owner(*_owner_args(rest))
|
|
667
|
+
return cmd_owner(*_owner_args(rest), on_owned=on_owned)
|
|
637
668
|
raise L.LanesError(USAGE)
|
|
638
669
|
|
|
639
670
|
|