-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathlinuxinstall.sh
More file actions
4191 lines (3948 loc) · 168 KB
/
Copy pathlinuxinstall.sh
File metadata and controls
4191 lines (3948 loc) · 168 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
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/bin/bash
#
# neohiro/linux - general interactive setup & hardening script
# Auto-detects Ubuntu / Debian / RHEL / AlmaLinux / Rocky / Fedora /
# CentOS / SUSE / openSUSE / Arch and adapts package manager, firewall,
# security tools, unattended upgrades, and kernel management accordingly.
# Automates the README tutorial with safety prompts.
# Run as root: sudo bash linuxinstall.sh
# Requires bash 4.0+ (uses [[ =~ ]], ${!var}, mapfile in helpers).
#
if [ "${BASH_VERSINFO[0]:-0}" -lt 4 ]; then
printf '%s\n' "linuxinstall.sh requires Bash 4.0+; found ${BASH_VERSION:-unknown}." >&2
printf '%s\n' " macOS users: brew install bash" >&2
exit 1
fi
REPO_RAW_BASE="${REPO_RAW_BASE:-https://raw.githubusercontent.com/neohiro/linux/main}"
# Validate REPO_RAW_BASE is a trusted domain (prevents malicious overrides)
if [[ "$REPO_RAW_BASE" != https://raw.githubusercontent.com/neohiro/linux/* ]]; then
err "REPO_RAW_BASE must be a raw.githubusercontent.com URL under neohiro/linux; got: $REPO_RAW_BASE"
exit 1
fi
SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]:-$0}")"
ORIG_CWD="$(pwd)"
# Canonical helpers. Resolved relative to the script's own location so the
# library works whether the script is run from a clone, a symlink, or
# curl|bash (where BASH_SOURCE[0] is /dev/fd/...; we fall back to $0).
_NEOHIRO_LIB_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]:-$0}" 2>/dev/null || echo /usr/local/bin)")/lib" 2>/dev/null && pwd || echo "")"
if [ -n "$_NEOHIRO_LIB_DIR" ] && [ -r "$_NEOHIRO_LIB_DIR/color.sh" ]; then
# shellcheck disable=SC1091
source "$_NEOHIRO_LIB_DIR/color.sh"
# shellcheck disable=SC1091
source "$_NEOHIRO_LIB_DIR/temp.sh"
# shellcheck disable=SC1091
if [ -r "$_NEOHIRO_LIB_DIR/runner.sh" ]; then
source "$_NEOHIRO_LIB_DIR/runner.sh"
fi
# shellcheck disable=SC1091
if [ -r "$_NEOHIRO_LIB_DIR/updater.sh" ]; then
source "$_NEOHIRO_LIB_DIR/updater.sh"
fi
else
# Inline fallback for run-from-pipe (curl ... | bash) where the lib
# directory is not on disk. Sources the canonical color-gate function from
# a local copy so curl|bash works without any on-disk dependencies.
_NEOHIRO_LIB_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]:-$0}" 2>/dev/null || echo /usr/local/bin)")/lib" 2>/dev/null && pwd || echo "")"
if [ -n "$_NEOHIRO_LIB_DIR" ] && [ -r "$_NEOHIRO_LIB_DIR/color-gate.sh" ]; then
# shellcheck source=lib/color-gate.sh
. "$_NEOHIRO_LIB_DIR/color-gate.sh"
else
# No on-disk lib; inline the gate function from the embedded heredoc below.
_apply_color_gate() {
if [ "${NEOHIRO_COLOR:-}" = "1" ]; then echo 1; return 0; fi
if [ "${NEOHIRO_COLOR:-}" = "0" ]; then echo 0; return 0; fi
if [ "${FORCE_TTY:-}" != "1" ] && [ ! -t 1 ]; then echo 0; return 0; fi
if [ -n "${NO_COLOR:-}" ] && [ "${NO_COLOR:-}" != "0" ]; then echo 0; return 0; fi
if [ "${TERM:-}" = "dumb" ]; then echo 0; return 0; fi
if ! command -v tput >/dev/null 2>&1; then echo 0; return 0; fi
local _tcol; _tcol=$(tput colors 2>/dev/null) || _tcol=""
case "${_tcol}" in
''|*[!0-9]*) echo 0; return 0 ;;
*) [ "${_tcol}" -ge 8 ] 2>/dev/null && echo 1 || echo 0; return 0 ;;
esac
}
fi
USE_COLOR=$(_apply_color_gate)
unset -f _apply_color_gate
# _c <ansi-code> <text> -- wrap text in CSI escapes iff USE_COLOR=1.
_c() { if [ "$USE_COLOR" = "1" ]; then printf '\033[%s%s\033[0m' "$1" "$2"; else printf '%s' "$2"; fi; }
# Print helpers. All accept a single message string. warn/err go to stderr.
bold() { printf "%s\n" "$(_c '1m' "$*")"; }
warn() { printf "%s %s\n" "$(_c '1;33m' '[WARNING]')" "$*" >&2; }
err() { printf "%s %s\n" "$(_c '1;31m' '[ERROR]')" "$*" >&2; }
ok() { printf "%s %s\n" "$(_c '1;32m' '[OK]')" "$*"; }
info() { printf " %s\n" "$*"; }
msg() { echo "=> $*"; }
# Inline fallback for run() when lib/runner.sh is not available.
# Provides basic command execution with DRY_RUN/VERBOSE support.
run() {
if [ "${DRY_RUN:-0}" = "1" ]; then
printf ' %s\n' "DRY: $*"
return 0
fi
if [ "${VERBOSE:-0}" -ge 2 ]; then
printf ' %s\n' "RUN: $*"
fi
if [ "${RUNNER_ECHO:-1}" = "1" ]; then
msg "$*"
fi
"$@"
local rc=$?
if [ $rc -ne 0 ]; then
if declare -F warn >/dev/null 2>&1; then
warn "Command failed (exit $rc): $*"
else
printf '%s\n' "[ERROR] Command failed (exit $rc): $*" >&2
fi
if declare -p _FAIL_COUNT >/dev/null 2>&1; then
_FAIL_COUNT=$((_FAIL_COUNT + 1))
else
FAIL_COUNT=${FAIL_COUNT:-0}
FAIL_COUNT=$((FAIL_COUNT + 1))
fi
if declare -F _log_error >/dev/null 2>&1; then
_log_error "$rc" "$*"
fi
if [ "${STRICT_RUN:-0}" = "1" ]; then
return $rc
fi
return 0
fi
return 0
}
TMP_DIR="$(mktemp -d)"
_TMP_FILES=()
# Debug log location. Override with NEOHIRO_DEBUG_LOG=path. /var/log may
# be unwritable in containers; fall back to TMP_DIR.
# The file is created with mode 0600 so command arguments (which may contain
# user-supplied values) are not readable by other users on the system.
if [ -z "${NEOHIRO_DEBUG_LOG:-}" ]; then
if [ -w /var/log ] 2>/dev/null; then
NEOHIRO_DEBUG_LOG="/var/log/neohiro-debug.log"
else
NEOHIRO_DEBUG_LOG="${TMP_DIR}/neohiro-debug.log"
fi
fi
# Create the log with owner-only permissions before any breadcrumb is written.
# Use mktemp for atomic creation with mode, then move into place.
_log_file=$(mktemp -m 0600 "$(dirname "$NEOHIRO_DEBUG_LOG")/neohiro-debug.XXXXXX" 2>/dev/null) \
&& mv "$_log_file" "$NEOHIRO_DEBUG_LOG" 2>/dev/null \
|| { touch "$NEOHIRO_DEBUG_LOG" && chmod 0600 "$NEOHIRO_DEBUG_LOG"; } 2>/dev/null || true
trap 'rm -rf "$TMP_DIR" "${_TMP_FILES[@]}" 2>/dev/null' EXIT
# Public: create a tracked temp file. Returns the new path.
# Usage: f=$(_tmpfile) or f=$(_tmpfile myprefix)
_tmpfile() {
local prefix="${1:-neohiro}"
local f
# Try mktemp with -m for atomic mode setting (GNU extension).
# Fall back to mktemp + chmod (small race window, acceptable).
if f=$(mktemp -m 0600 "${TMPDIR:-/tmp}/${prefix}.XXXXXX" 2>/dev/null); then
:
else
f=$(mktemp "${TMPDIR:-/tmp}/${prefix}.XXXXXX") && chmod 0600 "$f"
fi
_TMP_FILES+=("$f")
printf '%s' "$f"
}
fi
unset _NEOHIRO_LIB_DIR
# Inline fallback for lib/updater.sh (curl|bash path). Provides
# _run_all_updates so the Updates-only profile and the main script's update
# step can use the comprehensive update engine without requiring the lib
# directory to be on disk.
if ! declare -F _run_all_updates >/dev/null 2>&1; then
VERBOSE="${VERBOSE:-0}"; UPDATED=0; FAILED=0
_log() { [ "$VERBOSE" = "1" ] && info "$*" || true; }
_update_apt() { command -v apt >/dev/null 2>&1 || return 0
run sudo env DEBIAN_FRONTEND=noninteractive apt-get update -qq || return 1
run sudo env DEBIAN_FRONTEND=noninteractive apt-get -y -qq full-upgrade
run sudo env DEBIAN_FRONTEND=noninteractive apt-get -y autoremove -qq
run sudo apt-get clean -qq; }
_update_dnf() { command -v dnf >/dev/null 2>&1 || return 0
run sudo dnf upgrade --refresh -y -q || return 1
run sudo dnf autoremove -y -q; }
_update_yum() { command -v yum >/dev/null 2>&1 || return 0
run sudo yum update -y -q || return 1
run sudo yum autoremove -y -q; }
_update_zypper(){ command -v zypper >/dev/null 2>&1 || return 0
run sudo zypper --quiet refresh
run sudo zypper update -y --quiet || return 1
run sudo zypper --quiet clean; }
_update_pacman(){ command -v pacman >/dev/null 2>&1 || return 0
run sudo pacman -Syu --noconfirm --quiet || return 1
run sudo pacman -Scc --noconfirm -q; }
_update_snap() { command -v snap >/dev/null 2>&1 || return 0
run sudo snap refresh 2>/dev/null || true; }
_update_flatpak(){ command -v flatpak >/dev/null 2>&1 || return 0
run flatpak update -y --assumeyes 2>/dev/null || true
run flatpak uninstall --unused -y --assumeyes 2>/dev/null || true; }
_update_docker() { command -v docker >/dev/null 2>&1 || return 0
while IFS= read -r img; do
[ -z "$img" ] && continue
docker pull "$img" >/dev/null 2>&1 || true
done < <(docker images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -v '<none>')
docker image prune -f >/dev/null 2>&1 || true; }
_update_brew() { command -v brew >/dev/null 2>&1 || return 0
HOMEBREW_NO_ANALYTICS=1 run brew update 2>/dev/null || true
run brew upgrade 2>/dev/null || true
run brew cleanup -s -q 2>/dev/null || true; }
_update_firmware(){ command -v fwupdmgr >/dev/null 2>&1 || return 0
run sudo fwupdmgr refresh 2>/dev/null || true
run sudo fwupdmgr update -y --no-reboot-check 2>/dev/null || true; }
_run_all_updates() {
msg "=== Comprehensive system update ==="
_update_apt || true
_update_dnf || true
_update_yum || true
_update_zypper || true
_update_pacman || true
_update_snap || true
_update_flatpak || true
_update_docker || true
_update_brew || true
_update_firmware || true
printf '\n'; ok "Update engine complete."; }
fi
RECOVERY_CMD="tmux attach -t linux-setup # reconnect after SSH disconnect"
ROLLBACK_LOG="${ROLLBACK_LOG:-/var/log/linux-install-rollback.log}"
# Ensure the rollback log exists and is writable before any backup is recorded.
# Creates it with 0600 mode (owner-only) to avoid leaking paths to other users.
_record_backup_init() {
local logdir="${ROLLBACK_LOG%/*}"
# Use mktemp for atomic creation with mode, then move into place.
# This avoids the race window where touch+chmod leaves the file world-readable.
if [ "$EUID" -eq 0 ] && ! command -v sudo >/dev/null 2>&1; then
# Running as root without sudo available: create log directly.
mkdir -p "$logdir" 2>/dev/null || true
_log_file=$(mktemp -m 0600 "${logdir}/linux-install-rollback.XXXXXX" 2>/dev/null) \
&& mv "$_log_file" "$ROLLBACK_LOG" 2>/dev/null \
|| { touch "$ROLLBACK_LOG" && chmod 0600 "$ROLLBACK_LOG"; } 2>/dev/null || true
else
sudo mkdir -p "$logdir" 2>/dev/null || true
_log_file=$(sudo mktemp -m 0600 "${logdir}/linux-install-rollback.XXXXXX" 2>/dev/null) \
&& sudo mv "$_log_file" "$ROLLBACK_LOG" 2>/dev/null \
|| sudo sh -c "touch '$ROLLBACK_LOG' && chmod 0600 '$ROLLBACK_LOG'" 2>/dev/null || true
fi
}
_record_backup_init
# Append a backup-file line to a single rollback log so the user has one place
# to find every file we modified and the backup we made before modifying it.
record_backup() {
local original="$1" backup="$2"
[ -n "$original" ] && [ -n "$backup" ] || return 0
local line
line="$(printf '%s\t%s\n' "$original" "$backup")"
# As root without sudo: write directly. Otherwise: sudo tee so the log
# remains root-owned in /var/log.
if [ "$EUID" -eq 0 ] && ! command -v sudo >/dev/null 2>&1; then
printf '%s' "$line" >> "$ROLLBACK_LOG" 2>/dev/null || true
else
printf '%s' "$line" | sudo tee -a "$ROLLBACK_LOG" >/dev/null 2>&1 || true
fi
metrics_add configs_backed_up 1
metrics_add rollback_logged 1
}
print_recovery_cmd() {
_c '1;33m' "----------------------------------------------------------------------"
printf '\n'
_c '1;33m' "| RECOVERY COMMAND -- copy this BEFORE anything that might disconnect:"
printf '\n'
_c '1;33m' '|'
printf ' '
_c '1;36m' "$RECOVERY_CMD"
printf '\n'
_c '1;33m' "----------------------------------------------------------------------"
printf '\n'
printf " After reconnecting over SSH, run the command above to resume the run.\n\n"
}
_warn_if_not_tmux() {
[ -n "${TMUX:-}" ] || [ -n "${STY:-}" ] && return 0
print_recovery_cmd
}
# Returns the SSH port sshd is currently configured to listen on (from sshd_config
# and any drop-in files), or "22" as the default. IPv6-aware: if sshd is
# listening on [::] (all IPv6 interfaces) we return "22-ipv6" so callers
# can add an explicit IPv6 rule. On error, returns "22".
_ssh_current_port() {
local cfg="/etc/ssh/sshd_config" port=""
port=$(awk '/^[[:space:]]*Port[[:space:]]/ {print $2; exit}' \
/etc/ssh/sshd_config /etc/ssh/sshd_config.d/*.conf 2>/dev/null \
| tr -d '[:space:]' || true)
port="${port:-22}"
# Detect if sshd is listening on IPv6 (wildcard or specific IPv6 address).
if command -v ss >/dev/null 2>&1; then
if ss -ln -6 2>/dev/null | awk '{print $4}' | grep -qE ':('"${port}"'|'"${port}"')$'; then
printf '%s-ipv6' "$port"
return
fi
fi
printf '%s' "$port"
}
# Returns 0 if the active firewall already allows the SSH port (IPv4 + IPv6
# when applicable). On no active firewall, returns 0.
_ssh_port_allowed() {
local port="${1:-22}"
_fw_detect || return 0
[ -z "$FW_CMD" ] && return 0
case "$FW_CMD" in
ufw)
# OpenSSH (service name) opens port in both IPv4 and IPv6.
# A bare "22/tcp" only opens IPv4.
sudo ufw status 2>/dev/null \
| awk '/^Status:/ {active=$2; next} /OpenSSH.*ALLOW/ {found=1} END {exit !(active=="active" && found)}'
return $?
;;
firewall-cmd)
sudo firewall-cmd --list-all 2>/dev/null | grep -qE 'services:.*ssh' && return 0
sudo firewall-cmd --list-ports 2>/dev/null | grep -qE "${port}/(tcp|udp)" && return 0
return 1
;;
esac
return 0
}
# Add a firewall rule for the SSH port that covers IPv4 AND IPv6.
# ufw: use the "OpenSSH" service name (opens in both IP stacks) rather
# than a bare "22/tcp" which only covers IPv4. firewalld: use
# --add-service=ssh (protocol-agnostic) rather than a port rule.
_ssh_fw_allow() {
_fw_detect || return 0
[ -z "$FW_CMD" ] && return 0
case "$FW_CMD" in
ufw) run sudo ufw allow OpenSSH comment 'sshd-both-ips' ;;
firewall-cmd) run sudo firewall-cmd --add-service=ssh --permanent ;;
esac
}
# Detect the current SSH port and ensure it is open in the active firewall
# before we switch to default-deny. On IPv6-only instances sshd listens on
# [::] and a bare IPv4 rule would not protect it; this function adds an
# IPv6-aware rule (ufw OpenSSH service or firewalld --add-service=ssh) that
# covers both stacks. Idempotent: safe to call multiple times.
_ssh_guard() {
local port
port="$( _ssh_current_port 2>/dev/null || echo 22 )"
# Strip any -ipv6 suffix for the port number; the check is already done.
port="${port%-ipv6}"
if _ssh_port_allowed "$port"; then
info "SSH port $port is already permitted in the firewall."
return 0
fi
warn "SSH port $port is not currently allowed in the firewall."
if prompt_yn "Open SSH (port $port) in the firewall before applying default-deny?" "y"; then
_ssh_fw_allow
return $?
fi
err "SSH port $port is not allowed. Aborting firewall setup to prevent lockout."
return 1
}
# Restart sshd safely: prefer `systemctl reload` (preserves the existing
# daemon process, so the live connection is never dropped) over `restart`.
# Falls back to `restart` if `reload` is unavailable. Always validates
# the config before restarting. On failure, reverts from backup.
_ssh_safe_restart() {
local unit sshcfg="${1:-/etc/ssh/sshd_config}" backup=""
unit="$( _sshd_unit 2>/dev/null || echo sshd )"
# Find the most recent backup we made.
backup=$(ls -t "${sshcfg}.bak."* 2>/dev/null | head -n1 || true)
# Validate before touching anything.
if ! sudo sshd -t 2>&1; then
err "sshd config invalid — NOT restarting. Reverting."
[ -n "$backup" ] && [ -f "$backup" ] \
&& run sudo cp -f "$backup" "$sshcfg"
return 1
fi
# Reload (not restart): keeps the existing daemon process alive so the
# current SSH session is never dropped. Most modern sshd versions support this.
if systemctl reload "$unit" 2>/dev/null; then
ok "SSH config reloaded (session preserved)."
return 0
fi
# No reload support: do a restart. Warn the user the session will
# briefly drop. Because ensure_tmux_if_ssh wraps the whole run in tmux,
# a dropped SSH session can be recovered with `tmux attach`.
warn "sshd reload not supported — restarting. You may briefly lose this SSH session."
warn "Recover with: tmux attach -t linux-setup"
if ! sudo systemctl restart "$unit" 2>&1; then
err "sshd restart failed — reverting config."
[ -n "$backup" ] && [ -f "$backup" ] \
&& run sudo cp -f "$backup" "$sshcfg"
return 1
fi
ok "SSH restarted. If disconnected, run: tmux attach -t linux-setup"
}
_sshd_unit() {
if systemctl is-active --quiet sshd 2>/dev/null; then echo sshd; return 0; fi
if systemctl is-active --quiet ssh 2>/dev/null; then echo ssh; return 0; fi
if [ -f /etc/debian_version ]; then echo ssh; return 1; fi
echo sshd; return 1
}
print_recovery_if_ssh() {
[ -n "${SSH_CONNECTION:-}${SSH_TTY:-}" ] || return 0
print_recovery_cmd
}
ensure_tmux_if_ssh() {
[ -n "${SSH_CONNECTION:-}${SSH_TTY:-}" ] || return 0
[ -z "${TMUX:-}" ] && [ -z "${STY:-}" ] || return 0
if ! command -v tmux >/dev/null 2>&1; then
if [ "$EUID" -eq 0 ] || command -v sudo >/dev/null 2>&1; then
pkg_install tmux 2>/dev/null || true
fi
fi
command -v tmux >/dev/null 2>&1 || {
warn "tmux unavailable; SSH disconnect may kill the run. ${RECOVERY_CMD} will NOT exist - run the script from a local terminal instead."
return 0
}
[ -n "$SCRIPT_PATH" ] || { warn "SCRIPT_PATH is empty; cannot re-exec inside tmux."; return 0; }
bold "SSH session detected. Wrapping this run in a tmux session so disconnects do not abort it."
# Quote everything via env+args (no string interpolation) so paths with spaces
# or shell metacharacters survive. The inner bash re-execs the same script
# by absolute path; on clean exit it tears the tmux session down.
local inner
inner=$(cat <<'INNER_EOF'
trap 'tmux kill-session -t linux-setup 2>/dev/null' EXIT
cd "$1" && shift
bash "$1" "$@"
rc=$?
if [ "$rc" -eq 0 ]; then
tmux kill-session -t linux-setup 2>/dev/null
fi
exit "$rc"
INNER_EOF
)
exec tmux new-session -A -s linux-setup -n setup \
"cd $(printf '%q' "$ORIG_CWD") && bash $(printf '%q' "$SCRIPT_PATH")"
}
# bold/warn/err/ok/info/msg/_c are provided by lib/color.sh (sourced above).
# STRICT_RUN=1 makes run() propagate the actual exit code (default is 0,
# so a single failed command does not abort the whole interactive run).
# Set this in CI / unattended deployments to detect partial-failure runs.
STRICT_RUN="${STRICT_RUN:-0}"
QUICK_MODE="${QUICK_MODE:-0}"
# AUTO_MODE = "1" means "go through the whole setup without asking". The
# profile defaults to "auto" (intelligent detection: server vs desktop,
# full vs recommended), every y/n prompt uses its default, every
# category uses its profile default. Triggered by:
# * selecting "1) Auto" in the main menu (interactive)
# * --auto or -y on the command line
# * NEOHIRO_AUTO=1 in the environment
# * QUIET_PROMPTS=1 (legacy name for unattended runs)
AUTO_MODE="${AUTO_MODE:-${NEOHIRO_AUTO:-}}"
case "$AUTO_MODE" in
1|true|yes|y|Y) AUTO_MODE=1 ;;
*) AUTO_MODE=0 ;;
esac
# QUIET_PROMPTS implies AUTO_MODE (legacy CI / docker users).
if [ "${QUIET_PROMPTS:-0}" = "1" ]; then AUTO_MODE=1; fi
_FAIL_COUNT=0
# Source the shared runner helpers. _runner_cmd is the canonical implementation;
# alias it to `run` so all existing call sites are satisfied.
# shellcheck disable=SC1091
if [ -n "${_NEOHIRO_LIB_DIR:-}" ] && [ -r "$_NEOHIRO_LIB_DIR/runner.sh" ]; then
source "$_NEOHIRO_LIB_DIR/runner.sh"
run() { _runner_cmd "$@"; }
fi
# Fallback: if run() is still not defined (lib dir missing or runner.sh unreadable),
# provide an inline implementation matching lib/runner.sh behavior.
if ! declare -F run >/dev/null 2>&1; then
run() {
if [ "${DRY_RUN:-0}" = "1" ]; then
printf ' %s\n' "DRY: $*"
return 0
fi
if [ "${VERBOSE:-0}" -ge 2 ]; then
printf ' %s\n' "RUN: $*"
fi
if [ "${RUNNER_ECHO:-1}" = "1" ] && declare -F msg >/dev/null 2>&1; then
msg "$*"
fi
"$@"
local rc=$?
if [ $rc -ne 0 ]; then
if declare -F warn >/dev/null 2>&1; then
warn "Command failed (exit $rc): $*"
else
printf '%s\n' "[ERROR] Command failed (exit $rc): $*" >&2
fi
if declare -p _FAIL_COUNT >/dev/null 2>&1; then
_FAIL_COUNT=$((_FAIL_COUNT + 1))
else
FAIL_COUNT=${FAIL_COUNT:-0}
FAIL_COUNT=$((FAIL_COUNT + 1))
fi
if declare -F _log_error >/dev/null 2>&1; then
_log_error "$rc" "$*"
fi
if [ "${STRICT_RUN:-0}" = "1" ]; then
return $rc
fi
return 0
fi
return 0
}
fi
# Offer a Retry / Skip / Abort choice after a step failure.
# Only prompts when running interactively (tty + not QUIET_PROMPTS).
# Returns: 0 = retry, 1 = skip, 2 = abort.
_prompt_failure_recovery() {
local step_label="$1" rc="$2"
if [ "${QUIET_PROMPTS:-0}" = "1" ] || [ ! -t 0 ]; then
warn "Step '$step_label' failed (exit $rc). Continuing (non-interactive)."
return 1
fi
printf '\n %s %s\n' "$(_c '1;31m' '[FAIL]')" "Step '$step_label' exited with code $rc."
printf ' %s\n' "$(_c '1;37m' 'What do you want to do?')"
printf ' %s %s\n' "$(_c '1;32m' ' 1)')" "Retry this step"
printf ' %s %s\n' "$(_c '1;33m' ' 2)')" "Skip this step and continue"
printf ' %s %s\n' "$(_c '1;31m' ' 3)')" "Abort the entire run"
local a
if [ -t 0 ]; then
read -r -p "Choose 1 to 3 - default 2 skips: " a
elif [ -e /dev/tty ] && [ -r /dev/tty ]; then
printf 'Choose 1 to 3 - default 2 skips: ' >/dev/tty
read -r a </dev/tty
else
a=2
fi
a="${a:-2}"
case "$a" in
1) return 0 ;;
3) err "Aborted by user after step '$step_label'."; exit 1 ;;
*) return 1 ;;
esac
}
# Restore /etc from the latest snapshot. Callable via --restore-etc-snapshot.
_restore_etc_snapshot() {
if [ "$EUID" -ne 0 ]; then
err "This must be run as root."; return 1
fi
local snap_dir="/var/backups/etc-snapshots"
local latest
# Use find to avoid the bash-glob-literal-on-miss problem (and the noisy
# stderr that ls produces when the directory is empty or missing).
latest=$(find "$snap_dir" -maxdepth 1 -type f -name 'etc-*.tar.gz' -printf '%T@ %p\n' 2>/dev/null \
| sort -nr | head -n1 | cut -d' ' -f2-)
if [ -z "$latest" ]; then
err "No /etc snapshot found in $snap_dir."
info "Snapshots are created automatically when ENABLE_ETC_SNAPSHOT=1 (the default)."
info "Re-run the hardening script with ENABLE_ETC_SNAPSHOT=1 to create one."
return 1
fi
bold "=== /etc snapshot restore ==="
info "Latest snapshot: $latest"
local size
size=$(sudo du -h "$latest" 2>/dev/null | awk '{print $1}')
info "Size: ${size:-unknown}"
_c '1;31m' " WARNING: This will OVERWRITE /etc with the snapshot contents."
printf '\n'
printf " Any hardening changes made since the snapshot was taken will be lost.\n"
printf " DNS, SSH, firewall, and all other settings will be restored.\n\n"
if ! prompt_yn "Restore /etc from $latest?" "n"; then
info "Restore cancelled."; return 0
fi
if sudo tar -xzf "$latest" -C / 2>&1; then
ok "/etc restored from $latest"
info "You may need to: sudo systemctl restart sshd (if SSH was changed)"
info " sudo systemctl restart systemd-resolved (if DNS was changed)"
info " sudo reboot (to reload all services)"
else
err "tar restore failed. Check the output above."
return 1
fi
return 0
}
# Take a tar.gz snapshot of /etc before hardening begins. This is a safety
# net that lets the user do a full restore without relying on the per-file
# rollback log. Skipped if ENABLE_ETC_SNAPSHOT=0 or if /var/backups is
# not writable. Only the latest snapshot is kept (old ones are removed).
_ETC_SNAPSHOT_PATH=""
_take_etc_snapshot() {
if [ "${ENABLE_ETC_SNAPSHOT:-1}" = "0" ]; then
info "ETC snapshot disabled (ENABLE_ETC_SNAPSHOT=0)."
return 0
fi
local snap_dir="/var/backups/etc-snapshots"
local snap_path
snap_path="${snap_dir}/etc-$(date +%s%N).tar.gz"
if ! sudo mkdir -p "$snap_dir" 2>/dev/null; then
warn "Cannot create $snap_dir — /etc snapshot skipped."
return 0
fi
# Create the snapshot. On failure, remove the partial file so a future
# run does not pick up a half-written tarball.
if sudo tar -czf "$snap_path" \
--exclude='/etc/ssl/private' \
--exclude='/etc/ssh/ssh_host_*_key' \
--exclude='/etc/passwd-' \
--exclude='/etc/shadow' \
--exclude='/etc/group-' \
-C / etc 2>/dev/null; then
sudo chmod 600 "$snap_path"
_ETC_SNAPSHOT_PATH="$snap_path"
# Remove all older snapshots (by name — nanos in name makes name-order
# equivalent to time-order; mtime would be wasted I/O).
local prev
while IFS= read -r prev; do
[ -n "$prev" ] && sudo rm -f "$prev" 2>/dev/null
done < <(find "$snap_dir" -maxdepth 1 -type f -name 'etc-*.tar.gz' ! -name "$(basename "$snap_path")" 2>/dev/null)
info "Created /etc snapshot: $snap_path"
info " Full /etc restore: sudo bash $0 --restore-etc-snapshot"
info " (May need sudo systemd-resolve --reload if /etc/resolv.conf was reverted)"
else
sudo rm -f "$snap_path" 2>/dev/null
warn "Failed to create /etc snapshot — continuing without it."
fi
}
prompt_yn() {
local q="$1" def="${2:-N}"
local hint="[y/N]"
if [ "$def" = "y" ] || [ "$def" = "Y" ]; then hint="[Y/n]"; fi
# AUTO_MODE: never block. Use the default. Emit one short breadcrumb
# (printed at info level, not warn) so the run log shows the decision
# without spamming.
if [ "${AUTO_MODE:-0}" = "1" ]; then
info "[AUTO] $q $hint -> $def"
case "$def" in [Yy]*) return 0;; *) return 1;; esac
fi
local a
if [ -t 0 ]; then
read -r -p "$q $hint " a
elif [ -e /dev/tty ] && [ -r /dev/tty ]; then
printf '%s %s ' "$q" "$hint" >/dev/tty
read -r a </dev/tty
else
# No interactive terminal available - use the default and warn.
# QUIET_PROMPTS=1 silences this warning (useful in CI / Docker).
if [ "${QUIET_PROMPTS:-0}" != "1" ]; then
warn "stdin is not a TTY and /dev/tty is unavailable; defaulting to '$def' for: $q"
fi
a="$def"
fi
a="${a:-$def}"
case "$a" in [Yy]*) return 0;; *) return 1;; esac
}
prompt_choice() {
local q="$1"; shift
local opts=("$@")
local i=1
echo "$q"
for o in "${opts[@]}"; do printf " %d) %s\n" "$i" "$o"; i=$((i+1)); done
# AUTO_MODE: never block. Always pick option 1 (the safe/recommended
# one; subscripts put the safest choice first by convention).
if [ "${AUTO_MODE:-0}" = "1" ]; then
info "[AUTO] $q -> option 1 (default)"
REPLY_CHOICE=0
return 0
fi
local a
if [ -t 0 ]; then
read -r -p "Choose [1-${#opts[@]}] (default 1): " a
elif [ -e /dev/tty ] && [ -r /dev/tty ]; then
printf 'Choose [1-%d] (default 1): ' "${#opts[@]}" >/dev/tty
read -r a </dev/tty
else
if [ "${QUIET_PROMPTS:-0}" != "1" ]; then
warn "stdin is not a TTY and /dev/tty is unavailable; defaulting to 1 for: $q"
fi
a=1
fi
a="${a:-1}"
if ! [[ "$a" =~ ^[0-9]+$ ]] || [ "$a" -lt 1 ] || [ "$a" -gt ${#opts[@]} ]; then
a=1
fi
REPLY_CHOICE=$((a-1))
}
run_remote_script() {
local name="$1"
local url="${REPO_RAW_BASE}/${name}"
local dst="${TMP_DIR}/${name}"
if command -v curl >/dev/null 2>&1; then
if ! curl -fsSL "$url" -o "$dst"; then err "Failed to fetch $url"; return 1; fi
elif command -v wget >/dev/null 2>&1; then
if ! wget -qO "$dst" "$url"; then err "Failed to fetch $url"; return 1; fi
else
err "Neither curl nor wget available; cannot fetch $name"; return 1
fi
chmod +x "$dst"
# Optional GPG verification. Disabled by default; enable by setting
# NEOSIGN_GPG_LEVEL=required NEOSIGN_GPG_FPR=<40-hex fingerprint>
# in the environment. `advisory` warns but does not abort. The
# signature is expected alongside the script at the same URL with a
# `.asc` suffix (detached cleartext signature).
local gpg_level="${NEOSIGN_GPG_LEVEL:-off}"
if [ "$gpg_level" != "off" ]; then
if _verify_remote_gpg_signature "$name" "$dst"; then
ok "GPG signature OK for $name"
else
err "GPG signature verification FAILED for $name"
if [ "$gpg_level" = "required" ]; then
err "Aborting (NEOSIGN_GPG_LEVEL=required). Run with NEOSIGN_GPG_LEVEL=off to override."
return 1
fi
warn "Continuing despite bad signature (NEOSIGN_GPG_LEVEL=advisory)."
fi
fi
# Decide execution mode:
# - NON-INTERACTIVE (AUTO_MODE=1 / QUIET_PROMPTS=1 / stdin not a TTY):
# run directly with auto env propagated; no wrap, no detach.
# - INTERACTIVE over SSH without tmux: the SSH socket can drop mid-way
# and kill the subscript. Re-exec the subscript inside its own
# detached tmux session so it survives the disconnect. The current
# shell waits for that tmux session to exit, so the parent's progress
# bar stays honest.
# - INTERACTIVE on local console: run directly so the user sees the
# subscript's output flowing into their terminal.
local _env_prefix=()
[ "${AUTO_MODE:-0}" = "1" ] && _env_prefix=(env AUTO_MODE=1 QUIET_PROMPTS=1)
local _is_ssh=0
[ -n "${SSH_CONNECTION:-}${SSH_TTY:-}" ] && _is_ssh=1
local _in_tmux=0
[ -n "${TMUX:-}" ] && _in_tmux=1
if [ "${AUTO_MODE:-0}" = "1" ] || [ ! -t 0 ]; then
# Non-interactive — just run and capture the result.
"${_env_prefix[@]}" bash "$dst"
return $?
fi
if [ "$_is_ssh" = "1" ] && [ "$_in_tmux" = "0" ] && command -v tmux >/dev/null 2>&1; then
local _tmux_sess="neohiro-sub-$name-$$"
info "SSH session detected: wrapping $name in tmux ('$_tmux_sess') so disconnects don't kill it."
info "Reattach anytime: tmux attach -t $_tmux_sess"
# `tmux new-session -d` starts detached. We then `wait-for` it so the
# current shell pauses until the subscript finishes — keeps the
# parent's progress bar / step sequencing intact.
tmux new-session -d -s "$_tmux_sess" "cd $(printf '%q' "$ORIG_CWD") && ${_env_prefix[*]:-} bash $(printf '%q' "$dst")"
rc=$?
if [ "$rc" -ne 0 ]; then
err "Could not start tmux for $name — running directly (may be killed by SSH drop)."
"${_env_prefix[@]}" bash "$dst"; return $?
fi
# Poll the tmux session until it ends. This avoids a long blocking
# wait and lets us show a one-line status every few seconds.
while tmux has-session -t "$_tmux_sess" 2>/dev/null; do
sleep 5
done
# tmux does not propagate child exit codes through has-session; if
# the user needs a non-zero detection they can run the subscript
# directly via Maintenance suite.
return 0
fi
"${_env_prefix[@]}" bash "$dst"
}
# Verify a detached cleartext GPG signature (.asc) against the script.
# Uses the system default pubring (no custom keyring).
# The signer's key must already be in the user's keyring.
# Optionally verify the signer fingerprint matches $NEOSIGN_GPG_FPR if set.
_verify_remote_gpg_signature() {
local name="$1" script="$2" sig url_dst gpg_out rc
if ! command -v gpg >/dev/null 2>&1; then
err "gpg is not installed; cannot verify $name"
return 1
fi
local fpr="${NEOSIGN_GPG_FPR:-}"
url_dst="${TMP_DIR}/${name}.asc"
if ! curl -fsSL "${REPO_RAW_BASE}/${name}.asc" -o "$url_dst" 2>/dev/null; then
err "Could not fetch ${name}.asc from $REPO_RAW_BASE"
return 1
fi
# Verify with the system pubring. If the signer's key is not in the
# pubring, gpg still validates the cryptographic signature but warns
# about the unknown key. We capture all output to report it.
gpg_out=$(mktemp)
rc=0
gpg --batch --verify "$url_dst" "$script" >"$gpg_out" 2>&1 || rc=$?
if [ $rc -ne 0 ] && ! grep -qi 'gpg: no signer information' "$gpg_out" 2>/dev/null; then
# Also check for "Good signature" despite unknown key
if ! grep -qi 'Good signature' "$gpg_out" 2>/dev/null; then
err "gpg --verify failed:"
cat "$gpg_out" >&2
rm -f "$gpg_out"
return 1
fi
warn "Signature is cryptographically valid but key is not in pubring."
fi
# Optional fingerprint pin: if FPR is set, confirm the signing key matches.
if [ -n "$fpr" ]; then
local signer
signer=$(gpg --batch --verify "$url_dst" "$script" 2>&1 \
| awk -F'[=:]' '/Primary key fingerprint/ {gsub(/ /,"",$NF); print toupper($NF); exit}')
# gpg --verify output format varies; also try gpg --list-keys with the signer key id
if [ -z "$signer" ]; then
signer=$(gpg --batch --list-keys --keyid-format long "$url_dst" 2>/dev/null \
| awk '/^pub.*\// {sub(/.*\//,""); print toupper($0); exit}')
fi
if [ -n "$signer" ] && [ "$signer" != "$(printf '%s' "$fpr" | tr -d ' ' | tr 'a-f' 'A-F')" ]; then
err "Signer key ($signer) does not match trusted fingerprint ($fpr)."
rm -f "$gpg_out"
return 1
fi
ok "Signer fingerprint verified: ${signer:-$(printf '%s' "$fpr" | tr -d ' ')}"
fi
rm -f "$gpg_out"
return 0
}
ENV_TYPE=""
USE_REMOTE_SSH=""
FULL_AUTO=0 # set to 1 when Full profile on a server so SSH hardening runs auto
SSH_AUTO_MODE=0
_KERNEL_UPDATE_PENDING=0 # set to 1 by update_kernel; consumed by _print_run_summary
# ── Cross-distro package-manager and distro detection ──────────────────────
# Detected once at script start; every other function reads $PKG_MGR / $DISTRO.
DRY_RUN=0 # set to 1 to preview without executing
STEP_MODE=0 # set to 1 to run a single named step
SELECTED_STEP="" # step name for --step mode
PKG_MGR="" # apt | dnf | yum | zypper | pacman
DISTRO="" # ubuntu | debian | fedora | rhel | alma | amzn | rocky | centos | opensuse | arch | unknown
detect_distro() {
if [ -n "$PKG_MGR" ] && [ -n "$DISTRO" ]; then return 0; fi
local id="" id_like=""
if [ -f /etc/os-release ]; then
id="$(grep -m1 '^ID=' /etc/os-release | cut -d= -f2 | tr -d '"' || true)"
id_like="$(grep -m1 '^ID_LIKE=' /etc/os-release | cut -d= -f2 | tr -d '"' || true)"
fi
id="$(printf '%s' "$id" | tr '[:upper:]' '[:lower:]')"
id_like="$(printf '%s' "$id_like" | tr '[:upper:]' '[:lower:]')"
case "$id" in
ubuntu) DISTRO="ubuntu" ;;
debian) DISTRO="debian" ;;
fedora) DISTRO="fedora" ;;
rhel|redhat) DISTRO="rhel" ;;
almalinux) DISTRO="alma" ;;
amzn) DISTRO="amzn" ;; # Amazon Linux (AL2023) - uses dnf/yum like RHEL
rocky) DISTRO="rocky" ;;
centos) DISTRO="centos" ;;
opensuse|suse) DISTRO="opensuse" ;;
arch) DISTRO="arch" ;;
*)
case "$id_like" in
*ubuntu*) DISTRO="ubuntu" ;;
*debian*) DISTRO="debian" ;;
*rhel*|*centos*|*fedora*) DISTRO="rhel" ;;
*opensuse*|*suse*) DISTRO="opensuse" ;;
*arch*) DISTRO="arch" ;;
*) DISTRO="unknown" ;;
esac ;;
esac
if command -v pacman >/dev/null 2>&1 && [ -f /etc/pacman.conf ]; then
PKG_MGR="pacman"
elif command -v zypper >/dev/null 2>&1; then
PKG_MGR="zypper"
elif command -v dnf >/dev/null 2>&1; then
PKG_MGR="dnf"
elif command -v yum >/dev/null 2>&1; then
PKG_MGR="yum"
elif command -v apt-get >/dev/null 2>&1 || [ -f /etc/apt/sources.list ]; then
PKG_MGR="apt"
else
PKG_MGR="unknown"
fi
}
pkg_update() {
case "$PKG_MGR" in
apt) run sudo env DEBIAN_FRONTEND=noninteractive apt-get update ;;
dnf) info "Checking for updates (dnf check-update)..."
sudo dnf check-update >/dev/null 2>&1; rc=$?
[ "$rc" -eq 100 ] && ok "Updates available — will upgrade." \
|| [ "$rc" -eq 0 ] && ok "System up to date." \
|| info "dnf check-update exited $rc." ;;
yum) info "Checking for updates (yum check-update)..."
sudo yum check-update >/dev/null 2>&1; rc=$?
[ "$rc" -eq 100 ] && ok "Updates available — will upgrade." \
|| [ "$rc" -eq 0 ] && ok "System up to date." \
|| info "yum check-update exited $rc." ;;
zypper) run sudo zypper --quiet refresh ;;
pacman) run sudo pacman -Sy ;;
*) info " pkg_update: no-op on $PKG_MGR" ;;
esac
}
pkg_install() {
case "$PKG_MGR" in
apt) run sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y "$@" ;;
dnf) run sudo dnf install -y "$@" ;;
yum) run sudo yum install -y "$@" ;;
zypper) run sudo zypper install -y --no-confirm "$@" ;;
pacman) run sudo pacman -S --noconfirm "$@" ;;
*) err "pkg_install: unsupported $PKG_MGR"; return 1 ;;
esac
}
pkg_upgrade() {
case "$PKG_MGR" in
apt) run sudo env DEBIAN_FRONTEND=noninteractive apt-get -y full-upgrade ;;
dnf) run sudo dnf upgrade --refresh -y ;;
yum) run sudo yum update -y ;;
zypper) run sudo zypper update -y ;;
pacman) run sudo pacman -Syu --noconfirm ;;
*) err "pkg_upgrade: unsupported $PKG_MGR"; return 1 ;;
esac
}
pkg_autoremove() {
case "$PKG_MGR" in
apt) run sudo env DEBIAN_FRONTEND=noninteractive apt-get -y autoremove ;;
dnf) run sudo dnf autoremove -y ;;
yum) run sudo yum autoremove -y ;;
zypper) run sudo zypper packages --unneeded --delete --no-confirm 2>/dev/null || true ;;
pacman) run sudo pacman -Qdtq | xargs -r sudo pacman -Rns --noconfirm ;;
*) : ;;
esac
}
pkg_is_installed() {
case "$PKG_MGR" in
apt) dpkg-query -W -f='${Status}' "$1" 2>/dev/null | grep -q '^ii' ;;
dnf|yum) rpm -q "$1" >/dev/null 2>&1 ;;
zypper) rpm -q "$1" >/dev/null 2>&1 ;;
pacman) pacman -Q "$1" >/dev/null 2>&1 ;;
*) false ;;
esac
}
# ── Firewall helpers (UFW on apt; firewalld everywhere else) ───────────
FW_CMD="" # ufw | firewall-cmd
_fw_detect() {
if [ -n "$FW_CMD" ]; then return 0; fi
if command -v firewall-cmd >/dev/null 2>&1 && systemctl is-active --quiet firewalld 2>/dev/null; then
FW_CMD="firewall-cmd"
elif command -v ufw >/dev/null 2>&1; then
FW_CMD="ufw"
else
FW_CMD=""
fi
}
fw_allow() {
_fw_detect || return 0
if [ -z "$FW_CMD" ]; then
info "No active firewall (UFW or firewalld) detected; rule for '$1' not applied."
return 0
fi
# Normalize "22" to "22/tcp" so firewalld does not reject bare port numbers.
local spec="$1"
case "$spec" in
*/*) ;; # already has /tcp or /udp
*) spec="${spec}/tcp" ;;
esac
case "$FW_CMD" in
ufw) run sudo ufw allow "$spec" ;;
firewall-cmd) run sudo firewall-cmd --add-port="$spec" --permanent ;;
esac
}
fw_default_incoming_deny() {
_fw_detect || return 0
case "$FW_CMD" in
ufw)
run sudo ufw default deny incoming
run sudo ufw default allow outgoing
;;
firewall-cmd)
local zone
zone=$(sudo firewall-cmd --get-default-zone 2>/dev/null || echo public)
# Permit SSH and DHCPv6 in the active zone BEFORE switching the default
# zone to drop, otherwise the active interface's ruleset (which still
# has the old zone) is fine, but new interfaces get drop and the next
# firewall-cmd --reload re-evaluates from default. So we also switch
# the runtime default and rebind active interfaces to drop after
# whitelisting ssh on the new default zone.
run sudo firewall-cmd --zone="$zone" --add-service=ssh --permanent
run sudo firewall-cmd --zone="$zone" --add-service=dhcpv6-client --permanent
# Make the drop zone the new permanent + runtime default.
run sudo firewall-cmd --set-default-zone=drop
# Allow SSH and DHCPv6 on drop too, so the next interface bound to
# the default zone still has basic connectivity. dhcpv6-client is
# needed for SLAAC + DHCPv6 in IPv6 deployments.
run sudo firewall-cmd --zone=drop --add-service=ssh --permanent
run sudo firewall-cmd --zone=drop --add-service=dhcpv6-client --permanent
# Rebind active interfaces so they all live under the drop zone, not the
# old public zone. awk extracts interface names from the "interfaces:" lines.
local iface
for iface in $(sudo firewall-cmd --get-active-zones 2>/dev/null \
| awk '/^ interfaces: / {for(i=2;i<=NF;i++) print $i}' \
| grep -v '^lo$\|^lo[0-9]'); do
run sudo firewall-cmd --zone=drop --change-interface="$iface" --permanent