@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
|
@@ -38,9 +38,11 @@ CONTAINER_HOME = '/home/coder'
|
|
|
38
38
|
CONTAINER_UID = 1000
|
|
39
39
|
|
|
40
40
|
LANE_OPEN = '/.lz/bin/lane open ' # the uniform Cursor-facing entry point written by `lanes cursor-sync`
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
# `\Z`, never `$`: every caller uses .match (or _require), and `$` also matches before a final newline, so
|
|
42
|
+
# "anna-mbp\n" from a JSON body passed as a device name (DEF-1642).
|
|
43
|
+
IDENT = re.compile(r'^[A-Za-z0-9_][A-Za-z0-9_.-]*\Z')
|
|
44
|
+
TMUX_SESSION = re.compile(r'^[A-Za-z0-9_][A-Za-z0-9_. -]*\Z')
|
|
45
|
+
SAFE_PATH = re.compile(r'^/[A-Za-z0-9_./-]+\Z')
|
|
44
46
|
|
|
45
47
|
# The Throttle Studio Host's OWN grouped session names, mirroring `studio_host::tmux::WebSession::channel_of`
|
|
46
48
|
# exactly: `view-<studio token>-<channel>` as `for_channel` mints it (the token is `[A-Za-z0-9_-]` and may
|
|
@@ -103,11 +105,14 @@ class LanesError(Exception):
|
|
|
103
105
|
|
|
104
106
|
|
|
105
107
|
def atomic_write(path, text, mode=0o600):
|
|
108
|
+
"""Replace `path` with `text` atomically (temp file in the same directory, chmod, rename). `text` may be bytes:
|
|
109
|
+
then it is written byte for byte, never through the locale's encoding (keys.py keeps foreign authorized_keys
|
|
110
|
+
lines exactly)."""
|
|
106
111
|
path = Path(path)
|
|
107
112
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
108
113
|
fd, tmp = tempfile.mkstemp(prefix='.' + path.name + '.', dir=str(path.parent))
|
|
109
114
|
try:
|
|
110
|
-
with os.fdopen(fd, 'w') as handle:
|
|
115
|
+
with os.fdopen(fd, 'wb' if isinstance(text, bytes) else 'w') as handle:
|
|
111
116
|
handle.write(text)
|
|
112
117
|
os.chmod(tmp, mode)
|
|
113
118
|
os.replace(tmp, path)
|
|
@@ -122,9 +127,10 @@ def atomic_write(path, text, mode=0o600):
|
|
|
122
127
|
def atomic_write_if_changed(path, text, mode=0o600):
|
|
123
128
|
"""atomic_write, skipped when the file already holds exactly `text` -> True when it wrote. Readers that reload
|
|
124
129
|
on mtime (statusd and notify on registry.json, every sidebar) then do not wake for a projection that did not
|
|
125
|
-
change, and the timer-driven sync stops rewriting the container volumes every two minutes.
|
|
130
|
+
change, and the timer-driven sync stops rewriting the container volumes every two minutes. Bytes compare and
|
|
131
|
+
write as bytes."""
|
|
126
132
|
try:
|
|
127
|
-
if Path(path).read_text() == text:
|
|
133
|
+
if (Path(path).read_bytes() if isinstance(text, bytes) else Path(path).read_text()) == text:
|
|
128
134
|
return False
|
|
129
135
|
except (OSError, ValueError):
|
|
130
136
|
pass
|
|
@@ -661,6 +667,8 @@ SITE_SCHEMA = 1 # catalog.json / workspaces.<board>.json / commit-targets.json
|
|
|
661
667
|
|
|
662
668
|
def read_site_json(path):
|
|
663
669
|
data = json.loads(Path(path).read_text())
|
|
670
|
+
if not isinstance(data, dict): # every site file is an object; `.get` on an array was an AttributeError (DEF-1718)
|
|
671
|
+
raise LanesError(f'{path}: not a JSON object')
|
|
664
672
|
if int(data.get('version', 1)) > SITE_SCHEMA:
|
|
665
673
|
raise LanesError(f'{path} is version {data["version"]}; this server understands {SITE_SCHEMA} — update the lanes checkout')
|
|
666
674
|
return data
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""lanes members — the members/<device>.json store: which machine key belongs to whom (W8 A2, LNS-41).
|
|
2
|
+
|
|
3
|
+
One JSON file per enrolled device under ~/.local/state/lanes/members/ (directory 0700, files 0600):
|
|
4
|
+
|
|
5
|
+
{"device": "anna-mbp", "pubkey": "ssh-ed25519 AAAA...", "pubkey_sha256": "SHA256:...",
|
|
6
|
+
"owner": {"login": "anna@example.com", "name": "Anna Berg", "email": "anna@example.com"},
|
|
7
|
+
"enrolled_at": "2026-10-05T12:00:00Z", "via": "invite", "approved_by": "admin@example.com",
|
|
8
|
+
"invite_id": "7q4k", "workspaces": ["bw-tools", "throttle"]}
|
|
9
|
+
|
|
10
|
+
* This module owns the file I/O and the IDENTITY fields, and is their one definition of valid: keys.load_members
|
|
11
|
+
(the authorized_keys projection, fail closed), enrolment (W8 A3/A4) and create_session (bin/lanes) all use it.
|
|
12
|
+
The KEY fields (`pubkey`, `pubkey_sha256`) are keys.py's: it checks them on every load and before every write it
|
|
13
|
+
makes. A change to who may log in goes through keys.py under its `members.lock`, never through write() alone.
|
|
14
|
+
* `owner` is null for a key added by hand (`lanes keys add|adopt`, via `manual`) until `lanes keys owner` names one,
|
|
15
|
+
and {login, name, email} from enrolment: `login` is the tailnet login Tailscale Serve hands the enrol service
|
|
16
|
+
(`Tailscale-User-Login`; outside request mode the invite's email, or its name when that is a handle), `name`
|
|
17
|
+
and `email` may be null. `via` is invite|request|manual; an invite record carries its `invite_id`, a request
|
|
18
|
+
record its `request_id`, a manual one neither. `approved_by` is a login or null; `workspaces` lists board ids.
|
|
19
|
+
* `invited_as` (optional, additive; an invite record only) is the invite's own {name, email} when a TRUSTED tailnet
|
|
20
|
+
caller redeemed it (request mode): the owner is then the caller, whoever the invite was minted for, and this keeps
|
|
21
|
+
who it was minted for beside it. Absent when no trusted caller redeemed (the owner then came from the invite).
|
|
22
|
+
* Every text field can reach a terminal (`lanes device list`, `lanes keys list`) and the file is UTF-8: a character
|
|
23
|
+
of Unicode category Cc (C0, DEL, C1), Cf (format: the bidi marks, embeddings and isolates, zero-width characters,
|
|
24
|
+
the BOM, the soft hyphen), Cs (a lone surrogate — what json.loads makes of a body's "\\ud800"), Co (private use),
|
|
25
|
+
Cn (unassigned or a noncharacter) or Zl/Zp (line/paragraph separator) is refused (DEF-1703); logins and ids carry
|
|
26
|
+
no blanks, a name may carry inner ones (U+3000 included); a refusal never echoes the value. A display name the
|
|
27
|
+
store refuses can be stored as null by its writer: `name` is optional.
|
|
28
|
+
* Readers are tolerant (unknown keys pass, optional fields may be absent: what `lanes keys` wrote before A2 still
|
|
29
|
+
loads); record() emits exactly the schema, in its order. write() refuses a document it cannot encode as UTF-8 (an
|
|
30
|
+
unknown key the tolerant check passed), writing nothing.
|
|
31
|
+
* Writing a member never creates devices/<device>.json. Only a view session does (create_session); `lanes ensure`
|
|
32
|
+
builds a view session for every devices/*.json, so a member write that made one would resurrect a view nobody
|
|
33
|
+
opened. Which owner a device file records is owner_for(): the member record decides whenever one exists.
|
|
34
|
+
"""
|
|
35
|
+
import json
|
|
36
|
+
import os
|
|
37
|
+
import re
|
|
38
|
+
import time
|
|
39
|
+
import unicodedata
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
import lanes_lib as L
|
|
43
|
+
|
|
44
|
+
VIA = ('invite', 'request', 'manual')
|
|
45
|
+
SOURCE_ID = {'invite': 'invite_id', 'request': 'request_id'}
|
|
46
|
+
MAX_DEVICE = 32 # bin/lanes device_name()
|
|
47
|
+
MAX_LOGIN = 254
|
|
48
|
+
MAX_NAME = 128
|
|
49
|
+
MAX_ID = 64
|
|
50
|
+
REFUSED_CATEGORIES = frozenset(('Cc', 'Cf', 'Cs', 'Co', 'Cn', 'Zl', 'Zp'))
|
|
51
|
+
_EMAIL = re.compile(r'[^@\s]+@[^@\s]+')
|
|
52
|
+
_STAMP = re.compile(r'\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ', re.ASCII)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def members_dir():
|
|
56
|
+
return L.STATE / 'members'
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def device_name(value):
|
|
60
|
+
if not isinstance(value, str) or not L.IDENT.match(value) or len(value) > MAX_DEVICE:
|
|
61
|
+
raise L.LanesError(f'Invalid device name: {value!r}')
|
|
62
|
+
return value
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def unsafe(value):
|
|
66
|
+
"""Whether `value` holds a character no terminal line may carry (REFUSED_CATEGORIES; see the module docstring)."""
|
|
67
|
+
return any(unicodedata.category(char) in REFUSED_CATEGORIES for char in value)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _text(value, what, limit, blanks=False):
|
|
71
|
+
"""A one-line, printable string of at most `limit` characters (`blanks`: inner spaces allowed, as in a name)."""
|
|
72
|
+
if (not isinstance(value, str) or not value or len(value) > limit or unsafe(value)
|
|
73
|
+
or value != value.strip() or (not blanks and any(c.isspace() for c in value))):
|
|
74
|
+
raise L.LanesError(f'{what} is not valid (a one-line printable value of at most {limit} characters'
|
|
75
|
+
+ (')' if blanks else ' without blanks)'))
|
|
76
|
+
return value
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _ident(value, what, limit):
|
|
80
|
+
if not isinstance(value, str) or not L.IDENT.match(value) or len(value) > limit:
|
|
81
|
+
raise L.LanesError(f'{what} is not an identifier of at most {limit} characters')
|
|
82
|
+
return value
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def check_login(value, what='login'):
|
|
86
|
+
"""A tailnet login (`anna@example.com`, `anna@github`): printable, no blanks, at most 254 characters."""
|
|
87
|
+
return _text(value, what, MAX_LOGIN)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def validate_owner(value):
|
|
91
|
+
"""None, or the owner normalized to exactly {login, name, email} (name/email null when absent or empty — a tailnet
|
|
92
|
+
identity may carry no display name; other keys are not carried). Raises on anything else."""
|
|
93
|
+
if value is None:
|
|
94
|
+
return None
|
|
95
|
+
if not isinstance(value, dict):
|
|
96
|
+
raise L.LanesError('owner must be an object {login, name, email} or null')
|
|
97
|
+
owner = {'login': check_login(value.get('login'), 'owner login'), 'name': None, 'email': None}
|
|
98
|
+
if value.get('name') not in (None, ''):
|
|
99
|
+
owner['name'] = _text(value['name'], 'owner name', MAX_NAME, blanks=True)
|
|
100
|
+
if value.get('email') not in (None, ''):
|
|
101
|
+
owner['email'] = _text(value['email'], 'owner email', MAX_LOGIN)
|
|
102
|
+
if not _EMAIL.fullmatch(owner['email']):
|
|
103
|
+
raise L.LanesError('owner email is not an address')
|
|
104
|
+
return owner
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def valid_owner(value):
|
|
108
|
+
"""validate_owner for readers of files lanes does not trust to be well formed (devices/*.json): the normalized
|
|
109
|
+
owner, or None — never raises."""
|
|
110
|
+
try:
|
|
111
|
+
return validate_owner(value)
|
|
112
|
+
except L.LanesError:
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def validate_invited_as(value):
|
|
117
|
+
"""None, or an invite's own {name, email} normalized to exactly those keys (each null when absent or empty; the
|
|
118
|
+
owner's text rules). Raises on anything else."""
|
|
119
|
+
if value is None:
|
|
120
|
+
return None
|
|
121
|
+
if not isinstance(value, dict):
|
|
122
|
+
raise L.LanesError('invited_as must be an object {name, email} or null')
|
|
123
|
+
invited = {'name': None, 'email': None}
|
|
124
|
+
if value.get('name') not in (None, ''):
|
|
125
|
+
invited['name'] = _text(value['name'], 'invited_as name', MAX_NAME, blanks=True)
|
|
126
|
+
if value.get('email') not in (None, ''):
|
|
127
|
+
invited['email'] = _text(value['email'], 'invited_as email', MAX_LOGIN)
|
|
128
|
+
if not _EMAIL.fullmatch(invited['email']):
|
|
129
|
+
raise L.LanesError('invited_as email is not an address')
|
|
130
|
+
return invited
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def validate(document, stem=None):
|
|
134
|
+
"""`document` when its identity fields are valid (see the module docstring); `stem` is its file's name without
|
|
135
|
+
.json, which `device` must equal. Raises L.LanesError naming the first problem, never echoing a value."""
|
|
136
|
+
if not isinstance(document, dict):
|
|
137
|
+
raise L.LanesError('not a JSON object')
|
|
138
|
+
if stem is not None and document.get('device') != stem:
|
|
139
|
+
raise L.LanesError('its device does not match the file name')
|
|
140
|
+
device_name(document.get('device'))
|
|
141
|
+
via = document.get('via')
|
|
142
|
+
if via is not None and via not in VIA:
|
|
143
|
+
raise L.LanesError('via must be one of ' + ', '.join(VIA))
|
|
144
|
+
validate_owner(document.get('owner'))
|
|
145
|
+
if document.get('approved_by') is not None:
|
|
146
|
+
check_login(document['approved_by'], 'approved_by')
|
|
147
|
+
for source, field in SOURCE_ID.items():
|
|
148
|
+
if document.get(field) is not None:
|
|
149
|
+
if via != source:
|
|
150
|
+
raise L.LanesError(f'{field} on a record enrolled via {via or "nothing"}')
|
|
151
|
+
_ident(document[field], field, MAX_ID)
|
|
152
|
+
if document.get('invited_as') is not None:
|
|
153
|
+
if via != 'invite':
|
|
154
|
+
raise L.LanesError(f'invited_as on a record enrolled via {via or "nothing"}')
|
|
155
|
+
validate_invited_as(document['invited_as'])
|
|
156
|
+
workspaces = document.get('workspaces')
|
|
157
|
+
if workspaces is not None:
|
|
158
|
+
if not isinstance(workspaces, list):
|
|
159
|
+
raise L.LanesError('workspaces must be a list of workspace ids')
|
|
160
|
+
for workspace in workspaces:
|
|
161
|
+
_ident(workspace, 'a workspace id', MAX_ID)
|
|
162
|
+
if len(set(workspaces)) != len(workspaces):
|
|
163
|
+
raise L.LanesError('workspaces lists an id twice')
|
|
164
|
+
stamp = document.get('enrolled_at')
|
|
165
|
+
if stamp is not None and not (isinstance(stamp, str) and _STAMP.fullmatch(stamp)):
|
|
166
|
+
raise L.LanesError('enrolled_at is not a UTC YYYY-MM-DDTHH:MM:SSZ time')
|
|
167
|
+
return document
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def record(device, pubkey, pubkey_sha256, via='manual', owner=None, approved_by=None, invite_id=None,
|
|
171
|
+
request_id=None, workspaces=(), now=None, invited_as=None):
|
|
172
|
+
"""A members/<device>.json document, exactly the schema in its order. The caller supplies the key fields (keys.py
|
|
173
|
+
computes the fingerprint and checks the key); an invite needs its invite_id, a request its request_id. `invited_as`
|
|
174
|
+
(an invite redeemed by a trusted caller only) is appended last when given."""
|
|
175
|
+
ids = {'invite_id': invite_id, 'request_id': request_id}
|
|
176
|
+
if via not in VIA:
|
|
177
|
+
raise L.LanesError('via must be one of ' + ', '.join(VIA))
|
|
178
|
+
for field, value in ids.items():
|
|
179
|
+
if value is not None and SOURCE_ID.get(via) != field:
|
|
180
|
+
raise L.LanesError(f'{field} on a record enrolled via {via}')
|
|
181
|
+
if invited_as is not None and via != 'invite':
|
|
182
|
+
raise L.LanesError(f'invited_as on a record enrolled via {via}')
|
|
183
|
+
document = {'device': device_name(device), 'pubkey': pubkey, 'pubkey_sha256': pubkey_sha256,
|
|
184
|
+
'owner': validate_owner(owner), 'enrolled_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(now)),
|
|
185
|
+
'via': via, 'approved_by': approved_by}
|
|
186
|
+
if via in SOURCE_ID:
|
|
187
|
+
field = SOURCE_ID[via]
|
|
188
|
+
if ids[field] is None:
|
|
189
|
+
raise L.LanesError(f'a record enrolled via {via} needs its {field}')
|
|
190
|
+
document[field] = ids[field]
|
|
191
|
+
document['workspaces'] = ([] if workspaces is None else
|
|
192
|
+
list(workspaces) if isinstance(workspaces, (list, tuple)) else workspaces)
|
|
193
|
+
if invited_as is not None:
|
|
194
|
+
document['invited_as'] = validate_invited_as(invited_as)
|
|
195
|
+
return validate(document)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _private_dir(directory):
|
|
199
|
+
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
200
|
+
if directory.stat().st_mode & 0o777 != 0o700:
|
|
201
|
+
os.chmod(directory, 0o700)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def write(document, directory=None):
|
|
205
|
+
"""Validate, then replace members/<device>.json atomically (0600, directory 0700) with its UTF-8 JSON -> its path.
|
|
206
|
+
Writes nothing on a refusal (a document that does not validate or cannot be encoded), and never touches devices/."""
|
|
207
|
+
validate(document)
|
|
208
|
+
try:
|
|
209
|
+
data = (json.dumps(document, indent=2, ensure_ascii=False) + '\n').encode('utf-8')
|
|
210
|
+
except UnicodeEncodeError:
|
|
211
|
+
raise L.LanesError(f"member {document['device']}: a field holds text that is not valid Unicode "
|
|
212
|
+
'(a lone surrogate)') from None
|
|
213
|
+
directory = Path(directory or members_dir())
|
|
214
|
+
_private_dir(directory)
|
|
215
|
+
path = directory / f"{document['device']}.json"
|
|
216
|
+
L.atomic_write(path, data, mode=0o600)
|
|
217
|
+
return path
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _parse(path):
|
|
221
|
+
"""The JSON document in `path` (UTF-8, whatever the locale). Raises FileNotFoundError when there is none and
|
|
222
|
+
L.LanesError for a file that cannot be read or is not JSON."""
|
|
223
|
+
try:
|
|
224
|
+
text = path.read_bytes().decode('utf-8')
|
|
225
|
+
except FileNotFoundError:
|
|
226
|
+
raise
|
|
227
|
+
except (OSError, UnicodeDecodeError) as error:
|
|
228
|
+
raise L.LanesError(getattr(error, 'strerror', None) or error.__class__.__name__) from None
|
|
229
|
+
try:
|
|
230
|
+
return json.loads(text)
|
|
231
|
+
except ValueError:
|
|
232
|
+
raise L.LanesError('not JSON') from None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def read(device, directory=None):
|
|
236
|
+
"""members/<device>.json validated, or None when there is none. Raises L.LanesError for an invalid device name or
|
|
237
|
+
a file that is unreadable, not JSON, or does not validate."""
|
|
238
|
+
path = Path(directory or members_dir()) / f'{device_name(device)}.json'
|
|
239
|
+
try:
|
|
240
|
+
return validate(_parse(path), device)
|
|
241
|
+
except FileNotFoundError:
|
|
242
|
+
return None
|
|
243
|
+
except L.LanesError as error:
|
|
244
|
+
raise L.LanesError(f'members/{path.name}: {error}') from None
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def load(directory=None):
|
|
248
|
+
"""(valid records sorted by file name, [(file name, reason)] for every other *.json) — the identity part only;
|
|
249
|
+
keys.load_members adds its key checks on top."""
|
|
250
|
+
documents, problems = [], []
|
|
251
|
+
for path in sorted(Path(directory or members_dir()).glob('*.json')):
|
|
252
|
+
try:
|
|
253
|
+
documents.append(validate(_parse(path), path.stem))
|
|
254
|
+
except FileNotFoundError:
|
|
255
|
+
continue # removed between the listing and the read
|
|
256
|
+
except L.LanesError as error:
|
|
257
|
+
problems.append((path.name, str(error)))
|
|
258
|
+
return documents, problems
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def owner(device, directory=None):
|
|
262
|
+
"""The normalized owner members/<device>.json names, or None (no file, owner null, or anything invalid — the
|
|
263
|
+
device name included). Never raises."""
|
|
264
|
+
try:
|
|
265
|
+
document = read(device, directory)
|
|
266
|
+
except L.LanesError:
|
|
267
|
+
return None
|
|
268
|
+
return validate_owner(document.get('owner')) if document else None
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def owner_for(names, recorded=None, directory=None):
|
|
272
|
+
"""The owner a view session's devices/<device>.json records (DM-LANES-DEVICE-OWNER): `names` are the device, then
|
|
273
|
+
the machine it is a window of (its `base`); `recorded` is the owner the file holds now. The first name that has a
|
|
274
|
+
member file decides — its normalized owner, None when that record names nobody (a key added by hand) — so a
|
|
275
|
+
device revoked and enrolled again by another person follows its new record, and `lanes device list` agrees with
|
|
276
|
+
`lanes keys list` (DEF-1702). While none of them has a member file (revoked, or never enrolled) the valid
|
|
277
|
+
`recorded` owner is kept, like `base`. A member file that exists but cannot be read or does not validate is no
|
|
278
|
+
evidence of another owner: the recorded one is kept and the later names are not consulted. A name no member file
|
|
279
|
+
can carry (not a device name: a corrupt base) is skipped. Never raises: create_session runs it for `lanes ensure`
|
|
280
|
+
every minute, and a broken member file must not stop a view session."""
|
|
281
|
+
kept = valid_owner(recorded)
|
|
282
|
+
seen = set()
|
|
283
|
+
for name in names:
|
|
284
|
+
if not isinstance(name, str) or name in seen:
|
|
285
|
+
continue
|
|
286
|
+
seen.add(name)
|
|
287
|
+
try:
|
|
288
|
+
device_name(name)
|
|
289
|
+
except L.LanesError:
|
|
290
|
+
continue
|
|
291
|
+
try:
|
|
292
|
+
document = read(name, directory)
|
|
293
|
+
except L.LanesError:
|
|
294
|
+
return kept
|
|
295
|
+
if document is not None:
|
|
296
|
+
return validate_owner(document.get('owner'))
|
|
297
|
+
return kept
|
|
@@ -25,12 +25,30 @@ NEEDS_YOU set. The six-word `state` is never read: the agent status is the only
|
|
|
25
25
|
* A failed send is one journal line (lane id and the failure class — never the topic or the bearer) and is not
|
|
26
26
|
retried; it never stops the service.
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
28
|
+
Enrolment requests (W8 A5, LNS-44): the same service also watches `STATE/enrol-requests/` (lanes-enrold's store;
|
|
29
|
+
read through `enrold.load_requests`, so a request announced is one `lanes enrol list` shows). A request that is
|
|
30
|
+
pending — not approved, denied or past `expires_at` — and has not been announced yet yields ONE push:
|
|
31
|
+
`{title: 'Enrol request', message: '<name or login> · <device>\\nlanes enrol approve <id>', priority: 4}`.
|
|
32
|
+
* No presence suppression: someone is waiting at an installer for a member to approve them, so a desk device in
|
|
33
|
+
use never mutes it. Nothing about it is retried: a failed send is one journal line naming the request id.
|
|
34
|
+
* Once per request id, across restarts: the ids announced are kept in `STATE/notify.enrol-seen.json`
|
|
35
|
+
(`{version: 1, announced: [...]}`, 0600), pruned to the requests still on disk. An id is recorded before its
|
|
36
|
+
push is sent (at most once). A missing or damaged file is no record: requests still pending are announced again.
|
|
37
|
+
* Without a valid `url` and `topic` (or with a `token_file` that cannot be read) nothing is announced and nothing
|
|
38
|
+
is recorded, so the requests still pending when the config becomes valid are announced then — whichever of
|
|
39
|
+
notify.conf and the token file is written last (see the config paragraph below).
|
|
40
|
+
* The directory is re-read when its mtime changes, when the config changes, and on the tick after one that saw a
|
|
41
|
+
directory changed within SETTLE_S seconds (a write in the same kernel clock tick as the read leaves the mtime
|
|
42
|
+
unchanged).
|
|
43
|
+
|
|
44
|
+
Config `~/.config/lanes/notify.conf` (`LANES_NOTIFY_CONF` overrides the path), `key=value` lines, `#` comments:
|
|
45
|
+
`url` (ntfy server, http/https, required), `topic` (`[A-Za-z0-9_-]{1,64}`, required), `token_file` (a file holding
|
|
46
|
+
an ntfy access token, optional — sent as a bearer, never logged; an unreadable one disables sending rather than
|
|
47
|
+
sending unauthenticated), `quiet_when_device_active_s` (default 120; 0 never suppresses), `action_url` (optional
|
|
48
|
+
template, e.g. `https://lanes.example/?lane={lane}`). Without a valid `url` and `topic` the service only tracks
|
|
49
|
+
transitions and sends nothing. The config is re-read when notify.conf OR its `token_file` changes (inode, size,
|
|
50
|
+
mtime or ctime, so a token written, rotated, removed or chmod-ed after notify.conf counts), and on every tick while
|
|
51
|
+
sending is disabled, so a token that becomes readable switches the pushes on within one interval.
|
|
34
52
|
|
|
35
53
|
Answering a `PermissionRequest`-held approval FROM the phone needs a request/response channel back into the hook;
|
|
36
54
|
that is a follow-up design, not part of this module.
|
|
@@ -48,6 +66,7 @@ import urllib.request
|
|
|
48
66
|
from pathlib import Path
|
|
49
67
|
|
|
50
68
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
69
|
+
import enrold as E # noqa: E402
|
|
51
70
|
import lanes_lib as L # noqa: E402
|
|
52
71
|
import tokens # noqa: E402
|
|
53
72
|
|
|
@@ -64,12 +83,22 @@ TIMEOUT = 5
|
|
|
64
83
|
PRESENCE = '*.pid' # clients/ files only `lanes attach` writes
|
|
65
84
|
SEEN = '.seen.json' # a sidebar's opened-lane record; desk devices only
|
|
66
85
|
# (a .pid of any age, devices/<device>.json not mobile)
|
|
86
|
+
ENROL_DIR = 'enrol-requests' # STATE/<this>/<id>.json: lanes-enrold's requests
|
|
87
|
+
ENROL_SEEN = 'notify.enrol-seen.json' # STATE/<this>: the request ids already announced
|
|
88
|
+
ENROL_TITLE = 'Enrol request'
|
|
89
|
+
ENROL_PRIORITY = 4
|
|
90
|
+
SETTLE_S = 2 # a directory changed this recently is read again
|
|
67
91
|
|
|
68
92
|
|
|
69
93
|
def _text(value):
|
|
70
94
|
return UNSAFE.sub('', str(value or ''))
|
|
71
95
|
|
|
72
96
|
|
|
97
|
+
def _line(text):
|
|
98
|
+
"""One payload line, at most TEXT_MAX characters."""
|
|
99
|
+
return text if len(text) <= TEXT_MAX else text[:TEXT_MAX - 1] + '…'
|
|
100
|
+
|
|
101
|
+
|
|
73
102
|
def read_config(path):
|
|
74
103
|
"""notify.conf -> {url, topic, token_file, token, quiet_when_device_active_s, action_url, enabled}. Never raises."""
|
|
75
104
|
raw = {}
|
|
@@ -171,9 +200,7 @@ def _note(lane_id, status, agent, names, config):
|
|
|
171
200
|
label = LABELS.get(status, status)
|
|
172
201
|
name = _text(names.get(lane_id) or lane_id)
|
|
173
202
|
task = _text(agent.get('name'))
|
|
174
|
-
first = f'{name}: {task} — {label}' if task else f'{name} — {label}'
|
|
175
|
-
if len(first) > TEXT_MAX:
|
|
176
|
-
first = first[:TEXT_MAX - 1] + '…'
|
|
203
|
+
first = _line(f'{name}: {task} — {label}' if task else f'{name} — {label}')
|
|
177
204
|
note = {'lane': lane_id, 'status': status, 'title': label,
|
|
178
205
|
'message': f'{first}\nlanes-launch lane {lane_id}', 'priority': PRIORITY.get(status, 3)}
|
|
179
206
|
template = config.get('action_url') or ''
|
|
@@ -203,6 +230,29 @@ def notify_changes(prev_status, new_status, config, names=None, now=None, client
|
|
|
203
230
|
return [_note(lane_id, status, lanes[lane_id]['agent'], names, config) for lane_id, status in sorted(changed)]
|
|
204
231
|
|
|
205
232
|
|
|
233
|
+
def enrol_notes(requests, announced, now):
|
|
234
|
+
"""The pushes for enrolment requests that are pending at `now` (not approved, denied or expired) and whose id is
|
|
235
|
+
not in `announced`, in `requests` order (enrold.load_requests: oldest first). Never presence-suppressed."""
|
|
236
|
+
notes = []
|
|
237
|
+
for request in requests:
|
|
238
|
+
if request['id'] in announced or E.request_state(request, now) != 'pending':
|
|
239
|
+
continue
|
|
240
|
+
who = _text(request.get('name') or request.get('login'))
|
|
241
|
+
first = _line(f"{who} · {_text(request.get('device'))}")
|
|
242
|
+
notes.append({'request': request['id'], 'title': ENROL_TITLE,
|
|
243
|
+
'message': f"{first}\nlanes enrol approve {request['id']}", 'priority': ENROL_PRIORITY})
|
|
244
|
+
return notes
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def read_announced(path):
|
|
248
|
+
"""notify.enrol-seen.json -> the set of request ids already announced; missing or damaged = none."""
|
|
249
|
+
document = L.read_json(path)
|
|
250
|
+
ids = document.get('announced') if isinstance(document, dict) else None
|
|
251
|
+
if not isinstance(ids, list):
|
|
252
|
+
return set()
|
|
253
|
+
return {value for value in ids if isinstance(value, str) and E._REQUEST_ID.fullmatch(value)}
|
|
254
|
+
|
|
255
|
+
|
|
206
256
|
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
207
257
|
def redirect_request(self, *_args, **_kwargs): # a redirect would carry the bearer elsewhere: refuse it
|
|
208
258
|
return None
|
|
@@ -214,7 +264,8 @@ def default_opener():
|
|
|
214
264
|
|
|
215
265
|
def send(note, config, opener=None):
|
|
216
266
|
"""POST one notification as ntfy JSON to `config['url']`. True when the server accepted it. Any failure is one
|
|
217
|
-
stderr (journal) line naming the lane and the failure class — never the topic or
|
|
267
|
+
stderr (journal) line naming the lane (or the enrolment request id) and the failure class — never the topic or
|
|
268
|
+
the bearer — and False."""
|
|
218
269
|
body = {'topic': config['topic'], 'title': note['title'], 'message': note['message'],
|
|
219
270
|
'priority': note.get('priority', 3)}
|
|
220
271
|
for key in ('click', 'actions'):
|
|
@@ -240,13 +291,38 @@ def send(note, config, opener=None):
|
|
|
240
291
|
why = type(error).__name__
|
|
241
292
|
except Exception as error: # a push must never stop the service
|
|
242
293
|
why = type(error).__name__
|
|
243
|
-
|
|
294
|
+
what = note.get('lane') or f"enrol request {note.get('request')}"
|
|
295
|
+
print(f'notify: send failed for {what}: {why}', file=sys.stderr, flush=True)
|
|
244
296
|
return False
|
|
245
297
|
|
|
246
298
|
|
|
299
|
+
def _settling(path, now):
|
|
300
|
+
"""True when `path` changed within SETTLE_S seconds of `now` (its mtime may still move within this clock tick)."""
|
|
301
|
+
try:
|
|
302
|
+
return now - path.stat().st_mtime < SETTLE_S
|
|
303
|
+
except OSError:
|
|
304
|
+
return False
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _stamp(path):
|
|
308
|
+
"""What the watcher compares to decide a file changed: the path, device, inode, size, mtime and ctime. A
|
|
309
|
+
replacement moves the inode, an in-place rewrite within one clock tick moves the size, and a chmod, chown or ACL
|
|
310
|
+
change (which can make a file readable) moves only the ctime. None for no path; (path,) when it cannot be
|
|
311
|
+
stat'ed."""
|
|
312
|
+
if path is None:
|
|
313
|
+
return None
|
|
314
|
+
try:
|
|
315
|
+
info = os.stat(path)
|
|
316
|
+
except OSError:
|
|
317
|
+
return (str(path),)
|
|
318
|
+
return (str(path), info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns, info.st_ctime_ns)
|
|
319
|
+
|
|
320
|
+
|
|
247
321
|
class Watcher:
|
|
248
|
-
"""Tails status.json
|
|
249
|
-
|
|
322
|
+
"""Tails status.json (re-read when its _stamp changes); sends what notify_changes returns. The registry is re-read
|
|
323
|
+
when it changes; the config when notify.conf or its token_file changes, and on every tick while it is disabled.
|
|
324
|
+
The first readable document seeds the previous one. Also announces each new pending enrolment request once
|
|
325
|
+
(enrol_notes; the ids announced persist in STATE/notify.enrol-seen.json)."""
|
|
250
326
|
|
|
251
327
|
def __init__(self, state, conf_path, opener=None, clock=time.time):
|
|
252
328
|
self.state = Path(state)
|
|
@@ -254,24 +330,45 @@ class Watcher:
|
|
|
254
330
|
self.opener, self.clock = opener, clock
|
|
255
331
|
self.prev = None
|
|
256
332
|
self.stamps = {}
|
|
257
|
-
self.config =
|
|
333
|
+
self.config = None
|
|
258
334
|
self.names = {}
|
|
335
|
+
self.enrol_due = True
|
|
336
|
+
self.announced = read_announced(self.state / ENROL_SEEN)
|
|
337
|
+
self._config_tick()
|
|
259
338
|
|
|
260
339
|
def _changed(self, key, path):
|
|
261
|
-
|
|
262
|
-
stamp = path.stat().st_mtime_ns
|
|
263
|
-
except OSError:
|
|
264
|
-
stamp = None
|
|
340
|
+
stamp = _stamp(path)
|
|
265
341
|
if self.stamps.get(key, 'unset') == stamp:
|
|
266
342
|
return False
|
|
267
343
|
self.stamps[key] = stamp
|
|
268
344
|
return True
|
|
269
345
|
|
|
346
|
+
def _token_path(self):
|
|
347
|
+
name = (self.config or {}).get('token_file')
|
|
348
|
+
return Path(os.path.expanduser(name)) if name else None
|
|
349
|
+
|
|
350
|
+
def _config_tick(self):
|
|
351
|
+
"""Re-read the config when notify.conf or the token_file it names changed (both are stamped BEFORE the
|
|
352
|
+
read, so a change during it is seen next tick; a token_file path that changes is stamped next tick and
|
|
353
|
+
read once more), and on every tick while sending is disabled: a token that becomes readable need not move
|
|
354
|
+
any stamp (a security policy, a mount). A config that reads differently makes the enrolment directory
|
|
355
|
+
due, so the requests still pending when it becomes valid are announced then."""
|
|
356
|
+
conf_changed = self._changed('conf', self.conf_path)
|
|
357
|
+
token_changed = self._changed('token', self._token_path())
|
|
358
|
+
if self.config is not None and self.config['enabled'] and not (conf_changed or token_changed):
|
|
359
|
+
return
|
|
360
|
+
config = read_config(self.conf_path)
|
|
361
|
+
if config != self.config:
|
|
362
|
+
self.config = config
|
|
363
|
+
self.enrol_due = True
|
|
364
|
+
|
|
270
365
|
def tick(self):
|
|
271
|
-
|
|
272
|
-
self.config = read_config(self.conf_path)
|
|
366
|
+
self._config_tick()
|
|
273
367
|
if self._changed('registry', self.state / 'registry.json'):
|
|
274
368
|
self.names = lane_names(L.read_json(self.state / 'registry.json'))
|
|
369
|
+
return self._status_tick() + self._enrol_tick()
|
|
370
|
+
|
|
371
|
+
def _status_tick(self):
|
|
275
372
|
if not self._changed('status', self.state / 'status.json'):
|
|
276
373
|
return []
|
|
277
374
|
document = L.read_json(self.state / 'status.json')
|
|
@@ -286,9 +383,37 @@ class Watcher:
|
|
|
286
383
|
send(note, self.config, self.opener)
|
|
287
384
|
return notes
|
|
288
385
|
|
|
386
|
+
def _enrol_tick(self):
|
|
387
|
+
directory = self.state / ENROL_DIR
|
|
388
|
+
if not (self._changed('enrol', directory) or self.enrol_due):
|
|
389
|
+
return []
|
|
390
|
+
now = self.clock()
|
|
391
|
+
self.enrol_due = _settling(directory, now)
|
|
392
|
+
if not self.config['enabled']:
|
|
393
|
+
return [] # nothing announced, nothing recorded
|
|
394
|
+
requests = E.load_requests(directory)
|
|
395
|
+
notes = enrol_notes(requests, self.announced, now)
|
|
396
|
+
self._record((self.announced | {note['request'] for note in notes}) & {r['id'] for r in requests})
|
|
397
|
+
for note in notes:
|
|
398
|
+
send(note, self.config, self.opener)
|
|
399
|
+
return notes
|
|
400
|
+
|
|
401
|
+
def _record(self, announced):
|
|
402
|
+
"""Keep `announced` (in memory first, so a file that cannot be written never causes a repeat here)."""
|
|
403
|
+
if announced == self.announced:
|
|
404
|
+
return
|
|
405
|
+
self.announced = announced
|
|
406
|
+
text = json.dumps({'version': 1, 'announced': sorted(announced)}) + '\n'
|
|
407
|
+
try:
|
|
408
|
+
L.atomic_write_if_changed(self.state / ENROL_SEEN, text, mode=0o600)
|
|
409
|
+
except OSError as error:
|
|
410
|
+
print(f'notify: cannot record announced enrolment requests in {ENROL_SEEN}: '
|
|
411
|
+
f'{error.strerror or type(error).__name__}', file=sys.stderr, flush=True)
|
|
412
|
+
|
|
289
413
|
|
|
290
414
|
def main(argv=None):
|
|
291
|
-
parser = argparse.ArgumentParser(prog='notify', description='push needs-you transitions
|
|
415
|
+
parser = argparse.ArgumentParser(prog='notify', description='push needs-you transitions and new enrolment '
|
|
416
|
+
'requests to an ntfy topic')
|
|
292
417
|
parser.add_argument('--interval', type=float, default=3.0, help='seconds between status.json checks')
|
|
293
418
|
parser.add_argument('--test', action='store_true', help='send one test notification and exit (1 = not sent)')
|
|
294
419
|
args = parser.parse_args(argv)
|