-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathsetup-initcpio-tailscale
More file actions
executable file
·502 lines (453 loc) · 19 KB
/
Copy pathsetup-initcpio-tailscale
File metadata and controls
executable file
·502 lines (453 loc) · 19 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
#!/usr/bin/env bash
set -e
CMD0="${0##*/}"
TS_HOSTNAME="${HOSTNAME}-initrd"
TS_STATEDIR=/etc/initcpio/tailscale
PID=""
usage() {
cat <<EOF
${CMD0} launches and configures Tailscale daemon in an isolated environment that doesn't mess with the system service.
usage: ${CMD0} [option...]
The setup script shouldn't require any arguments to get it working, but keep in mind that any extra argument is passed as-is to 'tailscale up'.
See 'tailscale up --help' for available flags.
Defaults:
--hostname the current hostname plus an '-initrd' suffix, in this case "${TS_HOSTNAME}".
--ssh Tailscale's built-in SSH server is enabled, so the initramfs is
reachable without a second ssh server in it. OpenSSH host keys
are generated alongside the node key so the image presents the
same identity on every boot.
--no-ssh Turns that off, for images that run their own dropbear or
tinyssh instead. Only one of them can answer port 22 on the
tailnet, so don't enable both.
--tun[=name] Give the initramfs tailscaled a kernel TUN device instead of
its userspace network stack, and put the tun module back in
the image at the next rebuild. Off by default; plain '--tun'
names the interface tailscale0.
Instead of registering, '${CMD0} --check' verifies an existing setup without
changing anything: configuration files present, 'tailscale' placed correctly
in HOOKS=, and the built images actually carrying tailscaled.
i.e.:
${CMD0} --hostname ${TS_HOSTNAME} --login-server=headscale.my.net --authkey=file:node.key
If in doubt, run '$CMD0' without arguments.
EOF
}
info() { printf >&2 "${CMD0} INFO : %s\n" "$1"; }
error() { printf >&2 "${CMD0} ERROR: %s\n" "$1"; }
die() {
error "$@"
exit 1
}
# How to escalate for the few writes under /etc: not at all as root, otherwise
# the first of sudo/doas on PATH. Every use is a plain command prefix, which
# both take alike. XSU overrides the choice for anything else that does too.
xsu_pick() {
local c
[[ "$(id -u)" == 0 ]] && return 0
if [[ -n ${XSU:-} ]]; then
printf '%s' "$XSU"
return 0
fi
for c in sudo doas; do
if command -v "$c" >/dev/null; then
printf '%s' "$c"
return 0
fi
done
die "not root and neither sudo nor doas is available; re-run as root or set XSU"
}
# Everything that writes under /etc, in one place, running as plain root. The
# unprivileged flow reaches it through a single elevated re-invocation
# (--internal-install below), so this is the only code that needs auditing
# for privilege and the user is prompted at most once.
install_state() {
local setupdir=$1 ssh=$2 tun=$3
install -o root -g root -m600 -d "${TS_STATEDIR}"
install -o root -g root -m644 -t "${TS_STATEDIR}" "${setupdir}/tailscaled.state"
# default.env is the user's to edit -- the setup output says to -- and
# re-running this script (a new key, switching --ssh) is no reason to reset
# their PORT or FLAGS. Only a missing or empty file gets the defaults;
# empty counts because the install hook refuses to build from one anyway.
if [[ -s "${TS_STATEDIR}/default.env" ]]; then
info "keeping existing ${TS_STATEDIR}/default.env"
else
cat >"${TS_STATEDIR}/default.env" <<EOF
# Set the port to listen on for incoming VPN packets.
# Remote nodes will automatically be informed about the new port number,
# but you might want to configure this in order to set external firewall
# settings.
PORT="41641"
# Network stack for the initramfs tailscaled. Empty means userspace
# networking, which needs no kernel module; an interface name (tailscale0)
# means a kernel TUN device. Rebuild the initramfs after changing it, so the
# tun module is added or removed to match.
TUN="${tun}"
# Extra flags you might want to pass to tailscaled.
FLAGS=""
EOF
chmod 644 "${TS_STATEDIR}/default.env"
fi
# An explicit --tun updates a kept default.env too: set the TUN line to
# this run's choice, leaving everything else the user may have edited
# alone. When the flag was not given, an existing file's TUN stays theirs.
if [[ -n $tun ]] && grep -q '^TUN=' "${TS_STATEDIR}/default.env"; then
sed -i "s|^TUN=.*|TUN=\"${tun}\"|" "${TS_STATEDIR}/default.env"
elif [[ -n $tun ]]; then
printf 'TUN="%s"\n' "$tun" >>"${TS_STATEDIR}/default.env"
fi
if [[ $ssh == yes ]]; then
install -o root -g root -m600 -Dt "${TS_STATEDIR}/ssh" "${setupdir}/etc/ssh/"ssh_host_*_key
install -o root -g root -m644 -Dt "${TS_STATEDIR}/ssh" "${setupdir}/etc/ssh/"ssh_host_*_key.pub
else
# Host keys left behind by an earlier run would otherwise be copied into
# every image built from here on, for a server this node no longer runs.
rm -rf "${TS_STATEDIR}/ssh"
fi
}
# --- --check: read-only verification of an existing setup -------------------
CHECK_RC=0
chk_ok() { printf >&2 "${CMD0} OK : %s\n" "$1"; }
chk_warn() { printf >&2 "${CMD0} WARN : %s\n" "$1"; }
chk_fail() {
printf >&2 "${CMD0} FAIL : %s\n" "$1"
CHECK_RC=1
}
# The effective HOOKS= array, resolved the way mkinitcpio resolves it: the
# main file first, then the conf.d drop-ins in sorted order, later assignments
# overriding earlier ones. The subshell keeps the sourcing contained.
effective_hooks() {
(
HOOKS=()
for f in /etc/mkinitcpio.conf /etc/mkinitcpio.conf.d/*.conf; do
[[ -r $f ]] || continue
# shellcheck source=/dev/null
. "$f" 2>/dev/null
done
printf '%s\n' ${HOOKS[@]+"${HOOKS[@]}"}
)
}
run_check() {
# Privileged from the first probe: the state directory is root-only. Same
# single-boundary rule as the install -- re-exec the whole check as root
# once rather than prefixing every probe, which keeps the body plain and
# asks an escalation tool for a password at most once.
if [[ "$(id -u)" != 0 ]]; then
local xsu self
xsu=$(xsu_pick)
self=$(readlink -f "$0" 2>/dev/null) || self=$0
# Unquoted on purpose: XSU may name a tool plus its arguments.
# shellcheck disable=SC2086
exec $xsu "$self" --check
fi
# The configuration the install hook will copy into the image.
if [[ -s "${TS_STATEDIR}/tailscaled.state" ]]; then
chk_ok "node key present at ${TS_STATEDIR}/tailscaled.state"
else
chk_fail "no node key at ${TS_STATEDIR}/tailscaled.state; run ${CMD0} to register one"
fi
if [[ -s "${TS_STATEDIR}/default.env" ]]; then
chk_ok "daemon settings present at ${TS_STATEDIR}/default.env"
else
chk_fail "no ${TS_STATEDIR}/default.env; run ${CMD0} to write one"
fi
if [[ -s "${TS_STATEDIR}/ssh/ssh_host_ed25519_key" ]]; then
chk_ok "SSH host keys present: the image will answer Tailscale SSH"
else
chk_warn "no SSH host keys (registered with --no-ssh?): the image needs its own ssh server hook"
fi
# Placement in HOOKS=. Wrong order fails silently at boot, so it is the one
# thing most worth checking; see the README for why each rule holds.
local hooks=() h i ts=-1 sd=-1 net=-1 enc=-1 encname=''
mapfile -t hooks < <(effective_hooks)
i=0
for h in "${hooks[@]}"; do
case $h in
tailscale) ts=$i ;;
systemd) sd=$i ;;
sd-network | net | netconf) net=$i ;;
sd-encrypt | encrypt | encryptssh)
enc=$i
encname=$h
;;
esac
i=$((i + 1))
done
if ((ts < 0)); then
if ((${#hooks[@]} == 0)); then
chk_fail "could not read a HOOKS= array from /etc/mkinitcpio.conf"
else
chk_fail "'tailscale' is not in HOOKS=; add it after the network hook and before any encrypt hook"
fi
else
chk_ok "'tailscale' is in HOOKS="
if ((sd >= 0)) && ((sd > ts)); then
chk_fail "'tailscale' is listed before 'systemd': the hook builds the busybox layout there, which a systemd initramfs never runs"
fi
if ((enc >= 0)) && ((enc < ts)); then
chk_fail "'tailscale' is listed after '${encname}': boot blocks at the passphrase prompt before tailscaled ever starts"
fi
if ((net < 0)); then
chk_warn "no network hook (sd-network, net or netconf) in HOOKS=: tailscaled will have no address unless something else configures one"
elif ((net > ts)); then
chk_fail "the network hook is listed after 'tailscale'; move it before"
else
chk_ok "a network hook precedes 'tailscale'"
fi
fi
# Key expiry, the one setup step nothing else can verify: an expired initrd
# node cannot reach the tailnet, and you would find out while locked out of
# a machine waiting for its passphrase. The booted system's own tailscale
# sees the initrd node as a peer, and a node with expiry disabled -- what
# the setup output asks for -- simply has no KeyExpiry. Every way this can
# be inapplicable is a warning: it is a courtesy check on someone else's
# control plane, not a verdict on this machine's configuration.
local expiry='' status=''
if ! command -v tailscale >/dev/null; then
chk_warn "tailscale is not installed here, so '${TS_HOSTNAME}' key expiry cannot be checked"
elif ! command -v jq >/dev/null; then
chk_warn "jq is not installed, so '${TS_HOSTNAME}' key expiry cannot be checked"
elif ! status=$(tailscale status --json 2>/dev/null); then
chk_warn "the system tailscale is not running, so '${TS_HOSTNAME}' key expiry cannot be checked"
else
expiry=$(printf '%s' "$status" |
jq -r --arg n "$TS_HOSTNAME" '[.Peer[]? | select(.HostName == $n)][0].KeyExpiry // ""' 2>/dev/null)
if [[ -z $expiry ]]; then
# No peer of that name, or one with expiry disabled. Telling them apart
# keeps a custom --hostname from reading as an all-clear.
if printf '%s' "$status" |
jq -e --arg n "$TS_HOSTNAME" '[.Peer[]? | select(.HostName == $n)] | length > 0' >/dev/null 2>&1; then
chk_ok "key expiry is disabled for '${TS_HOSTNAME}'"
else
chk_warn "'${TS_HOSTNAME}' is not among this tailnet's peers, so its key expiry cannot be checked (registered under another name? pass --hostname=)"
fi
else
local now left
now=$(date +%s)
left=$(($(date -d "$expiry" +%s 2>/dev/null || echo "$now") - now))
if ((left <= 0)); then
chk_fail "the key for '${TS_HOSTNAME}' expired on ${expiry%T*}; the node cannot reach your tailnet. Re-run ${CMD0}, then disable expiry at https://console.tailscale.com/admin/machines"
elif ((left < 14 * 86400)); then
chk_fail "the key for '${TS_HOSTNAME}' expires on ${expiry%T*}, in $((left / 86400)) days; disable expiry at https://console.tailscale.com/admin/machines"
else
chk_warn "the key for '${TS_HOSTNAME}' expires on ${expiry%T*}, in $((left / 86400)) days; disabling expiry is what keeps an initrd node reachable"
fi
fi
fi
# Best effort: do built images actually carry the daemon? Skipped when the
# tools or images are not where this expects them. When TUN= asks for a
# kernel device, the module has to be in the image too -- configured but not
# rebuilt is exactly the silent break this mode can produce.
local img found=0 checked=0 list tun
# The subshell keeps the sourcing contained, the same way the install hook
# reads it at build time.
# shellcheck source=/dev/null
tun=$(. "${TS_STATEDIR}/default.env" 2>/dev/null; printf '%s' "${TUN:-}")
if command -v lsinitcpio >/dev/null; then
for img in /boot/initramfs-*.img; do
[[ -e $img ]] || continue
checked=1
list=$(lsinitcpio -l "$img" 2>/dev/null)
if grep -q 'tailscaled$' <<<"$list"; then
found=1
chk_ok "$img contains tailscaled"
else
chk_warn "$img does not contain tailscaled (built before the hook was added?)"
fi
if [[ -n $tun && $tun != userspace-networking ]] && grep -q 'tailscaled$' <<<"$list"; then
if grep -q '/tun\.ko' <<<"$list"; then
chk_ok "$img carries the tun module for TUN=\"$tun\""
else
chk_fail "$img lacks the tun module but default.env sets TUN=\"$tun\"; run 'mkinitcpio -P'"
fi
fi
done
((checked)) || chk_warn 'no /boot/initramfs-*.img found to inspect'
if ((checked && !found)); then
chk_fail "no inspected image contains tailscaled; run 'mkinitcpio -P' and re-check"
fi
fi
if ((CHECK_RC == 0)); then
info 'all checks passed'
else
error 'some checks failed; see above'
fi
exit "$CHECK_RC"
}
cleanup() {
# Whatever brought us here, on the way out. Exiting 0 unconditionally, as this
# used to, reported every failure -- die() included -- as a successful setup.
local rc=$?
if [[ -n "${SETUPDIR:-}" ]]; then
rm -rf "${SETUPDIR}"
fi
if [[ -n "$PID" ]]; then
kill "$PID" 2>/dev/null || true
fi
exit "$rc"
}
trap "cleanup" EXIT
# The elevated half of the flow: not part of the public interface, reached
# only through the re-invocation above install_state. Everything it needs
# arrives as arguments, so sudo's environment scrubbing has nothing to break.
if [[ ${1:-} == --internal-install ]]; then
[[ "$(id -u)" == 0 ]] || die "--internal-install is internal and runs as root only"
[[ $# -eq 4 ]] || die "--internal-install expects <setupdir> <ssh> <tun>"
install_state "$2" "$3" "$4"
exit 0
fi
# Scan the arguments for the flags this script has an opinion about, and note
# whether the user stated them so the defaults below only fill in gaps.
#
# Tailscale SSH is on unless asked otherwise: an initrd node exists to be logged
# into, and enabling it here is what saves the image from needing a dropbear or
# tinyssh of its own. '--no-ssh' and '--tun' are this script's own spellings --
# 'tailscale up' has neither -- so they are dropped from the arguments rather
# than passed on.
#
# --tun opts back into a kernel TUN device for the initramfs tailscaled, which
# otherwise runs on its own userspace network stack. It lands in default.env as
# TUN=, where the runtime hooks read it and the install hook decides whether
# the tun module belongs in the image.
TS_SSH=yes
TS_TUN=''
DO_CHECK=no
FOUND_HOSTNAME_IN_ARGS=no
FOUND_SSH_IN_ARGS=no
FOUND_NETFILTER_IN_ARGS=no
TS_ARGS=()
WANT_HOSTNAME=no
for arg in "$@"; do
if [[ $WANT_HOSTNAME == yes ]]; then
# The value half of a space separated '--hostname NAME'.
WANT_HOSTNAME=no
TS_HOSTNAME=${arg//[\"\']/}
TS_ARGS+=("$arg")
continue
fi
# Matching is done with the leading dashes stripped: 'tailscale up' is a Go
# flag parser and takes '-ssh' and '--ssh' alike, so this script does too.
flag=${arg#-}
flag=${flag#-}
case $flag in
h | help)
usage
exit 0
;;
check)
# Deferred until the scan finishes: the check resolves the initrd node by
# hostname, and a --hostname given after --check has not been seen yet.
DO_CHECK=yes
continue
;;
hostname)
FOUND_HOSTNAME_IN_ARGS=yes
WANT_HOSTNAME=yes
;;
hostname=*)
TS_HOSTNAME=${flag#*=}
TS_HOSTNAME=${TS_HOSTNAME//[\"\']/}
FOUND_HOSTNAME_IN_ARGS=yes
;;
no-ssh)
TS_SSH=no
FOUND_SSH_IN_ARGS=yes
continue
;;
tun)
TS_TUN=tailscale0
continue
;;
tun=*)
TS_TUN=${flag#*=}
TS_TUN=${TS_TUN//[\"\']/}
[[ -n $TS_TUN ]] || die "--tun= needs an interface name"
continue
;;
ssh | ssh=*)
FOUND_SSH_IN_ARGS=yes
# The spellings Go's flag package accepts for a boolean.
case ${flag#ssh} in
'' | =1 | =t | =T | =true | =TRUE | =True) TS_SSH=yes ;;
*) TS_SSH=no ;;
esac
;;
netfilter-mode | netfilter-mode=*)
FOUND_NETFILTER_IN_ARGS=yes
;;
esac
TS_ARGS+=("$arg")
done
[[ $WANT_HOSTNAME == no ]] || die "--hostname needs a value"
# --check verifies and exits; TS_HOSTNAME is settled by now, so it knows which
# node to look for.
[[ $DO_CHECK == no ]] || run_check
# Defaults are prepended, so an argument the user did pass always has the last
# word with the flag parser on the other end.
#
# netfilter-mode is a preference: it registers into the state file and is what
# tailscaled applies when the image later loads that state. Off by default
# because the initramfs node routes nothing and runs for minutes, and it is
# what lets the install hook leave the whole netfilter userland out of the
# image.
set -- "${TS_ARGS[@]}"
[[ $FOUND_NETFILTER_IN_ARGS == yes ]] || set -- --netfilter-mode=off "$@"
[[ $FOUND_SSH_IN_ARGS == yes ]] || set -- --ssh "$@"
[[ $FOUND_HOSTNAME_IN_ARGS == yes ]] || set -- --hostname="$TS_HOSTNAME" "$@"
SETUPDIR="$(mktemp -d)"
socket="${SETUPDIR}/tailscaled.sock"
state="${SETUPDIR}/tailscaled.state"
if [[ $TS_SSH == yes ]]; then
# Checked before anything registers, so a missing openssh fails cleanly
# instead of via set -e halfway through, and names the way out.
command -v ssh-keygen >/dev/null ||
die "ssh-keygen not found: install openssh, or pass --no-ssh to skip Tailscale SSH"
# ssh-keygen -A writes into <prefix>/etc/ssh and will not create the directory
# itself. The keys are the initrd node's stable identity: generating them here,
# rather than letting the image make new ones on each boot, is what keeps a
# client from warning about a changed host key every time the machine reboots.
mkdir -p "${SETUPDIR}/etc/ssh"
ssh-keygen -A -f "${SETUPDIR}"
fi
tailscaled \
-state="$state" \
-socket="$socket" \
-no-logs-no-support \
-tun=userspace-networking \
>"${SETUPDIR}/setup.log" 2>&1 &
PID="$!"
# --accept-risk=lose-ssh is fine because we are setting up an isolated tailscaled daemon,
# it has nothing to do with system tailscale service.
if ! tailscale --socket="$socket" up --qr --accept-risk=lose-ssh "$@"; then
cp -f "${SETUPDIR}/setup.log" /tmp/setup-initcpio-tailscale-daemon.log
die "Failed to configure tailscale. Check daemon logs at /tmp/setup-initcpio-tailscale-daemon.log"
fi
# The one escalation boundary of the whole flow: as root, install the state
# directly; otherwise re-invoke this script once, elevated, and let the same
# code run plainly there. One password prompt on any escalation tool.
if [[ "$(id -u)" == 0 ]]; then
install_state "$SETUPDIR" "$TS_SSH" "$TS_TUN"
else
SELF=$(readlink -f "$0" 2>/dev/null) || SELF=$0
xsu=$(xsu_pick)
# Unquoted on purpose: XSU may name a tool plus its arguments.
# shellcheck disable=SC2086
$xsu "$SELF" --internal-install "$SETUPDIR" "$TS_SSH" "$TS_TUN" ||
die "the privileged install step failed; nothing was written"
fi
if [[ $TS_SSH == yes ]]; then
ssh_note=" * Tailscale SSH is enabled, so you will log in with 'ssh root@${TS_HOSTNAME}'
and need no ssh server hook in the image. Re-run with '--no-ssh' to use your own"
else
ssh_note=" * Tailscale SSH is disabled, so add an ssh server hook (dropbear or tinyssh)
to /etc/mkinitcpio.conf or you will have no way to enter the passphrase"
fi
info "tailscale successfully configured.
Next steps:
* Disable key expiry for '${TS_HOSTNAME}' at https://console.tailscale.com/admin/machines
* Review ${TS_STATEDIR}/default.env (as root)
* Edit /etc/mkinitcpio.conf and add 'tailscale'. A safe choice is to insert it right before 'sd-encrypt' or 'encrypt*' hooks
* Add a network hook too -- 'sd-network' on a systemd image, 'net' or 'netconf' on a busybox one -- since nothing in mkinitcpio's stock hooks brings up an interface
${ssh_note}
* Run 'mkinitcpio -P' to rebuild initramfs
* Check the README at https://github.com/dangra/mkinitcpio-tailscale for security considerations
enjoy!
"