@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
@@ -7,21 +7,44 @@
7
7
  # ./install.sh --check verify terminal, SSH path, key and server (changes nothing)
8
8
  # ./install.sh --uninstall remove what the installer added (your SSH key and ~/Lanes Uploads are kept)
9
9
  #
10
- # Options
11
- # --device NAME name of this machine's view session (default: <user>-<hostname>; kept on re-install)
10
+ # Options — remembered in ~/.config/lanes/install-options: a re-run (an update) repeats the last install's choices,
11
+ # and a flag given again overrides the remembered value (`lanes-launch update`: the newest npm package, run that way)
12
+ # --device NAME name of this machine's view session (default: <user>-<hostname>)
12
13
  # --key PATH SSH private key to use (default: the one already configured, else ~/.ssh/id_ed25519,
13
14
  # else a new dedicated key ~/.ssh/lanes_ed25519)
14
- # --site FILE server profile (default: site/site.conf, else the remembered client profile)
15
- # --link symlink the client into this checkout instead of copying it (for working on the harness)
16
- # --coder-launch also provide the command name `coder-launch` (an existing one is kept as coder-launch-legacy)
17
- # --no-desktop skip desktop integration (Lanes.app / .desktop entry / Hyprland key binding)
18
- # --no-uploads skip the uploads watch folder and the upload helper service
15
+ # --site FILE server profile (default: the remembered one, else site/site.conf, else the installed copy)
16
+ # --coder-launch also provide the command name `coder-launch` (an existing one is kept as coder-launch-legacy);
17
+ # --no-coder-launch takes it away again
18
+ # --no-desktop no desktop integration (Lanes.app / .desktop entry / Hyprland key binding): what an earlier
19
+ # install added is removed; --desktop: back on
20
+ # --no-uploads no uploads watch-folder agent and no upload helper service: what an earlier install added is
21
+ # stopped and removed (~/Lanes Uploads and the files in it stay); --uploads: back on
19
22
  # --palette give the lanes window the cockpit's own Ghostty palette instead of your everyday theme
20
- # (off by default; a re-run without it puts the inherited look back)
23
+ # (off by default; --no-palette puts the inherited look back)
24
+ # Not remembered
25
+ # --invite CODE join with an invite code when the server does not know this machine's key yet (or: LANES_INVITE)
26
+ # --resume keep waiting on this machine's pending access request (~/.config/lanes/enrol-request)
27
+ # --link symlink the client into this checkout instead of copying it (for working on the harness)
21
28
  # --yes never prompt (a new key is then created WITHOUT a passphrase)
22
29
  #
30
+ # With a key that has a passphrase (Linux), the upload helper service logs in through the SSH agent the systemd user
31
+ # manager holds, looked up again before every login: the installer hands this terminal's agent over and adds a marked
32
+ # block to your login profile that does it at every local login (removed by --uninstall and --no-uploads; a profile
33
+ # it cannot write is left as it was, the line printed). An agent your desktop session hands over itself works too.
34
+ #
35
+ # A server with a Tailscale name (the profile's LANES_SERVER_NAME ends in .ts.net) is tried only once this machine is
36
+ # in the tailnet: no Tailscale -> the install command for it; logged out -> the installer opens `tailscale login` and
37
+ # waits up to 120 s (LANES_TAILSCALE_WAIT) for this machine to connect, with a countdown. --check only looks.
38
+ # The SSH alias `lanes` names the server by that name when this machine resolves it (MagicDNS), its address as lanes-ip.
39
+ #
40
+ # Joining: when the server does not know this machine's key, the site's enrolment service (https://<LANES_SERVER_NAME>
41
+ # :8443/enrol/) authorizes it — at once for an invite code; without one, on a terminal, the installer offers to ask the
42
+ # site's members and waits up to 10 min (LANES_ENROL_WAIT) for an approval. The client never edits authorized_keys.
43
+ # No service, a refused code, or no code and no terminal: the public key is printed for the admin (exit 3), as before.
44
+ #
23
45
  # Exit status: 0 ready to use · 3 installed, but this machine's key is not authorized on the server yet ·
24
- # 4 installed, but the server is not reachable (Tailscale?) or did not answer in time (busy?) · 1 error
46
+ # 4 installed, but not in the tailnet, or the server is not reachable or did not answer in time (busy?) ·
47
+ # 1 error
25
48
  set -eu
26
49
  here=$(cd "$(dirname "$0")" && pwd)
27
50
  if [ "${1:-}" = --server ]; then
@@ -37,8 +60,8 @@ stamp=$(date -u +%Y%m%dT%H%M%SZ)
37
60
  label=ai.beyondwork.lanes-upload
38
61
  helper_label=ai.beyondwork.lanes-helper
39
62
 
40
- mode=install device= key= site_file=$here/site/site.conf link=0 coder_launch=0 desktop=1 uploads=1 assume_yes=0 palette=0
41
- [ -r "$site_file" ] || site_file=$config/client-site.conf
63
+ # Empty = not given on the command line: resolve_options fills it from install-options, else the default.
64
+ mode=install device= key= site_file= link=0 coder_launch= desktop= uploads= assume_yes=0 palette= invite= resume=0
42
65
  while [ $# -gt 0 ]; do
43
66
  case "$1" in
44
67
  --check) mode=check ;;
@@ -46,17 +69,24 @@ while [ $# -gt 0 ]; do
46
69
  --device) device=${2:?--device NAME}; shift ;;
47
70
  --key) key=${2:?--key PATH}; shift ;;
48
71
  --site) site_file=${2:?--site FILE}; shift ;;
72
+ --invite) invite=${2:?--invite CODE}; shift ;;
73
+ --resume) resume=1 ;;
49
74
  --link) link=1 ;;
50
75
  --coder-launch) coder_launch=1 ;;
76
+ --no-coder-launch) coder_launch=0 ;;
77
+ --desktop) desktop=1 ;;
51
78
  --no-desktop) desktop=0 ;;
79
+ --uploads) uploads=1 ;;
52
80
  --no-uploads) uploads=0 ;;
53
81
  --palette) palette=1 ;;
82
+ --no-palette) palette=0 ;;
54
83
  --yes|-y) assume_yes=1 ;;
55
- -h|--help) sed -n '2,24p' "$0"; exit 0 ;;
84
+ -h|--help) sed -n '2,47p' "$0"; exit 0 ;;
56
85
  *) echo "install.sh: unknown option $1 (see --help)" >&2; exit 1 ;;
57
86
  esac
58
87
  shift
59
88
  done
89
+ [ -n "$invite" ] || invite=${LANES_INVITE:-}
60
90
 
61
91
  say() { printf ' %s\n' "$*"; }
62
92
  head_() { printf '\n%s\n' "$*"; }
@@ -64,6 +94,93 @@ warn() { printf ' ! %s\n' "$*" >&2; }
64
94
  die() { printf 'install.sh: %s\n' "$*" >&2; exit 1; }
