dsh-vpn 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/scripts/vpn.sh ADDED
@@ -0,0 +1,644 @@
1
+ #!/usr/bin/env bash
2
+ # dsh-vpn: one-shot sing-box manager.
3
+ #
4
+ # Usage: vpn.sh <install|status|links|uninstall> [options]
5
+ # install [--protocols LIST] [--port NAME=N]... [--sni HOST] [--server ADDR] [--regenerate]
6
+ # [--cert self|ip] [--acme-provider letsencrypt|URL]
7
+ # status
8
+ # links [--server ADDR]
9
+ # uninstall
10
+ #
11
+ # Protocols (comma-separated LIST; default: previous selection, else reality,hy2):
12
+ # reality VLESS + Reality + Vision TCP
13
+ # hy2 Hysteria2 (pinned cert) UDP
14
+ # tuic TUIC v5 UDP
15
+ # anytls AnyTLS TCP
16
+ # ss Shadowsocks-2022 TCP+UDP
17
+ #
18
+ # Certificates for hy2/tuic/anytls (--cert; default: previous choice, else self):
19
+ # self self-signed; hy2 links pin it, tuic/anytls links skip verification
20
+ # ip Let's Encrypt IP-address certificate for --server (6-day "shortlived"
21
+ # profile), issued and renewed by sing-box itself via HTTP-01 on port 80;
22
+ # every link verifies normally
23
+ #
24
+ # Human-readable progress goes to stderr. The last stdout line is always
25
+ # DSH_VPN_RESULT=<json>
26
+ # so the dsh plugin (or any caller) can parse the outcome.
27
+ #
28
+ # Runs as root. Can be used standalone:
29
+ # ssh my-vps 'sudo bash -s -- install --protocols reality,hy2' < vpn.sh
30
+
31
+ set -Eeuo pipefail
32
+
33
+ STATE_DIR=/etc/dsh-vpn
34
+ STATE_FILE=$STATE_DIR/state.env
35
+ CONFIG_FILE=$STATE_DIR/config.json
36
+ CERT_DIR=$STATE_DIR/cert
37
+ BIN=/usr/local/bin/sing-box
38
+ UNIT=dsh-vpn
39
+ UNIT_FILE=/etc/systemd/system/$UNIT.service
40
+ # CN of the self-signed cert shared by the TLS-over-QUIC/TCP protocols.
41
+ CERT_SNI=www.bing.com
42
+ ALL_PROTOCOLS="reality hy2 tuic anytls ss"
43
+ SS_METHOD=2022-blake3-aes-128-gcm
44
+ ACME_DIR=$STATE_DIR/acme
45
+ ACME_TAG=acme-ip
46
+ CERT_WAIT_SECONDS=${CERT_WAIT_SECONDS:-150}
47
+
48
+ log() { printf '[dsh-vpn] %s\n' "$*" >&2; }
49
+
50
+ # Emit the machine-readable result line and exit.
51
+ result() { printf 'DSH_VPN_RESULT=%s\n' "$1"; exit "${2:-0}"; }
52
+
53
+ fail() {
54
+ local code=$1; shift
55
+ log "ERROR: $*"
56
+ result "{\"ok\":false,\"error\":\"$code\",\"message\":\"$(json_escape "$*")\"}" 1
57
+ }
58
+
59
+ # Messages may quote logs: drop ANSI colors and control characters, which are
60
+ # not allowed raw inside a JSON string.
61
+ json_escape() {
62
+ printf '%s' "$1" | sed -e 's/\x1b\[[0-9;]*[A-Za-z]//g' | tr '\n\t\r' ' ' | tr -d '\000-\037\177' \
63
+ | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'
64
+ }
65
+
66
+ # Safety net: any unexpected failure still ends with a result line.
67
+ trap 'fail script_error "line $LINENO: $BASH_COMMAND"' ERR
68
+
69
+ # ---------------------------------------------------------------- preflight
70
+
71
+ require_root() {
72
+ [ "$(id -u)" = 0 ] || fail not_root "must run as root (use sudo)"
73
+ }
74
+
75
+ detect_arch() {
76
+ case "$(uname -m)" in
77
+ x86_64|amd64) echo amd64 ;;
78
+ aarch64|arm64) echo arm64 ;;
79
+ armv7l) echo armv7 ;;
80
+ *) return 1 ;;
81
+ esac
82
+ }
83
+
84
+ install_deps() {
85
+ local missing=()
86
+ for c in curl tar openssl; do command -v "$c" >/dev/null || missing+=("$c"); done
87
+ [ ${#missing[@]} -eq 0 ] && return
88
+ log "installing dependencies: ${missing[*]}"
89
+ if command -v apt-get >/dev/null; then
90
+ DEBIAN_FRONTEND=noninteractive apt-get update -qq >&2
91
+ DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "${missing[@]}" >&2
92
+ elif command -v dnf >/dev/null; then dnf install -y -q "${missing[@]}" >&2
93
+ elif command -v yum >/dev/null; then yum install -y -q "${missing[@]}" >&2
94
+ elif command -v apk >/dev/null; then apk add --no-cache "${missing[@]}" >&2
95
+ else fail no_pkg_manager "cannot install: ${missing[*]}"
96
+ fi
97
+ }
98
+
99
+ require_systemd() {
100
+ # TODO: OpenRC (Alpine) support.
101
+ command -v systemctl >/dev/null && [ -d /run/systemd/system ] \
102
+ || fail no_systemd "systemd is required"
103
+ }
104
+
105
+ port_in_use() { ss -Hlntu 2>/dev/null | awk '{print $5}' | grep -Eq "[:.]$1\$"; }
106
+
107
+ # Sets RANDOM_PORT (no subshell, so the ERR trap can't leak into a captured
108
+ # value). Also avoids ports already handed to another protocol in this run.
109
+ random_port() {
110
+ local p
111
+ for _ in $(seq 1 50); do
112
+ p=$(( (RANDOM << 15 | RANDOM) % 50000 + 10000 ))
113
+ port_in_use "$p" && continue
114
+ case " ${TAKEN_PORTS:-} " in *" $p "*) continue ;; esac
115
+ RANDOM_PORT=$p; return 0
116
+ done
117
+ return 1
118
+ }
119
+
120
+ public_ip() {
121
+ local ip
122
+ for url in https://api.ipify.org https://ifconfig.me https://ipv4.icanhazip.com; do
123
+ ip=$(curl -fsS4 --max-time 5 "$url" 2>/dev/null | tr -d '[:space:]') && [ -n "$ip" ] && { echo "$ip"; return; }
124
+ done
125
+ ip=$(curl -fsS6 --max-time 5 https://api64.ipify.org 2>/dev/null | tr -d '[:space:]') && [ -n "$ip" ] && { echo "[$ip]"; return; }
126
+ return 1
127
+ }
128
+
129
+ has_protocol() { case ",$PROTOCOLS," in *",$1,"*) return 0 ;; *) return 1 ;; esac; }
130
+
131
+ validate_protocols() {
132
+ local p
133
+ [ -n "$1" ] || fail bad_args "no protocols selected"
134
+ for p in ${1//,/ }; do
135
+ case " $ALL_PROTOCOLS " in *" $p "*) ;; *) fail bad_args "unknown protocol: $p" ;; esac
136
+ done
137
+ }
138
+
139
+ # ---------------------------------------------------------------- install steps
140
+
141
+ install_singbox() {
142
+ local arch ver url tmp
143
+ arch=$(detect_arch) || fail unsupported_arch "unsupported architecture: $(uname -m)"
144
+ # Follow the /releases/latest redirect rather than the rate-limited API.
145
+ ver=${SB_VERSION:-}
146
+ if [ -z "$ver" ]; then
147
+ ver=$(curl -fsSLI -o /dev/null -w '%{url_effective}' --max-time 15 \
148
+ https://github.com/SagerNet/sing-box/releases/latest) || fail download_failed "cannot reach github.com"
149
+ ver=${ver##*/tag/}
150
+ fi
151
+ [[ $ver =~ ^v?[0-9]+\.[0-9]+\.[0-9]+ ]] || fail download_failed "cannot resolve latest sing-box version"
152
+ ver=${ver#v}
153
+ if [ -x "$BIN" ] && "$BIN" version 2>/dev/null | grep -q "version $ver\$"; then
154
+ log "sing-box $ver already installed"; return
155
+ fi
156
+ url="https://github.com/SagerNet/sing-box/releases/download/v$ver/sing-box-$ver-linux-$arch.tar.gz"
157
+ log "downloading sing-box $ver ($arch)"
158
+ tmp=$(mktemp -d)
159
+ curl -fsSL --max-time 120 "$url" -o "$tmp/sb.tgz" || fail download_failed "download failed: $url"
160
+ tar -xzf "$tmp/sb.tgz" -C "$tmp"
161
+ install -m 0755 "$tmp/sing-box-$ver-linux-$arch/sing-box" "$BIN"
162
+ rm -rf "$tmp"
163
+ }
164
+
165
+ # Load existing state, fill in anything missing, persist.
166
+ # Credentials and ports are generated for every protocol, selected or not, so
167
+ # enabling one later never rotates the others.
168
+ prepare_state() {
169
+ local regenerate=$1 want_sni=$2
170
+ mkdir -p "$STATE_DIR" "$CERT_DIR"; chmod 700 "$STATE_DIR"
171
+ if [ "$regenerate" != 1 ] && [ -f "$STATE_FILE" ]; then
172
+ local keep_protocols=${PROTOCOLS:-} keep_cert=${CERT_MODE:-} keep_provider=${ACME_PROVIDER:-}
173
+ # shellcheck disable=SC1090
174
+ . "$STATE_FILE"
175
+ [ -n "$keep_protocols" ] && PROTOCOLS=$keep_protocols
176
+ [ -n "$keep_cert" ] && CERT_MODE=$keep_cert
177
+ [ -n "$keep_provider" ] && ACME_PROVIDER=$keep_provider
178
+ fi
179
+ PROTOCOLS=${PROTOCOLS:-reality,hy2}
180
+ CERT_MODE=${CERT_MODE:-self}
181
+ [ -z "${NEW_CERT_IP:-}" ] || CERT_IP=$NEW_CERT_IP
182
+ UUID=${UUID:-$("$BIN" generate uuid)}
183
+ if [ -z "${REALITY_PRIVATE:-}" ]; then
184
+ local kp; kp=$("$BIN" generate reality-keypair)
185
+ REALITY_PRIVATE=$(echo "$kp" | awk '/PrivateKey/{print $2}')
186
+ REALITY_PUBLIC=$(echo "$kp" | awk '/PublicKey/{print $2}')
187
+ fi
188
+ SHORT_ID=${SHORT_ID:-$("$BIN" generate rand 8 --hex)}
189
+ HY2_PASSWORD=${HY2_PASSWORD:-$("$BIN" generate rand 16 --hex)}
190
+ TUIC_PASSWORD=${TUIC_PASSWORD:-$("$BIN" generate rand 16 --hex)}
191
+ ANYTLS_PASSWORD=${ANYTLS_PASSWORD:-$("$BIN" generate rand 16 --hex)}
192
+ SS_KEY=${SS_KEY:-$("$BIN" generate rand 16 --base64)}
193
+ REALITY_SNI=${want_sni:-${REALITY_SNI:-www.microsoft.com}}
194
+
195
+ TAKEN_PORTS=""
196
+ local p var
197
+ for p in $ALL_PROTOCOLS; do
198
+ var="$(echo "$p" | tr '[:lower:]' '[:upper:]')_PORT"
199
+ local wanted; wanted=$(port_override "$p")
200
+ if [ -n "$wanted" ]; then printf -v "$var" '%s' "$wanted"
201
+ elif [ -z "${!var:-}" ]; then
202
+ if ! random_port; then fail no_free_port "could not find a free port"; fi
203
+ printf -v "$var" '%s' "$RANDOM_PORT"
204
+ fi
205
+ TAKEN_PORTS="$TAKEN_PORTS ${!var}"
206
+ done
207
+
208
+ cat >"$STATE_FILE" <<EOF
209
+ PROTOCOLS=$PROTOCOLS
210
+ UUID=$UUID
211
+ REALITY_PRIVATE=$REALITY_PRIVATE
212
+ REALITY_PUBLIC=$REALITY_PUBLIC
213
+ SHORT_ID=$SHORT_ID
214
+ REALITY_SNI=$REALITY_SNI
215
+ REALITY_PORT=$REALITY_PORT
216
+ HY2_PASSWORD=$HY2_PASSWORD
217
+ HY2_PORT=$HY2_PORT
218
+ TUIC_PASSWORD=$TUIC_PASSWORD
219
+ TUIC_PORT=$TUIC_PORT
220
+ ANYTLS_PASSWORD=$ANYTLS_PASSWORD
221
+ ANYTLS_PORT=$ANYTLS_PORT
222
+ SS_KEY=$SS_KEY
223
+ SS_PORT=$SS_PORT
224
+ CERT_MODE=$CERT_MODE
225
+ CERT_IP=${CERT_IP:-}
226
+ ACME_PROVIDER=${ACME_PROVIDER:-letsencrypt}
227
+ EOF
228
+ chmod 600 "$STATE_FILE"
229
+ }
230
+
231
+ # Rewrite just the certificate mode (used when falling back to self-signed).
232
+ save_cert_state() {
233
+ sed -i -e "s|^CERT_MODE=.*|CERT_MODE=$CERT_MODE|" "$STATE_FILE"
234
+ }
235
+
236
+ # --port NAME=N overrides, collected by cmd_install.
237
+ PORT_OVERRIDES=""
238
+ port_override() {
239
+ local kv
240
+ for kv in $PORT_OVERRIDES; do
241
+ [ "${kv%%=*}" = "$1" ] && { echo "${kv#*=}"; return; }
242
+ done
243
+ return 0
244
+ }
245
+
246
+ write_cert() {
247
+ [ -f "$CERT_DIR/cert.pem" ] && return
248
+ log "generating self-signed certificate"
249
+ openssl ecparam -genkey -name prime256v1 -out "$CERT_DIR/key.pem" 2>/dev/null
250
+ openssl req -new -x509 -days 36500 -key "$CERT_DIR/key.pem" -out "$CERT_DIR/cert.pem" \
251
+ -subj "/CN=$CERT_SNI" 2>/dev/null
252
+ chmod 600 "$CERT_DIR/key.pem"
253
+ }
254
+
255
+ cert_tls() {
256
+ if [ "$CERT_MODE" = ip ]; then
257
+ printf '{ "enabled": true, "alpn": [%s], "certificate_provider": "%s" }' "$1" "$ACME_TAG"
258
+ else
259
+ printf '{ "enabled": true, "alpn": [%s], "certificate_path": "%s", "key_path": "%s" }' \
260
+ "$1" "$CERT_DIR/cert.pem" "$CERT_DIR/key.pem"
261
+ fi
262
+ }
263
+
264
+ uses_cert() { has_protocol hy2 || has_protocol tuic || has_protocol anytls; }
265
+
266
+ is_ip_literal() { [[ $1 =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ || $1 == *:* ]]; }
267
+
268
+ # Sets ACME_CERT to the issued certificate for CERT_IP, or empty. Never fails
269
+ # (no subshell), so it is safe under the ERR trap.
270
+ find_acme_cert() {
271
+ ACME_CERT=""
272
+ [ -d "$ACME_DIR" ] || return 0
273
+ local f
274
+ while IFS= read -r f; do
275
+ # openssl prints IPv6 SANs expanded, so only IPv4 is matched exactly.
276
+ if [[ $CERT_IP == *:* ]] || openssl x509 -in "$f" -noout -ext subjectAltName 2>/dev/null | grep -q "IP Address:$CERT_IP\$"; then
277
+ ACME_CERT=$f; return 0
278
+ fi
279
+ done < <(find "$ACME_DIR" -name '*.crt' -type f 2>/dev/null)
280
+ return 0
281
+ }
282
+
283
+ # Anything other than our own sing-box listening on TCP 80 blocks HTTP-01.
284
+ port80_blocker() {
285
+ ss -Hltnp 'sport = :80' 2>/dev/null | grep -v '"sing-box"' | grep -o 'users:(("[^"]*"' | cut -d'"' -f2 | sort -u | tr '\n' ' ' || true
286
+ }
287
+
288
+ wait_for_cert() {
289
+ local waited=0
290
+ log "waiting for the IP certificate (HTTP-01 on port 80)"
291
+ while [ "$waited" -lt "$CERT_WAIT_SECONDS" ]; do
292
+ find_acme_cert
293
+ [ -n "$ACME_CERT" ] && { log "certificate issued"; return 0; }
294
+ sleep 3; waited=$((waited + 3))
295
+ done
296
+ return 1
297
+ }
298
+
299
+ # Keep the node working: switch back to the self-signed certificate and
300
+ # remember why, so install can report it.
301
+ cert_fallback() {
302
+ local why
303
+ why=$(journalctl -u "$UNIT" --since "-10min" --no-pager -o cat 2>/dev/null \
304
+ | grep -iE 'acme|challenge|obtain|certificate' | tail -2 | tr '\n' ' ' | tail -c 800 || true)
305
+ log "IP certificate failed; falling back to self-signed: $why"
306
+ CERT_MODE=self
307
+ save_cert_state
308
+ write_config
309
+ write_unit
310
+ CERT_FAILURE="IP certificate for $CERT_IP was not issued within ${CERT_WAIT_SECONDS}s (is TCP port 80 open to the internet?); fell back to self-signed. $why"
311
+ }
312
+
313
+ # sing-box's ACME provider (certmagic) solves HTTP-01 itself on port 80 and
314
+ # renews in the background; TLS-ALPN is off so port 443 stays untouched.
315
+ cert_providers_json() {
316
+ [ "$CERT_MODE" = ip ] && uses_cert || return 0
317
+ local provider=$ACME_PROVIDER
318
+ [ "$provider" = letsencrypt ] || provider=$(json_escape "$provider")
319
+ printf ' "certificate_providers": [{\n'
320
+ printf ' "type": "acme", "tag": "%s", "domain": ["%s"], "data_directory": "%s",\n' "$ACME_TAG" "$CERT_IP" "$ACME_DIR"
321
+ printf ' "provider": "%s", "profile": "shortlived", "disable_tls_alpn_challenge": true\n' "$provider"
322
+ printf ' }],\n'
323
+ }
324
+
325
+ inbound_json() {
326
+ case "$1" in
327
+ reality) cat <<EOF
328
+ {
329
+ "type": "vless", "tag": "reality", "listen": "::", "listen_port": $REALITY_PORT,
330
+ "users": [{ "uuid": "$UUID", "flow": "xtls-rprx-vision" }],
331
+ "tls": {
332
+ "enabled": true, "server_name": "$REALITY_SNI",
333
+ "reality": {
334
+ "enabled": true,
335
+ "handshake": { "server": "$REALITY_SNI", "server_port": 443 },
336
+ "private_key": "$REALITY_PRIVATE", "short_id": ["$SHORT_ID"]
337
+ }
338
+ }
339
+ }
340
+ EOF
341
+ ;;
342
+ hy2) cat <<EOF
343
+ {
344
+ "type": "hysteria2", "tag": "hy2", "listen": "::", "listen_port": $HY2_PORT,
345
+ "users": [{ "password": "$HY2_PASSWORD" }],
346
+ "tls": $(cert_tls '"h3"')
347
+ }
348
+ EOF
349
+ ;;
350
+ tuic) cat <<EOF
351
+ {
352
+ "type": "tuic", "tag": "tuic", "listen": "::", "listen_port": $TUIC_PORT,
353
+ "users": [{ "uuid": "$UUID", "password": "$TUIC_PASSWORD" }],
354
+ "congestion_control": "bbr",
355
+ "tls": $(cert_tls '"h3"')
356
+ }
357
+ EOF
358
+ ;;
359
+ anytls) cat <<EOF
360
+ {
361
+ "type": "anytls", "tag": "anytls", "listen": "::", "listen_port": $ANYTLS_PORT,
362
+ "users": [{ "name": "dsh", "password": "$ANYTLS_PASSWORD" }],
363
+ "tls": $(cert_tls '')
364
+ }
365
+ EOF
366
+ ;;
367
+ ss) cat <<EOF
368
+ {
369
+ "type": "shadowsocks", "tag": "ss", "listen": "::", "listen_port": $SS_PORT,
370
+ "method": "$SS_METHOD", "password": "$SS_KEY"
371
+ }
372
+ EOF
373
+ ;;
374
+ esac
375
+ }
376
+
377
+ write_config() {
378
+ local p first=1
379
+ {
380
+ printf '{\n "log": { "level": "warn", "timestamp": true },\n'
381
+ cert_providers_json
382
+ printf ' "inbounds": [\n'
383
+ for p in $ALL_PROTOCOLS; do
384
+ has_protocol "$p" || continue
385
+ [ $first = 1 ] || printf ',\n'
386
+ first=0
387
+ inbound_json "$p"
388
+ done
389
+ printf '\n ],\n "outbounds": [{ "type": "direct", "tag": "direct" }]\n}\n'
390
+ } >"$CONFIG_FILE"
391
+ chmod 600 "$CONFIG_FILE"
392
+ "$BIN" check -c "$CONFIG_FILE" >&2 || fail bad_config "sing-box rejected the generated config"
393
+ }
394
+
395
+ write_unit() {
396
+ cat >"$UNIT_FILE" <<EOF
397
+ [Unit]
398
+ Description=dsh-vpn (sing-box)
399
+ After=network-online.target
400
+ Wants=network-online.target
401
+
402
+ [Service]
403
+ ExecStart=$BIN run -c $CONFIG_FILE
404
+ Restart=on-failure
405
+ RestartSec=3
406
+ LimitNOFILE=1048576
407
+
408
+ [Install]
409
+ WantedBy=multi-user.target
410
+ EOF
411
+ systemctl daemon-reload
412
+ systemctl enable "$UNIT" >/dev/null 2>&1
413
+ systemctl restart "$UNIT"
414
+ sleep 1
415
+ systemctl is-active --quiet "$UNIT" || {
416
+ journalctl -u "$UNIT" -n 20 --no-pager >&2 || true
417
+ fail service_failed "service failed to start"
418
+ }
419
+ }
420
+
421
+ # Transport(s) each protocol listens on.
422
+ protocol_transports() {
423
+ case "$1" in
424
+ reality|anytls) echo tcp ;;
425
+ hy2|tuic) echo udp ;;
426
+ ss) echo "tcp udp" ;;
427
+ esac
428
+ }
429
+
430
+ protocol_port() { local var; var="$(echo "$1" | tr '[:lower:]' '[:upper:]')_PORT"; echo "${!var}"; }
431
+
432
+ open_firewall() {
433
+ local p t rules=()
434
+ for p in $ALL_PROTOCOLS; do
435
+ has_protocol "$p" || continue
436
+ for t in $(protocol_transports "$p"); do rules+=("$(protocol_port "$p")/$t"); done
437
+ done
438
+ [ "$CERT_MODE" = ip ] && uses_cert && rules+=("80/tcp")
439
+ if command -v ufw >/dev/null && ufw status 2>/dev/null | grep -q 'Status: active'; then
440
+ log "opening ports in ufw: ${rules[*]}"
441
+ for r in "${rules[@]}"; do ufw allow "$r" >/dev/null; done
442
+ elif command -v firewall-cmd >/dev/null && firewall-cmd --state >/dev/null 2>&1; then
443
+ log "opening ports in firewalld: ${rules[*]}"
444
+ for r in "${rules[@]}"; do firewall-cmd -q --permanent --add-port="$r"; done
445
+ firewall-cmd -q --reload
446
+ fi
447
+ # TODO: raw iptables/nftables; cloud security groups must still be opened by the user.
448
+ }
449
+
450
+ enable_bbr() {
451
+ sysctl -n net.ipv4.tcp_congestion_control 2>/dev/null | grep -q bbr && return
452
+ modprobe tcp_bbr 2>/dev/null || true
453
+ grep -q bbr /proc/sys/net/ipv4/tcp_available_congestion_control 2>/dev/null || return
454
+ log "enabling BBR"
455
+ cat >/etc/sysctl.d/99-dsh-vpn.conf <<EOF
456
+ net.core.default_qdisc=fq
457
+ net.ipv4.tcp_congestion_control=bbr
458
+ EOF
459
+ sysctl --system >/dev/null 2>&1 || true
460
+ }
461
+
462
+ # ---------------------------------------------------------------- output
463
+
464
+ # --server (domain or IP for NAT/CDN setups) overrides detection.
465
+ resolve_ip() {
466
+ if [ -n "${SERVER_OVERRIDE:-}" ]; then
467
+ case "$SERVER_OVERRIDE" in *:*) SERVER_IP="[$SERVER_OVERRIDE]" ;; *) SERVER_IP=$SERVER_OVERRIDE ;; esac
468
+ return
469
+ fi
470
+ SERVER_IP=$(public_ip) || fail no_public_ip "could not detect public IP"
471
+ }
472
+
473
+ b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
474
+
475
+ # One {"id","port","link",...} object per selected protocol.
476
+ # Caller must set SERVER_IP first (see resolve_ip) so failures are not
477
+ # swallowed by the command substitution around links_json.
478
+ links_json() {
479
+ local ip=$SERVER_IP items=() p sni q_hy2 q_tuic q_anytls insecure pins=""
480
+ if [ "$CERT_MODE" = ip ] && uses_cert; then
481
+ # Publicly trusted certificate for the IP itself: clients verify normally.
482
+ sni=${ip#[}; sni=${sni%]}
483
+ q_hy2="insecure=0"; q_tuic="allow_insecure=0"; q_anytls="insecure=0"; insecure=false
484
+ else
485
+ # Hysteria2 links pin the self-signed cert (hex, `pinSHA256`); sing-box client
486
+ # configs need the base64 public-key hash (`certificate_public_key_sha256`).
487
+ # TUIC/AnyTLS links have no widely supported pin parameter, so clients must
488
+ # skip verification (flagged as insecure).
489
+ local pin spki
490
+ pin=$(openssl x509 -in "$CERT_DIR/cert.pem" -outform DER | sha256sum | awk '{print $1}')
491
+ spki=$(openssl x509 -in "$CERT_DIR/cert.pem" -pubkey -noout | openssl pkey -pubin -outform der \
492
+ | openssl dgst -sha256 -binary | openssl enc -base64)
493
+ sni=$CERT_SNI
494
+ q_hy2="insecure=0&pinSHA256=$pin"; q_tuic="allow_insecure=1&insecure=1"; q_anytls="insecure=1&allowInsecure=1"; insecure=true
495
+ pins=",\"cert_sha256\":\"$pin\",\"pubkey_sha256\":\"$spki\""
496
+ fi
497
+ for p in $ALL_PROTOCOLS; do
498
+ has_protocol "$p" || continue
499
+ case "$p" in
500
+ reality) items+=("$(printf '{"id":"reality","port":%s,"sni":"%s","link":"%s"}' "$REALITY_PORT" "$REALITY_SNI" \
501
+ "vless://$UUID@$ip:$REALITY_PORT?encryption=none&flow=xtls-rprx-vision&security=reality&sni=$REALITY_SNI&fp=chrome&pbk=$REALITY_PUBLIC&sid=$SHORT_ID&type=tcp#dsh-reality")") ;;
502
+ hy2) items+=("$(printf '{"id":"hy2","port":%s,"insecure":false%s,"link":"%s"}' "$HY2_PORT" "$pins" \
503
+ "hysteria2://$HY2_PASSWORD@$ip:$HY2_PORT?sni=$sni&alpn=h3&$q_hy2#dsh-hy2")") ;;
504
+ tuic) items+=("$(printf '{"id":"tuic","port":%s,"insecure":%s%s,"link":"%s"}' "$TUIC_PORT" "$insecure" "$pins" \
505
+ "tuic://$UUID:$TUIC_PASSWORD@$ip:$TUIC_PORT?congestion_control=bbr&udp_relay_mode=native&alpn=h3&sni=$sni&$q_tuic#dsh-tuic")") ;;
506
+ anytls) items+=("$(printf '{"id":"anytls","port":%s,"insecure":%s%s,"link":"%s"}' "$ANYTLS_PORT" "$insecure" "$pins" \
507
+ "anytls://$ANYTLS_PASSWORD@$ip:$ANYTLS_PORT?sni=$sni&$q_anytls#dsh-anytls")") ;;
508
+ ss) items+=("$(printf '{"id":"ss","port":%s,"method":"%s","link":"%s"}' "$SS_PORT" "$SS_METHOD" \
509
+ "ss://$(printf '%s:%s' "$SS_METHOD" "$SS_KEY" | b64url)@$ip:$SS_PORT#dsh-ss")") ;;
510
+ esac
511
+ done
512
+ local IFS=,
513
+ printf '{"server":"%s","cert":"%s","items":[%s]}' "$ip" "$CERT_MODE" "${items[*]}"
514
+ }
515
+
516
+ # {"mode":…} plus expiry for an issued IP certificate.
517
+ cert_status_json() {
518
+ if [ "$CERT_MODE" != ip ] || ! uses_cert; then printf '{"mode":"%s"}' "$CERT_MODE"; return; fi
519
+ find_acme_cert
520
+ if [ -z "$ACME_CERT" ]; then printf '{"mode":"ip","issued":false}'; return; fi
521
+ local end secs
522
+ end=$(openssl x509 -enddate -noout -in "$ACME_CERT" | cut -d= -f2)
523
+ secs=$(( $(date -d "$end" +%s) - $(date +%s) ))
524
+ printf '{"mode":"ip","issued":true,"expires":"%s","expires_in_hours":%s}' "$(date -u -d "$end" +%FT%TZ)" "$((secs / 3600))"
525
+ }
526
+
527
+ load_state_or_fail() {
528
+ [ -f "$STATE_FILE" ] || fail not_installed "dsh-vpn is not installed"
529
+ # shellcheck disable=SC1090
530
+ . "$STATE_FILE"
531
+ PROTOCOLS=${PROTOCOLS:-reality,hy2} # state written before protocol selection existed
532
+ CERT_MODE=${CERT_MODE:-self}
533
+ }
534
+
535
+ # ---------------------------------------------------------------- commands
536
+
537
+ cmd_install() {
538
+ local sni="" regenerate=0 want_cert
539
+ PROTOCOLS=""; CERT_MODE=""; ACME_PROVIDER=""; CERT_FAILURE=""
540
+ while [ $# -gt 0 ]; do
541
+ case "$1" in
542
+ --protocols) validate_protocols "$2"; PROTOCOLS=$2; shift 2 ;;
543
+ --port)
544
+ [[ $2 =~ ^[a-z0-9]+=[0-9]+$ ]] || fail bad_args "bad --port value: $2"
545
+ PORT_OVERRIDES="$PORT_OVERRIDES $2"; shift 2 ;;
546
+ --sni) sni=$2; shift 2 ;;
547
+ --server) SERVER_OVERRIDE=$2; shift 2 ;;
548
+ --regenerate) regenerate=1; shift ;;
549
+ --cert)
550
+ case "$2" in self|ip) CERT_MODE=$2 ;; *) fail bad_args "bad --cert value: $2" ;; esac
551
+ shift 2 ;;
552
+ --acme-provider) ACME_PROVIDER=$2; shift 2 ;;
553
+ --cert-wait) # seconds to wait for issuance before falling back (tests)
554
+ [[ $2 =~ ^[0-9]+$ ]] || fail bad_args "bad --cert-wait value: $2"
555
+ CERT_WAIT_SECONDS=$2; shift 2 ;;
556
+ *) fail bad_args "unknown option: $1" ;;
557
+ esac
558
+ done
559
+ require_root
560
+ require_systemd
561
+
562
+ # Validate the IP-certificate prerequisites before anything is written, so a
563
+ # refusal leaves the running node untouched.
564
+ want_cert=$CERT_MODE
565
+ if [ -z "$want_cert" ] && [ -f "$STATE_FILE" ]; then want_cert=$(sed -n 's/^CERT_MODE=//p' "$STATE_FILE"); fi
566
+ if [ "${want_cert:-self}" = ip ]; then
567
+ is_ip_literal "${SERVER_OVERRIDE:-}" \
568
+ || fail cert_needs_ip "IP certificates need the server's IP address as the node address (got: ${SERVER_OVERRIDE:-none})"
569
+ local blocker; blocker=$(port80_blocker)
570
+ [ -z "$blocker" ] || fail port80_in_use "TCP port 80 is already used by: $blocker"
571
+ NEW_CERT_IP=$SERVER_OVERRIDE
572
+ fi
573
+
574
+ install_deps
575
+ install_singbox
576
+ prepare_state "$regenerate" "$sni"
577
+ write_cert
578
+ write_config
579
+ write_unit
580
+ open_firewall
581
+ if [ "$CERT_MODE" = ip ] && uses_cert && ! wait_for_cert; then cert_fallback; fi
582
+ enable_bbr
583
+ resolve_ip
584
+ log "done"
585
+ local head='"ok":true'
586
+ [ -z "$CERT_FAILURE" ] || head="\"ok\":false,\"error\":\"cert_failed\",\"message\":\"$(json_escape "$CERT_FAILURE")\""
587
+ result "{$head,\"action\":\"install\",\"protocols\":\"$PROTOCOLS\",\"version\":\"$("$BIN" version | awk 'NR==1{print $3}')\",\"cert\":$(cert_status_json),\"links\":$(links_json)}"
588
+ }
589
+
590
+ cmd_status() {
591
+ require_root
592
+ local active=false version="" p t ok listening=()
593
+ [ -f "$STATE_FILE" ] || result '{"ok":true,"action":"status","installed":false}'
594
+ load_state_or_fail
595
+ systemctl is-active --quiet "$UNIT" && active=true
596
+ [ -x "$BIN" ] && version=$("$BIN" version | awk 'NR==1{print $3}')
597
+ for p in $ALL_PROTOCOLS; do
598
+ has_protocol "$p" || continue
599
+ ok=true
600
+ for t in $(protocol_transports "$p"); do
601
+ ss -Hln --"$t" | awk '{print $4}' | grep -Eq "[:.]$(protocol_port "$p")\$" || ok=false
602
+ done
603
+ listening+=("\"$p\":$ok")
604
+ done
605
+ local IFS=,
606
+ local cert; cert=$(cert_status_json)
607
+ result "{\"ok\":true,\"action\":\"status\",\"installed\":true,\"active\":$active,\"version\":\"$version\",\"protocols\":\"$PROTOCOLS\",\"cert\":$cert,\"listening\":{${listening[*]}}}"
608
+ }
609
+
610
+ cmd_links() {
611
+ while [ $# -gt 0 ]; do
612
+ case "$1" in
613
+ --server) SERVER_OVERRIDE=$2; shift 2 ;;
614
+ *) fail bad_args "unknown option: $1" ;;
615
+ esac
616
+ done
617
+ require_root
618
+ load_state_or_fail
619
+ resolve_ip
620
+ result "{\"ok\":true,\"action\":\"links\",\"protocols\":\"$PROTOCOLS\",\"links\":$(links_json)}"
621
+ }
622
+
623
+ cmd_uninstall() {
624
+ require_root
625
+ systemctl disable --now "$UNIT" >/dev/null 2>&1 || true
626
+ rm -f "$UNIT_FILE" /etc/sysctl.d/99-dsh-vpn.conf
627
+ systemctl daemon-reload 2>/dev/null || true
628
+ rm -rf "$STATE_DIR" "$BIN"
629
+ log "removed"
630
+ result '{"ok":true,"action":"uninstall"}'
631
+ }
632
+
633
+ main() {
634
+ local cmd=${1:-}; shift || true
635
+ case "$cmd" in
636
+ install) cmd_install "$@" ;;
637
+ status) cmd_status ;;
638
+ links) cmd_links "$@" ;;
639
+ uninstall) cmd_uninstall ;;
640
+ *) fail bad_args "usage: vpn.sh <install|status|links|uninstall>" ;;
641
+ esac
642
+ }
643
+
644
+ main "$@"