transtation.git

transtation

~ Your own VLESS + REALITY proxy with optional Cloudflare WARP egress. Server in a container or native; native client with a fail-closed killswitch.
git clone https://github.com/debuman/transtation.git

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