65
95
  tilde() { case "$1" in "$HOME"/*) printf '~/%s' "${1#"$HOME"/}" ;; *) printf '%s' "$1" ;; esac; }
66
96
 
97
+ # ---- remembered options ---------------------------------------------------------------------------------------------
98
+ # $config/install-options holds every choice of the last install (KEY=VALUE, mode 0600, read with sed — never sourced
99
+ # or executed), so a plain re-run, and with it every update, repeats that install; a flag on the command line wins and
100
+ # becomes the new remembered value; --check only reads it; --uninstall removes it. A newer SCHEMA is neither read nor
101
+ # overwritten. SITE holds a profile bundled with the running tree by NAME (it resolves against the tree that runs, so
102
+ # an update reads the new package's copy and its pinned host key), any other profile by absolute path.
103
+ options=$config/install-options
104
+ options_schema=1
105
+ opt() { sed -n "s/^$1=//p" "$options" 2>/dev/null | tail -n 1; }
106
+ opt_flag() { # opt_flag NAME -> 0, 1 or nothing
107
+ ov=$(opt "$1")
108
+ case "$ov" in ''|0|1) printf '%s' "$ov" ;; *) warn "install-options: ignored $1 (not 0 or 1)" ;; esac
109
+ }
110
+ read_options() { # -> o_*; returns 1 when there is no file to read
111
+ o_device= o_key= o_site= o_desktop= o_uploads= o_palette= o_coder_launch= options_keep=0
112
+ [ -r "$options" ] || return 1
113
+ case "$(opt SCHEMA)" in ''|"$options_schema") ;; *)
114
+ warn "$(tilde "$options") was written by a newer installer: not read, and left as it is"
115
+ options_keep=1; return 0 ;;
116
+ esac
117
+ o_device=$(opt DEVICE)
118
+ case "$o_device" in *[!a-z0-9_.-]*|.*) warn 'install-options: ignored DEVICE (not a device name)'; o_device= ;; esac
119
+ o_key=$(opt KEY)
120
+ case "$o_key" in ''|/*|'~/'*) ;; *) warn 'install-options: ignored KEY (not an absolute path)'; o_key= ;; esac
121
+ o_site=$(opt SITE)
122
+ case "$o_site" in ''|/*) ;; .*|*/*|*[!A-Za-z0-9_.-]*) warn 'install-options: ignored SITE (neither a profile name nor an absolute path)'; o_site= ;; esac
123
+ o_desktop=$(opt_flag DESKTOP); o_uploads=$(opt_flag UPLOADS)
124
+ o_palette=$(opt_flag PALETTE); o_coder_launch=$(opt_flag CODER_LAUNCH)
125
+ return 0
126
+ }
127
+ # A client installed before install-options existed left its choices behind as files: read them from there once.
128
+ # Desktop and uploads read as off only when NOTHING of that integration is left (desktop_left / uploads_left): an
129
+ # option that is off takes away what an earlier install added, so an "off" inferred from one deleted piece would take
130
+ # away the rest — a key binding still in use, a running upload helper.
131
+ adopt_options() {
132
+ if [ -z "$palette" ] && [ -e "$config/ghostty-palette.conf" ]; then palette=1; fi
133
+ if [ -z "$coder_launch" ]; then
134
+ case "$(readlink "$bin/coder-launch" 2>/dev/null || :)" in */lanes/client/*|"$here"/client/*) coder_launch=1 ;; esac
135
+ fi
136
+ if [ -z "$desktop" ] && ! desktop_left; then desktop=0; fi
137
+ if [ -z "$uploads" ] && ! uploads_left; then uploads=0; fi
138
+ }
139
+ # Command line > install-options > (an older install's files) > defaults. Sets site_pick: the explicitly chosen
140
+ # profile (command line or remembered), empty when the default chain picked it.
141
+ resolve_options() {
142
+ adopting=0
143
+ if ! read_options && [ -r "$config/device" ]; then adopting=1; fi
144
+ [ -n "$device" ] || device=$o_device
145
+ [ -n "$key" ] || key=$o_key
146
+ site_pick=$site_file
147
+ if [ -z "$site_file" ] && [ -n "$o_site" ]; then
148
+ case "$o_site" in /*) remembered_site=$o_site ;; *) remembered_site=$here/site/$o_site/site.conf ;; esac
149
+ if [ -r "$remembered_site" ]; then site_file=$remembered_site; site_pick=$remembered_site
150
+ else warn "the remembered site profile ($o_site) is not here: using the default profile"; fi
151
+ fi
152
+ if [ -z "$site_file" ]; then site_file=$here/site/site.conf; [ -r "$site_file" ] || site_file=$config/client-site.conf; fi
153
+ [ -n "$desktop" ] || desktop=$o_desktop
154
+ [ -n "$uploads" ] || uploads=$o_uploads
155
+ [ -n "$palette" ] || palette=$o_palette
156
+ [ -n "$coder_launch" ] || coder_launch=$o_coder_launch
157
+ if [ "$adopting" = 1 ]; then adopt_options; fi
158
+ : "${desktop:=1}" "${uploads:=1}" "${palette:=0}" "${coder_launch:=0}"
159
+ }
160
+ site_memory() { # -> the SITE value to remember, or nothing (load_site has proven $site_pick readable)
161
+ [ -n "$site_pick" ] || return 0
162
+ case "$site_pick" in /*) sm=$site_pick ;; *) sm=$(cd "$(dirname "$site_pick")" && pwd)/$(basename "$site_pick") ;; esac
163
+ case "$sm" in "$config/client-site.conf") return 0 ;; esac # the installed copy itself
164
+ case "$sm" in "$here"/site/*/site.conf)
165
+ sm_name=${sm#"$here"/site/}; sm_name=${sm_name%/site.conf}
166
+ case "$sm_name" in ''|.*|*/*|*[!A-Za-z0-9_.-]*) ;; *) printf '%s' "$sm_name"; return 0 ;; esac ;;
167
+ esac
168
+ printf '%s' "$sm"
169
+ }
170
+ write_options() {
171
+ [ "$options_keep" = 0 ] || return 0
172
+ sm=$(site_memory)
173
+ rm -f "$options.new"
174
+ ( umask 077
175
+ { printf '%s\n' '# lanes client install options: written by install.sh on every install and read by the next one.' \
176
+ '# A flag on the command line overrides a value here; install.sh --uninstall removes this file. Never executed.' \
177
+ "SCHEMA=$options_schema" "DEVICE=$device" "KEY=$key"
178
+ if [ -n "$sm" ]; then printf 'SITE=%s\n' "$sm"; fi
179
+ printf '%s\n' "DESKTOP=$desktop" "UPLOADS=$uploads" "PALETTE=$palette" "CODER_LAUNCH=$coder_launch"
180
+ } > "$options.new" )
181
+ chmod 600 "$options.new"; mv "$options.new" "$options"
182
+ }
183
+
67
184
  # ---- site -----------------------------------------------------------------------------------------------------------
68
185
  site_value() { sed -n "s/^$1=//p" "$site_file" 2>/dev/null | head -n 1; }
69
186
  load_site() {
@@ -71,11 +188,16 @@ load_site() {
71
188
  case "$(site_value LANES_SITE_SCHEMA)" in ''|1) ;; *) die "site file $site_file has a newer schema than this installer: update the checkout" ;; esac
72
189
  server_host=$(site_value LANES_SERVER_HOST); server_user=$(site_value LANES_SERVER_USER)
73
190
  server_alias=$(site_value LANES_SERVER_ALIAS); server_name=$(site_value LANES_SERVER_NAME)
191
+ site_name=$(site_value LANES_SITE); site_code=$(site_value LANES_SITE_CODE)
74
192
  server_public=$(site_value LANES_SERVER_PUBLIC)
75
193
  [ -n "$server_public" ] || server_public=$(sed -n 's/^LANES_SERVER_PUBLIC=//p' "$config/site.cache" 2>/dev/null | head -n 1)
76
194
  for value in "$server_host" "$server_user" "$server_alias"; do
77
195
  case "$value" in ''|*[!A-Za-z0-9_.:-]*) die "site file $site_file: LANES_SERVER_HOST/USER/ALIAS missing or malformed" ;; esac
78
196
  done
197
+ # (the name goes into an SSH stanza, a URL and curl's --resolve; the code is compared with an invite's prefix)
198
+ case "$server_name" in *[!A-Za-z0-9.-]*) die "site file $site_file: LANES_SERVER_NAME malformed" ;; esac
199
+ case "$site_code" in *[!A-Za-z]*|?????????*) die "site file $site_file: LANES_SITE_CODE malformed (1-8 letters)" ;; esac
200
+ case "$site_name" in *[!A-Za-z0-9_.-]*) site_name= ;; esac
79
201
  case "$server_public" in *[!A-Za-z0-9_.:-]*) server_public= ;; esac
80
202
  pinned=$(dirname "$site_file")/known_hosts
81
203
  [ "$site_file" != "$config/client-site.conf" ] || pinned=$config/client-known-hosts
@@ -97,6 +219,8 @@ choose_key() {
97
219
  key=$(awk '$1 == "IdentityFile" { print $2; exit }' "$HOME/.ssh/config.d/lanes.conf")
98
220
  fi
99
221
  case "$key" in "~/"*) key=$HOME/${key#"~/"} ;; esac
222
+ # (absolute, so the SSH stanza and install-options name the same file from any working directory)
223
+ case "$key" in ''|/*) ;; *) if [ -d "$(dirname "$key")" ]; then key=$(cd "$(dirname "$key")" && pwd)/$(basename "$key"); fi ;; esac
100
224
  if [ -z "$key" ]; then
101
225
  if [ -r "$HOME/.ssh/id_ed25519" ]; then key=$HOME/.ssh/id_ed25519; else key=$HOME/.ssh/lanes_ed25519; fi
102
226
  fi
@@ -122,17 +246,43 @@ ssh_knows() { ssh -F /dev/null -o "$1" -G localhost >/dev/null 2>&1; }
122
246
  optional() { # optional <MARKER> <yes|no>: keep or drop the template lines carrying #@MARKER@
123
247
  if [ "$2" = yes ]; then sed "s/^#@$1@ \{0,1\}//"; else grep -v "^#@$1@" || :; fi
124
248
  }
249
+ # The alias names the server by its MagicDNS name when THIS machine resolves it — a server that gets a new tailnet
250
+ # address keeps working — with the address as a second alias, lanes-ip, that lanes-launch falls back to. A machine
251
+ # without MagicDNS (or with `tailscale up --accept-dns=false`) gets the address, as before. Both aliases pin ONE host
252
+ # key: known_hosts is keyed by HostKeyAlias (the profile's alias), never by the name or the address.
253
+ name_resolves() { # NAME -> 0 when this machine's resolver answers for it (bounded: a resolver that hangs is a no)
254
+ deadline=$(( $(date +%s) + 5 ))
255
+ if command -v getent >/dev/null 2>&1; then
256
+ bounded getent hosts "$1" && [ -n "$out" ]
257
+ elif command -v dscacheutil >/dev/null 2>&1; then # macOS has no getent
258
+ bounded dscacheutil -q host -a name "$1" || :
259
+ case "$out" in *ip_address:*) return 0 ;; esac
260
+ return 1
261
+ else
262
+ return 1
263
+ fi
264
+ }
265
+ ssh_target() { # -> ssh_hostname (HostName of `lanes`), ip_alias (HostName of lanes-ip; empty = none), magicdns_note
266
+ ssh_hostname=$server_host ip_alias= magicdns_note=
267
+ st_name=${server_name%.}
268
+ if [ -z "$st_name" ] || [ "$st_name" = "$server_host" ]; then return 0; fi
269
+ if name_resolves "$st_name"; then ssh_hostname=$st_name; ip_alias=$server_host
270
+ else magicdns_note=" (MagicDNS does not resolve $st_name here)"; fi
271
+ }
125
272
  render_stanza() { # -> stdout
126
273
  identity=$(tilde "$key")
127
274
  public=no; [ -n "$server_public" ] && public=yes
128
275
  obscure=no; ssh_knows ObscureKeystrokeTiming=no && obscure=yes
129
276
  keychain=no; ssh_knows UseKeychain=yes && keychain=yes
130
- sed -e "s|@HOST@|$server_host|g" -e "s|@USER@|$server_user|g" -e "s|@ALIAS@|$server_alias|g" \
277
+ ipalias=no noipalias=yes; if [ -n "$ip_alias" ]; then ipalias=yes noipalias=no; fi
278
+ sed -e "s|@HOST@|$ssh_hostname|g" -e "s|@IP@|$ip_alias|g" -e "s|@USER@|$server_user|g" -e "s|@ALIAS@|$server_alias|g" \
131
279
  -e "s|@IDENTITY@|$identity|g" -e "s|@PUBLIC_HOST@|$server_public|g" "$src/ssh.stanza.in" |
280
+ optional IPALIAS "$ipalias" | optional NOIPALIAS "$noipalias" |
132
281
  optional PUBLIC "$public" | optional OBSCURE "$obscure" | optional KEYCHAIN "$keychain"
133
282
  }
134
283
  install_ssh() {
135
284
  mkdir -p "$HOME/.ssh/config.d"; chmod 700 "$HOME/.ssh" "$HOME/.ssh/config.d"
285
+ ssh_target
136
286
  # Guard: our file is parsed for EVERY ssh destination. If the configuration parsed before and does not parse
137
287
  # after, put the previous file back — never leave a machine whose ssh is broken for other hosts.
138
288
  parsed_before=no; ssh -G lanes-selftest.invalid >/dev/null 2>&1 && parsed_before=yes
@@ -155,7 +305,9 @@ install_ssh() {
155
305
  die "the generated SSH stanza does not parse on this machine — put the previous one back. ssh said: $problem"
156
306
  fi
157
307
  rm -f "$stanza.prev"
158
- say "ssh: host alias 'lanes' -> $server_user@$server_host, key $(tilde "$key")$([ -n "$server_public" ] && echo ', fallback lanes-public')"
308
+ if [ -n "$ip_alias" ]; then sv_path="$server_user@$ssh_hostname (fallback lanes-ip -> $ip_alias)"
309
+ else sv_path="$server_user@$ssh_hostname$magicdns_note"; fi
310
+ say "ssh: host alias 'lanes' -> $sv_path, key $(tilde "$key")$([ -n "$server_public" ] && echo ', fallback lanes-public')"
159
311
  pin_host_key
160
312
  }
161
313
  # The server key is PINNED from the package (site/known_hosts): a new machine never trusts on first use.
@@ -198,6 +350,7 @@ install_client() {
198
350
  say "commands: lanes-launch, lanes-window, lanes-upload in $(tilde "$bin") ($([ "$link" = 1 ] && echo "linked to this checkout" || echo "copied to $(tilde "$target")"))"
199
351
  printf '%s\n' "$device" > "$config/device"
200
352
  say "device (view session) name: $device"
353
+ write_options
201
354
  # (a non-interactive ssh session has a minimal PATH: ask the login shell before complaining)
202
355
  login_path=$("${SHELL:-/bin/sh}" -lc 'printf %s "$PATH"' 2>/dev/null || :)
203
356
  case ":$PATH:$login_path:" in *":$bin:"*) ;; *)
@@ -210,6 +363,13 @@ install_client() {
210
363
  mv "$bin/coder-launch" "$bin/coder-launch-legacy"; say 'kept the previous coder-launch as coder-launch-legacy'
211
364
  fi
212
365
  ln -sfn "$target/lanes-launch" "$bin/coder-launch"; say '`coder-launch` now starts the lanes client'
366
+ else
367
+ # Off (--no-coder-launch, or remembered off): a coder-launch this installer provided goes, the kept one returns.
368
+ case "$(readlink "$bin/coder-launch" 2>/dev/null || :)" in */lanes/client/*|"$here"/client/*)
369
+ rm -f "$bin/coder-launch"
370
+ if [ -e "$bin/coder-launch-legacy" ]; then mv "$bin/coder-launch-legacy" "$bin/coder-launch"; say 'restored the previous coder-launch'; fi
371
+ say '`coder-launch` no longer starts the lanes client (--coder-launch brings it back)' ;;
372
+ esac
213
373
  fi
214
374
  # Ghostty: a SEPARATE instance with its own config file; the everyday Ghostty config is only read, never written.
215
375
  { echo "# Generated by the lanes installer — edit the sources in client/ghostty/ and re-run it."
@@ -460,6 +620,89 @@ install_macos_helper_app() {
460
620
  mv "$built" "$helper_app"
461
621
  rmdir "$build"
462
622
  }
623
+ # ---- SSH agent hand-off (Linux, a key with a passphrase) ------------------------------------------------------------
624
+ # A user unit cannot ask for a passphrase, so the helper service logs in through the agent the systemd user manager
625
+ # holds — looked up again before every login (lanes-launch `manager_agent`), because default.target can start the
626
+ # service before a desktop session hands its agent to the manager. The installer hands this terminal's agent over now
627
+ # and appends ONE marked block to the file this account's login shell reads at session start, which does the same at
628
+ # every local login: never a remote login's forwarded agent (SSH_CONNECTION), and a no-op while the manager already
629
+ # has that agent (so login shells that tools spawn do not restart the helper; the helper takes the manager's agent at
630
+ # its next login anyway — the try-restart only saves that wait). The markers keep a re-run from adding a second block
631
+ # and let --uninstall and --no-uploads remove exactly it.
632
+ agent_begin='# >>> lanes agent hand-off >>>'
633
+ agent_end='# <<< lanes agent hand-off <<<'
634
+ agent_profile() { # -> the login file of $SHELL; fails for fish (not POSIX syntax; that block is not written)
635
+ case "${SHELL:-}" in
636
+ */fish) return 1 ;;
637
+ */zsh) printf '%s' "${ZDOTDIR:-$HOME}/.zprofile" ;;
638
+ */bash) # bash reads the FIRST of these that exists, ~/.profile only when neither other one does
639
+ for ap in "$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.profile"; do
640
+ if [ -e "$ap" ]; then printf '%s' "$ap"; return 0; fi
641
+ done
642
+ printf '%s' "$HOME/.profile" ;;
643
+ *) printf '%s' "$HOME/.profile" ;;
644
+ esac
645
+ }
646
+ agent_block() {
647
+ printf '%s\n' "$agent_begin" \
648
+ "# The lanes upload helper service (a systemd user unit) logs in through this login's SSH agent: hand it to the" \
649
+ '# user manager once per agent. Added by the lanes installer; `install.sh --uninstall` removes this block.' \
650
+ 'if [ -n "${SSH_AUTH_SOCK:-}" ] && [ -z "${SSH_CONNECTION:-}" ] && command -v systemctl >/dev/null 2>&1 &&' \
651
+ ' ! systemctl --user show-environment 2>/dev/null | grep -qxF "SSH_AUTH_SOCK=$SSH_AUTH_SOCK"; then' \
652
+ ' systemctl --user import-environment SSH_AUTH_SOCK >/dev/null 2>&1 &&' \
653
+ ' systemctl --user try-restart lanes-helper.service >/dev/null 2>&1 || :' \
654
+ 'fi' "$agent_end"
655
+ }
656
+ # The login profile is the user's own file (a read-only dotfiles checkout, root-owned, chattr +i, a full disk): every
657
+ # write to it is backed up first and made in a subshell, so one that fails costs a warning, never the install or the
658
+ # uninstall, and one that fails part-way is put back from the backup — in place, so a profile that is a symlink into
659
+ # a dotfiles checkout stays one. append_agent_block returns 0 only when the whole block was written.
660
+ append_agent_block() { # FILE: append the block (after a newline when FILE does not end in one)
661
+ if [ ! -e "$1" ]; then
662
+ ( agent_block > "$1" ) 2>/dev/null && return 0
663
+ if [ ! -L "$1" ]; then rm -f "$1"; fi; return 1 # (a new file written part-way goes again)
664
+ fi
665
+ if ! cp -p "$1" "$1.pre-lanes.$stamp" 2>/dev/null; then rm -f "$1.pre-lanes.$stamp"; return 1; fi
666
+ ( { if [ -s "$1" ] && [ -n "$(tail -c 1 "$1")" ]; then printf '\n'; fi; agent_block; } >> "$1" ) 2>/dev/null && return 0
667
+ undo_profile_write "$1" "$1.pre-lanes.$stamp"; return 1
668
+ }
669
+ undo_profile_write() { # FILE BACKUP: a write to FILE failed — FILE gets the backup's content back, the backup goes
670
+ if cmp -s "$2" "$1" || ( cat "$2" > "$1" ) 2>/dev/null; then rm -f "$2" || :; return 0; fi
671
+ warn "$(tilde "$1") was changed part-way and could not be put back: its earlier content is in $(tilde "$2")"
672
+ }
673
+ hand_agent_over() {
674
+ if ! ap=$(agent_profile); then
675
+ warn 'your key has a passphrase and your shell is fish: add `systemctl --user import-environment SSH_AUTH_SOCK; systemctl --user try-restart lanes-helper` to your fish login (nothing is written for fish)'
676
+ later='your fish login (the line above)'
677
+ elif { [ -f "$ap" ] && grep -qxF "$agent_begin" "$ap"; } || append_agent_block "$ap"; then
678
+ later="$(tilde "$ap") (block 'lanes agent hand-off')"
679
+ else
680
+ warn "your key has a passphrase and $(tilde "$ap") could not be written (left as it was): add \`systemctl --user import-environment SSH_AUTH_SOCK; systemctl --user try-restart lanes-helper\` to your login yourself"
681
+ later='your login (the line above)'
682
+ fi
683
+ if [ -n "${SSH_AUTH_SOCK:-}" ] && [ -z "${SSH_CONNECTION:-}" ]; then
684
+ systemctl --user import-environment SSH_AUTH_SOCK >/dev/null 2>&1 || :
685
+ say "upload helper: your key has a passphrase — handed this terminal's SSH agent to the service now; $later hands it over at every local login"
686
+ else
687
+ say "upload helper: your key has a passphrase — $later hands your SSH agent to the service from your next local login on; until then the open window serves the upload box"
688
+ fi
689
+ }
690
+ remove_agent_hand_off() { # [uninstall|off]: the reason, which names the backup (.pre-lanes-<reason>.<UTC>)
691
+ for ap in "$HOME/.profile" "$HOME/.bash_profile" "$HOME/.bash_login" "${ZDOTDIR:-$HOME}/.zprofile"; do
692
+ if [ ! -f "$ap" ] || ! grep -qxF "$agent_begin" "$ap"; then continue; fi
693
+ if ! grep -qxF "$agent_end" "$ap"; then warn "$(tilde "$ap"): the lanes agent hand-off block has lost its end marker — left as it is"; continue; fi
694
+ backup=$ap.pre-lanes-${1:-uninstall}.$stamp
695
+ if ! cp -p "$ap" "$backup" 2>/dev/null; then
696
+ rm -f "$backup" # (a backup that did not fit: nothing else touched)
697
+ elif ( awk -v b="$agent_begin" -v e="$agent_end" '$0 == b { skip = 1 } !skip { print } $0 == e { skip = 0 }' \
698
+ "$backup" > "$ap" ) 2>/dev/null; then
699
+ say "removed the SSH agent hand-off from $(tilde "$ap") (backup kept)"; continue
700
+ else
701
+ undo_profile_write "$ap" "$backup"
702
+ fi
703
+ warn "$(tilde "$ap") could not be written: the lanes agent hand-off block is still in it — delete its lines from '$agent_begin' to '$agent_end' yourself"
704
+ done
705
+ }
463
706
  install_helper_service() {
464
707
  if ! helper_supported; then say 'upload helper: the open lanes window serves the upload box (this client has no service mode)'; return 0; fi
465
708
  mkdir -p "$state"
@@ -486,13 +729,14 @@ install_helper_service() {
486
729
  '[Service]' "ExecStart=/bin/sh $bin/lanes-launch --helper $device --service" 'Restart=always' 'RestartSec=5' \
487
730
  '[Install]' 'WantedBy=default.target' > "$units/lanes-helper.service"
488
731
  systemctl --user daemon-reload >/dev/null 2>&1 || :
732
+ # A user unit has no terminal to ask for a passphrase and cannot be handed SSH_AUTH_SOCK by this script
733
+ # (PassEnvironment is not valid in user units): with a protected key the service logs in through the agent
734
+ # the user manager knows. (-P '': succeeds only for a key without a passphrase.) Before the restart below,
735
+ # so the restarted service starts with the agent.
736
+ ssh-keygen -y -P '' -f "$key" >/dev/null 2>&1 || hand_agent_over
489
737
  # enable + restart (not `enable --now`): an update must run the NEW client, a first install connects once.
490
738
  systemctl --user enable lanes-helper.service >/dev/null 2>&1 || :
491
739
  systemctl --user restart lanes-helper.service >/dev/null 2>&1 || :
492
- # A user unit has no terminal to ask for a passphrase and cannot be handed SSH_AUTH_SOCK by this script
493
- # (PassEnvironment is not valid in user units). (-P '': succeeds only for a key without a passphrase.)
494
- ssh-keygen -y -P '' -f "$key" >/dev/null 2>&1 ||
495
- warn "your key has a passphrase: the helper service can only log in through an agent the systemd user manager knows (\`systemctl --user import-environment SSH_AUTH_SOCK\` at session start, then \`systemctl --user restart lanes-helper\`). Until then the open window serves the upload box."
496
740
  else
497
741
  say 'upload helper: no launchd or systemd here — the open lanes window serves the upload box'; return 0
498
742
  fi
@@ -504,18 +748,254 @@ mtime() { stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null; }
504
748
  # --check: is anything serving the upload box? Whoever does (the service, or a window's own helper when there is no
505
749
  # service) touches helper.alive on every READY/PING line from the server (one every 5 s); serving = younger than 90 s.
506
750
  check_helper() {
751
+ # (uploads off: no service is meant to run; one that still does is what an install with --no-uploads takes away —
752
+ # named with the flag: off may come from this command line while install-options still says UPLOADS=1)
753
+ if [ "$uploads" = 0 ]; then
754
+ if helper_loaded; then warn 'upload helper: uploads are off, but the service still runs — ./install.sh --no-uploads takes it away'
755
+ else say 'upload helper: off (--uploads turns it on) — an open lanes window serves the upload box itself'; fi
756
+ return 0
757
+ fi
507
758
  # (a client without the service side never writes helper.alive: there is nothing to measure, and nothing wrong)
508
759
  if ! helper_supported; then say 'upload helper: an open lanes window serves the upload box itself (this client has no service mode)'; return 0; fi
509
760
  service='no service (./install.sh adds it)'; if helper_loaded; then service='service running'; fi
510
761
  seen=$(mtime "$state/helper.alive") || seen=
511
762
  if [ -z "$seen" ] && helper_loaded; then warn "upload helper: $service, but it has never reached the server (log: $(helper_log))"; return 0; fi
512
- # (no service and no heartbeat may be a choice: --no-uploads, or no window opened yet)
763
+ # (no service and no heartbeat: no launchd or systemd here, or no window opened yet)
513
764
  if [ -z "$seen" ]; then say "upload helper: $service — an open lanes window serves the upload box itself"; return 0; fi
514
765
  age=$(( $(date +%s) - seen ))
515
766
  if [ "$age" -lt 90 ]; then say "ok upload helper: $service; last heard from the server ${age}s ago"
516
767
  else warn "upload helper: $service; last heard from the server ${age}s ago (log: $(helper_log))"; fi
517
768
  }
518
769
 
770
+ # ---- an integration turned off --------------------------------------------------------------------------------------
771
+ # --no-desktop / --no-uploads — given, or remembered — on a machine where an earlier install added that integration
772
+ # take it away again, as --no-coder-launch and --no-palette do: the installed state follows the remembered value, so no
773
+ # update leaves a helper running, or a key binding in place, that the options say is off. --uninstall runs the same.
774
+ # desktop_off|uploads_off off only what is there: a machine that never had it is not touched
775
+ # desktop_off|uploads_off uninstall every agent and unit, also one whose file is already gone
776
+ # -> off_gone: what was taken away, for one line ('' = nothing was there). The watch folder and its files always stay.
777
+ off_add() { off_gone="${off_gone:+$off_gone, }$1"; }
778
+ # The lanes lines of a Hyprland config, and the blank line install_desktop put before its comment: the file is put back
779
+ # as it was before, so turning the desktop off and on again never piles up blank lines.
780
+ hypr_strip='index($0, c) || index($0, r) || index($0, s) { if (index($0, c)) held = 0; next }
781
+ held { print "" } { held = ($0 == "") } !held { print } END { if (held) print "" }'
782
+ hypr_require='require("hypr.lanes")' hypr_source='source = ~/.config/hypr/lanes.conf'
783
+ # Is any piece of that integration on this machine — anything desktop_off / uploads_off would take away (the watch
784
+ # folder's README counts for uploads: a 1.0.x install always wrote it)? adopt_options reads "off" only from a no.
785
+ desktop_left() {
786
+ for piece in "$HOME/Applications/Lanes.app" "$HOME/Applications/Lanes Upload.app" \
787
+ "$HOME/.local/share/applications/lanes.desktop" "$HOME/.config/hypr/lanes.lua" "$HOME/.config/hypr/lanes.conf"; do
788
+ [ ! -e "$piece" ] || return 0
789
+ done
790
+ for main in "$HOME/.config/hypr/hyprland.lua" "$HOME/.config/hypr/hyprland.conf"; do
791
+ if [ -f "$main" ] && grep -q -F -e "$hypr_require" -e "$hypr_source" "$main"; then return 0; fi
792
+ done
793
+ return 1
794
+ }
795
+ uploads_left() {
796
+ left_units=${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user
797
+ for piece in "$(cat "$config/uploads-dir" 2>/dev/null || echo "$HOME/Lanes Uploads")/README.txt" \
798
+ "$HOME/Library/LaunchAgents/$label.plist" "$HOME/Library/LaunchAgents/$helper_label.plist" \
799
+ "$HOME/Applications/Lanes Upload.app" "$HOME/Applications/Lanes Helper.app" \
800
+ "$left_units/lanes-upload.path" "$left_units/lanes-upload.service" "$left_units/lanes-helper.service"; do
801
+ [ ! -e "$piece" ] || return 0
802
+ done
803
+ return 1
804
+ }
805
+ desktop_off() {
806
+ off_gone=
807
+ if [ "$os" = Darwin ]; then
808
+ for app in "$HOME/Applications/Lanes.app" "$HOME/Applications/Lanes Upload.app"; do
809
+ if [ -e "$app" ]; then rm -rf "$app"; off_add "$(tilde "$app")"; fi
810
+ done
811
+ return 0
812
+ fi
813
+ entry=$HOME/.local/share/applications/lanes.desktop
814
+ if [ -e "$entry" ]; then rm -f "$entry"; off_add 'the Lanes launcher entry'; fi
815
+ hypr=$HOME/.config/hypr hypr_kept=0 hypr_gone=0
816
+ for main in "$hypr/hyprland.lua" "$hypr/hyprland.conf"; do
817
+ [ -f "$main" ] && grep -q -F -e "$hypr_require" -e "$hypr_source" "$main" || continue
818
+ # The user's own file, written as the login profile is (backup first, in a subshell, put back on failure): one
819
+ # that cannot be written costs a warning, never the run — and keeps the bind file it still loads.
820
+ backup=$main.pre-lanes-$1.$stamp
821
+ if ! cp -p "$main" "$backup" 2>/dev/null; then
822
+ rm -f "$backup"
823
+ elif ( awk -v c='lanes workspace server (remove this line' -v r="$hypr_require" \
824
+ -v s="$hypr_source" "$hypr_strip" "$backup" > "$main" ) 2>/dev/null; then
825
+ off_add "the lanes line in $(tilde "$main") (backup $(basename "$backup"))"; hypr_gone=1; continue
826
+ else
827
+ undo_profile_write "$main" "$backup"
828
+ fi
829
+ hypr_kept=1
830
+ warn "Hyprland: $(tilde "$main") could not be written — it still loads the lanes key bindings; delete its lanes line yourself"
831
+ done
832
+ if [ "$hypr_kept" = 0 ]; then
833
+ for piece in "$hypr/lanes.lua" "$hypr/lanes.conf"; do
834
+ if [ -e "$piece" ]; then rm -f "$piece"; off_add "$(tilde "$piece")"; hypr_gone=1; fi
835
+ done
836
+ fi
837
+ if [ "$hypr_gone" = 1 ] || [ "$1" = uninstall ]; then
838
+ if command -v hyprctl >/dev/null 2>&1; then hyprctl reload >/dev/null 2>&1 || :; fi
839
+ fi
840
+ }
841
+ uploads_off() {
842
+ off_gone=
843
+ if [ "$os" = Darwin ]; then
844
+ for agent in "$label" "$helper_label"; do
845
+ plist=$HOME/Library/LaunchAgents/$agent.plist
846
+ [ "$1" = uninstall ] || [ -e "$plist" ] || continue
847
+ launchctl bootout "gui/$(id -u)/$agent" >/dev/null 2>&1 || :
848
+ if [ -e "$plist" ]; then rm -f "$plist"; off_add "the launchd agent $agent"; fi
849
+ done
850
+ for app in "$HOME/Applications/Lanes Upload.app" "$HOME/Applications/Lanes Helper.app"; do
851
+ if [ -e "$app" ]; then rm -rf "$app"; off_add "$(tilde "$app")"; fi
852
+ done
853
+ else
854
+ units=${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user
855
+ uo_any=0
856
+ for unit in lanes-upload.path lanes-helper.service; do
857
+ [ "$1" = uninstall ] || [ -e "$units/$unit" ] || continue
858
+ systemctl --user disable --now "$unit" >/dev/null 2>&1 || :
859
+ uo_any=1; if [ -e "$units/$unit" ]; then off_add "$unit"; fi
860
+ done
861
+ if [ "$uo_any" = 1 ] || [ -e "$units/lanes-upload.service" ]; then
862
+ [ ! -e "$units/lanes-upload.service" ] || [ -n "$off_gone" ] || off_add lanes-upload.service
863
+ rm -f "$units/lanes-upload.path" "$units/lanes-upload.service" "$units/lanes-helper.service"
864
+ systemctl --user daemon-reload >/dev/null 2>&1 || :
865
+ fi
866
+ fi
867
+ rm -f "$state/helper.alive" # (a window's own helper takes over at once instead of waiting it out)
868
+ }
869
+
870
+ # ---- tailscale ------------------------------------------------------------------------------------------------------
871
+ # A profile whose server has a MagicDNS name (LANES_SERVER_NAME=*.ts.net) reaches it only through the tailnet, so this
872
+ # machine has to be in it before the server is tried. An install opens `tailscale login` when the tailnet asks for one
873
+ # and waits — bounded by LANES_TAILSCALE_WAIT seconds (default 120), with a countdown — until this machine is Running
874
+ # and online; --check only looks. Not in the tailnet keeps the exit status the server's: 4, "installed, but the server
875
+ # is not reachable". A profile without a .ts.net name skips all of this.
876
+ tailscale_hint='macOS: brew install --cask tailscale · Linux: https://tailscale.com/download'
877
+ tailnet_site() { case "$server_name" in *.ts.net|*.ts.net.) return 0 ;; esac; return 1; }
878
+ tailscale_bin() { # -> the tailscale CLI: on PATH, else (macOS) the one inside the app, a per-user copy first
879
+ if command -v tailscale >/dev/null 2>&1; then command -v tailscale; return 0; fi
880
+ [ "$os" = Darwin ] || return 1
881
+ for app in "$HOME/Applications/Tailscale.app" /Applications/Tailscale.app; do
882
+ [ -x "$app/Contents/MacOS/Tailscale" ] && { printf '%s\n' "$app/Contents/MacOS/Tailscale"; return 0; }
883
+ done
884
+ return 1
885
+ }
886
+ # JSON on stdin -> one "PATH=VALUE" line per scalar: PATH is /Key/Key/0… (array items by index), strings unquoted with
887
+ # their escapes left as they are. Enough of a tokenizer for `tailscale status --json` in any layout — braces and quotes
888
+ # inside strings included — so a peer's "Online" or another user's "LoginName" is never mistaken for this machine's.
889
+ json_paths() {
890
+ awk '
891
+ function emit(v, p, k) { p = ""; for (k = 1; k <= d; k++) p = p "/" key[k]; print p "=" v }
892
+ {
893
+ n = length($0); i = 1
894
+ while (i <= n) {
895
+ c = substr($0, i, 1)
896
+ if (c == "\"") {
897
+ j = i + 1
898
+ while (j <= n) { e = substr($0, j, 1); if (e == "\\") j += 2; else if (e == "\"") break; else j++ }
899
+ s = substr($0, i + 1, j - i - 1); i = j + 1
900
+ if (kind[d] == "o" && want) { key[d] = s; want = 0 } else emit(s)
901
+ continue
902
+ }
903
+ if (c == "{") { d++; kind[d] = "o"; key[d] = ""; want = 1 }
904
+ else if (c == "[") { d++; kind[d] = "a"; key[d] = 0; want = 0 }
905
+ else if (c == "}" || c == "]") d--
906
+ else if (c == ",") { if (kind[d] == "a") key[d]++; else want = 1 }
907
+ else if (c ~ /[-0-9tfn]/) {
908
+ j = i; while (j <= n && substr($0, j, 1) ~ /[-+.0-9A-Za-z]/) j++
909
+ emit(substr($0, i, j - i)); i = j; continue
910
+ }
911
+ i++
912
+ }
913
+ }'
914
+ }
915
+ ts_path() { printf '%s\n' "$ts_paths" | sed -n "s|^$1=||p" | head -n 1; }
916
+ # One look -> ts_state (BackendState; empty when tailscaled does not answer), ts_online (Self.Online), ts_ip (Self's
917
+ # first Tailscale address), ts_login (the LoginName of Self's user, looked up by Self.UserID in the User map).
918
+ tailscale_look() {
919
+ ts_paths=$("$ts" status --json 2>/dev/null | json_paths) || ts_paths=
920
+ ts_state=$(ts_path /BackendState); ts_online=$(ts_path /Self/Online); ts_ip=$(ts_path /Self/TailscaleIPs/0)
921
+ ts_uid=$(ts_path /Self/UserID); ts_login=
922
+ case "$ts_uid" in ''|*[!0-9]*) ;; *) ts_login=$(ts_path "/User/$ts_uid/LoginName") ;; esac
923
+ }
924
+ # Said once per state, when it is first seen; ts_explained=1 when the state had its own advice.
925
+ tailscale_explain() {
926
+ ts_explained=1
927
+ case "$ts_state" in
928
+ NeedsLogin)
929
+ if [ "$mode" = install ]; then say 'tailscale: needs login — opening `tailscale login`… (approve in the browser)'
930
+ else warn 'tailscale: needs login — run `tailscale login` (an install opens it for you), then check again'; fi ;;
931
+ '') warn "tailscale: installed, but not running — macOS: open the Tailscale app · Linux: sudo systemctl enable --now tailscaled ($("$ts" status --json 2>&1 >/dev/null | head -n 1))" ;;
932
+ Stopped) warn 'tailscale: switched off — switch it on in the Tailscale menu, or run `tailscale up`' ;;
933
+ NeedsMachineAuth) warn 'tailscale: this machine waits for a tailnet admin to approve it (admin console → Machines)' ;;
934
+ *) ts_explained=0 ;;
935
+ esac
936
+ }
937
+ # The countdown: rewritten in place on a terminal, a line every 10 s otherwise.
938
+ ts_countdown() {
939
+ if [ -t 1 ]; then printf '\r tailscale: waiting for the tailnet (%s) — %s s left ' "${ts_state:-no answer}" "$1"; ts_line=1
940
+ elif [ -z "$ts_note" ] || [ "$1" -le "$ts_note" ]; then
941
+ say "tailscale: waiting for the tailnet (${ts_state:-no answer}) — $1 s left"; ts_note=$(( $1 - 10 ))
942
+ fi
943
+ }
944
+ ts_countdown_done() { if [ "$ts_line" = 1 ]; then printf '\r\033[K'; ts_line=0; fi; }
945
+ ts_end_login() { # a login still waiting for approval is ended with the wait (the CLI is one process)
946
+ [ -n "$ts_login_pid" ] || return 0
947
+ kill "$ts_login_pid" 2>/dev/null || :
948
+ wait "$ts_login_pid" 2>/dev/null || :
949
+ ts_login_pid=
950
+ }
951
+ ts_finish() { ts_countdown_done; ts_end_login; trap - INT TERM HUP; }
952
+ # -> 0 in the tailnet ("connected as …"), 1 not (why, and what to do, already printed)
953
+ check_tailscale() {
954
+ if ! ts=$(tailscale_bin); then warn "tailscale: not installed — $tailscale_hint"; return 1; fi
955
+ ts_wait=${LANES_TAILSCALE_WAIT:-120}
956
+ case "$ts_wait" in ''|*[!0-9]*) warn "LANES_TAILSCALE_WAIT ignored (not a number of seconds): waiting 120 s"; ts_wait=120 ;; esac
957
+ [ "$mode" = install ] || ts_wait=0 # --check looks once: no login, no wait
958
+ ts_deadline=$(( $(date +%s) + ts_wait ))
959
+ ts_seen=- ts_note= ts_line=0 ts_login_pid= ts_login_rc= ts_explained=0
960
+ while :; do
961
+ tailscale_look
962
+ if [ "$ts_state" = Running ] && [ "$ts_online" = true ]; then
963
+ ts_finish
964
+ say "tailscale: connected as ${ts_login:-an unknown login} (${ts_ip:-no address yet})"
965
+ return 0
966
+ fi
967
+ if [ "$ts_state" != "$ts_seen" ]; then ts_seen=$ts_state; ts_countdown_done; tailscale_explain; fi
968
+ if [ "$ts_state" = NeedsLogin ] && [ "$mode" = install ] && [ -z "$ts_login_pid$ts_login_rc" ]; then
969
+ # Its output — the login URL where no browser opens by itself — reaches this terminal.
970
+ "$ts" login </dev/null &
971
+ ts_login_pid=$!
972
+ # A background job ignores SIGINT: Ctrl-C (or a closed terminal) ends the login with the installer.
973
+ trap 'ts_end_login; exit 130' INT; trap 'ts_end_login; exit 143' TERM; trap 'ts_end_login; exit 129' HUP
974
+ fi
975
+ # A login that has already given up (Linux without operator rights: "Access denied") ends the wait now.
976
+ if [ -n "$ts_login_pid" ] && ! kill -0 "$ts_login_pid" 2>/dev/null; then
977
+ ts_login_rc=0; wait "$ts_login_pid" || ts_login_rc=$?
978
+ ts_login_pid=
979
+ if [ "$ts_login_rc" != 0 ]; then
980
+ ts_finish
981
+ warn "tailscale: \`tailscale login\` failed (exit $ts_login_rc) — Linux: \`sudo tailscale up\` once (\`sudo tailscale set --operator=\$USER\` lets you log in without sudo), then run ./install.sh again"
982
+ return 1
983
+ fi
984
+ fi
985
+ ts_left=$(( ts_deadline - $(date +%s) ))
986
+ [ "$ts_left" -gt 0 ] || break
987
+ ts_countdown "$ts_left"
988
+ sleep 1
989
+ done
990
+ ts_finish
991
+ if [ "$mode" = install ]; then
992
+ warn "tailscale: not on the tailnet after $ts_wait s (state: ${ts_state:-no answer}) — connect it, then run ./install.sh again"
993
+ elif [ "$ts_explained" = 0 ]; then
994
+ warn "tailscale: not on the tailnet yet (state: ${ts_state:-no answer}) — wait for it to connect, then check again"
995
+ fi
996
+ return 1
997
+ }
998
+
519
999
  # ---- server ---------------------------------------------------------------------------------------------------------
