@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.
Files changed (37) hide show
  1. package/README.md +52 -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 +39 -18
  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 +86 -2
  11. package/payload/lanes/docs/INSTALL.md +296 -43
  12. package/payload/lanes/docs/RUNBOOK.md +453 -27
  13. package/payload/lanes/docs/USERGUIDE.md +449 -76
  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
@@ -0,0 +1,315 @@
1
+ """Zellij web login tokens for `lanes web --token` and `lanes token …`, each attributed to the device it was minted for.
2
+
3
+ Zellij 0.45.1 cannot name a token, and its naming rule makes a single revocation hazardous. MEASURED 2026-10-05 from the
4
+ pinned binary (inner sha256 d006c521…19dd) in a scratch HOME / XDG_DATA_HOME / --config-dir, never the real database:
5
+ - `web --create-token --token-name NAME` is refused by the argument parser (exit 2: `--create-token` "cannot be used
6
+ with one or more of the other specified arguments"), in either order; `--create-read-only-token` likewise;
7
+ - each new token is named `token_<number of tokens + 1>`, so after revoking any token but the newest the next
8
+ `--create-token` fails ("Token name 'token_N' already exists", exit 2) until enough tokens are revoked;
9
+ - `--list-tokens` prints names and creation times only (`token_2: created at 2026-10-05 16:06:27`), never a token;
10
+ - `--revoke-token NAME` also deletes that token's sign-in sessions (`session_tokens` rows); `--revoke-all-tokens`
11
+ deletes every token and session;
12
+ - the database is $XDG_DATA_HOME/zellij/tokens.db (default ~/.local/share/zellij/tokens.db); `--data-dir` does not
13
+ move it.
14
+
15
+ So lanes records, in STATE/web-tokens.json, which device each token was minted for: under a lock it lists the names
16
+ before and after running `--create-token` on the caller's terminal (stdin/stdout/stderr inherited, nothing captured —
17
+ lanes never reads, prints or logs a token) and attributes the one new name — to the view, and, when the view belongs
18
+ to another machine (`lanes-launch web phone` opens `<device>-phone` for `<device>`), to that machine as `base`.
19
+ `revoke <device>` revokes that device's tokens only when doing so leaves creation working, and otherwise refuses and
20
+ names `revoke-all`, the coarse fallback. `lanes offboard <device>` (offboard below) signs the whole machine out: the
21
+ tokens of the device, of its windows and of every view minted with it as `base` — also when that leaves creation
22
+ blocked, because the departing machine comes first; it then names `revoke-all`, which repairs creation.
23
+
24
+ `env` (bin/lanes token_env) carries XDG_DATA_HOME=<account home>/.local/share, the root systemd/lanes-web.service pins
25
+ as `%h/.local/share`, never the caller's; bin/lanes refuses every call here while the RUNNING server's environment
26
+ names another database (token_db_refusal), and `lanes doctor` reports the comparison.
27
+ """
28
+ import fcntl
29
+ import json
30
+ import re
31
+ import subprocess
32
+ import sys
33
+ import time
34
+ from contextlib import contextmanager
35
+
36
+ import lanes_lib as L
37
+
38
+ USAGE = 'usage: lanes token list | revoke <device> | revoke --name NAME | revoke-all [--yes]'
39
+ LISTED = re.compile(r'^(\S+): created at (.+)$')
40
+ NAME = re.compile(r'^[A-Za-z0-9_.-]{1,64}$')
41
+ VERSION = 1
42
+ REVOKE_ALL = 'lanes token revoke-all --yes'
43
+ SIGN_IN_AGAIN = 'every browser and phone view must sign in again with a new token (`lanes web <device> --token`)'
44
+
45
+
46
+ def map_path():
47
+ return L.STATE / 'web-tokens.json'
48
+
49
+
50
+ @contextmanager
51
+ def token_lock(timeout=5):
52
+ """One mint or revocation at a time: the before/after name diff is exact only when no other lanes mint runs."""
53
+ L.STATE.mkdir(parents=True, exist_ok=True)
54
+ with (L.STATE / 'web-tokens.lock').open('a') as handle:
55
+ deadline = time.monotonic() + timeout
56
+ while True:
57
+ try:
58
+ fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
59
+ break
60
+ except BlockingIOError:
61
+ if time.monotonic() >= deadline:
62
+ raise L.LanesError('another web token is being created or revoked; try again shortly')
63
+ time.sleep(0.05)
64
+ try:
65
+ yield
66
+ finally:
67
+ fcntl.flock(handle, fcntl.LOCK_UN)
68
+
69
+
70
+ def listing(base, env):
71
+ """[(name, created)] in zellij's order, from `web --list-tokens`. Raises when zellij did not answer."""
72
+ rc, out, err = L.run_bounded(base + ['web', '--list-tokens'], 20, env=env)
73
+ if rc != 0:
74
+ raise L.LanesError('zellij web --list-tokens: ' + ('no answer in 20 s' if rc is None
75
+ else err.strip() or out.strip() or f'exit {rc}'))
76
+ return [m.groups() for m in map(LISTED.match, out.splitlines()) if m]
77
+
78
+
79
+ def next_name(names):
80
+ return f'token_{len(names) + 1}'
81
+
82
+
83
+ def wedged(names):
84
+ """True when zellij 0.45.1 cannot create another token: the name it would choose is taken."""
85
+ return next_name(names) in names
86
+
87
+
88
+ def load_map():
89
+ """{name: {device, created, minted_at[, base]}} from web-tokens.json ({} when absent). A file lanes cannot read is
90
+ refused, never overwritten: it holds the only record of which device holds which token."""
91
+ path = map_path()
92
+ if not path.exists():
93
+ return {}
94
+ document = L.read_json(path)
95
+ tokens = document.get('tokens') if isinstance(document, dict) and document.get('version') == VERSION else None
96
+ if not isinstance(tokens, dict) or not all(isinstance(v, dict) for v in tokens.values()):
97
+ raise L.LanesError(f'{path} is not a version {VERSION} token map; move it aside (only attribution is lost: '
98
+ f'`{REVOKE_ALL}` still revokes every token)')
99
+ return tokens
100
+
101
+
102
+ def save_map(tokens):
103
+ L.atomic_write(map_path(), json.dumps({'version': VERSION, 'tokens': tokens}, indent=1, sort_keys=True) + '\n')
104
+
105
+
106
+ def attributed(tokens, listed):
107
+ """The map entries that still describe a listed token: same name AND same creation time (zellij reuses a name
108
+ after a revocation, so a name alone can point at a token minted later, outside lanes)."""
109
+ created = dict(listed)
110
+ return {name: entry for name, entry in tokens.items() if created.get(name) == entry.get('created')}
111
+
112
+
113
+ def wedge_advice(names):
114
+ return (f'zellij 0.45.1 names the next token {next_name(names)}, which exists, so it cannot create one; '
115
+ f'`{REVOKE_ALL}` repairs this — {SIGN_IN_AGAIN}')
116
+
117
+
118
+ def run_attached(argv, timeout, env):
119
+ """argv on the caller's terminal: stdin, stdout and stderr inherited, nothing captured (the token zellij prints
120
+ goes to the terminal only). -> exit code, None when it did not finish in `timeout` s (killed; one bounded wait)."""
121
+ try:
122
+ child = subprocess.Popen(argv, env=env)
123
+ except OSError as error:
124
+ print(f'lanes: {error}', file=sys.stderr)
125
+ return 127
126
+ try:
127
+ return child.wait(timeout)
128
+ except subprocess.TimeoutExpired:
129
+ child.kill()
130
+ try:
131
+ child.wait(2)
132
+ except subprocess.TimeoutExpired:
133
+ pass
134
+ return None
135
+
136
+
137
+ def mint(device, base, env, machine=None):
138
+ """`lanes web <device> --token`: create a login token on the caller's terminal and attribute it to `device` —
139
+ and to `machine` (recorded as `base`) when the view is another machine's (bin/lanes view_machine)."""
140
+ with token_lock():
141
+ tokens = load_map()
142
+ before = listing(base, env)
143
+ names = [name for name, _created in before]
144
+ if wedged(names):
145
+ print(f'lanes: {wedge_advice(names)}', file=sys.stderr)
146
+ return 1
147
+ print('\nZellij prints the new token below as NAME: TOKEN. Paste only TOKEN into the sign-in screen.\n',
148
+ flush=True)
149
+ rc = run_attached(base + ['web', '--create-token'], 60, env)
150
+ after = listing(base, env)
151
+ new = [pair for pair in after if pair not in before]
152
+ kept = attributed(tokens, after)
153
+ if len(new) == 1:
154
+ name, created = new[0]
155
+ kept[name] = {'device': device, 'created': created, 'minted_at': round(time.time(), 3)}
156
+ if machine and machine != device:
157
+ kept[name]['base'] = machine
158
+ if kept != tokens:
159
+ save_map(kept)
160
+ if len(new) == 1:
161
+ print(f'\ntoken-name: {new[0][0]} (for {device}; `lanes token revoke {device}` signs it out, '
162
+ '`lanes token list` shows every token)')
163
+ return 0
164
+ if rc != 0:
165
+ print('lanes: zellij web --create-token ' + ('did not finish in 60 s' if rc is None else f'failed (exit {rc})'),
166
+ file=sys.stderr)
167
+ return 1
168
+ print(f'lanes: {len(new)} new tokens appeared while this one was created (another `zellij web --create-token` '
169
+ f'ran at the same time?), so none is attributed to {device}; `lanes token list` shows them as not attributed',
170
+ file=sys.stderr)
171
+ return 0
172
+
173
+
174
+ def cmd_list(base, env):
175
+ listed = listing(base, env)
176
+ kept = attributed(load_map(), listed)
177
+ if not listed:
178
+ print('no web login tokens')
179
+ for name, created in listed:
180
+ owner = kept[name]['device'] if name in kept else \
181
+ '(not attributed: created before LNS-50, or outside `lanes web --token`)'
182
+ print(f'{name:<10} {created} {owner}')
183
+ names = [name for name, _created in listed]
184
+ if wedged(names):
185
+ print(f'⚠ {wedge_advice(names)}')
186
+ return 0
187
+
188
+
189
+ def revoke(names, base, env):
190
+ """Revoke each name; a name zellij no longer knows counts as already revoked. -> [(name, outcome)]."""
191
+ done = []
192
+ for name in names:
193
+ rc, out, err = L.run_bounded(base + ['web', '--revoke-token', name], 20, env=env)
194
+ if rc == 0:
195
+ done.append((name, 'revoked'))
196
+ elif 'does not exist' in err + out:
197
+ done.append((name, 'already gone'))
198
+ else:
199
+ raise L.LanesError(f'zellij web --revoke-token {name}: ' + (
200
+ 'no answer in 20 s' if rc is None else err.strip() or out.strip() or f'exit {rc}'))
201
+ return done
202
+
203
+
204
+ def cmd_revoke(args, base, env):
205
+ if len(args) == 2 and args[0] == '--name' and NAME.match(args[1]):
206
+ device, wanted = None, args[1]
207
+ elif len(args) == 1 and not args[0].startswith('-'):
208
+ if not L.IDENT.match(args[0]) or len(args[0]) > 32:
209
+ raise L.LanesError('Invalid device name')
210
+ device, wanted = args[0], None
211
+ else:
212
+ raise L.LanesError(USAGE)
213
+ with token_lock():
214
+ tokens = load_map()
215
+ listed = listing(base, env)
216
+ names = [name for name, _created in listed]
217
+ kept = attributed(tokens, listed)
218
+ targets = [wanted] if wanted in names else [] if wanted else \
219
+ [name for name in names if kept.get(name, {}).get('device') == device]
220
+ if not targets:
221
+ if kept != tokens:
222
+ save_map(kept)
223
+ print(f'no token named {wanted} exists (`lanes token list`)' if wanted else
224
+ f'no token is attributed to {device}: nothing was revoked. `lanes token list` shows the tokens; one '
225
+ 'that is not attributed is revoked with `lanes token revoke --name NAME`')
226
+ return 0
227
+ remaining = [name for name in names if name not in targets]
228
+ if wedged(remaining) and not wedged(names):
229
+ raise L.LanesError(
230
+ f"revoking {', '.join(targets)} alone would leave zellij 0.45.1 unable to create the next token (it "
231
+ f'would be named {next_name(remaining)}, which stays) — revoke every token instead: `{REVOKE_ALL}` '
232
+ f'({SIGN_IN_AGAIN})')
233
+ done = revoke(targets, base, env)
234
+ for name, _outcome in done:
235
+ kept.pop(name, None)
236
+ save_map(kept)
237
+ for name, outcome in done:
238
+ print(f'{name}: {outcome}' + (f' ({device})' if device else '')
239
+ + (' — its sign-in sessions are deleted: that browser signs in again' if outcome == 'revoked' else ''))
240
+ if wedged(remaining):
241
+ print(f'⚠ {wedge_advice(remaining)}')
242
+ return 0
243
+
244
+
245
+ def cmd_revoke_all(args, base, env):
246
+ if args not in ([], ['--yes']):
247
+ raise L.LanesError(USAGE)
248
+ if args != ['--yes']:
249
+ names = [name for name, _created in listing(base, env)]
250
+ print(f"would revoke {len(names)} web login token{'s' if len(names) != 1 else ''}"
251
+ + (f" ({', '.join(names)})" if names else '') + f': {SIGN_IN_AGAIN}.')
252
+ print('nothing was changed: re-run with --yes')
253
+ return 2
254
+ with token_lock():
255
+ rc, out, err = L.run_bounded(base + ['web', '--revoke-all-tokens'], 20, env=env)
256
+ if rc != 0:
257
+ raise L.LanesError('zellij web --revoke-all-tokens: ' + (
258
+ 'no answer in 20 s' if rc is None else err.strip() or out.strip() or f'exit {rc}'))
259
+ save_map({})
260
+ print(f'revoked every web login token: {SIGN_IN_AGAIN}')
261
+ return 0
262
+
263
+
264
+ def offboard(devices, base, env):
265
+ """`lanes offboard <device>` step 3 (bin/lanes revoke_web_token) -> (status, sentence). `devices` is the offboarded
266
+ device first, then its windows (devices/<view>.json `base` names it). Revokes every token attributed to one of
267
+ them or minted with the device as its machine (`base`), and leaves every other machine's alone. Status: 'done'
268
+ (revoked, or nothing to revoke), 'warn' (as done, but a token nobody can attribute may be this machine's, or
269
+ zellij 0.45.1 cannot create the next token — the sentence names the step); a zellij that does not answer, an
270
+ unreadable map or the lock raise L.LanesError for the caller to report as a failed step.
271
+ Unlike `revoke <device>`, a revocation that blocks token creation (DEF-1711) is made anyway: the departing
272
+ machine is signed out first, and `revoke-all --yes` repairs creation when the next token is needed."""
273
+ device, wanted = devices[0], set(devices)
274
+ with token_lock():
275
+ tokens = load_map()
276
+ listed = listing(base, env)
277
+ names = [name for name, _created in listed]
278
+ kept = attributed(tokens, listed)
279
+ targets = [name for name in names if name in kept
280
+ and (kept[name].get('device') in wanted or kept[name].get('base') == device)]
281
+ unattributed = [name for name in names if name not in kept]
282
+ done = revoke(targets, base, env) if targets else []
283
+ for name, _outcome in done:
284
+ kept.pop(name, None)
285
+ if kept != tokens:
286
+ save_map(kept)
287
+ remaining = [name for name in names if name not in targets]
288
+ views = f" or its views {', '.join(devices[1:])}" if len(devices) > 1 else ''
289
+ if done:
290
+ said = 'revoked ' + ', '.join(f"{name} ({tokens[name]['device']}"
291
+ + ('' if outcome == 'revoked' else ', already gone') + ')' for name, outcome in done)
292
+ said += ': those browsers and phones are signed out'
293
+ else:
294
+ said = f'no web login token is attributed to {device}{views}'
295
+ warnings = []
296
+ if unattributed:
297
+ count = len(unattributed)
298
+ warnings.append(f"{count} web login token{'s are' if count > 1 else ' is'} not attributed to any device "
299
+ f"({', '.join(unattributed)}: minted before LNS-50, or outside `lanes web --token`); if this "
300
+ 'machine signed in with one, `lanes token revoke --name NAME`, or `lanes token revoke-all '
301
+ f'--yes`: {SIGN_IN_AGAIN}')
302
+ if wedged(remaining):
303
+ warnings.append(wedge_advice(remaining))
304
+ return ('warn' if warnings else 'done'), '; '.join([said] + warnings)
305
+
306
+
307
+ def main(args, base, env):
308
+ """`lanes token …` (bin/lanes cmd_token): `base` is the pinned zellij argv, `env` the token environment."""
309
+ if args == ['list']:
310
+ return cmd_list(base, env)
311
+ if args[:1] == ['revoke']:
312
+ return cmd_revoke(args[1:], base, env)
313
+ if args[:1] == ['revoke-all']:
314
+ return cmd_revoke_all(args[1:], base, env)
315
+ raise L.LanesError(USAGE)
@@ -16,11 +16,23 @@ workspace-server ssh-ed25519 AAAA...verified-public-host-key...
16
16
  Use the alias from `LANES_SERVER_ALIAS`. Get the key from the administrator or an already trusted server
