@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
package/payload/lanes/install.sh
CHANGED
|
@@ -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
|
-
#
|
|
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
|
|
15
|
-
# --
|
|
16
|
-
#
|
|
17
|
-
# --no-desktop
|
|
18
|
-
#
|
|
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;
|
|
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
|
|
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
|
-
|
|
41
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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=$(
|
|
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
|
-
|
|
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
|
|
634
|
-
|
|
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;
|
|
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
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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'
|