server/transtation (49809B)
1 #!/bin/sh
2 # transtation -- self-provisioning VLESS + REALITY proxy server, optional Cloudflare WARP egress.
3 # https://github.com/sqzer-x/transtation
4 #
5 # Everything in /data except config.json and runtime.env is IDENTITY: written
6 # once, atomically, and never rewritten. Everything else is DERIVED and
7 # regenerated from this image on every boot. That split is what makes an image
8 # upgrade safe and a half-finished first boot resumable.
9 set -eu
10
11 SCHEMA_SUPPORTED=1
12 INTERNAL_PORT=8443
13 HEALTH_ADDR=127.0.0.1
14 HEALTH_PORT=10808
15 # Cloudflare's public WARP peer key. Not a secret; identical for every user.
16 WARP_PEER_PUBKEY='bmXOC+F1FxEMF9dyiK2H5/1SUtzH0JuVo51h2wPfgyo='
17 WARP_ENDPOINT_DEFAULT='162.159.192.1:2408'
18 TAB=$(printf '\t')
19 # How this instance is supervised. The image sets TT_MANAGED=docker; a native
20 # install leaves it unset and is managed by systemd. It decides how the server
21 # is restarted and which file the operator edits -- and, critically, that
22 # `kill -TERM 1` is only ever right inside a container. On a host, PID 1 is
23 # init.
24 TT_MANAGED=${TT_MANAGED:-systemd}
25 restart_hint() { [ "$TT_MANAGED" = docker ] && printf 'docker compose up -d' || printf 'systemctl restart transtation'; }
26 env_hint() { [ "$TT_MANAGED" = docker ] && printf '/opt/transtation/.env' || printf '/etc/transtation/server.env'; }
27 # Is the server up, or on its way up? Under docker this command only runs at all
28 # because `docker exec` found a live container, so the answer is yes by
29 # construction; natively, ask the init system.
30 server_starting() {
31 [ "$TT_MANAGED" = docker ] && return 0
32 systemctl is-active --quiet transtation 2>/dev/null
33 }
34
35 set_paths() {
36 # The container mounts its volume at /data; a native install keeps state
37 # where a system service should. Getting this wrong sends `transtation show`
38 # looking at an empty directory.
39 if [ -n "${TT_DATA:-}" ]; then
40 DATA=$TT_DATA
41 elif [ "$TT_MANAGED" = docker ]; then
42 DATA=/data
43 else
44 DATA=/var/lib/transtation
45 fi
46 STATE="$DATA/state.env"
47 USERS="$DATA/users.tsv"
48 CONF="$DATA/config.json"
49 RUNTIME="$DATA/runtime.env"
50 ACCOUNT="$DATA/wgcf-account.toml"
51 PROFILE="$DATA/wgcf-profile.conf"
52 SNIFILE="$DATA/sni.chosen"
53 }
54 set_paths
55
56 die() { printf 'transtation: %s\n' "$*" >&2; exit 1; }
57 warn() { printf 'transtation: %s\n' "$*" >&2; }
58 # Values echoed back at the operator are stripped of control characters first.
59 q() { printf '%s' "$1" | tr -dc '[:print:]' | cut -c1-64; }
60
61 # State files are PARSED, never `.`-sourced: the values include free text such
62 # as "best of KR-region candidates", and sourcing that runs `of` as a command.
63 # Parsing also means a tampered state file cannot execute code. Each loader
64 # reads its file once with the shell's own `read` rather than spawning a
65 # process per key.
66
67 # ---------------------------------------------------------------- identity
68
69 # Xray has shipped three different `xray x25519` output formats:
70 # <=25.6.8 "Private key: <b64>" / "Public key: <b64>"
71 # 25.9-26.1 "PrivateKey: <b64>" / "Password: <b64>"
72 # >=26.3.27 "PrivateKey: <b64>" / "Password (PublicKey): <b64>" + "Hash32: <b64>"
73 # Parse by label, never by position. The ^ anchors keep "Password (PublicKey)"
74 # from also matching the private rule, and keep Hash32 -- which belongs to VLESS
75 # Encryption, not REALITY -- from matching either.
76 parse_x25519() {
77 awk -F':[[:space:]]*' '
78 /^Private ?[Kk]ey/ { p = $2 }
79 /^(Public ?[Kk]ey|Password)/ { q = $2 }
80 END { gsub(/[[:space:]]/, "", p); gsub(/[[:space:]]/, "", q); print p " " q }'
81 }
82
83 provision_identity() {
84 _pair=$(xray x25519 | parse_x25519)
85 _priv=${_pair% *}
86 _pub=${_pair#* }
87 # An x25519 key is 32 bytes in base64url without padding == 43 characters.
88 # This assert is the tripwire for a fourth output format.
89 [ "${#_priv}" -eq 43 ] && [ "${#_pub}" -eq 43 ] || die \
90 "could not parse 'xray x25519' (priv=${#_priv}ch pub=${#_pub}ch).
91 Xray changed its output format. Open an issue with the output of: xray x25519"
92
93 _t=$(mktemp "$DATA/.state.XXXXXX")
94 printf 'SCHEMA=%s\nREALITY_PRIV=%s\nREALITY_PUB=%s\nCREATED=%s\n' \
95 "$SCHEMA_SUPPORTED" "$_priv" "$_pub" "$(date -u +%F)" >"$_t"
96 chmod 600 "$_t"
97 # Atomic. The existence of state.env IS the "identity provisioned" marker,
98 # so it must appear only once everything in it is durable.
99 mv -f "$_t" "$STATE"
100 }
101
102 migrate() {
103 # case "$SCHEMA" in 1) ...upgrade to 2...;& 2) ...;; esac -- nothing yet.
104 :
105 }
106
107 # ------------------------------------------------------------------- users
108
109 user_add() {
110 _name=${1:?usage: transtation user add <name>}
111 case "$_name" in
112 '' | *[!A-Za-z0-9_-]*) die "name must match [A-Za-z0-9_-]{1,32}" ;;
113 esac
114 [ "${#_name}" -le 32 ] || die "name must be 32 characters or fewer"
115 if [ -f "$USERS" ] && grep -q "^$_name$TAB" "$USERS"; then
116 die "user '$_name' already exists"
117 fi
118 _uuid=$(xray uuid)
119 # 8 random bytes == 16 hex chars, the maximum REALITY accepts. Never "",
120 # which hex-decodes to the same all-zero shortId as "00" and leaves the
121 # public key -- which is printed in every share link you hand out -- as the
122 # only thing gating the handshake.
123 _sid=$(head -c 8 /dev/urandom | od -An -tx1 | tr -d ' \n')
124 umask 077
125 printf '%s\t%s\t%s\n' "$_name" "$_uuid" "$_sid" >>"$USERS"
126 chmod 600 "$USERS"
127 printf 'added user %s\n' "$_name"
128 }
129
130 user_rm() {
131 _name=${1:?usage: transtation user rm <name>}
132 [ -f "$USERS" ] || die "no users file"
133 grep -q "^$_name$TAB" "$USERS" || die "no such user: $_name"
134 _n=$(wc -l <"$USERS")
135 [ "$_n" -gt 1 ] || die "refusing to remove the last user -- the server cannot start with none"
136 _t=$(mktemp "$DATA/.users.XXXXXX")
137 grep -v "^$_name$TAB" "$USERS" >"$_t"
138 chmod 600 "$_t"
139 mv -f "$_t" "$USERS"
140 printf 'removed user %s (its UUID and shortId are both revoked)\n' "$_name"
141 }
142
143 user_ls() {
144 [ -f "$USERS" ] || die "no users yet"
145 printf 'NAME\tUUID\n'
146 while IFS="$TAB" read -r n u _; do
147 [ -n "$n" ] || continue
148 printf '%s\t%s\n' "$n" "$u"
149 done <"$USERS"
150 }
151
152 # ------------------------------------------------------------------ config
153
154 warp_wanted() { [ "${WARP:-1}" = 1 ]; }
155 warp_ready() { warp_wanted && [ -f "$PROFILE" ]; }
156
157 warp_register() {
158 # ONE attempt. Never a retry loop: hammering Cloudflare's registration
159 # endpoint is how a source IP -- historically a whole region -- gets blocked.
160 #
161 # Registers into temporary files and promotes them only on success. An
162 # earlier version deleted the live profile first, so a rate-limited retry --
163 # the single most likely outcome of running this by hand -- destroyed a
164 # working WARP identity and silently degraded the server to direct egress on
165 # its next restart.
166 _ta="$DATA/.acct.$$"
167 _tp="$DATA/.prof.$$"
168 rm -f "$_ta" "$_tp"
169 if wgcf register --accept-tos --config "$_ta" >/dev/null 2>&1 &&
170 wgcf generate --config "$_ta" --profile "$_tp" >/dev/null 2>&1; then
171 chmod 600 "$_ta" "$_tp"
172 mv -f "$_ta" "$ACCOUNT"
173 mv -f "$_tp" "$PROFILE"
174 return 0
175 fi
176 rm -f "$_ta" "$_tp"
177 warn "Cloudflare WARP registration failed (rate limit, or the API changed).
178 Cloudflare rate-limits registration per source IP for roughly 15 minutes.
179 Continuing with DIRECT egress from this VPS's own IP -- the proxy works.
180 retry: transtation warp register
181 turn off for good: set WARP=0 in $(env_hint) and '$(restart_hint)'
182 Any WARP identity you already had is untouched."
183 return 1
184 }
185
186 render_warp_outbound() {
187 _priv=$(sed -n 's/^[[:space:]]*PrivateKey[[:space:]]*=[[:space:]]*//p' "$PROFILE" | head -1)
188 _addrs=$(sed -n 's/^[[:space:]]*Address[[:space:]]*=[[:space:]]*//p' "$PROFILE" | head -1)
189 [ -n "$_priv" ] && [ -n "$_addrs" ] || die "$PROFILE is unreadable or truncated -- try: transtation warp register"
190 _addr_json=$(printf '%s' "$_addrs" | tr ',' '\n' |
191 sed 's/^[[:space:]]*//;s/[[:space:]]*$//' |
192 awk 'NF { printf "%s\"%s\"", (n++ ? ", " : ""), $0 }')
193 cat <<-EOF
194 {
195 "tag": "warp-out",
196 "protocol": "wireguard",
197 "settings": {
198 "secretKey": "$_priv",
199 "address": [$_addr_json],
200 "peers": [
201 {
202 "publicKey": "$WARP_PEER_PUBKEY",
203 "endpoint": "${WARP_ENDPOINT:-$WARP_ENDPOINT_DEFAULT}",
204 "allowedIPs": ["0.0.0.0/0", "::/0"],
205 "keepAlive": 25
206 }
207 ],
208 "mtu": 1280,
209 "noKernelTun": true,
210 "domainStrategy": "ForceIPv4v6"
211 }
212 },
213 EOF
214 }
215
216 # NOTE: this image ships no geoip.dat / geosite.dat (they are 62% of the image
217 # and nothing else needed them). Any "geoip:" or "geosite:" rule added below
218 # will fail at startup with a file-not-found error. Use literal CIDRs.
219 render_config() {
220 _clients=$(awk -F'\t' 'NF>=3 { printf "%s{ \"id\": \"%s\", \"flow\": \"xtls-rprx-vision\" }", (n++ ? ",\n " : ""), $2 }' "$USERS")
221 _sids=$(awk -F'\t' 'NF>=3 { printf "%s\"%s\"", (n++ ? ", " : ""), $3 }' "$USERS")
222 [ -n "$_clients" ] || die "no users defined -- refusing to render a config REALITY would reject"
223
224 if warp_ready; then
225 _warp_out=$(render_warp_outbound)
226 _final_out=warp-out
227 else
228 _warp_out=""
229 _final_out=direct
230 fi
231
232 cat <<-EOF
233 {
234 "log": { "loglevel": "warning", "access": "none", "error": "" },
235 "dns": { "servers": ["1.1.1.1", "1.0.0.1"] },
236 "inbounds": [
237 {
238 "tag": "reality-in",
239 "listen": "0.0.0.0",
240 "port": ${LISTEN_PORT:-$INTERNAL_PORT},
241 "protocol": "vless",
242 "settings": {
243 "clients": [
244 $_clients
245 ],
246 "decryption": "none"
247 },
248 "streamSettings": {
249 "network": "tcp",
250 "security": "reality",
251 "realitySettings": {
252 "show": false,
253 "dest": "$DEST",
254 "xver": 0,
255 "serverNames": ["$SNI"],
256 "privateKey": "$REALITY_PRIV",
257 "shortIds": [$_sids],
258 "minClientVer": "${MIN_CLIENT_VER:-0.0.0}"
259 }
260 },
261 "sniffing": { "enabled": true, "destOverride": ["http", "tls", "quic"], "routeOnly": true }
262 },
263 {
264 "tag": "health-in",
265 "listen": "$HEALTH_ADDR",
266 "port": $HEALTH_PORT,
267 "protocol": "socks",
268 "settings": { "auth": "noauth", "udp": false }
269 }
270 ],
271 "outbounds": [
272 $_warp_out { "tag": "direct", "protocol": "freedom" },
273 { "tag": "block", "protocol": "blackhole" }
274 ],
275 "routing": {
276 "domainStrategy": "IPIfNonMatch",
277 "rules": [
278 {
279 "type": "field",
280 "ip": [
281 "127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
282 "169.254.0.0/16", "100.64.0.0/10",
283 "::1/128", "fc00::/7", "fe80::/10"
284 ],
285 "outboundTag": "block"
286 },
287 { "type": "field", "port": "25", "outboundTag": "block" },
288 { "type": "field", "protocol": ["bittorrent"], "outboundTag": "block" },
289 { "type": "field", "network": "tcp,udp", "outboundTag": "$_final_out" }
290 ]
291 }
292 }
293 EOF
294 }
295
296 # 169.254.0.0/16 in that block list is load-bearing, not decoration: without it
297 # any client of your proxy can reach 169.254.169.254 and read your instance's
298 # cloud metadata, including IAM credentials.
299
300 write_config() {
301 _t=$(mktemp "$DATA/.config.XXXXXX")
302 render_config >"$_t"
303 if command -v jq >/dev/null 2>&1; then
304 jq -e . "$_t" >/dev/null 2>&1 || { rm -f "$_t"; die "generated config is not valid JSON -- this is a transtation bug"; }
305 fi
306 # -format json is required, not optional: xray infers the format from the
307 # file extension, and this is a mktemp name with no .json suffix. Without it
308 # every validation fails with "Failed to get format of ..." and the server
309 # never starts.
310 xray run -test -format json -c "$_t" >/dev/null 2>&1 || {
311 xray run -test -format json -c "$_t" >&2 || true
312 rm -f "$_t"
313 die "generated config was rejected by xray -- this is a transtation bug"
314 }
315 # 0600: this file inlines the REALITY private key and the WireGuard private
316 # key. Xray runs as the same uid that owns it, so it needs nothing wider.
317 chmod 600 "$_t"
318 mv -f "$_t" "$CONF"
319 }
320
321 write_runtime() {
322 # Derived, rewritten every boot. `show`/`status`/`health` run as separate
323 # `docker exec` processes and would otherwise not see an auto-detected HOST.
324 _t=$(mktemp "$DATA/.runtime.XXXXXX")
325 printf 'HOST=%s\nPORT=%s\nLISTEN_PORT=%s\nSNI=%s\nDEST=%s\nWARP=%s\nWARP_READY=%s\nSNI_REASON=%s\n' \
326 "$HOST" "$PORT" "${LISTEN_PORT:-$INTERNAL_PORT}" "$SNI" "$DEST" "${WARP:-1}" \
327 "$(warp_ready && echo 1 || echo 0)" "${SNI_REASON:-unknown}" >"$_t"
328 chmod 600 "$_t"
329 mv -f "$_t" "$RUNTIME"
330 }
331
332 load_runtime() {
333 # The runtime file is written a moment into startup, after the dest probe.
334 # Running `transtation show` straight after `systemctl start` therefore used
335 # to be told to restart -- advice that loops you, because waiting was all it
336 # needed. Wait briefly when the thing is genuinely coming up, and only then
337 # say so.
338 if [ ! -f "$RUNTIME" ] && server_starting; then
339 _w=0
340 while [ ! -f "$RUNTIME" ] && [ "$_w" -lt 15 ]; do sleep 1; _w=$((_w + 1)); done
341 fi
342 [ -f "$RUNTIME" ] || die "server has not started yet -- run '$(restart_hint)' first"
343 HOST=""; PORT=""; LISTEN_PORT=""; SNI=""; DEST=""; WARP=""; WARP_READY=""; SNI_REASON=""
344 while IFS='=' read -r _k _v; do
345 case "$_k" in
346 HOST) HOST=$_v ;;
347 PORT) PORT=$_v ;;
348 LISTEN_PORT) LISTEN_PORT=$_v ;;
349 SNI) SNI=$_v ;;
350 DEST) DEST=$_v ;;
351 WARP) WARP=$_v ;;
352 WARP_READY) WARP_READY=$_v ;;
353 SNI_REASON) SNI_REASON=$_v ;;
354 esac
355 done <"$RUNTIME"
356 }
357
358 load_state() {
359 [ -s "$STATE" ] || die "no identity yet -- start the server first"
360 SCHEMA=""; REALITY_PRIV=""; REALITY_PUB=""; CREATED=""
361 while IFS='=' read -r _k _v; do
362 case "$_k" in
363 SCHEMA) SCHEMA=$_v ;;
364 REALITY_PRIV) REALITY_PRIV=$_v ;;
365 REALITY_PUB) REALITY_PUB=$_v ;;
366 CREATED) CREATED=$_v ;;
367 esac
368 done <"$STATE"
369 }
370
371 apply() {
372 # `user add` / `sni` / `warp register` arrive as separate `docker exec`
373 # processes with none of run()'s shell state, so rebuild it before
374 # re-rendering -- including the SNI precedence, or changing the dest would
375 # write a config that still names the old one.
376 _sni_env=${SNI:-}
377 _dest_env=${DEST:-}
378 load_state
379 load_runtime
380 if [ -n "$_sni_env" ]; then
381 SNI=$_sni_env
382 elif [ -s "$SNIFILE" ]; then
383 sni_load
384 fi
385 DEST=${_dest_env:-$SNI:443}
386 write_config
387 write_runtime
388 # Xray has no reload signal and no config watcher, so the file is the source
389 # of truth and the only way to apply it is to restart.
390 if [ "$TT_MANAGED" = docker ]; then
391 if [ -r /proc/1/comm ] && [ "$(cat /proc/1/comm)" = xray ]; then
392 echo "applying: stopping xray so your restart policy brings it back up."
393 echo " existing connections drop and clients reconnect on their own."
394 printf ' If you started this container WITHOUT a restart policy, start it again:\n'
395 printf ' docker start %s\n' "$(hostname)"
396 kill -TERM 1
397 else
398 echo "written; takes effect the next time the container starts"
399 fi
400 elif command -v systemctl >/dev/null 2>&1 && systemctl is-active --quiet transtation 2>/dev/null; then
401 echo "applying: restarting transtation.service"
402 echo " existing connections drop and clients reconnect on their own."
403 systemctl restart transtation
404 else
405 echo "written; takes effect the next time the server starts"
406 fi
407 }
408
409 # ------------------------------------------------------------------ sharing
410
411 share_uri() {
412 _name=$1 _uuid=$2 _sid=$3
413 _h=$HOST
414 case "$_h" in *:*) _h="[$_h]" ;; esac
415 printf 'vless://%s@%s:%s?encryption=none&flow=xtls-rprx-vision&security=reality&sni=%s&fp=chrome&pbk=%s&sid=%s&spx=%%2F&type=tcp&headerType=none#transtation-%s\n' \
416 "$_uuid" "$_h" "$PORT" "$SNI" "$REALITY_PUB" "$_sid" "$_name"
417 }
418
419 cmd_show() {
420 load_state
421 load_runtime
422 [ -f "$USERS" ] || die "no users yet"
423 _want=${1:-}
424 _found=0
425 while IFS="$TAB" read -r n u s; do
426 [ -n "$n" ] || continue
427 [ -z "$_want" ] || [ "$_want" = "$n" ] || continue
428 _found=1
429 _uri=$(share_uri "$n" "$u" "$s")
430 printf '\n user: %s\n\n' "$n"
431 printf ' !! The QR and the line below are your credential. Anyone holding either can\n'
432 printf ' use your proxy, and a link that has been published stays fingerprintable\n'
433 printf ' forever. Revoke with: transtation user rm %s\n\n' "$n"
434 if command -v qrencode >/dev/null 2>&1; then
435 printf '%s' "$_uri" | qrencode -t ANSIUTF8 -m 2
436 printf '\n'
437 fi
438 printf ' %s\n\n' "$_uri"
439 done <"$USERS"
440 [ "$_found" = 1 ] || die "no such user: $_want"
441 # The URI is deliberately never written to container stdout during `run`:
442 # the json-file log driver would persist this credential to
443 # /var/lib/docker/containers/<id>/<id>-json.log, where it survives in every
444 # log bundle anyone ever pastes into a support thread.
445 printf ' This link was NOT written to the container log.\n\n'
446 }
447
448 # -------------------------------------------------------------- dest checks
449
450 # A dest whose TLS certificate chain is too large silently breaks REALITY: the
451 # handshake authenticates correctly and then dies partway through the borrowed
452 # certificate, so the server looks perfectly healthy while no client can
453 # connect. Measured against Xray 26.3.27, end to end:
454 #
455 # dl.google.com 895 works www.ibm.com 4331 works
456 # one.one.one.one 2307 works www.paypal.com 4883 works
457 # www.cloudflare.com 2493 works www.linkedin.com 5046 works
458 # addons.mozilla.org 2843 works www.microsoft.com 5879 FAILS
459 # www.nicovideo.jp 3822 works
460 #
461 # The boundary sits between 5046 and 5879, so the warning fires above 5200.
462 CHAIN_LIMIT=5200
463
464 check_dest() {
465 _ping=$(xray tls ping "$SNI" 2>&1) || true
466 case "$_ping" in
467 *"Handshake succeeded"*) ;;
468 *)
469 warn "TLS probe to $SNI failed from this host. Starting anyway, but clients
470 may fail the Reality handshake. Investigate with: xray tls ping $SNI"
471 return 0
472 ;;
473 esac
474 _chain=$(printf '%s' "$_ping" | sed -n "s/^Certificate chain's total length: *\([0-9][0-9]*\).*/\1/p" | head -1)
475 [ -n "$_chain" ] || return 0
476 if [ "$_chain" -gt "$CHAIN_LIMIT" ]; then
477 warn "$SNI presents a $_chain-byte certificate chain. Above roughly $CHAIN_LIMIT bytes the
478 REALITY handshake does not complete, and the failure is SILENT: this server
479 will look healthy and no client will be able to connect.
480 Pick a smaller target -- dl.google.com (895), www.cloudflare.com (2493) and
481 addons.mozilla.org (2843) all have plenty of headroom.
482 Confirm either way with: transtation verify"
483 fi
484 }
485
486 # ------------------------------------------------------- SNI auto-selection
487
488 # Candidates are filtered well below CHAIN_LIMIT so that a site adding one
489 # intermediate certificate later does not silently break the server.
490 CHAIN_SAFE=4500
491
492 # --- what the dest has to survive -----------------------------------------
493 #
494 # The dest is not just a TLS handshake donor. Your clients' ISP sees their
495 # traffic labelled with this name, and it will judge the VOLUME AND SHAPE of
496 # that traffic against what the name implies. Hours of sustained multi-gigabyte
497 # download addressed to a corporate brochure page is not a plausible thing for
498 # a human to be doing, and a DPI box that has already taken an interest in the
499 # address will start dropping the connection. Pulling the same bytes from a
500 # video service is what everyone does all evening.
501 #
502 # That is a judgement about the internet, not something a TLS probe can
503 # measure -- `accept-ranges` was tested for this and does not discriminate
504 # (dl.google.com answers "none" on its root path; addons.mozilla.org answers
505 # "bytes"). So the class below is curated, deliberately, and it dominates the
506 # score:
507 #
508 # bulk video, streaming, and real download/CDN hosts. Sustained multi-GB
509 # transfer and hours-long connections are unremarkable here.
510 # mixed media-heavy portals. Moderate sustained traffic is fine; a torrent
511 # of it is a bit odd.
512 # light corporate sites, marketing pages, DNS endpoints. Bulk transfer to
513 # these looks exactly as wrong as it is. Last resort only.
514 #
515 # Weights are in milliseconds so they can be compared with the measured
516 # handshake latency directly. They are deliberately lopsided: being dropped by
517 # a DPI box is fatal, whereas a slower dest only adds to connection setup. Real
518 # candidate latencies span roughly 50-800ms, so a bulk dest wins essentially
519 # every realistic comparison, and a light one wins only when nothing else
520 # answered at all.
521 CLASS_BULK=0
522 CLASS_MIXED=800
523 CLASS_LIGHT=2500
524 # How much slower a same-region candidate may be and still win. Matching the
525 # dest to where the traffic is observed is what makes it unremarkable.
526 REGION_BONUS_MS=150
527
528 # Chain sizes and TLS versions measured 2026-08-06 from a live probe; every
529 # entry passed. Entries that failed and are therefore NOT here: abema.tv
530 # (TLS 1.2 only), www.hulu.com (4717-byte chain).
531 sni_regional() {
532 case "$1" in
533 JP) echo "bulk:www.nicovideo.jp bulk:tver.jp mixed:www.pixiv.net" ;;
534 KR) echo "bulk:www.tving.com bulk:tv.naver.com bulk:www.wavve.com" ;;
535 US | CA) echo "bulk:www.twitch.tv bulk:vimeo.com mixed:www.reddit.com" ;;
536 GB | IE | DE | FR | NL | SE | NO | DK | FI | PL | IT | ES | PT | CH | AT | BE | CZ | RO | HU | GR) echo "bulk:www.dailymotion.com bulk:www.viaplay.com bulk:www.rtl.de" ;;
537 SG | HK | TW | MY | TH | VN | ID | PH) echo "bulk:www.vidio.com bulk:www.viu.com" ;;
538 IN | PK | BD | LK) echo "bulk:www.hotstar.com bulk:www.zee5.com bulk:www.sonyliv.com" ;;
539 BR | AR | CL | MX | CO | PE | UY) echo "bulk:globoplay.globo.com bulk:vimeo.com" ;;
540 AU | NZ) echo "bulk:iview.abc.net.au bulk:www.stan.com.au" ;;
541 *) echo "" ;;
542 esac
543 }
544 # Always probed, whatever the region. Global streaming brands and real download
545 # hosts are plausible almost anywhere; the light entry exists only so that a
546 # server on a restricted network still gets an answer.
547 SNI_GLOBAL="bulk:dl.google.com bulk:www.netflix.com bulk:cdn.discordapp.com bulk:www.twitch.tv mixed:addons.mozilla.org light:www.cloudflare.com"
548 SNI_FALLBACK=dl.google.com
549
550 # Pure function, so the weighting can be tested offline.
551 sni_score() {
552 # class rtt_ms chain_bytes region_match(0|1) -> score, lower is better
553 case "$1" in
554 bulk) _cp=$CLASS_BULK ;;
555 mixed) _cp=$CLASS_MIXED ;;
556 *) _cp=$CLASS_LIGHT ;;
557 esac
558 _rb=0
559 [ "$4" = 1 ] && _rb=$REGION_BONUS_MS
560 # The chain term is a tiebreak, not a filter: a smaller chain is headroom
561 # against the dest adding an intermediate certificate later.
562 _sc=$(($2 + _cp + $3 / 100 - _rb))
563 [ "$_sc" -lt 0 ] && _sc=0
564 printf '%s' "$_sc"
565 }
566
567 # One candidate. `xray tls ping` performs a full TLS handshake, so timing that
568 # call gives the latency directly -- the earlier version made a second curl
569 # round trip per candidate to measure what the first one already cost.
570 # /proc/uptime is used rather than `date`, which in busybox has no %N.
571 sni_probe() {
572 # "class:domain" region_match dir
573 _cls=${1%%:*} _c=${1#*:} _rm=$2 _dir=$3
574 read -r _t0 _ </proc/uptime
575 _p=$(timeout 12 xray tls ping "$_c" 2>&1) || return 0
576 read -r _t1 _ </proc/uptime
577 case "$_p" in *"Handshake succeeded"*) ;; *) return 0 ;; esac
578 # TLS 1.2 is a hard reject, not a penalty -- REALITY needs 1.3. This is not
579 # theoretical: abema.tv looked like an ideal Japanese video dest and serves
580 # only TLS 1.2.
581 case "$_p" in *"TLS Version:"*"1.3"*) ;; *) return 0 ;; esac
582 _chain=$(printf '%s' "$_p" | sed -n "s/^Certificate chain's total length: *\\([0-9][0-9]*\\).*/\\1/p" | head -1)
583 [ -n "$_chain" ] && [ "$_chain" -le "$CHAIN_SAFE" ] || return 0
584 _ms=$(awk -v a="$_t0" -v b="$_t1" 'BEGIN { printf "%d", (b - a) * 1000 }')
585 [ -n "$_ms" ] && [ "$_ms" -gt 0 ] || _ms=1
586 printf '%s %s %s %s %s %s\n' "$(sni_score "$_cls" "$_ms" "$_chain" "$_rm")" \
587 "$_c" "$_chain" "$_ms" "$_cls" "$_rm" >"$_dir/$_c"
588 }
589
590 # Picks the dest this server should impersonate, then STICKS to it. The choice
591 # is persisted because every share link carries `sni=`: silently picking a
592 # different dest on a later boot would break every client already configured.
593 sni_auto() {
594 # The region that matters is where your clients connect FROM -- the DPI that
595 # judges the traffic is theirs, not your server's. We default to the
596 # server's own country because most people put the server near or relevant
597 # to their users; CLIENT_REGION overrides it.
598 _loc=${CLIENT_REGION:-${1:-}}
599 _dir=$(mktemp -d)
600 printf 'selecting the best dest for this server'
601 [ -n "$_loc" ] && printf ' (traffic observed from %s)' "$_loc"
602 printf ' ...\n'
603
604 _regional=$(sni_regional "$_loc")
605 for _e in $_regional; do sni_probe "$_e" 1 "$_dir" & done
606 for _e in $SNI_GLOBAL; do sni_probe "$_e" 0 "$_dir" & done
607 wait
608
609 _best=$(cat "$_dir"/* 2>/dev/null | sort -n | head -1)
610 rm -rf "$_dir"
611
612 if [ -n "$_best" ]; then
613 # shellcheck disable=SC2086
614 set -- $_best
615 _sni=$2 _chain=$3 _rtt=$4 _cls=$5 _rm=$6
616 case "$_cls" in
617 bulk) _what="sustained heavy traffic is unremarkable here" ;;
618 mixed) _what="media-heavy site, moderate traffic is unremarkable" ;;
619 *) _what="LAST RESORT -- bulk traffic to this name looks odd; re-run 'transtation sni auto' from a less restricted network" ;;
620 esac
621 [ "$_rm" = 1 ] && _where="same region as your clients" || _where="global"
622 _reason="$_what; $_where; ${_rtt}ms TLS handshake, ${_chain}-byte chain"
623 else
624 _sni=$SNI_FALLBACK
625 _reason="FALLBACK -- nothing could be probed (no network yet?). Re-run: transtation sni auto"
626 fi
627
628 sni_write "$_sni" "$_reason"
629 printf ' chose %s\n %s\n' "$_sni" "$_reason"
630 }
631
632 sni_write() {
633 printf '%s' "$1" | grep -Eq '^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$' ||
634 die "not a hostname: $(q "$1")"
635 case "$1" in
636 *apple* | *icloud*) die "Xray warns that apple/icloud targets get your IP blocked by the GFW." ;;
637 esac
638 # The reason is free text built partly from CLIENT_REGION; a newline in it
639 # would inject a second KEY=value line into this file.
640 _r=$(printf '%s' "$2" | tr -d '\n\r')
641 _t=$(mktemp "$DATA/.sni.XXXXXX")
642 printf 'SNI=%s\nSNI_REASON=%s\nSNI_CHOSEN=%s\n' "$1" "$_r" "$(date -u +%F)" >"$_t"
643 chmod 600 "$_t"
644 mv -f "$_t" "$SNIFILE"
645 }
646
647 sni_load() {
648 SNI=""; SNI_REASON=""; SNI_CHOSEN=""
649 while IFS='=' read -r _k _v; do
650 case "$_k" in
651 SNI) SNI=$_v ;;
652 SNI_REASON) SNI_REASON=$_v ;;
653 SNI_CHOSEN) SNI_CHOSEN=$_v ;;
654 esac
655 done <"$SNIFILE" 2>/dev/null
656 [ -n "$SNI" ] || die "$SNIFILE is empty or corrupt -- fix it with: transtation sni auto"
657 }
658
659 cmd_sni() {
660 case "${1:-}" in
661 '')
662 [ -s "$SNIFILE" ] || die "no stored selection -- the SNI is coming from your .env, or the server has not started yet"
663 sni_load
664 printf ' sni %s\n why %s\n since %s\n' "$SNI" "${SNI_REASON:-?}" "${SNI_CHOSEN:-?}"
665 ;;
666 auto | *.*)
667 [ -z "${SNI:-}" ] || warn "SNI is set in your .env; that overrides whatever is stored here."
668 cat >&2 <<-'EOF'
669 Changing the dest invalidates every share link already issued: clients send
670 the old name in their ClientHello and the server will no longer answer to it.
671 Re-issue them afterwards with 'transtation show'.
672 EOF
673 if [ "$1" = auto ]; then
674 _loc=$(curl -fsS --max-time 8 https://cloudflare.com/cdn-cgi/trace 2>/dev/null | sed -n 's/^loc=//p')
675 sni_auto "$_loc"
676 else
677 sni_write "$1" "set by hand"
678 printf ' set to %s\n' "$1"
679 fi
680 apply
681 ;;
682 *) die "usage: transtation sni [auto | <domain>]" ;;
683 esac
684 }
685
686 # The only check that exercises the actual REALITY path. The healthcheck cannot:
687 # it goes through the loopback SOCKS inbound and never touches REALITY at all,
688 # so a broken dest passes it forever.
689 cmd_verify() {
690 load_state
691 load_runtime
692 [ -s "$USERS" ] || die "no users"
693 _u=$(head -1 "$USERS" | cut -f2)
694 _s=$(head -1 "$USERS" | cut -f3)
695 _d=$(mktemp -d)
696 cat >"$_d/c.json" <<-EOF
697 {
698 "log": { "loglevel": "none" },
699 "inbounds": [{ "listen": "127.0.0.1", "port": 10809, "protocol": "socks",
700 "settings": { "auth": "noauth", "udp": false } }],
701 "outbounds": [{
702 "protocol": "vless",
703 "settings": { "vnext": [{ "address": "127.0.0.1", "port": ${LISTEN_PORT:-$INTERNAL_PORT},
704 "users": [{ "id": "$_u", "encryption": "none", "flow": "xtls-rprx-vision" }] }] },
705 "streamSettings": { "network": "tcp", "security": "reality",
706 "realitySettings": { "fingerprint": "chrome", "serverName": "$SNI",
707 "publicKey": "$REALITY_PUB", "shortId": "$_s", "spiderX": "/" } }
708 }]
709 }
710 EOF
711 xray run -format json -c "$_d/c.json" >/dev/null 2>&1 &
712 _pid=$!
713 # The 2s settle is measured, not arbitrary. If the probe client is not ready
714 # its first outbound sits on a ~5s retry backoff, so racing to the earliest
715 # request is slower than waiting:
716 # settle 2.0s, then request : 2.1s <- what install.sh actually pays
717 # settle 0.5s, 3s cap, retry: 4.1s on a cold boot (0.6s once warm)
718 # Repeat calls could be made faster, but the call that matters happens once,
719 # right after installation, and that one is fastest this way. The retries
720 # below are for hosts slower than the ones this was measured on, not speed.
721 _out=""
722 _i=0
723 while [ "$_i" -lt 6 ]; do
724 sleep 2
725 _out=$(curl -fsS --max-time 10 --socks5-hostname 127.0.0.1:10809 https://cloudflare.com/cdn-cgi/trace 2>/dev/null) && break
726 _out=""
727 _i=$((_i + 1))
728 done
729 kill "$_pid" 2>/dev/null || true
730 rm -rf "$_d"
731
732 if [ -z "$_out" ]; then
733 die "REALITY handshake FAILED against this server's own inbound.
734 Clients will not be able to connect, however healthy the container looks.
735 The usual cause is the dest: $SNI
736 Check its certificate chain -- above roughly $CHAIN_LIMIT bytes REALITY cannot
737 complete the handshake:
738 xray tls ping $SNI
739 Then pick a smaller one (dl.google.com, www.cloudflare.com, addons.mozilla.org),
740 set SNI= in $(env_hint), and '$(restart_hint)'."
741 fi
742 printf 'REALITY handshake OK. Egress through the tunnel: %s\n' \
743 "$(echo "$_out" | grep -E '^(ip|colo|warp)=' | tr '\n' ' ')"
744 }
745
746 # ------------------------------------------------------------------- health
747
748 trace() { curl -fsS --max-time 8 --socks5-hostname "$HEALTH_ADDR:$HEALTH_PORT" https://cloudflare.com/cdn-cgi/trace; }
749
750 cmd_health() {
751 load_runtime
752 _t=$(trace) || { echo "no egress at all through the proxy"; exit 1; }
753 if [ "$WARP_READY" = 1 ]; then
754 echo "$_t" | grep -qx 'warp=on' || {
755 cat >&2 <<-'EOF'
756 Reality is up, but the WARP tunnel is not passing traffic.
757 - some VPS providers block outbound UDP/2408 (registration is TCP, so it
758 succeeded anyway and this only shows up now)
759 - the WireGuard stack itself is fine: this image pins "noKernelTun": true, so
760 it always uses Xray's in-process userspace stack no matter what
761 capabilities the container holds.
762 fix: set WARP=0 in $(env_hint) and run '$(restart_hint)'. The proxy works
763 without WARP; it just egresses from this VPS's own IP.
764 EOF
765 exit 1
766 }
767 fi
768 exit 0
769 }
770
771 cmd_status() {
772 load_state
773 load_runtime
774 printf '\n server %s:%s\n' "$HOST" "$PORT"
775 printf ' dest / SNI %s\n' "$SNI"
776 printf ' %s\n' "${SNI_REASON:-unknown}"
777 printf ' clients minClientVer=%s\n' "${MIN_CLIENT_VER:-0.0.0}"
778 printf ' identity created %s\n' "${CREATED:-?}"
779
780 if [ "$WARP" = 1 ] && [ "$WARP_READY" = 0 ]; then
781 printf ' egress DIRECT -- WARP was requested but is NOT registered.\n'
782 printf ' Every packet leaves from this VPS IP. Fix: transtation warp register\n'
783 elif _t=$(trace 2>/dev/null); then
784 printf ' egress %s\n' "$(echo "$_t" | grep -E '^(ip|colo|loc|warp)=' | tr '\n' ' ')"
785 else
786 printf ' egress UNREACHABLE -- no traffic is getting out through the proxy\n'
787 fi
788
789 if [ "$TT_MANAGED" = docker ]; then
790 if [ -r /proc/1/comm ] && [ "$(cat /proc/1/comm)" = xray ]; then
791 printf ' xray running\n'
792 else
793 printf ' xray NOT running as PID 1 of this container\n'
794 fi
795 elif command -v systemctl >/dev/null 2>&1 && systemctl is-active --quiet transtation 2>/dev/null; then
796 printf ' xray running under transtation.service\n'
797 else
798 printf ' xray NOT running\n'
799 fi
800 printf ' users %s\n' "$(awk -F'\t' 'NF>=3{printf "%s%s", (n++?", ":""), $1}' "$USERS" 2>/dev/null)"
801 printf ' inbound not verifiable from this host. If clients time out with no server\n'
802 printf ' log line, open TCP %s in your provider firewall / security group.\n\n' "$PORT"
803 }
804
805 # ------------------------------------------------------------------ backup
806
807 cmd_backup() {
808 # stdout only -- the caller decides where it lands and with what umask.
809 [ -f "$STATE" ] || die "nothing to back up yet"
810 # sni.chosen is included on purpose: every share link carries `sni=`, so a
811 # restore that re-picked a different dest would invalidate all of them.
812 # shellcheck disable=SC2046 # word splitting is the point: skip absent files
813 tar -cz -C "$DATA" \
814 state.env users.tsv \
815 $([ -f "$SNIFILE" ] && echo sni.chosen) \
816 $([ -f "$ACCOUNT" ] && echo wgcf-account.toml) \
817 $([ -f "$PROFILE" ] && echo wgcf-profile.conf)
818 }
819
820 cmd_reset() {
821 [ "${I_UNDERSTAND_THIS_DESTROYS_MY_KEYS:-}" = yes ] || die \
822 "refusing. This destroys your Reality keypair and every issued link.
823 If you are certain:
824 I_UNDERSTAND_THIS_DESTROYS_MY_KEYS=yes transtation reset"
825 rm -f "$STATE" "$USERS" "$CONF" "$RUNTIME" "$ACCOUNT" "$PROFILE" "$SNIFILE"
826 echo "identity destroyed. The next start provisions a brand new server."
827 }
828
829 # ------------------------------------------------------------------ selftest
830
831 # Runs offline. This is the CI gate: it proves the rendered config is accepted
832 # by the pinned xray with WARP both on and off, that the x25519 parser survives
833 # every historical output format, and that the share URI is complete.
834 cmd_selftest() {
835 _tmp=$(mktemp -d)
836 trap 'rm -rf "$_tmp"' EXIT
837 TT_DATA=$_tmp
838 set_paths
839 _fail=0
840 # Never `cmd; _check $?` -- under `set -e` a failing test aborts the run
841 # instead of reporting it. Always evaluate inside the if.
842 _assert() { _d=$1; shift; if "$@"; then printf ' ok %s\n' "$_d"; else printf ' FAIL %s\n' "$_d"; _fail=1; fi; }
843 _jq() { jq -e "$1" "$CONF" >/dev/null 2>&1; }
844 # Subshell so that write_config's `die` cannot take the whole selftest with it.
845 _renders() { (WARP=$1; write_config) >/dev/null 2>&1; }
846
847 echo "transtation selftest"
848
849 # 1. x25519 parser against all three historical output formats.
850 _assert "x25519 parser: <=25.6.8 format" \
851 test "$(printf 'Private key: AAA\nPublic key: BBB\n' | parse_x25519)" = "AAA BBB"
852 _assert "x25519 parser: 25.9-26.1 format" \
853 test "$(printf 'PrivateKey: AAA\nPassword: BBB\n' | parse_x25519)" = "AAA BBB"
854 _assert "x25519 parser: >=26.3.27 format (Hash32 ignored)" \
855 test "$(printf 'PrivateKey: AAA\nPassword (PublicKey): BBB\nHash32: CCC\n' | parse_x25519)" = "AAA BBB"
856
857 # 2. real key generation reaches the 43-char assert.
858 provision_identity
859 load_state
860 _assert "generated x25519 keypair is 43 chars each" \
861 test "${#REALITY_PRIV}${#REALITY_PUB}" = "4343"
862
863 # 3. render + xray validation, WARP OFF.
864 user_add alice >/dev/null
865 user_add bob >/dev/null
866 HOST=203.0.113.10
867 PORT=443
868 SNI=www.microsoft.com
869 DEST=www.microsoft.com:443
870 _assert "config renders and xray accepts it (WARP off)" _renders 0
871 _assert "no warp-out outbound emitted when WARP is unregistered" \
872 _jq '.outbounds | map(.tag) | index("warp-out") == null'
873 _assert "final route falls back to direct when WARP is off" \
874 _jq '.routing.rules[-1].outboundTag == "direct"'
875
876 # 4. render + xray validation, WARP ON (synthetic profile).
877 printf '[Interface]\nPrivateKey = %s\nAddress = 172.16.0.2/32, 2606:4700:110:1:2:3:4:5/128\n' \
878 "$(head -c 32 /dev/urandom | base64)" >"$PROFILE"
879 _assert "config renders and xray accepts it (WARP on)" _renders 1
880 _assert "warp outbound pins noKernelTun (userspace gVisor stack)" \
881 _jq '.outbounds[0].settings.noKernelTun == true'
882 _assert "warp outbound does not random-pick between A and AAAA" \
883 _jq '.outbounds[0].settings.domainStrategy == "ForceIPv4v6"'
884 _assert "warp endpoint is a literal IP, not a hostname" \
885 _jq '.outbounds[0].settings.peers[0].endpoint | test("^[0-9.]+:[0-9]+$")'
886
887 # 5. invariants that protect users.
888 _assert "shortIds never contains the empty string" \
889 _jq '.inbounds[0].streamSettings.realitySettings.shortIds | index("") == null'
890 _assert "every shortId is even-length hex, at most 16 chars" \
891 _jq '.inbounds[0].streamSettings.realitySettings.shortIds | all(test("^([0-9a-f]{2}){1,8}$"))'
892 _assert "minClientVer pinned to 0.0.0 (no client silently rejected)" \
893 _jq '.inbounds[0].streamSettings.realitySettings.minClientVer == "0.0.0"'
894 _assert "outbound port 25 blocked (no open SMTP relay)" \
895 _jq '[.routing.rules[] | select(.port == "25")] | length == 1'
896 _assert "cloud metadata range 169.254.0.0/16 blocked" \
897 _jq '[.routing.rules[] | select(.outboundTag == "block") | .ip // []] | flatten | index("169.254.0.0/16")'
898 _assert "logs go to stdout, not an unrotated file" _jq '.log.error == ""'
899 _assert "health SOCKS inbound is loopback-only" _jq '.inbounds[1].listen == "127.0.0.1"'
900 # config.json inlines the REALITY private key and the WireGuard private key.
901 # It was 0644 once, copied out of habit from a setup where xray ran as a
902 # different user than the file's owner. Here it does not.
903 _perm600() { [ "$(stat -c %a "$1" 2>/dev/null)" = 600 ]; }
904 _assert "config.json is 0600 (it inlines both private keys)" _perm600 "$CONF"
905 _assert "state.env is 0600" _perm600 "$STATE"
906 _assert "users.tsv is 0600" _perm600 "$USERS"
907 _assert "two users produce two clients and two shortIds" \
908 _jq '(.inbounds[0].settings.clients | length) == 2 and (.inbounds[0].streamSettings.realitySettings.shortIds | length) == 2'
909
910 # 6. SNI auto-selection: the weighting, the table, and the write boundary.
911 _empty_region() { [ -z "$(sni_regional "$1")" ]; }
912 _region_has_class() { case " $(sni_regional "$1") " in *" $2:"*) return 0 ;; *) return 1 ;; esac; }
913 _assert "regional table lists a bulk-traffic dest for a known country" _region_has_class KR bulk
914 _assert "regional table is empty for an unlisted country" _empty_region ZZ
915
916 # Every entry must parse as class:domain with a class the scorer knows,
917 # or it silently scores as "light" and never gets picked.
918 _table_well_formed() {
919 for _e in $(sni_regional JP) $(sni_regional KR) $(sni_regional US) $(sni_regional GB) \
920 $(sni_regional SG) $(sni_regional IN) $(sni_regional BR) $(sni_regional AU) $SNI_GLOBAL; do
921 case "$_e" in
922 bulk:?*.?* | mixed:?*.?* | light:?*.?*) ;;
923 *) return 1 ;;
924 esac
925 done
926 }
927 _assert "every candidate is class:domain with a known class" _table_well_formed
928 _assert "the fallback dest is itself a probed global candidate" \
929 sh -c "case ' $SNI_GLOBAL ' in *':$SNI_FALLBACK '*) exit 0 ;; *) exit 1 ;; esac"
930
931 # The weighting is the whole point of this design, so it gets tested rather
932 # than trusted. A far-away video service must beat a nearby corporate page:
933 # that is exactly the swap that stopped the traffic being dropped.
934 _beats() { [ "$(sni_score "$1" "$2" "$3" "$4")" -lt "$(sni_score "$5" "$6" "$7" "$8")" ]; }
935 _assert "a distant bulk dest beats a fast light one" \
936 _beats bulk 600 4000 0 light 30 900 0
937 _assert "a distant bulk dest beats a fast mixed one" \
938 _beats bulk 450 4000 0 mixed 40 900 0
939 _assert "between two bulk dests, the nearer one wins" \
940 _beats bulk 80 2000 0 bulk 300 2000 0
941 _assert "a same-region dest wins a close race" \
942 _beats bulk 200 2000 1 bulk 100 2000 0
943 _assert "but the region bonus does not override a big latency gap" \
944 _beats bulk 100 2000 0 bulk 400 2000 1
945 _assert "a smaller certificate chain breaks a tie" \
946 _beats bulk 100 900 0 bulk 100 4400 0
947 _assert "scores never go negative" test "$(sni_score bulk 10 100 1)" = 0
948
949 # Same injection class as the client's link parser, arriving by environment.
950 _env_rejects() {
951 (SNI=www.cloudflare.com DEST="" PORT=443 HOST=203.0.113.10
952 eval "$1=\$2"
953 validate_config_env) >/dev/null 2>&1 && return 1 || return 0
954 }
955 _assert "an IPv6 HOST is refused (the rest of the stack is IPv4-only)" \
956 _env_rejects HOST '2406:da14:157d:8900::1'
957 _assert "MIN_CLIENT_VER is not free text" _env_rejects MIN_CLIENT_VER '0.0.0", "show": true, "x": "'
958 _assert "WARP_ENDPOINT must be an IPv4 address and port" _env_rejects WARP_ENDPOINT 'evil.example:2408'
959
960 _sni_rejects() { (sni_write "$1" test) >/dev/null 2>&1 && return 1 || return 0; }
961 _assert "sni_write rejects a non-hostname" _sni_rejects 'not a host; rm -rf /'
962 _assert "sni_write rejects apple targets (Xray warns they get the IP blocked)" \
963 _sni_rejects www.apple.com
964 sni_write www.cloudflare.com "a reason with spaces and 'quotes'"
965 sni_load
966 _assert "sni_write round-trips through sni_load" test "$SNI" = www.cloudflare.com
967 _assert "a reason containing spaces survives" \
968 sh -c "case \"$SNI_REASON\" in *spaces*) exit 0 ;; *) exit 1 ;; esac"
969 _perm600b() { [ "$(stat -c %a "$SNIFILE" 2>/dev/null)" = 600 ]; }
970 _assert "sni.chosen is 0600" _perm600b
971
972 # 7. share URI completeness.
973 _u=$(head -1 "$USERS" | cut -f2)
974 _s=$(head -1 "$USERS" | cut -f3)
975 _uri=$(share_uri alice "$_u" "$_s")
976 _missing=""
977 for _k in encryption=none flow=xtls-rprx-vision security=reality sni= fp=chrome pbk= sid= spx= type=tcp headerType=none; do
978 case "$_uri" in *"$_k"*) ;; *) _missing="$_missing $_k" ;; esac
979 done
980 _assert "share URI carries all 10 parameters (${_missing:-none missing})" test -z "$_missing"
981 # `_assert desc ! cmd` cannot work -- `!` is a shell keyword and does not
982 # survive "$@" expansion -- so the negation lives inside the helper.
983 _uri_params_nonempty() { case "$_uri" in *"=&"* | *"=#"*) return 1 ;; *) return 0 ;; esac; }
984 _assert "share URI has no empty parameter values" _uri_params_nonempty
985
986 echo
987 [ "$_fail" = 0 ] || die "selftest FAILED"
988 echo "selftest passed"
989 }
990
991 # Everything here is interpolated into the generated JSON. It arrives from the
992 # operator rather than from a stranger, but a .env copied off a blog post is not
993 # meaningfully more trustworthy than a link someone sends you, and each check is
994 # one line. Kept as its own function so the selftest can attack it directly.
995 validate_config_env() {
996 case "${HOST:-}" in
997 *:*) die "HOST is an IPv6 address ($(q "$HOST")). transtation is IPv4-only for now:
998 the client resolves A records only and the killswitch refuses an IPv6
999 server, so a link naming this address would not work. Set HOST to an IPv4
1000 address or a name with an A record." ;;
1001 esac
1002 printf '%s' "$SNI" | grep -Eq '^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$' ||
1003 die "SNI must be a hostname, got: $(q "$SNI")"
1004 case "$SNI" in
1005 *apple* | *icloud*) die "Xray warns that apple/icloud targets get your IP blocked by the GFW. Pick another SNI." ;;
1006 esac
1007 DEST=${DEST:-$SNI:443}
1008 printf '%s' "$DEST" | grep -Eq '^[A-Za-z0-9.-]+:[0-9]{1,5}$' || die "DEST must be host:port, got: $(q "$DEST")"
1009 PORT=${PORT:-443}
1010 printf '%s' "$PORT" | grep -Eq '^[0-9]{1,5}$' && [ "$PORT" -ge 1 ] && [ "$PORT" -le 65535 ] ||
1011 die "PORT must be 1-65535, got: $(q "$PORT")"
1012 # In a container we listen high and let dockerd perform the privileged bind,
1013 # which is why the image needs no capability at all. Natively there is
1014 # nothing in front of us, so we listen on the real port.
1015 [ "$TT_MANAGED" = docker ] && LISTEN_PORT=$INTERNAL_PORT || LISTEN_PORT=$PORT
1016 printf '%s' "${MIN_CLIENT_VER:-0.0.0}" | grep -Eq '^[0-9]{1,3}(\.[0-9]{1,3}){0,2}$' ||
1017 die "MIN_CLIENT_VER must look like 0.0.0, got: $(q "${MIN_CLIENT_VER:-}")"
1018 # Deliberately IPv4:port only, never a hostname: resolving the WARP endpoint
1019 # is exactly what produced the random IPv6 failures this design removed.
1020 printf '%s' "${WARP_ENDPOINT:-$WARP_ENDPOINT_DEFAULT}" | grep -Eq '^[0-9]{1,3}(\.[0-9]{1,3}){3}:[0-9]{1,5}$' ||
1021 die "WARP_ENDPOINT must be an IPv4 address and port, got: $(q "${WARP_ENDPOINT:-}")"
1022 }
1023
1024 # --------------------------------------------------------------------- run
1025
1026 banner() {
1027 printf '\n transtation -- listening on 0.0.0.0:%s\n' "${LISTEN_PORT:-$INTERNAL_PORT}"
1028 printf ' server %s:%s\n' "$HOST" "$PORT"
1029 printf ' dest / SNI %s (%s)\n' "$SNI" "${SNI_REASON:-unknown}"
1030 if [ "${WARP:-1}" = 1 ] && ! warp_ready; then
1031 printf ' egress DIRECT -- WARP requested but NOT registered (see the warning above)\n'
1032 elif warp_ready; then
1033 printf ' egress WARP (Cloudflare)\n'
1034 else
1035 printf ' egress DIRECT (WARP=0)\n'
1036 fi
1037 printf ' users %s\n' "$(awk -F'\t' 'NF>=3{printf "%s%s", (n++?", ":""), $1}' "$USERS")"
1038 printf ' share link transtation show (never printed here -- it is a credential)\n\n'
1039 }
1040
1041 cmd_run() {
1042 [ -d "$DATA" ] || die "$DATA does not exist"
1043 [ -w "$DATA" ] || die \
1044 "$DATA is not writable by uid $(id -u).
1045 If you replaced the named volume with a bind mount, either chown it:
1046 chown -R 10001:10001 ./data
1047 or switch back to the named volume, which inherits the right ownership."
1048 umask 077
1049
1050 # --- identity: gated on its own artifact, written last and atomically ---
1051 [ -s "$STATE" ] || provision_identity
1052 load_state
1053 [ "${SCHEMA:-0}" -le "$SCHEMA_SUPPORTED" ] || die \
1054 "data schema $SCHEMA is newer than this image supports ($SCHEMA_SUPPORTED).
1055 You pinned back to an older tag. Pull the newer one, or restore a backup."
1056 migrate
1057
1058 # --- WARP registration depends on nothing else here and is the single
1059 # slowest step (about 4s), so start it now and collect it after the dest has
1060 # been chosen. First boot pays max(warp, dest) instead of the sum.
1061 _warp_pid=""
1062 if warp_wanted && [ ! -f "$PROFILE" ]; then
1063 warp_register &
1064 _warp_pid=$!
1065 fi
1066
1067 # --- where are we? one request, used for both the address and the region ---
1068 # -4 is not optional. On a dual-stack host curl prefers IPv6, so the address
1069 # discovered here -- the one that goes into every share link -- came back as
1070 # an IPv6 literal, which nothing else in this stack can use: the client
1071 # resolves A records only, its DNS strategy is ipv4_only, and the killswitch
1072 # refuses an IPv6 server outright. A container hid this, because Docker's
1073 # default bridge is IPv4-only.
1074 _trace=$(curl -4 -fsS --max-time 8 https://cloudflare.com/cdn-cgi/trace 2>/dev/null) || _trace=""
1075 [ -n "${HOST:-}" ] || HOST=$(printf '%s' "$_trace" | sed -n 's/^ip=//p')
1076 [ -n "${HOST:-}" ] || die \
1077 "could not auto-detect this host's public IPv4 address.
1078 Set HOST=<your.ip.or.domain> in $(env_hint) and start again.
1079 (transtation is IPv4-only for now; an IPv6-only host is not supported.)"
1080 LOC=$(printf '%s' "$_trace" | sed -n 's/^loc=//p')
1081
1082 # --- SNI: env override, else the sticky auto-selection, else pick now ---
1083 _picked_now=0
1084 if [ -n "${SNI:-}" ]; then
1085 SNI_REASON="set in your .env"
1086 elif [ -s "$SNIFILE" ]; then
1087 sni_load
1088 else
1089 sni_auto "$LOC"
1090 sni_load
1091 _picked_now=1
1092 fi
1093
1094 validate_config_env
1095
1096 # The auto-selection already probed and filtered on exactly these criteria,
1097 # so re-running the check would just be a second round-trip.
1098 [ "$_picked_now" = 1 ] || [ "${SKIP_DEST_CHECK:-0}" = 1 ] || check_dest
1099
1100 # --- first user: gated on users.tsv, not on state.env ---------------
1101 # This separation is the whole point. If provisioning is interrupted between
1102 # writing the identity and creating the first user, the next start resumes
1103 # instead of booting into a config with an empty shortIds array, which xray
1104 # rejects outright and which turns into a crash loop you can only escape by
1105 # destroying the volume.
1106 [ -s "$USERS" ] || user_add "${USER_NAME:-main}"
1107
1108 # --- collect the WARP registration started above ---------------------
1109 [ -z "$_warp_pid" ] || wait "$_warp_pid" 2>/dev/null || true
1110
1111 write_config
1112 write_runtime
1113 banner
1114 exec xray run -c "$CONF"
1115 }
1116
1117 # --------------------------------------------------------------------- cli
1118
1119 usage() {
1120 cat <<-'EOF'
1121 transtation -- VLESS + REALITY proxy server
1122
1123 run start the server (default; this is what the image does)
1124 show [name] print the share link and QR for a user, or for all users
1125 status server address, SNI, egress IP, users, WARP state
1126 health exit 0 if traffic (and WARP, if enabled) is getting out
1127 verify do a real REALITY handshake against this server -- the
1128 only check that proves clients can actually connect
1129 sni show which dest this server impersonates, and why
1130 sni auto re-run the selection (invalidates existing links)
1131 sni <domain> set the dest by hand (invalidates existing links)
1132 user add <name> create a user with a fresh UUID and shortId, then apply
1133 user rm <name> revoke a user, then apply
1134 user ls list users
1135 warp register register or re-register a Cloudflare WARP identity
1136 backup write a tar.gz of the identity to stdout
1137 reset destroy the identity (requires an explicit env var)
1138 selftest offline self-check; no network, no state touched
1139 EOF
1140 }
1141
1142 case "${1:-run}" in
1143 run) cmd_run ;;
1144 show) shift; cmd_show "${1:-}" ;;
1145 status) cmd_status ;;
1146 health) cmd_health ;;
1147 verify) cmd_verify ;;
1148 sni) shift; cmd_sni "${1:-}" ;;
1149 backup) cmd_backup ;;
1150 reset) cmd_reset ;;
1151 selftest) cmd_selftest ;;
1152 user)
1153 shift
1154 case "${1:-}" in
1155 add) shift; user_add "${1:-}"; apply ;;
1156 rm) shift; user_rm "${1:-}"; apply ;;
1157 ls | '') user_ls ;;
1158 *) die "unknown: user $1" ;;
1159 esac
1160 ;;
1161 warp)
1162 shift
1163 case "${1:-}" in
1164 register)
1165 # No pre-emptive delete: warp_register promotes its result only on
1166 # success, so a failed retry leaves a working identity in place.
1167 warp_register && { echo "WARP registered."; apply; }
1168 ;;
1169 *) die "unknown: warp ${1:-}" ;;
1170 esac
1171 ;;
1172 -h | --help | help) usage ;;
1173 *) usage; die "unknown command: $1" ;;
1174 esac