-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathinstall-homeserver.sh
More file actions
executable file
·3180 lines (2995 loc) · 167 KB
/
Copy pathinstall-homeserver.sh
File metadata and controls
executable file
·3180 lines (2995 loc) · 167 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
# Unattended homeserver provisioning for APIARY — the Linux-side
# equivalent of a Windows autounattend.xml. This is the single entry point
# described in issue #518; it covers everything smoke-test-verified so far
# (see docs/research/518-smoke-test-research.md) and is expected to grow.
#
# Scope: this script provisions a MANUALLY installed base Ubuntu Server or
# Rocky Linux 10 system into a running APIARY homeserver (Docker, NVIDIA/GPU
# stack, Arcane, WireGuard, the repo checkout, secret restore, and starting
# the Compose stacks in dependency order). It does NOT partition disks or
# install the OS itself — that's docs/autoinstall/homeserver-user-data.yaml
# on Ubuntu, or a kickstart on Rocky (#2730), run once, separately, before
# this script ever sees the box.
#
# Distro support: every package operation goes through the pkg_* shim rather
# than calling apt-get directly, and $DISTRO_FAMILY (debian|rhel) is resolved
# once at source time. Only genuinely distro-specific things branch on it --
# package names, repository format, and the NVIDIA driver path.
#
# Design goals (per #518): a single entry point, live status as it runs,
# a clear non-fatal-by-default failure report at the end so a partial run
# can be diagnosed and re-run, resumability (already-completed steps are
# skipped on re-run via markers under $MARKER_DIR), and retries for
# network-flaky steps (apt, git, rsync, docker pull) rather than a hard
# failure on the first transient blip.
#
# Usage:
# sudo ./scripts/install-homeserver.sh --config /path/to/answers.conf
# sudo ./scripts/install-homeserver.sh --config answers.conf --force-rerun-from docker-install
# sudo ./scripts/install-homeserver.sh --config answers.conf --reset-markers # ignore all markers, redo everything
# sudo ./scripts/install-homeserver.sh --install-self # stage this script + its library under /usr/local
#
# scripts/install.sh --profile home is the same thing with the answers file
# defaulted to /etc/apiary/install-home.conf.
#
# --install-self exists because this script does not only run from the
# checkout: systemd under SELinux cannot exec it out of /home or /root, so the
# homeserver runs a copy at /usr/local/sbin/apiary-install-homeserver.sh. That
# copy was made by hand, which is how the shared library it now sources would
# go missing; --install-self stages both halves together. Re-run it after every
# checkout update, or the installed copy keeps running the old code.
#
# The answers file follows scripts/install-homeserver.conf.example --
# copy it, fill in every <PLACEHOLDER>, keep the filled-in copy OUT of
# version control (it will contain real IPs, keys, and a real git remote).
set -uo pipefail
# ---------------------------------------------------------------------------
# Status tracking / resumability — every phase reports through run_step so
# one failure doesn't abort the whole run; everything attempted gets
# recorded and printed in a final summary. Steps that already succeeded on a
# prior run (a marker file exists under $MARKER_DIR) are skipped unless
# --reset-markers or --force-rerun-from <step-id> is passed.
#
# That framework now lives in scripts/lib/install-common.sh, shared with
# install-vps.sh (#1609 Phase 5) instead of being copied into both. Only this
# script's own identity is set here.
# ---------------------------------------------------------------------------
INSTALLER_NAME="install-homeserver.sh"
INSTALLER_CONF_EXAMPLE="scripts/install-homeserver.conf.example"
INSTALLER_SELF_PATH="$(readlink -f "${BASH_SOURCE[0]}")"
LOG_DIR="/var/log/honeypot-install"
MARKER_DIR="/var/lib/honeypot-install/markers"
SUMMARY_WIDTH=36
# Resolve the shared library: explicit override, then next to this script (a
# checkout), then the installed location. This script does not only run from
# the checkout — systemd under SELinux cannot exec it from /home or /root, so
# the live homeserver runs a copy at /usr/local/sbin (see --install-self).
# Hard-exit rather than sourcing nothing: `set -u` without `-e` lets a failed
# `source` continue, after which every framework call is a "command not found"
# and the run limps on half-executed. That risk is why this extraction was
# deferred once; this is the guard that closes it.
APIARY_INSTALL_LIB_RESOLVED=""
for _cand in \
"${APIARY_INSTALL_LIB:-}" \
"$(dirname "$INSTALLER_SELF_PATH")/lib/install-common.sh" \
"/usr/local/lib/apiary/install-common.sh"; do
if [[ -n "$_cand" && -r "$_cand" ]]; then APIARY_INSTALL_LIB_RESOLVED="$_cand"; break; fi
done
if [[ -z "$APIARY_INSTALL_LIB_RESOLVED" ]]; then
echo "Cannot find install-common.sh (the shared installer framework)." >&2
echo "Looked at: \$APIARY_INSTALL_LIB, $(dirname "$INSTALLER_SELF_PATH")/lib/, /usr/local/lib/apiary/" >&2
echo "Run this script from a repo checkout, or re-run 'sudo $0 --install-self'" >&2
echo "from one so the script and its library are staged together." >&2
exit 2
fi
# shellcheck source=lib/install-common.sh
source "$APIARY_INSTALL_LIB_RESOLVED"
install_common_require
# ---------------------------------------------------------------------------
# Args / config
# ---------------------------------------------------------------------------
install_common_parse_args "$@"
if [[ $EUID -ne 0 ]]; then
echo "Run as root: sudo $0 --config <file>" >&2
exit 1
fi
if [[ -z "$CONFIG_FILE" || ! -f "$CONFIG_FILE" ]]; then
echo "Missing or unreadable --config file." >&2
echo "Copy scripts/install-homeserver.conf.example, fill in every" >&2
echo "<PLACEHOLDER>, and pass it with --config." >&2
exit 1
fi
# shellcheck disable=SC1090
source "$CONFIG_FILE"
for var in GIT_REPO_URL GIT_REF REPO_DIR HOME_WG_ADDRESS \
VPS_WG_ADDRESS VPS_WG_ENDPOINT VPS_WG_PUBLIC_KEY \
VPS_SSH_HOST VPS_SSH_PORT VPS_SSH_USER VPS_SSH_KEY ENABLE_GPU_STACK \
INSTALL_TIMEZONE BACKUP_HOST BACKUP_HOST_USER BACKUP_HOST_KEY BACKUP_HOST_PATH \
TECHNITIUM_LAN_IP ENABLE_SANDBOX_RESTORE AUTH_THEME_REPO_URL \
KEYCLOAK_PUBLIC_DOMAIN ARCANE_URL ARCANE_API_TOKEN; do
if [[ -z "${!var:-}" || "${!var}" == *'<'*'>'* ]]; then
echo "Config value $var is unset or still a <PLACEHOLDER> in $CONFIG_FILE." >&2
echo "Fill in every field before running unattended." >&2
exit 1
fi
done
# KEYCLOAK_PUBLIC_DOMAIN is the BASE domain -- every public hostname is derived
# from it by prefixing a service label (auth.<domain>, arcane.<domain>,
# dashboard.<domain>, ...). Handing it a hostname that already carries one of
# those labels silently produces a second-level subdomain: the 2026-09-03
# rebuild was configured with the auth HOSTNAME rather than the base domain,
# and generated https://arcane.auth.<domain> plus issuer
# https://auth.auth.<domain> -- neither of which resolves or is covered by the
# wildcard origin certificate (*.<domain> matches one label only). Nothing
# downstream validated it, so it
# surfaced as OIDC discovery failures far from the cause. Reject it here.
for label in auth arcane dashboard kibana arkime evebox traefik tanner snare rev; do
if [[ "$KEYCLOAK_PUBLIC_DOMAIN" == "$label."* ]]; then
echo "KEYCLOAK_PUBLIC_DOMAIN is \"$KEYCLOAK_PUBLIC_DOMAIN\", which already starts with the" >&2
echo "service label \"$label.\". This value must be the BASE domain only --" >&2
echo "the installer derives auth.<domain>, arcane.<domain> and friends from it," >&2
echo "so this would generate ${label}.${KEYCLOAK_PUBLIC_DOMAIN} (a second-level" >&2
echo "subdomain that a *.<domain> wildcard certificate does not cover)." >&2
echo "Use \"${KEYCLOAK_PUBLIC_DOMAIN#"$label".}\" instead." >&2
exit 1
fi
done
# INSTALL_HOSTNAME is intentionally excluded from the loop above and
# allowed to be genuinely empty -- this file's own header comment (and
# install-homeserver.conf.example's) already documented "leave empty to
# keep whatever's already set" as supported, but nothing actually was:
# the validation loop rejected an empty value outright, and even past
# that, step_set_hostname unconditionally called `hostnamectl
# set-hostname ""`, which errors rather than leaving the hostname alone.
# Caught live (#787's actual homeserver reinstall, 2026-08-09) -- worked
# around at the time by filling in the box's real current hostname
# explicitly, but the documented empty-value behavior is real and worth
# actually supporting, not just documenting.
# HOME_WG_PRIVATE_KEY is intentionally allowed to be empty/absent — if the
# original tunnel private key wasn't part of the backup (it wasn't captured
# by the .env-only backup pass, see #518 comment history), step_wireguard_config
# generates a fresh keypair and step_wireguard_sync_vps_peer pushes the new
# public key to the VPS side automatically.
#
# ARCANE_URL/ARCANE_API_TOKEN (#1502): step_arcane_import_stacks needs an
# already-running Arcane with an API key generated through its own UI
# (Settings -> API Keys, after Arcane's first interactive login -- an
# unattended installer can't complete Arcane's own OIDC/passkey login
# itself). #1504 closed half of this gap: step_arcane_install (formerly
# step_dockge_install) now stands Arcane itself up from
# docker-compose.arcane.yml, so it's installed and reachable before this
# script reaches the import step, rather than the plain Dockge it used to
# install. The remaining, irreducible part is a genuine two-pass bootstrap:
# minting ARCANE_API_TOKEN still requires a first human login, and once
# Keycloak is up that login is OIDC-only, so the very first from-scratch run
# lands Arcane + Keycloak, a human logs in and mints a token, and a second
# run with the filled-in token completes the honeypot-* import. Nothing in
# this installer can complete Arcane's own interactive login for you.
#
# KEYCLOAK_PUBLIC_DOMAIN (#1504): the base domain the honeypot's public
# hostnames hang off (auth.<domain>, arcane.<domain>, ...) -- step_arcane_install
# derives Arcane's own APP_URL and OIDC issuer URL from it for the .env it
# generates, matching the Keycloak realm's own example.invalid -> <domain>
# substitution. Same value as honeypot-keycloak's KEYCLOAK_PUBLIC_DOMAIN.
# BACKUP_HOST_SANDBOX_PATH is only needed when ENABLE_SANDBOX_RESTORE=true --
# don't force every user to fill it in just to skip a 170G+ optional restore.
if [[ "$ENABLE_SANDBOX_RESTORE" == "true" ]]; then
if [[ -z "${BACKUP_HOST_SANDBOX_PATH:-}" || "${BACKUP_HOST_SANDBOX_PATH}" == *'<'*'>'* ]]; then
echo "ENABLE_SANDBOX_RESTORE=true but BACKUP_HOST_SANDBOX_PATH is unset or still a <PLACEHOLDER>." >&2
exit 1
fi
fi
install_common_open_run_log
# ---------------------------------------------------------------------------
# Distro shim
# ---------------------------------------------------------------------------
# The homeserver is moving from Ubuntu to Rocky Linux 10, so every package
# operation goes through pkg_* rather than calling apt-get directly. Only the
# places where the two distros genuinely differ -- package names, repository
# format, the NVIDIA driver path -- branch on $DISTRO_FAMILY. Everything else
# (Docker, WireGuard, libvirt, the whole APIARY provisioning flow) is
# identical once the packages are on disk.
#
# Set once, at source time, so a step cannot disagree with preflight. The
# detection itself lives in the shared library (#1609 Phase 5) -- install-vps.sh
# needs exactly the same answer and used to derive it inside a step, where a
# marker-skipped preflight left it unset.
install_common_detect_distro
pkg_update() {
case "$DISTRO_FAMILY" in
debian) with_retry 3 10 env DEBIAN_FRONTEND=noninteractive apt-get update -y ;;
rhel) with_retry 3 10 dnf -y makecache ;;
*) echo "unsupported distro family: $DISTRO_FAMILY" >&2; return 1 ;;
esac
}
pkg_install() {
case "$DISTRO_FAMILY" in
debian) with_retry 3 15 env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "$@" ;;
rhel) with_retry 3 15 dnf install -y "$@" ;;
*) echo "unsupported distro family: $DISTRO_FAMILY" >&2; return 1 ;;
esac
}
# Arcane materializes each stack's directory with its compose file named exactly
# as the manifest's dockerComposePath says -- which is compose.yml for the 33
# honeypot-* stacks but docker-compose.yml for auth-events-worker, llm-worker
# and ml-worker. Steps that hardcoded compose.yml silently did nothing for
# those three (#2817). Resolve it instead of assuming.
stack_compose_file() {
local dir="$1" f
for f in compose.yml docker-compose.yml; do
[[ -f "$dir/$f" ]] && { printf '%s\n' "$f"; return 0; }
done
return 1
}
# ---------------------------------------------------------------------------
# Phase 0 — preflight
# ---------------------------------------------------------------------------
step_preflight_os() {
case "$DISTRO_FAMILY" in
debian|rhel) : ;;
*) echo "Unsupported OS '$DISTRO_ID' (need an Ubuntu/Debian or Rocky/RHEL family host)" >&2; return 1 ;;
esac
echo "OS: ${DISTRO_ID} (family: ${DISTRO_FAMILY})"
}
# Rocky enforces SELinux and ships firewalld enabled; Ubuntu did neither.
# Both can silently break a honeypot host -- containers hitting EACCES on a
# bind mount, or published sensor ports being filtered. Deliberately REPORTS
# rather than changes: turning off a firewall or SELinux is a security
# decision for the operator, not something an installer should do quietly.
# Non-fatal by design, exactly like step_preflight_disks.
step_preflight_rhel_platform() {
[[ "$DISTRO_FAMILY" == "rhel" ]] || { echo "not a RHEL-family host, nothing to check"; return 0; }
local mode="unknown"
command -v getenforce >/dev/null 2>&1 && mode="$(getenforce 2>/dev/null)"
echo "SELinux: $mode"
if [[ "$mode" == "Enforcing" ]]; then
echo "WARNING: SELinux is enforcing. Compose bind mounts written for Ubuntu"
echo " carry no :z/:Z labels, so containers can fail with permission"
echo " denied on paths under /var/dockge/stacks. Verify container"
echo " health after the run and check 'ausearch -m avc -ts recent'."
fi
if systemctl is-active --quiet firewalld 2>/dev/null; then
echo "WARNING: firewalld is active. The Ubuntu build installed ufw but never"
echo " enabled it, so this host previously ran with no host firewall."
echo " Confirm the sensor ports are actually reachable from outside"
echo " before treating the install as good."
else
echo "firewalld: inactive"
fi
return 0
}
step_preflight_disks() {
# Non-fatal check: warn (in the log) if the layout from
# docs/HOMESERVER-DISK-LAYOUT.md isn't present, but don't block —
# a second build server may legitimately use a different layout.
for mnt in /var; do
mountpoint -q "$mnt" || echo "WARNING: $mnt is not a separate mount — see docs/HOMESERVER-DISK-LAYOUT.md"
done
return 0
}
step_set_hostname() {
[[ -n "${INSTALL_HOSTNAME:-}" ]] && hostnamectl set-hostname "$INSTALL_HOSTNAME"
timedatectl set-timezone "$INSTALL_TIMEZONE"
}
# ---------------------------------------------------------------------------
# Phase 1 — base packages
# ---------------------------------------------------------------------------
step_pkg_update() {
pkg_update
}
step_base_packages() {
# Same tools either way; four of them are simply named differently.
# dnsutils -> bind-utils (dig, nslookup)
# gnupg -> gnupg2
# openssh-client -> openssh-clients (note the plural)
# ufw -> firewalld (RHEL's host firewall; see
# step_preflight_rhel_platform, which
# reports rather than configures it)
# lsb-release is Debian-only and unused on RHEL, where /etc/os-release
# carries the same information.
case "$DISTRO_FAMILY" in
debian)
pkg_install ca-certificates curl dnsutils gnupg lsb-release git jq rsync ufw \
xfsprogs nvme-cli openssh-client
;;
rhel)
# EPEL first, and unconditionally: Rocky's own repos do not carry
# fuse-sshfs (step_sshfs_install) or dkms (the GPU branch). It used to be
# installed only inside the GPU branch, so a host with
# ENABLE_GPU_STACK=false reached step_sshfs_install with no repo
# providing sshfs at all and failed there -- hit live on the 2026-09-03
# Rocky 10 rebuild ("No match for argument: fuse-sshfs"). Installing it
# here makes it available to every later step regardless of profile.
pkg_install epel-release
pkg_install ca-certificates curl bind-utils gnupg2 git jq rsync firewalld \
xfsprogs nvme-cli openssh-clients
;;
esac
}
# ---------------------------------------------------------------------------
# Phase 2 — Docker + Compose plugin
# ---------------------------------------------------------------------------
step_docker_repo() {
case "$DISTRO_FAMILY" in
debian)
install -m 0755 -d /etc/apt/keyrings
with_retry 3 10 curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
-o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
. /etc/os-release
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu ${VERSION_CODENAME} stable" \
> /etc/apt/sources.list.d/docker.list
with_retry 3 10 apt-get update -y
;;
rhel)
# Docker's own CentOS repofile, dropped in verbatim rather than added
# via `dnf config-manager`: that plugin's syntax changed between dnf4
# and dnf5, and writing the file works identically on both.
#
# Its baseurl interpolates $releasever, which on Rocky resolves to the
# major version ("10") and not the point release -- checked, because
# Docker publishes centos/10 but no centos/10.2, so a point-release
# $releasever would 404 every package.
with_retry 3 10 curl -fsSL https://download.docker.com/linux/centos/docker-ce.repo \
-o /etc/yum.repos.d/docker-ce.repo
chmod 0644 /etc/yum.repos.d/docker-ce.repo
with_retry 3 10 dnf -y makecache
;;
esac
}
step_docker_install() {
# Package names are the same on both -- Docker ships them under identical
# names in the deb and rpm repos.
pkg_install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
systemctl enable --now docker
}
step_docker_daemon_config() {
# Matches the live homeserver's /etc/docker/daemon.json: bounded log
# rotation (containers run forever, unbounded logs will fill /var), wider
# default-address-pools (STACK-REBUILD.md documents this box exhausting
# Docker's default pools once ~15+ Compose projects are up — fix it before
# that happens rather than after), the nvidia runtime registered once the
# container toolkit is installed (safe to declare even before the toolkit
# exists — dockerd just won't use it until nvidia-container-runtime is on
# $PATH), and a builder GC policy (#2743): `docker builder prune -af`
# reclaimed 179GB of buildkit cache with zero active entries the day /var
# hit 96% and took down two ES-backed CI legs (unavailable_shards_exception
# on primary allocation) -- `builder.gc` is on by default, but dockerd
# infers its threshold as a *percentage* of the filesystem
# (defaultReservedSpacePercentage = 10 in moby's builder-next/worker/gc.go;
# in 29.x the full path is daemon/internal/builder-next/worker/gc.go),
# which on this 1.8T /var computes to exactly 179GB -- the same 179GB
# `docker builder prune -af` reclaimed. Exactly, because diskPercentage()
# rounds as `(total*pct/100 / (1<<30) + 1) * 1e9`: 178 GiB-units + 1, times
# 1e9. Note that makes it 179 *decimal* GB (166.7 GiB), and that the knob is
# ReservedSpace -- a floor GC will not prune below -- not a ceiling; the
# nominal ceiling is the inferred MaxUsedSpace (80% = 1.43 TB). On this host
# the floor is what binds, so 179GB was the operative target.
# The GC was working; its inferred
# threshold was simply far too large for this box. `builder.gc` is buildkit's own
# supported policy mechanism (no separate systemd timer needed): it runs
# automatically as part of normal build activity once enabled, keeping
# cache usage near the explicit values below rather than the inferred
# default.
# 20GB is generous for any single build on this box (the largest image
# here, backend-service's Rust release build, has nowhere near that much
# unique layer churn) while still well below the 179GB this issue found.
# Careful with the unit: dockerd parses these values with units.RAMInBytes
# (confirmed for all three fields, not just the deprecated one --
# daemon/internal/builder-next/controller.go on the docker-29.x branch
# calls units.RAMInBytes on ReservedSpace, MaxUsedSpace and MinFreeSpace
# alike), which is binary, so "20GB" is read as 20 GiB (21,474,836,480 B)
# and `docker buildx inspect` renders it back as "20GiB". Real reduction
# from the pre-#2743 inferred floor is 179.0 GB -> 21.5 GB, about 157.5 GB
# tighter.
#
# #2750: `defaultKeepStorage` alone was wrong in two ways. First, setting
# only it means `reservedSpace != 0` in DefaultGCPolicy's guard below, so
# the whole percentage-inference block that used to also fill in
# MaxUsedSpace/MinFreeSpace never runs -- both silently stay 0 (disabled).
# Confirmed directly against the moby source for the docker-29.x branch
# (the version actually running here, `docker version` -> 29.7.2),
# daemon/internal/builder-next/worker/gc.go:
# if reservedSpace == 0 && maxUsedSpace == 0 && minFreeSpace == 0 {
# reservedSpace = diskPercentage(dstat, defaultReservedSpacePercentage) // 10%
# maxUsedSpace = diskPercentage(dstat, defaultMaxUsedPercentage) // 80%
# minFreeSpace = diskPercentage(dstat, defaultMinFreePercentage) // 20%
# }
# Before #2743 this host had an inferred MinFreeSpace of ~358GB (20% of
# 1.8T) -- a guard that pruned build cache whenever /var free space fell
# below that, regardless of what was actually consuming the disk. Setting
# only defaultKeepStorage silently dropped that guard entirely; buildkit
# now holds its 20GB cap and never reacts to disk pressure from a
# non-buildkit source (Elasticsearch growth, a large writable container
# layer, log growth).
# Second, `defaultKeepStorage` is deprecated -- confirmed in
# daemon/config/builder.go's own doc comment ("Deprecated option is now
# equivalent to DefaultReservedSpace") -- with no deprecation warning
# logged when set, so a future Docker upgrade could drop the key outright
# and silently revert to the inferred ~179GB cap on this 1.8T /var while
# this comment still promised 20GB.
# Fix: the non-deprecated spelling plus an explicit floor. /var sits at
# 93% (131G free of 1.8T) as of 2026-08-31 -- tighter than the 90% this
# script's other guidance assumes -- so 100GB is a real, load-bearing
# floor here, not a formality: buildkit GC now starts pruning once /var
# free space drops within about 31GB of that floor, not after the disk is
# already critical.
cat >/etc/docker/daemon.json <<'EOF'
{
"log-driver": "local",
"log-opts": {
"max-file": "3",
"max-size": "10m"
},
"default-address-pools": [
{ "base": "172.16.0.0/12", "size": 24 }
],
"runtimes": {
"nvidia": {
"args": [],
"path": "nvidia-container-runtime"
}
},
"builder": {
"gc": {
"enabled": true,
"defaultReservedSpace": "20GB",
"defaultMaxUsedSpace": "100GB",
"defaultMinFreeSpace": "100GB"
}
}
}
EOF
systemctl restart docker
}
# #1388: short-lived Docker veth interfaces vanish before networkd-dispatcher,
# libvirtd, and systemd-resolved get around to inspecting them, producing a
# continuous "not found"/"ethtool ioctl error"/"Failed to determine whether
# the interface is managed" flood at warning/error severity that buries real
# host/network failures -- confirmed live: 16,593 networkd-dispatcher and
# 4,754 libvirtd priority-3 entries in 24 hours, almost entirely veth noise.
#
# networkd-dispatcher has zero configured hook scripts under any
# /etc/networkd-dispatcher/*.d/ on this host (confirmed live), so it does no
# actual work here -- disabling it entirely, rather than trying to filter its
# output, is the clean fix its own noise volume (the majority of the flood)
# deserves. Idempotent to a box where it isn't installed at all.
#
# libvirtd/systemd-resolved still do real work for real interfaces, so they
# stay running; systemd's own LogFilterPatterns= (v253+, this fleet runs
# 259) drops exactly the veth-shaped messages by content before they reach
# journald, leaving every other warning/error -- real NICs, bridges,
# WireGuard, libvirt-managed taps -- fully intact. A broad severity/rate
# suppression was deliberately rejected (see the issue): this is the
# narrowest mechanism systemd offers that's actually message-content-aware.
step_quiet_veth_noise() {
if systemctl list-unit-files networkd-dispatcher.service 2>/dev/null | grep -q networkd-dispatcher; then
systemctl disable --now networkd-dispatcher.service || true
fi
if systemctl list-unit-files libvirtd.service 2>/dev/null | grep -q libvirtd; then
mkdir -p /etc/systemd/system/libvirtd.service.d
cat >/etc/systemd/system/libvirtd.service.d/99-veth-noise.conf <<'EOF'
[Service]
LogFilterPatterns=~ethtool ioctl error on veth[0-9a-f]+: No such device
EOF
fi
if systemctl list-unit-files systemd-resolved.service 2>/dev/null | grep -q systemd-resolved; then
mkdir -p /etc/systemd/system/systemd-resolved.service.d
cat >/etc/systemd/system/systemd-resolved.service.d/99-veth-noise.conf <<'EOF'
[Service]
LogFilterPatterns=~veth[0-9a-f]+: Failed to determine whether the interface is managed
EOF
fi
systemctl daemon-reload
if systemctl is-active --quiet libvirtd.service 2>/dev/null; then
systemctl restart libvirtd.service
fi
if systemctl is-active --quiet systemd-resolved.service 2>/dev/null; then
systemctl restart systemd-resolved.service
fi
}
# ---------------------------------------------------------------------------
# Phase 3 — NVIDIA GPU stack (driver + container toolkit), skippable
# ---------------------------------------------------------------------------
step_gpu_driver() {
case "$DISTRO_FAMILY" in
debian)
pkg_install ubuntu-drivers-common
# `ubuntu-drivers autoinstall` was removed in this box's ubuntu-drivers-common
# (1:0.10.9, Ubuntu 26.04) -- the CLI now uses `install` with no args to mean
# "install the recommended driver for every detected device", confirmed via
# `ubuntu-drivers -h` live on this box. Keep the old subcommand as a fallback
# in case a different target runs an older ubuntu-drivers-common.
ubuntu-drivers install || ubuntu-drivers autoinstall
;;
rhel)
# There is no ubuntu-drivers equivalent, so this is explicit: NVIDIA's
# CUDA repository plus the `cuda-drivers` meta-package, which pulls the
# proprietary driver and its DKMS kernel module.
#
# dkms lives in EPEL, not in Rocky's own repos, and the module will not
# build without headers matching the *running* kernel -- so both are
# installed before the driver rather than letting the driver fail late.
pkg_install epel-release
pkg_install dkms gcc make "kernel-devel-$(uname -r)" "kernel-headers-$(uname -r)" \
|| pkg_install dkms gcc make kernel-devel kernel-headers
with_retry 3 10 curl -fsSL \
"https://developer.download.nvidia.com/compute/cuda/repos/rhel10/$(uname -m)/cuda-rhel10.repo" \
-o /etc/yum.repos.d/cuda-rhel10.repo
chmod 0644 /etc/yum.repos.d/cuda-rhel10.repo
with_retry 3 10 dnf -y makecache
# Deliberately `cuda-drivers` (proprietary) and not `nvidia-open`: the
# open kernel modules only support Turing and newer. This box also holds
# a Pascal Quadro P2200 alongside the Ada compute card, and while the
# P2200 is meant to be bound to vfio-pci for the Windows sandbox rather
# than driven by the host, picking the open modules here would make that
# card unusable if it ever is needed on the host.
pkg_install cuda-drivers
;;
esac
}
step_gpu_container_toolkit() {
case "$DISTRO_FAMILY" in
debian)
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
| gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
| sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \
> /etc/apt/sources.list.d/nvidia-container-toolkit.list
with_retry 3 10 apt-get update -y
;;
rhel)
with_retry 3 10 curl -fsSL \
https://nvidia.github.io/libnvidia-container/stable/rpm/nvidia-container-toolkit.repo \
-o /etc/yum.repos.d/nvidia-container-toolkit.repo
chmod 0644 /etc/yum.repos.d/nvidia-container-toolkit.repo
with_retry 3 10 dnf -y makecache
;;
esac
pkg_install nvidia-container-toolkit
nvidia-ctk runtime configure --runtime=docker
# SELinux blocks a container from touching the GPU device nodes unless the
# toolkit's own boolean is set. No-op where SELinux is not enforcing.
if [[ "$DISTRO_FAMILY" == "rhel" ]] && command -v setsebool >/dev/null 2>&1; then
setsebool -P container_use_devices 1 || echo "WARNING: could not set container_use_devices"
fi
systemctl restart docker
}
# One pin, used by both step_gpu_verify and step_arcane_install's #2950 check
# for whether the nvidia runtime actually works on this host.
GPU_SMOKE_IMAGE="nvidia/cuda:12.4.0-base-ubuntu22.04"
step_gpu_verify() {
nvidia-smi -L || return 1
docker run --rm --gpus all "$GPU_SMOKE_IMAGE" nvidia-smi -L
}
# GPU driver installs a new kernel module -- on a genuinely fresh box this
# usually needs a reboot before `nvidia-smi` sees the card. Detect that
# instead of treating it as a hard failure so the operator knows to reboot
# and re-run rather than debug a phantom GPU problem.
step_gpu_verify_or_note_reboot() {
if step_gpu_verify; then
return 0
fi
if ! lsmod | grep -q '^nvidia '; then
echo "nvidia kernel module not loaded yet -- this is expected right after"
echo "a fresh driver install and usually just needs a reboot. Reboot the"
echo "box, then re-run: $0 --config $CONFIG_FILE --force-rerun-from gpu-verify"
return 1
fi
return 1
}
# ---------------------------------------------------------------------------
# Phase 4 — WireGuard tunnel to the VPS
# ---------------------------------------------------------------------------
step_wireguard_install() {
# Ubuntu's `wireguard` is a metapackage; RHEL ships the userspace tools as
# wireguard-tools (the kernel module is in-tree on both).
case "$DISTRO_FAMILY" in
debian) pkg_install wireguard ;;
rhel) pkg_install wireguard-tools ;;
esac
}
step_wireguard_config() {
install -d -m 0700 /etc/wireguard
# wg0.conf gets an explicit `chmod 600` below, so no umask override is
# needed here -- and a bare (unscoped) `umask 077` would be actively
# harmful: umask is a shell-wide setting that persists for the rest of
# this script's process, not just this function. Found live during
# #787's homeserver reinstall (2026-08-09): it silently downgraded every
# file `git clone` wrote in step_clone_repo (Phase 5, runs right after
# this) from the tracked 100644/100755 modes to 0600/0700, breaking any
# bind-mounted repo file a container reads as a non-root user --
# elasticsearch-setup.sh landed at 0600 root:root and the elasticsearch
# container (runs as a non-root uid) got "Permission denied" trying to
# read it. The other two umask uses in this file ((umask 027; ...) for
# postgres-password/bootstrap-admin-password) correctly scope it to a
# subshell instead -- this one didn't, and there's no reason to: this
# function already chmods its one sensitive file explicitly.
# The home side's WireGuard private key AND preshared key were never part
# of the .env-only backup (system config, not a Dockge stack secret) --
# see #518 comment history. If the config didn't supply a private key,
# generate a fresh keypair (and always generate a fresh PSK alongside it,
# since a stale/mismatched PSK is exactly as fatal to the handshake as a
# stale pubkey -- see the incident below). step_wireguard_sync_vps_peer
# pushes both to the VPS's peer config, rather than silently failing to
# bring the tunnel up with keys the other end doesn't have.
#
# #518 incident: an earlier version of this script only generated/synced
# the keypair, not a PSK. The VPS's peer config required one (predating
# this script entirely). Every run silently produced a wg0.conf that
# associated cleanly (`wg show` displayed the interface fine) but never
# completed a handshake -- 0 bytes received, forever, no error anywhere.
# `step_wireguard_verify` below only checked the interface existed, not
# that a handshake had actually happened, so this went undetected for the
# entire rest of that session: real attacker traffic never reached the
# honeypot sensors the whole time, silently. Both gaps are fixed here.
# step_wireguard_sync_vps_peer always re-derives the current pubkey/PSK
# from the wg0.conf written below and pushes them unconditionally -- no
# need to track "was this freshly generated" separately (that tracking
# via .new marker files was itself the source of the SSH-argument bug
# documented on that function).
local priv="${HOME_WG_PRIVATE_KEY:-}"
local psk="${HOME_WG_PRESHARED_KEY:-}"
if [[ -z "$priv" ]]; then
priv="$(wg genkey)"
echo "Generated a fresh WireGuard private key (no HOME_WG_PRIVATE_KEY in config)."
echo "New home public key: $(echo "$priv" | wg pubkey)"
fi
if [[ -z "$psk" ]]; then
psk="$(wg genpsk)"
echo "Generated a fresh WireGuard preshared key (no HOME_WG_PRESHARED_KEY in config)."
fi
cat >/etc/wireguard/wg0.conf <<EOF
[Interface]
Address = ${HOME_WG_ADDRESS}
PrivateKey = ${priv}
ListenPort = 51820
[Peer]
PublicKey = ${VPS_WG_PUBLIC_KEY}
PresharedKey = ${psk}
Endpoint = ${VPS_WG_ENDPOINT}
AllowedIPs = ${VPS_WG_ADDRESS}/32
PersistentKeepalive = 25
EOF
chmod 600 /etc/wireguard/wg0.conf
systemctl enable wg-quick@wg0
systemctl restart wg-quick@wg0
}
# Always push home's CURRENT effective pubkey+PSK (from the just-written
# local wg0.conf), not just "whatever was freshly generated this run" --
# confirmed live (#518), two separate bugs with the old "only sync if a
# .new marker file exists" approach:
# 1. A stale .new marker from an earlier run lingered forever (nothing
# ever cleaned it up), so a run that used already-persisted config
# values still thought a fresh key had been generated and tried to
# resync unnecessarily.
# 2. Worse: SSH does not preserve individual argv separation to the
# remote command the way a local exec does -- per ssh(1), the command
# and its arguments are concatenated into a SINGLE space-joined string
# before being sent, then re-split by the remote shell. An empty-string
# argument (e.g. "no new PSK this run") contributes nothing but a
# space, which collapses away in that re-split -- every argument after
# it silently shifts down one position. `peer_ip="$3"` on the remote
# end became unbound because $psk had been empty, not because
# anything was wrong with $VPS_WG_ADDRESS itself. Always deriving and
# sending real, non-empty values sidesteps the whole class of bug --
# the remote side becomes an unconditional idempotent replace instead
# of a conditional one.
step_wireguard_sync_vps_peer() {
rm -f /etc/wireguard/wg0.pub.new /etc/wireguard/wg0.psk.new
local pubkey psk
pubkey="$(grep '^PrivateKey' /etc/wireguard/wg0.conf | awk '{print $3}' | wg pubkey)"
psk="$(grep '^PresharedKey' /etc/wireguard/wg0.conf | awk '{print $3}')"
[[ -n "$pubkey" && -n "$psk" ]] || { echo "could not read local wg0.conf pubkey/PSK"; return 1; }
# #1059 investigation: this must be HOME_WG_ADDRESS (stripped of its /24),
# not VPS_WG_ADDRESS -- the block being matched is the VPS's own peer
# entry FOR the home side, so its AllowedIPs is home's tunnel IP
# (confirmed live against the real VPS config: AllowedIPs = 10.8.0.2/32,
# not 10.8.0.1/32). Using VPS_WG_ADDRESS here matched zero peer blocks on
# the real config -- a silent no-op with no error, the exact failure
# class this function's own comments already describe from #518 (wrong
# keys, clean `wg show`, 0 bytes received, forever). The match-count
# guard below now also fails loudly instead of silently no-op-ing if this
# regresses again.
local home_ip="${HOME_WG_ADDRESS%%/*}"
ssh -i "$VPS_SSH_KEY" -p "$VPS_SSH_PORT" -o StrictHostKeyChecking=accept-new \
-o ConnectTimeout=10 "${VPS_SSH_USER}@${VPS_SSH_HOST}" bash -s -- "$pubkey" "$psk" "$home_ip" <<'REMOTE'
set -euo pipefail
new_pub="$1"
new_psk="$2"
peer_ip="$3"
conf="/etc/wireguard/wg0.conf"
[[ -f "$conf" ]] || { echo "no $conf on VPS" >&2; exit 1; }
cp -p "$conf" "$conf.bak.$(date +%s)"
# Replace the PublicKey/PresharedKey lines inside the [Peer] block matching
# this home peer's AllowedIPs, not any other peer that might exist.
python3 - "$conf" "$new_pub" "$new_psk" "$peer_ip" <<'PY'
import re, sys
conf, new_pub, new_psk, peer_ip = sys.argv[1:5]
text = open(conf).read()
blocks = re.split(r'(?=\[Peer\])', text)
out = []
matched = 0
# Every pattern here is line-anchored with re.M and uses [ \t] rather than
# \s around the '=', deliberately. \s matches newlines, so the previous
# `PresharedKey\s*=\s*\S+` would, on a key line with an EMPTY value, run
# past the end of its own line and swallow the first token of the next one:
#
# PresharedKey = -> PresharedKey = <psk> = 10.8.0.2/32
# AllowedIPs = 10.8.0.2/32
#
# because \s* crossed the newline and \S+ then matched "AllowedIPs". That is
# not hypothetical -- the live VPS had exactly `PresharedKey = ` with no
# value, and this produced a wg0.conf that wg-quick refused outright:
# Key is not the correct length or format: `<psk>=10.8.0.2/32'
# Configuration parsing error
# It then deleted the interface, so the step failed with the tunnel DOWN
# rather than merely unchanged. Measured on the 2026-09-04 rebuild.
#
# \S* (not \S+) so an empty value is matched and replaced in place instead of
# falling through to the insert branch and producing a duplicate key line.
for b in blocks:
if b.startswith('[Peer]') and f"{peer_ip}/32" in b:
matched += 1
b = re.sub(r'^PublicKey[ \t]*=[ \t]*\S*[ \t]*$',
f'PublicKey = {new_pub}', b, flags=re.M)
if re.search(r'^PresharedKey[ \t]*=', b, re.M):
b = re.sub(r'^PresharedKey[ \t]*=[ \t]*\S*[ \t]*$',
f'PresharedKey = {new_psk}', b, flags=re.M)
else:
b = re.sub(r'^(PublicKey[ \t]*=[ \t]*\S*[ \t]*)$',
rf'\1\nPresharedKey = {new_psk}', b, flags=re.M)
out.append(b)
# Fail loudly on 0 or >1 matches instead of silently no-op-ing (0 matches)
# or updating the wrong/multiple peers (>1) -- exactly the kind of mistake
# a clean `wg show` and no error would otherwise hide until the next real
# handshake attempt fails.
if matched != 1:
print(f"expected exactly 1 peer block with AllowedIPs {peer_ip}/32, found {matched}", file=sys.stderr)
sys.exit(1)
open(conf, 'w').write(''.join(out))
PY
# Validate the rewritten config BEFORE restarting. wg-quick's failure mode
# here is to `ip link delete dev wg0` on a parse error, so a bad edit does
# not leave the tunnel merely unchanged -- it takes it DOWN, on the one host
# whose reachability everything else on the homeserver depends on. `wg-quick
# strip` parses without touching the interface, so a malformed file is caught
# while the tunnel is still up and the backup taken above is restored.
if ! wg-quick strip wg0 >/dev/null 2>&1; then
echo "rewritten $conf does not parse -- restoring the backup, tunnel untouched" >&2
wg-quick strip wg0 2>&1 | tail -3 >&2 || true
cp -p "$(ls -1t "$conf".bak.* | head -1)" "$conf"
exit 1
fi
systemctl restart wg-quick@wg0
# Confirm the interface actually came back; a restart that fails leaves no
# device at all, and the caller should hear about it here rather than three
# steps later when an sshfs mount times out.
ip link show wg0 >/dev/null 2>&1 || { echo "wg0 did not come up after restart" >&2; exit 1; }
REMOTE
}
step_wireguard_verify() {
# Confirmed live (#518): `wg show wg0` succeeding only proves the
# interface exists, not that a handshake ever completed -- a stale/missing
# PSK produces exactly this false-positive (interface up, 0 bytes
# received, forever). Actually check for a completed handshake, with a
# short retry window since one can take a few seconds after a fresh
# restart.
wg show wg0 >/dev/null
local waited=0
while (( waited < 30 )); do
local hs
hs=$(wg show wg0 latest-handshakes 2>/dev/null | awk '{print $2}')
if [[ -n "$hs" && "$hs" != "0" ]]; then
echo "WireGuard handshake confirmed ($(date -d "@$hs" -Iseconds 2>/dev/null || echo "$hs"))."
return 0
fi
sleep 3
waited=$(( waited + 3 ))
done
echo "No WireGuard handshake after ${waited}s -- tunnel interface is up but not" >&2
echo "actually passing traffic. Check the peer's PublicKey/PresharedKey match on" >&2
echo "both ends (this is exactly the #518 incident this check was added for)." >&2
return 1
}
# ---------------------------------------------------------------------------
# Phase 5 — repo checkout (must land at REPO_DIR exactly — other stacks'
# `build:` directives reference this path as an absolute string, see
# docker-compose.yml's #258 header comment)
# ---------------------------------------------------------------------------
step_clone_repo() {
mkdir -p /opt
ln -sfn "$(dirname "$REPO_DIR")" /opt/stacks 2>/dev/null || true
if [[ -d "$REPO_DIR/.git" ]]; then
with_retry 3 10 git -C "$REPO_DIR" fetch origin
git -C "$REPO_DIR" checkout "$GIT_REF"
with_retry 3 10 git -C "$REPO_DIR" pull --ff-only origin "$GIT_REF"
else
# REPO_DIR can already exist and be non-empty even on a fresh box:
# other stacks' sshfs mounts live under it (e.g. apiary/logs/suricata,
# set up by step_sshfs_boot_ordering's fstab entries), and systemd
# creates a mount unit's mountpoint directory at boot regardless of
# whether the mount itself actually succeeds. A plain `git clone`
# refuses to clone into a non-empty directory -- found live during
# #787's homeserver reinstall (2026-08-09), and only surfaced now
# because with_retry actually propagates failure correctly (#1078);
# before that fix this step silently "succeeded" while never cloning
# anything, and everything downstream cascaded off a missing repo.
# `git init` + fetch + checkout works fine into a pre-populated
# directory as long as there's no tracked-path collision (there isn't
# -- logs/ and state/ aren't part of this repo's tree).
mkdir -p "$REPO_DIR"
if [[ ! -d "$REPO_DIR/.git" ]]; then
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" remote add origin "$GIT_REPO_URL"
fi
with_retry 3 10 git -C "$REPO_DIR" fetch origin "$GIT_REF"
git -C "$REPO_DIR" checkout -f -B "$GIT_REF" FETCH_HEAD
fi
}
# ---------------------------------------------------------------------------
# Phase 6 — Arcane (#1504: replaces the old step_dockge_install)
# ---------------------------------------------------------------------------
# #1504: every already-provisioned APIARY homeserver replaced Dockge with
# Arcane (#1185) months before #1502's migration -- deploy.yml and
# docker-compose.arcane.yml both assume Arcane. This step stands Arcane up on
# a genuinely from-scratch host so step_arcane_import_stacks (later in this
# same run) has a running instance at ARCANE_URL to import the honeypot-*
# stacks into. It models docker-compose.arcane.yml's own live-verified
# service definition exactly (same image, same WireGuard-only port binding,
# same env-var names) -- that file, not this step, stays the source of truth
# for the service shape; this step only supplies the .env it reads.
#
# Bootstrap ordering (the part that genuinely needed a live host to verify,
# #1504's own caveat): Arcane's REST API -- the only thing this installer
# talks to -- authenticates by the pre-minted ARCANE_API_TOKEN, NOT by an
# OIDC session, so Arcane is fully usable by the installer the moment it's
# healthy here, before Keycloak exists. OIDC (interactive human login) is a
# separate concern that only has to work once a person visits the UI, which
# is necessarily after honeypot-keycloak has itself been imported-and-started
# by step_arcane_import_stacks. So this step writes a *placeholder*
# ARCANE_OIDC_CLIENT_SECRET good enough to satisfy the compose file's `:?`
# guard and start the container; step_provision_arcane_oidc_secret later
# replaces it with the real per-realm secret Keycloak generates on
# --import-realm (same pattern as apiary-dashboard's own client secret --
# see provision-arcane-oidc-secret.sh) and re-ups Arcane.
#
# Unresolved from-scratch chicken-and-egg (#1504, still needs a live call):
# minting ARCANE_API_TOKEN requires a first human login, and that login is
# OIDC-only (OIDC_AUTO_REDIRECT_TO_PROVIDER=true) once Keycloak is up, so a
# truly-first install is inherently two-pass -- see ARCANE_URL/
# ARCANE_API_TOKEN's own comment near the top of this file.
step_arcane_install() {
mkdir -p /var/dockge/data /var/dockge/stacks
local dir="/var/dockge/stacks/honeypot-arcane"
install -d -m 755 "$dir"
# Arcane's ENCRYPTION_KEY/JWT_SECRET/OIDC_CLIENT_SECRET have no *_FILE
# variant it supports (confirmed against its own env-var reference, see
# docker-compose.arcane.yml's inline comment) -- they live in this stack's
# own .env, generated once here the same "start from zero, don't restore a
# prior value" way step_provision_keycloak_secrets treats Keycloak's own
# secrets. Idempotent: an existing .env is left untouched so a re-run never
# rotates a key out from under an already-encrypted arcane-data volume
# (rotating ENCRYPTION_KEY would make every stored secret undecryptable).
local env_file="$dir/.env"
if [[ ! -f "$env_file" ]]; then
# Derive the two public URLs Arcane's OIDC needs from the same
# KEYCLOAK_PUBLIC_DOMAIN the Keycloak realm's own example.invalid ->
# <domain> substitution uses (arcane.<domain> for redirect URIs,
# auth.<domain> for the issuer) -- matches docker-compose.arcane.yml's
# and honeypot-keycloak/compose.yml's own default hostnames.
local app_url="https://arcane.${KEYCLOAK_PUBLIC_DOMAIN}"
local issuer_url="https://auth.${KEYCLOAK_PUBLIC_DOMAIN}/realms/apiary"
(
umask 077
cat > "$env_file" <<EOF
# honeypot-arcane -- generated by install-homeserver.sh step_arcane_install
# (#1504). ENCRYPTION_KEY/JWT_SECRET are one-time-generated and MUST NOT be
# rotated once arcane-data holds encrypted secrets. OIDC_CLIENT_SECRET below
# is a bootstrap placeholder -- provision-arcane-oidc-secret.sh overwrites it
# with the real Keycloak-generated value once the realm is imported.
HP_BIND=${HP_BIND:-10.8.0.2}
ARCANE_PORT=${ARCANE_PORT:-3552}
ARCANE_URL=${app_url}
ARCANE_ENCRYPTION_KEY=$(openssl rand -base64 32)
ARCANE_JWT_SECRET=$(openssl rand -hex 32)
OIDC_ISSUER_URL=${issuer_url}
ARCANE_OIDC_CLIENT_SECRET=$(openssl rand -hex 32)
TZ=${INSTALL_TIMEZONE}
EOF
)
chmod 600 "$env_file"
echo "generated honeypot-arcane .env (placeholder OIDC secret -- synced later from Keycloak)"
else
echo "honeypot-arcane .env already present -- leaving secrets untouched"
fi
# Same "copy the file, don't symlink it" shape deploy.yml's own
# Synchronize honeypot-arcane step uses -- honeypot-arcane is deliberately
# NOT one of the Arcane-managed Git syncs (syncing the thing that has to
# run before any sync can happen is a bootstrap loop, see
# docs/ARCANE-GIT-SYNC.md), so it's installer-/deploy.yml-managed by a
# plain file copy from the repo checkout.
# #2950: the base compose is deliberately GPU-free, because Arcane's optional
# GPU-monitoring panel needs a *hard* NVIDIA device reservation and Docker
# refuses to start the container at all without a working nvidia runtime
# ("could not select device driver \"nvidia\""). Arcane is the control plane
# that materializes every other stack, so it must be able to start on a host
# whose GPU driver is absent or not yet loaded -- on the 2026-09-03 rebuild
# that single reservation blocked the entire install.
#
# The overlay is merged in only after confirming the runtime actually works,
# rather than trusting ENABLE_GPU_STACK: the driver needs a reboot before its
# kernel module loads, so "GPU requested" and "GPU usable" are different
# facts, and this step can run in between them.
#
# --no-interpolate matters: `config` would otherwise resolve ${...} against
# this stack's .env and bake ENCRYPTION_KEY/JWT_SECRET/the OIDC secret as
# literals into a world-readable compose.yml.
local gpu_overlay="$REPO_DIR/docker-compose.arcane.gpu.yml"
if [[ "$ENABLE_GPU_STACK" == "true" && -f "$gpu_overlay" ]] \
&& docker run --rm --gpus all "$GPU_SMOKE_IMAGE" true >/dev/null 2>&1; then
if docker compose -f "$REPO_DIR/docker-compose.arcane.yml" -f "$gpu_overlay" \
config --no-interpolate > "$dir/compose.yml.tmp" 2>/dev/null; then
mv "$dir/compose.yml.tmp" "$dir/compose.yml"
echo "Arcane: GPU monitoring enabled (nvidia runtime verified)"
else
rm -f "$dir/compose.yml.tmp"
cp "$REPO_DIR/docker-compose.arcane.yml" "$dir/compose.yml"
echo "Arcane: GPU overlay failed to render -- continuing without GPU monitoring" >&2
fi
else
cp "$REPO_DIR/docker-compose.arcane.yml" "$dir/compose.yml"
echo "Arcane: no usable NVIDIA runtime -- GPU monitoring left off (see #2950)"
fi
(cd "$dir" && docker compose -f compose.yml config --quiet \
&& with_retry 3 15 docker compose -f compose.yml up -d --wait)
}
# ---------------------------------------------------------------------------
# Phase 7 — secret restore from the LAN backup host
# ---------------------------------------------------------------------------