-
Notifications
You must be signed in to change notification settings - Fork 18
Expand file tree
/
Copy pathsetup.sh
More file actions
executable file
·3414 lines (3196 loc) · 169 KB
/
Copy pathsetup.sh
File metadata and controls
executable file
·3414 lines (3196 loc) · 169 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
# Written by Paul Clevett
# (C)Copyright Wolf Software Systems Ltd
# https://wolf.uk.com
#
#
# WolfStack Quick Install Script
# Installs WolfStack server management dashboard
# Supported: Ubuntu/Debian, Fedora/RHEL/CentOS, SLES/openSUSE, Arch Linux, IBM Power (ppc64le),
# Unraid (auto-detected — installs the static-binary agent, no package manager needed)
#
# Usage (as root — Proxmox root login):
# curl -sSL https://raw.githubusercontent.com/wolfsoftwaresystemsltd/WolfStack/master/setup.sh | bash
# curl -sSL https://raw.githubusercontent.com/wolfsoftwaresystemsltd/WolfStack/beta/setup.sh | bash -s -- --beta
# bash setup.sh --install-dir /mnt/usb # build & install from external drive
# Usage (sudoer — Ubuntu/Debian):
# curl -sSL https://raw.githubusercontent.com/wolfsoftwaresystemsltd/WolfStack/master/setup.sh | sudo bash
# sudo bash setup.sh
#
set -e
# Helper: read from /dev/tty if available, otherwise return empty (use defaults)
prompt_read() {
if [ -e /dev/tty ] && : < /dev/tty 2>/dev/null; then
read "$1" < /dev/tty 2>/dev/null || eval "$1="
else
eval "$1="
fi
}
# ─── Parse arguments ─────────────────────────────────────────────────────────
BRANCH="master"
CUSTOM_INSTALL_DIR=""
ASSUME_YES=false
AGENT_MODE=false
SKIP_PBS_BUILD=false
FORCE_PBS_BUILD=false
MINIMAL=false
while [ $# -gt 0 ]; do
case "$1" in
--beta) BRANCH="beta" ;;
--yes|-y|--assume-yes) ASSUME_YES=true ;;
--agent) AGENT_MODE=true ;;
--skip-pbs-build|--no-pbs-build) SKIP_PBS_BUILD=true ;;
--build-pbs|--pbs-from-source) FORCE_PBS_BUILD=true ;;
--minimal|--lite) MINIMAL=true ;;
--install-dir|--install)
if [ -n "$2" ]; then
shift
CUSTOM_INSTALL_DIR="$1"
else
echo "✗ --install-dir requires a path argument"
exit 1
fi
;;
esac
shift
done
# ─── Optional-component selection (--minimal) ───────────────────────────────
# Which heavy optional runtimes to install. Default = all, so a plain install
# is byte-for-byte the historic behaviour and nothing changes for existing
# users. `--minimal` lets the operator pick, so a lightweight box (a Raspberry
# Pi print server, an edge node) gets a working monitoring UI without a
# hypervisor and backup stack it will never use. The WolfStack binary detects
# missing runtimes gracefully — skipped ones simply show as "not installed".
WANT_DOCKER=true
WANT_LXC=true
WANT_KVM=true
WANT_PBS=true
wolfstack_pick_components() {
# Interactive picker for --minimal. Reads from /dev/tty so it works through
# `curl … | bash`, where stdin is the piped script rather than the keyboard.
local can_prompt=false
if [ -t 0 ] || { [ -e /dev/tty ] && : < /dev/tty 2>/dev/null; }; then can_prompt=true; fi
if [ "$ASSUME_YES" = true ] || [ "$can_prompt" != true ]; then
# No terminal to ask (unattended / --yes) — honour "minimal" literally:
# core only, nothing heavy. Everything can be added later.
WANT_DOCKER=false; WANT_LXC=false; WANT_KVM=false; WANT_PBS=false
echo " --minimal (unattended): installing core only — no Docker, LXC, KVM/QEMU or PBS client."
return
fi
echo ""
echo "Minimal install — choose which optional runtimes to add (Enter = No):"
_ws_pick() { # $1 = question ; $2 = variable name to set true/false
local reply=""
printf " %s [y/N] " "$1" > /dev/tty
read -r reply < /dev/tty 2>/dev/null || reply=""
case "$reply" in y|Y|yes|YES) eval "$2=true" ;; *) eval "$2=false" ;; esac
}
_ws_pick "Docker (container management)?" WANT_DOCKER
_ws_pick "LXC system containers?" WANT_LXC
_ws_pick "KVM / QEMU virtual machines?" WANT_KVM
_ws_pick "Proxmox Backup Client (PBS backups)?" WANT_PBS
echo ""
}
# Existing install = upgrade. The /api/upgrade endpoint in older WolfStack
# binaries spawns this script via `curl|bash` without --yes, with stdin nulled
# out, so any interactive prompt would block the upgrade forever. If
# /etc/wolfstack exists this is an upgrade, not a fresh install — force
# unattended mode so the in-app "Upgrade" button actually completes.
if [ -d /etc/wolfstack ]; then
ASSUME_YES=true
fi
# apt/dpkg must NEVER stop at an interactive prompt. The in-app Upgrade runs
# this via `curl|bash` with stdin nulled, so a debconf config-file question or
# — the common one on Debian 12 / Ubuntu 22.04+ — needrestart's "which services
# should be restarted?" dialog would hang the whole upgrade forever while
# holding the dpkg lock (klas 2026-07-22). `-y` alone does not cover those, so
# force fully unattended mode for every apt call in this script.
export DEBIAN_FRONTEND=noninteractive
export NEEDRESTART_MODE=a
export NEEDRESTART_SUSPEND=1
# Allow git to operate on repos owned by other users (setup.sh runs as root
# but repos may have been cloned by a regular user)
export GIT_CONFIG_COUNT=1
export GIT_CONFIG_KEY_0=safe.directory
export GIT_CONFIG_VALUE_0="*"
# ─── Architecture detection for prebuilt binaries ──────────────────────────
HOST_ARCH=$(uname -m)
case "$HOST_ARCH" in
x86_64) BINARY_ARCH="x86_64" ;;
aarch64) BINARY_ARCH="aarch64" ;;
# 32-bit ARM userland: a Pi on a 32-bit OS reports armv7l, and armv8l is
# the same thing on 64-bit silicon running a 32-bit userland — both run the
# armv7 build. Published since 2026-07-28 so these boards get binaries
# instead of being told to retry (upgrades never compile locally).
armv7l|armv8l) BINARY_ARCH="armv7" ;;
# No riscv64 here on purpose: WolfNet publishes one, but WolfStack cannot
# be built as a static musl binary for riscv64 (the cross image has no
# riscv64-linux-musl-gcc for its C dependencies), so there is nothing to
# download and claiming support would just fail later with a 404.
*) BINARY_ARCH="" ;; # unsupported — fresh install builds from source
esac
# Download a prebuilt binary from GitHub Releases.
# Usage: download_prebuilt <repo> <binary_name> <dest_path>
# Returns 0 on success, 1 on failure (caller should fall back to source build)
download_prebuilt() {
local repo="$1" binary="$2" dest="$3"
if [ -z "$BINARY_ARCH" ]; then
return 1
fi
local url="https://github.com/${repo}/releases/latest/download/${binary}-${BINARY_ARCH}"
echo " Downloading prebuilt ${binary} for ${BINARY_ARCH}..."
local tmpfile="${dest}.download"
# --max-time is a poor fit for an 80 MB binary: a slow-but-healthy link can
# exceed it and be written off as "unavailable", which used to drop the
# node into a 20-50 minute source build (JJ, 2026-07-27). Abort on a
# genuinely STALLED transfer instead — under 10 kB/s for 60s — and give a
# working download plenty of time to finish.
if curl -fSL --connect-timeout 15 --max-time 1800 \
--speed-limit 10000 --speed-time 60 --retry 3 -o "$tmpfile" "$url" 2>&1; then
# This binary is about to be executed as root, so check it against the
# release's SHA256SUMS. WolfStack publishes one (see
# .github/workflows/release.yml); a release that doesn't still
# installs, with a warning, because making it fatal would break
# upgrades for any component repo that has yet to publish sums. A
# checksum that IS published and does NOT match is always fatal.
if command -v sha256sum >/dev/null 2>&1; then
local sums="${dest}.SHA256SUMS"
if curl -fsSL --connect-timeout 15 --max-time 60 -o "$sums" \
"https://github.com/${repo}/releases/latest/download/SHA256SUMS" 2>/dev/null; then
local want got
want=$(awk -v f="${binary}-${BINARY_ARCH}" '$2 == f || $2 == "*" f {print $1}' "$sums" | head -1)
got=$(sha256sum "$tmpfile" | awk '{print $1}')
rm -f "$sums"
if [ -n "$want" ] && [ "$want" != "$got" ]; then
echo " ✗ Checksum MISMATCH for ${binary}-${BINARY_ARCH} — refusing to install"
echo " expected: $want"
echo " got: $got"
rm -f "$tmpfile"
return 1
elif [ -n "$want" ]; then
echo " ✓ Checksum verified"
else
echo " ⚠ ${binary}-${BINARY_ARCH} is not listed in SHA256SUMS — installing unverified"
fi
else
rm -f "$sums" 2>/dev/null || true
echo " ⚠ No SHA256SUMS published for this release — installing unverified"
fi
else
echo " ⚠ sha256sum not available — installing unverified"
fi
mv "$tmpfile" "$dest"
chmod +x "$dest"
echo " ✓ Downloaded prebuilt ${binary} (${BINARY_ARCH})"
return 0
else
echo " ⚠ Prebuilt binary not available — will build from source"
rm -f "$tmpfile"
return 1
fi
}
# ─── Custom install directory (for low-disk devices like Raspberry Pi) ───────
if [ -n "$CUSTOM_INSTALL_DIR" ]; then
# If given a block device, mount it
if [ -b "$CUSTOM_INSTALL_DIR" ]; then
MOUNT_DEV="$CUSTOM_INSTALL_DIR"
CUSTOM_INSTALL_DIR="/mnt/wolfstack-build"
mkdir -p "$CUSTOM_INSTALL_DIR"
if ! mountpoint -q "$CUSTOM_INSTALL_DIR" 2>/dev/null; then
echo "Mounting $MOUNT_DEV at $CUSTOM_INSTALL_DIR..."
mount "$MOUNT_DEV" "$CUSTOM_INSTALL_DIR"
fi
fi
mkdir -p "$CUSTOM_INSTALL_DIR"
# Redirect EVERYTHING to external drive: Rust toolchain, build cache, temp files
export RUSTUP_HOME="$CUSTOM_INSTALL_DIR/.rustup"
export CARGO_HOME="$CUSTOM_INSTALL_DIR/.cargo"
export TMPDIR="$CUSTOM_INSTALL_DIR/tmp"
export PATH="$CARGO_HOME/bin:$PATH"
mkdir -p "$TMPDIR"
fi
echo ""
echo " 🐺 WolfStack Installer"
echo " ─────────────────────────────────────"
if [ "$AGENT_MODE" = true ]; then
echo " Mode: Agent (cluster API only — no management UI)"
echo " Manage this node from your master server's UI after install."
else
echo " Server Management Platform"
fi
if [ "$BRANCH" != "master" ]; then
echo " Branch: $BRANCH"
fi
if [ -n "$CUSTOM_INSTALL_DIR" ]; then
echo " Install dir: $CUSTOM_INSTALL_DIR"
fi
echo ""
# ─── Must run as root ────────────────────────────────────────────────────────
if [ "$(id -u)" -ne 0 ]; then
echo "✗ This script must be run as root."
if command -v sudo >/dev/null 2>&1; then
echo " Usage: sudo bash setup.sh"
echo " or: curl -sSL <url> | sudo bash"
else
# Proxmox / minimal installs without sudo — operator is expected
# to log in as root and run the script directly.
echo " sudo is not installed on this system. Log in as root and run:"
echo " bash setup.sh"
echo " curl -sSL <url> | bash"
fi
exit 1
fi
# Detect the real user (for Rust install) when running under sudo
REAL_USER="${SUDO_USER:-root}"
REAL_HOME=$(eval echo "~$REAL_USER")
# ─── Unraid (Slackware, no package manager, no systemd, RAM-based OS) ────────
# Unraid boots from USB into RAM: /etc, /usr and /usr/local/bin are recreated
# on every boot, and there is no apt/dnf/etc. and no systemd — so the normal
# install path below cannot run (it dies at "Could not detect package manager").
# Instead we install the prebuilt static-musl binary onto the array
# (/mnt/user/appdata, which persists), symlink the config dir, and wire startup
# into /boot/config/go (Unraid's boot script). The node runs in --agent mode and
# is managed from a master node; agent mode serves an inline page and needs no
# on-disk web/ assets, so the single binary is self-sufficient. This must run
# BEFORE the package-manager check. See docs: "Installing WolfStack Agent on
# Unraid".
if [ -f /etc/unraid-version ]; then
UNRAID_VER=$(tr -d '"' < /etc/unraid-version 2>/dev/null | sed -n 's/^version=//p')
echo "✓ Detected Unraid ${UNRAID_VER:-(unknown version)} — using the static-binary agent install"
echo ""
# Unraid is always a managed AGENT node: there's no package manager to build
# a full UI host, and full mode needs on-disk web/ assets we don't ship.
AGENT_MODE=true
if [ -z "$BINARY_ARCH" ]; then
echo "✗ Unsupported CPU architecture '$HOST_ARCH' for Unraid."
echo " WolfStack ships prebuilt static binaries for x86_64 and aarch64 only,"
echo " and Unraid has no toolchain to build from source. Cannot continue."
exit 1
fi
# Persistent storage MUST be the array — /usr/local/bin and /tmp are RAM and
# vanish on reboot. Require /mnt/user (the array) to be started.
WS_APPDATA="/mnt/user/appdata/wolfstack"
if [ ! -d /mnt/user ]; then
echo "✗ /mnt/user not found — the Unraid array doesn't appear to be started."
echo " Start the array (so /mnt/user/appdata is available), then re-run this script."
exit 1
fi
mkdir -p "$WS_APPDATA/etc"
# Stop any agent we previously started (upgrade / re-run) so the new binary
# takes over the port cleanly.
#
# Match on "wolfstack --agent" only — never an anchored path. Historic
# launches used the RELATIVE `./wolfstack --agent` (cwd appdata); the
# current supervisor launches `/usr/local/bin/wolfstack --agent`. An
# absolute-path pattern missed the relative form, so updates never
# stopped the running agent and the node silently stayed on the old
# version (klasSponsor, Unraid, 2026-06-22). The unanchored substring
# matches every historic and current form; on an Unraid agent node
# it's the only such process.
# Download the static musl binary BEFORE stopping the running agent.
# download_prebuilt writes to a temp file and mv's into place, and mv over
# a running binary's path is safe (the old process keeps its inode) — so
# the old agent keeps serving until the new binary is provably on disk.
# The previous order (kill first, download second) left the node DARK
# whenever the download failed or the GUI-spawned environment couldn't
# reach github.com: nothing was left running and nothing new was started
# (PineappleGod, Unraid, 2026-07-03 — GUI update killed the node while the
# same update over ssh worked).
if ! download_prebuilt "wolfsoftwaresystemsltd/WolfStack" "wolfstack" "$WS_APPDATA/wolfstack"; then
echo "✗ Could not download the prebuilt WolfStack binary for $BINARY_ARCH."
echo " Check this server's internet access to github.com and re-run."
echo " The running agent (if any) has NOT been touched."
exit 1
fi
if pgrep -f "wolfstack --agent" >/dev/null 2>&1; then
echo " Stopping running WolfStack agent for upgrade..."
pkill -f "wolfstack --agent" 2>/dev/null || true
sleep 2
fi
# /etc is RAM-fresh each boot, so /etc/wolfstack must be a symlink onto the
# array. -n replaces an existing symlink instead of descending into it.
ln -sfn "$WS_APPDATA/etc" /etc/wolfstack
# The binary on PATH is a real RAM-side COPY, not a symlink into
# /mnt/user: appdata sits on Unraid's shfs FUSE mount, and executing
# (demand-paging) a binary from a stalled shfs can freeze any thread in
# uninterruptible D-state mid-run. The persistent copy stays on the
# array; the RAM copy is what runs (klas 2026-07-16: supervised agent
# alive with zero listeners while /mnt/user was under stress).
if cp -f "$WS_APPDATA/wolfstack" /usr/local/bin/wolfstack.next 2>/dev/null; then
chmod +x /usr/local/bin/wolfstack.next
mv -f /usr/local/bin/wolfstack.next /usr/local/bin/wolfstack
else
rm -f /usr/local/bin/wolfstack.next 2>/dev/null || true
fi
# The agent supervisor — ONE source of truth used by both this installer
# run and every boot (the go block copies it to RAM and runs it there;
# scripts are never executed from FUSE either). @WS_APPDATA@ is
# substituted below.
cat > "$WS_APPDATA/agent-supervisor.sh" <<'SUPERVISOR_EOF'
#!/bin/bash
# WolfStack Unraid agent supervisor — written by setup.sh; do not edit
# (installer re-runs overwrite it). Executed from a RAM copy at
# /usr/local/bin/wolfstack-agent-supervisor, never from /mnt/user.
#
# Everything runtime-critical lives OFF /mnt/user: appdata sits on
# Unraid's shfs FUSE mount, and a stalled shfs (mover, backup plugins,
# getxattr storms) freezes processes in uninterruptible D-state on a
# single read/write/page-fault. klas 2026-07-16: the supervised agent sat
# alive with ZERO listeners while a foreground run from /root worked —
# the launcher's FUSE-side cwd/log/binary were the difference. So: the
# binary runs from RAM-backed /usr/local/bin, the log is RAM-backed
# /var/log (size-capped — tmpfs eats RAM), cwd is /, the lock is in
# /var/run, and the only remaining FUSE touches (binary refresh, port
# lookup) are wrapped in `timeout` — best-effort, since a true D-state
# stall is unkillable, but those touches happen once per restart instead
# of once per log line. Config stays on the array (/etc/wolfstack ->
# appdata/etc symlink) — that's what persists across RAM-fresh boots.
WS_APPDATA="@WS_APPDATA@"
LOG="/var/log/wolfstack-agent.log"
RAM_BIN="/usr/local/bin/wolfstack"
# Single supervisor per host. RAM-side lock: taking it can never stall on
# FUSE, and RAM-fresh boots start clean. The installer fuser-kills any
# pre-RAM-era supervisor holding the OLD appdata lock before starting us.
#
# All three bail-outs below LOG before exiting. They used to be bare
# `|| exit 0`, and callers start us with `>/dev/null 2>&1`, so bash's own
# stderr message went to the bit bucket and a supervisor that died here
# left no evidence anywhere on the box — the operator got "agent FAILED to
# start" printed over a log whose newest line was from the previous day
# (klas, 2026-07-31). $LOG is RAM-backed and is exactly what the
# installer's diagnostic reads back, so a line here is the one thing that
# turns a silent death into a self-diagnosing one.
if ! exec 9>"/var/run/wolfstack-agent-supervisor.lock"; then
echo "$(date) supervisor exiting: cannot open /var/run/wolfstack-agent-supervisor.lock — is /var/run writable and does the root fs have space?" >> "$LOG"
exit 0
fi
if ! flock -n 9; then
_holder=$(fuser /var/run/wolfstack-agent-supervisor.lock 2>/dev/null | tr -s ' ')
echo "$(date) supervisor exiting: another supervisor already holds the lock (PID(s):${_holder:- unknown}) — that one owns the agent" >> "$LOG"
exit 0
fi
trap '' HUP # survive the installer's curl|bash session ending
if ! cd /; then
echo "$(date) supervisor exiting: cd / failed" >> "$LOG"
exit 0
fi
api_port() {
# The operator may have moved the API port (ports.json). timeout:
# ports.json is on FUSE — never let a wedged share freeze the watchdog.
p=$(timeout 5 tr -d ' \t\n' 2>/dev/null < "$WS_APPDATA/etc/ports.json" \
| grep -o '"api":[0-9]*' | grep -o '[0-9]*$' | head -1)
case "$p" in ("") echo 8553 ;; (*) echo "$p" ;; esac
}
port_open() {
# TCP-level liveness probe via curl (guaranteed present — this very
# install arrived over curl). NOT bash /dev/tcp: that needs a bash
# built with --enable-net-redirections, which we have not verified on
# every Unraid release, and a bash without it would make every check
# read "closed" and kill healthy agents forever. curl exit 7 is the
# one code that means "nothing accepted the connection" — any other
# outcome (HTTP response, TLS error against a non-TLS probe, protocol
# noise) proves a listener accepted us. Fail-safe: if curl is somehow
# missing, report OPEN so the watchdog never kills on tooling doubt.
command -v curl >/dev/null 2>&1 || return 0
curl -sk -o /dev/null --max-time 5 "https://127.0.0.1:$1/" 2>/dev/null
[ $? -ne 7 ]
}
# Refresh the RAM copy from the array (installer download or self-update
# wrote a newer binary). Temp+mv keeps the running inode intact.
#
# This runs as a background child that WE time out, not `timeout cp`. A cp
# wedged in D-state on a stalled shfs mount ignores every signal, so
# `timeout` cannot clear it and the supervisor blocked here forever — as
# the very first thing in its loop, before it had written a single log
# line. klas 2026-07-31: two S-state processes holding the lock, zero log
# entries for 36 hours, no agent, and an installer that could only report
# "FAILED" with yesterday's log. Abandoning the child leaks a D-state cp
# until the share recovers, but the node RUNS, which is the whole point:
# the array must never get a vote on whether this agent starts.
stage_binary() {
( cp -f "$WS_APPDATA/wolfstack" "$RAM_BIN.next" 2>/dev/null \
&& chmod +x "$RAM_BIN.next" 2>/dev/null \
&& mv -f "$RAM_BIN.next" "$RAM_BIN" 2>/dev/null ) &
_cp=$!
_w=0
while [ "$_w" -lt 60 ] && kill -0 "$_cp" 2>/dev/null; do
sleep 1
_w=$((_w + 1))
done
if kill -0 "$_cp" 2>/dev/null; then
kill -9 "$_cp" 2>/dev/null
echo "$(date) staging copy from $WS_APPDATA stuck after ${_w}s — abandoning it (stalled array?) and using the existing RAM binary" >> "$LOG"
rm -f "$RAM_BIN.next" 2>/dev/null
return 1
fi
wait "$_cp" 2>/dev/null
return 0
}
while true; do
# Heartbeat BEFORE touching the array. Without this the loop's first act
# was an unlogged FUSE read, so "stalled on the array" and "never even
# started" were indistinguishable — both showed up as an empty log.
echo "$(date) supervisor: staging agent binary from $WS_APPDATA" >> "$LOG"
if [ -x "$RAM_BIN" ]; then
# We already have something runnable. Staging is an optimisation, so
# a failure here is not fatal — run what we have.
stage_binary || true
elif ! stage_binary; then
echo "$(date) no runnable agent binary at $RAM_BIN and the array copy did not complete — retrying in 30s" >> "$LOG"
sleep 30
continue
fi
if [ ! -x "$RAM_BIN" ]; then
echo "$(date) no runnable agent binary at $RAM_BIN yet — retrying in 30s" >> "$LOG"
sleep 30
continue
fi
# Cap the RAM-backed log at ~5 MB, keeping one rotation.
if [ "$(stat -c %s "$LOG" 2>/dev/null || echo 0)" -gt 5242880 ]; then
mv -f "$LOG" "$LOG.1" 2>/dev/null || : > "$LOG"
fi
_s=$(date +%s)
# 9>&- : the agent must NOT inherit the supervisor's lock fd — an
# agent outliving a killed supervisor would block the next one.
"$RAM_BIN" --agent </dev/null >> "$LOG" 2>&1 9>&- &
AGENT=$!
# Listener watchdog. Exit codes catch crashes; this catches the HANG
# class (process alive, zero listeners — klas 2026-07-16): if the API
# port stays closed for 5 consecutive minute-checks while the process
# lives, kill -9 and restart. The probe is port_open() above (curl,
# "closed" = connection refused only). Normal startup binds well
# inside the first minute; 5 minutes tolerates a slow array without
# flapping a healthy agent. Port re-read per check so an operator
# port move is honoured without restarting the supervisor.
fails=0
while kill -0 "$AGENT" 2>/dev/null; do
sleep 60
kill -0 "$AGENT" 2>/dev/null || break
# In-flight log cap: the pre-launch rotation only runs between
# restarts, and a HEALTHY long-lived agent never restarts — its
# log would grow unbounded in tmpfs (= RAM). The agent's fd is
# O_APPEND (>>), so truncating in place is safe: the next write
# lands at the new end. Keep the last 256K as .1 for context.
if [ "$(stat -c %s "$LOG" 2>/dev/null || echo 0)" -gt 5242880 ]; then
tail -c 262144 "$LOG" > "$LOG.1" 2>/dev/null
: > "$LOG"
echo "$(date) (wolfstack-agent.log truncated at 5 MB cap — older tail in $LOG.1)" >> "$LOG"
fi
if port_open "$(api_port)"; then
fails=0
else
fails=$((fails + 1))
fi
if [ "$fails" -ge 5 ]; then
echo "$(date) wolfstack agent PID $AGENT alive but API port closed for $fails consecutive checks — killing for restart (hang recovery)" >> "$LOG"
kill -9 "$AGENT" 2>/dev/null
break
fi
done
wait "$AGENT"
_ec=$?; _ran=$(( $(date +%s) - _s ))
echo "$(date) wolfstack agent exited (code $_ec) after ${_ran}s — restarting" >> "$LOG"
if [ "$_ran" -lt 10 ]; then sleep 30; else sleep 3; fi
done
SUPERVISOR_EOF
sed -i "s|@WS_APPDATA@|$WS_APPDATA|g" "$WS_APPDATA/agent-supervisor.sh"
chmod +x "$WS_APPDATA/agent-supervisor.sh"
# Wire startup into /boot/config/go (persists on the USB). Append an
# idempotent, marker-delimited block — never clobber the rest of go, which
# carries emhttp (Unraid's own UI) and any user customisations.
GO_FILE="/boot/config/go"
GO_START="# >>> WolfStack agent (managed by setup.sh) >>>"
GO_END="# <<< WolfStack agent (managed by setup.sh) <<<"
if [ ! -d /boot/config ]; then
echo " ⚠ /boot/config not found — cannot persist startup across reboots."
echo " The agent will run now but won't auto-start after a reboot."
else
if [ ! -f "$GO_FILE" ]; then
# Very unusual on a real Unraid, but be safe: a fresh go still needs
# the shebang and emhttp so the Unraid UI starts.
printf '%s\n%s\n%s\n' '#!/bin/bash' '# Start the Management Utilities' '/usr/local/sbin/emhttp &' > "$GO_FILE"
chmod +x "$GO_FILE" 2>/dev/null || true
fi
# Strip any previous WolfStack block (exact-line match, no regex
# escaping), then append a fresh one.
WS_GO_APPEND=true
if grep -qF "$GO_START" "$GO_FILE"; then
if awk -v s="$GO_START" -v e="$GO_END" \
'$0==s{skip=1} skip&&$0==e{skip=0;next} !skip{print}' \
"$GO_FILE" > "$GO_FILE.tmp"; then
mv "$GO_FILE.tmp" "$GO_FILE"
else
rm -f "$GO_FILE.tmp"
echo " ⚠ Could not rewrite $GO_FILE cleanly — leaving the existing block in place."
WS_GO_APPEND=false
fi
fi
if [ "$WS_GO_APPEND" = true ]; then
# Drop trailing blank lines first so repeated re-runs don't slowly
# accumulate them above the block; then append with one separator.
if awk 'NF{last=NR} {line[NR]=$0} END{for(i=1;i<=last;i++) print line[i]}' \
"$GO_FILE" > "$GO_FILE.tmp"; then
mv "$GO_FILE.tmp" "$GO_FILE"
else
rm -f "$GO_FILE.tmp"
fi
{
printf '\n%s\n' "$GO_START"
# /boot/config/go runs at boot BEFORE the Unraid array is
# started, so /mnt/user/appdata (where the binary + config
# live) does NOT exist yet. Starting the agent directly here
# fails the `cd` and the node never comes back after a reboot —
# this is the "node did not survive an Unraid update" bug.
# Instead wait (in a backgrounded subshell, so `go` never
# blocks boot / emhttp) for the binary to appear once the array
# mounts, then symlink + launch. 180×5s = up to 15 min.
printf '%s\n' "("
printf '%s\n' " for _i in \$(seq 1 180); do [ -x \"$WS_APPDATA/wolfstack\" ] && break; sleep 5; done"
printf '%s\n' " [ -x \"$WS_APPDATA/wolfstack\" ] || exit 0"
printf '%s\n' " ln -sfn \"$WS_APPDATA/etc\" /etc/wolfstack"
# Persisted static tools (proxmox-backup-client, pxar, smartctl —
# fetched by the agent's unraid_tools bootstrapper) re-linked into
# RAM-backed /usr/local/bin so they're on PATH from the first
# moment; the agent re-checks at startup as belt-and-braces.
printf '%s\n' " for t in \"$WS_APPDATA/tools/\"*; do [ -x \"\$t\" ] && ln -sf \"\$t\" \"/usr/local/bin/\$(basename \"\$t\")\"; done || true"
# Supervise the agent via the shared supervisor script —
# copied to RAM and executed THERE (never run scripts or
# binaries from FUSE; see agent-supervisor.sh header). The
# supervisor itself creates the RAM binary copy, logs to
# /var/log, watchdogs the listener, and restarts on
# crash/hang (klas 2026-07-15 + 2026-07-16).
printf '%s\n' " cp -f \"$WS_APPDATA/agent-supervisor.sh\" /usr/local/bin/wolfstack-agent-supervisor 2>/dev/null || exit 0"
printf '%s\n' " chmod +x /usr/local/bin/wolfstack-agent-supervisor"
printf '%s\n' " /usr/local/bin/wolfstack-agent-supervisor >/dev/null 2>&1 &"
printf '%s\n' ") &"
printf '%s\n' "$GO_END"
} >> "$GO_FILE"
echo " ✓ Startup wired into $GO_FILE (survives reboots)"
fi
fi
# Start the agent now (no reboot needed), SUPERVISED. Unraid has no
# systemd, so without a supervisor any later crash/OOM-kill leaves the
# node dead until the installer is re-run (klas, 2026-07-15). The loop
# restarts the agent and logs each exit code+runtime (the crash
# diagnostic). flock ensures exactly one supervisor even across a
# re-run or a boot-started supervisor.
echo " Starting WolfStack agent (supervised)..."
# Retire any PRE-RAM-ERA supervisor first: older installs ran the
# supervisor loop with its lock on appdata and launched agents FROM
# /mnt/user (the FUSE-freeze bug, klas 2026-07-16). fuser -k kills the
# old lock's holders — the old supervisor shell AND any old agent that
# inherited its lock fd — so it can't respawn a FUSE-side agent under
# our new one. timeout: the lock file is on FUSE and fuser must stat it —
# best-effort guard against a wedged share.
#
# We ALSO retire any current-era supervisor holding the RAM-side lock.
# Previously only the appdata lock was cleared, so a wedged new-style
# supervisor kept /var/run/wolfstack-agent-supervisor.lock forever: the
# installer's fresh supervisor lost the flock, exited 0 in silence, no
# agent ever started, and re-running this installer could never recover
# the node (klas, 2026-07-31). Killing a HEALTHY incumbent is harmless —
# we start a replacement below and its loop refreshes the RAM binary the
# same way. Agents don't inherit the lock fd (9>&- at exec), so they
# survive this and are cleared by the pkill that follows.
if command -v fuser >/dev/null 2>&1; then
timeout 15 fuser -k "$WS_APPDATA/agent-supervisor.lock" >/dev/null 2>&1 || true
timeout 15 fuser -k /var/run/wolfstack-agent-supervisor.lock >/dev/null 2>&1 || true
sleep 1
else
# No fuser on this build: we can still kill old AGENTS below, but
# an old supervisor loop may survive and respawn a FUSE-side agent
# that fights the new one for the port. Say so instead of hiding it.
echo " ⚠ 'fuser' not found — if the node flaps after this update, reboot once"
echo " to clear any pre-update supervisor loop (RAM-fresh boot starts clean)."
fi
pkill -f "wolfstack --agent" 2>/dev/null || true
sleep 1
# Run the shared supervisor from RAM. If a NEW-style supervisor is
# already alive (holding the /var/run lock), this one exits at flock
# and the incumbent relaunches the freshly-installed binary — its loop
# refreshes the RAM copy on every restart.
if ! cp -f "$WS_APPDATA/agent-supervisor.sh" /usr/local/bin/wolfstack-agent-supervisor \
|| ! chmod +x /usr/local/bin/wolfstack-agent-supervisor; then
echo " ✗ Could not stage the agent supervisor to /usr/local/bin (RAM)."
echo " Check free space in the root filesystem and re-run this installer."
exit 1
fi
nohup /usr/local/bin/wolfstack-agent-supervisor >/dev/null 2>&1 &
# Remember which supervisor is OURS. The diagnostic below reports lock
# holders, and we have just created one — without this it accused the
# supervisor it had started moments earlier of being a wedged incumbent
# and told the operator to reboot (klas, 2026-07-31).
WS_SUP_PID=$!
# The whole point of an update is a RUNNING agent — verify it, don't
# assume it (PineappleGod, Unraid, 2026-07-03).
#
# Wait as long as the supervisor is actually allowed to take. Its FIRST
# action is `timeout 60 cp` of the ~86 MB binary off /mnt/user, and that
# 60s budget exists precisely because the copy is slow when the array is
# spun down, mover is running, or a parity check is in flight. This check
# used to give it 3s + 5s, so any node where that copy took longer than
# eight seconds was told "agent FAILED to start" while it was quietly
# coming up fine moments later — and because we aborted that early, the
# diagnostic below had nothing from this run to show and printed
# YESTERDAY's log lines instead (klas, 2026-07-31, whose whole diagnostic
# was dated a day before the run that printed it).
#
# Poll once a second instead of sleeping blind, so a healthy node still
# finishes in about a second, and narrate the staging copy so a slow array
# doesn't look like a hang.
WS_WAIT=0
WS_WAIT_MAX=90 # 60s copy budget + agent startup + a little slack
WS_SAID_STAGING=false
while [ "$WS_WAIT" -lt "$WS_WAIT_MAX" ]; do
pgrep -f "wolfstack --agent" >/dev/null 2>&1 && break
# .next only exists while the supervisor's staging copy is in flight.
if [ -e /usr/local/bin/wolfstack.next ] && [ "$WS_SAID_STAGING" = false ]; then
echo " … staging the agent binary from the array (slow if the array is spun down)"
WS_SAID_STAGING=true
fi
sleep 1
WS_WAIT=$((WS_WAIT + 1))
if [ "$WS_WAIT" = 10 ]; then
echo " ⚠ Agent not running yet — waiting up to ${WS_WAIT_MAX}s for the supervisor..."
fi
done
# Self-recovery before we even think about reporting failure. The old
# behaviour was to print a diagnostic and hand the operator a list of
# kill commands to run by hand — but everything in that list is
# something this script can do itself, and on the one node where it
# fired the advice was wrong anyway (it told klas to reboot a pair of
# plainly killable S-state processes, 2026-07-31). Kill every lock
# holder that ISN'T in D state, clear stray agents, start a fresh
# supervisor, and wait again. Only a D-state holder is beyond us.
if ! pgrep -f "wolfstack --agent" >/dev/null 2>&1; then
WS_RECOVER_KILL=""
WS_STUCK_D=false
if command -v fuser >/dev/null 2>&1; then
for _p in $(timeout 5 fuser /var/run/wolfstack-agent-supervisor.lock 2>/dev/null | tr -s ' ' || true); do
_st=$(grep '^State:' "/proc/$_p/status" 2>/dev/null | tr -s ' \t' ' ' || true)
case "$_st" in
(*" D"*) WS_STUCK_D=true ;;
(*) WS_RECOVER_KILL="$WS_RECOVER_KILL $_p" ;;
esac
done
fi
if [ "$WS_STUCK_D" = false ] && [ -n "$WS_RECOVER_KILL" ]; then
echo " ⚠ Agent still not up — clearing the stalled supervisor and retrying..."
# shellcheck disable=SC2086
kill -9 $WS_RECOVER_KILL 2>/dev/null || true
pkill -9 -f "wolfstack --agent" 2>/dev/null || true
sleep 2
nohup /usr/local/bin/wolfstack-agent-supervisor >/dev/null 2>&1 &
WS_SUP_PID=$!
WS_WAIT=0
while [ "$WS_WAIT" -lt 60 ]; do
pgrep -f "wolfstack --agent" >/dev/null 2>&1 && break
sleep 1
WS_WAIT=$((WS_WAIT + 1))
done
if pgrep -f "wolfstack --agent" >/dev/null 2>&1; then
echo " ✓ Recovered — agent came up after clearing the stalled supervisor."
fi
fi
fi
if ! pgrep -f "wolfstack --agent" >/dev/null 2>&1; then
echo ""
echo " ✗ WolfStack agent FAILED to start."
# A blind `tail -20` here used to print 20 lines of auth kernel-block
# spam from the PREVIOUS run and hide the actual cause — the reported
# symptom was stale WARNs from the day before, telling the operator
# nothing (klas, 2026-07-31). Report the three things that actually
# distinguish the failure modes: who holds the lock, what the
# supervisor itself logged, and the agent's own output minus the
# auth noise.
WS_LOCK=/var/run/wolfstack-agent-supervisor.lock
WS_HOLDERS=""
# Every probe below is `|| true`-guarded: this script runs under
# `set -euo pipefail`, and fuser/grep exit non-zero when they simply
# find nothing. Unguarded, an empty result would abort the installer
# part-way through its own diagnostic and swallow the recovery
# instructions at the end of this block.
WS_CAN_CHECK_LOCK=false
if command -v fuser >/dev/null 2>&1; then
WS_CAN_CHECK_LOCK=true
WS_HOLDERS=$(timeout 5 fuser "$WS_LOCK" 2>/dev/null | tr -s ' ' || true)
fi
if [ "$WS_CAN_CHECK_LOCK" = false ]; then
# Silence here used to read as "nothing holds the lock", which is
# the single most useful fact in this diagnostic. Say we couldn't
# look rather than letting an absent tool imply an all-clear.
echo ""
echo " Could not check who holds $WS_LOCK — 'fuser' is not"
echo " installed on this box, so a wedged supervisor cannot be"
echo " ruled out. Reboot once if the steps below don't help."
fi
if [ -n "$WS_HOLDERS" ]; then
# Report the state we actually read instead of asserting one. This
# block used to say "it is wedged (classically D-state)" and "a
# wedged process cannot be killed — reboot this server" no matter
# what, and then print `State: S (sleeping)` between the two
# claims. S is interruptible: SIGKILL clears it and no reboot is
# needed. Sending an operator to an unnecessary reboot on the
# strength of a state we had already disproved is worse than
# saying nothing (klas, 2026-07-31).
WS_STUCK_D=false
WS_OTHER_HOLDER=false
echo ""
echo " Processes holding $WS_LOCK:"
for _p in $WS_HOLDERS; do
_st=$(grep '^State:' "/proc/$_p/status" 2>/dev/null | tr -s ' \t' ' ' || true)
_cmd=$(tr '\0' ' ' < "/proc/$_p/cmdline" 2>/dev/null || true)
_mine=""
if [ "$_p" = "${WS_SUP_PID:-}" ]; then
_mine=" <- the supervisor this installer just started"
else
WS_OTHER_HOLDER=true
fi
case "$_st" in (*" D"*) WS_STUCK_D=true ;; esac
echo " PID $_p ${_st:-state unknown} ${_cmd:-(no cmdline — likely a zombie)}$_mine"
done
if [ "$WS_STUCK_D" = true ]; then
echo ""
echo " One of those is in D (uninterruptible) state — classically a"
echo " stalled /mnt/user share. A D-state process cannot be killed,"
echo " by any signal. Reboot this server; a RAM-fresh boot starts clean."
elif [ "$WS_OTHER_HOLDER" = true ]; then
echo ""
echo " None of those is in D state, so they CAN be killed — no reboot"
echo " needed. Clear them and start a fresh supervisor:"
echo " kill -9 $WS_HOLDERS"
echo " pkill -9 -f 'wolfstack --agent'"
echo " /usr/local/bin/wolfstack-agent-supervisor >/dev/null 2>&1 &"
else
# Only our own supervisor holds it: it is alive and working,
# it just has not got an agent up inside our wait. Its own
# hang-recovery watchdog needs up to 5 minutes (5 x 60s port
# checks) to kill a non-serving agent and retry, which is
# longer than we are willing to block here. Not a failure yet.
echo ""
echo " That is our own supervisor and it is alive — it simply has not"
echo " got an agent up yet. Its hang-recovery watchdog can take up to"
echo " 5 minutes to retire a non-serving agent and retry, longer than"
echo " this installer waits. Give it 5 minutes, then check:"
echo " pgrep -af 'wolfstack --agent'"
echo " tail -20 /var/log/wolfstack-agent.log"
fi
fi
if [ -s /var/log/wolfstack-agent.log ]; then
echo ""
echo " Supervisor lifecycle (last 10):"
{ grep -E 'agent (exited|PID)|no runnable agent binary|truncated at' \
/var/log/wolfstack-agent.log 2>/dev/null || true; } | tail -10 | sed 's/^/ /'
echo " Agent output, auth noise removed (last 15):"
{ grep -vE 'wolfstack::auth' /var/log/wolfstack-agent.log 2>/dev/null || true; } \
| tail -15 | sed 's/^/ /'
else
echo ""
echo " /var/log/wolfstack-agent.log is empty or absent — the"
echo " supervisor never reached its run loop, so the agent was"
echo " never launched. The lock check above is the likely cause."
fi
echo ""
echo " See the real startup error by running it in the foreground:"
echo " /usr/local/bin/wolfstack --agent"
echo ""
echo " Then press Ctrl-C to STOP that foreground agent before the next"
echo " step — it holds the API port, and a supervised agent started"
echo " alongside it cannot bind and will exit immediately."
echo ""
echo " Now restart the supervisor:"
echo " /usr/local/bin/wolfstack-agent-supervisor >/dev/null 2>&1 &"
[ -t 0 ] || cat >/dev/null 2>&1 || true
exit 1
fi
echo " ✓ Agent is running (supervised from RAM; log: /var/log/wolfstack-agent.log)"
# awk (not grep -PoP) so it works on busybox grep too: pull the token after "src".
WS_IP=$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1); exit}}')
[ -z "$WS_IP" ] && WS_IP="<this-unraid-ip>"
echo ""
echo " ✅ WolfStack agent installed on Unraid"
echo " ─────────────────────────────────────"
echo " Binary: $WS_APPDATA/wolfstack ($BINARY_ARCH static musl)"
echo " Config: $WS_APPDATA/etc (→ /etc/wolfstack)"
echo " Log: $WS_APPDATA/wolfstack.log"
echo " Startup: $GO_FILE"
echo ""
# 8553, the HTTPS management port — NOT the inter-node port.
#
# api::add_node defaults to `body.port.unwrap_or(8553)` and hands it to
# build_node_urls(), which tries `https://host:PORT` FIRST. So 8553 joins
# over TLS on the first attempt and never touches the plain-HTTP listener.
#
# This line said 8554 for years, which is wrong twice over: the handshake
# tries https://host:8554 and fails (that listener is plain HTTP), then
# falls back to unencrypted — and on any node where 8554 was already taken
# (go2rtc/Frigate RTSP, routine on Unraid) the daemon has moved to some
# other port in 8554..8599 and there is nothing on 8554 to reach at all.
# klas 2026-07-31: agent healthy and serving HTTPS on 8553, inter-node on
# 8555, installer still saying 8554, node "unreachable on all protocols".
echo " This node runs in AGENT mode — manage it from your master node's UI:"
echo " master UI → + (bottom of the sidebar) → host $WS_IP, port 8553"
echo " (8553 is the HTTPS port — the join handshake tries TLS first.)"
if [ -s /etc/wolfstack/join-token ]; then
echo " join token: $(tr -d '\n\r' < /etc/wolfstack/join-token 2>/dev/null)"
else
echo " join token: cat /etc/wolfstack/join-token (once the agent has finished starting)"
fi
echo ""
echo " Verify: tail -f $WS_APPDATA/wolfstack.log"
echo ""
# This Unraid branch is self-contained and exits before the rest of the
# script. When run via `curl ... | bash`, exiting while curl still has the
# remaining ~1500 lines to send breaks the pipe and curl dies with
# "curl: (23) Failure writing output to destination" — alarming the user
# even though the install succeeded. Drain the rest of the piped script
# first so curl finishes cleanly. Guarded on a non-tty stdin so a direct
# `bash setup.sh` (tty) never blocks waiting on the terminal.
[ -t 0 ] || cat >/dev/null 2>&1 || true
exit 0
fi
# ─── Detect package manager ─────────────────────────────────────────────────
echo "Checking system requirements..."
if command -v apt >/dev/null 2>&1; then
PKG_MANAGER="apt"
echo "✓ Detected Debian/Ubuntu (apt)"
elif command -v dnf >/dev/null 2>&1; then
PKG_MANAGER="dnf"
echo "✓ Detected Fedora/RHEL (dnf)"
elif command -v yum >/dev/null 2>&1; then
PKG_MANAGER="yum"
echo "✓ Detected RHEL/CentOS (yum)"
elif command -v zypper >/dev/null 2>&1; then
PKG_MANAGER="zypper"
echo "✓ Detected SLES/openSUSE (zypper)"
elif command -v pacman >/dev/null 2>&1; then
PKG_MANAGER="pacman"
echo "✓ Detected Arch Linux (pacman)"
else
echo "✗ Could not detect package manager (apt/dnf/yum/zypper/pacman)"
echo " Please install dependencies manually."
exit 1
fi
# ─── Pre-flight: warn about services we know will collide ──────────────────
# Adam Cogswell's feedback: WolfStack installs dnsmasq for LXC's bridge.
# On a host that already runs an authoritative resolver (Technitium,
# Pi-hole, AdGuard, bind, unbound), or a reverse proxy that owns :80/:443,
# the install can break the user's existing setup. We don't fail — we
# warn loudly and require explicit confirmation. Skip the prompt with
# --yes for unattended installs.
echo ""
echo "Pre-flight checks..."
WS_CONFLICT_FOUND=false
ws_warn() {
WS_CONFLICT_FOUND=true
echo " ⚠ $1"
}
# Helper: is a port held by a non-wolfstack process?
ws_port_holder() {
local port="$1"
if command -v ss >/dev/null 2>&1; then
ss -tnlp 2>/dev/null | awk -v p=":$port$" '$4 ~ p { for(i=1;i<=NF;i++) if($i ~ /users:/) print $i; exit }'
elif command -v netstat >/dev/null 2>&1; then
netstat -tnlp 2>/dev/null | awk -v p=":$port$" '$4 ~ p {print $7; exit}'
fi
}
# DNS-on-:53 conflicts
DNS_HOLDER=$(ws_port_holder 53)
if [ -n "$DNS_HOLDER" ]; then
case "$DNS_HOLDER" in
*Technitium*|*technitium*)
ws_warn "Technitium DNS Server is bound to :53. WolfStack installs dnsmasq for LXC; the global dnsmasq.service will be left disabled but the package install may still affect Technitium. Consider running WolfStack on a different host." ;;
*pihole*|*pihole-FTL*)
ws_warn "Pi-hole is bound to :53. Same caveat as Technitium — installing dnsmasq alongside Pi-hole's FTL can collide." ;;
*AdGuardHome*|*AdGuard*)
ws_warn "AdGuard Home is bound to :53. Installing dnsmasq alongside AdGuard can collide." ;;
*systemd-resolve*)
echo " ℹ systemd-resolved is bound to :53 (stub listener). WolfStack handles this case automatically — leaving it alone." ;;
*named*|*bind*)
ws_warn "BIND (named) is bound to :53. Installing dnsmasq alongside BIND can collide." ;;
*unbound*)
ws_warn "Unbound is bound to :53. Installing dnsmasq alongside Unbound can collide." ;;
*dnsmasq*)
: ;; # already dnsmasq — fine
*)
ws_warn "Something is already bound to :53 ($DNS_HOLDER). Installing dnsmasq may collide." ;;
esac
fi
# 8553 / 8554 / 8550 — the three ports WolfStack actually binds. 8553 is the
# management UI / API, 8554 is the inter-node HTTP listener (cluster proxy),
# 8550 is the dedicated public status-page listener. Calling all three out
# matters for firewall planning — users open just :8553 and wonder why
# inter-node sync or status pages don't work.
for port in 8553 8554 8550; do
HOLDER=$(ws_port_holder "$port")
if [ -n "$HOLDER" ] && [ "$HOLDER" != "${HOLDER#*wolfstack}" ]; then
: # our own previous instance — fine on upgrade
elif [ -n "$HOLDER" ]; then
case "$port" in
8553) ROLE="management UI / API" ;;
8554) ROLE="inter-node cluster API" ;;
8550) ROLE="public status pages" ;;
esac
# 8554 and 8550 auto-fall-back through a scan range and persist the
# choice to ports.json; only the API port is fatal when taken. Saying
# "WolfStack will fail to bind it" for all three was wrong, and it sent
# operators hunting for a conflict that the daemon had already routed
# around — while the REAL consequence (the node is now reachable on a
# different port than the docs quote) went unmentioned.
case "$port" in
8553)
ws_warn "Port $port ($ROLE) is already bound by $HOLDER. WolfStack cannot start until that service is stopped or the API port is moved in /etc/wolfstack/ports.json." ;;
*)
ws_warn "Port $port ($ROLE) is already bound by $HOLDER. WolfStack will start anyway on the next free port and record it in /etc/wolfstack/ports.json — use THAT port when adding this node, not $port." ;;
esac
fi
done
# Reverse proxies on 80/443 (we don't install one by default but WolfProxy
# component does — flag so the user knows).
for port in 80 443; do
HOLDER=$(ws_port_holder "$port")
if [ -n "$HOLDER" ]; then
case "$HOLDER" in
*nginx*|*apache*|*httpd*|*caddy*|*traefik*|*haproxy*)
echo " ℹ Reverse proxy detected on :$port ($HOLDER). WolfStack core will not touch :$port — only relevant if you install WolfProxy later." ;;
esac