@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.
- package/README.md +55 -15
- package/bin/lanes-install.mjs +8 -2
- package/lib/plan.mjs +34 -1
- package/package.json +2 -2
- package/payload/lanes/README.md +91 -33
- 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 +88 -3
- package/payload/lanes/docs/INSTALL.md +297 -43
- package/payload/lanes/docs/RUNBOOK.md +458 -31
- package/payload/lanes/docs/USERGUIDE.md +1612 -0
- package/payload/lanes/install/enable-phone.sh +8 -20
- package/payload/lanes/install/enable-web.sh +2 -2
- package/payload/lanes/install/host-install.sh +6 -0
- package/payload/lanes/install.sh +888 -52
- package/payload/lanes/libexec/enrol.py +695 -0
- package/payload/lanes/libexec/enrold.py +1264 -0
- package/payload/lanes/libexec/keys.py +644 -0
- package/payload/lanes/libexec/lanes_lib.py +14 -6
- package/payload/lanes/libexec/members.py +297 -0
- package/payload/lanes/libexec/notify.py +146 -21
- package/payload/lanes/libexec/qr.py +217 -0
- package/payload/lanes/libexec/site_edit.py +237 -0
- package/payload/lanes/libexec/web_tokens.py +315 -0
- package/payload/lanes/site/README.md +13 -1
- package/payload/lanes/site/beyondwork/site.conf +3 -0
- package/payload/lanes/site/site.example.conf +5 -0
- package/payload/lanes/sudo/one-time-root.sh +1 -1
- package/payload/lanes/systemd/lanes-enrold.service +22 -0
- package/payload/lanes/systemd/lanes-notify.service +4 -4
- package/payload/lanes/systemd/lanes-web.service +4 -0
- package/payload/lanes/web/Caddyfile +10 -0
- package/payload/lanes/web/README.md +8 -2
- package/payload/lanes/workspaces.example/README.md +6 -1
- package/payload/manifest.json +7 -4
|
@@ -0,0 +1,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
|
|
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:
|
|
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
|
|
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
|
|
6
|
-
# ~/.config/lanes/notify.conf (LANES_NOTIFY_CONF), so switching it on is an
|
|
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
|
-
|
|
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
|
|---|---|
|
package/payload/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vibelord/lanes",
|
|
3
|
-
"version": "1.0
|
|
4
|
-
"commit": "
|
|
5
|
-
"digest": "sha256:
|
|
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
|
}
|