17
17
  session (`/etc/ssh/ssh_host_ed25519_key.pub`); a network key scan alone does not authenticate it.
18
18
 
19
+ `LANES_SERVER_NAME` is the server's MagicDNS name. When it ends in `.ts.net`, the client installer checks that the
20
+ machine is in the tailnet before it tries the server: it names the Tailscale install command when Tailscale is
21
+ missing and opens `tailscale login` when the machine is logged out. Leave it out for a server outside a tailnet.
22
+ When the client resolves the name, its SSH alias `lanes` uses it, with `LANES_SERVER_HOST` kept as the fallback alias
23
+ `lanes-ip`. The server's enrolment service answers under it too (`https://<name>:8443/enrol/`).
24
+
25
+ `LANES_SITE_CODE` (optional, 1-8 letters) is the prefix of this site's invite codes (`LN` for `beyondwork`). The npm
26
+ package picks a bundled profile by it (`npx @vibelord/lanes --invite LN-…`), and the installer refuses a code with
27
+ another prefix. Keep it unique among the profiles you distribute.
28
+
19
29
  For the existing Beyond Work server, select `./install.sh --site site/beyondwork/site.conf` explicitly.
20
30
  That directory preserves the existing deployment address and pinned public key as one named profile.
21
31
 
22
32
  The installer saves a client-owned copy of the profile and key in `~/.config/lanes/client-site.conf` and
