-
Notifications
You must be signed in to change notification settings - Fork 55
Expand file tree
/
Copy pathnova-node.sh
More file actions
executable file
·1275 lines (1212 loc) · 64.8 KB
/
Copy pathnova-node.sh
File metadata and controls
executable file
·1275 lines (1212 loc) · 64.8 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
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)."
# ---- 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
# ---- preflight ---------------------------------------------------------------
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
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"
curl -fsSL 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
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
}
if ! command -v xray >/dev/null 2>&1 && [ ! -x /usr/local/bin/xray ]; then
say "Installing xray-core"
XRAY_SH="$(mktemp)"
if ! xray_installer "$XRAY_SH"; then
rm -f "$XRAY_SH"
die "could not download the xray-core installer (checked raw.githubusercontent.com and github.com). Check the server's connectivity to GitHub and try again."
fi
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"
die "xray-core install failed."
fi
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
curl -fsSL -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
SINGBOX_URL="${NOVA_SINGBOX_URL:-https://github.com/IRNova/Tools/releases/download/sing-box/sing-box-nova.gz}"
if [ ! -x "$SINGBOX_BIN" ]; then
say "Installing sing-box (Hysteria2)"
for attempt in 1 2 3; do
if curl -fsSL "$SINGBOX_URL" -o /tmp/sb.gz && gunzip -f /tmp/sb.gz \
&& mv -f /tmp/sb "$SINGBOX_BIN" && chmod +x "$SINGBOX_BIN"; then
mark_owned sing-box-nova
break
fi
warn "sing-box download failed (try $attempt), retrying..."; sleep 3
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
ok "sing-box installed"
# grpcurl: the agent uses it to read sing-box's per-user stats for quota.
if ! command -v grpcurl >/dev/null 2>&1; then
garch="$(uname -m)"; case "$garch" in aarch64) garch=arm64;; x86_64) garch=x86_64;; esac
if curl -fsSL "https://github.com/fullstorydev/grpcurl/releases/download/v1.9.1/grpcurl_1.9.1_linux_${garch}.tar.gz" -o /tmp/grpcurl.tgz 2>/dev/null \
&& tar -xzf /tmp/grpcurl.tgz -C /usr/local/bin grpcurl 2>/dev/null \
&& chmod +x /usr/local/bin/grpcurl 2>/dev/null; then
mark_owned grpcurl
else
warn "grpcurl install failed; Hysteria2 usage will not be metered."
fi
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
fi
# ---- Tor + Psiphon exits (optional egress paths) -----------------------------
# Local SOCKS services the panel's routing rules can send an inbound out through
# (random / DPI-resistant IPs). Best-effort: if a download fails the matching
# "Tor exit" / "Psiphon exit" toggle simply has nothing behind it.
if ! command -v tor >/dev/null 2>&1; then
say "Installing Tor (local SOCKS exit on 9050)"
if DEBIAN_FRONTEND=noninteractive apt-get install -y tor >/dev/null 2>&1 \
&& systemctl enable --now tor >/dev/null 2>&1; then
mark_owned tor
ok "Tor installed"
else
warn "Could not install Tor; the Tor exit will be unavailable."
fi
fi
arch="$(uname -m)"; pbin="psiphon-tunnel-core-x86_64"
[ "$arch" = "aarch64" ] && pbin="psiphon-tunnel-core-arm64"
if [ ! -x "/etc/psiphon/$pbin" ]; then
say "Installing Psiphon (local SOCKS exit on 1080)"
mkdir -p /etc/psiphon
if curl -fsSL -o /etc/psiphon/"$pbin" "https://raw.githubusercontent.com/Psiphon-Labs/psiphon-tunnel-core-binaries/master/linux/$pbin" \
&& curl -fsSL -o /etc/psiphon/psiphon.config "https://raw.githubusercontent.com/IRNova/Nova-Server/main/psiphon.config"; then
chmod +x /etc/psiphon/"$pbin"
mark_owned psiphon
cat > /etc/systemd/system/psiphon.service <<PSI
[Unit]
Description=Psiphon tunnel (local SOCKS exit for Nova)
After=network-online.target
Wants=network-online.target
[Service]
WorkingDirectory=/etc/psiphon
ExecStart=/etc/psiphon/$pbin -config /etc/psiphon/psiphon.config
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
PSI
systemctl daemon-reload && systemctl enable --now psiphon >/dev/null 2>&1 && ok "Psiphon installed" \
|| warn "Psiphon installed but the service did not start."
else
warn "Could not install Psiphon; the Psiphon exit will be unavailable."
fi
fi
# ---- standalone protocol backends (Telegram MTProto proxy, mieru) ------------
# Neither protocol can be served by a core Nova already runs: xray-core dropped
# its mtproto inbound, and the sing-box build here refuses a mieru outbound
# ("unknown outbound type"). Both are therefore separate daemons the agent
# manages, and both are OFF until an operator turns them on in the panel; this
# only puts the binary in place.
#
# Pinned by version AND by SHA-256, and served from IRNova/Tools rather than
# from upstream. A node runs these as a service, so whoever controls the bytes
# controls the node: pulling a publisher's "latest" would let an upstream
# account compromise reach every Nova node with no release of ours in between.
# The mirrored files are byte-identical copies of upstream's releases, so these
# hashes are also upstream's own published checksums.
#
# KEEP IN SYNC WITH src/binaries.mjs. The agent installs the same artifacts on
# demand, because a node that already exists never re-runs this script, and
# test/binary-pins.mjs fails if the two ever disagree.
MTGMULTI_VERSION="1.15.0"
MITA_VERSION="3.35.0"
# `set -e` is on, so the architecture choice is a case statement rather than a
# `[ ... ] && var=...` one-liner: on x86_64 that pattern ends the line with a
# non-zero status and takes the whole installer down.
case "$(uname -m)" in
aarch64|arm64)
barch="arm64"
MTGMULTI_SHA256="9ed776b2052b95e8344896d43fbe01250014f36d7cfdd7f29f7903179bce4bed"
MITA_SHA256="808849223d34ccd9ad86afc0eedef4d6c827133258e96dc3f3794bd17e7d54de"
;;
*)
barch="amd64"
MTGMULTI_SHA256="f1f8763504753fb863a0ddff83eab19c856747289c376275c44b717f1747908e"
MITA_SHA256="a07d5afc5e1353ab346bb3ddbe95c7f960828204be529f4a88d688dfe83e252d"
;;
esac
# 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" ]
}
# A private directory per download. A fixed /tmp/<name> is world-predictable,
# and the window between `tar` and `install` is long enough for an unprivileged
# local account to swap the file, which would put attacker bytes into
# /usr/local/bin as root and defeat the checksum that was just verified.
#
# Cleaned up explicitly rather than with `trap ... EXIT`: a second EXIT trap
# silently REPLACES the first, and 1.34.1 records that exact bug swallowing the
# self-updater's status file. `mktemp -d` is 0700, so a leak on an abort costs
# nothing.
btmp="$(mktemp -d)"
# mtg-multi, not 9seconds/mtg: the fork carries a [secrets] table, a loopback
# management API and per-secret counters, which is what makes a per-customer
# Telegram proxy with its own data limit possible. See docs/mtg-multi-adoption.md.
if [ ! -x /usr/local/bin/mtg-multi ]; then
say "Installing mtg-multi (Telegram MTProto proxy)"
if curl -fsSL --proto '=https' --proto-redir '=https' -o "$btmp/mtg.tar.gz" \
"https://github.com/IRNova/Tools/releases/download/mtgMulti/mtg-multi-${MTGMULTI_VERSION}-linux-${barch}.tar.gz" \
&& sha_is "$btmp/mtg.tar.gz" "$MTGMULTI_SHA256" \
&& tar -xzf "$btmp/mtg.tar.gz" -C "$btmp" --strip-components=1 "mtg-multi-${MTGMULTI_VERSION}-linux-${barch}/mtg-multi" \
&& install -m 0755 "$btmp/mtg-multi" /usr/local/bin/mtg-multi; then
id -u nova-mtg >/dev/null 2>&1 || useradd --system --no-create-home --shell /usr/sbin/nologin --user-group nova-mtg >/dev/null 2>&1 || true
mark_owned mtgMulti
ok "mtg-multi installed (checksum verified)"
else
warn "Could not install mtg-multi; the Telegram proxy will be unavailable."
fi
fi
if [ ! -x /usr/local/bin/mita ]; then
say "Installing mita (mieru server)"
if curl -fsSL --proto '=https' --proto-redir '=https' -o "$btmp/mita.tar.gz" \
"https://github.com/IRNova/Tools/releases/download/mita/mita_${MITA_VERSION}_linux_${barch}.tar.gz" \
&& sha_is "$btmp/mita.tar.gz" "$MITA_SHA256" \
&& tar -xzf "$btmp/mita.tar.gz" -C "$btmp" mita \
&& install -m 0755 "$btmp/mita" /usr/local/bin/mita; then
id -u mita >/dev/null 2>&1 || useradd --system --no-create-home --shell /usr/sbin/nologin --user-group mita >/dev/null 2>&1 || true
install -d -m 750 -o mita -g mita /etc/mita /var/lib/mita /var/run/mita 2>/dev/null || true
mark_owned mita
ok "mita installed (checksum verified)"
else
warn "Could not install mita; mieru will be unavailable."
fi
fi
rm -rf "$btmp"
# ---- tunnel backends (Iran bridge <-> foreign exit) --------------------------
# Selectable reverse-tunnel tools so an Iran box can front a foreign Nova exit
# over a censorship-resistant transport. Best-effort: a missing binary just means
# that backend is greyed out in the panel's Tunnel section. All carry UDP so
# Hysteria2 survives the hop.
tarch="$(uname -m)"; garch="amd64"; [ "$tarch" = "aarch64" ] && garch="arm64"
install -d /usr/local/bin
# Resolve a release asset's download URL by matching a substring against the
# latest release (handles versioned/arch-specific asset names that a static
# /latest/download/ path cannot).
gh_asset() { # repo match
# The trailing "|| true" is load-bearing. This script runs under `set -euo
# pipefail`, and every caller assigns this to a variable: rurl="$(gh_asset ...)".
# A rate-limited or blocked GitHub API makes curl exit non-zero, pipefail makes
# the pipeline non-zero, and the bare assignment then kills the whole install on
# the spot, without printing a word, before any of the "could not install that
# backend, carrying on" branches below can run. In a container that turns into a
# first-boot unit that restarts forever. An empty result is what the callers
# already expect and handle.
curl -fsSL "https://api.github.com/repos/$1/releases/latest" 2>/dev/null \
| grep browser_download_url | grep -i "$2" | head -1 | cut -d'"' -f4 || true
}
# Backhaul (default): widest transport set, connection pooling, self-signed OK.
if ! command -v backhaul >/dev/null 2>&1; then
say "Installing Backhaul tunnel backend"
if curl -fsSL -o /tmp/backhaul.tgz "https://github.com/Musixal/Backhaul/releases/latest/download/backhaul_linux_${garch}.tar.gz" \
&& tar -xzf /tmp/backhaul.tgz -C /usr/local/bin backhaul 2>/dev/null; then
chmod +x /usr/local/bin/backhaul && mark_owned backhaul && ok "Backhaul installed"
else
warn "Could not install Backhaul; that tunnel backend will be unavailable."
fi
fi
# BackPack: Backhaul-class Go reverse tunnel; ships checksum-verified binaries.
if ! command -v backpack >/dev/null 2>&1; then
say "Installing BackPack tunnel backend"
bpurl="$(gh_asset AminMGMT/BackPack "backpack_linux_${garch}.tar.gz")"
bpsum="$(gh_asset AminMGMT/BackPack "SHA256SUMS")"
if [ -n "$bpurl" ] && curl -fsSL -o /tmp/backpack.tgz "$bpurl" && curl -fsSL -o /tmp/backpack.sums "${bpsum:-/dev/null}" 2>/dev/null; then
# Verify against the published SHA256SUMS before trusting the binary.
want="$(grep -i "backpack_linux_${garch}.tar.gz" /tmp/backpack.sums 2>/dev/null | awk '{print $1}' | head -1)"
got="$(sha256sum /tmp/backpack.tgz 2>/dev/null | awk '{print $1}')"
if [ -n "$want" ] && [ "$want" = "$got" ] && tar -xzf /tmp/backpack.tgz -C /usr/local/bin backpack 2>/dev/null; then
chmod +x /usr/local/bin/backpack && mark_owned backpack && ok "BackPack installed (checksum verified)"
else
warn "BackPack checksum mismatch or extract failed; skipping that backend."
fi
else
warn "Could not download BackPack; that tunnel backend will be unavailable."
fi
fi
# rathole: lightweight Rust, TCP+UDP, Noise/TLS. (aarch64 ships musl only.)
if ! command -v rathole >/dev/null 2>&1; then
say "Installing rathole tunnel backend"
rmatch="x86_64-unknown-linux-gnu.zip"; [ "$tarch" = "aarch64" ] && rmatch="aarch64-unknown-linux-musl.zip"
rurl="$(gh_asset rapiz1/rathole "$rmatch")"
if [ -n "$rurl" ] && curl -fsSL -o /tmp/rathole.zip "$rurl" \
&& unzip -o /tmp/rathole.zip -d /usr/local/bin rathole >/dev/null 2>&1; then
chmod +x /usr/local/bin/rathole && mark_owned rathole && ok "rathole installed"
else
warn "Could not install rathole; that tunnel backend will be unavailable."
fi
fi
# wstunnel: tunnels over WebSocket/HTTPS, fronts cleanly behind a CDN. Asset
# names carry the version, so resolve via the API.
if ! command -v wstunnel >/dev/null 2>&1; then
say "Installing wstunnel tunnel backend"
warch="amd64"; [ "$tarch" = "aarch64" ] && warch="arm64"
wurl="$(gh_asset erebe/wstunnel "linux_${warch}.tar.gz")"
if [ -n "$wurl" ] && curl -fsSL "$wurl" -o /tmp/wstunnel.tgz \
&& tar -xzf /tmp/wstunnel.tgz -C /usr/local/bin wstunnel 2>/dev/null; then
chmod +x /usr/local/bin/wstunnel && mark_owned wstunnel && ok "wstunnel installed"
else
warn "Could not install wstunnel; that tunnel backend will be unavailable."
fi
fi
mkdir -p /etc/nova/tunnel && chmod 700 /etc/nova/tunnel
# A convenience shortcut so a locked-out admin can reset their password over SSH:
# nova-passwd 'NewPassword' [--clear-2fa]
cat > /usr/local/bin/nova-passwd <<'NPW'
#!/bin/bash
if [ -r /etc/nova/agent.env ]; then
nova_front_port="$(sed -n 's/^NOVA_FRONT_PORT=//p' /etc/nova/agent.env | tail -n 1)"
case "$nova_front_port" in ''|*[!0-9]*) ;; *) export NOVA_FRONT_PORT="$nova_front_port" ;; esac
fi
exec node /opt/nova-node-agent/bin/reset-password.mjs "$@"
NPW
chmod +x /usr/local/bin/nova-passwd 2>/dev/null || true
# A convenience shortcut to recover or change panel + subscription access from the
# server when the panel is unreachable (bad domain / Cloudflare / SSL change):
# nova-access show the current panel URL
# nova-access --reset revert to a self-signed no-domain node (server IP)
cat > /usr/local/bin/nova-access <<'NAC'
#!/bin/bash
if [ -r /etc/nova/agent.env ]; then
nova_front_port="$(sed -n 's/^NOVA_FRONT_PORT=//p' /etc/nova/agent.env | tail -n 1)"
case "$nova_front_port" in ''|*[!0-9]*) ;; *) export NOVA_FRONT_PORT="$nova_front_port" ;; esac
fi
exec node /opt/nova-node-agent/bin/reset-access.mjs "$@"
NAC
chmod +x /usr/local/bin/nova-access 2>/dev/null || true
# Reclaim a managed node whose parent panel is gone (turns nodeMode off and sets a
# new admin password so you can sign in locally): nova-unlock 'YourPassword'
cat > /usr/local/bin/nova-unlock <<'NUL'
#!/bin/bash
if [ -r /etc/nova/agent.env ]; then
nova_front_port="$(sed -n 's/^NOVA_FRONT_PORT=//p' /etc/nova/agent.env | tail -n 1)"
case "$nova_front_port" in ''|*[!0-9]*) ;; *) export NOVA_FRONT_PORT="$nova_front_port" ;; esac
fi
exec node /opt/nova-node-agent/bin/unlock-node.mjs "$@"
NUL
chmod +x /usr/local/bin/nova-unlock 2>/dev/null || true
# Shortcut to configure + enable the built-in Telegram control bot, e.g.
# nova-tgbot '123456789:AA...' '<admin-chat-id>'
cat > /usr/local/bin/nova-tgbot <<'NTB'
#!/bin/bash
exec node /opt/nova-node-agent/bin/set-tgbot.mjs "$@"
NTB
chmod +x /usr/local/bin/nova-tgbot 2>/dev/null || true
# Shortcut to remove Nova and all its data: nova-uninstall (add --yes to skip
# the prompt). Bundled with the agent, so it works offline after install.
cat > /usr/local/bin/nova-uninstall <<'NUN'
#!/bin/bash
exec bash /opt/nova-node-agent/install/nova-uninstall.sh "$@"
NUN
chmod +x /usr/local/bin/nova-uninstall 2>/dev/null || true
# ---- agent code --------------------------------------------------------------
say "Fetching the Nova node agent"
mkdir -p "$AGENT_DIR" "$DB_DIR" "$CERT_DIR"
# xray writes its access log here and runs as 'nobody'; create it up front owned by
# that user so xray can write it (the agent also self-heals this, belt and braces).
mkdir -p /var/log/nova && chown nobody:nogroup /var/log/nova 2>/dev/null || true
tmp="$(mktemp -d)"
curl -fsSL "$TARBALL_URL" -o "$tmp/agent.tar.gz" || die "Could not download the agent."
# Verify a release checksum when the publisher provides one. Operators may also
# pin it explicitly with NOVA_TARBALL_SHA256 for an out-of-band trust anchor.
expected="${NOVA_TARBALL_SHA256:-}"
if [ -z "$expected" ] && curl -fsSL "${TARBALL_URL}.sha256" -o "$tmp/agent.sha256" 2>/dev/null; then
expected="$(awk 'NR==1 {print $1}' "$tmp/agent.sha256")"
fi
if [ -n "$expected" ]; then
case "$expected" in (*[!0-9A-Fa-f]*|"") die "Published agent checksum is invalid.";; esac
got="$(sha256sum "$tmp/agent.tar.gz" | awk '{print $1}')"
[ "$got" = "$expected" ] || die "Agent checksum verification failed."
fi
if tar -tzf "$tmp/agent.tar.gz" | grep -Eq '(^/|(^|/)\.\.(/|$))'; then
die "Agent archive contains an unsafe path."
fi
# --warning=no-unknown-keyword: hide the harmless "Ignoring unknown extended
# header keyword" lines GNU tar prints when a release tarball was built on macOS
# (Apple provenance xattrs). Extraction succeeds either way; the flag keeps the
# output clean so a successful install never looks like it errored.
tar --warning=no-unknown-keyword -xzf "$tmp/agent.tar.gz" -C "$AGENT_DIR" || die "Could not extract the agent."
rm -rf "$tmp"
ok "agent installed at $AGENT_DIR"
# ---- host + TLS cert ---------------------------------------------------------
# Iran-reachable IP echoes only: ifconfig.me is sanction-blocked from Iran (403).
PUBIP="$(curl -fsSL --max-time 6 https://api.ipify.org 2>/dev/null || curl -fsSL --max-time 6 https://icanhazip.com 2>/dev/null || curl -fsSL --max-time 6 https://ipinfo.io/ip 2>/dev/null || hostname -I | awk '{print $1}')"
# The IP-echo endpoints are network-reachable, so treat their output as untrusted:
# it lands in the network-settings.json body below, and a crafted response could
# otherwise inject JSON. Keep only a bare IPv4/IPv6 literal; blank anything else.
PUBIP="$(printf '%s' "$PUBIP" | tr -d '[:space:]')"
case "$PUBIP" in *[!0-9.:a-fA-F]* | "") PUBIP="" ;; esac
# The node always comes up self-signed on its public IP. If NOVA_DOMAIN is set we
# switch it to a trusted Let's Encrypt cert further down, once the agent is live
# (same code path the app/panel "add a domain" button uses).
HOST="$PUBIP"; INSECURE=true
url_host() {
case "$1" in *:*) printf '[%s]' "$1";; *) printf '%s' "$1";; esac
}
json_error() {
node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{const v=JSON.parse(s);console.log(String(v.error||'').replace(/[\\r\\n]+/g,' ').slice(0,500))}catch{}})" 2>/dev/null || true
}
if [ ! -s "$CERT_DIR/origin.pem" ] || [ ! -s "$CERT_DIR/origin.key" ]; then
say "Generating a TLS certificate for $HOST"
# A SubjectAltName, always. Every current TLS stack rejects a certificate that
# has only a CN, so a cert generated without one still gets SERVED and the
# panel still answers with -k, while every client refuses the subscription
# link. The old fallback here dropped the SAN silently, which is how a node
# ended up in that state with nothing in the output to say so: if $PUBIP was
# empty, `IP:` was invalid, the first command failed, and the second produced
# exactly the certificate no client accepts.
#
# So the fallback keeps a SAN and only changes what goes in it: the detected
# address, else whatever HOST is, as an IP or a name depending on its shape.
case "$HOST" in
*[!0-9.]*) SAN_HOST="DNS:$HOST" ;;
*) SAN_HOST="IP:$HOST" ;;
esac
[ -n "${PUBIP:-}" ] && SAN_TRY="IP:$PUBIP" || SAN_TRY="$SAN_HOST"
openssl req -x509 -newkey rsa:2048 -sha256 -days 3650 -nodes \
-keyout "$CERT_DIR/origin.key" -out "$CERT_DIR/origin.pem" \
-subj "/CN=$HOST" -addext "subjectAltName=$SAN_TRY" >/dev/null 2>&1 \
|| openssl req -x509 -newkey rsa:2048 -sha256 -days 3650 -nodes \
-keyout "$CERT_DIR/origin.key" -out "$CERT_DIR/origin.pem" \
-subj "/CN=$HOST" -addext "subjectAltName=$SAN_HOST" >/dev/null 2>&1
# And say so if it still has no SAN, rather than leaving the operator to find
# out from a customer whose client will not import the link.
if ! openssl x509 -in "$CERT_DIR/origin.pem" -noout -ext subjectAltName >/dev/null 2>&1; then
warn "the certificate has no SubjectAltName; clients will refuse it. Reissue it from Settings > Domain."
fi
fi
# xray runs as user 'nobody' (group nogroup); let it read the key.
chgrp nogroup "$CERT_DIR/origin.pem" "$CERT_DIR/origin.key" 2>/dev/null || true
chmod 640 "$CERT_DIR/origin.pem" "$CERT_DIR/origin.key"
ok "certificate ready"
# ---- kernel network tuning ---------------------------------------------------
# TCP BBR + fq: better throughput on lossy, high-latency links (Iran's routes).
# Helps every TCP protocol; Hysteria2 has its own CC. Safe since kernel 4.9. The
# agent also re-applies this on boot per the panel toggle, so it self-heals.
modprobe tcp_bbr 2>/dev/null || true
cat > /etc/sysctl.d/99-nova-net.conf <<'SYSCTL'
# Nova: BBR congestion control for better throughput on lossy/high-latency links.
net.core.default_qdisc=fq
net.ipv4.tcp_congestion_control=bbr
SYSCTL
sysctl -p /etc/sysctl.d/99-nova-net.conf >/dev/null 2>&1 || true
# ---- env + systemd -----------------------------------------------------------
say "Configuring services"
# This is a truncating write, and re-running the installer is the documented way
# to repair or update a node, so anything the operator added here by hand is
# about to be lost. STATS_OPTOUT is a promise ("nothing is ever sent"), and the
# panel tells them to set it here, so silently dropping it on the next repair
# would quietly reverse that promise. Carry it across.
#
# Both writes below are VALIDATED. agent.env is the EnvironmentFile of a root
# service, and the agent reads NOVA_TARBALL_URL and NOVA_VERSION_URL out of that
# environment to decide what the self-updater downloads. A newline inside a value
# appends attacker-chosen KEY=value lines to it, which is why NOVA_VERSION_URL
# and NOVA_TARBALL_URL are already newline-checked further down and why
# docker/entry.sh does the same. STATS_OPTOUT is a boolean, so it is checked as
# one, and the carried-over line must match the shape it was written in. A
# trailing backslash matters too: systemd treats it as a line continuation and it
# would swallow the next setting.
case "${STATS_OPTOUT:-}" in
""|0|1|true|false|yes|no|on|off|TRUE|FALSE|YES|NO|ON|OFF) ;;
*) die "STATS_OPTOUT must be 1 or 0." ;;
esac
KEEP_OPTOUT="$(grep -hE '^STATS_OPTOUT=[A-Za-z0-9]+$' "$CERT_DIR/agent.env" 2>/dev/null | tail -n 1 || true)"
cat > "$CERT_DIR/agent.env" <<ENV
NOVA_DB=$DB_DIR/nova.db
NOVA_PORT=8088
NOVA_HOST=127.0.0.1
NOVA_POLL_MS=30000
NOVA_XRAY_API=127.0.0.1:10085
NOVA_XRAY_BIN=$XRAY_BIN
ENV
[ -n "${STATS_OPTOUT:-}" ] && printf 'STATS_OPTOUT=%s\n' "$STATS_OPTOUT" >> "$CERT_DIR/agent.env"
[ -z "${STATS_OPTOUT:-}" ] && [ -n "$KEEP_OPTOUT" ] && printf '%s\n' "$KEEP_OPTOUT" >> "$CERT_DIR/agent.env"
# Custom front port (443 was taken): the agent fronts xray here and every link uses it.
[ "${FRONT_PORT:-443}" != 443 ] && printf 'NOVA_FRONT_PORT=%s\n' "$FRONT_PORT" >> "$CERT_DIR/agent.env"
# Update channel. Persisting this is what keeps a preview or mirror node from
# replacing itself with the public build on its next check, but it is also the
# node's SUPPLY CHAIN: agent.env is the EnvironmentFile of a root service, the
# self-updater reads the URL from there, and the .sha256 it verifies against
# comes from the same origin, so the checksum proves nothing about a URL someone
# else chose. Three gates, because a one-liner with a variable prepended is a
# shape operators already see and paste.
if [ "$TARBALL_URL" != "$PUBLIC_TARBALL_URL" ] || [ "$VERSION_URL" != "$PUBLIC_VERSION_URL" ]; then
# Every gate below guards what gets WRITTEN, so all of it lives inside the
# PERSIST_CHANNEL branch. Validating earlier would reject installs that
# persist nothing: the Docker image installs from a local file:// archive
# (docker/firstboot.sh), so an https check out here refuses to build or
# recreate any container, and the failure lands after agent.env is written but
# before the systemd unit exists.
if [ "$PERSIST_CHANNEL" = 1 ]; then
# 1. No shell or systemd metacharacters. A newline would append arbitrary
# extra KEY=value lines to a root service's environment; a quote or
# backslash corrupts the file through systemd's own parsing.
case "$TARBALL_URL$VERSION_URL" in
*[!A-Za-z0-9:/._~%?=+-]*) die "Refusing to persist a malformed update URL." ;;
esac
# 2. Plain http would let anyone on the path replace the agent.
case "$TARBALL_URL" in https://*) ;; *) die "A persisted update channel must be https." ;; esac
case "$VERSION_URL" in https://*) ;; *) die "A persisted update channel must be https." ;; esac
# Both halves must come from the same place. A custom tarball with the
# public version marker pins the node to a build that never sees another
# update while the panel reports "up to date"; the reverse restarts the
# agent every 24h without ever converging.
if [ "$TARBALL_URL" != "$PUBLIC_TARBALL_URL" ] && [ "$VERSION_URL" = "$PUBLIC_VERSION_URL" ]; then
die "A persisted channel needs NOVA_VERSION_URL from the same origin as NOVA_TARBALL_URL."
fi
if [ "$VERSION_URL" != "$PUBLIC_VERSION_URL" ] && [ "$TARBALL_URL" = "$PUBLIC_TARBALL_URL" ]; then
die "A persisted channel needs NOVA_TARBALL_URL from the same origin as NOVA_VERSION_URL."
fi
printf 'NOVA_TARBALL_URL=%s\n' "$TARBALL_URL" >> "$CERT_DIR/agent.env"
printf 'NOVA_VERSION_URL=%s\n' "$VERSION_URL" >> "$CERT_DIR/agent.env"
warn "This node tracks a custom update channel, not the public Nova release."
else
# PERSIST_CHANNEL is baked into a rebranded installer by whoever publishes
# that channel, and cannot be set from the environment, so a pasted
# NOVA_TARBALL_URL= is a ONE-TIME install and updates stay on the public
# release. Silent for file:// (the Docker image's normal path).
case "$TARBALL_URL$VERSION_URL" in
*file://*) ;;
*) warn "Installed once from a custom URL. Updates still come from the public release." ;;
esac
fi
fi
NODE_BIN="$(command -v node)"
cat > /etc/systemd/system/nova-agent.service <<UNIT
[Unit]
Description=Nova VPS node agent (admin panel + xray bridge)
After=network-online.target xray.service
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=$AGENT_DIR
# --max-old-space-size caps V8's heap. Without it V8 sizes the heap from total
# system RAM and never returns it, so a busy node's agent settles at a few
# hundred MB and the operator sees "the panel's RAM keeps climbing". 192 MB is
# well above what a large registry needs and small enough that the agent can
# never be what fills a 1 GB VPS. MemoryMax is a wall well above the measured
# working set, and Restart=always means a genuine leak restarts the agent
# instead of taking the node down (xray keeps serving). No MemoryHigh: it would
# throttle a node that never receives the heap flag, and the agent writes the
# same MemoryMax as a drop-in, which is parsed last and must not disagree.
ExecStart=$NODE_BIN --max-old-space-size=192 $AGENT_DIR/bin/nova-agent.mjs
EnvironmentFile=$CERT_DIR/agent.env
Restart=always
RestartSec=2
MemoryMax=768M
User=root
StateDirectory=nova
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable nova-agent >/dev/null 2>&1 || true
# restart (not just enable --now): on a re-run the agent is already active and
# "enable --now" would NOT pick up freshly extracted code. restart starts it when
# stopped and reloads new code when running, so re-running the one-liner also
# updates an existing node.
systemctl restart nova-agent >/dev/null 2>&1 || die "Could not start nova-agent."
# A reinstall can start with an existing secret panel path in the persistent DB.
# Read it locally so installer API calls use the real path instead of receiving
# the deliberate root-path 404 decoy. This is also what lets a recreated Docker
# container restore its image-layer runtime without resetting panel state.
LOCAL_STATE="$(
NOVA_DB="$DB_DIR/nova.db" node -e '
import("/opt/nova-node-agent/src/kv/sqlite.mjs").then(async ({ openKv }) => {
const kv = openKv(process.env.NOVA_DB);
try {
const s = JSON.parse(await kv.get("network-settings.json") || "{}");
const p = String(s.panelPath || "").replace(/^\/+|\/+$/g, "");
const safe = /^[A-Za-z0-9_-]{3,64}$/.test(p) ? p : "";
/* The hostname the panel answers on. The agent serves the decoy page
* to a request whose Host it does not recognise, so polling loopback
* without this is how a perfectly healthy update came to report "The
* agent did not respond in time" on every run.
* NOTE: no apostrophes in here, the whole block is single-quoted. */
const h = String(s.host || "").replace(/:\d+$/, "").trim();
const host = /^[A-Za-z0-9.\-]{1,253}$/.test(h) ? h : "";
/* Whether this node serves a real certificate. Needed because HOST and
* INSECURE below still hold their first-run defaults on a re-install,
* and only a certificate issued in THIS run corrects them. */
const insec = s.insecure === false ? "0" : "1";
process.stdout.write(safe + "|" + (s.nodeMode === true ? "1" : "0") + "|" + host + "|" + insec);
} finally {
kv.close();
}
}).catch(() => process.stdout.write("|0||"));
' 2>/dev/null || true
)"
# Split positionally. NOT with ${VAR##*|}, which takes whatever field happens to
# be LAST: that is how PERSISTED_NODE_MODE came to read the hostname when the
# host field was appended, and appending `insecure` here would have done the
# same to LOCAL_HOST. `read` cannot fail the `set -e` here because the here-doc
# always supplies the trailing newline it wants.
IFS='|' read -r LOCAL_PANEL_PATH PERSISTED_NODE_MODE LOCAL_HOST LOCAL_INSECURE <<LOCAL_STATE_EOF
$LOCAL_STATE
LOCAL_STATE_EOF
# Ask as the panel's own hostname; without it the agent serves the decoy and the
# loop below never sees "configured", however healthy the agent is.
HOSTARG=""
[ -n "$LOCAL_HOST" ] && HOSTARG="-H Host:$LOCAL_HOST"
B=http://127.0.0.1:8088
[ -n "$LOCAL_PANEL_PATH" ] && B="$B/$LOCAL_PANEL_PATH"
# Wait for the agent's local API to answer /install/status with a real JSON body,
# and read whether the panel is already configured. Reading the actual state (not
# just "did any HTTP code come back") is what lets us tell a genuine re-install
# from a momentary hiccup during first boot, so a single transient can never make
# the installer skip configuring a fresh node.
CONFIGURED=""
for i in $(seq 1 40); do
RESP="$(curl -fsS $HOSTARG "$B/install/status" 2>/dev/null || true)"
case "$RESP" in
*'"configured"'*)
case "$RESP" in *'"configured":true'*) CONFIGURED=true;; *) CONFIGURED=false;; esac
break;;
esac
sleep 1
done
# Managed nodes intentionally have no local admin password, so their public
# install status reports configured=false. The persisted nodeMode setting is
# authoritative during an idempotent runtime restore and prevents the installer
# from reopening or replaying first-claim and enrollment.
if [ "$PERSISTED_NODE_MODE" = 1 ]; then
CONFIGURED=true
NODE_MODE=0
NOVA_INSTALL_RESUME=0
NOVA_ADMIN_PASS=""
NOVA_DOMAIN=""
NOVA_DOMAIN_EMAIL=""
NOVA_PANEL_PATH=""
NOVA_PANEL_PORT=""
fi
[ -n "$CONFIGURED" ] || die "The agent did not respond in time. Check: journalctl -u nova-agent -n 50"
ok "agent running"
# ---- configure the panel -----------------------------------------------------
ADMIN_PASS="${NOVA_ADMIN_PASS:-$(openssl rand -base64 12 | tr -dc 'A-Za-z0-9' | head -c 14)}"
UA='User-Agent: Nova/1.0.0 (desktop; sing-box)'
CJ="$(mktemp)"
FRESH_SETUP=0
[ "$CONFIGURED" = false ] && FRESH_SETUP=1
if [ "$CONFIGURED" = true ] && [ "${NOVA_INSTALL_RESUME:-0}" = 1 ]; then
[ -n "${NOVA_ADMIN_PASS:-}" ] || die "Interrupted install recovery requires NOVA_ADMIN_PASS."
FRESH_SETUP=1
warn "Resuming an interrupted first-time setup."
fi
say "Setting up the panel"
if [ "$CONFIGURED" = true ]; then
# Genuinely already configured (a re-install): keep the existing password.
warn "Panel already configured; keeping the existing password."
ADMIN_PASS="(unchanged from a previous install)"
else
# Fresh panel: set the admin password, retrying a few times in case the agent
# is still settling right after its first start. A single failed attempt must
# NOT be mistaken for "already configured" (that would skip host, protocols,
# the panel path and the starter user, leaving the node half-set-up).
SET_OK=0
CLAIM_FILE="${NOVA_INSTALL_CLAIM_FILE:-$CERT_DIR/install-claim}"
CLAIM_TOKEN="$(tr -d '[:space:]' < "$CLAIM_FILE" 2>/dev/null || true)"
if [[ ! "$CLAIM_TOKEN" =~ ^[A-Fa-f0-9]{32}$ ]]; then
die "The agent did not create a valid install claim token. Check: journalctl -u nova-agent -n 50"
fi
PASS_BODY="$(json_body password string "$ADMIN_PASS")"
for i in $(seq 1 10); do
if curl -fsS -c "$CJ" -X POST "$B/install/set" -H "$UA" -H 'Content-Type: application/json' \
-H "X-Nova-Install-Claim: $CLAIM_TOKEN" \
--data-binary @- <<< "$PASS_BODY" >/dev/null 2>&1; then SET_OK=1; break; fi
# If a concurrent run set it in the meantime, stop and keep that password.
if curl -fsS "$B/install/status" 2>/dev/null | grep -q '"configured":true'; then
warn "Panel already configured; keeping the existing password."
ADMIN_PASS="(unchanged from a previous install)"; SET_OK=1; break