-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathinstall.sh
More file actions
executable file
·1355 lines (1170 loc) · 45.7 KB
/
install.sh
File metadata and controls
executable file
·1355 lines (1170 loc) · 45.7 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
#!/usr/bin/env bash
set -o errexit
set -o nounset
set -o pipefail
# =============================================
# GLOBAL VARIABLES
# =============================================
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"
UI_LIB="${SCRIPT_DIR}/scripts/lib/ui.sh"
if [[ -f "$UI_LIB" ]]; then
# shellcheck source=/dev/null
source "$UI_LIB"
ui_init
fi
# Stable paths (avoid writing root-owned files into the git repo)
LOG_DIR="/var/log/pihole-suite"
STATE_DIR="/var/lib/pihole-suite"
ENV_DIR="/etc/pihole-suite"
LOG_FILE="${LOG_DIR}/install.log"
ERROR_LOG="${LOG_DIR}/install_errors.log"
STATE_FILE="${STATE_DIR}/install.state"
ENV_FILE="${ENV_DIR}/pihole-suite.env"
RESOLV_CONF="/etc/resolv.conf"
RESOLV_CONF_BACKUP="/etc/resolv.conf.bak"
PIHOLE_TOML="/etc/pihole/pihole.toml"
# Ensure this script never blocks on sudo password prompts mid-run.
# If running as root, emulate sudo while supporting common flags (e.g. -n)
# and leading environment assignments (e.g. VAR=1 cmd).
if [[ ${EUID:-$(id -u)} -eq 0 ]]; then
sudo() {
# Drop common sudo flags in root-mode execution
while [[ $# -gt 0 ]]; do
case "$1" in
-n|-E) shift ;;
-u) shift; shift ;;
--) shift; break ;;
-*) shift ;;
*) break ;;
esac
done
local envs=()
while [[ $# -gt 0 && "$1" == *=* ]]; do
envs+=("$1")
shift
done
if [[ ${#envs[@]} -gt 0 ]]; then
env "${envs[@]}" "$@"
else
"$@"
fi
}
fi
# Defaults (NOT readonly)
CONTAINER_MODE=false
DRY_RUN=false
FORCE=false
AUTO_REMOVE_CONFLICTS=false
INSTALL_NETALERTX=false
INSTALL_PYTHON_SUITE=true
# Ports
UNBOUND_PORT=5335
NETALERTX_PORT=20211
PYTHON_SUITE_PORT=8090
CONTAINER_PIHOLE_DNS_PORT=8053
CONTAINER_PIHOLE_WEB_PORT=8080
# =============================================
# LOGGING
# =============================================
log() { log_info "$@"; }
log_success() { log_ok "$@"; }
log_warning() { log_warn "$@"; }
init_runtime_paths() {
# Enforce: clone as normal user, execute via sudo.
# (root shells like "su -" are intentionally rejected)
if [[ ${EUID:-$(id -u)} -eq 0 && ( -z "${SUDO_USER:-}" || "${SUDO_USER:-}" == "root" ) ]]; then
log_err "Do not run this installer as root directly."
log_err "Clone the repo as a normal user and run it via: sudo ./install.sh"
exit 1
fi
# Ensure log dir exists; fall back to repo dir if it cannot be used.
if [[ ${EUID:-$(id -u)} -eq 0 ]]; then
mkdir -p "$LOG_DIR" 2>/dev/null || true
chmod 0755 "$LOG_DIR" 2>/dev/null || true
fi
if [[ ! -d "$LOG_DIR" || ! -w "$LOG_DIR" ]]; then
LOG_FILE="${SCRIPT_DIR}/install.log"
ERROR_LOG="${SCRIPT_DIR}/install_errors.log"
fi
UI_LOG_FILE="$LOG_FILE"
UI_ERROR_LOG="$ERROR_LOG"
# Ensure state dir exists; fall back to repo dir if it cannot be used.
if [[ ${EUID:-$(id -u)} -eq 0 ]]; then
mkdir -p "$STATE_DIR" 2>/dev/null || true
chmod 0755 "$STATE_DIR" 2>/dev/null || true
fi
if [[ ! -d "$(dirname "$STATE_FILE")" || ! -w "$(dirname "$STATE_FILE")" ]]; then
STATE_FILE="${SCRIPT_DIR}/data/install.state"
fi
# Ensure env dir exists; fall back to repo dir if it cannot be used.
if [[ ${EUID:-$(id -u)} -eq 0 ]]; then
mkdir -p "$ENV_DIR" 2>/dev/null || true
chmod 0755 "$ENV_DIR" 2>/dev/null || true
fi
if [[ ! -d "$(dirname "$ENV_FILE")" || ! -w "$(dirname "$ENV_FILE")" ]]; then
ENV_FILE="${SCRIPT_DIR}/.env"
fi
}
ensure_suite_env_file() {
# Generate/maintain the suite env file regardless of whether the Python suite is installed.
# This matches the README expectation and avoids confusion when users want to retrieve SUITE_API_KEY.
local suite_data_dir="${1:-$STATE_DIR/data}"
if [[ "$DRY_RUN" == true ]]; then
log "DRY RUN: Would ensure suite env at $ENV_FILE"
return
fi
sudo mkdir -p "$(dirname "$ENV_FILE")" 2>/dev/null || true
sudo mkdir -p "$suite_data_dir" 2>/dev/null || true
if [[ ! -f "$ENV_FILE" ]] || ! grep -q '^SUITE_API_KEY=' "$ENV_FILE" 2>/dev/null; then
echo "SUITE_API_KEY=$(openssl rand -hex 32)" > "$ENV_FILE"
fi
grep -q '^SUITE_PORT=' "$ENV_FILE" 2>/dev/null || echo "SUITE_PORT=$PYTHON_SUITE_PORT" >> "$ENV_FILE"
grep -q '^UNBOUND_PORT=' "$ENV_FILE" 2>/dev/null || echo "UNBOUND_PORT=$UNBOUND_PORT" >> "$ENV_FILE"
grep -q '^NETALERTX_PORT=' "$ENV_FILE" 2>/dev/null || echo "NETALERTX_PORT=$NETALERTX_PORT" >> "$ENV_FILE"
grep -q '^SUITE_DATA_DIR=' "$ENV_FILE" 2>/dev/null || echo "SUITE_DATA_DIR=$suite_data_dir" >> "$ENV_FILE"
grep -q '^SUITE_LOG_LEVEL=' "$ENV_FILE" 2>/dev/null || echo "SUITE_LOG_LEVEL=INFO" >> "$ENV_FILE"
sudo chmod 640 "$ENV_FILE" 2>/dev/null || true
}
ensure_python_venv() {
# Ensure a working venv with an executable python at $venv_dir/bin/python
# Recreates the venv if python is missing (common after interrupted installs).
local venv_dir="$1"
[[ -z "$venv_dir" ]] && return 1
if [[ ! -x "$venv_dir/bin/python" ]]; then
log_warning "Python venv missing/broken at $venv_dir; recreating"
sudo rm -rf "$venv_dir" 2>/dev/null || true
python3 -m venv "$venv_dir"
fi
}
# =============================================
# STATE MANAGEMENT
# =============================================
init_state() {
mkdir -p "$(dirname "$STATE_FILE")"
if [[ ! -f "$STATE_FILE" ]]; then
cat > "$STATE_FILE" <<EOF
PACKAGES_OK=false
UNBOUND_OK=false
PIHOLE_OK=false
NETALERTX_OK=false
PY_SUITE_OK=false
HEALTH_OK=false
EOF
fi
# shellcheck source=/dev/null
source "$STATE_FILE"
}
update_state() {
sed -i "s/^$1=.*/$1=$2/" "$STATE_FILE"
# shellcheck source=/dev/null
source "$STATE_FILE"
}
# =============================================
# STATE VALIDATION (avoid stale install.state)
# =============================================
validate_state_against_system() {
# The state file is an optimization only. If it claims something is OK but the
# underlying binary/service/container is missing, reset the flag so the
# installer performs the step again.
[[ "$FORCE" == true ]] && return
local changed=false
# Unbound
if [[ "${UNBOUND_OK:-false}" == true ]]; then
if ! command -v unbound-checkconf >/dev/null 2>&1; then
log_warning "State override: UNBOUND_OK=true but unbound not installed"
update_state UNBOUND_OK false
changed=true
elif command -v systemctl >/dev/null 2>&1 && ! systemctl is-active --quiet unbound 2>/dev/null; then
log_warning "State override: UNBOUND_OK=true but unbound service not active"
update_state UNBOUND_OK false
changed=true
fi
fi
# Pi-hole
if [[ "${PIHOLE_OK:-false}" == true ]]; then
if [[ "$CONTAINER_MODE" == true ]]; then
if ! command -v docker >/dev/null 2>&1; then
log_warning "State override: PIHOLE_OK=true but docker missing"
update_state PIHOLE_OK false
changed=true
elif ! sudo -n docker ps 2>/dev/null | grep -q '\bpihole\b'; then
log_warning "State override: PIHOLE_OK=true but pihole container missing"
update_state PIHOLE_OK false
changed=true
fi
else
if ! command -v pihole >/dev/null 2>&1; then
log_warning "State override: PIHOLE_OK=true but pihole command missing"
update_state PIHOLE_OK false
changed=true
elif command -v systemctl >/dev/null 2>&1 && ! systemctl is-active --quiet pihole-FTL 2>/dev/null; then
log_warning "State override: PIHOLE_OK=true but pihole-FTL service not active"
update_state PIHOLE_OK false
changed=true
elif command -v dig >/dev/null 2>&1; then
if ! dig +time=2 +tries=1 +short @127.0.0.1 example.com A | grep -qE '^[0-9.]+$'; then
log_warning "State override: PIHOLE_OK=true but Pi-hole DNS not resolving"
update_state PIHOLE_OK false
changed=true
fi
fi
fi
fi
# NetAlertX
if [[ "${NETALERTX_OK:-false}" == true && "${INSTALL_NETALERTX:-true}" == true ]]; then
if ! command -v docker >/dev/null 2>&1; then
log_warning "State override: NETALERTX_OK=true but docker missing"
update_state NETALERTX_OK false
changed=true
else
local netalertx_status
netalertx_status="$(sudo -n docker ps --filter name=^/netalertx$ --format '{{.Status}}' 2>/dev/null | head -n1 || true)"
if [[ -z "$netalertx_status" ]]; then
log_warning "State override: NETALERTX_OK=true but netalertx container missing"
update_state NETALERTX_OK false
changed=true
elif [[ "$netalertx_status" != Up* ]]; then
log_warning "State override: NETALERTX_OK=true but netalertx not running (status: $netalertx_status)"
update_state NETALERTX_OK false
changed=true
fi
fi
fi
# Python Suite
if [[ "${PY_SUITE_OK:-false}" == true && "$CONTAINER_MODE" == false && "${INSTALL_PYTHON_SUITE:-true}" == true ]]; then
if command -v systemctl >/dev/null 2>&1; then
local py_state
py_state="$(systemctl is-active pihole-suite.service 2>/dev/null || true)"
if [[ "$py_state" != "active" ]]; then
log_warning "State override: PY_SUITE_OK=true but pihole-suite service not active (state: $py_state)"
update_state PY_SUITE_OK false
changed=true
fi
fi
fi
# Health is derived; if anything changed, recompute.
if [[ "$changed" == true ]]; then
update_state HEALTH_OK false
fi
}
# =============================================
# ARGUMENT PARSING
# =============================================
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--container-mode) CONTAINER_MODE=true ;;
--dry-run) DRY_RUN=true ;;
--force) FORCE=true ;;
# Compatibility alias: some docs/older guidance referenced --resume.
# Default behavior is already idempotent; keep it as a no-op.
--resume) : ;;
--auto-remove-conflicts) AUTO_REMOVE_CONFLICTS=true ;;
--install-netalertx|--with-netalertx) INSTALL_NETALERTX=true ;;
--skip-netalertx) INSTALL_NETALERTX=false ;;
--skip-python-api) INSTALL_PYTHON_SUITE=false ;;
--minimal) INSTALL_NETALERTX=false; INSTALL_PYTHON_SUITE=false ;;
*) log_error "Unknown option: $1"; exit 1 ;;
esac
shift
done
}
# =============================================
# SYSTEM CHECKS
# =============================================
check_dependencies() {
# OS / package manager sanity
if [[ ! -r /etc/os-release ]]; then
log_error "Cannot read /etc/os-release (unsupported system)"
exit 1
fi
if ! grep -Eq "^(ID|ID_LIKE)=(debian|ubuntu|.*debian.*|.*ubuntu.*)$" /etc/os-release; then
log_error "Unsupported OS (expected Debian/Ubuntu family)"
exit 1
fi
command -v apt-get >/dev/null 2>&1 || { log_error "apt-get not found"; exit 1; }
# Enforce sudo invocation to keep stable paths writable.
if [[ ${EUID:-$(id -u)} -ne 0 ]]; then
log_error "Run this installer via sudo: sudo ./install.sh"
exit 1
fi
# Note: most dependencies are installed by install_packages(); warn early for clarity
local missing_bootstrap=()
for cmd in curl openssl python3; do
command -v "$cmd" >/dev/null 2>&1 || missing_bootstrap+=("$cmd")
done
if [[ ${#missing_bootstrap[@]} -gt 0 ]]; then
log_warning "Bootstrap tools missing (installer will install): ${missing_bootstrap[*]}"
fi
# Optional tools (installer will install them, but preflight helps when debugging)
local missing_optional=()
for cmd in git jq dig ss ip; do
command -v "$cmd" >/dev/null 2>&1 || missing_optional+=("$cmd")
done
if [[ ${#missing_optional[@]} -gt 0 ]]; then
log_warning "Optional tools missing (installer will install): ${missing_optional[*]}"
fi
}
handle_systemd_resolved() {
[[ "$CONTAINER_MODE" == true ]] && return 0
command -v systemctl >/dev/null 2>&1 || return 0
# systemd-resolved's stub listener (127.0.0.53/54:53) can block Pi-hole/dnsmasq from binding to :53.
# Prefer disabling only the stub listener (keeps resolved functional) and fall back to stop/mask if needed.
if ! systemctl is-active --quiet systemd-resolved 2>/dev/null; then
return 0
fi
local resolved_conflict=true
if command -v ss >/dev/null 2>&1; then
# Avoid filter expressions (vary by iproute2 build); parse plain output.
if ss -H -lntup 2>/dev/null | grep -E '(^|[[:space:]])127\.0\.0\.(53|54):53([[:space:]]|$)' >/dev/null; then
resolved_conflict=true
elif ss -H -lntup 2>/dev/null | grep -E ':53[[:space:]]' | grep -qi systemd-resolve; then
resolved_conflict=true
else
resolved_conflict=false
fi
fi
[[ "$resolved_conflict" == true ]] || return 0
log_warning "systemd-resolved appears to be using port 53 (conflicts with Pi-hole); disabling stub listener"
local dropin_dir="/etc/systemd/resolved.conf.d"
local dropin_file="$dropin_dir/99-pihole-no-stub.conf"
sudo mkdir -p "$dropin_dir" 2>/dev/null || true
sudo bash -c 'cat > '"$dropin_file"' <<"EOF"
[Resolve]
DNSStubListener=no
EOF' || true
sudo systemctl restart systemd-resolved 2>/dev/null || true
# Ensure /etc/resolv.conf does not point to the stub resolver.
# Keep this conservative: only rewrite when it is a symlink to stub-resolv.conf or references 127.0.0.53/54.
if [[ -f "$RESOLV_CONF" && ! -f "$RESOLV_CONF_BACKUP" ]]; then
sudo cp -a "$RESOLV_CONF" "$RESOLV_CONF_BACKUP" 2>/dev/null || true
fi
if [[ -L "$RESOLV_CONF" ]]; then
local link_target
link_target="$(readlink "$RESOLV_CONF" 2>/dev/null || true)"
if [[ "$link_target" == */stub-resolv.conf* && -f /run/systemd/resolve/resolv.conf ]]; then
sudo ln -sf /run/systemd/resolve/resolv.conf "$RESOLV_CONF" || true
fi
fi
if grep -qE '^[[:space:]]*nameserver[[:space:]]+127\.0\.0\.(53|54)([[:space:]]|$)' "$RESOLV_CONF" 2>/dev/null; then
printf '%s\n' "nameserver 1.1.1.1" | sudo tee "$RESOLV_CONF" >/dev/null
fi
# If resolved still binds to :53, stop + disable + mask to prevent reactivation.
if command -v ss >/dev/null 2>&1; then
if ss -H -lntup 2>/dev/null | grep -E ':53[[:space:]]' | grep -qi systemd-resolve; then
log_warning "systemd-resolved still listening on port 53; disabling and masking service"
sudo systemctl disable --now systemd-resolved || true
sudo systemctl mask systemd-resolved || true
fi
fi
}
ensure_system_dns_for_docker() {
# Docker image pulls depend on the host resolver. If the host is already configured
# to use 127.0.0.1 but Pi-hole DNS isn't ready yet, Docker will fail to resolve.
[[ "$DRY_RUN" == true ]] && return 0
command -v getent >/dev/null 2>&1 || return 0
# If resolution works, nothing to do
if getent ahostsv4 registry-1.docker.io >/dev/null 2>&1; then
return 0
fi
# If /etc/resolv.conf points to localhost, temporarily restore a working resolver
if grep -qE '^[[:space:]]*nameserver[[:space:]]+127\.0\.0\.1([[:space:]]|$)' "$RESOLV_CONF" 2>/dev/null; then
log_warning "Host resolver points to 127.0.0.1 but cannot resolve registry-1.docker.io; temporarily restoring resolver for Docker"
if [[ -f "$RESOLV_CONF_BACKUP" ]]; then
sudo cp -f "$RESOLV_CONF_BACKUP" "$RESOLV_CONF" 2>/dev/null || true
else
echo "nameserver 1.1.1.1" | sudo tee "$RESOLV_CONF" >/dev/null
fi
fi
}
configure_local_dns_resolver() {
# Keep system DNS stable during installation (e.g. for Docker pulls).
# Only switch /etc/resolv.conf to 127.0.0.1 once Pi-hole is confirmed working.
[[ "$CONTAINER_MODE" == true ]] && return 0
if [[ "$DRY_RUN" == true ]]; then
log "DRY RUN: Would set $RESOLV_CONF to nameserver 127.0.0.1"
return 0
fi
# Backup current resolver once
if [[ -f "$RESOLV_CONF" && ! -f "$RESOLV_CONF_BACKUP" ]]; then
sudo cp -f "$RESOLV_CONF" "$RESOLV_CONF_BACKUP" 2>/dev/null || true
fi
echo "nameserver 127.0.0.1" | sudo tee "$RESOLV_CONF" >/dev/null
# Verify Pi-hole can resolve; if not, restore previous resolver to avoid breaking the host.
if command -v dig >/dev/null 2>&1; then
if ! dig +time=2 +tries=1 +short @127.0.0.1 example.com A | grep -qE '^[0-9.]+$'; then
log_warning "Local DNS (127.0.0.1) not resolving yet; restoring previous $RESOLV_CONF"
[[ -f "$RESOLV_CONF_BACKUP" ]] && sudo cp -f "$RESOLV_CONF_BACKUP" "$RESOLV_CONF" 2>/dev/null || true
return 0
fi
else
if ! python3 - <<'PYCHECK' >/dev/null 2>&1
import socket
socket.getaddrinfo('example.com', 80)
PYCHECK
then
log_warning "System resolver not working after switch to 127.0.0.1; restoring previous $RESOLV_CONF"
[[ -f "$RESOLV_CONF_BACKUP" ]] && sudo cp -f "$RESOLV_CONF_BACKUP" "$RESOLV_CONF" 2>/dev/null || true
return 0
fi
fi
log_success "System resolver configured: nameserver 127.0.0.1"
}
check_ports() {
if [[ "$DRY_RUN" == true ]]; then
log "DRY RUN: Would check ports $UNBOUND_PORT, $NETALERTX_PORT, $PYTHON_SUITE_PORT, 53"
return 0
fi
# Idempotency: during re-runs it's expected that Unbound/Pi-hole/etc. are
# already bound to their ports. Only fail if a port is occupied by an
# unexpected service.
is_expected_listener() {
local port="$1"
local ss_line=""
if command -v ss >/dev/null 2>&1; then
ss_line="$(ss -H -ltnup "sport = :$port" 2>/dev/null || true)"
fi
if [[ "$port" == "$UNBOUND_PORT" ]]; then
if command -v systemctl >/dev/null 2>&1 && systemctl is-active --quiet unbound 2>/dev/null; then
return 0
fi
echo "$ss_line" | grep -qi unbound && return 0
fi
if [[ "$port" == "53" && "$CONTAINER_MODE" == false ]]; then
if command -v systemctl >/dev/null 2>&1 && systemctl is-active --quiet pihole-FTL 2>/dev/null; then
return 0
fi
echo "$ss_line" | grep -qiE 'pihole-FTL|pihole' && return 0
fi
if [[ "$port" == "$NETALERTX_PORT" && "${INSTALL_NETALERTX:-true}" == true ]]; then
if command -v docker >/dev/null 2>&1; then
docker ps --format '{{.Names}}' 2>/dev/null | grep -qx netalertx && return 0
fi
fi
if [[ "$port" == "$PYTHON_SUITE_PORT" && "$CONTAINER_MODE" == false && "${INSTALL_PYTHON_SUITE:-true}" == true ]]; then
if command -v systemctl >/dev/null 2>&1 && systemctl is-active --quiet pihole-suite 2>/dev/null; then
return 0
fi
echo "$ss_line" | grep -qiE 'pihole-suite|uvicorn|python' && return 0
fi
return 1
}
port_is_in_use() {
local port="$1"
if command -v ss &>/dev/null; then
ss -tuln 2>/dev/null | grep -q "\:$port "
elif command -v netstat &>/dev/null; then
netstat -tuln 2>/dev/null | grep -q "\:$port "
else
return 1
fi
}
local ports=("$UNBOUND_PORT" "53")
[[ "$INSTALL_NETALERTX" == true ]] && ports+=("$NETALERTX_PORT")
[[ "$INSTALL_PYTHON_SUITE" == true ]] && ports+=("$PYTHON_SUITE_PORT")
if [[ "$CONTAINER_MODE" == true ]]; then
ports+=("$CONTAINER_PIHOLE_DNS_PORT" "$CONTAINER_PIHOLE_WEB_PORT")
fi
for port in "${ports[@]}"; do
if port_is_in_use "$port"; then
if is_expected_listener "$port"; then
log "✅ Port $port already in use (expected)"
continue
fi
log_error "Port $port in use"
return 1
fi
done
}
# =============================================
# DOCKER SERVICE MANAGEMENT
# =============================================
ensure_docker_service() {
log "Ensuring Docker service is running..."
if ! systemctl is-active --quiet docker 2>/dev/null; then
if ! $DRY_RUN; then
sudo systemctl enable docker || { log_error "Failed to enable Docker"; exit 1; }
sudo systemctl start docker || { log_error "Failed to start Docker"; exit 1; }
# Wait for Docker to be ready
local timeout=30
local count=0
while ! docker info >/dev/null 2>&1 && [ $count -lt $timeout ]; do
sleep 1
((count++))
done
if [ $count -eq $timeout ]; then
log_error "Docker service failed to start within ${timeout}s"
exit 1
fi
log_success "Docker service is running"
else
log "DRY RUN: Would ensure Docker service is running"
fi
else
log "✅ Docker service already running"
fi
}
install_packages() {
[[ "$PACKAGES_OK" == true && "$FORCE" != true ]] && { log "✅ Packages OK"; return; }
local packages=(
unbound unbound-host unbound-anchor dns-root-data ca-certificates curl dnsutils iproute2
python3 python3-venv python3-pip git openssl sqlite3 jq
)
if [[ "$INSTALL_NETALERTX" == true || "$CONTAINER_MODE" == true ]]; then
packages+=(docker.io)
fi
log "Installing packages..."
sudo apt-get update -qq
[[ "$AUTO_REMOVE_CONFLICTS" == true ]] && {
sudo apt-get remove -y containerd.io docker-ce docker-ce-cli || true
}
for _ in {1..3}; do
if ! $DRY_RUN; then
sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y "${packages[@]}" && {
log_success "Packages installed"
update_state PACKAGES_OK true
return
}
else
log "DRY RUN: Would install ${packages[*]}"
update_state PACKAGES_OK true
return
fi
sleep 2
done
log_error "Failed to install packages"
exit 1
}
# =============================================
# UNBOUND CONFIGURATION
# =============================================
configure_unbound() {
[[ "$UNBOUND_OK" == true && "$FORCE" != true ]] && { log "✅ Unbound OK"; return; }
log "Configuring Unbound DNS with DoT (DNS-over-TLS)..."
if ! $DRY_RUN; then
# Create Unbound directories
sudo install -d -m 0755 /var/lib/unbound
# Download root hints for DNS resolution
sudo curl -fsSL https://www.internic.net/domain/named.root -o /var/lib/unbound/root.hints || {
log_error "Failed to download root.hints"; exit 1;
}
# Update/validate DNSSEC trust anchor (root.key)
command -v unbound-anchor >/dev/null 2>&1 || { log_error "unbound-anchor missing"; exit 1; }
local unbound_anchor="/var/lib/unbound/root.key"
local unbound_anchor_log="/tmp/unbound-anchor.log"
log "Updating/validating DNSSEC trust anchor (root.key)..."
if [[ -f "$unbound_anchor" ]]; then
if sudo unbound-anchor -a "$unbound_anchor" -v >"$unbound_anchor_log" 2>&1; then
log_success "trust anchor ok (existing root.key)"
else
log_warning "unbound-anchor returned non-zero, but root.key exists; retrying..."
sudo tail -n 50 "$unbound_anchor_log" 2>/dev/null || true
sleep 2
if sudo unbound-anchor -a "$unbound_anchor" -v >"$unbound_anchor_log" 2>&1; then
log_success "trust anchor ok after retry"
else
log_warning "unbound-anchor still failing; continuing (DNSSEC may be impaired)."
sudo tail -n 80 "$unbound_anchor_log" 2>/dev/null || true
fi
fi
else
if sudo unbound-anchor -a "$unbound_anchor" -v >"$unbound_anchor_log" 2>&1; then
log_success "trust anchor created"
else
log_warning "unbound-anchor failed to create root.key; continuing (DNSSEC may be impaired)."
sudo tail -n 80 "$unbound_anchor_log" 2>/dev/null || true
fi
fi
# Verify TLS certificate bundle exists
if [[ ! -f /etc/ssl/certs/ca-certificates.crt ]]; then
log_error "TLS certificate bundle missing at /etc/ssl/certs/ca-certificates.crt"
exit 1
fi
# Create Unbound configuration directory if missing
sudo mkdir -p /etc/unbound/unbound.conf.d
# Ensure base config exists (it may be missing after manual cleanup or on some distros)
if [[ ! -f /etc/unbound/unbound.conf ]]; then
log_warning "/etc/unbound/unbound.conf missing; creating minimal include file"
sudo bash -c 'cat > /etc/unbound/unbound.conf' <<'EOF'
# Minimal Unbound config created by installer.
# Loads all drop-in configs from /etc/unbound/unbound.conf.d/.
server:
directory: "/etc/unbound"
include: "/etc/unbound/unbound.conf.d/*.conf"
EOF
fi
# Create comprehensive Unbound configuration
sudo bash -c 'cat > /etc/unbound/unbound.conf.d/forward.conf' <<EOF
server:
# Network interface and port
interface: 127.0.0.1
port: $UNBOUND_PORT
# TLS configuration for DNS-over-TLS
tls-cert-bundle: /etc/ssl/certs/ca-certificates.crt
# DNSSEC validation: Ubuntu bringt auto-trust-anchor-file als Drop-in
root-hints: /var/lib/unbound/root.hints
# Protocol support
do-ip4: yes
do-ip6: no
do-udp: yes
do-tcp: yes
# Access control
access-control: 127.0.0.0/8 allow
access-control: 10.0.0.0/8 allow
access-control: 172.16.0.0/12 allow
access-control: 192.168.0.0/16 allow
# Privacy and security
hide-identity: yes
hide-version: yes
harden-glue: yes
harden-dnssec-stripped: yes
harden-below-nxdomain: yes
harden-referral-path: yes
# Performance
cache-min-ttl: 3600
cache-max-ttl: 86400
prefetch: yes
# Logging (disable for production)
verbosity: 1
log-queries: no
# Forward zone for DNS-over-TLS to Quad9
forward-zone:
name: "."
forward-tls-upstream: yes
# Primary Quad9 DoT servers
forward-addr: 9.9.9.9@853#dns.quad9.net
forward-addr: 149.112.112.112@853#dns.quad9.net
# Backup Cloudflare DoT servers
forward-addr: 1.1.1.1@853#cloudflare-dns.com
forward-addr: 1.0.0.1@853#cloudflare-dns.com
EOF
# Remove duplicate trust anchor directives if Ubuntu drop-in exists
if [[ -f /etc/unbound/unbound.conf.d/root-auto-trust-anchor-file.conf ]]; then
sudo sed -i '/^[[:space:]]*trust-anchor-file:/d' /etc/unbound/unbound.conf.d/forward.conf || true
log "Ubuntu auto-trust-anchor detected, removed duplicate trust-anchor-file directive"
fi
# Validate configuration before restart
if ! sudo unbound-checkconf; then
log_error "Unbound configuration validation failed"
exit 1
fi
# Restart and verify Unbound service
sudo systemctl restart unbound || { log_error "Failed to restart Unbound"; exit 1; }
sleep 3
# Comprehensive health check
local health_checks=("example.com" "google.com" "cloudflare.com")
local failed_checks=0
for domain in "${health_checks[@]}"; do
if dig +short @127.0.0.1 -p $UNBOUND_PORT "$domain" | grep -qE '^[0-9.]+$'; then
log "✓ Unbound resolves $domain"
else
log_warning "✗ Unbound failed to resolve $domain"
((failed_checks++))
fi
done
if [ $failed_checks -lt ${#health_checks[@]} ]; then
log_success "Unbound DNS-over-TLS configured successfully"
update_state UNBOUND_OK true
else
log_error "Unbound health check failed for all test domains"
exit 1
fi
else
log "DRY RUN: Would configure Unbound with DoT, root.hints, trust anchors, and TLS certificates"
update_state UNBOUND_OK true
fi
}
# =============================================
# PI-HOLE SETUP
# =============================================
configure_pihole_v6_toml_upstreams() {
local toml_file="/etc/pihole/pihole.toml"
# Create the file if it doesn't exist
if [[ ! -f "$toml_file" ]]; then
log "Creating $toml_file..."
sudo install -o pihole -g pihole -m 0644 /dev/null "$toml_file"
fi
# Create timestamped backup preserving attributes
local backup_file="${toml_file}.backup.$(date +%Y%m%d_%H%M%S)"
sudo cp -a "$toml_file" "$backup_file"
log "Backup created: $backup_file"
local upstream="127.0.0.1#${UNBOUND_PORT}"
# Fast-path: avoid rewriting TOML if upstreams already match expected.
local current_upstreams=""
if command -v pihole-FTL >/dev/null 2>&1; then
current_upstreams="$(sudo pihole-FTL --config dns.upstreams 2>/dev/null || true)"
fi
if [[ -f "$toml_file" ]] \
&& grep -qF "$upstream" "$toml_file" 2>/dev/null \
&& [[ "$current_upstreams" == *"$upstream"* ]] \
&& [[ "$current_upstreams" != *","* ]]; then
log_success "Pi-hole upstreams already set to $upstream"
return 0
fi
# Always ensure the value is persisted in pihole.toml.
# Note: `pihole-FTL --config` may update runtime config but not necessarily write TOML.
(
set -e
local temp_file
temp_file="$(mktemp)" || { log_error "Failed to create temp file"; exit 1; }
trap 'rm -f "$temp_file" 2>/dev/null || true' EXIT
sudo awk -v upstream="$upstream" '
BEGIN { in_dns=0; dns_exists=0; skipping_upstreams=0 }
# Track when we enter [dns] section
/^\[dns\]/ {
in_dns=1
dns_exists=1
print
print " upstreams = [ \"" upstream "\" ]"
next
}
# Track when we leave [dns] section
/^\[/ && !/^\[dns\]/ {
in_dns=0
}
# If we are replacing an existing multi-line upstreams array, skip until closing bracket
skipping_upstreams {
if ($0 ~ /^[[:space:]]*\][[:space:]]*$/) {
skipping_upstreams=0
}
next
}
# Replace existing upstreams (single-line or multi-line) within [dns] section
in_dns && /^[[:space:]]*upstreams[[:space:]]*=/ {
# If this is a multi-line array (e.g. "upstreams = [") then skip until closing bracket
if ($0 ~ /\[[[:space:]]*$/ && $0 !~ /\][[:space:]]*$/) {
skipping_upstreams=1
}
next
}
# Print all other lines
{ print }
# At end of file, if [dns] was never found, add it
END {
if (!dns_exists) {
print ""
print "[dns]"
print " upstreams = [ \"" upstream "\" ]"
}
}
' "$toml_file" > "$temp_file"
sudo mv "$temp_file" "$toml_file"
sudo chown pihole:pihole "$toml_file"
sudo chmod 0644 "$toml_file"
)
# Prefer FTL CLI config as well (does validation); do not rely on it for persistence.
if command -v pihole-FTL >/dev/null 2>&1; then
log "Setting Pi-hole upstream via pihole-FTL --config (dns.upstreams)..."
if sudo pihole-FTL --config dns.upstreams "[ \"${upstream}\" ]" >/dev/null 2>&1; then
log_success "Configured dns.upstreams via pihole-FTL"
else
log_warning "pihole-FTL --config failed (TOML already updated)"
fi
fi
log "Configured DNS upstreams in $toml_file"
# Restart pihole-FTL
log "Restarting pihole-FTL..."
if ! sudo systemctl restart pihole-FTL; then
log_error "Failed to restart pihole-FTL"
exit 1
fi
# Wait until Pi-hole DNS can actually resolve (avoid 'Not Ready' race)
if command -v dig >/dev/null 2>&1; then
local ok=false
for _ in {1..30}; do
if dig +time=2 +tries=1 +short @127.0.0.1 example.com A | grep -qE '^[0-9.]+$'; then
ok=true
break
fi
sleep 2
done
if [[ "$ok" != true ]]; then
log_error "Pi-hole DNS did not become ready (example.com not resolving)"
exit 1
fi
fi
log_success "Pi-hole v6 DNS upstreams configured"
}
setup_pihole() {
if [[ "$PIHOLE_OK" == true && "$FORCE" != true ]]; then
log "✅ Pi-hole OK"
# Still enforce upstream config on resume to avoid stale dns.upstreams (Pi-hole v6).
if [[ "$CONTAINER_MODE" == false ]]; then
configure_pihole_v6_toml_upstreams
fi
return
fi
if [[ "$CONTAINER_MODE" == true ]]; then
setup_pihole_container
else
setup_pihole_host
fi
}
setup_pihole_host() {
if ! $DRY_RUN; then
if ! command -v pihole &>/dev/null; then
# Install Pi-hole non-interactively (SSH-safe)
# Security hardening: do not execute remote content via pipe.
# Important: keep temp-file cleanup scoped (avoid global RETURN trap).
(
set -e
local pihole_installer
pihole_installer="$(mktemp)" || { log_error "Failed to create temp file"; exit 1; }
trap 'rm -f "$pihole_installer" 2>/dev/null || true' EXIT
if ! curl -fsSL --proto '=https' --tlsv1.2 https://install.pi-hole.net -o "$pihole_installer"; then
log_error "Failed to download Pi-hole installer"
exit 1
fi
chmod 0700 "$pihole_installer" 2>/dev/null || true
log_warning "Note: Pi-hole installer is downloaded over HTTPS but not cryptographically verified (no checksum/signature available)."
sudo env \
PIHOLE_SKIP_OS_CHECK=true \
PIHOLE_INSTALL_AUTO=true \
DEBIAN_FRONTEND=noninteractive \
PIHOLE_DNS_1=127.0.0.1#$UNBOUND_PORT \
PIHOLE_DNS_2=no \
bash "$pihole_installer" --unattended
) || { log_error "Pi-hole install failed"; exit 1; }
fi
# Wait briefly for Pi-hole FTL to come up (avoid racing immediately after install)
for _ in {1..30}; do
systemctl is-active --quiet pihole-FTL 2>/dev/null && break
sleep 2
done
if [[ -f /etc/pihole/setupVars.conf ]]; then
log_warning "Legacy setupVars.conf present; Pi-hole v6 uses pihole.toml (no changes applied)."
fi
if [[ -f "$PIHOLE_TOML" ]]; then
log "Detected pihole.toml at $PIHOLE_TOML (v6.1.4+ built-in web server)"
else
log_warning "pihole.toml missing; creating placeholder for Pi-hole v6.1.4 expectations"
sudo install -o pihole -g pihole -m 0644 /dev/null "$PIHOLE_TOML"
echo "# Managed via Pi-hole UI (placeholder created by installer)" | sudo tee "$PIHOLE_TOML" >/dev/null
fi
configure_pihole_v6_toml_upstreams
log_success "Pi-hole OK"
update_state PIHOLE_OK true
else
log "DRY RUN: Would install Pi-hole"
update_state PIHOLE_OK true
fi
}
setup_pihole_container() {
if ! $DRY_RUN; then
ensure_docker_service
ensure_system_dns_for_docker
# Remove existing Pi-hole container if it exists
sudo docker rm -f pihole 2>/dev/null || true
log "Creating Pi-hole container with host networking..."
sudo docker run -d --name pihole --network host \