520
1000
  # Every call to the server is BOUNDED. A thrashing host accepts the connection and then says nothing for minutes;
521
1001
  # ConnectTimeout does not cover the remote command, and macOS has no timeout(1) — so a watchdog subshell does it.
@@ -596,6 +1076,12 @@ report_server() {
596
1076
  ok) say "server: $server_note" ;;
597
1077
  old) warn "server answered, but without the expected greeting (an older server?): $server_note" ;;
598
1078
  unauthorized)
1079
+ # The enrolment service refused this machine for a reason a re-run fixes (enrol_conflicted): that remedy,
1080
+ # never the hand-added key line — an invite is still live after a 409.
1081
+ case "$enrol_conflict" in
1082
+ device-taken) report_device_taken; return 0 ;;
1083
+ key-taken) report_key_taken; return 0 ;;
1084
+ esac
599
1085
  head_ 'One step left: this machine is not authorized on the server yet.'
600
1086
  say 'Send this PUBLIC key to your workspace-server admin (it is safe to share):'
601
1087
  printf '\n %s\n\n' "$(cat "$key.pub")"
@@ -609,34 +1095,399 @@ report_server() {
609
1095
  unreachable)
610
1096
  head_ "The server ($server_host) is not reachable from this machine."
611
1097
  say 'The path runs over Tailscale: is it running, and is this machine in the tailnet?'