23
- `client-known-hosts`. Updates and `--check` reuse them. To switch deployments, pass `--site` again.
33
+ `client-known-hosts`, and remembers the choice in `~/.config/lanes/install-options` (`SITE=`: a profile bundled
34
+ with the package by its directory name, so an update copies the new package's profile and pinned key; your own file
35
+ by its absolute path). Updates and `--check` reuse them. To switch deployments, pass `--site` again.
24
36
  SSH refuses changed host keys until the administrator's new fingerprint has been verified.
25
37
 
26
38
  Server-side board catalogs belong in `~/.config/lanes/workspaces/`; real workspaces, credentials and generated
@@ -3,6 +3,9 @@
3
3
  # fallback address) is fetched from the server once the client is authorized: lanes client-config
4
4
  LANES_SITE_SCHEMA=1
5
5
  LANES_SITE=beyondwork
6
+ # invite codes for this site start with this prefix (LN-xxxx-xxxxxxxxxxxx): `npx @vibelord/lanes --invite LN-…` picks
7
+ # this profile by it, and install.sh refuses a code with another prefix
8
+ LANES_SITE_CODE=LN
6
9
  # known_hosts alias the server's host key is pinned under (site/known_hosts)
7
10
  LANES_SERVER_ALIAS=ceobox
8
11
  # Tailscale address of the server (the address is only meaningful inside the tailnet) and its MagicDNS name
