Repository navigation
Expand file tree
/
Copy pathnova-node.sh
More file actions
executable file
·1981 lines (1896 loc) · 103 KB
/
Copy pathnova-node.sh
File metadata and controls
executable file
·1981 lines (1896 loc) · 103 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
# =============================================================================
# Nova Node: one-line VPS installer for the full Nova panel
#
# Installs xray-core + the Nova node agent and wires them together so ONE
# public port (443) serves both the admin panel and the tunnel:
# - xray terminates TLS on :443 and dispatches by path
# <wsPath> -> the VLESS/VMess/Trojan tunnel inbounds (loopback)
# everything else -> the agent's HTTP panel + browser dashboard
# The agent is managed from the Nova app, a browser (https://<your-vps>), or
# the built-in Telegram bot. Runs entirely on YOUR server; nothing is sent out.
#
# Run on your own VPS (Debian/Ubuntu):
# bash <(curl -fsSL https://raw.githubusercontent.com/IRNova/Nova-Server/main/nova-node.sh)
#
# Options (env vars):
# NOVA_ADMIN_PASS=... panel admin password (a random one is generated if unset)
# NOVA_DOMAIN=... a domain that points at this server (optional). Without
# one, the node uses the public IP with a self-signed cert
# and the app's "no domain" switch.
# NOVA_PANEL_PATH=... secret panel subpath (stealth). Unset = a random one is
# generated on a fresh install; "none" = panel at the root.
# NOVA_PANEL_PORT=... extra HTTPS port that serves only the panel (optional).
# NOVA_NO_PROMPT=1 never ask questions (use env values / defaults).
#
# Managed-node (fleet) mode: install a box that is driven from a main panel,
# with no panel of its own. The main panel's "Add node" button prints the exact
# one-liner, which sets:
# NOVA_JOIN_URL=... the main panel's address
# NOVA_JOIN_TOKEN=... a one-time join token from that panel
# The node installs, registers itself with the main panel, and then locks its
# own panel (a stub page, no sign-in). Everything else installs the same way.
# =============================================================================
set -euo pipefail
# The public release channel. A preview or mirror copy of this script overrides
# the two URLs below; either way the node is pinned to whatever it installed
# from, and PUBLIC_TARBALL_URL is what "is this the public build" is measured
# against, so it must keep pointing at the real release.
PUBLIC_TARBALL_URL="https://raw.githubusercontent.com/IRNova/Nova-Server/main/nova-node-agent.tar.gz"
TARBALL_URL="${NOVA_TARBALL_URL:-$PUBLIC_TARBALL_URL}"
PUBLIC_VERSION_URL="https://raw.githubusercontent.com/IRNova/Nova-Server/main/nova-node-agent.version"
VERSION_URL="${NOVA_VERSION_URL:-$PUBLIC_VERSION_URL}"
# Set to 1 only in a published preview/mirror copy of this script. Env cannot
# turn it on, so a pasted NOVA_TARBALL_URL= cannot make itself permanent.
PERSIST_CHANNEL=0
AGENT_DIR=/opt/nova-node-agent
CERT_DIR=/etc/nova
# Verify a downloaded archive against a pinned SHA-256. Fails CLOSED: a host
# with no sha256 tool refuses rather than installing something unchecked, which
# is the rule the self-updater learned the hard way in 1.34.1.
sha_is() { # file expected
_got=""
if command -v sha256sum >/dev/null 2>&1; then _got="$(sha256sum "$1" | awk '{print $1}')"
elif command -v shasum >/dev/null 2>&1; then _got="$(shasum -a 256 "$1" | awk '{print $1}')"
else return 1; fi
[ "$_got" = "$2" ]
}
DB_DIR=/var/lib/nova
c_grn=$'\033[0;32m'; c_red=$'\033[0;31m'; c_yel=$'\033[1;33m'; c_cyn=$'\033[0;36m'; c_bld=$'\033[1m'; c_rst=$'\033[0m'
say() { printf '%s\n' "${c_cyn}==>${c_rst} $*"; }
ok() { printf '%s\n' "${c_grn}OK${c_rst} $*"; }
warn() { printf '%s\n' "${c_yel}!!${c_rst} $*"; }
die() { printf '%s\n' "${c_red}xx${c_rst} $*" >&2; exit 1; }
mark_owned() {
install -d -m 700 "$DB_DIR/.owned"
: > "$DB_DIR/.owned/$1"
chmod 600 "$DB_DIR/.owned/$1"
}
[ "$(id -u)" = 0 ] || die "Please run as root (sudo)."
# Is another package manager holding apt's locks?
#
# A freshly created cloud VPS runs its own apt on boot (cloud-init, and
# unattended-upgrades right behind it). An installer that starts during that
# window does not get a clean error: apt-get update fails to take the lock, the
# package list stays stale, and the next `apt-get install` quietly resolves
# against whatever the distro already knew about. That is not hypothetical, it
# is how a node ended up running Ubuntu's Node 18 when this script asked for 24.
#
# Ask whether the LOCK is held, not whether something is named like apt.
#
# The first version of this asked `pgrep -f unattended-upgrade`, and that is
# true on an idle Ubuntu box forever: unattended-upgrades.service runs
# `/usr/share/unattended-upgrades/unattended-upgrade-shutdown --wait-for-signal`
# for the whole uptime of the machine, waiting for a shutdown that has not been
# requested. It is not an upgrade. So every install waited the full five
# minutes, twice, printed "still busy", then ran apt perfectly well, and
# rebooting never helped because the service comes straight back. Reported from
# a fresh VPS and reproduced in a container.
_uu_upgrading() {
# An unattended upgrade that is genuinely running, never the shutdown helper.
for _pid in $(pgrep -f 'unattended-upgrade' 2>/dev/null); do
case "$(tr '\0' ' ' < "/proc/$_pid/cmdline" 2>/dev/null)" in
*unattended-upgrade-shutdown*) ;;
*) return 0 ;;
esac
done
return 1
}
apt_busy() {
# The authoritative answer when we can get it: dpkg and apt hold these files
# open for exactly as long as they hold the lock, so this cannot be fooled by
# a process that merely has a similar name.
if command -v fuser >/dev/null 2>&1; then
for _l in /var/lib/dpkg/lock-frontend /var/lib/dpkg/lock \
/var/cache/apt/archives/lock /var/lib/apt/lists/lock; do
[ -e "$_l" ] || continue
if fuser "$_l" >/dev/null 2>&1; then return 0; fi
done
return 1
fi
# Before the prerequisites are installed there may be no fuser, so fall back
# to process names, with the shutdown helper excluded.
for _p in apt apt-get dpkg; do
if pgrep -x "$_p" >/dev/null 2>&1; then return 0; fi
done
_uu_upgrading
}
# Every fetch from a third party is bounded, because an install that stops with
# no output is indistinguishable from one that crashed.
#
# `curl -fsSL` with no timeout waits essentially forever. A fresh VPS reported
# an install that stopped dead after sing-box and never reached AmneziaWG: the
# next thing to run was an unbounded download of grpcurl, and it hung. Every
# other optional component had the same shape, so it would have hung at the
# next one anyway.
#
# --connect-timeout is the one that matters most: a host whose IPv6 route is
# broken stalls on connect rather than failing, which is the common way this
# presents on a cloud VPS. --max-time bounds the transfer itself, and the
# retries cover a flaky link without turning a hang into a longer hang. Callers
# that move more bytes pass their own --max-time; curl takes the last one given.
dl() {
curl -fsSL --connect-timeout 15 --max-time 180 \
--retry 2 --retry-delay 3 --retry-connrefused "$@"
}
# Wait for it, rather than racing it and installing the wrong thing. Bounded, so
# a stuck package manager delays the install instead of hanging it forever, and
# announced, so five silent minutes do not look like a frozen script.
wait_for_apt() {
if ! apt_busy; then return 0; fi
say "Waiting for another package manager to finish (a freshly booted VPS updates itself on boot)"
_waited=0
while apt_busy; do
if [ "$_waited" -ge 300 ]; then
warn "Package manager still busy after 5 minutes; continuing anyway."
return 0
fi
sleep 5
_waited=$((_waited + 5))
done
ok "package manager free after ${_waited}s"
}
# ---- setup questions ---------------------------------------------------------
# Asked up front so the rest of the install runs unattended. Reads /dev/tty so
# both `bash <(curl ...)` and `curl ... | bash` forms work; with no terminal (or
# NOVA_NO_PROMPT=1) the env values / defaults are used silently.
ask() { # prompt -> REPLY
REPLY=""
[ "${NOVA_NO_PROMPT:-0}" = 1 ] && return 0
# Whether /dev/tty can actually be OPENED, not whether the device node is
# readable. A process with no controlling terminal (the installer bot's ssh,
# or `ssh host 'bash <(curl ...)'`) still passes `[ -r /dev/tty ]`, because
# that only checks permissions on the node, and then open() fails with ENXIO
# and bash prints "/dev/tty: No such device or address" for every prompt. The
# install was always fine; it just looked like it had errored three times.
{ : > /dev/tty; } 2>/dev/null || return 0
printf '%s' "${c_cyn}?${c_rst} $1 " > /dev/tty 2>/dev/null || return 0
IFS= read -r REPLY < /dev/tty || REPLY=""
}
# Managed-node mode when the main panel handed us a join URL + token. The node
# has no panel of its own, so the panel path/port questions do not apply; a
# domain is still honored (a node with a real cert is nicer for the parent).
NODE_MODE=0
if [ -n "${NOVA_JOIN_URL:-}" ] && [ -n "${NOVA_JOIN_TOKEN:-}" ]; then
NODE_MODE=1
NOVA_NO_PROMPT=1
say "Managed-node install: this box will be controlled from ${NOVA_JOIN_URL}"
fi
if [ "$NODE_MODE" = 0 ] && [ -z "${NOVA_DOMAIN:-}" ]; then
ask "Do you have a domain pointing at this server? It gets a trusted (Let's Encrypt) certificate automatically. [y/N]"
case "$REPLY" in
[yY]*)
ask "Domain (e.g. node.example.com):"
NOVA_DOMAIN="$(printf '%s' "$REPLY" | tr -d '[:space:]')"
if [ -n "$NOVA_DOMAIN" ]; then
ask "Email for certificate expiry notices (optional, Enter to skip):"
NOVA_DOMAIN_EMAIL="$(printf '%s' "$REPLY" | tr -d '[:space:]')"
fi
;;
esac
fi
if [ "$NODE_MODE" = 0 ] && [ -z "${NOVA_PANEL_PATH:-}" ]; then
ask "Secret panel path: hides the panel behind https://<server>/<path>/ so scanners see nothing. [Enter = auto-generate / type your own / \"none\" = panel at the root]"
case "$(printf '%s' "$REPLY" | tr -d '[:space:]')" in
"") NOVA_PANEL_PATH="" ;; # stays empty -> auto-generated below on a fresh install
none|no) NOVA_PANEL_PATH="none" ;;
*) NOVA_PANEL_PATH="$(printf '%s' "$REPLY" | tr -d '[:space:]/')" ;;
esac
fi
if [ -n "${NOVA_PANEL_PATH:-}" ] && [ "$NOVA_PANEL_PATH" != "none" ] \
&& ! printf '%s' "$NOVA_PANEL_PATH" | grep -qE '^[A-Za-z0-9_-]{3,64}$'; then
warn "Panel path must be 3-64 letters/digits/-/_ ; a random one will be generated instead."
NOVA_PANEL_PATH=""
fi
if [ "$NODE_MODE" = 0 ] && [ -z "${NOVA_PANEL_PORT:-}" ]; then
ask "Extra panel port (the panel also gets its own HTTPS port, e.g. 2053). [Enter = none, panel stays on 443]"
NOVA_PANEL_PORT="$(printf '%s' "$REPLY" | tr -d '[:space:]')"
fi
if [ -n "${NOVA_PANEL_PORT:-}" ] && ! printf '%s' "$NOVA_PANEL_PORT" | grep -qE '^[0-9]{1,5}$'; then
warn "Panel port must be a number; skipping the extra port."
NOVA_PANEL_PORT=""
fi
# Front port: Nova's panel + proxy front normally binds :443. If :443 is already
# taken by another service on this box, offer an alternate so the WHOLE front (and
# every generated panel/subscription link) uses that port instead. Only relevant
# for a full panel install; a managed node keeps :443.
FRONT_PORT="${NOVA_FRONT_PORT:-443}"
if [ "$NODE_MODE" = 0 ] && [ "$FRONT_PORT" = 443 ] && command -v ss >/dev/null 2>&1; then
if ss -tlnH "sport = :443" 2>/dev/null | grep -q . && ! ss -tlnpH "sport = :443" 2>/dev/null | grep -qi xray; then
ask ":443 is already used by another service on this server. Enter an alternate HTTPS port for the Nova panel + proxy (e.g. 4430) so the whole front and its links use it. [Enter = keep 443]"
ALT="$(printf '%s' "$REPLY" | tr -d '[:space:]')"
if printf '%s' "$ALT" | grep -qE '^[0-9]{1,5}$' && [ "$ALT" -ge 1 ] && [ "$ALT" -le 65535 ] && [ "$ALT" != 443 ]; then
FRONT_PORT="$ALT"
warn "Nova will serve its front on :$FRONT_PORT. Make sure that port is open to the internet."
fi
fi
fi
# ---- ports -------------------------------------------------------------------
# What this install will bind, said out loud before it binds anything.
#
# Reported because an operator running Nova alongside other services asked for
# it: they had moved a dockerised Nova to :4443 and had no way to know what else
# Nova was going to take. Nova asks about :443 and the extra panel port above;
# everything else was silent until something failed to start.
#
# The two loopback ports are the ones this can actually FIX without asking. They
# are internal (the agent's own HTTP port and xray's gRPC API), nothing outside
# the box reaches them, and their numbers are written to agent.env, so moving
# one off a collision costs nothing and needs no decision from anybody. A public
# port is the opposite: moving it changes every link this node hands out, so
# those are reported and left alone.
port_busy() {
command -v ss >/dev/null 2>&1 || return 1
ss -tlnH "sport = :$1" 2>/dev/null | grep -q .
}
# The first free port at or after $1, giving up after 40 tries.
free_port_from() {
_p="$1"; _n=0
while [ "$_n" -lt 40 ]; do
port_busy "$_p" || { printf '%s' "$_p"; return 0; }
_p=$((_p + 1)); _n=$((_n + 1))
done
printf '%s' "$1"
}
# An existing install KEEPS the port it already has, and that is the whole rule.
#
# Nova's own agent is listening on 8088 during a re-run, so probing for a free
# port finds its own process and walks to 8089. The agent would move; xray's
# front, which falls back to the agent by number, would not, and the panel and
# every /sub would answer on :443 with nothing behind them. The readiness poll
# below would still pass, because it reads the new port, so the installer would
# print a clean success over a node whose public surface just died. Every
# further re-run would walk it again.
#
# The :443 probe twenty lines up already excludes Nova's own process for exactly
# this reason. This does it by remembering rather than by sniffing, because the
# answer is written down: a port only gets chosen on a node that has none.
# Read only when the file is THERE, and never let its absence end the install.
#
# `set -o pipefail` is on. On a fresh machine agent.env does not exist, so sed
# exits non-zero, pipefail hands that status to the whole pipeline, the
# assignment inherits it, and `set -e` kills the script. Silently, because the
# 2>/dev/null that hides sed's complaint hides the only clue too: the installer
# asked its questions and returned to the prompt with no output at all.
#
# That is every FIRST install, which is the one case the re-run logic above was
# never exercised against. `|| true` on each read, and an existence check, so a
# missing or unreadable file simply means "no previous port", which is exactly
# what a fresh machine has.
prev_env_val() {
[ -r "$CERT_DIR/agent.env" ] || return 0
sed -n "$1" "$CERT_DIR/agent.env" 2>/dev/null | tail -n 1 || true
}
PREV_AGENT_PORT="$(prev_env_val 's/^NOVA_PORT=//p' || true)"
case "$PREV_AGENT_PORT" in ''|*[!0-9]*) PREV_AGENT_PORT="" ;; esac
PREV_API_PORT="$(prev_env_val 's/^NOVA_XRAY_API=127\.0\.0\.1://p' || true)"
case "$PREV_API_PORT" in ''|*[!0-9]*) PREV_API_PORT="" ;; esac
NOVA_AGENT_PORT="${PREV_AGENT_PORT:-$(free_port_from 8088)}"
NOVA_XRAY_API_PORT="${PREV_API_PORT:-$(free_port_from 10085)}"
[ "$NOVA_AGENT_PORT" != 8088 ] \
&& warn "127.0.0.1:8088 is in use, so the Nova agent will listen on 127.0.0.1:$NOVA_AGENT_PORT instead."
[ "$NOVA_XRAY_API_PORT" != 10085 ] \
&& warn "127.0.0.1:10085 is in use, so the xray API will listen on 127.0.0.1:$NOVA_XRAY_API_PORT instead."
# Plain stdout, like say/ok/warn. This is a REPORT, not a prompt.
#
# It was written to /dev/tty, copied from `ask`, which needs a terminal because
# it reads an answer. Nothing here reads anything. Over ssh there is no
# controlling terminal, so every line printed
# `/dev/tty: No such device or address` instead of the report, and the
# `2>/dev/null || true` could not stop it: bash reports a failed redirection
# itself, before the command runs, so the command's own stderr redirect never
# sees it. That made the ports report unreadable on exactly the path most
# updates take, including the installer bot.
say "Ports this install will use"
printf ' %-22s %s\n' "${FRONT_PORT:-443}/tcp" "panel and proxy front (public)"
[ -n "${NOVA_PANEL_PORT:-}" ] \
&& printf ' %-22s %s\n' "$NOVA_PANEL_PORT/tcp" "extra panel port (public)"
printf ' %-22s %s\n' "127.0.0.1:$NOVA_AGENT_PORT" "agent, loopback only"
printf ' %-22s %s\n' "127.0.0.1:$NOVA_XRAY_API_PORT" "xray API, loopback only"
printf ' %s\n' "Protocol ports (mieru, MTProto, Hysteria2, AmneziaWG, Tor, Psiphon) are chosen in the panel later, and the panel refuses a port another Nova service already holds."
# A public port already in use is reported and NOT changed. Nova cannot pick a
# different one on the operator's behalf here: the front port is written into
# every subscription and share link this node produces, so moving it silently
# would hand customers links to a port the operator never agreed to.
for _pp in "${FRONT_PORT:-443}" ${NOVA_PANEL_PORT:-}; do
# Nova's own xray holds this on every re-run; warning about it would train an
# operator to ignore the one message that matters. Same exclusion the :443
# prompt above uses.
if port_busy "$_pp" && ! ss -tlnpH "sport = :$_pp" 2>/dev/null | grep -qi xray; then
warn "Port $_pp is already in use by another service. Nova will try to bind it anyway and may fail to start; free it, or rerun with NOVA_FRONT_PORT set to a port that is free."
fi
done
# ---- preflight ---------------------------------------------------------------
wait_for_apt
say "Installing prerequisites"
export DEBIAN_FRONTEND=noninteractive
if command -v apt-get >/dev/null 2>&1; then
apt-get update -y >/dev/null 2>&1 || true
# psmisc brings fuser, which is what lets apt_busy above ask whether the lock
# is actually held rather than guessing from process names.
apt-get install -y curl unzip ca-certificates openssl tar psmisc >/dev/null 2>&1 \
|| apt-get install -y curl unzip ca-certificates openssl tar >/dev/null 2>&1 \
|| die "Could not install prerequisites via apt-get."
else
die "This installer targets Debian/Ubuntu (apt-get not found)."
fi
# ---- low-memory boxes: add swap rather than fail halfway ---------------------
# Reported from the field: "on 512 MB RAM the install is simply not possible,
# but with swap I ran it for an hour with no problem". That operator was right,
# and they had to work it out themselves because the installer said nothing.
#
# What actually runs out: `apt-get install nodejs` and the Node runtime itself
# are the peaks, so the failure lands in the middle of the install with an
# out-of-memory message from apt rather than anything mentioning memory. A
# little swap carries the box through, and it stays useful afterwards because
# the agent, xray and sing-box all sit resident.
#
# Deliberately conservative: swap is only ADDED when there is none at all, the
# file is sized once and never resized, and any failure is a warning rather
# than a stop, because plenty of 512 MB boxes have swap already or run a
# provider image that forbids swapfiles. Nova never removes an operator's own
# swap, and the ownership marker means the uninstaller only removes a file this
# script created.
mem_mb="$(awk '/^MemTotal:/ {printf "%d", $2/1024}' /proc/meminfo 2>/dev/null || echo 0)"
swap_mb="$(awk '/^SwapTotal:/ {printf "%d", $2/1024}' /proc/meminfo 2>/dev/null || echo 0)"
if [ "${mem_mb:-0}" -gt 0 ] && [ "$mem_mb" -lt 1024 ] && [ "${swap_mb:-0}" -lt 256 ]; then
say "Only ${mem_mb} MB of RAM and no swap; adding a 1 GB swap file"
if [ ! -e /swapfile ] \
&& { fallocate -l 1G /swapfile 2>/dev/null || dd if=/dev/zero of=/swapfile bs=1M count=1024 status=none 2>/dev/null; } \
&& chmod 600 /swapfile && mkswap /swapfile >/dev/null 2>&1 && swapon /swapfile 2>/dev/null; then
grep -q '^/swapfile ' /etc/fstab 2>/dev/null || printf '/swapfile none swap sw 0 0\n' >> /etc/fstab
mark_owned swapfile
ok "swap enabled (1 GB), so the install and the panel have room"
else
# Clean up a half-made swap file. Some hosts allow the file but refuse
# swapon (containers, and providers that block it), and leaving a stray 1 GB
# behind takes disk from the very box that had none to spare. Only ever the
# file this branch just created, never one that already existed.
if [ ! -f "$DB_DIR/.owned/swapfile" ] && [ -e /swapfile ] && ! swapon --show 2>/dev/null | grep -q '^/swapfile '; then
rm -f /swapfile
fi
warn "Could not add swap. On ${mem_mb} MB the install may fail; add swap yourself and re-run."
fi
fi
# ---- Node 24 -----------------------------------------------------------------
need_node=1
if command -v node >/dev/null 2>&1; then
maj="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)"
[ "${maj:-0}" -ge 24 ] && need_node=0
fi
if [ "$need_node" = 1 ]; then
say "Installing Node.js 24"
# The NodeSource script runs its own apt-get update, which we cannot pass
# options to, so the lock has to be clear before it starts rather than during.
wait_for_apt
dl https://deb.nodesource.com/setup_24.x | bash - >/dev/null 2>&1 \
|| die "Could not add the NodeSource repository."
apt-get install -y nodejs >/dev/null 2>&1 || die "Could not install Node.js."
fi
# Check what we ACTUALLY got, not that some node exists.
#
# Both commands above can succeed and still leave Node 18 installed: if the
# package list was not refreshed, `apt-get install nodejs` resolves against the
# distro's own nodejs and exits 0. This line used to be `ok "node $(node -v)"`,
# which printed a green OK for v18.19.1 and let the install continue. The agent
# then crash-looped forever on `No such built-in module: node:sqlite` (added in
# Node 22, and src/kv/sqlite.mjs is the KV store, so nothing works without it),
# restarting every two seconds with an error no operator can be expected to map
# back to a package resolution that happened ten minutes earlier.
#
# The version test above already knows what "good enough" means. Applying it
# only BEFORE the install and never after is what let the wrong version through.
node_maj="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)"
case "$node_maj" in ''|*[!0-9]*) node_maj=0 ;; esac
if [ "$node_maj" -lt 24 ]; then
die "Node 24+ is required, but $(node -v 2>/dev/null || echo 'no node') is installed.
The Nova agent uses node:sqlite, which does not exist before Node 22, so it
cannot start on this version.
This usually means another package manager held apt's lock while Node was
installed, so the NodeSource package list was never picked up. Wait for it to
finish, then re-run this installer:
pgrep -a apt-get unattended-upgrade
bash <(curl -fsSL https://raw.githubusercontent.com/IRNova/Nova-Server/main/nova-node.sh)"
fi
ok "node $(node -v)"
# Build JSON request bodies without shell interpolation. Arguments are sent to
# Node over a NUL-delimited stdin stream, so quotes, backslashes and newlines in
# operator input stay data and never alter the JSON structure.
json_body() { # key type value ... ; type = string|boolean|number|json
printf '%s\0' "$@" | node -e '
const fs = require("node:fs");
const parts = fs.readFileSync(0).toString("utf8").split("\0");
if (parts.at(-1) === "") parts.pop();
if (parts.length % 3) process.exit(2);
const out = {};
for (let i = 0; i < parts.length; i += 3) {
const [key, type, raw] = parts.slice(i, i + 3);
if (type === "string") out[key] = raw;
else if (type === "boolean") out[key] = raw === "true";
else if (type === "number") {
const n = Number(raw);
if (!Number.isFinite(n)) process.exit(2);
out[key] = n;
} else if (type === "json") out[key] = JSON.parse(raw);
else process.exit(2);
}
process.stdout.write(JSON.stringify(out));
'
}
# ---- xray-core ---------------------------------------------------------------
# Fetch XTLS's installer to a file, CHECK it, then run it.
#
# It used to be `bash -c "$(curl -L https://github.com/.../raw/main/...)"`, and
# that URL began returning a 404 HTML page. curl -L without -f exits 0 on a 404,
# so the page was handed to bash, which died on "<!DOCTYPE html>", and the only
# thing anybody saw was "xray-core install failed" with the real error swallowed
# by >/dev/null. Every new install failed at that line.
#
# Three changes, each of which would have caught it on its own: the canonical
# raw.githubusercontent.com host (which serves it correctly), `-f` so an error
# response is a failure rather than a payload, and a look at what arrived before
# executing it. Piping an unverified download straight into a root shell is
# worth not doing regardless of who is serving it.
xray_installer() {
local out="$1" u
for u in \
"https://raw.githubusercontent.com/XTLS/Xray-install/main/install-release.sh" \
"https://github.com/XTLS/Xray-install/raw/main/install-release.sh"
do
if curl -fsSL --max-time 60 -o "$out" "$u" 2>/dev/null \
&& [ -s "$out" ] && head -c 2 "$out" | grep -q '#!' ; then
return 0
fi
done
return 1
}
# Which version to install, resolved HERE rather than by XTLS's script.
#
# Their script asks GitHub for the full release LIST, and that endpoint began
# answering `200 []` for Xray-core -- an empty array, not an error -- so it
# concluded there were no releases and stopped with "Failed to get the latest
# release version". Every fresh install died there. The `/releases/latest`
# endpoint answers correctly throughout, so Nova asks that one and hands the
# answer over with --version, which skips the list entirely.
#
# Empty is not fatal: without --version the script does what it always did, so
# if GitHub starts answering again nothing here has to change.
xray_latest_tag() {
curl -fsSL --max-time 30 -H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/XTLS/Xray-core/releases/latest" 2>/dev/null \
| sed 'y/,/\n/' | grep '"tag_name"' | awk -F '"' '{print $4}' | head -1
}
# Install xray from the Nova mirror, without XTLS's script.
#
# Why this exists (IRNova/Nova-Server#21): the upstream path needs
# raw.githubusercontent.com for the script and api.github.com for the version,
# and both are commonly unreachable from exactly the networks Nova is built for.
# An operator in Iran saw `curl: (28) SSL connection timeout` and the install
# stopped dead, with nothing wrong with their server. mtg, mieru and sing-box
# were already mirrored on IRNova/Tools for this reason; xray was the last piece
# still fetched straight from upstream.
#
# This is a fallback, not a replacement. Upstream stays primary, so a normal
# server keeps getting whatever XTLS publishes today; this only runs when that
# path could not be reached at all.
#
# The archive is checked against a pinned hash before anything is unpacked. The
# mirror is a copy we control, which makes it a supply-chain root, so it gets
# the same treatment as every other pinned download in this file.
XRAY_MIRROR_VERSION="26.3.27"
XRAY_MIRROR_BASE="${NOVA_XRAY_MIRROR_URL:-https://github.com/IRNova/Tools/releases/download/xray}"
xray_from_mirror() {
_arch="$(uname -m)"
case "$_arch" in
x86_64|amd64) _asset="Xray-linux-64.zip"
_sum="23cd9af937744d97776ee35ecad4972cf4b2109d1e0fe6be9930467608f7c8ae" ;;
aarch64|arm64) _asset="Xray-linux-arm64-v8a.zip"
_sum="4d30283ae614e3057f730f67cd088a42be6fdf91f8639d82cb69e48cde80413c" ;;
*) return 1 ;;
esac
_d="$(mktemp -d)"
if ! curl -fsSL --max-time 300 --proto-redir '=https' "$XRAY_MIRROR_BASE/$_asset" -o "$_d/x.zip"; then
rm -rf "$_d"; return 1
fi
if ! sha_is "$_d/x.zip" "$_sum"; then
rm -rf "$_d"; return 1
fi
if ! unzip -o -q "$_d/x.zip" -d "$_d" 2>/dev/null || [ ! -f "$_d/xray" ]; then
rm -rf "$_d"; return 1
fi
install -m 0755 "$_d/xray" /usr/local/bin/xray || { rm -rf "$_d"; return 1; }
# The geo files xray refuses to start without when a rule names a code.
for _g in geoip.dat geosite.dat; do
if [ -f "$_d/$_g" ]; then
install -m 0644 "$_d/$_g" "/usr/local/share/xray/$_g" 2>/dev/null || true
fi
done
rm -rf "$_d"
write_xray_unit || return 1
return 0
}
# Write xray's systemd unit, the half that makes the mirror an install rather
# than a file on disk.
#
# Split out of xray_from_mirror so it has a second caller: a node whose binary
# landed and whose unit did not could not be repaired, because the whole xray
# block is guarded on `[ ! -x /usr/local/bin/xray ]` and is skipped forever once
# the binary exists. An interrupted install, a full disk or a read-only
# /etc/systemd/system left the node permanently in the state this exists to
# prevent, and re-running the installer did nothing.
#
# Every step is checked. Inside `if xray_from_mirror; then` the shell has set -e
# suspended for the whole function body, so an unguarded failure here is silent
# and the caller still prints OK.
write_xray_unit() {
install -d -m 755 /usr/local/etc/xray || return 1
[ -f /usr/local/etc/xray/config.json ] || printf '%s\n' '{}' > /usr/local/etc/xray/config.json || return 1
# The log files, not just the directory. xray runs as nobody and cannot create
# a file in a root-owned 0755 directory, and the panel's Log Settings card
# offers these exact paths as its placeholders. Without this, an operator who
# accepts the placeholder gets a config that passes `xray -test` as root and
# then crash-loops as nobody: a working panel and no proxy, one save away.
install -d -m 755 -o 0 -g 0 /var/log/xray || return 1
for _l in access.log error.log; do
[ -f "/var/log/xray/$_l" ] || install -m 600 -o nobody -g nogroup /dev/null "/var/log/xray/$_l" || return 1
done
cat > /etc/systemd/system/xray.service <<'XUNIT' || return 1
[Unit]
Description=Xray Service
Documentation=https://github.com/xtls
After=network.target nss-lookup.target
[Service]
User=nobody
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE
NoNewPrivileges=true
ExecStart=/usr/local/bin/xray run -config /usr/local/etc/xray/config.json
Restart=on-failure
RestartPreventExitStatus=23
LimitNPROC=10000
LimitNOFILE=1000000
[Install]
WantedBy=multi-user.target
XUNIT
systemctl daemon-reload >/dev/null 2>&1 || true
systemctl enable xray >/dev/null 2>&1 || true
return 0
}
if ! command -v xray >/dev/null 2>&1 && [ ! -x /usr/local/bin/xray ]; then
say "Installing xray-core"
mkdir -p /usr/local/share/xray
XRAY_SH="$(mktemp)"
if ! xray_installer "$XRAY_SH"; then
rm -f "$XRAY_SH"
warn "Could not reach GitHub for the xray installer; trying the Nova mirror."
if xray_from_mirror; then
mark_owned xray
ok "xray installed from the Nova mirror (v$XRAY_MIRROR_VERSION)"
else
die "could not install xray-core. Neither GitHub nor the Nova mirror could be reached from this server. Check outbound HTTPS and try again."
fi
XRAY_SH=""
fi
if [ -n "$XRAY_SH" ]; then
XRAY_TAG="$(xray_latest_tag || true)"
# An array, not `set --`: this script has its own positional parameters and
# overwriting them here would be a nasty thing to leave behind.
XRAY_ARGS=(install)
case "$XRAY_TAG" in
v[0-9]*) XRAY_ARGS=(install --version "$XRAY_TAG") ;;
esac
# Kept out of the log on success, shown on failure: the message this replaces
# said only "install failed" and hid the reason, which is how a broken URL
# went unnoticed.
XRAY_LOG="$(mktemp)"
if bash "$XRAY_SH" "${XRAY_ARGS[@]}" >"$XRAY_LOG" 2>&1; then
mark_owned xray
rm -f "$XRAY_SH" "$XRAY_LOG"
else
echo "--- xray-core installer output ---" >&2
tail -20 "$XRAY_LOG" >&2
rm -f "$XRAY_SH" "$XRAY_LOG"
# Reached when the script DOWNLOADED but its own run failed, which is the
# more likely shape in a censored network: it needs api.github.com for the
# version and release-assets.githubusercontent.com for the binary, and those
# are blocked independently of raw.githubusercontent.com. Falling through to
# `die` here meant the mirror never fired on the failure it was written for,
# while the release notes told operators it did.
warn "The xray installer ran but could not finish; trying the Nova mirror."
if xray_from_mirror; then
mark_owned xray
ok "xray installed from the Nova mirror (v$XRAY_MIRROR_VERSION)"
else
die "xray-core install failed, and the Nova mirror could not be reached either."
fi
fi
fi
fi
# Repair a node that has the binary and no unit.
#
# The block above only runs when xray is ABSENT, so once the binary exists it is
# skipped forever, unit included. An install interrupted between the two, or one
# where /etc/systemd/system could not be written, left a node that re-running the
# installer could not fix: `systemctl restart xray` kept failing with "Unit not
# found" while every install summary said OK.
#
# Only when nothing else provides one: an upstream-installed node has its unit in
# /lib or /etc already and must not be overwritten with ours.
if [ -x /usr/local/bin/xray ] \
&& [ ! -f /etc/systemd/system/xray.service ] \
&& [ ! -f /lib/systemd/system/xray.service ] \
&& [ ! -f /usr/lib/systemd/system/xray.service ]; then
warn "xray is installed but has no service unit; writing one."
write_xray_unit || warn "Could not write the xray service unit."
fi
# ---- xray runs as nobody, whatever wrote its unit ----------------------------
# Nova writes everything xray reads or writes for nobody:nogroup: the config
# (root:nogroup 0640), the certificate (root:nogroup 0640 under /etc/nova, which
# is 0750) and the access log (nobody:nogroup). The upstream XTLS installer ran
# xray as nobody too, until 2026-10-17, when its unit switched to a dedicated
# `xray` user and group. From that day every fresh install crash-looped on
# "open /usr/local/etc/xray/config.json: permission denied": the summary said
# "xray failed", :443 never came up and no certificate could be issued, because
# the challenge rides on xray's :443. A drop-in pins the user Nova's files are
# written for; it outranks the unit's own User= and survives upstream reruns.
# The agent writes the same drop-in whenever it finds the unit on another user.
xray_unit_user="$(systemctl show xray -p User --value 2>/dev/null || true)"
if [ -n "$xray_unit_user" ] && [ "$xray_unit_user" != "nobody" ]; then
say "Pinning the xray service user to nobody (its unit runs it as $xray_unit_user)"
install -d -m 0755 "${NOVA_SYSTEMD_DIR:-/etc/systemd/system}/xray.service.d"
printf '[Service]\nUser=nobody\nGroup=nogroup\n' > "${NOVA_SYSTEMD_DIR:-/etc/systemd/system}/xray.service.d/10-nova-user.conf"
systemctl daemon-reload >/dev/null 2>&1 || true
if systemctl is-active --quiet xray 2>/dev/null || systemctl is-failed --quiet xray 2>/dev/null; then
systemctl reset-failed xray >/dev/null 2>&1 || true
systemctl restart xray >/dev/null 2>&1 || true
fi
ok "xray service user pinned to nobody"
fi
# Upstream also creates this directory as root:xray 0750. Changing the unit
# user and config file group alone leaves nobody unable to traverse it. Repair
# the standard directory even when a previous run already pinned the unit.
if [ -d /usr/local/etc/xray ]; then
chgrp nogroup /usr/local/etc/xray
chmod 0750 /usr/local/etc/xray
fi
XRAY_BIN="$(command -v xray || echo /usr/local/bin/xray)"
ok "xray $("$XRAY_BIN" version 2>/dev/null | head -1 | awk '{print $2}')"
# Geo databases: the routing engine references geosite:category-ads-all / cn and
# geoip:ir/cn/ru. Refresh with the comprehensive Loyalsoldier set so those codes
# always resolve (a missing code makes xray refuse to start). Best-effort; the
# stock dat that ships with xray stays as the fallback.
GEO_DIR=/usr/local/share/xray
mkdir -p "$GEO_DIR"
for g in geoip geosite; do
dl -o "$GEO_DIR/$g.dat.new" \
"https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/$g.dat" 2>/dev/null \
&& mv "$GEO_DIR/$g.dat.new" "$GEO_DIR/$g.dat" || rm -f "$GEO_DIR/$g.dat.new"
done
# ---- sing-box (Hysteria2 / QUIC gaming path) --------------------------------
# A custom sing-box build (compiled with the v2ray stats API) so the agent can
# meter Hysteria2 per-user, same as xray. Pulled as a single gzipped binary,
# no apt/.deb, so this step is reliable on a fresh box.
HAS_SINGBOX=0
SINGBOX_BIN=/usr/local/bin/sing-box-nova
# sing-box asset by architecture.
#
# One name per architecture, and the amd64 one keeps the name it has always
# had: every installer and agent published before 1.89.1 asks for exactly
# `sing-box-nova.gz` and must keep receiving amd64 bytes. Until 1.89.1 that
# single asset was the only one, and this script installed it on arm64 servers
# too, where the kernel answered every start with "Exec format error". The
# agent then misread that as sing-box rejecting its config, which under its
# strict certificate transaction rolled back every Let's Encrypt and Cloudflare
# certificate on every arm64 node and reported xray as unhealthy. src/singbox/
# update.mjs makes the same choice from process.arch; test/binary-pins.mjs
# holds the two in step.
case "$(uname -m)" in
aarch64|arm64) SINGBOX_ASSET="sing-box-nova-linux-arm64.gz" ;;
x86_64|amd64) SINGBOX_ASSET="sing-box-nova.gz" ;;
*) SINGBOX_ASSET="" ;;
esac
SINGBOX_URL="${NOVA_SINGBOX_URL:-${SINGBOX_ASSET:+https://github.com/IRNova/Tools/releases/download/sing-box/$SINGBOX_ASSET}}"
# end sing-box asset by architecture
SINGBOX_SHA_URL="${NOVA_SINGBOX_SHA_URL:-${SINGBOX_URL:+${SINGBOX_URL}.sha256}}"
# What we installed last time, so a re-run can tell "already current" from
# "never updated". Lives beside the other node state in /etc/nova.
SINGBOX_STAMP="$CERT_DIR/singbox.sha256"
# Until now this was `if [ ! -x "$SINGBOX_BIN" ]`, so a node installed the
# binary once and then kept it forever. Publishing a rebuilt sing-box reached
# fresh installs only, and every existing node stayed on whatever it first got.
# That is the wrong shape for a component whose updates are mostly leak and
# hang fixes: those matter most on the nodes that have been up longest, which
# were exactly the ones that never received them.
#
# The published .sha256 sidecar is the version signal. It is a few dozen bytes,
# so asking for it on every run is free, and comparing it to the stamp says
# whether this node is current without downloading 13MB to find out.
# Normalised before it is validated, not after. sha256sum and shasum both print
# lowercase, and sha_is compares verbatim, so an uppercase sidecar would pass
# this check and then never match anything: on an existing node that is a
# skipped update, but on a fresh one sing-box is never installed at all and
# Hysteria2 goes missing fleet-wide for that release. A CRLF sidecar is worse
# still, because the stray \r fails the hex test and silently clears the
# checksum. runSelfUpdate in src/server.mjs already lowercases for exactly this
# reason; this path did not inherit it.
#
# --proto-redir keeps an https-to-http downgrade from being followed, and
# --max-filesize keeps a hostile body from being read unbounded into awk.
sb_want="$(curl -fsSL --max-time 20 --max-filesize 4096 --proto-redir '=https' "$SINGBOX_SHA_URL" 2>/dev/null \
| tr -d '\r' | awk 'NR==1 {print $1}' | tr 'A-F' 'a-f' || true)"
case "$sb_want" in
*[!0-9a-f]* | "") sb_want="" ;;
*) [ "${#sb_want}" -eq 64 ] || sb_want="" ;;
esac
sb_have=""
if [ -r "$SINGBOX_STAMP" ]; then
sb_have="$(head -n 1 "$SINGBOX_STAMP" 2>/dev/null | awk '{print $1}' || true)"
fi
sb_need=0
sb_replacing=0
if [ ! -x "$SINGBOX_BIN" ]; then
sb_need=1
elif [ -n "$sb_want" ] && [ "$sb_want" != "$sb_have" ]; then
# Only ever REPLACE a working binary against a checksum we actually fetched.
# With no sidecar reachable we cannot tell "newer" from "the release moved or
# the network lied", and swapping a binary that works for unverified bytes is
# a worse outcome than staying a version behind.
sb_need=1
sb_replacing=1
fi
# sing-box must run here
# A binary the kernel will not execute is not sing-box, it is a file. Until
# 1.89.1 every arm64 node carried the amd64 build at this path, the unit below
# was enabled anyway, systemd restarted it into "Exec format error" every three
# seconds for the life of the node, and the agent read the same failure as a
# rejected config. The ELF header says which machine a build is for (byte 18,
# little-endian: 3e00 is x86-64, b700 is aarch64); a file that is not an ELF
# is not judged. Nothing is deleted here: deleting would send the next run down
# the fresh-install path, which installs without a checksum when the sidecar is
# unreachable, and that is a worse place for a root-run binary to come from
# than staying broken. The unit is stopped, and the verified replacement is
# requested through the ordinary update path, which refuses without a checksum.
sb_wrong_arch=0
if [ -x "$SINGBOX_BIN" ] && [ "$(head -c 4 "$SINGBOX_BIN" 2>/dev/null | od -An -tx1 | tr -d ' \n')" = "7f454c46" ]; then
sb_machine="$(od -An -tx1 -j18 -N2 "$SINGBOX_BIN" 2>/dev/null | tr -d ' \n')"
case "$(uname -m):$sb_machine" in
x86_64:3e00|amd64:3e00|aarch64:b700|arm64:b700|*:) ;;
*) sb_wrong_arch=1 ;;
esac
fi
if [ "$sb_wrong_arch" = 1 ]; then
systemctl disable --now sing-box >/dev/null 2>&1 || true
if [ -n "$sb_want" ] && [ -n "$SINGBOX_URL" ]; then
warn "sing-box at $SINGBOX_BIN was built for another CPU architecture and cannot run on $(uname -m); replacing it with the build for this machine."
sb_need=1
sb_replacing=1
else
warn "sing-box at $SINGBOX_BIN was built for another CPU architecture and cannot run on $(uname -m). Its checksum could not be fetched, so it is left in place and Hysteria2 stays unavailable; re-run this installer when github.com is reachable."
sb_need=0
fi
fi
# end sing-box must run here
if [ "$sb_need" = 1 ] && [ -z "$SINGBOX_URL" ]; then
# Nothing to fetch for this machine. Said once, here, rather than as three
# download failures below that would read like a network problem.
warn "No sing-box build is published for $(uname -m); Hysteria2 will be unavailable on this server."
sb_need=0
fi
if [ "$sb_need" = 1 ]; then
if [ "$sb_replacing" = 1 ]; then
say "Updating sing-box (Hysteria2)"
else
say "Installing sing-box (Hysteria2)"
fi
if [ -z "$sb_want" ]; then
# Say it out loud. An unreachable sidecar downgrades a fresh install to no
# verification at all, and for nodes behind Iran's filtering a blocked or
# 404'd request is an ordinary event, not an exotic one.
warn "sing-box checksum unavailable; installing without verifying the download."
fi
for attempt in 1 2 3; do
# Two different temp locations, for two different reasons.
#
# The download goes in a private 0700 directory. /tmp/sb.gz was fixed and
# world-predictable, and the checksum was taken there and then the file
# re-read from the same path, so any local unprivileged account (xray runs
# as nobody) could pass verification and swap the bytes before gunzip read
# them, ending with attacker code at /usr/local/bin/sing-box-nova which the
# unit runs as root. This file's own comment 200 lines down already states
# that rule; this path had not followed it.
#
# The final staging file goes next to the target, because /usr/local/bin is
# often a different filesystem where mv degrades to a copy, and copying onto
# a running executable fails with ETXTBSY. A rename within the directory
# always works, and the running process keeps its old inode until restart.
# That directory is root-owned 0755, so the predictable suffix is not a risk.
sb_dir="$(mktemp -d)"
sb_tmp="$SINGBOX_BIN.new.$$"
if curl -fsSL --max-time 300 --proto-redir '=https' "$SINGBOX_URL" -o "$sb_dir/sb.gz" \
&& { [ -z "$sb_want" ] || sha_is "$sb_dir/sb.gz" "$sb_want"; } \
&& gunzip -f "$sb_dir/sb.gz" \
&& install -m 0755 "$sb_dir/sb" "$sb_tmp" \
&& mv -f "$sb_tmp" "$SINGBOX_BIN"; then
mark_owned sing-box-nova
rm -rf "$sb_dir"
if [ -n "$sb_want" ]; then
# /etc/nova does not exist yet on a fresh install: it is created much
# later, with the agent directories. Without this mkdir the redirection
# below fails, and under `set -euo pipefail` that ends the script, so
# every first install would die here having installed nothing else. That
# is the same shape as the 1.73.0 regression this release exists to fix.
mkdir -p "$CERT_DIR"
printf '%s\n' "$sb_want" > "$SINGBOX_STAMP"
chmod 0600 "$SINGBOX_STAMP"
fi
# A replaced binary is not in use until the service is bounced, and a node
# that silently kept running the old one would make this whole change a
# no-op wearing a success message.
if [ "$sb_replacing" = 1 ] && systemctl is-active --quiet sing-box 2>/dev/null; then
systemctl restart sing-box >/dev/null 2>&1 || warn "sing-box restart failed; the old binary is still running"
fi
break
fi
rm -rf "$sb_dir"
rm -f "$sb_tmp"
if [ "$attempt" = 3 ] && [ "$sb_replacing" = 1 ]; then
# The existing binary is untouched, so this is a skipped update and not a
# broken node. Say which, so the operator does not go hunting.
warn "sing-box update failed after 3 tries; keeping the working binary already installed"
else
warn "sing-box download failed (try $attempt), retrying..."; sleep 3
fi
done
fi
if [ -x "$SINGBOX_BIN" ]; then
mkdir -p /etc/sing-box
# Our own unit: run as root so it can read the origin key, and use our config
# path. The agent writes /etc/sing-box/config.json and bounces this service.
cat > /etc/systemd/system/sing-box.service <<UNIT
[Unit]
Description=Nova sing-box (Hysteria2 UDP)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=$SINGBOX_BIN run -c /etc/sing-box/config.json
Restart=always
RestartSec=3
User=root
[Install]
WantedBy=multi-user.target
UNIT
mark_owned sing-box-service
systemctl daemon-reload
systemctl enable sing-box >/dev/null 2>&1 || true
HAS_SINGBOX=1
# Report WHICH sing-box this is, and prove it can load what Nova writes.
#
# The box may carry an unrelated sing-box from a distro package, and
# `command -v sing-box` finds that one, not this one. Reading a version or a
# config check off the wrong binary is how an afternoon gets spent diagnosing
# a bug that does not exist. So this names the path it actually installed.
#
# The check itself is the useful half: Nova generates a config that needs
# specific build tags (the v2ray stats API for Hysteria2 accounting, quic for
# Hysteria2 itself). A binary missing one of those refuses the whole config,
# the service never starts, and nothing says so, because a stopped unit with
# no journal entry looks exactly like a unit that was never needed. Running
# the check here turns that into a line at install time.
singbox_ver="$("$SINGBOX_BIN" version 2>/dev/null | head -1 || true)"
ok "sing-box installed at $SINGBOX_BIN${singbox_ver:+ ($singbox_ver)}"
if [ -s /etc/sing-box/config.json ]; then
if "$SINGBOX_BIN" check -c /etc/sing-box/config.json >/dev/null 2>&1; then
ok "sing-box accepts the current config"
else
# The command is deliberately truncated with `head -3`, and deliberately
# carries the warning. sing-box decode errors quote the offending JSON,
# and that file is mode 0600 because it holds every user's UUID, Trojan
# and Hysteria2 passwords, and the salamander obfs password. Operators
# routinely paste installer output straight into the support chat.
warn "sing-box will NOT start: it rejects /etc/sing-box/config.json. Hysteria2 stays unavailable until this is fixed."
warn " To see why: $SINGBOX_BIN check -c /etc/sing-box/config.json 2>&1 | head -3"
warn " That output can quote user credentials. Do not paste it into a chat."
fi
fi
# grpcurl: the agent uses it to read sing-box's per-user stats for quota.
#
# Pinned by version AND by SHA-256, like mtg-multi and mita below, and for the
# same reason: the agent runs this binary as root on every metering tick, so
# whoever controls these bytes controls the node. Until 1.86.0 it was the one
# download in this file with no hash, no https-only rule and a fixed /tmp
# path. The hashes are upstream's own, from grpcurl_1.9.4_checksums.txt, and
# were re-derived from the downloaded bytes before being written here.
if ! command -v grpcurl >/dev/null 2>&1; then
garch="$(uname -m)"
case "$garch" in
aarch64|arm64) garch=arm64; GRPCURL_SHA256="ad66227d90631da5428b4a5ccf28d63846f0f15649d8b2367df044a59edbb617" ;;
x86_64|amd64) garch=x86_64; GRPCURL_SHA256="97e13d58d2733a0e62cd2571d1d5f0c02823f0d25282f08bddedf1ad9c5d1736" ;;
*) garch=""; GRPCURL_SHA256="" ;;
esac
gtmp="$(mktemp -d)"
if [ -n "$garch" ] \
&& dl --proto '=https' --proto-redir '=https' -o "$gtmp/grpcurl.tgz" "https://github.com/fullstorydev/grpcurl/releases/download/v1.9.4/grpcurl_1.9.4_linux_${garch}.tar.gz" 2>/dev/null \
&& sha_is "$gtmp/grpcurl.tgz" "$GRPCURL_SHA256" \
&& tar -xzf "$gtmp/grpcurl.tgz" -C "$gtmp" grpcurl 2>/dev/null \
&& install -m 0755 "$gtmp/grpcurl" /usr/local/bin/grpcurl 2>/dev/null; then
mark_owned grpcurl
else
warn "grpcurl install failed (or its checksum did not match); Hysteria2 usage will not be metered."
fi
rm -rf "$gtmp"
fi
else
warn "Could not install sing-box; the node will run without Hysteria2."
fi
# ---- AmneziaWG (obfuscated WireGuard server) ---------------------------------
# Optional: lets the node host an AmneziaWG exit (junk packets + magic headers)
# that survives DPI where plain WireGuard/WARP is blocked. Best-effort: a failed
# install just leaves the "AmneziaWG server" panel card showing "not installed".
if ! command -v awg >/dev/null 2>&1; then
say "Installing AmneziaWG (obfuscated WireGuard)"
if add-apt-repository -y ppa:amnezia/ppa >/dev/null 2>&1 && apt-get update >/dev/null 2>&1 \
&& apt-get install -y linux-headers-"$(uname -r)" amneziawg amneziawg-tools >/dev/null 2>&1; then
modprobe amneziawg 2>/dev/null || true
ok "AmneziaWG installed"
else
warn "Could not install AmneziaWG; the node will run without the AmneziaWG server."
fi