@vibelord/lanes 1.0.3 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +55 -15
  2. package/bin/lanes-install.mjs +8 -2
  3. package/lib/plan.mjs +34 -1
  4. package/package.json +2 -2
  5. package/payload/lanes/README.md +91 -33
  6. package/payload/lanes/VERSION +1 -1
  7. package/payload/lanes/bin/lanes +994 -49
  8. package/payload/lanes/client/lanes-launch +160 -14
  9. package/payload/lanes/client/ssh.stanza.in +11 -2
  10. package/payload/lanes/docs/ARCHITECTURE.md +88 -3
  11. package/payload/lanes/docs/INSTALL.md +297 -43
  12. package/payload/lanes/docs/RUNBOOK.md +458 -31
  13. package/payload/lanes/docs/USERGUIDE.md +1612 -0
  14. package/payload/lanes/install/enable-phone.sh +8 -20
  15. package/payload/lanes/install/enable-web.sh +2 -2
  16. package/payload/lanes/install/host-install.sh +6 -0
  17. package/payload/lanes/install.sh +888 -52
  18. package/payload/lanes/libexec/enrol.py +695 -0
  19. package/payload/lanes/libexec/enrold.py +1264 -0
  20. package/payload/lanes/libexec/keys.py +644 -0
  21. package/payload/lanes/libexec/lanes_lib.py +14 -6
  22. package/payload/lanes/libexec/members.py +297 -0
  23. package/payload/lanes/libexec/notify.py +146 -21
  24. package/payload/lanes/libexec/qr.py +217 -0
  25. package/payload/lanes/libexec/site_edit.py +237 -0
  26. package/payload/lanes/libexec/web_tokens.py +315 -0
  27. package/payload/lanes/site/README.md +13 -1
  28. package/payload/lanes/site/beyondwork/site.conf +3 -0
  29. package/payload/lanes/site/site.example.conf +5 -0
  30. package/payload/lanes/sudo/one-time-root.sh +1 -1
  31. package/payload/lanes/systemd/lanes-enrold.service +22 -0
  32. package/payload/lanes/systemd/lanes-notify.service +4 -4
  33. package/payload/lanes/systemd/lanes-web.service +4 -0
  34. package/payload/lanes/web/Caddyfile +10 -0
  35. package/payload/lanes/web/README.md +8 -2
  36. package/payload/lanes/workspaces.example/README.md +6 -1
  37. package/payload/manifest.json +7 -4
@@ -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
- IDENT = re.compile(r'^[A-Za-z0-9_][A-Za-z0-9_.-]*$')
42
- TMUX_SESSION = re.compile(r'^[A-Za-z0-9_][A-Za-z0-9_. -]*$')
43
- SAFE_PATH = re.compile(r'^/[A-Za-z0-9_./-]+$')
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
- Config `~/.config/lanes/notify.conf` (`LANES_NOTIFY_CONF` overrides the path), `key=value` lines, `#` comments,
29
- re-read when its mtime changes: `url` (ntfy server, http/https, required), `topic` (`[A-Za-z0-9_-]{1,64}`,
30
- required), `token_file` (a file holding an ntfy access token, optional — sent as a bearer, never logged; an
31
- unreadable one disables sending rather than sending unauthenticated), `quiet_when_device_active_s` (default 120;
32
- 0 never suppresses), `action_url` (optional template, e.g. `https://lanes.example/?lane={lane}`). Without a valid
33
- `url` and `topic` the service only tracks transitions and sends nothing.
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 the bearer — and False."""
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
- print(f'notify: send failed for {note.get("lane")}: {why}', file=sys.stderr, flush=True)
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 by mtime; sends what notify_changes returns. The config and registry are re-read when
249
- their mtime changes. The first readable document seeds the previous one."""
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 = read_config(self.conf_path)
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
- try:
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
- if self._changed('conf', self.conf_path):
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 to an ntfy topic')
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)