Repository navigation
Expand file tree
/
Copy pathinstall.sh
More file actions
executable file
·1699 lines (1435 loc) · 63.3 KB
/
Copy pathinstall.sh
File metadata and controls
executable file
·1699 lines (1435 loc) · 63.3 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
# Beacon panel installer — interactive, idempotent, Ubuntu 22.04/24.04.
#
# Just run it and answer the questions:
# curl -fsSL https://raw.githubusercontent.com/torgodly/beacon/master/install.sh | sudo bash
# sudo bash install.sh
#
# Every answer can also be supplied up front, which skips the matching
# question. Supplying --yes (or piping with no TTY) runs fully unattended:
# sudo bash install.sh --domain panel.example.com --email me@example.com \
# --admin-email me@example.com --admin-password '…' --yes
set -euo pipefail
BEACON_ROOT="/opt/beacon"
BEACON_BIN="${BEACON_ROOT}/bin"
PANEL_ROOT="${BEACON_ROOT}/panel"
PANEL_SHARED="${PANEL_ROOT}/shared"
PANEL_CURRENT="${PANEL_ROOT}/current"
PANEL_USER="beacon-panel"
PANEL_PHP="${BEACON_PANEL_PHP_VERSION:-8.4}"
PANEL_REPO="${BEACON_PANEL_REPO:-https://github.com/torgodly/beacon.git}"
LOG="/var/log/beacon-install.log"
BEACON_PHP_VERSIONS=(8.1 8.2 8.3 8.4)
BEACON_NODE_MAJORS=(20 22 24)
BEACON_NODE_RELEASES=(20.18.2 22.14.0 24.0.2)
BEACON_DEFAULT_NODE_MAJOR=22
DOMAIN=""
EMAIL=""
REF=""
ADMIN_NAME=""
ADMIN_EMAIL=""
ADMIN_PASSWORD=""
SKIP_MYSQL=0
SKIP_PANEL=0
ASSUME_YES=0
PHP_SELECTION=""
NODE_SELECTION=""
DOMAIN_ANSWERED=0
MYSQL_ANSWERED=0
usage() {
cat <<'USAGE'
Beacon installer
sudo bash install.sh Guided install — asks a few questions.
curl -fsSL … | sudo bash Same, works over a pipe.
Options (each one skips its question):
--domain FQDN Hostname to serve the panel on (Let's Encrypt).
--email EMAIL Contact address for the TLS certificate.
--admin-name NAME Administrator display name.
--admin-email EMAIL Administrator login.
--admin-password PASS Administrator password (min 12 characters).
--php "8.3 8.4" PHP versions to install. Default: all supported.
--node 22 Default Node major version.
--no-mysql Do not install or configure MySQL.
--skip-panel Provision the host only; do not deploy the panel.
--ref TAG Panel release to deploy.
--repo URL Panel git repository.
-y, --yes Never prompt; accept defaults for anything unset.
-h, --help Show this message.
USAGE
}
while [[ $# -gt 0 ]]; do
case "$1" in
--domain) DOMAIN="$2"; DOMAIN_ANSWERED=1; shift 2 ;;
--email) EMAIL="$2"; shift 2 ;;
--ref) REF="$2"; shift 2 ;;
--repo) PANEL_REPO="$2"; shift 2 ;;
--admin-name) ADMIN_NAME="$2"; shift 2 ;;
--admin-email) ADMIN_EMAIL="$2"; shift 2 ;;
--admin-password) ADMIN_PASSWORD="$2"; shift 2 ;;
--php) PHP_SELECTION="$2"; shift 2 ;;
--node) NODE_SELECTION="$2"; shift 2 ;;
--no-mysql) SKIP_MYSQL=1; MYSQL_ANSWERED=1; shift ;;
--skip-panel) SKIP_PANEL=1; shift ;;
-y|--yes) ASSUME_YES=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown option: $1" >&2; usage >&2; exit 64 ;;
esac
done
# ── Preflight, before we ask anything ────────────────────────────────
# Asking six questions and only then discovering we are not root would be
# a poor trade of the operator's attention.
if [[ "$(id -u)" -ne 0 ]]; then
echo "Beacon must be installed as root. Re-run with sudo:" >&2
echo " curl -fsSL …/install.sh | sudo bash" >&2
exit 64
fi
# Note the `;` rather than `&&`: with `&&` a missing /etc/os-release makes the
# substitution exit non-zero, and under `set -e` the assignment takes the whole
# installer down before it has said anything.
OS_PRETTY="unknown"
if [[ -r /etc/os-release ]]; then
OS_PRETTY="$( . /etc/os-release 2>/dev/null; echo "${PRETTY_NAME:-unknown}" )"
fi
# ── Terminal plumbing ────────────────────────────────────────────────
# `curl … | sudo bash` hands the script itself to stdin, so a bare `read`
# would silently swallow the script's own remaining lines. Everything
# interactive therefore goes through /dev/tty on its own descriptor.
# The braces matter: `exec 3</dev/tty 2>/dev/null` still lets bash print its
# own "Device not configured" to the real stderr, because the redirect is
# applied to exec rather than to the shell reporting the failure.
HAS_TTY=0
if { exec 3</dev/tty; } 2>/dev/null; then
HAS_TTY=1
fi
INTERACTIVE=1
if [[ "$HAS_TTY" -eq 0 || "$ASSUME_YES" -eq 1 ]]; then
INTERACTIVE=0
fi
if [[ -t 2 && -z "${NO_COLOR:-}" ]]; then
C_DIM=$'\033[90m'; C_CYAN=$'\033[36m'; C_BOLD=$'\033[1m'
C_RED=$'\033[31m'; C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m'; C_OFF=$'\033[0m'
else
C_DIM=""; C_CYAN=""; C_BOLD=""; C_RED=""; C_GREEN=""; C_YELLOW=""; C_OFF=""
fi
# Prompts go straight to the terminal, never through the tee'd log — the
# log should record decisions, not redraw the questionnaire.
say() { printf '%s\n' "$*" >&2; }
tty_out() { if [[ "$HAS_TTY" -eq 1 ]]; then printf '%b' "$*" >/dev/tty; else printf '%b' "$*" >&2; fi; }
banner() {
tty_out "\n${C_CYAN}${C_BOLD}"
tty_out " ██████ BEACON\n"
tty_out "${C_OFF}${C_DIM} Self-hosted server control panel · installer${C_OFF}\n\n"
tty_out " ${C_DIM}host${C_OFF} $(hostname)\n"
tty_out " ${C_DIM}os${C_OFF} ${OS_PRETTY}\n"
tty_out " ${C_DIM}log${C_OFF} ${LOG}\n\n"
}
section() { tty_out "\n${C_BOLD}$1${C_OFF}\n${C_DIM}$2${C_OFF}\n\n"; }
hint() { tty_out " ${C_DIM}$1${C_OFF}\n"; }
oops() { tty_out " ${C_RED}✖ $1${C_OFF}\n"; }
# Best-effort primary IPv4. `hostname -I` is Linux-only and can be empty on a
# host with only a loopback, so fall back through `ip route` before giving up.
primary_ip() {
local addr
addr="$(hostname -I 2>/dev/null | awk '{print $1; exit}')"
[[ -z "$addr" ]] && addr="$(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}}')"
printf '%s' "${addr:-this-server}"
}
# ask <varname> <question> [default] [validator] [validator-hint]
ask() {
local __var="$1" question="$2" default="${3:-}" validator="${4:-}" vhint="${5:-}"
local current="${!__var}" answer
# Already supplied on the command line — do not ask again.
[[ -n "$current" ]] && return 0
if [[ "$INTERACTIVE" -eq 0 ]]; then
printf -v "$__var" '%s' "$default"
return 0
fi
while true; do
if [[ -n "$default" ]]; then
tty_out " ${question} ${C_DIM}[${default}]${C_OFF} "
else
tty_out " ${question} "
fi
IFS= read -r answer <&3 || answer=""
answer="${answer:-$default}"
if [[ -z "$answer" && -z "$default" ]]; then
oops "This one is required."
continue
fi
if [[ -n "$validator" ]] && ! "$validator" "$answer"; then
oops "${vhint:-That does not look right.}"
continue
fi
printf -v "$__var" '%s' "$answer"
return 0
done
}
# A 24-character password from openssl, which is already a hard dependency.
#
# `tr -dc … </dev/urandom` is the usual one-liner and it is quietly broken:
# under a UTF-8 locale tr aborts with "Illegal byte sequence" on the first
# invalid sequence, so the pipeline yields a password one or two characters
# long. openssl has no such failure mode.
random_password() {
# The generated password has to satisfy the same rules beacon:create-admin
# enforces in production — Password::min(12)->mixedCase()->numbers()
# ->symbols() — or the very last step of the install fails with
# "The password field must contain at least one symbol" and no
# administrator is created.
#
# base64 only emits [A-Za-z0-9+/=], so symbols are injected explicitly
# rather than filtered for. Entropy comes from openssl; $RANDOM only
# chooses which symbol, on top of 20 already-random alphanumerics.
local set='!@#%^*_+=-' core s1 s2 attempt=0
while (( attempt++ < 50 )); do
core="$(LC_ALL=C openssl rand -base64 48 | LC_ALL=C tr -dc 'A-Za-z0-9' | cut -c1-20)"
(( ${#core} == 20 )) || continue
[[ "$core" == *[a-z]* ]] || continue
[[ "$core" == *[A-Z]* ]] || continue
[[ "$core" == *[0-9]* ]] || continue
s1="${set:$(( RANDOM % ${#set} )):1}"
s2="${set:$(( RANDOM % ${#set} )):1}"
printf '%s%s%s%s' "${core:0:7}" "$s1" "${core:7}" "$s2"
return 0
done
echo "ERROR: could not generate a compliant password" >&2
return 1
}
# ask_secret <varname> <question> — hidden input, typed twice.
ask_secret() {
local __var="$1" question="$2" first second
[[ -n "${!__var}" ]] && return 0
if [[ "$INTERACTIVE" -eq 0 ]]; then
printf -v "$__var" '%s' "$(random_password)"
GENERATED_PASSWORD=1
return 0
fi
while true; do
tty_out " ${question} ${C_DIM}[blank = generate one]${C_OFF} "
IFS= read -rs first <&3 || first=""
tty_out "\n"
if [[ -z "$first" ]]; then
printf -v "$__var" '%s' "$(random_password)"
GENERATED_PASSWORD=1
hint "Generated a 24-character password — shown at the end."
return 0
fi
# Mirror the rules beacon:create-admin enforces in production. Checking
# here costs a keystroke; discovering it after a five-minute install
# costs the whole run.
if (( ${#first} < 12 )); then
oops "Use at least 12 characters."
continue
fi
if [[ "$first" != *[a-z]* || "$first" != *[A-Z]* ]]; then
oops "Use both upper and lower case letters."
continue
fi
if [[ "$first" != *[0-9]* ]]; then
oops "Include at least one number."
continue
fi
# Strip every alphanumeric; anything left over is a symbol. Avoids
# needing `shopt -s extglob` for a +([A-Za-z0-9]) pattern.
if [[ -z "${first//[A-Za-z0-9]/}" ]]; then
oops "Include at least one symbol, e.g. ! @ # % ^ _ + -"
continue
fi
tty_out " Confirm password: "
IFS= read -rs second <&3 || second=""
tty_out "\n"
if [[ "$first" != "$second" ]]; then
oops "Those did not match."
continue
fi
printf -v "$__var" '%s' "$first"
return 0
done
}
# confirm <question> <default y|n>
confirm() {
local question="$1" default="${2:-y}" answer suffix
if [[ "$INTERACTIVE" -eq 0 ]]; then
[[ "$default" == "y" ]]
return
fi
[[ "$default" == "y" ]] && suffix="[Y/n]" || suffix="[y/N]"
while true; do
tty_out " ${question} ${C_DIM}${suffix}${C_OFF} "
IFS= read -r answer <&3 || answer=""
answer="${answer:-$default}"
# tr rather than ${answer,,} — the latter is bash 4+, and an installer
# is the last place to depend on the shell being new enough.
case "$(printf '%s' "$answer" | tr '[:upper:]' '[:lower:]')" in
y|yes) return 0 ;;
n|no) return 1 ;;
*) oops "Please answer y or n." ;;
esac
done
}
valid_domain() {
[[ "$1" =~ ^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$ ]]
}
valid_email() {
[[ "$1" =~ ^[^@[:space:]]+@[^@[:space:]]+\.[a-z]{2,}$ ]]
}
valid_php_list() {
local version
for version in $1; do
[[ " ${BEACON_PHP_VERSIONS[*]} " == *" ${version} "* ]] || return 1
done
[[ -n "$1" ]]
}
valid_node_major() {
[[ " ${BEACON_NODE_MAJORS[*]} " == *" $1 "* ]]
}
# Read a single value from the panel's SQLite database.
#
# sqlite3 is installed by this script, so it is missing on a first run — but so
# is the database, so there is nothing to ask. On every re-run both sqlite3 and
# php exist, and that is exactly when these answers matter. php is tried as a
# fallback because a first run installs it before sqlite3 is available.
panel_db_query() {
local sql="$1"
local db="${PANEL_SHARED}/beacon.sqlite"
[[ -s "$db" ]] || return 1
if command -v sqlite3 >/dev/null 2>&1; then
sqlite3 "$db" "$sql" 2>/dev/null && return 0
fi
local php_bin
for php_bin in "/usr/bin/php${PANEL_PHP}" /usr/bin/php; do
[[ -x "$php_bin" ]] || continue
"$php_bin" -r '
try {
$pdo = new PDO("sqlite:".$argv[1]);
$value = $pdo->query($argv[2])->fetchColumn();
echo $value === false ? "" : $value;
} catch (Throwable $e) {
exit(1);
}
' "$db" "$sql" 2>/dev/null && return 0
done
return 1
}
# Statement variant, for keeping the stored panel URL in step with reality.
panel_db_exec() {
local sql="$1"
local db="${PANEL_SHARED}/beacon.sqlite"
[[ -s "$db" ]] || return 1
if command -v sqlite3 >/dev/null 2>&1; then
sqlite3 "$db" "$sql" >/dev/null 2>&1 && return 0
fi
local php_bin
for php_bin in "/usr/bin/php${PANEL_PHP}" /usr/bin/php; do
[[ -x "$php_bin" ]] || continue
"$php_bin" -r '
try {
(new PDO("sqlite:".$argv[1]))->exec($argv[2]);
} catch (Throwable $e) {
exit(1);
}
' "$db" "$sql" 2>/dev/null && return 0
done
return 1
}
# The hostname the panel is currently configured to serve, if any.
existing_panel_domain() {
local domain
domain="$(panel_db_query 'SELECT COALESCE(panel_domain, "") FROM servers WHERE id = 1;')" || return 1
domain="${domain//[[:space:]]/}"
[[ -n "$domain" ]] || return 1
printf '%s' "$domain"
}
# Does the panel database already contain a user?
#
# Without this the wizard asks for a name, an email and a password on every
# re-run, generates a password it then cannot apply, and prints it in the
# closing summary as though it were live — which is worse than not asking,
# because it looks like the credentials changed.
panel_has_admin() {
local count
count="$(panel_db_query 'SELECT COUNT(*) FROM users;')" || return 1
count="${count//[[:space:]]/}"
[[ "$count" =~ ^[0-9]+$ ]] && (( count > 0 ))
}
# ── The questionnaire ────────────────────────────────────────────────
GENERATED_PASSWORD=0
ADMIN_EXISTS=0
REVERT_TO_IP=0
banner
if [[ "$INTERACTIVE" -eq 0 && "$HAS_TTY" -eq 0 && "$ASSUME_YES" -eq 0 ]]; then
hint "No terminal attached — running with defaults. Use --help to see the options."
tty_out "\n"
fi
if ! grep -qiE 'ubuntu|debian' /etc/os-release 2>/dev/null; then
tty_out " ${C_YELLOW}⚠ Beacon is tested on Ubuntu 24.04 and 22.04 only.${C_OFF}\n"
if ! confirm "Continue anyway?" n; then
say "Aborted."
exit 1
fi
fi
section "1 · How will you reach the panel?" \
"A real hostname gets a free Let's Encrypt certificate and lets GitHub deliver webhooks. Without one, Beacon serves on https://IP:8443 with a self-signed certificate and falls back to polling for deploys — you can attach a domain later from Settings."
# A re-run must never silently undo a working configuration.
#
# Previously the wizard just asked "do you have a domain?", and answering no —
# the habitual answer from the first install — tore down the vhost for an
# already-attached hostname, leaving the panel on :8443 while the database
# still advertised the domain. Through Cloudflare that surfaces as a 525 SSL
# handshake failure, with the UI insisting the domain is configured.
CURRENT_PANEL_DOMAIN=""
if [[ "$DOMAIN_ANSWERED" -eq 0 && -z "$DOMAIN" ]]; then
CURRENT_PANEL_DOMAIN="$(existing_panel_domain || true)"
fi
if [[ -n "$CURRENT_PANEL_DOMAIN" ]]; then
hint "This panel is already served at https://${CURRENT_PANEL_DOMAIN}"
if confirm "Keep serving it at ${CURRENT_PANEL_DOMAIN}?" y; then
DOMAIN="$CURRENT_PANEL_DOMAIN"
DOMAIN_ANSWERED=1
else
tty_out " ${C_YELLOW}⚠${C_OFF} ${CURRENT_PANEL_DOMAIN} will stop working; the panel"
tty_out " reverts to https://$(primary_ip):8443\n"
DOMAIN_ANSWERED=1
REVERT_TO_IP=1
fi
fi
if [[ "$DOMAIN_ANSWERED" -eq 0 && -z "$DOMAIN" ]]; then
if confirm "Do you have a domain pointed at this server?" y; then
ask DOMAIN "Panel hostname:" "" valid_domain \
"Enter a fully-qualified hostname, e.g. panel.example.com"
else
hint "Using https://$(primary_ip):8443 with a self-signed certificate."
fi
fi
# Only needed when a certificate actually has to be issued. Keeping an
# already-attached domain reuses the certificate on disk, so asking again
# would be a question with no consequence.
if [[ -n "$DOMAIN" && ! -f "/etc/letsencrypt/live/${DOMAIN}/fullchain.pem" ]]; then
ask EMAIL "Email for the TLS certificate:" "" valid_email \
"Let's Encrypt needs a valid address for expiry notices."
fi
if panel_has_admin; then
section "2 · Administrator account" \
"An administrator already exists on this server."
hint "Skipping — sign in with your existing account."
hint "Forgotten it? sudo -u ${PANEL_USER} php ${PANEL_CURRENT}/artisan beacon:create-admin --force"
ADMIN_EXISTS=1
else
section "2 · Administrator account" \
"Public registration is disabled, so this is the only way in. Turn on two-factor authentication at first login."
ask ADMIN_NAME "Your name:" "Beacon Admin"
ask ADMIN_EMAIL "Login email:" "${EMAIL:-}" valid_email "That does not look like an email address."
ask_secret ADMIN_PASSWORD "Password:"
fi
section "3 · Runtimes and services" \
"Everything runs natively on the host — no containers."
if [[ -z "$PHP_SELECTION" ]]; then
if [[ "$INTERACTIVE" -eq 1 ]]; then
hint "Supported: ${BEACON_PHP_VERSIONS[*]}"
fi
ask PHP_SELECTION "PHP versions to install:" "${BEACON_PHP_VERSIONS[*]}" \
valid_php_list "Pick from: ${BEACON_PHP_VERSIONS[*]}"
fi
ask NODE_SELECTION "Default Node major version:" "$BEACON_DEFAULT_NODE_MAJOR" \
valid_node_major "Pick from: ${BEACON_NODE_MAJORS[*]}"
if [[ "$MYSQL_ANSWERED" -eq 0 ]]; then
if confirm "Install and configure MySQL 8?" y; then
SKIP_MYSQL=0
else
SKIP_MYSQL=1
fi
fi
# Apply the answers to the install plan.
read -r -a BEACON_PHP_VERSIONS <<< "$PHP_SELECTION"
BEACON_DEFAULT_NODE_MAJOR="$NODE_SELECTION"
# ── Summary + point of no return ─────────────────────────────────────
section "Ready to install" "Nothing has been changed on this server yet."
tty_out " ${C_DIM}panel url${C_OFF} $( [[ -n "$DOMAIN" ]] && echo "https://${DOMAIN}/" || echo "https://$(primary_ip):8443/" )\n"
tty_out " ${C_DIM}tls${C_OFF} $( [[ -n "$DOMAIN" ]] && echo "Let's Encrypt (${EMAIL})" || echo "self-signed" )\n"
if [[ "$ADMIN_EXISTS" -eq 1 ]]; then
tty_out " ${C_DIM}administrator${C_OFF} ${C_DIM}existing account, unchanged${C_OFF}\n"
else
tty_out " ${C_DIM}administrator${C_OFF} ${ADMIN_EMAIL}\n"
fi
tty_out " ${C_DIM}php${C_OFF} ${BEACON_PHP_VERSIONS[*]}\n"
tty_out " ${C_DIM}node${C_OFF} ${BEACON_DEFAULT_NODE_MAJOR} ${C_DIM}(plus Bun)${C_OFF}\n"
tty_out " ${C_DIM}mysql${C_OFF} $( [[ "$SKIP_MYSQL" -eq 0 ]] && echo "yes" || echo "skipped" )\n"
tty_out " ${C_DIM}also${C_OFF} nginx · redis · supervisor · certbot · ufw\n\n"
if [[ "$INTERACTIVE" -eq 1 ]]; then
if ! confirm "Start the install?" y; then
say "Aborted — nothing was changed."
exit 0
fi
fi
tty_out "\n${C_DIM}This takes 3–8 minutes. Full log: ${LOG}${C_OFF}\n\n"
exec > >(tee -a "$LOG") 2>&1
echo "==> Beacon install started $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "==> Plan: domain='${DOMAIN:-none}' php='${BEACON_PHP_VERSIONS[*]}' node='${BEACON_DEFAULT_NODE_MAJOR}' mysql=$(( 1 - SKIP_MYSQL ))"
# Did the operator upload the repo and run install.sh from inside it?
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd || true)"
if [[ -z "$SCRIPT_DIR" || "$SCRIPT_DIR" == "/" || ! -f "${SCRIPT_DIR}/artisan" ]]; then
SCRIPT_DIR=""
fi
# ── Fetch our own source when piped ──────────────────────────────────
# `curl … | sudo bash` gives the shell a script on stdin and nothing else, so
# SCRIPT_DIR is empty and every file the installer needs from the repo — the
# sudo wrappers, the sudoers policy, the panel .env template, the nginx and
# MySQL drop-ins — is missing. Clone the repo once, up front, and carry on as
# if the operator had uploaded it.
BEACON_SRC_TMP=""
cleanup_src_tmp() {
[[ -n "$BEACON_SRC_TMP" && -d "$BEACON_SRC_TMP" ]] && rm -rf "$BEACON_SRC_TMP"
return 0
}
trap cleanup_src_tmp EXIT
if [[ -z "$SCRIPT_DIR" ]]; then
echo "==> No local source tree — fetching ${PANEL_REPO}"
if ! command -v git >/dev/null 2>&1; then
DEBIAN_FRONTEND=noninteractive apt-get update -qq || true
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq git ca-certificates
fi
BEACON_SRC_TMP="$(mktemp -d /tmp/beacon-src.XXXXXX)"
# No --branch unless one was asked for: the repo's default branch may be
# master or main, and guessing wrong fails the clone outright.
if [[ -n "$REF" ]]; then
git clone --depth 1 --branch "$REF" "$PANEL_REPO" "$BEACON_SRC_TMP"
else
git clone --depth 1 "$PANEL_REPO" "$BEACON_SRC_TMP"
fi
if [[ ! -f "${BEACON_SRC_TMP}/artisan" ]]; then
echo "ERROR: ${PANEL_REPO} does not look like a Beacon checkout (no artisan)." >&2
echo " Pass --repo URL, or upload the source and run install.sh from inside it." >&2
exit 1
fi
SCRIPT_DIR="$BEACON_SRC_TMP"
echo " Source ready at ${SCRIPT_DIR} ($(git -C "$SCRIPT_DIR" rev-parse --short HEAD 2>/dev/null || echo unknown))"
fi
# ── Users ─────────────────────────────────────────────────────────────
if ! id beacon &>/dev/null; then
useradd --create-home --home-dir /home/beacon --shell /bin/bash --user-group beacon
fi
usermod --append --groups www-data beacon 2>/dev/null || true
chown beacon:www-data /home/beacon
chmod 750 /home/beacon
if ! id "$PANEL_USER" &>/dev/null; then
useradd --system --create-home --home-dir "/home/${PANEL_USER}" \
--shell /usr/sbin/nologin --user-group "$PANEL_USER"
fi
configure_beacon_shell() {
local bashrc=/home/beacon/.bashrc
touch "$bashrc"
chown beacon:beacon "$bashrc"
if ! grep -q 'Beacon site umask' "$bashrc" 2>/dev/null; then
cat >> "$bashrc" <<'EOF'
# Beacon site umask — group-writable files (0664) and directories (2775)
umask 0002
EOF
fi
}
configure_beacon_shell
install -d -o beacon -g beacon -m 0700 /home/beacon/.ssh
install -d -o beacon -g beacon -m 0750 /home/beacon/.beacon/{bin,deploy}
install -d -o "$PANEL_USER" -g "$PANEL_USER" -m 0750 \
/var/log/beacon/{deployments,commands,operations,panel-updates,backups}
install -d -o beacon -g "$PANEL_USER" -m 0750 /var/log/beacon/sites
install -d -o www-data -g www-data -m 0755 /var/www/beacon-acme
touch /var/log/beacon/panel-php-error.log /var/log/beacon/panel-worker.log
chown "$PANEL_USER:$PANEL_USER" /var/log/beacon/panel-{php-error,worker}.log
chmod 0640 /var/log/beacon/panel-{php-error,worker}.log
# ── Swap + OOM (audit §0.3) ─────────────────────────────────────────
ram_mb=$(awk '/MemTotal/{print int($2/1024)}' /proc/meminfo 2>/dev/null || echo 0)
if [[ -z "$(swapon --show --noheadings 2>/dev/null || true)" && "$ram_mb" -lt 4096 && "$ram_mb" -gt 0 ]]; then
swap_mb=$(( ram_mb * 2 )); (( swap_mb < 2048 )) && swap_mb=2048
(( swap_mb > 4096 )) && swap_mb=4096
avail_mb=$(df --output=avail -m / | tail -1)
if (( avail_mb > swap_mb + 5120 )); then
fstype=$(findmnt -no FSTYPE / 2>/dev/null || echo ext4)
if [[ "$fstype" == "btrfs" ]]; then
truncate -s 0 /swapfile 2>/dev/null || true
chattr +C /swapfile 2>/dev/null || true
fi
fallocate -l "${swap_mb}M" /swapfile 2>/dev/null \
|| dd if=/dev/zero of=/swapfile bs=1M count="$swap_mb" status=none
chmod 600 /swapfile && mkswap /swapfile >/dev/null && swapon /swapfile
grep -q '^/swapfile' /etc/fstab || echo '/swapfile none swap sw,nofail 0 0' >> /etc/fstab
printf 'vm.swappiness=10\nvm.vfs_cache_pressure=50\n' > /etc/sysctl.d/99-beacon.conf
sysctl -q --system 2>/dev/null || true
fi
fi
for unit in mysql redis-server nginx supervisor; do
install -d "/etc/systemd/system/${unit}.service.d"
printf '[Service]\nOOMScoreAdjust=-500\n' > "/etc/systemd/system/${unit}.service.d/99-beacon-oom.conf"
done
for ver in "${BEACON_PHP_VERSIONS[@]}"; do
unit="php${ver}-fpm"
install -d "/etc/systemd/system/${unit}.service.d"
printf '[Service]\nOOMScoreAdjust=-500\n' > "/etc/systemd/system/${unit}.service.d/99-beacon-oom.conf"
done
# ── systemd sandbox exemptions ──────────────────────────────────────
#
# Debian/sury ship php-fpm with `ProtectSystem=full`, which mounts /etc
# read-only for everything in the service's cgroup. sudo raises the UID but
# does NOT escape the mount namespace, so a wrapper invoked from the panel
# runs as root and still cannot write /etc — certbot reports it as
# [Errno 30] Read-only file system: '/etc/letsencrypt/.certbot.lock'
# and it would fail identically for nginx vhosts, FPM pools and Supervisor
# units. Beacon manages exactly these paths, so they are re-opened for write.
#
# ReadWritePaths is a narrow exemption: everything else stays read-only, which
# keeps most of the sandbox's value. apt extension installs write .so files to
# /usr/lib/php (and sometimes pull libs into /usr/lib/<arch>-linux-gnu).
# beacon-pkg also runs apt via systemd-run so dpkg escapes this sandbox entirely.
SANDBOX_PATHS_SRC="${SCRIPT_DIR}/bin/lib/beacon-sandbox-paths.sh"
if [[ -z "$SCRIPT_DIR" || ! -f "$SANDBOX_PATHS_SRC" ]]; then
SANDBOX_PATHS_SRC="$(pwd)/bin/lib/beacon-sandbox-paths.sh"
fi
# shellcheck source=bin/lib/beacon-sandbox-paths.sh
source "$SANDBOX_PATHS_SRC"
# The panel's own pool is what serves the UI, and supervisord runs the queue
# worker that performs the same privileged actions asynchronously.
install -d -m 0755 /etc/letsencrypt
beacon_sync_sandbox_dropins "$PANEL_PHP"
# ── Wrappers + sudoers ──────────────────────────────────────────────
install -d -m 0755 "$BEACON_BIN"
WRAPPER_SRC="${SCRIPT_DIR}/bin/wrappers"
if [[ -z "$SCRIPT_DIR" || ! -d "$WRAPPER_SRC" ]]; then
WRAPPER_SRC="$(pwd)/bin/wrappers"
fi
for wrapper in nginx php supervisor certbot service pkg cron update run fs; do
src="${WRAPPER_SRC}/beacon-${wrapper}"
if [[ -f "$src" ]]; then
install -m 0755 "$src" "${BEACON_BIN}/beacon-${wrapper}"
fi
done
SUDOERS_SRC="${SCRIPT_DIR}/bin/sudoers/beacon-panel"
if [[ -z "$SCRIPT_DIR" || ! -f "$SUDOERS_SRC" ]]; then
SUDOERS_SRC="$(pwd)/bin/sudoers/beacon-panel"
fi
if [[ -f "$SUDOERS_SRC" ]]; then
visudo -cf "$SUDOERS_SRC"
install -m 0440 "$SUDOERS_SRC" /etc/sudoers.d/beacon-panel
fi
panel_primary_ip() {
primary_ip
}
apt_wait() {
local waited=0
local max=300
while fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 \
|| fuser /var/lib/dpkg/lock >/dev/null 2>&1 \
|| fuser /var/lib/apt/lists/lock >/dev/null 2>&1; do
if (( waited >= max )); then
echo "WARN: apt locks still held after ${max}s — proceeding anyway" >&2
return 0
fi
echo " Waiting for unattended-upgrades to release apt locks…"
sleep 5
waited=$((waited + 5))
done
}
apt_update() {
apt_wait
apt-get update -qq
}
apt_install() {
apt_wait
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$@" 2>/dev/null || true
}
seed_git_known_hosts() {
echo "==> Pre-seeding Git SSH host keys for site deploys"
local known=/home/beacon/.ssh/known_hosts
touch "$known"
chown beacon:beacon "$known"
chmod 600 "$known"
if ! ssh-keygen -F github.com -f "$known" >/dev/null 2>&1; then
ssh-keyscan -H github.com gitlab.com bitbucket.org >> "$known" 2>/dev/null || true
chown beacon:beacon "$known"
chmod 600 "$known"
fi
}
configure_nginx_catch_all() {
echo "==> Installing Nginx 000-catch-all default vhost"
local deploy_dir="${SCRIPT_DIR}/deploy"
if [[ ! -d "$deploy_dir" ]]; then
deploy_dir="$(pwd)/deploy"
fi
install -m 0644 "${deploy_dir}/nginx/000-catch-all.conf" /etc/nginx/sites-available/000-catch-all
ln -sfn /etc/nginx/sites-available/000-catch-all /etc/nginx/sites-enabled/000-catch-all
rm -f /etc/nginx/sites-enabled/default
}
# Run a command as the panel user, from a directory it can actually enter.
#
# runuser keeps the caller's working directory. The installer runs from root's
# shell — usually /root, which is mode 0700 — so a child dropped to
# beacon-panel cannot stat its own cwd, and the first getcwd()/chdir() it
# attempts dies with "chdir(): Permission denied (errno 13)". Composer does
# exactly that on startup.
# runuser also *preserves* the caller's environment, so HOME stays /root.
# npm would then write its cache to /root/.npm and Composer to /root/.composer,
# both unreadable to beacon-panel. Pin HOME and the cache locations explicitly.
panel_run() {
local workdir="$1"
shift
( cd "$workdir" && runuser -u "$PANEL_USER" -- env \
HOME="/home/${PANEL_USER}" \
USER="$PANEL_USER" \
COMPOSER_HOME="/home/${PANEL_USER}/.composer" \
COMPOSER_ALLOW_SUPERUSER=0 \
NPM_CONFIG_CACHE="/home/${PANEL_USER}/.npm" \
NPM_CONFIG_UPDATE_NOTIFIER=false \
NODE_OPTIONS="--max-old-space-size=$(panel_node_heap_mb)" \
CI=true \
PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" \
"$@" )
}
# Cap V8 so the panel's own Vite build cannot OOM a small VPS.
panel_node_heap_mb() {
local ram
ram=$(awk '/MemTotal/{print int($2/1024)}' /proc/meminfo 2>/dev/null || echo 2048)
local heap=$(( ram * 65 / 100 ))
(( heap < 512 )) && heap=512
(( heap > 4096 )) && heap=4096
printf '%s' "$heap"
}
# Freeze a release: root-owned, nobody but root may write, everybody may read.
#
# Root-owned code is the point — a panel compromise cannot rewrite its own
# source. But it must stay READABLE: php-fpm runs the panel as beacon-panel
# and nginx serves public/ as www-data, and neither is the owner.
#
# This matters because root's umask on many VPS images is 077, so the git
# clone lands at 0600/0700. The chown to root:beacon-panel then leaves the
# group with no read bit at all, which surfaces as
# Could not open input file: /opt/beacon/panel/current/artisan
# and a bare "File not found." from PHP-FPM in the browser.
#
# The application code is not secret. The secrets live in shared/.env, which
# is a symlink to a 0640 root:beacon-panel file and is unaffected by this.
# Delete bootstrap caches inherited from the previous release.
#
# bootstrap/cache is a symlink into shared state, so a new release begins life
# holding the last release's routes-v7.php and config.php. The Vite build runs
# `wayfinder:generate`, which boots Laravel to enumerate routes — against that
# stale cache. A route added in this release is invisible, its typed helper is
# never written, and the build dies with
# Could not load .../resources/js/routes/sites/serving
# (resources/js/routes is gitignored, so nothing else supplies it.)
#
# The files are removed directly rather than via `artisan optimize:clear`,
# which runs cache:clear SECOND and route:clear FIFTH. cache:clear talks to
# Redis, so if Redis is briefly unreachable the command aborts before ever
# clearing the route cache — silently, and precisely when it matters.
#
# packages.php and services.php are left alone: they are package-discovery
# output written moments ago by composer, not stale state.
clear_inherited_bootstrap_cache() {
local rel="$1"
local cache="${rel}/bootstrap/cache"
[[ -d "$cache" ]] || return 0
rm -f "${cache}/config.php" "${cache}/events.php" "${cache}"/routes-v*.php
echo " Cleared inherited config/route cache"
}
harden_release() {
local rel="$1"
[[ -d "$rel" ]] || return 0
chown -R root:"$PANEL_USER" "$rel"
# a+rX: read for all, traverse for directories (and keep existing +x on
# files such as artisan). go-w: only root may write.
chmod -R a+rX,go-w "$rel"
}
bootstrap_panel() {
echo "==> Bootstrapping panel under ${PANEL_ROOT}"
# Every component of PANEL_CURRENT must be traversable by beacon-panel, or
# Supervisor cannot even start the worker:
# supervisor: couldn't chdir to /opt/beacon/panel/current: EACCES
# `install -d` does not guarantee the mode it applies to directories it
# creates as *parents*, so set them explicitly. The chmod also repairs an
# existing installation whose parents were created too restrictively.
install -d -m 0755 "$BEACON_ROOT"
install -d -m 0755 "$PANEL_ROOT"
install -d -m 0755 "${PANEL_ROOT}/releases"
chmod 0755 "$BEACON_ROOT" "$PANEL_ROOT" "${PANEL_ROOT}/releases"
install -d -o "$PANEL_USER" -g "$PANEL_USER" -m 2770 "$PANEL_SHARED"
install -d -o "$PANEL_USER" -g "$PANEL_USER" -m 2770 \
"${PANEL_SHARED}/storage"/{app/public,framework/{cache,sessions,views},logs}
install -d -o "$PANEL_USER" -g "$PANEL_USER" -m 2770 "${PANEL_SHARED}/bootstrap-cache"
ensure_panel_env
# Rebuild when the fetched source differs from what is deployed.
#
# Skipping purely because `current` exists means a re-run silently keeps
# serving old code, so a fix that was just pushed appears not to work —
# and the operator has to know to `rm -f current` first. The deployed
# commit is recorded in the release, so this can be decided automatically.
local source_sha=""
if [[ -n "$SCRIPT_DIR" && -d "${SCRIPT_DIR}/.git" ]]; then
source_sha="$(git -C "$SCRIPT_DIR" rev-parse HEAD 2>/dev/null || true)"
fi
if [[ -L "$PANEL_CURRENT" && -f "${PANEL_CURRENT}/artisan" ]]; then
local deployed_sha=""
if [[ -f "${PANEL_CURRENT}/.beacon-source-sha" ]]; then
deployed_sha="$(tr -d '[:space:]' < "${PANEL_CURRENT}/.beacon-source-sha")"
fi
if [[ -n "$source_sha" && "$source_sha" != "$deployed_sha" ]]; then
echo " Source is newer than the deployed release — rebuilding"
echo " deployed: ${deployed_sha:-unknown}"
echo " source: ${source_sha}"
else
echo " Panel release already present at ${PANEL_CURRENT} — skipping build"
# Still re-apply permissions: an existing release may predate the
# hardening fix, and the build is skipped so nothing else repairs it.
harden_release "$(readlink -f "$PANEL_CURRENT")"
return 0
fi
fi
rel="${PANEL_ROOT}/releases/$(date -u +%Y%m%d%H%M%S)"
install -d -o "$PANEL_USER" -g "$PANEL_USER" -m 0755 "$rel"
if [[ -n "$SCRIPT_DIR" && -f "${SCRIPT_DIR}/artisan" ]]; then
echo " Copying panel source from ${SCRIPT_DIR}"
rsync -a \
--exclude '.git' \
--exclude 'node_modules' \
--exclude 'vendor' \
--exclude 'storage' \
--exclude 'bootstrap/cache' \
--exclude '.env' \
"${SCRIPT_DIR}/" "${rel}/"
# `rsync -a` implies -o -g, so as root it stamps the copy with the
# source's ownership (root:root). Composer and npm then cannot create
# vendor/, node_modules/ or public/build inside their own release
# directory. Hand the tree back to the panel user for the build; it is
# re-frozen to root:beacon-panel once the build finishes.
chown -R "$PANEL_USER:$PANEL_USER" "$rel"
else
# Reached only if the source vanished between the fetch and here.
# No --branch unless asked: the default branch may be master or main.
echo " Cloning ${PANEL_REPO}${REF:+ @ ${REF}}"
if [[ -n "$REF" ]]; then
panel_run "$rel" git clone --depth 1 --branch "$REF" "$PANEL_REPO" "$rel"
else
panel_run "$rel" git clone --depth 1 "$PANEL_REPO" "$rel"
fi
fi
# Shared state is wired BEFORE composer, not after.
#
# composer's post-autoload-dump hook runs `artisan package:discover`, which
# boots Laravel and requires bootstrap/cache to exist and be writable — and
# rsync deliberately excludes both bootstrap/cache and storage. Linking
# afterwards means the very first composer run always dies with
# "The …/bootstrap/cache directory must be present and writable."
rm -rf "$rel/storage" "$rel/bootstrap/cache"
install -d -o "$PANEL_USER" -g "$PANEL_USER" -m 0755 "${rel}/bootstrap"
ln -sfn "${PANEL_SHARED}/.env" "${rel}/.env"
ln -sfn "${PANEL_SHARED}/storage" "${rel}/storage"
ln -sfn "${PANEL_SHARED}/bootstrap-cache" "${rel}/bootstrap/cache"
panel_run "$rel" composer install -d "$rel" --no-dev --no-interaction \
--prefer-dist --optimize-autoloader
clear_inherited_bootstrap_cache "$rel"
panel_run "$rel" npm --prefix "$rel" ci
panel_run "$rel" npm --prefix "$rel" run build
panel_run "$rel" php "$rel/artisan" migrate --force --no-interaction
panel_run "$rel" php "$rel/artisan" optimize
# Stamp the release so a later run can tell whether it is current.
if [[ -n "$source_sha" ]]; then
printf '%s\n' "$source_sha" > "${rel}/.beacon-source-sha"
fi
harden_release "$rel"
ln -sfn "$rel" "${PANEL_ROOT}/current.new"
mv -Tf "${PANEL_ROOT}/current.new" "$PANEL_CURRENT"
}
ensure_panel_env() {
local env_file="${PANEL_SHARED}/.env"
if [[ -f "$env_file" ]]; then
return 0
fi
local template="${SCRIPT_DIR}/deploy/env/panel.env"
if [[ ! -f "$template" ]]; then
template="$(pwd)/deploy/env/panel.env"
fi
[[ -f "$template" ]] || { echo "Missing deploy/env/panel.env template" >&2; exit 1; }
local app_url="https://$(panel_primary_ip):8443"
if [[ -n "$DOMAIN" ]]; then
app_url="https://${DOMAIN}"
fi
local app_key