@@ -2,8 +2,13 @@
2
2
  # Plain data, not a shell script. Put the matching public host key in site/known_hosts.
3
3
  LANES_SITE_SCHEMA=1
4
4
  LANES_SITE=my-team
5
+ # Optional: the prefix of this site's invite codes (letters; the server's `lanes invite` mints <CODE>-xxxx-xxxxxxxxxxxx).
6
+ # A profile bundled with the npm package is chosen by it (`npx @vibelord/lanes --invite <CODE>-…`); install.sh
7
+ # refuses a code with another prefix. One prefix per site.
8
+ # LANES_SITE_CODE=MT
5
9
  LANES_SERVER_ALIAS=workspace-server
6
10
  LANES_SERVER_HOST=workspace-server.example.ts.net
11
+ # MagicDNS name; a *.ts.net name makes the installer check Tailscale (and open its login) before the server
7
12
  LANES_SERVER_NAME=workspace-server.example.ts.net
8
13
  LANES_SERVER_USER=workspace
9
14
  # Optional public SSH fallback (use only if your server exposes SSH there):
@@ -29,4 +29,4 @@ if [ "$keepalive" = 1 ]; then
29
29
  fi
30
30
  fi
31
31
  fi
32
- printf 'Next, as %s: sh ~/.local/share/lanes/current/install/enable-phone.sh --local\n' "$user"
32
+ printf 'Next, as %s in your own terminal: lanes web phone --mobile --token (or any device; --desktop for a browser)\n' "$user"
@@ -0,0 +1,22 @@
1
+ [Unit]
2
+ Description=lanes enrolment service (invite redemption, requests, approvals) on loopback 127.0.0.1:8184
3
+
4
+ # Enabled and restarted by every server install (install/host-install.sh). lanes-mobile's Caddy forwards
5
+ # https://<host>:8443/enrol/* here (web/Caddyfile `handle_path /enrol/*`). It writes the workspace account's
6
+ # ~/.ssh/authorized_keys (the lanes-managed block, libexec/keys.py) and ~/.local/state/lanes/{invites,
7
+ # enrol-requests,members}/, so it has no ProtectHome. Request mode is pinned with `lanes enrol mode request|invite`
8
+ # after the Serve header check (~/.local/state/lanes/enrol-mode, read on every request, kept across restarts and
9
+ # re-installs). Optional settings (LANES_ENROL_DOMAINS, and LANES_ENROL_REQUEST_MODE, which wins over the pin) go in a
10
+ # drop-in, `systemctl --user edit lanes-enrold.service`, which a re-install keeps (RUNBOOK "Enrolment service").
11
+ [Service]
12
+ ExecStart=/usr/bin/python3 -S -E "@REPO@/libexec/enrold.py"
13
+ Restart=on-failure
14
+ RestartSec=10
15
+ NoNewPrivileges=true
16
+ UMask=0077
17
+ MemoryHigh=48M
18
+ MemoryMax=96M
19
+ Slice=lanes.slice
20
+
21
+ [Install]
22
+ WantedBy=default.target
@@ -1,10 +1,10 @@
1
1
  [Unit]
