Repository navigation
Expand file tree
/
Copy pathbootstrap-vps.sh
More file actions
executable file
·751 lines (644 loc) · 25.1 KB
/
Copy pathbootstrap-vps.sh
File metadata and controls
executable file
·751 lines (644 loc) · 25.1 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
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
#!/usr/bin/env bash
set -euo pipefail
# VPS Bootstrap & Hardening Script
# Assumes: Debian/Ubuntu, running as root, SSH keys already on the box.
#
# Usage:
# USERNAME=rich DOTFILES_REPO=https://github.com/you/dotfiles.git ./bootstrap-vps.sh
#
# Safety:
# Remote access changes use the separate staged lockdown script.
# --- Configuration (override via environment) --------------------------------
USERNAME="${USERNAME:-rich}"
DOTFILES_REPO="${DOTFILES_REPO:-https://github.com/YOURNAME/dotfiles.git}"
SSH_PORT="${SSH_PORT:-22}"
SWAP_SIZE_MB="${SWAP_SIZE_MB:-2048}"
LOCK_SSH_TO_TAILSCALE="${LOCK_SSH_TO_TAILSCALE:-0}"
DISABLE_ROOT_LOGIN="${DISABLE_ROOT_LOGIN:-0}"
ALLOW_PASSWORDLESS_SUDO="${ALLOW_PASSWORDLESS_SUDO:-0}"
COPY_ROOT_AUTH_KEYS="${COPY_ROOT_AUTH_KEYS:-0}"
TRUST_ON_FIRST_USE_INSTALLERS="${TRUST_ON_FIRST_USE_INSTALLERS:-0}"
MAX_AUTH_TRIES="${MAX_AUTH_TRIES:-3}"
LOGIN_GRACE_TIME="${LOGIN_GRACE_TIME:-20}"
CLIENT_ALIVE_INTERVAL="${CLIENT_ALIVE_INTERVAL:-300}"
CLIENT_ALIVE_COUNT_MAX="${CLIENT_ALIVE_COUNT_MAX:-2}"
PINNED_CHEZMOI_VERSION="${PINNED_CHEZMOI_VERSION:-2.69.4}"
PINNED_CHEZMOI_AMD64_DEB_SHA256="${PINNED_CHEZMOI_AMD64_DEB_SHA256:-3f2a4c46d7f13a71db417041ec1e165b05a8d1be4e22cd137ac79423aac6770a}"
PINNED_CHEZMOI_ARM64_DEB_SHA256="${PINNED_CHEZMOI_ARM64_DEB_SHA256:-2129b8a1b925d022e42ddd0065c1fde8df361219a682b47aac54ea952922c1da}"
F2B_MAXRETRY="${F2B_MAXRETRY:-3}"
F2B_FINDTIME="${F2B_FINDTIME:-10m}"
F2B_BANTIME="${F2B_BANTIME:-1h}"
export DEBIAN_FRONTEND=noninteractive
# --- Logging & helpers -------------------------------------------------------
if [[ "${BASH_SOURCE[0]}" = "$0" ]]; then
LOG_FILE="/var/log/bootstrap.log"
touch "${LOG_FILE}"
chmod 600 "${LOG_FILE}"
exec > >(tee -a "${LOG_FILE}") 2>&1
fi
VERBOSE="${VERBOSE:-false}"
vecho() { [[ "${VERBOSE}" == "true" ]] && echo "$@" || true; }
trap 'echo "FAILED at line ${LINENO} in ${FUNCNAME[0]:-main}" >&2' ERR
SECONDS=0
wait_for_apt_lock() {
local attempts=0
while fuser /var/lib/dpkg/lock /var/lib/dpkg/lock-frontend \
/var/lib/apt/lists/lock /var/cache/apt/archives/lock \
>/dev/null 2>&1; do
if (( ++attempts >= 30 )); then
echo "ERROR: apt lock held for too long." >&2
exit 1
fi
vecho "Waiting for apt lock (${attempts}/30)..."
sleep 10
done
}
apt_install() {
wait_for_apt_lock
apt-get install -y --no-install-recommends "$@"
}
require_trust_for_remote_installer() {
local installer="$1"
if [[ "${TRUST_ON_FIRST_USE_INSTALLERS}" != "1" ]]; then
echo "ERROR: Refusing to run remote installer '${installer}' without explicit trust." >&2
echo "Set TRUST_ON_FIRST_USE_INSTALLERS=1 to allow this installer." >&2
exit 1
fi
}
download_file() {
local url="$1" dest="$2"
curl --fail --location --show-error --silent \
--proto '=https' --tlsv1.2 \
--retry 3 --retry-delay 2 \
--connect-timeout 10 --max-time 300 \
"$url" -o "$dest"
}
sha256_file() {
local file="$1"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$file" | awk '{print $1}'
else
shasum -a 256 "$file" | awk '{print $1}'
fi
}
verify_sha256() {
local file="$1" expected="$2"
[[ "$(sha256_file "$file")" == "$expected" ]]
}
authorized_keys_has_key() {
awk 'NF >= 2 && $1 ~ /^(ssh-(ed25519|rsa)|ecdsa-sha2-|sk-ssh-)/ && $2 ~ /^[A-Za-z0-9+\/=]+$/ { found=1 } END { exit !found }' "$1" || return 1
command -v ssh-keygen >/dev/null 2>&1 || return 1
ssh-keygen -l -f "$1" >/dev/null 2>&1
}
# --- Validation --------------------------------------------------------------
validate() {
[[ "${EUID}" -eq 0 ]] || { echo "ERROR: Must run as root."; exit 1; }
[[ "$USERNAME" =~ ^[a-z_][a-z0-9_-]*$ && "$USERNAME" != root ]] || {
echo "ERROR: USERNAME must name a non-root local user." >&2; exit 1;
}
[[ "$SSH_PORT" =~ ^[0-9]+$ ]] && (( SSH_PORT >= 1 && SSH_PORT <= 65535 )) || {
echo "ERROR: SSH_PORT must be between 1 and 65535." >&2; exit 1;
}
local flag
for flag in LOCK_SSH_TO_TAILSCALE DISABLE_ROOT_LOGIN ALLOW_PASSWORDLESS_SUDO COPY_ROOT_AUTH_KEYS TRUST_ON_FIRST_USE_INSTALLERS; do
[[ "${!flag}" = 0 || "${!flag}" = 1 ]] || {
echo "ERROR: $flag must be 0 or 1." >&2; exit 1;
}
done
if [[ "$DISABLE_ROOT_LOGIN" = 1 || "$LOCK_SSH_TO_TAILSCALE" = 1 ]]; then
echo "ERROR: Use scripts/server-lockdown-tailscale.sh from a working Tailscale SSH session for access restriction and timed recovery." >&2
exit 1
fi
if [[ "$COPY_ROOT_AUTH_KEYS" = 1 && ! -s /root/.ssh/authorized_keys ]]; then
echo "ERROR: Root authorized_keys is missing or empty." >&2; exit 1
fi
if [[ "$COPY_ROOT_AUTH_KEYS" = 1 ]] && ! authorized_keys_has_key /root/.ssh/authorized_keys; then
echo "ERROR: Root authorized_keys has no usable public key." >&2; exit 1
fi
if ! id -u "$USERNAME" >/dev/null 2>&1 && [[ "$COPY_ROOT_AUTH_KEYS" != 1 ]]; then
echo "ERROR: Create the non-root user and install its SSH key before system updates, or select COPY_ROOT_AUTH_KEYS=1." >&2
exit 1
fi
# shellcheck source=/dev/null
source /etc/os-release 2>/dev/null || { echo "ERROR: /etc/os-release not found."; exit 1; }
case "${ID:-}" in
debian|ubuntu) vecho "Detected: ${PRETTY_NAME:-${ID}}" ;;
*) echo "ERROR: Unsupported OS '${ID:-unknown}'. Requires Debian/Ubuntu."; exit 1 ;;
esac
[[ "${DOTFILES_REPO}" != *"YOURNAME"* ]] || {
echo "ERROR: Set DOTFILES_REPO to your actual repo URL."; exit 1;
}
curl -fsS --max-time 10 https://github.com >/dev/null 2>&1 || {
echo "ERROR: Cannot reach github.com. Check network."; exit 1;
}
vecho "Validation passed"
}
# --- System preparation ------------------------------------------------------
system_update() {
echo "== System update =="
wait_for_apt_lock
apt-get update -y
apt-get dist-upgrade -y
}
configure_swap() {
echo "== Swap =="
if swapon --show | grep -q '/'; then
vecho "Swap already active"
return 0
fi
local swapfile="/swapfile"
if [[ ! -f "${swapfile}" ]]; then
dd if=/dev/zero of="${swapfile}" bs=1M count="${SWAP_SIZE_MB}" status=none
chmod 600 "${swapfile}"
mkswap "${swapfile}" >/dev/null
fi
swapon "${swapfile}"
grep -q "${swapfile}" /etc/fstab || echo "${swapfile} none swap sw 0 0" >> /etc/fstab
vecho "Swap active: ${SWAP_SIZE_MB}MB"
}
configure_locale() {
echo "== Timezone & locale =="
timedatectl set-timezone UTC 2>/dev/null || ln -sf /usr/share/zoneinfo/UTC /etc/localtime
if ! locale -a 2>/dev/null | grep -qi 'en_US.utf8'; then
apt_install locales
sed -i 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen
locale-gen en_US.UTF-8
fi
update-locale LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 2>/dev/null || true
}
# --- User setup --------------------------------------------------------------
setup_user() {
echo "== User setup =="
if ! id -u "${USERNAME}" >/dev/null 2>&1; then
adduser --disabled-password --gecos "" "${USERNAME}"
fi
USER_HOME="$(getent passwd "$USERNAME" | cut -d: -f6)"
[[ -n "$USER_HOME" && -d "$USER_HOME" ]] || {
echo "ERROR: Cannot resolve a home directory for $USERNAME." >&2; return 1;
}
apt_install sudo
usermod -aG sudo "${USERNAME}"
if [[ "${ALLOW_PASSWORDLESS_SUDO}" == "1" ]]; then
echo "${USERNAME} ALL=(ALL) NOPASSWD:ALL" > "/etc/sudoers.d/${USERNAME}"
chmod 440 "/etc/sudoers.d/${USERNAME}"
else
echo "Passwordless sudo was not configured by this run. Review any existing user sudo rule."
fi
# Copy root's SSH keys to the new user (opt-in only)
if [[ "${COPY_ROOT_AUTH_KEYS}" == "1" ]]; then
if [[ -f /root/.ssh/authorized_keys ]]; then
local target_keys="$USER_HOME/.ssh/authorized_keys" key_line
[[ ! -L "$USER_HOME/.ssh" && ! -L "$target_keys" ]] || {
echo "ERROR: Refusing to write SSH keys through a symlink." >&2
return 1
}
install -d -m 700 -o "${USERNAME}" -g "$(id -gn "$USERNAME")" "$USER_HOME/.ssh"
if [[ ! -e "$target_keys" ]]; then
install -m 600 -o "${USERNAME}" -g "$(id -gn "$USERNAME")" \
/root/.ssh/authorized_keys "$target_keys"
else
while IFS= read -r key_line || [[ -n "$key_line" ]]; do
[[ -n "$key_line" ]] || continue
grep -Fqx -- "$key_line" "$target_keys" || printf '%s\n' "$key_line" >> "$target_keys"
done < /root/.ssh/authorized_keys
chown "${USERNAME}:$(id -gn "$USERNAME")" "$target_keys"
chmod 600 "$target_keys"
fi
else
echo "WARNING: /root/.ssh/authorized_keys not found."
fi
else
echo "Root SSH key copy disabled by default (set COPY_ROOT_AUTH_KEYS=1 to enable)."
fi
}
write_security_flags() {
local state_dir="$USER_HOME/.config/bootstrap"
local state_file="${state_dir}/security-flags.json"
install -d -m 700 -o "${USERNAME}" -g "$(id -gn "$USERNAME")" "${state_dir}"
cat > "${state_file}" <<EOF
{"allow_passwordless_sudo": ${ALLOW_PASSWORDLESS_SUDO}, "copy_root_auth_keys": ${COPY_ROOT_AUTH_KEYS}, "trust_on_first_use_installers": ${TRUST_ON_FIRST_USE_INSTALLERS}}
EOF
chown "${USERNAME}:$(id -gn "$USERNAME")" "${state_file}"
chmod 600 "${state_file}"
vecho "Wrote bootstrap security flags to ${state_file}"
}
ensure_ssh_access() {
local ssh_dir="$USER_HOME/.ssh" keys="$USER_HOME/.ssh/authorized_keys"
[[ ! -L "$ssh_dir" && ! -L "$keys" ]] || {
echo "ERROR: SSH key directory or file must not be a symlink." >&2; return 1;
}
if [[ ! -s "$keys" ]]; then
echo "ERROR: $keys is missing or empty." >&2
echo "Provide SSH keys before proceeding, or re-run with COPY_ROOT_AUTH_KEYS=1." >&2
return 1
fi
[[ "$(stat -c %u "$ssh_dir")" = "$(id -u "$USERNAME")" && "$(stat -c %u "$keys")" = "$(id -u "$USERNAME")" ]] || {
echo "ERROR: SSH key directory or file has the wrong owner." >&2; return 1;
}
[[ "$(stat -c %a "$ssh_dir")" =~ ^7[0-5][0-5]$ && "$(stat -c %a "$keys")" =~ ^[0-7][0-5][0-5]$ ]] || {
echo "ERROR: SSH key directory or file permissions are too broad." >&2; return 1;
}
authorized_keys_has_key "$keys" || {
echo "ERROR: $keys has no usable public key." >&2; return 1;
}
}
# --- SSH hardening -----------------------------------------------------------
harden_sshd() {
echo "== SSH hardening =="
apt_install openssh-server
# Remove weak host keys; ensure Ed25519 + RSA exist
rm -f /etc/ssh/ssh_host_{dsa,ecdsa}_key{,.pub}
[[ -f /etc/ssh/ssh_host_ed25519_key ]] || \
ssh-keygen -t ed25519 -f /etc/ssh/ssh_host_ed25519_key -N "" >/dev/null
[[ -f /etc/ssh/ssh_host_rsa_key ]] || \
ssh-keygen -t rsa -b 4096 -f /etc/ssh/ssh_host_rsa_key -N "" >/dev/null
local allow_users="root ${USERNAME}" root_login="prohibit-password"
local dropin=/etc/ssh/sshd_config.d/99-hardening.conf backup="" had_dropin=0
if [[ -e "$dropin" ]]; then
backup="$(mktemp /etc/ssh/sshd_config.d/.99-hardening.XXXXXXXX)"
cp -p "$dropin" "$backup"
had_dropin=1
fi
cat > "$dropin" <<EOF || { restore_sshd_dropin "$dropin" "$backup" "$had_dropin"; return 1; }
Port ${SSH_PORT}
# Authentication
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
PermitRootLogin ${root_login}
AllowUsers ${allow_users}
# Brute-force limits
MaxAuthTries ${MAX_AUTH_TRIES}
LoginGraceTime ${LOGIN_GRACE_TIME}
ClientAliveInterval ${CLIENT_ALIVE_INTERVAL}
ClientAliveCountMax ${CLIENT_ALIVE_COUNT_MAX}
# Host keys
HostKeyAlgorithms ssh-ed25519,ssh-ed25519-cert-v01@openssh.com,rsa-sha2-512,rsa-sha2-256
HostKey /etc/ssh/ssh_host_ed25519_key
HostKey /etc/ssh/ssh_host_rsa_key
EOF
if ! sshd -t; then
echo "ERROR: sshd config invalid." >&2
restore_sshd_dropin "$dropin" "$backup" "$had_dropin"
return 1
fi
local effective
if ! effective="$(sshd -T -C "user=$USERNAME,host=localhost,addr=127.0.0.1")"; then
restore_sshd_dropin "$dropin" "$backup" "$had_dropin"
return 1
fi
if ! { grep -Eq '^passwordauthentication no$' <<< "$effective" &&
grep -Eq '^kbdinteractiveauthentication no$' <<< "$effective" &&
grep -Eq '^pubkeyauthentication yes$' <<< "$effective" &&
grep -Eq "^port $SSH_PORT$" <<< "$effective" &&
grep -Eq "^permitrootlogin $root_login$" <<< "$effective"; }; then
echo "ERROR: Effective SSH settings differ from the managed hardening file. Check earlier includes and Match blocks." >&2
restore_sshd_dropin "$dropin" "$backup" "$had_dropin"
return 1
fi
local root_effective
root_effective="$(sshd -T -C "user=root,host=localhost,addr=127.0.0.1")" || {
restore_sshd_dropin "$dropin" "$backup" "$had_dropin"
return 1
}
if ! grep -Eq '^permitrootlogin prohibit-password$' <<< "$root_effective"; then
echo "ERROR: Effective root SSH policy differs from the managed hardening file." >&2
restore_sshd_dropin "$dropin" "$backup" "$had_dropin"
return 1
fi
if ! systemctl enable ssh || ! { systemctl restart ssh || systemctl restart sshd; }; then
echo "ERROR: SSH service could not restart; restoring its prior configuration." >&2
restore_sshd_dropin "$dropin" "$backup" "$had_dropin"
return 1
fi
if ! ss -ltnH | awk -v port="$SSH_PORT" '$4 ~ (":" port "$") {found=1} END {exit !found}'; then
echo "ERROR: SSH is not listening on the selected port; restoring its prior configuration." >&2
restore_sshd_dropin "$dropin" "$backup" "$had_dropin"
return 1
fi
[[ -z "$backup" ]] || rm -f "$backup"
}
restore_sshd_dropin() {
local dropin="$1" backup="$2" had_dropin="$3"
if [[ "$had_dropin" = 1 ]]; then
cp -p "$backup" "$dropin"
rm -f "$backup"
else
rm -f "$dropin"
fi
systemctl restart ssh || systemctl restart sshd || true
}
# --- Firewall ----------------------------------------------------------------
configure_ufw() {
echo "== Firewall =="
apt_install ufw
ufw allow "${SSH_PORT}/tcp"
ufw default deny incoming
ufw default allow outgoing
if command -v tailscale >/dev/null 2>&1; then
ufw allow 41641/udp comment "Tailscale"
fi
ufw --force enable
}
# --- Kernel hardening --------------------------------------------------------
harden_kernel() {
echo "== Kernel hardening =="
cat > /etc/sysctl.d/99-hardening.conf <<'EOF'
# SYN flood protection
net.ipv4.tcp_syncookies = 1
# Disable ICMP redirects
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
net.ipv6.conf.default.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0
# Reject source-routed packets
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.conf.default.accept_source_route = 0
net.ipv6.conf.all.accept_source_route = 0
net.ipv6.conf.default.accept_source_route = 0
# Reverse path filtering
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
# Ignore broadcast pings
net.ipv4.icmp_echo_ignore_broadcasts = 1
# Log martian packets
net.ipv4.conf.all.log_martians = 1
net.ipv4.conf.default.log_martians = 1
# Hide kernel pointers from non-root
kernel.kptr_restrict = 2
# Restrict dmesg to root
kernel.dmesg_restrict = 1
# Protect hardlinks and symlinks
fs.protected_hardlinks = 1
fs.protected_symlinks = 1
# Protect FIFOs and regular files in world-writable dirs
fs.protected_fifos = 2
fs.protected_regular = 2
EOF
sysctl --system >/dev/null 2>&1
}
# --- Auto-updates ------------------------------------------------------------
configure_unattended_upgrades() {
echo "== Auto security updates =="
apt_install unattended-upgrades
cat > /etc/apt/apt.conf.d/20auto-upgrades <<'EOF'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
EOF
systemctl enable unattended-upgrades || true
}
# --- fail2ban ----------------------------------------------------------------
configure_fail2ban() {
echo "== fail2ban =="
apt_install fail2ban
cat > /etc/fail2ban/jail.d/sshd.local <<EOF
[sshd]
enabled = true
port = ${SSH_PORT}
backend = systemd
maxretry = ${F2B_MAXRETRY}
findtime = ${F2B_FINDTIME}
bantime = ${F2B_BANTIME}
EOF
# Whitelist Tailscale CGNAT range so fail2ban never bans Tailscale peers
if command -v tailscale >/dev/null 2>&1; then
cat > /etc/fail2ban/jail.d/tailscale-whitelist.conf <<'EOF'
[DEFAULT]
ignoreip = 127.0.0.1/8 ::1 100.64.0.0/10
EOF
fi
systemctl enable fail2ban
systemctl restart fail2ban
}
# --- Tailscale ---------------------------------------------------------------
install_tailscale() {
echo "== Tailscale =="
if command -v tailscale >/dev/null 2>&1; then
# Ensure the non-root user can manage Tailscale without sudo
tailscale set --operator="${USERNAME}" 2>/dev/null || true
vecho "Already installed"
return 0
fi
if apt-cache show tailscale >/dev/null 2>&1; then
apt_install tailscale
systemctl enable tailscaled || true
systemctl start tailscaled || true
tailscale set --operator="${USERNAME}" 2>/dev/null || true
echo "Tailscale installed. Run 'tailscale up' to authenticate."
else
echo "WARNING: tailscale package not available in apt sources. Install manually and rerun if needed."
fi
}
# --- Dotfiles ----------------------------------------------------------------
install_dotfiles() {
echo "== Dotfiles =="
apt_install git ca-certificates curl bat python3
if ! command -v delta >/dev/null 2>&1; then
if apt-cache show git-delta >/dev/null 2>&1; then
if ! apt_install git-delta; then
echo "WARNING: failed to install git-delta; continuing with native diff tooling."
fi
elif apt-cache show delta >/dev/null 2>&1; then
if ! apt_install delta; then
echo "WARNING: failed to install delta package; continuing with native diff tooling."
fi
else
echo "WARNING: no apt package found for delta (git-delta/delta)."
fi
fi
if ! command -v eza >/dev/null 2>&1 && ! command -v exa >/dev/null 2>&1; then
if apt-cache show eza >/dev/null 2>&1; then
if ! apt_install eza; then
echo "WARNING: failed to install eza; continuing with native ls."
fi
elif apt-cache show exa >/dev/null 2>&1; then
if ! apt_install exa; then
echo "WARNING: failed to install exa; continuing with native ls."
fi
else
echo "WARNING: no apt package found for eza/exa."
fi
fi
local chezmoi_bin="/usr/local/bin/chezmoi"
if [[ ! -x "${chezmoi_bin}" ]]; then
if apt-cache show chezmoi >/dev/null 2>&1; then
apt_install chezmoi
chezmoi_bin="$(command -v chezmoi)"
else
require_trust_for_remote_installer "GitHub release package download"
local arch deb_name deb_sha deb_url deb_path
arch="$(dpkg --print-architecture)"
case "$arch" in
amd64)
deb_name="chezmoi_${PINNED_CHEZMOI_VERSION}_linux_amd64.deb"
deb_sha="${PINNED_CHEZMOI_AMD64_DEB_SHA256}"
;;
arm64)
deb_name="chezmoi_${PINNED_CHEZMOI_VERSION}_linux_arm64.deb"
deb_sha="${PINNED_CHEZMOI_ARM64_DEB_SHA256}"
;;
*)
echo "ERROR: Unsupported architecture for pinned chezmoi package: ${arch}" >&2
return 1
;;
esac
deb_url="https://github.com/twpayne/chezmoi/releases/download/v${PINNED_CHEZMOI_VERSION}/${deb_name}"
deb_path="/tmp/${deb_name}"
download_file "$deb_url" "$deb_path"
if ! verify_sha256 "$deb_path" "$deb_sha"; then
echo "ERROR: checksum verification failed for ${deb_name}" >&2
return 1
fi
dpkg -i "$deb_path" || apt-get install -f -y
rm -f "$deb_path"
chezmoi_bin="$(command -v chezmoi)"
fi
# Installer may ignore -b and put it in ~/.local/bin; move it if so
if [[ ! -x "${chezmoi_bin}" && -x "${HOME}/.local/bin/chezmoi" ]]; then
cp "${HOME}/.local/bin/chezmoi" "${chezmoi_bin}"
chmod 755 "${chezmoi_bin}"
fi
fi
sudo -u "${USERNAME}" -H "${chezmoi_bin}" init "${DOTFILES_REPO}"
sudo -u "${USERNAME}" -H python3 "$USER_HOME/.local/share/chezmoi/scripts/set-chezmoi-local-data.py" \
--role server --profile standard
sudo -u "${USERNAME}" -H env "CHEZMOI_ROLE=server" "VERBOSE=${VERBOSE}" "TRUST_ON_FIRST_USE_INSTALLERS=${TRUST_ON_FIRST_USE_INSTALLERS}" \
"${chezmoi_bin}" apply
}
# --- Verification ------------------------------------------------------------
verify() {
echo "== Verification =="
local pass=0 fail=0 warn=0
check() {
local desc="$1"; shift
if "$@" >/dev/null 2>&1; then
vecho " [PASS] ${desc}"; (( ++pass ))
else
echo " [FAIL] ${desc}"; (( ++fail ))
fi
}
check_optional() {
local desc="$1"; shift
if "$@" >/dev/null 2>&1; then
vecho " [PASS] ${desc}"; (( ++pass ))
else
echo " [WARN] ${desc} (optional)"; (( ++warn ))
fi
}
# Wrap commands that need pipes or || in bash -c
check "User ${USERNAME} exists" id -u "${USERNAME}"
if [[ "${ALLOW_PASSWORDLESS_SUDO}" == "1" ]]; then
check "Passwordless sudo" sudo -u "${USERNAME}" sudo -n true
else
vecho " [SKIP] Passwordless sudo check (ALLOW_PASSWORDLESS_SUDO=0)"
fi
check "SSH authorized_keys" ensure_ssh_access
check "SSHD config valid" sshd -t
check "SSHD running" bash -c "systemctl is-active ssh || systemctl is-active sshd"
check "SSH listening on selected port" bash -c "ss -ltnH | awk -v port='$SSH_PORT' '\$4 ~ (\":\" port \"\$\") {found=1} END {exit !found}'"
check "UFW active" bash -c "ufw status | grep -q 'Status: active'"
check "Public SSH UFW rule" bash -c "ufw status | grep -Eq '^${SSH_PORT}/tcp[[:space:]]+ALLOW[[:space:]]+Anywhere'"
check "fail2ban running" systemctl is-active fail2ban
check "fail2ban sshd jail" fail2ban-client status sshd
check "Kernel hardening config" test -f /etc/sysctl.d/99-hardening.conf
check "Swap active" bash -c "swapon --show | grep -q '/'"
check_optional "Tailscale installed" bash -c "command -v tailscale"
check "bat installed" bash -c "command -v bat || command -v batcat"
check_optional "delta installed" bash -c "command -v delta"
check_optional "eza/exa installed" bash -c "command -v eza || command -v exa"
local chezmoi_status
if chezmoi_status="$(sudo -u "${USERNAME}" -H chezmoi status --exclude=scripts 2>&1)"; then
if [[ -z "$chezmoi_status" ]]; then
vecho " [PASS] Dotfiles match destination"; (( ++pass ))
else
echo " [FAIL] Dotfiles differ from destination"; (( ++fail ))
printf '%s\n' "$chezmoi_status"
fi
else
echo " [FAIL] Could not check dotfiles destination"; (( ++fail ))
printf '%s\n' "$chezmoi_status"
fi
echo ""
echo " Results: ${pass} passed, ${warn} warnings, ${fail} failed"
if (( fail > 0 )); then
echo " Review failures above."
return 1
fi
return 0
}
# --- Summary -----------------------------------------------------------------
print_summary() {
local minutes=$(( SECONDS / 60 )) seconds=$(( SECONDS % 60 ))
local root_status="key-only" ts_status="not installed"
if command -v tailscale >/dev/null 2>&1; then
ts_status="installed; login pending"
if tailscale status --self >/dev/null 2>&1; then ts_status="connected"; fi
fi
echo ""
echo "============================================"
echo " Bootstrap complete (${minutes}m ${seconds}s)"
echo "============================================"
echo ""
echo " User: ${USERNAME}"
echo " SSH port: ${SSH_PORT}"
echo " Root SSH: ${root_status}"
echo " Tailscale: ${ts_status}"
echo " SSH access: public port ${SSH_PORT}"
echo " NOPASSWD sudo: ${ALLOW_PASSWORDLESS_SUDO}"
echo " Copy root keys: ${COPY_ROOT_AUTH_KEYS}"
echo " TOFU installers: ${TRUST_ON_FIRST_USE_INSTALLERS}"
echo ""
echo " Measured phase times:"
printf ' %s\n' "${PHASE_TIMINGS[@]}"
echo ""
if [[ -f /var/run/reboot-required ]]; then
echo " *** REBOOT REQUIRED ***"
echo ""
fi
echo " Next steps:"
echo " 1. Test: ssh -p ${SSH_PORT} ${USERNAME}@<this-ip>"
echo " 2. Run 'tailscale up' to join your tailnet"
echo " 3. Test a new Tailscale SSH connection as ${USERNAME}"
echo " 4. From that Tailscale SSH session, run scripts/server-lockdown-tailscale.sh"
echo " 5. Confirm from another new Tailscale SSH session within five minutes"
echo ""
}
# --- Main --------------------------------------------------------------------
PHASE_TIMINGS=()
timed_phase() {
local label="$1" started=$SECONDS
shift
"$@"
PHASE_TIMINGS+=("$label: $((SECONDS - started))s")
}
main() {
validate
if id -u "$USERNAME" >/dev/null 2>&1; then
USER_HOME="$(getent passwd "$USERNAME" | cut -d: -f6)"
[[ -d "$USER_HOME" ]] || { echo "ERROR: Invalid home directory for $USERNAME" >&2; return 1; }
[[ ! -L "$USER_HOME/.ssh" && ! -L "$USER_HOME/.ssh/authorized_keys" ]] || {
echo "ERROR: Refusing to use symlinked SSH key paths." >&2; return 1;
}
if [[ "$COPY_ROOT_AUTH_KEYS" != 1 ]]; then
ensure_ssh_access
fi
fi
timed_phase "System update" system_update
timed_phase "Swap" configure_swap
timed_phase "Locale" configure_locale
timed_phase "User setup" setup_user
write_security_flags
ensure_ssh_access
timed_phase "Tailscale package" install_tailscale
timed_phase "SSH hardening" harden_sshd
timed_phase "UFW" configure_ufw
timed_phase "Kernel" harden_kernel
timed_phase "Automatic updates" configure_unattended_upgrades
timed_phase "fail2ban" configure_fail2ban
timed_phase "Dotfiles apply" install_dotfiles
verify
print_summary
}
if [[ "${BASH_SOURCE[0]}" = "$0" ]]; then main "$@"; fi