@vibelord/lanes 1.0.4 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -15
- package/bin/lanes-install.mjs +8 -2
- package/lib/plan.mjs +34 -1
- package/package.json +2 -2
- package/payload/lanes/README.md +39 -18
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +994 -49
- package/payload/lanes/client/lanes-launch +160 -14
- package/payload/lanes/client/ssh.stanza.in +11 -2
- package/payload/lanes/docs/ARCHITECTURE.md +86 -2
- package/payload/lanes/docs/INSTALL.md +296 -43
- package/payload/lanes/docs/RUNBOOK.md +453 -27
- package/payload/lanes/docs/USERGUIDE.md +449 -76
- package/payload/lanes/install/enable-phone.sh +8 -20
- package/payload/lanes/install/enable-web.sh +2 -2
- package/payload/lanes/install/host-install.sh +6 -0
- package/payload/lanes/install.sh +888 -52
- package/payload/lanes/libexec/enrol.py +695 -0
- package/payload/lanes/libexec/enrold.py +1264 -0
- package/payload/lanes/libexec/keys.py +644 -0
- package/payload/lanes/libexec/lanes_lib.py +14 -6
- package/payload/lanes/libexec/members.py +297 -0
- package/payload/lanes/libexec/notify.py +146 -21
- package/payload/lanes/libexec/qr.py +217 -0
- package/payload/lanes/libexec/site_edit.py +237 -0
- package/payload/lanes/libexec/web_tokens.py +315 -0
- package/payload/lanes/site/README.md +13 -1
- package/payload/lanes/site/beyondwork/site.conf +3 -0
- package/payload/lanes/site/site.example.conf +5 -0
- package/payload/lanes/sudo/one-time-root.sh +1 -1
- package/payload/lanes/systemd/lanes-enrold.service +22 -0
- package/payload/lanes/systemd/lanes-notify.service +4 -4
- package/payload/lanes/systemd/lanes-web.service +4 -0
- package/payload/lanes/web/Caddyfile +10 -0
- package/payload/lanes/web/README.md +8 -2
- package/payload/lanes/workspaces.example/README.md +6 -1
- package/payload/manifest.json +7 -4
|
@@ -6,6 +6,12 @@
|
|
|
6
6
|
# lanes-launch <device> [--ascii] [--theme dark|light|auto] [--glyphs M] [--color C] the device's look (lanes attach)
|
|
7
7
|
# lanes-launch lane <lane-id> attach straight to ONE lane, no multiplexer in between
|
|
8
8
|
# lanes-launch ls list lanes with live status
|
|
9
|
+
# lanes-launch web phone|laptop|<device> [--desktop|--mobile] [--no-qr]
|
|
10
|
+
# a browser view in one step: the server prepares it, prints its private URL and a QR
|
|
11
|
+
# code of it (--no-qr: none), then a login token in THIS terminal. phone = <your
|
|
12
|
+
# device>-phone (phone layout), laptop = <your device>-web (desktop layout); any other
|
|
13
|
+
# name is that device
|
|
14
|
+
# lanes-launch update [install.sh options] the newest client from npm, with your remembered install options
|
|
9
15
|
# lanes-launch --helper <device> [--service | --while-pid PID] run only the upload helper
|
|
10
16
|
# (--service: the per-machine background service that install.sh sets up)
|
|
11
17
|
# It never ends by itself: after network loss, sleep, or a server that dropped or refused it, it says so, waits and
|
|
@@ -13,7 +19,9 @@
|
|
|
13
19
|
set -u
|
|
14
20
|
host=${LANES_HOST:-lanes}
|
|
15
21
|
primary=$host
|
|
16
|
-
|
|
22
|
+
# Tried in this order after two immediate connect failures, each only when the ssh config defines it: lanes-ip (the
|
|
23
|
+
# server's tailnet address, when `lanes` names it by its MagicDNS name), then lanes-public (the site's public path).
|
|
24
|
+
fallbacks=${LANES_FALLBACK_HOST:-lanes-ip lanes-public}
|
|
17
25
|
remote='$HOME/.local/bin/lanes'
|
|
18
26
|
config=${XDG_CONFIG_HOME:-$HOME/.config}/lanes
|
|
19
27
|
state=${XDG_STATE_HOME:-$HOME/.local/state}/lanes-client # logs and helper.alive; `install.sh --check` reads them
|
|
@@ -24,6 +32,113 @@ default_device() {
|
|
|
24
32
|
printf '%s' "$name" | tr 'A-Z' 'a-z' | tr -c 'a-z0-9_.-' '-' | sed 's/-*$//'
|
|
25
33
|
}
|
|
26
34
|
|
|
35
|
+
# The directory this script really lives in. install.sh links ~/.local/bin/lanes-launch (and coder-launch) to the copy
|
|
36
|
+
# in ~/.local/share/lanes/client, or with --link to a checkout's client/ — always by an absolute path.
|
|
37
|
+
self_dir() {
|
|
38
|
+
sd=$0 sd_hops=0
|
|
39
|
+
while [ -L "$sd" ] && [ "$sd_hops" -lt 8 ]; do
|
|
40
|
+
sd_to=$(readlink "$sd") || break
|
|
41
|
+
case "$sd_to" in /*) sd=$sd_to ;; *) sd=$(dirname "$sd")/$sd_to ;; esac
|
|
42
|
+
sd_hops=$((sd_hops + 1))
|
|
43
|
+
done
|
|
44
|
+
(cd "$(dirname "$sd")" 2>/dev/null && pwd -P)
|
|
45
|
+
}
|
|
46
|
+
# This client's release: the VERSION file install.sh copies beside the client, else a checkout's lanes/VERSION.
|
|
47
|
+
# Nothing when neither is there (then the server is told no version).
|
|
48
|
+
client_version() {
|
|
49
|
+
cv_dir=$(self_dir)
|
|
50
|
+
for cv_file in "$cv_dir/VERSION" "$cv_dir/../VERSION"; do
|
|
51
|
+
if [ -r "$cv_file" ]; then head -n 1 "$cv_file" | tr -cd 'A-Za-z0-9._+-'; return 0; fi
|
|
52
|
+
done
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# `lanes-launch web phone|laptop|<device>`: one command for a browser view, run in THIS terminal: `lanes web` on the
|
|
56
|
+
# server prepares the view, enables the web services and prints the private URL with a QR code of it (never of the
|
|
57
|
+
# token; --no-qr: none), then shows a fresh login token here (the server refuses it without a terminal; nothing logs
|
|
58
|
+
# it). phone and laptop are views of their own, named after this machine's device so the server counts them as this
|
|
59
|
+
# machine's (`--default-device`: one upload helper per machine).
|
|
60
|
+
web_view() {
|
|
61
|
+
wv_usage='usage: lanes-launch web phone|laptop|<device> [--desktop|--mobile] [--no-qr]'
|
|
62
|
+
[ $# -gt 0 ] || { echo "$wv_usage" >&2; exit 64; }
|
|
63
|
+
wv_base=$(default_device)
|
|
64
|
+
case "$1" in
|
|
65
|
+
phone) wv_device=$wv_base-phone wv_layout=--mobile ;;
|
|
66
|
+
laptop) wv_device=$wv_base-web wv_layout=--desktop ;;
|
|
67
|
+
*) wv_device=$1 wv_layout= ;;
|
|
68
|
+
esac
|
|
69
|
+
shift
|
|
70
|
+
wv_chosen= wv_qr=
|
|
71
|
+
# Spliced into the remote command line: only this closed vocabulary passes, anything else refuses before a connection.
|
|
72
|
+
while [ $# -gt 0 ]; do
|
|
73
|
+
case "$1" in
|
|
74
|
+
--desktop|--mobile)
|
|
75
|
+
if [ -n "$wv_chosen" ] && [ "$wv_chosen" != "$1" ]; then echo '--desktop and --mobile are exclusive' >&2; exit 64; fi
|
|
76
|
+
wv_chosen=$1 ;;
|
|
77
|
+
--no-qr) wv_qr=' --no-qr' ;;
|
|
78
|
+
*) echo "lanes-launch web: unknown option $1" >&2; echo "$wv_usage" >&2; exit 64 ;;
|
|
79
|
+
esac
|
|
80
|
+
shift
|
|
81
|
+
done
|
|
82
|
+
[ -z "$wv_chosen" ] || wv_layout=$wv_chosen
|
|
83
|
+
for wv_name in "$wv_base" "$wv_device"; do
|
|
84
|
+
case "$wv_name" in ''|-*|*[!A-Za-z0-9_.-]*) echo "invalid name: $wv_name" >&2; exit 64 ;; esac
|
|
85
|
+
done
|
|
86
|
+
# The server's limit. Never shortened here: a cut suffix could turn the view into the machine's own device.
|
|
87
|
+
if [ "${#wv_device}" -gt 32 ]; then
|
|
88
|
+
echo "lanes-launch web: $wv_device is longer than 32 characters; name the view: lanes-launch web <name>" >&2; exit 64
|
|
89
|
+
fi
|
|
90
|
+
# One connection, decided on the server: `lanes hello` says its release (`ok device=- version=V boards=N`, every
|
|
91
|
+
# release since 1.0). A server older than 1.1.0 refuses --default-device and --no-qr (`unknown option`, before the
|
|
92
|
+
# view exists), so it gets the view and the token without them, and this says what that costs and how the server
|
|
93
|
+
# updates. Run by sh -c on the server whatever the account's login shell is; the names are checked above and the
|
|
94
|
+
# sentence holds no single quote.
|
|
95
|
+
wv_view="$remote web $wv_device${wv_layout:+ $wv_layout} --token"
|
|
96
|
+
wv_old='lanes-launch web: the server runs lanes '\$wv_release', older than this client: no QR code, and the upload box of this view does not reach this machine. The server updates with: npx @vibelord/lanes@latest --server'
|
|
97
|
+
exec ssh -t "$host" "sh -c 'wv_release=\$($remote hello 2>/dev/null); wv_release=\${wv_release##* version=}; wv_release=\${wv_release%% *}; case \$wv_release in 0.*|1.0.*) echo \"$wv_old\" >&2; exec $wv_view ;; *) exec $wv_view --default-device $wv_base$wv_qr ;; esac'"
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
# `lanes-launch update`: the newest client from npm with this machine's remembered choices. Every KEY=VALUE line of
|
|
101
|
+
# ~/.config/lanes/install-options becomes its install.sh flag, checked as install.sh checks the file (a value it would
|
|
102
|
+
# ignore is not passed; it names that one itself), so the update repeats the last install even where the new package
|
|
103
|
+
# would resolve a default differently. Arguments given here follow the replayed ones: on install.sh's command line the
|
|
104
|
+
# last flag wins.
|
|
105
|
+
update_client() {
|
|
106
|
+
for uc_arg in "$@"; do
|
|
107
|
+
case "$uc_arg" in --server) echo 'lanes-launch update updates this client; a server updates with: npx @vibelord/lanes@latest --server' >&2; exit 64 ;; esac
|
|
108
|
+
done
|
|
109
|
+
uc_dir=$(self_dir)
|
|
110
|
+
if [ -r "$uc_dir/../install.sh" ]; then
|
|
111
|
+
# (a linked or checkout client: npm would replace it with a copy)
|
|
112
|
+
echo "lanes-launch update: this client runs from a checkout ($(dirname "$uc_dir")): update it there with git pull, then ./install.sh" >&2
|
|
113
|
+
exit 64
|
|
114
|
+
fi
|
|
115
|
+
command -v npx >/dev/null 2>&1 || { echo 'lanes-launch update: needs Node.js 18 or newer (npx), https://nodejs.org' >&2; exit 69; }
|
|
116
|
+
uc_given=$#
|
|
117
|
+
uc_options=$config/install-options
|
|
118
|
+
if [ -r "$uc_options" ]; then
|
|
119
|
+
case "$(sed -n 's/^SCHEMA=//p' "$uc_options" | tail -n 1)" in
|
|
120
|
+
''|1)
|
|
121
|
+
while IFS= read -r uc_line || [ -n "$uc_line" ]; do
|
|
122
|
+
uc_value=${uc_line#*=}
|
|
123
|
+
uc_flag=$(printf '%s' "${uc_line%%=*}" | tr 'A-Z_' 'a-z-')
|
|
124
|
+
case "$uc_line" in
|
|
125
|
+
DEVICE=*) case "$uc_value" in ''|*[!a-z0-9_.-]*|.*) ;; *) set -- "$@" --device "$uc_value" ;; esac ;;
|
|
126
|
+
KEY=/*|KEY='~/'*) set -- "$@" --key "$uc_value" ;;
|
|
127
|
+
SITE=/*) set -- "$@" --site "$uc_value" ;;
|
|
128
|
+
SITE=*) case "$uc_value" in ''|.*|*/*|*[!A-Za-z0-9_.-]*) ;; *) set -- "$@" --site "$uc_value" ;; esac ;;
|
|
129
|
+
DESKTOP=1|UPLOADS=1|PALETTE=1|CODER_LAUNCH=1) set -- "$@" "--$uc_flag" ;;
|
|
130
|
+
DESKTOP=0|UPLOADS=0|PALETTE=0|CODER_LAUNCH=0) set -- "$@" "--no-$uc_flag" ;;
|
|
131
|
+
esac # (SCHEMA, comments, unknown keys: install.sh does not read them either)
|
|
132
|
+
done < "$uc_options" ;;
|
|
133
|
+
*) echo "lanes-launch update: $uc_options was written by a newer installer: not replayed" >&2 ;;
|
|
134
|
+
esac
|
|
135
|
+
fi
|
|
136
|
+
# The arguments given here go last: move them behind the replayed flags.
|
|
137
|
+
while [ "$uc_given" -gt 0 ]; do set -- "$@" "$1"; shift; uc_given=$((uc_given - 1)); done
|
|
138
|
+
printf 'lanes-launch update: npx --yes @vibelord/lanes@latest %s\n' "$*" >&2
|
|
139
|
+
exec npx --yes @vibelord/lanes@latest "$@"
|
|
140
|
+
}
|
|
141
|
+
|
|
27
142
|
while_pid=
|
|
28
143
|
service=
|
|
29
144
|
extra=
|
|
@@ -39,10 +154,12 @@ case "${1:-}" in
|
|
|
39
154
|
esac
|
|
40
155
|
done ;;
|
|
41
156
|
ls) exec ssh -o RequestTTY=no "$host" "$remote ls" ;;
|
|
157
|
+
web) shift; web_view "$@" ;;
|
|
158
|
+
update) shift; update_client "$@" ;;
|
|
42
159
|
lane)
|
|
43
160
|
[ -n "${2:-}" ] || { echo 'usage: lanes-launch lane <lane-id>' >&2; exit 64; }
|
|
44
161
|
mode=open target=$2 ;;
|
|
45
|
-
-h|--help) sed -n '2,
|
|
162
|
+
-h|--help) sed -n '2,18p' "$0"; exit 0 ;;
|
|
46
163
|
*) mode=attach target=${1:-$(default_device)} ;;
|
|
47
164
|
esac
|
|
48
165
|
if [ "$mode" = attach ] && [ $# -gt 1 ]; then
|
|
@@ -65,6 +182,9 @@ fi
|
|
|
65
182
|
case "$target" in ''|-*|*[!A-Za-z0-9_.-]*) echo "invalid name: $target" >&2; exit 64 ;; esac
|
|
66
183
|
client="$(hostname -s 2>/dev/null || echo client)-$$"
|
|
67
184
|
client=$(printf '%s' "$client" | tr -c 'A-Za-z0-9_.-' '-')
|
|
185
|
+
# Told to `lanes attach`, which says in one line when this client is older than the server (`lanes-launch update`).
|
|
186
|
+
version=$(client_version)
|
|
187
|
+
[ -z "$version" ] || version=" --client-version $version"
|
|
68
188
|
|
|
69
189
|
# ---- log: what a window that showed nothing never left behind ------------------------------------------------------
|
|
70
190
|
# One line per connection event, rotated to .1 at 256 KiB. The service writes to stderr instead: launchd / journald
|
|
@@ -176,10 +296,19 @@ hold() {
|
|
|
176
296
|
wait "$reader" 2>/dev/null || :
|
|
177
297
|
reader=
|
|
178
298
|
}
|
|
179
|
-
#
|
|
180
|
-
|
|
181
|
-
resolved=$(ssh -G "$
|
|
182
|
-
[ -n "$resolved" ] && [ "$resolved" != "$
|
|
299
|
+
# A fallback host counts only if the ssh config really defines it (an undefined alias resolves to itself).
|
|
300
|
+
defined() {
|
|
301
|
+
resolved=$(ssh -G "$1" 2>/dev/null | awk '$1 == "hostname" { print $2; exit }')
|
|
302
|
+
[ -n "$resolved" ] && [ "$resolved" != "$1" ]
|
|
303
|
+
}
|
|
304
|
+
# The path after $host: the next DEFINED alias in $fallbacks, and past the last one the primary again.
|
|
305
|
+
next_host() {
|
|
306
|
+
nh_after=; [ "$host" = "$primary" ] && nh_after=1
|
|
307
|
+
for nh in $fallbacks; do
|
|
308
|
+
if [ -n "$nh_after" ] && [ "$nh" != "$primary" ] && defined "$nh"; then printf '%s' "$nh"; return 0; fi
|
|
309
|
+
[ "$nh" = "$host" ] && nh_after=1
|
|
310
|
+
done
|
|
311
|
+
printf '%s' "$primary"
|
|
183
312
|
}
|
|
184
313
|
# ---- upload helper: serves the sidebar's upload box ------------------------------------------------------------
|
|
185
314
|
# A drop on the terminal only gives the SERVER a local path string; the file lives here. This helper waits for
|
|
@@ -213,6 +342,20 @@ service_serving() {
|
|
|
213
342
|
seen=$(mtime "$state/helper.alive") || return 1
|
|
214
343
|
[ -n "$seen" ] && [ $(( $(date +%s) - seen )) -lt 90 ]
|
|
215
344
|
}
|
|
345
|
+
# A systemd user service keeps the environment it was started with, and default.target starts lanes-helper at boot —
|
|
346
|
+
# often before the desktop session (gnome-keyring, KDE, uwsm) hands its SSH_AUTH_SOCK to the user manager. So the
|
|
347
|
+
# SERVICE takes the agent the user manager holds before every login: a key with a passphrase works from the first
|
|
348
|
+
# login after that hand-over, with no restart and no login shell. Only a plain absolute path is taken (systemd prints a
|
|
349
|
+
# value that needs quoting as $'…'). A window's helper keeps its terminal's agent; launchd has no user manager to ask.
|
|
350
|
+
manager_agent() {
|
|
351
|
+
[ -n "$service" ] || return 0
|
|
352
|
+
command -v systemctl >/dev/null 2>&1 || return 0
|
|
353
|
+
ma=$(systemctl --user show-environment 2>/dev/null | sed -n 's/^SSH_AUTH_SOCK=//p' | tail -n 1)
|
|
354
|
+
case "$ma" in /*) ;; *) return 0 ;; esac
|
|
355
|
+
[ "$ma" != "${SSH_AUTH_SOCK:-}" ] || return 0
|
|
356
|
+
SSH_AUTH_SOCK=$ma; export SSH_AUTH_SOCK
|
|
357
|
+
log "upload helper: SSH agent of the user manager: $ma"
|
|
358
|
+
}
|
|
216
359
|
upload_helper() {
|
|
217
360
|
[ -x "$HOME/.local/bin/lanes-upload" ] || return 0
|
|
218
361
|
dflt=$(default_device)
|
|
@@ -230,6 +373,7 @@ upload_helper() {
|
|
|
230
373
|
# A window's own helper is the fallback only. While the service serves this machine it opens no connection
|
|
231
374
|
# (each one is a long-lived process on the server); when the service goes quiet it takes over within ~2 min.
|
|
232
375
|
if [ -z "$service" ] && [ "$helper_target" = "$dflt" ] && service_serving; then sleep 30; continue; fi
|
|
376
|
+
manager_agent
|
|
233
377
|
if ssh -o RequestTTY=no -o BatchMode=yes -o ServerAliveInterval=15 -o ServerAliveCountMax=4 "$primary" "$remote upload-requests $helper_target --default-device $dflt" 2>"$errfile" |
|
|
234
378
|
( heard=1
|
|
235
379
|
while IFS= read -r line; do
|
|
@@ -319,7 +463,7 @@ while :; do
|
|
|
319
463
|
log "connecting: $mode $target via $host"
|
|
320
464
|
started=$(date +%s)
|
|
321
465
|
if [ "$mode" = attach ]; then
|
|
322
|
-
run ssh "$host" "$remote attach $target --client $client --default-device $(default_device) $extra" || :
|
|
466
|
+
run ssh "$host" "$remote attach $target --client $client --default-device $(default_device)$version $extra" || :
|
|
323
467
|
else
|
|
324
468
|
run ssh "$host" "$remote open $target" || :
|
|
325
469
|
fi
|
|
@@ -353,14 +497,16 @@ while :; do
|
|
|
353
497
|
pause=60
|
|
354
498
|
what='trying again'
|
|
355
499
|
else
|
|
356
|
-
# Two transport failures in a row on
|
|
357
|
-
# that times out takes ConnectTimeout 20 — it must count, or a dead
|
|
500
|
+
# Two transport failures in a row on one path: the next path the ssh config defines (lanes -> lanes-ip ->
|
|
501
|
+
# lanes-public -> lanes). (25 s: a connect that times out takes ConnectTimeout 20 — it must count, or a dead
|
|
502
|
+
# tailnet never reaches a fallback.)
|
|
358
503
|
if [ "$rc" = 255 ] && [ "$lasted" -lt 25 ]; then fails=$((fails + 1)); else fails=0; fi
|
|
359
|
-
if [ "$fails" -ge 2 ]
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
504
|
+
if [ "$fails" -ge 2 ]; then
|
|
505
|
+
next=$(next_host); fails=0
|
|
506
|
+
if [ "$next" != "$host" ] && [ "$next" != "$primary" ]; then
|
|
507
|
+
printf '\r\n\033[2mlanes: %s unreachable, using %s\033[0m\r\n' "$host" "$next"; delay=1
|
|
508
|
+
fi
|
|
509
|
+
host=$next
|
|
364
510
|
fi
|
|
365
511
|
printf '\r\n\033[2mlanes: connection ended (%s).\033[0m\r\n' "$rc"
|
|
366
512
|
pause=$delay
|
|
@@ -3,7 +3,15 @@
|
|
|
3
3
|
# ~/.ssh/config; nothing else in your SSH setup is changed. Re-run the installer instead of editing this file.
|
|
4
4
|
# Options that only some OpenSSH builds know (ObscureKeystrokeTiming, UseKeychain) are written ONLY when this
|
|
5
5
|
# machine's ssh accepts them: an unknown option is a parse error for EVERY host, even inside a non-matching block.
|
|
6
|
-
|
|
6
|
+
# Every alias below pins the same host key: HostKeyAlias keys known_hosts by "@ALIAS@", whatever HostName says.
|
|
7
|
+
#@IPALIAS@ # `lanes` names the server by its MagicDNS name (this machine resolved it at install time); lanes-ip is the
|
|
8
|
+
#@IPALIAS@ # same server by its tailnet address, which lanes-launch falls back to after two immediate connect failures on
|
|
9
|
+
#@IPALIAS@ # `lanes` (MagicDNS switched off later, say). OpenSSH keeps the FIRST value it reads for an option, so this
|
|
10
|
+
#@IPALIAS@ # HostName wins over the one in the shared block below.
|
|
11
|
+
#@IPALIAS@ Host lanes-ip
|
|
12
|
+
#@IPALIAS@ HostName @IP@
|
|
13
|
+
#@IPALIAS@ Host lanes lanes-ip
|
|
14
|
+
#@NOIPALIAS@ Host lanes
|
|
7
15
|
HostName @HOST@
|
|
8
16
|
User @USER@
|
|
9
17
|
HostKeyAlias @ALIAS@
|
|
@@ -31,7 +39,8 @@ Host lanes
|
|
|
31
39
|
LogLevel ERROR
|
|
32
40
|
#@PUBLIC@
|
|
33
41
|
#@PUBLIC@ # Fallback path: the same sshd over the server's public address (key-only). lanes-launch uses it only after two
|
|
34
|
-
#@PUBLIC@ # immediate connect failures on `lanes` (tailnet down); it goes back to the tailnet on the next
|
|
42
|
+
#@PUBLIC@ # immediate connect failures on `lanes` (and lanes-ip; tailnet down); it goes back to the tailnet on the next
|
|
43
|
+
#@PUBLIC@ # healthy session.
|
|
35
44
|
#@PUBLIC@ Host lanes-public
|
|
36
45
|
#@PUBLIC@ HostName @PUBLIC_HOST@
|
|
37
46
|
#@PUBLIC@ User @USER@
|
|
@@ -198,7 +198,9 @@ shim, so `EVAL_CLAUDE_REAL_BIN=<checkout>/.bw-tools/bin/claude eval-claude` comp
|
|
|
198
198
|
The client installer creates an SSH alias and dedicated terminal configuration, using the selected profile's
|
|
199
199
|
host key and the user's SSH key. `lanes-launch` reconnects its view and provides a helper channel for uploads.
|
|
200
200
|
A background helper can serve the same channel when installed; on macOS it runs under the Lanes Helper app's
|
|
201
|
-
identity so folder permissions work.
|
|
201
|
+
identity so folder permissions work. On Linux it is a systemd user service that logs in through the SSH agent the
|
|
202
|
+
user manager holds, looked up before every login, so an agent a desktop session hands over after boot is used
|
|
203
|
+
without a restart. The installer's remembered `--no-uploads` / `--no-desktop` remove what an earlier install added.
|
|
202
204
|
|
|
203
205
|
The sidebar sends an upload request naming paths, workspace and device. The client checks its folder allowlist,
|
|
204
206
|
streams an archive over SSH, and reports original errors and progress. The host extracts data into that
|
|
@@ -206,11 +208,44 @@ workspace's `uploads/` folder without overwriting files; an optional paste place
|
|
|
206
208
|
selected lane. Active transfers have an idle timeout, not a short total-duration limit. Credentials, `.git`
|
|
207
209
|
metadata and disallowed folders cannot be uploaded through the helper.
|
|
208
210
|
|
|
211
|
+
## Joining a site
|
|
212
|
+
|
|
213
|
+
A machine whose key the server does not know (`lanes hello` refused with `Permission denied`) is authorized through
|
|
214
|
+
the site's enrolment service, `lanes-enrold` (W8 Track A, loopback, behind Tailscale Serve and Caddy's `/enrol/*`),
|
|
215
|
+
never by the client editing `authorized_keys`. The client half is in `install.sh` (`enrol`). It asks
|
|
216
|
+
`https://<LANES_SERVER_NAME>:8443` with curl, or with python3's `http.client` when curl is absent. When the machine
|
|
217
|
+
cannot resolve the name (MagicDNS off), it repeats the request against the profile's IPv4 `LANES_SERVER_HOST`
|
|
218
|
+
(`--resolve NAME:PORT:ADDRESS`), so TLS still checks the certificate against the name. Request bodies go through stdin;
|
|
219
|
+
curl runs with `-q` (no `~/.curlrc`) and `--noproxy '*'` (the service is on the tailnet only).
|
|
220
|
+
|
|
221
|
+
Wire contract v1 is the one `lanes-enrold` serves (W8 A3; its RUNBOOK section "Enrolment service" and the data-model
|
|
222
|
+
row DM-LANES-ENROL-WIRE are the authority; additive only, so the client ignores keys it does not know). The client
|
|
223
|
+
reads JSON answers of at most 64 KiB; any status other than the success below is a refusal whose body may carry
|
|
224
|
+
`{"error": "<word>"}` (for example `invalid-or-expired`, `requests-disabled`, `rate-limited`), which the client shows:
|
|
225
|
+
|
|
226
|
+
| Call | Body the client sends | Success, and what the client reads of it |
|
|
227
|
+
| --- | --- | --- |
|
|
228
|
+
| `GET /enrol/site` | — | `200 {alias, host_name, host_key, version, request_mode, caller, source}`: `request_mode` (`true`\|`false`, always present) is what tells the service from a web page or any other JSON; `false` = invite only; `caller` is the tailnet login the service received (`null` outside request mode). `version` is the server's Lanes release, not a wire number: the client does not gate on it |
|
|
229
|
+
| `POST /enrol/redeem` | `{"code", "pubkey": "ssh-ed25519 <base64>", "device", "client_version"}` | `200 {device, owner: {login, name, email} \| null, ssh_ready: true}`: the key is authorized now |
|
|
230
|
+
| `POST /enrol/request` | `{"pubkey", "device", "client_version"}` | `202 {id, expires_at}` (the same device and key asked again: the same request); the client keeps `id` |
|
|
231
|
+
| `GET /enrol/request/<id>` | — | `200 {id, state: pending\|approved\|denied\|expired, expires_at}`; `404` = the service does not know it (or it was filed by another login): the request is over |
|
|
232
|
+
|
|
233
|
+
A verdict carries no owner: an approved request belongs to the login it was filed under, the `/site` `caller`, which
|
|
234
|
+
is what the client names. The client sends the public key without its comment (the managed block writes
|
|
235
|
+
`lanes:<device>`), never sends the code anywhere but the redeem body, and keeps an open request's id in
|
|
236
|
+
`~/.config/lanes/enrol-request` (`SCHEMA=1`, `ID`, `URL`, `DEVICE`, `FILED`; mode 0600; read without executing it;
|
|
237
|
+
removed once answered and by `--uninstall`). No service (no answer, a page, `502` from a Caddy whose enrold is down), a
|
|
238
|
+
refusal, or no code and no terminal: the exit-3 text with the public key, unchanged — except a `409 {"error":
|
|
239
|
+
"device-taken"|"key-taken"}`. enrold answers that only after a valid code (or to a request it would otherwise file),
|
|
240
|
+
and nothing is used up, so the exit-3 text names the re-run that joins instead: another `--device NAME` (the invite is
|
|
241
|
+
still valid) or a key of its own, and where a member looks (`lanes offboard <device> --yes`, `lanes keys list`).
|
|
242
|
+
|
|
209
243
|
## Web and phone
|
|
210
244
|
|
|
211
245
|
Tailscale Serve terminates private HTTPS on 8443 and forwards to the dedicated Caddy user service on loopback
|
|
212
246
|
8083. Caddy serves `web/` and forwards native assets, authentication and WebSockets to Zellij on loopback 8082.
|
|
213
|
-
Zellij's existing token/cookie checks remain authoritative; the mobile layer adds no second login system.
|
|
247
|
+
Zellij's existing token/cookie checks remain authoritative; the mobile layer adds no second login system. The one
|
|
248
|
+
other route, `/enrol/*`, goes to the enrolment service on loopback 8184 (see Access).
|
|
214
249
|
|
|
215
250
|
The phone shell uses the pinned client's window hooks for workspaces, panes, input and sizing. A visible
|
|
216
251
|
**Type here** field holds the original terminal input. Programmatic input focus is blocked on phones, so a
|
|
@@ -251,3 +286,52 @@ and [host protection](HOST-GUARD.md) for those operations.
|
|
|
251
286
|
The package uses Python's standard library and POSIX shell. Host binaries are versioned and checksum-pinned
|
|
252
287
|
in `holders.lock`; Zellij's web assets are served by that binary and integrity-pinned in `web/index.html`.
|
|
253
288
|
Deployment profiles, workspace catalogs and runtime state remain distinct from shared source code.
|
|
289
|
+
|
|
290
|
+
## Access
|
|
291
|
+
|
|
292
|
+
Clients log in to one Unix account over SSH (tailnet, or the `lanes-public` fallback). Who may do so is the
|
|
293
|
+
`members/<device>.json` store in the lanes state directory, projected by `libexec/keys.py` (`lanes keys`) into one
|
|
294
|
+
`# lanes-managed BEGIN` … `END` block of that account's `~/.ssh/authorized_keys`: one
|
|
295
|
+
`restrict,pty ssh-ed25519 <key> lanes:<device>` line per member, re-rendered whole on every change, written
|
|
296
|
+
atomically and only when the bytes change. Lines outside the block belong to the operator and are never edited
|
|
297
|
+
(only `lanes keys adopt` moves plain hand-added `lanes:<device>` lines in, keeping a backup). The block never names a
|
|
298
|
+
device the store does not hold: a member file is written before its line and a revoked line leaves the block before
|
|
299
|
+
its member file does, so an interrupted change can leave a member without a line (no access) but never a line
|
|
300
|
+
without a member, and running the same verb again finishes it. The options follow
|
|
301
|
+
what the client does over that key: a forced TTY and plain commands with stdin pipes, no forwarding. The client
|
|
302
|
+
installer never edits the server's file.
|
|
303
|
+
|
|
304
|
+
Identity on the server is *device → owner*: a member record also names the owner `{login, name, email}` (the
|
|
305
|
+
tailnet login), how the key arrived (`invite`, `request`, `manual`), who approved it and the workspaces it was
|
|
306
|
+
invited to. `libexec/members.py` owns that store's file I/O and the one definition of a valid record's identity
|
|
307
|
+
fields; `keys.py` owns the key fields. The Unix account stays shared. A view session takes its owner from the
|
|
308
|
+
store (`create_session` and `lanes attach`, also from the machine's record for a window of that machine) and records
|
|
309
|
+
it in `devices/<device>.json`: whenever a member record exists it decides (`members.owner_for`), so `lanes device
|
|
310
|
+
list` agrees with `lanes keys list` and a handed-over machine follows its new owner; after a revoke the last owner is
|
|
311
|
+
kept, like the base. Enrolment writes `members/` only, never `devices/`, because every device file is a view `lanes
|
|
312
|
+
ensure` keeps alive. Every text field of a member is refused when it holds a control, format (bidi, zero-width),
|
|
313
|
+
surrogate, private-use or unassigned character, since `device list` and `keys list` print them.
|
|
314
|
+
|
|
315
|
+
Enrolment is a service, not an admin edit: `libexec/enrold.py` (`lanes-enrold.service`, loopback 8184, reached as
|
|
316
|
+
`/enrol/*` through the same Tailscale Serve and Caddy as the browser view) adds a member either by redeeming a one-time
|
|
317
|
+
invite code from `invites/` or by a request in `enrol-requests/` that a member approves on `/enrol/pending` (a member
|
|
318
|
+
whose owner login is the caller's; `lanes keys owner` names the owner of a key added by hand). It writes
|
|
319
|
+
through `keys.py` under the same lock as `lanes keys`, so the block stays a projection of `members/`. The caller's
|
|
320
|
+
identity is the `Tailscale-User-Login` header Serve injects (a redeemed invite then belongs to that caller, whoever it
|
|
321
|
+
was minted for; the invite's own name and email stay in the record as `invited_as`), trusted only in request mode —
|
|
322
|
+
pinned by the operator with `lanes enrol mode request` after the header check (or forced by the unit's environment),
|
|
323
|
+
else decided by the first request that came through Serve after a start. Serve replaces a forged header only on the tailnet path: any process that
|
|
324
|
+
reaches the host's loopback can send one itself, so request mode is safe only where no account besides the workspace
|
|
325
|
+
account and root can (DEF-1645, open; RUNBOOK "Trust boundary"). Every redeem failure is one indistinguishable
|
|
326
|
+
answer, rate-limited per tailnet device (Caddy keeps the `X-Forwarded-For` Serve sets, by trusting its loopback peer),
|
|
327
|
+
and the append-only `enrol.log` never holds a code. RUNBOOK "Enrolment service" has the wire contract.
|
|
328
|
+
|
|
329
|
+
The operator's half is `libexec/enrol.py` behind three verbs. `lanes invite` writes `invites/<id>.json` — the code's
|
|
330
|
+
hash, never the code, which is printed once and only to a terminal — and can mail the welcome kit and mint the
|
|
331
|
+
tailnet invite, each through its own optional Lanes key file. `lanes enrol approve|deny` calls the service's own
|
|
332
|
+
`decide()`, recording the SSH client address as the approver (over SSH every login is the same account; the phone
|
|
333
|
+
path records the tailnet login); `lanes enrol mode` pins request mode. `lanes offboard` reverses an enrolment in a
|
|
334
|
+
fixed order — key, device record, the machine's web login tokens (those `web-tokens.json` records for the device, its
|
|
335
|
+
windows and the views minted with it as their machine: Zellij 0.45.1 names tokens `token_<n>`, so the record decides),
|
|
336
|
+
view session — stopping at the first two when they fail, because a view ended while its device file or key remains is
|
|
337
|
+
brought back.
|