2
- Description=lanes push notifications (opt-in: POSTs needs-you transitions from status.json to an ntfy topic)
2
+ Description=lanes push notifications (opt-in: POSTs needs-you transitions and new enrolment requests to an ntfy topic)
3
3
  After=lanes-statusd.service
4
4
 
5
- # No installer enables this unit. It sends lane names and agent tasks to the ntfy server in
6
- # ~/.config/lanes/notify.conf (LANES_NOTIFY_CONF), so switching it on is an operator action:
7
- # systemctl --user enable --now lanes-notify.service (RUNBOOK "Push notifications (opt-in)").
5
+ # No installer enables this unit. It sends lane names, agent tasks and enrolment requesters' names, logins and
6
+ # device names to the ntfy server in ~/.config/lanes/notify.conf (LANES_NOTIFY_CONF), so switching it on is an
7
+ # operator action: systemctl --user enable --now lanes-notify.service (RUNBOOK "Push notifications (opt-in)").
8
8
  [Service]
9
9
  ExecStart=/usr/bin/python3 -S -E "@REPO@/libexec/notify.py" --interval 3
10
10
  Restart=on-failure
@@ -3,6 +3,10 @@ Description=lanes: zellij web server on loopback only (tailnet exposure is a sep
3
3
 
4
4
  [Service]
5
5
  ExecStart="@SHARE@/bin/zellij-0.45.1" --config-dir "@REPO@/zellij" --config "@REPO@/zellij/config.kdl" web --start --ip 127.0.0.1 --port 8082
6
+ # Zellij validates web sign-ins against $XDG_DATA_HOME/zellij/tokens.db. Pinned here, never left to the user manager's
7
+ # environment block; `lanes web --token` and `lanes token` run zellij with the same root (bin/lanes WEB_DATA_HOME), and
8
+ # `lanes doctor` compares it with this server's running environment.
9
+ Environment=XDG_DATA_HOME=%h/.local/share
6
10
  Restart=always
7
11
  RestartSec=5
8
12
  MemoryHigh=512M
@@ -18,6 +18,16 @@ http://:8083 {
18
18
  header Cache-Control "no-cache"
19
19
  file_server
20
20
  }