612
- ts=$(command -v tailscale 2>/dev/null || echo /Applications/Tailscale.app/Contents/MacOS/Tailscale)
613
- if [ -x "$ts" ]; then say "tailscale: $("$ts" status 2>&1 | head -n 1)"; else say 'tailscale: not installed (tailscale.com/download)'; fi
1098
+ if ts=$(tailscale_bin); then say "tailscale: $("$ts" status 2>&1 | head -n 1)"; else say "tailscale: not installed — $tailscale_hint"; fi
614
1099
  say "detail: $server_note" ;;
1100
+ # (check_tailscale has said why and what to do; the server was not tried from outside the tailnet)
1101
+ tailnet)
1102
+ if [ "$mode" = install ]; then say 'The client is installed; the server is checked once this machine is in the tailnet.'; fi ;;
615
1103
  esac
616
1104
  }
617
1105
 
1106
+ # A 409 from the enrolment service (enrold answers it only after a valid invite code, or to a request it would
1107
+ # otherwise file): the machine's name or key is already a member's. Nothing was used up: the invite stays live and no
1108
+ # request is open, so the same command with another name — or another key — joins.
1109
+ report_device_taken() {
1110
+ head_ "One step left: the server already has a device named $device."
1111
+ say 'Another machine has that name (two laptops with one hostname), or this machine joined before with another key.'
1112
+ if [ "$enrol_via" = invite ]; then
1113
+ say 'Your invite is still valid. Run the install again with a name of its own for this machine:'
1114
+ say ' --invite CODE --device NAME (the installer remembers the name)'
1115
+ else
1116
+ say 'Ask again under a name of its own for this machine: --device NAME (the installer remembers the name)'
1117
+ fi
1118
+ say "If the machine that had the name is gone, a member first ends it on the server: lanes offboard $device --yes"
1119
+ }
1120
+ report_key_taken() {
1121
+ head_ "One step left: the server already holds this machine's key ($(tilde "$key")) for another device."
1122
+ say 'A key joins once. The server keeps it under another device name, yet does not let it in here.'
1123
+ if [ "$key" != "$HOME/.ssh/lanes_ed25519" ]; then
1124
+ if [ "$enrol_via" = invite ]; then
1125
+ say 'Your invite is still valid. Join with a key of its own (the installer makes it):'
1126
+ say ' --invite CODE --key ~/.ssh/lanes_ed25519'
1127
+ else
1128
+ say 'Ask again with a key of its own (the installer makes it): --key ~/.ssh/lanes_ed25519'
1129
+ fi
1130
+ fi
1131
+ say 'A member sees on the server which device holds it: lanes keys list (gone? lanes offboard <device> --yes)'
1132
+ }
1133
+
1134
+ # ---- joining a site -------------------------------------------------------------------------------------------------
1135
+ # The server answered, but not for this machine's key (check_server: unauthorized). The site's enrolment service —
1136
+ # lanes-enrold behind the server's private HTTPS front, https://<LANES_SERVER_NAME>:8443/enrol/ — authorizes the key: at
1137
+ # once for an invite code (--invite, LANES_INVITE, or typed at the prompt); without one, a member approves an access
1138
+ # request while the installer waits (LANES_ENROL_WAIT s, default 600; --resume waits again). The service writes the
1139
+ # server's authorized_keys; this client never edits that file. Wire contract v1 as lanes-enrold serves it (W8 A3,
1140
+ # DM-LANES-ENROL-WIRE; what this client reads of it: lanes/docs/ARCHITECTURE.md, "Joining a site"): JSON both ways,
1141
+ # 200 = done (202 = a request filed), any other status = refused, with {"error": …}. No service, a refusal, or no code
1142
+ # and nobody at a terminal to ask: report_server's exit-3 text, unchanged — except a 409 (the device name or the key is
1143
+ # already a member's), which names the re-run that joins instead (report_device_taken, report_key_taken). The code
1144
+ # goes in a request body on stdin: never on a command line, in a file or in the output.
1145
+ enrol_file=$config/enrol-request enrol_caller= enrol_conflict= enrol_via=
1146
+ invite_problem() { # CODE -> why it is not an invite code for this profile, or nothing. Names the prefix at most.
1147
+ ip_bad='not an invite code (letters, digits and dashes, like LN-xxxx-xxxxxxxxxxxx)'
1148
+ case "$1" in ''|-*|*-|*--*|*[!A-Za-z0-9-]*) printf '%s' "$ip_bad"; return 0 ;; *-*) ;; *) printf '%s' "$ip_bad"; return 0 ;; esac
1149
+ ip_prefix=${1%%-*}
1150
+ case "$ip_prefix" in *[!A-Za-z]*) printf '%s' "$ip_bad"; return 0 ;; esac
1151
+ if [ "${#1}" -gt 64 ] || [ "${#ip_prefix}" -gt 8 ]; then printf '%s' "$ip_bad"; return 0; fi
1152
+ ip_prefix=$(printf '%s' "$ip_prefix" | tr 'a-z' 'A-Z'); ip_site=$(printf '%s' "$site_code" | tr 'a-z' 'A-Z')
1153
+ if [ -n "$ip_site" ] && [ "$ip_prefix" != "$ip_site" ]; then
1154
+ printf 'this code is for another site (prefix %s); the profile %s takes %s codes — choose its site with --site' \
1155
+ "$ip_prefix" "${site_name:-$(tilde "$site_file")}" "$ip_site"
1156
+ fi
1157
+ }
1158
+ check_invite() { # a code from the command line or LANES_INVITE: refused before anything is installed
1159
+ [ -n "$invite" ] || return 0
1160
+ invite=$(printf '%s' "$invite" | tr -d ' \t\r\n') # (pasted with a blank or a line break)
1161
+ ci_problem=$(invite_problem "$invite")
1162
+ [ -z "$ci_problem" ] || die "invite: $ci_problem"
1163
+ }
1164
+ enrol_clean() { printf '%s' "$1" | tr -cd 'A-Za-z0-9@._+-' | cut -c1-80; } # a server's word, safe for a terminal
1165
+ enrol_json() { case "$(printf '%s' "$http_body" | tr -d ' \t\r\n' | cut -c1)" in '{') return 0 ;; esac; return 1; }
1166
+ json_get() { # PATH -> the value at that path of $http_body (strings unquoted); nothing for null or absent
1167
+ jg=$(printf '%s\n' "$http_body" | json_paths | sed -n "s|^$1=||p" | head -n 1)
1168
+ [ "$jg" = null ] || printf '%s' "$jg"
1169
+ }
1170
+ # -> enrol_base (scheme://host[:port]), enrol_host, enrol_port, enrol_ip (the tailnet address to use when this machine
1171
+ # cannot resolve the name: an IPv4 LANES_SERVER_HOST); 1 + enrol_note when there is no service to ask.
1172
+ # LANES_ENROL_URL replaces the base (development and tests); the name and address logic stays the same.
1173
+ enrol_target() {
1174
+ enrol_base=${LANES_ENROL_URL:-} enrol_resolve= enrol_note=
1175
+ if [ -z "$enrol_base" ]; then
1176
+ if [ -z "$server_name" ]; then enrol_note='this site profile names no LANES_SERVER_NAME (its enrolment service answers under that name)'; return 1; fi
1177
+ enrol_base=https://${server_name%.}:8443
1178
+ fi
1179
+ enrol_base=${enrol_base%/}
1180
+ et_hostport=${enrol_base#*://}
1181
+ case "$enrol_base" in http://*|https://*) ;; *) enrol_note='LANES_ENROL_URL is not an http(s) URL'; return 1 ;; esac
1182
+ case "$et_hostport" in ''|*/*|*[!A-Za-z0-9.:-]*) enrol_note='LANES_ENROL_URL is not scheme://host[:port]'; return 1 ;; esac
1183
+ enrol_host=${et_hostport%:*} enrol_port=${et_hostport##*:}
1184
+ if [ "$enrol_host" = "$et_hostport" ]; then
1185
+ enrol_port=443; case "$enrol_base" in http://*) enrol_port=80 ;; esac
1186
+ fi
1187
+ case "$enrol_port" in ''|*[!0-9]*) enrol_note='LANES_ENROL_URL has a malformed port'; return 1 ;; esac
1188
+ enrol_ip=
1189
+ case "$server_host" in ''|*[!0-9.]*) ;; *) [ "$server_host" = "$enrol_host" ] || enrol_ip=$server_host ;; esac
1190
+ }
1191
+ # python3 when there is no curl (minimal Linux images): http.client, because urllib.request raises on every status but
1192
+ # 2xx and cannot be told which address to connect to. Status on stdout, the answer into $4, why not on stderr.
1193
+ enrol_py='
1194
+ import http.client, socket, ssl, sys, urllib.parse
1195
+ method, url, address, out = sys.argv[1:5]
1196
+ body = sys.stdin.buffer.read() if method == "POST" else None
1197
+ parts = urllib.parse.urlsplit(url)
1198
+ tls = parts.scheme == "https"
1199
+ host, port = parts.hostname, parts.port or (443 if tls else 80)
1200
+ context = ssl.create_default_context() if tls else None
1201
+ def ask(where):
1202
+ if tls:
1203
+ conn = http.client.HTTPSConnection(host, port, timeout=15, context=context)
1204
+ else:
1205
+ conn = http.client.HTTPConnection(host, port, timeout=15)
1206
+ if where != host:
1207
+ def connect(): # the tailnet address; the name stays for TLS (SNI, the certificate) and Host
1208
+ sock = socket.create_connection((where, port), 5)
1209
+ sock.settimeout(15)
1210
+ conn.sock = context.wrap_socket(sock, server_hostname=host) if tls else sock
1211
+ conn.connect = connect
1212
+ headers = {"Accept": "application/json"}
1213
+ if body is not None:
1214
+ headers["Content-Type"] = "application/json"
1215
+ conn.request(method, parts.path or "/", body=body, headers=headers)
1216
+ reply = conn.getresponse()
1217
+ return reply.status, reply.read(65537)
1218
+ try:
1219
+ try:
1220
+ status, data = ask(host)
1221
+ except socket.gaierror:
1222
+ if not address:
1223
+ raise
1224
+ status, data = ask(address)
1225
+ except Exception as error:
1226
+ sys.stderr.write("python3: %s: %s\n" % (type(error).__name__, error))
1227
+ sys.exit(7)
1228
+ if len(data) > 65536:
1229
+ sys.stderr.write("python3: the answer is larger than 64 KiB\n")
1230
+ sys.exit(7)
1231
+ with open(out, "wb") as f:
1232
+ f.write(data)
1233
+ print(status)
1234
+ '
1235
+ enrol_curl() { # METHOD PATH [BODY] -> ec_rc, ec_code; the answer in $ec_tmp, curl's complaint in $ec_tmp.err
1236
+ ecm=$1 ecp=$2 ecb=${3:-}
1237
+ # -q first: no ~/.curlrc (it could add -v or another -o); no proxy: the service lives on the tailnet only.
1238
+ set -- -q -sS --noproxy '*' --connect-timeout 5 --max-time 15 --max-filesize 65536 -o "$ec_tmp" -w '%{http_code}' \
1239
+ -H 'Accept: application/json'
1240
+ if [ -n "$enrol_resolve" ]; then set -- "$@" --resolve "$enrol_resolve"; fi
1241
+ ec_rc=0
1242
+ if [ "$ecm" = POST ]; then
1243
+ ec_code=$(printf '%s' "$ecb" | curl "$@" -H 'Content-Type: application/json' --data-binary @- "$enrol_base$ecp" \
1244
+ 2>"$ec_tmp.err") || ec_rc=$?
1245
+ else
1246
+ ec_code=$(curl "$@" "$enrol_base$ecp" </dev/null 2>"$ec_tmp.err") || ec_rc=$?
1247
+ fi
1248
+ }
1249
+ # enrol_call GET|POST PATH [BODY] -> http_status (empty = no answer), http_body, enrol_detail (why no answer)
1250
+ enrol_call() {
1251
+ http_status= http_body= enrol_detail= ec_rc=0 ec_code=
1252
+ ec_tmp=$(mktemp)
1253
+ if command -v curl >/dev/null 2>&1; then
1254
+ enrol_curl "$@"
1255
+ # 6 = this machine could not resolve the name (MagicDNS off): the same request to the tailnet address, the name
1256
+ # kept for TLS and Host (--resolve), and remembered for the rest of this run.
1257
+ if [ "$ec_rc" = 6 ] && [ -z "$enrol_resolve" ] && [ -n "$enrol_ip" ]; then
1258
+ enrol_resolve=$enrol_host:$enrol_port:$enrol_ip
1259
+ enrol_curl "$@"
1260
+ fi
1261
+ elif command -v python3 >/dev/null 2>&1; then
1262
+ ec_rc=0
1263
+ ec_code=$(printf '%s' "${3:-}" | python3 -c "$enrol_py" "$1" "$enrol_base$2" "$enrol_ip" "$ec_tmp" 2>"$ec_tmp.err") ||
1264
+ ec_rc=$?
1265
+ else
1266
+ ec_rc=127; echo 'neither curl nor python3 is installed' > "$ec_tmp.err"
1267
+ fi
1268
+ if [ "$ec_rc" = 0 ]; then
1269
+ case "$ec_code" in [1-5][0-9][0-9]) http_status=$ec_code; http_body=$(cat "$ec_tmp") ;; esac
1270
+ fi
1271
+ [ -n "$http_status" ] || enrol_detail=$(head -n 1 "$ec_tmp.err" 2>/dev/null | tr -cd '[:print:]' | cut -c1-160)
1272
+ rm -f "$ec_tmp" "$ec_tmp.err"
1273
+ }
1274
+ enrol_refusal() { # -> "the server answered 403 (invalid-or-expired)" | "no answer from <base> (<why>)"
1275
+ if [ -z "$http_status" ]; then printf 'no answer from %s (%s)' "$enrol_base" "${enrol_detail:-no detail}"; return 0; fi
1276
+ er=; if enrol_json; then er=$(json_get /error | tr -cd 'A-Za-z0-9@._+:,;()/ -' | cut -c1-120); fi
1277
+ printf 'the server answered %s%s' "$http_status" "${er:+ ($er)}"
1278
+ }
1279
+ enrol_conflicted() { # invite|request, after a refusal: a 409 device-taken|key-taken -> enrol_conflict, enrol_via
1280
+ enrol_conflict= enrol_via=$1
1281
+ if [ "$http_status" = 409 ] && enrol_json; then
1282
+ case "$(json_get /error)" in device-taken|key-taken) enrol_conflict=$(json_get /error) ;; esac
1283
+ fi
1284
+ }
1285
+ enrol_site() { # GET /enrol/site -> 0, request_mode and enrol_caller; or 1 and enrol_note
1286
+ enrol_call GET /enrol/site
1287
+ if [ -z "$http_status" ]; then enrol_note=$(enrol_refusal); return 1; fi
1288
+ if [ "$http_status" != 200 ]; then enrol_note="no enrolment service at $enrol_base/enrol/ (the server answered $http_status)"; return 1; fi
1289
+ # (a server without the service hands /enrol/site to its web client: a page, not this document)
1290
+ if ! enrol_json; then enrol_note="no enrolment service at $enrol_base/enrol/ (the answer is not JSON)"; return 1; fi
1291
+ # lanes-enrold's /site always says request_mode, true or false: that is what tells it from any other JSON. Its
1292
+ # `version` is the server's Lanes release, not a wire number (wire v1 is additive only, and none is served).
1293
+ request_mode=$(json_get /request_mode)
1294
+ case "$request_mode" in
1295
+ true|false) ;;
1296
+ *) enrol_note="no enrolment service at $enrol_base/enrol/ (the answer says nothing of request_mode)"; return 1 ;;
1297
+ esac
1298
+ # The tailnet login the service received (null outside request mode): the owner a request is filed under.
1299
+ enrol_caller=$(enrol_clean "$(json_get /caller)")
1300
+ }
1301
+ enrol_key() { # -> enrol_pubkey "ssh-ed25519 <base64>" (enrold writes its own comment); 1, said, for any other key
1302
+ enrol_pubkey= kt= kb=
1303
+ if [ -r "$key.pub" ]; then read -r kt kb _ < "$key.pub" || :; fi
1304
+ if [ "$kt" != ssh-ed25519 ]; then
1305
+ warn "enrolment: takes an ed25519 key, and $(tilde "$key") is $(enrol_clean "${kt:-unreadable}") — choose one with --key, or let the installer make one (--key ~/.ssh/lanes_ed25519)"
1306
+ return 1
1307
+ fi
1308
+ case "$kb" in ''|*[!A-Za-z0-9+/=]*) warn "enrolment: $(tilde "$key.pub") does not hold a public key"; return 1 ;; esac
1309
+ enrol_pubkey="$kt $kb"
1310
+ }
1311
+ enrol_restore_tty() { if [ -n "$ep_tty" ]; then stty "$ep_tty" 2>/dev/null || :; fi; }
1312
+ # On a terminal: the code, typed with echo off (switched off BEFORE the prompt shows, so nothing typed early is echoed).
1313
+ # -> 0 with invite set, or empty (Enter: ask the members); 1 nothing usable. A malformed code is asked again, 3 times.
1314
+ enrol_prompt() {
1315
+ if [ "$request_mode" = true ]; then ep_q='Invite code (Enter = ask the members of this site for access instead): '
1316
+ else ep_q='Invite code (Enter = skip): '; fi
1317
+ head_ 'This machine is not authorized on the server yet.'
1318
+ ep_tries=0
1319
+ while [ "$ep_tries" -lt 3 ]; do
1320
+ ep_tries=$((ep_tries + 1))
1321
+ ep_tty=$(stty -g 2>/dev/null) || ep_tty=
1322
+ trap 'enrol_restore_tty; exit 130' INT; trap 'enrol_restore_tty; exit 143' TERM; trap 'enrol_restore_tty; exit 129' HUP
1323
+ stty -echo 2>/dev/null || :
1324
+ printf ' %s' "$ep_q"
1325
+ ep_read=0; IFS= read -r ep_answer || ep_read=$?
1326
+ enrol_restore_tty; trap - INT TERM HUP
1327
+ printf '\n'
1328
+ [ "$ep_read" = 0 ] || return 1 # end of input (Ctrl-D): nothing
1329
+ ep_answer=$(printf '%s' "$ep_answer" | tr -d ' \t\r')
1330
+ if [ -z "$ep_answer" ]; then
1331
+ invite=
1332
+ if [ "$request_mode" = true ]; then return 0; fi
1333
+ say 'request: this site admits by invite only — ask a member for an invite, then ./install.sh --invite CODE'
1334
+ return 1
1335
+ fi
1336
+ ep_problem=$(invite_problem "$ep_answer")
1337
+ if [ -z "$ep_problem" ]; then invite=$ep_answer; return 0; fi
1338
+ warn "invite: $ep_problem"
1339
+ done
1340
+ return 1
1341
+ }
1342
+ enrol_authorized() { # invite|request: say the verdict; the pending request, if any, is over
1343
+ # A redeem answers {device, owner{login,name,email}|null, ssh_ready}; a request's verdict is only {id, state,
1344
+ # expires_at}, and its owner is the login the request was filed under (the /site caller).
1345
+ if [ "$1" = invite ]; then
1346
+ ea_owner=$(enrol_clean "$(json_get /owner/login)"); [ -n "$ea_owner" ] || ea_owner=$(enrol_clean "$(json_get /owner/name)")
1347
+ ea_device=$(enrol_clean "$(json_get /device)")
1348
+ else
1349
+ ea_owner=$enrol_caller ea_device=
1350
+ fi
1351
+ ea_said="this machine's key is now authorized for ${ea_owner:-this site} (device ${ea_device:-$device})"
1352
+ if [ "$1" = invite ]; then say "invite: redeemed — $ea_said"; else say "request: approved — $ea_said"; fi
1353
+ if [ -n "$ea_device" ] && [ "$ea_device" != "$device" ]; then
1354
+ warn "the server enrolled this key as device $ea_device, and this machine's view is $device (--device $ea_device matches it)"
1355
+ fi
1356
+ rm -f "$enrol_file"
1357
+ }
1358
+ enrol_redeem() {
1359
+ enrol_call POST /enrol/redeem "{\"code\":\"$invite\",\"pubkey\":\"$enrol_pubkey\",\"device\":\"$device\",\"client_version\":\"$client_version\"}"
1360
+ if [ "$http_status" = 200 ] && enrol_json; then enrol_authorized invite; return 0; fi
1361
+ warn "invite: not redeemed — $(enrol_refusal)"
1362
+ enrol_conflicted invite
1363
+ return 1
1364
+ }
1365
+ # $config/enrol-request: the open request (KEY=VALUE, mode 0600, read with sed, never executed), kept until a member
1366
+ # answers so --resume waits on it instead of filing another; tied to the enrolment service it was filed with.
1367
+ enrol_save() {
1368
+ rm -f "$enrol_file.new"
1369
+ ( umask 077
1370
+ printf '%s\n' '# lanes access request: written by install.sh while no member has answered it; `install.sh --resume` keeps' \
1371
+ '# waiting on it. Removed once it is answered and by install.sh --uninstall. Never executed.' \
1372
+ 'SCHEMA=1' "ID=$1" "URL=$enrol_base" "DEVICE=$device" "FILED=$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$enrol_file.new" )
1373
+ chmod 600 "$enrol_file.new"; mv "$enrol_file.new" "$enrol_file"
1374
+ }
1375
+ enrol_saved() { # -> the id of this machine's open request to this enrolment service, or nothing
1376
+ [ -r "$enrol_file" ] || return 0
1377
+ es_id=$(sed -n 's/^ID=//p' "$enrol_file" | tail -n 1); es_url=$(sed -n 's/^URL=//p' "$enrol_file" | tail -n 1)
1378
+ case "$es_id" in ''|*[!A-Za-z0-9_-]*) return 0 ;; esac
1379
+ if [ "${#es_id}" -le 64 ] && [ "$es_url" = "$enrol_base" ]; then printf '%s' "$es_id"; fi
1380
+ }
1381
+ enrol_request() { # -> enrol_id, saved
1382
+ enrol_call POST /enrol/request "{\"pubkey\":\"$enrol_pubkey\",\"device\":\"$device\",\"client_version\":\"$client_version\"}"
1383
+ enrol_id=
1384
+ # (enrold answers 202 Accepted {id, expires_at}, also for the same device and key asked again)
1385
+ case "$http_status" in 200|202) if enrol_json; then enrol_id=$(json_get /id); fi ;; esac
1386
+ case "$enrol_id" in ''|*[!A-Za-z0-9_-]*) warn "request: not filed — $(enrol_refusal)"; enrol_conflicted request; return 1 ;; esac
1387
+ if [ "${#enrol_id}" -gt 64 ]; then warn "request: not filed — $(enrol_refusal)"; return 1; fi
1388
+ enrol_save "$enrol_id"
1389
+ say "request: filed as $enrol_id for $device — a member approves it on their phone ($enrol_base/enrol/pending) or on the server: lanes enrol approve $enrol_id"
1390
+ }
1391
+ enrol_clock() { printf '%d:%02d' $(( $1 / 60 )) $(( $1 % 60 )); }
1392
+ enrol_countdown() { # rewritten in place on a terminal, a line a minute otherwise
1393
+ if [ -t 1 ]; then printf '\r request: waiting for approval — %s left ' "$(enrol_clock "$1")"; ep_line=1
1394
+ elif [ -z "$ep_note" ] || [ "$1" -le "$ep_note" ]; then say "request: waiting for approval — $(enrol_clock "$1") left"; ep_note=$(( $1 - 60 )); fi
1395
+ }
1396
+ enrol_countdown_done() { if [ "$ep_line" = 1 ]; then printf '\r\033[K'; ep_line=0; fi; }
1397
+ enrol_poll() { # ID: ask every LANES_ENROL_POLL s (5) for the verdict, at most LANES_ENROL_WAIT s (600)
1398
+ ep_id=$1 ep_wait=${LANES_ENROL_WAIT:-600} ep_every=${LANES_ENROL_POLL:-5} ep_line=0 ep_note=
1399
+ case "$ep_wait" in ''|*[!0-9]*) warn 'LANES_ENROL_WAIT ignored (not a number of seconds): waiting 600 s'; ep_wait=600 ;; esac
1400
+ case "$ep_every" in ''|0|*[!0-9]*) ep_every=5 ;; esac
1401
+ ep_deadline=$(( $(date +%s) + ep_wait ))
1402
+ say "request: waiting up to $(enrol_clock "$ep_wait") for a member's answer (Ctrl-C stops; ./install.sh --resume waits again)"
1403
+ trap 'enrol_countdown_done; warn "request: still open — ./install.sh --resume keeps waiting (request $ep_id)"; exit 130' INT
1404
+ trap 'enrol_countdown_done; exit 143' TERM; trap 'enrol_countdown_done; exit 129' HUP
1405
+ while :; do
1406
+ enrol_call GET "/enrol/request/$ep_id"
1407
+ if [ "$http_status" = 200 ] && enrol_json; then
1408
+ case "$(json_get /state)" in # {id, state: pending|approved|denied|expired, expires_at}
1409
+ approved) enrol_countdown_done; trap - INT TERM HUP; enrol_authorized request; return 0 ;;
1410
+ denied) ep_end='denied — ask a member for an invite, then ./install.sh --invite CODE'; break ;;
1411
+ expired) ep_end='expired before anyone answered — run ./install.sh again to ask again'; break ;;
1412
+ esac
1413
+ elif [ "$http_status" = 404 ]; then # (enrold: unknown, or filed by another tailnet login)
1414
+ ep_end="the server no longer knows request $ep_id — run ./install.sh again to ask again"; break
1415
+ fi
1416
+ # pending — or no answer for now (a server restarting): ask again until the bound
1417
+ ep_left=$(( ep_deadline - $(date +%s) ))
1418
+ if [ "$ep_left" -le 0 ]; then ep_end=; break; fi
1419
+ enrol_countdown "$ep_left"
1420
+ if [ "$ep_left" -lt "$ep_every" ]; then sleep "$ep_left"; else sleep "$ep_every"; fi
1421
+ done
1422
+ enrol_countdown_done; trap - INT TERM HUP
1423
+ if [ -n "$ep_end" ]; then rm -f "$enrol_file"; warn "request: $ep_end"; return 1; fi
1424
+ warn "request: no answer within $(enrol_clock "$ep_wait") — it stays open: ./install.sh --resume keeps waiting (request $ep_id)"
1425
+ return 1
1426
+ }
1427
+ # -> 0 when the key is authorized now (and check_server has looked again); 1 = report_server's exit-3 text follows.
1428
+ enrol() {
1429
+ en_terminal=0; if [ -t 0 ] && [ -t 1 ] && [ "$assume_yes" = 0 ]; then en_terminal=1; fi
1430
+ en_asked=0; if [ -n "$invite" ] || [ "$resume" = 1 ]; then en_asked=1; fi
1431
+ [ "$en_asked" = 1 ] || [ "$en_terminal" = 1 ] || return 1 # nobody asked, nobody to ask: nothing is sent
1432
+ # (a bare terminal install against a server without the service stays silent: the exit-3 text says what to do)
1433
+ if ! enrol_target; then [ "$en_asked" = 0 ] || warn "enrolment: $enrol_note"; return 1; fi
1434
+ enrol_pending=$(enrol_saved)
1435
+ if [ "$resume" = 1 ] && [ -z "$invite" ] && [ -z "$enrol_pending" ]; then
1436
+ warn 'request: nothing to resume — this machine has no open access request'; return 1
1437
+ fi
1438
+ if ! enrol_site; then [ "$en_asked" = 0 ] || warn "enrolment: $enrol_note"; return 1; fi
1439
+ enrol_key || return 1
1440
+ client_version=dev; if [ -r "$here/VERSION" ]; then client_version=$(tr -cd 'A-Za-z0-9._+-' < "$here/VERSION"); fi
1441
+ # (a request already open is waited on again, not asked about: a second one would only queue behind it)
1442
+ if [ -z "$invite" ] && [ -z "$enrol_pending" ]; then enrol_prompt || return 1; fi
1443
+ if [ -n "$invite" ]; then enrol_redeem || return 1
1444
+ elif [ -n "$enrol_pending" ]; then say "request: $enrol_pending is still open"; enrol_poll "$enrol_pending" || return 1
1445
+ else { enrol_request && enrol_poll "$enrol_id"; } || return 1
1446
+ fi
1447
+ check_server
1448
+ }
1449
+
618
1450
  # ---- modes ----------------------------------------------------------------------------------------------------------
619
1451
  do_install() {
620
1452
  command -v ssh >/dev/null 2>&1 || die 'OpenSSH (ssh) is required'
621
1453
  case "$os" in Darwin|Linux) ;; *) die "unsupported system: $os (macOS and Linux only)" ;; esac
622
- load_site; src=$here/client
1454
+ resolve_options; load_site; check_invite; src=$here/client
623
1455
  head_ "lanes client $(cat "$here/VERSION" 2>/dev/null || echo dev) -> $server_user@$server_alias"
624
1456
  choose_device; choose_key; ensure_key
625
1457
  install_client; src=$target
626
1458
  install_ssh
627
1459
  find_terminal
628
1460
  head_ 'Checking the server…'
629
- check_server
1461
+ if tailnet_site && ! check_tailscale; then server_state=tailnet; else
1462
+ # MagicDNS answers only inside the tailnet, which the login above may just have joined: look at the name again.
1463
+ if tailnet_site && [ -n "$magicdns_note" ] && name_resolves "${server_name%.}"; then install_ssh; fi
1464
+ check_server
1465
+ fi
1466
+ if [ "$server_state" = unauthorized ]; then enrol || :; fi
1467
+ case "$server_state" in ok|old) rm -f "$enrol_file" ;; esac # (an open request is moot once the key works)
630
1468
  boards=
631
1469
  if [ "$server_state" = ok ] || [ "$server_state" = old ]; then after_authorized; fi
632
1470
  report_server
633
- if [ "$desktop" = 1 ]; then head_ 'Desktop'; install_desktop; fi
634
- if [ "$uploads" = 1 ]; then [ "$desktop" = 1 ] || head_ 'Uploads'; install_uploads; install_helper_service; fi
1471
+ if [ "$desktop" = 1 ]; then head_ 'Desktop'; install_desktop
1472
+ else
1473
+ desktop_off off
1474
+ if [ -n "$off_gone" ]; then head_ 'Desktop'; say "desktop: off — removed $off_gone (--desktop puts it back)"; fi
1475
+ fi
1476
+ if [ "$uploads" = 1 ]; then [ "$desktop" = 1 ] || head_ 'Uploads'; install_uploads; install_helper_service
1477
+ else
1478
+ uploads_off off
1479
+ if [ -n "$off_gone" ]; then
1480
+ head_ 'Uploads'
1481
+ say "uploads: off — stopped and removed $off_gone; \"$(tilde "$(cat "$config/uploads-dir" 2>/dev/null || echo "$HOME/Lanes Uploads")")\" and its files stay (--uploads turns them back on)"
1482
+ fi
1483
+ remove_agent_hand_off off # (it exists for the helper service alone)
1484
+ fi
635
1485
  case "$server_state" in
636
1486
  ok|old)
637
1487
  head_ 'Ready.'
638
1488
  say 'Open the window: lanes-window (Omarchy: Super+Shift+L · macOS: the Lanes app)'
639
1489
  say 'In any terminal: lanes-launch one lane only: lanes-launch lane <id> · list: lanes-launch ls'
1490
+ say 'Phone or browser: lanes-launch web phone · lanes-launch web laptop (in your own terminal: a login token)'
640
1491
  say 'Keys: Ctrl-Alt-←/→ board · Ctrl-Alt-↑/↓ lane · Ctrl-Alt-/ find · Ctrl-Alt-u upload box · close the window to detach'
641
1492
  exit 0 ;;
642
1493
  unauthorized) exit 3 ;;
@@ -645,7 +1496,7 @@ do_install() {
645
1496
  }
646
1497
 
647
1498
  do_check() {
648
- load_site; src=$here/client; choose_device; choose_key
1499
+ resolve_options; load_site; src=$here/client; choose_device; choose_key
649
1500
  head_ "lanes client check -> $server_user@$server_alias"
650
1501
  for name in lanes-launch lanes-window lanes-upload; do
651
1502
  if [ -x "$bin/$name" ]; then say "ok $name -> $(readlink "$bin/$name" 2>/dev/null || echo "$bin/$name")"; else warn "missing: $bin/$name (run ./install.sh)"; fi
@@ -654,7 +1505,11 @@ do_check() {
654
1505
  ssh -G lanes 2>/dev/null | awk '$1 == "hostname" { print " ssh alias lanes -> " $2 }'
655
1506
  find_terminal
656
1507
  check_helper
657
- check_server; report_server
1508
+ if tailnet_site && ! check_tailscale; then server_state=tailnet; else check_server; fi
1509
+ report_server
1510
+ if [ "$server_state" = unauthorized ] && [ -n "$invite" ]; then
1511
+ say 'invite: --check changes nothing — redeem it with ./install.sh --invite CODE'
1512
+ fi
658
1513
  case "$server_state" in ok) exit 0 ;; unauthorized) exit 3 ;; *) exit 4 ;; esac
659
1514
  }
660
1515
 
@@ -664,40 +1519,21 @@ do_uninstall() {
664
1519
  case "$(readlink "$bin/$name" 2>/dev/null || :)" in */lanes/client/*|"$here"/client/*) rm -f "$bin/$name"; say "removed $bin/$name" ;; esac
665
1520
  done
666
1521
  if [ -e "$bin/coder-launch-legacy" ] && [ ! -e "$bin/coder-launch" ]; then mv "$bin/coder-launch-legacy" "$bin/coder-launch"; say 'restored the previous coder-launch'; fi
667
- if [ "$os" = Darwin ]; then
668
- for agent in "$label" "$helper_label"; do
669
- launchctl bootout "gui/$(id -u)/$agent" >/dev/null 2>&1 || :
670
- rm -f "$HOME/Library/LaunchAgents/$agent.plist"
671
- done
672
- rm -rf "$HOME/Applications/Lanes.app" "$HOME/Applications/Lanes Upload.app" "$HOME/Applications/Lanes Helper.app"
673
- else
674
- units=${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user
675
- systemctl --user disable --now lanes-upload.path >/dev/null 2>&1 || :
676
- systemctl --user disable --now lanes-helper.service >/dev/null 2>&1 || :
677
- rm -f "$units/lanes-upload.path" "$units/lanes-upload.service" "$units/lanes-helper.service"
678
- systemctl --user daemon-reload >/dev/null 2>&1 || :
679
- rm -f "$HOME/.local/share/applications/lanes.desktop"
680
- for main in "$HOME/.config/hypr/hyprland.lua" "$HOME/.config/hypr/hyprland.conf"; do
681
- [ -f "$main" ] && grep -q -F -e 'require("hypr.lanes")' -e 'source = ~/.config/hypr/lanes.conf' "$main" || continue
682
- cp -p "$main" "$main.pre-lanes-uninstall.$stamp"
683
- grep -v -F -e 'lanes workspace server (remove this line' -e 'require("hypr.lanes")' -e 'source = ~/.config/hypr/lanes.conf' \
684
- "$main.pre-lanes-uninstall.$stamp" > "$main" || :
685
- say "Hyprland: removed the lanes line from $(tilde "$main") (backup kept)"
686
- done
687
- rm -f "$HOME/.config/hypr/lanes.lua" "$HOME/.config/hypr/lanes.conf"
688
- command -v hyprctl >/dev/null 2>&1 && hyprctl reload >/dev/null 2>&1 || :
689
- fi
1522
+ uploads_off uninstall; uploads_gone=$off_gone
1523
+ desktop_off uninstall; [ -z "$uploads_gone" ] || off_gone="$uploads_gone${off_gone:+, $off_gone}"
1524
+ if [ -n "$off_gone" ]; then say "removed $off_gone"; fi
690
1525
  if grep -q '^Include config.d/lanes.conf' "$HOME/.ssh/config" 2>/dev/null; then
691
1526
  cp -p "$HOME/.ssh/config" "$HOME/.ssh/config.pre-lanes-uninstall.$stamp"
692
1527
  grep -v -e '^Include config.d/lanes.conf' -e '^# lanes workspace server (added ' "$HOME/.ssh/config.pre-lanes-uninstall.$stamp" > "$HOME/.ssh/config" || :
693
1528
  chmod 600 "$HOME/.ssh/config"; say '~/.ssh/config: removed the Include line (backup kept)'
694
1529
  fi
695
1530
  rm -f "$HOME/.ssh/config.d/lanes.conf"
1531
+ remove_agent_hand_off
696
1532
  rm -rf "$share/client" "$state"
697
1533
  # A Linux account can run both roles. Keep server catalogs and any files
698
1534
  # the client did not create in the shared configuration directory.
699
- rm -f "$config/device" "$config/ghostty.conf" "$config"/ghostty.*.conf "$config"/ghostty-palette*.conf \
700
- "$config/client-site.conf" "$config/client-known-hosts" "$config/site.cache" \
1535
+ rm -f "$config/device" "$config/install-options" "$config/ghostty.conf" "$config"/ghostty.*.conf "$config"/ghostty-palette*.conf \
1536
+ "$config/client-site.conf" "$config/client-known-hosts" "$config/site.cache" "$enrol_file" \
701
1537
  "$config/upload-allow" "$config/uploads-dir" "$config/upload-watch.log"
702
1538
  rmdir "$config" 2>/dev/null || :
703
1539
  say 'stopped the upload agents (watch folder, upload helper service); removed the client, its settings and its logs'