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/LICENSE +21 -0
- package/README.md +114 -0
- package/THIRD_PARTY_NOTICES.md +177 -0
- package/assets/vpn-ops.md +46 -0
- package/cordis.patch.yml +11 -0
- package/lib/client.js +1346 -0
- package/lib/index.js +20820 -0
- package/lib/skill.js +54 -0
- package/package.json +84 -0
- package/scripts/vpn.sh +644 -0
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 "$@"
|