21
+ # lanes-enrold.service (libexec/enrold.py): invite redemption, enrolment requests, /enrol/pending approvals.
22
+ # handle_path strips /enrol; it must come before the Zellij routes below.
23
+ handle_path /enrol/* {
24
+ reverse_proxy 127.0.0.1:8184 {
25
+ # Tailscale Serve connects from loopback and sets X-Forwarded-For to the client's tailnet address.
26
+ # Trusting that peer keeps the header (Caddy appends 127.0.0.1), so the redeem rate limit counts per
27
+ # device; untrusted, Caddy replaces it with 127.0.0.1 and every device shares one bucket. Caddy >= 2.5.
28
+ trusted_proxies 127.0.0.0/8
29
+ }
30
+ }
21
31
  @backend path /assets/* /ws/* /command/* /info/* /session /session-list
22
32
  handle @backend {
23
33
  reverse_proxy 127.0.0.1:8082
@@ -37,8 +37,14 @@ before the control socket opens makes 0.45.1 throw `InvalidStateError` and leave
37
37
 
38
38
  `lanes-mobile.service` runs a separate, loopback-only Caddy on port 8083 with its admin API disabled.
39
39
  It does not change the system Caddy. Tailscale Serve forwards private HTTPS on 8443 to this frontend;
40
- the original Zellij service stays on 8082. Caddy must already be installed on the host.
41
- Run `sh install/enable-phone.sh --host lanes` from an installed client, or `--local` on the server.
40
+ the original Zellij service stays on 8082. Caddy (2.5 or later) must already be installed on the host. `/enrol/*` is
41
+ the one route that is neither these assets nor Zellij: `handle_path` strips `/enrol` and forwards to the enrolment
42
+ service on 127.0.0.1:8184 (`lanes-enrold.service`, [runbook](../docs/RUNBOOK.md#enrolment-service-lanes-enrold-w8-a3));
43
+ it must stay before the Zellij routes, and its `trusted_proxies 127.0.0.0/8` keeps the `X-Forwarded-For` Tailscale
44
+ Serve sets (the client's tailnet address), so the redeem rate limit counts per device.
45
+ Run `lanes web <device> --token` on the server (it also adds that Tailscale Serve mapping when it is missing),
46
+ `lanes-launch web phone|laptop` from an installed client, or `sh install/enable-phone.sh --host lanes` (`--local` on
47
+ the server).
42
48
  The [installation guide](../docs/INSTALL.md) covers Caddy, Tailscale operator permission and the selected SSH key.
43
49
  Service registration supplies `LANES_REPO` so Caddy serves the actual checkout from any installation path.
44
50
 
@@ -6,7 +6,12 @@ The server reads its boards from a **site directory**, not from the package:
6
6
  2. `~/.config/lanes/workspaces/` on the server, if it contains a `catalog.json`;
7
7
  3. `workspaces/` inside the checkout (what a single-site checkout uses).
8
8
 
9
- Copy this folder to `~/.config/lanes/workspaces/` and edit it.
9
+ Copy this folder to `~/.config/lanes/workspaces/` and edit it, or let the server write a one-repository board for you:
10
+ `lanes workspaces add <id> --container <name> --repo /home/coder/<repo> [--title T] [--write]` appends the board to
11
+ `catalog.json` (keeping a `catalog.json.bak-<UTC>Z` copy on the first write) and creates its `workspaces.<id>.json`.
12
+ It is a dry run until `--write`. An id that is already in the catalog is checked and never rewritten. A repository
13
+ belongs to one board: one that another board already declares is refused, since `lanes cursor-sync` rewrites a
14
+ repository's `terminals.json` (and a `cursor` file) for every board that names it. Keep that rule when editing by hand.
10
15
 
11
16
  | File | What it says |
12
17
  |---|---|
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@vibelord/lanes",
3
- "version": "1.0.4",
4
- "commit": "6b92bcb292e4123f2d723f0fe9da209642c2f2a2",
5
- "digest": "sha256:130e2da52ee8a4999a51e351c4bf3faef4a1d9e309ebd946e04c861742dfa719",
3
+ "version": "1.1.0",
4
+ "commit": "3d79500ae78bdd95a81cc9ef59f1cdcef1cde5cc",
5
+ "digest": "sha256:690fa7bb4e929589101ca519afea682f3084ca977b3244d1567e653f9aa172ba",
6
6
  "executables": [
7
7
  "bin/lanes",
8
8
  "client/install.sh",
@@ -32,5 +32,8 @@
32
32
  ],
33
33
  "sites": [
34
34
  "beyondwork"
35
- ]
35
+ ],
36
+ "site_codes": {
37
+ "LN": "beyondwork"
38
+ }
36
39
  }