#!/bin/sh
# transtation -- self-provisioning VLESS + REALITY proxy server, optional Cloudflare WARP egress.
# https://github.com/sqzer-x/transtation
#
# Everything in /data except config.json and runtime.env is IDENTITY: written
# once, atomically, and never rewritten. Everything else is DERIVED and
# regenerated from this image on every boot. That split is what makes an image
# upgrade safe and a half-finished first boot resumable.
set -eu

SCHEMA_SUPPORTED=1
INTERNAL_PORT=8443
HEALTH_ADDR=127.0.0.1
HEALTH_PORT=10808
# Cloudflare's public WARP peer key. Not a secret; identical for every user.
WARP_PEER_PUBKEY='bmXOC+F1FxEMF9dyiK2H5/1SUtzH0JuVo51h2wPfgyo='
WARP_ENDPOINT_DEFAULT='162.159.192.1:2408'
TAB=$(printf '\t')
# How this instance is supervised. The image sets TT_MANAGED=docker; a native
# install leaves it unset and is managed by systemd. It decides how the server
# is restarted and which file the operator edits -- and, critically, that
# `kill -TERM 1` is only ever right inside a container. On a host, PID 1 is
# init.
TT_MANAGED=${TT_MANAGED:-systemd}
restart_hint() { [ "$TT_MANAGED" = docker ] && printf 'docker compose up -d' || printf 'systemctl restart transtation'; }
env_hint() { [ "$TT_MANAGED" = docker ] && printf '/opt/transtation/.env' || printf '/etc/transtation/server.env'; }
# Is the server up, or on its way up? Under docker this command only runs at all
# because `docker exec` found a live container, so the answer is yes by
# construction; natively, ask the init system.
server_starting() {
	[ "$TT_MANAGED" = docker ] && return 0
	systemctl is-active --quiet transtation 2>/dev/null
}

set_paths() {
	# The container mounts its volume at /data; a native install keeps state
	# where a system service should. Getting this wrong sends `transtation show`
	# looking at an empty directory.
	if [ -n "${TT_DATA:-}" ]; then
		DATA=$TT_DATA
	elif [ "$TT_MANAGED" = docker ]; then
		DATA=/data
	else
		DATA=/var/lib/transtation
	fi
	STATE="$DATA/state.env"
	USERS="$DATA/users.tsv"
	CONF="$DATA/config.json"
	RUNTIME="$DATA/runtime.env"
	ACCOUNT="$DATA/wgcf-account.toml"
	PROFILE="$DATA/wgcf-profile.conf"
	SNIFILE="$DATA/sni.chosen"
}
set_paths

die() { printf 'transtation: %s\n' "$*" >&2; exit 1; }
warn() { printf 'transtation: %s\n' "$*" >&2; }
# Values echoed back at the operator are stripped of control characters first.
q() { printf '%s' "$1" | tr -dc '[:print:]' | cut -c1-64; }

# State files are PARSED, never `.`-sourced: the values include free text such
# as "best of KR-region candidates", and sourcing that runs `of` as a command.
# Parsing also means a tampered state file cannot execute code. Each loader
# reads its file once with the shell's own `read` rather than spawning a
# process per key.

# ---------------------------------------------------------------- identity

# Xray has shipped three different `xray x25519` output formats:
#   <=25.6.8   "Private key: <b64>"        / "Public key: <b64>"
#   25.9-26.1  "PrivateKey: <b64>"         / "Password: <b64>"
#   >=26.3.27  "PrivateKey: <b64>"         / "Password (PublicKey): <b64>"  + "Hash32: <b64>"
# Parse by label, never by position. The ^ anchors keep "Password (PublicKey)"
# from also matching the private rule, and keep Hash32 -- which belongs to VLESS
# Encryption, not REALITY -- from matching either.
parse_x25519() {
	awk -F':[[:space:]]*' '
		/^Private ?[Kk]ey/            { p = $2 }
		/^(Public ?[Kk]ey|Password)/  { q = $2 }
		END { gsub(/[[:space:]]/, "", p); gsub(/[[:space:]]/, "", q); print p " " q }'
}

provision_identity() {
	_pair=$(xray x25519 | parse_x25519)
	_priv=${_pair% *}
	_pub=${_pair#* }
	# An x25519 key is 32 bytes in base64url without padding == 43 characters.
	# This assert is the tripwire for a fourth output format.
	[ "${#_priv}" -eq 43 ] && [ "${#_pub}" -eq 43 ] || die \
		"could not parse 'xray x25519' (priv=${#_priv}ch pub=${#_pub}ch).
    Xray changed its output format. Open an issue with the output of: xray x25519"

	_t=$(mktemp "$DATA/.state.XXXXXX")
	printf 'SCHEMA=%s\nREALITY_PRIV=%s\nREALITY_PUB=%s\nCREATED=%s\n' \
		"$SCHEMA_SUPPORTED" "$_priv" "$_pub" "$(date -u +%F)" >"$_t"
	chmod 600 "$_t"
	# Atomic. The existence of state.env IS the "identity provisioned" marker,
	# so it must appear only once everything in it is durable.
	mv -f "$_t" "$STATE"
}

migrate() {
	# case "$SCHEMA" in 1) ...upgrade to 2...;& 2) ...;; esac -- nothing yet.
	:
}

# ------------------------------------------------------------------- users

user_add() {
	_name=${1:?usage: transtation user add <name>}
	case "$_name" in
		'' | *[!A-Za-z0-9_-]*) die "name must match [A-Za-z0-9_-]{1,32}" ;;
	esac
	[ "${#_name}" -le 32 ] || die "name must be 32 characters or fewer"
	if [ -f "$USERS" ] && grep -q "^$_name$TAB" "$USERS"; then
		die "user '$_name' already exists"
	fi
	_uuid=$(xray uuid)
	# 8 random bytes == 16 hex chars, the maximum REALITY accepts. Never "",
	# which hex-decodes to the same all-zero shortId as "00" and leaves the
	# public key -- which is printed in every share link you hand out -- as the
	# only thing gating the handshake.
	_sid=$(head -c 8 /dev/urandom | od -An -tx1 | tr -d ' \n')
	umask 077
	printf '%s\t%s\t%s\n' "$_name" "$_uuid" "$_sid" >>"$USERS"
	chmod 600 "$USERS"
	printf 'added user %s\n' "$_name"
}

user_rm() {
	_name=${1:?usage: transtation user rm <name>}
	[ -f "$USERS" ] || die "no users file"
	grep -q "^$_name$TAB" "$USERS" || die "no such user: $_name"
	_n=$(wc -l <"$USERS")
	[ "$_n" -gt 1 ] || die "refusing to remove the last user -- the server cannot start with none"
	_t=$(mktemp "$DATA/.users.XXXXXX")
	grep -v "^$_name$TAB" "$USERS" >"$_t"
	chmod 600 "$_t"
	mv -f "$_t" "$USERS"
	printf 'removed user %s (its UUID and shortId are both revoked)\n' "$_name"
}

user_ls() {
	[ -f "$USERS" ] || die "no users yet"
	printf 'NAME\tUUID\n'
	while IFS="$TAB" read -r n u _; do
		[ -n "$n" ] || continue
		printf '%s\t%s\n' "$n" "$u"
	done <"$USERS"
}

# ------------------------------------------------------------------ config

warp_wanted() { [ "${WARP:-1}" = 1 ]; }
warp_ready() { warp_wanted && [ -f "$PROFILE" ]; }

warp_register() {
	# ONE attempt. Never a retry loop: hammering Cloudflare's registration
	# endpoint is how a source IP -- historically a whole region -- gets blocked.
	#
	# Registers into temporary files and promotes them only on success. An
	# earlier version deleted the live profile first, so a rate-limited retry --
	# the single most likely outcome of running this by hand -- destroyed a
	# working WARP identity and silently degraded the server to direct egress on
	# its next restart.
	_ta="$DATA/.acct.$$"
	_tp="$DATA/.prof.$$"
	rm -f "$_ta" "$_tp"
	if wgcf register --accept-tos --config "$_ta" >/dev/null 2>&1 &&
		wgcf generate --config "$_ta" --profile "$_tp" >/dev/null 2>&1; then
		chmod 600 "$_ta" "$_tp"
		mv -f "$_ta" "$ACCOUNT"
		mv -f "$_tp" "$PROFILE"
		return 0
	fi
	rm -f "$_ta" "$_tp"
	warn "Cloudflare WARP registration failed (rate limit, or the API changed).
    Cloudflare rate-limits registration per source IP for roughly 15 minutes.
    Continuing with DIRECT egress from this VPS's own IP -- the proxy works.
      retry:            transtation warp register
      turn off for good: set WARP=0 in $(env_hint) and '$(restart_hint)'
    Any WARP identity you already had is untouched."
	return 1
}

render_warp_outbound() {
	_priv=$(sed -n 's/^[[:space:]]*PrivateKey[[:space:]]*=[[:space:]]*//p' "$PROFILE" | head -1)
	_addrs=$(sed -n 's/^[[:space:]]*Address[[:space:]]*=[[:space:]]*//p' "$PROFILE" | head -1)
	[ -n "$_priv" ] && [ -n "$_addrs" ] || die "$PROFILE is unreadable or truncated -- try: transtation warp register"
	_addr_json=$(printf '%s' "$_addrs" | tr ',' '\n' |
		sed 's/^[[:space:]]*//;s/[[:space:]]*$//' |
		awk 'NF { printf "%s\"%s\"", (n++ ? ", " : ""), $0 }')
	cat <<-EOF
		    {
		      "tag": "warp-out",
		      "protocol": "wireguard",
		      "settings": {
		        "secretKey": "$_priv",
		        "address": [$_addr_json],
		        "peers": [
		          {
		            "publicKey": "$WARP_PEER_PUBKEY",
		            "endpoint": "${WARP_ENDPOINT:-$WARP_ENDPOINT_DEFAULT}",
		            "allowedIPs": ["0.0.0.0/0", "::/0"],
		            "keepAlive": 25
		          }
		        ],
		        "mtu": 1280,
		        "noKernelTun": true,
		        "domainStrategy": "ForceIPv4v6"
		      }
		    },
	EOF
}

# NOTE: this image ships no geoip.dat / geosite.dat (they are 62% of the image
# and nothing else needed them). Any "geoip:" or "geosite:" rule added below
# will fail at startup with a file-not-found error. Use literal CIDRs.
render_config() {
	_clients=$(awk -F'\t' 'NF>=3 { printf "%s{ \"id\": \"%s\", \"flow\": \"xtls-rprx-vision\" }", (n++ ? ",\n          " : ""), $2 }' "$USERS")
	_sids=$(awk -F'\t' 'NF>=3 { printf "%s\"%s\"", (n++ ? ", " : ""), $3 }' "$USERS")
	[ -n "$_clients" ] || die "no users defined -- refusing to render a config REALITY would reject"

	if warp_ready; then
		_warp_out=$(render_warp_outbound)
		_final_out=warp-out
	else
		_warp_out=""
		_final_out=direct
	fi

	cat <<-EOF
		{
		  "log": { "loglevel": "warning", "access": "none", "error": "" },
		  "dns": { "servers": ["1.1.1.1", "1.0.0.1"] },
		  "inbounds": [
		    {
		      "tag": "reality-in",
		      "listen": "0.0.0.0",
		      "port": ${LISTEN_PORT:-$INTERNAL_PORT},
		      "protocol": "vless",
		      "settings": {
		        "clients": [
		          $_clients
		        ],
		        "decryption": "none"
		      },
		      "streamSettings": {
		        "network": "tcp",
		        "security": "reality",
		        "realitySettings": {
		          "show": false,
		          "dest": "$DEST",
		          "xver": 0,
		          "serverNames": ["$SNI"],
		          "privateKey": "$REALITY_PRIV",
		          "shortIds": [$_sids],
		          "minClientVer": "${MIN_CLIENT_VER:-0.0.0}"
		        }
		      },
		      "sniffing": { "enabled": true, "destOverride": ["http", "tls", "quic"], "routeOnly": true }
		    },
		    {
		      "tag": "health-in",
		      "listen": "$HEALTH_ADDR",
		      "port": $HEALTH_PORT,
		      "protocol": "socks",
		      "settings": { "auth": "noauth", "udp": false }
		    }
		  ],
		  "outbounds": [
		$_warp_out    { "tag": "direct", "protocol": "freedom" },
		    { "tag": "block", "protocol": "blackhole" }
		  ],
		  "routing": {
		    "domainStrategy": "IPIfNonMatch",
		    "rules": [
		      {
		        "type": "field",
		        "ip": [
		          "127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
		          "169.254.0.0/16", "100.64.0.0/10",
		          "::1/128", "fc00::/7", "fe80::/10"
		        ],
		        "outboundTag": "block"
		      },
		      { "type": "field", "port": "25", "outboundTag": "block" },
		      { "type": "field", "protocol": ["bittorrent"], "outboundTag": "block" },
		      { "type": "field", "network": "tcp,udp", "outboundTag": "$_final_out" }
		    ]
		  }
		}
	EOF
}

# 169.254.0.0/16 in that block list is load-bearing, not decoration: without it
# any client of your proxy can reach 169.254.169.254 and read your instance's
# cloud metadata, including IAM credentials.

write_config() {
	_t=$(mktemp "$DATA/.config.XXXXXX")
	render_config >"$_t"
	if command -v jq >/dev/null 2>&1; then
		jq -e . "$_t" >/dev/null 2>&1 || { rm -f "$_t"; die "generated config is not valid JSON -- this is a transtation bug"; }
	fi
	# -format json is required, not optional: xray infers the format from the
	# file extension, and this is a mktemp name with no .json suffix. Without it
	# every validation fails with "Failed to get format of ..." and the server
	# never starts.
	xray run -test -format json -c "$_t" >/dev/null 2>&1 || {
		xray run -test -format json -c "$_t" >&2 || true
		rm -f "$_t"
		die "generated config was rejected by xray -- this is a transtation bug"
	}
	# 0600: this file inlines the REALITY private key and the WireGuard private
	# key. Xray runs as the same uid that owns it, so it needs nothing wider.
	chmod 600 "$_t"
	mv -f "$_t" "$CONF"
}

write_runtime() {
	# Derived, rewritten every boot. `show`/`status`/`health` run as separate
	# `docker exec` processes and would otherwise not see an auto-detected HOST.
	_t=$(mktemp "$DATA/.runtime.XXXXXX")
	printf 'HOST=%s\nPORT=%s\nLISTEN_PORT=%s\nSNI=%s\nDEST=%s\nWARP=%s\nWARP_READY=%s\nSNI_REASON=%s\n' \
		"$HOST" "$PORT" "${LISTEN_PORT:-$INTERNAL_PORT}" "$SNI" "$DEST" "${WARP:-1}" \
		"$(warp_ready && echo 1 || echo 0)" "${SNI_REASON:-unknown}" >"$_t"
	chmod 600 "$_t"
	mv -f "$_t" "$RUNTIME"
}

load_runtime() {
	# The runtime file is written a moment into startup, after the dest probe.
	# Running `transtation show` straight after `systemctl start` therefore used
	# to be told to restart -- advice that loops you, because waiting was all it
	# needed. Wait briefly when the thing is genuinely coming up, and only then
	# say so.
	if [ ! -f "$RUNTIME" ] && server_starting; then
		_w=0
		while [ ! -f "$RUNTIME" ] && [ "$_w" -lt 15 ]; do sleep 1; _w=$((_w + 1)); done
	fi
	[ -f "$RUNTIME" ] || die "server has not started yet -- run '$(restart_hint)' first"
	HOST=""; PORT=""; LISTEN_PORT=""; SNI=""; DEST=""; WARP=""; WARP_READY=""; SNI_REASON=""
	while IFS='=' read -r _k _v; do
		case "$_k" in
			HOST) HOST=$_v ;;
			PORT) PORT=$_v ;;
			LISTEN_PORT) LISTEN_PORT=$_v ;;
			SNI) SNI=$_v ;;
			DEST) DEST=$_v ;;
			WARP) WARP=$_v ;;
			WARP_READY) WARP_READY=$_v ;;
			SNI_REASON) SNI_REASON=$_v ;;
		esac
	done <"$RUNTIME"
}

load_state() {
	[ -s "$STATE" ] || die "no identity yet -- start the server first"
	SCHEMA=""; REALITY_PRIV=""; REALITY_PUB=""; CREATED=""
	while IFS='=' read -r _k _v; do
		case "$_k" in
			SCHEMA) SCHEMA=$_v ;;
			REALITY_PRIV) REALITY_PRIV=$_v ;;
			REALITY_PUB) REALITY_PUB=$_v ;;
			CREATED) CREATED=$_v ;;
		esac
	done <"$STATE"
}

apply() {
	# `user add` / `sni` / `warp register` arrive as separate `docker exec`
	# processes with none of run()'s shell state, so rebuild it before
	# re-rendering -- including the SNI precedence, or changing the dest would
	# write a config that still names the old one.
	_sni_env=${SNI:-}
	_dest_env=${DEST:-}
	load_state
	load_runtime
	if [ -n "$_sni_env" ]; then
		SNI=$_sni_env
	elif [ -s "$SNIFILE" ]; then
		sni_load
	fi
	DEST=${_dest_env:-$SNI:443}
	write_config
	write_runtime
	# Xray has no reload signal and no config watcher, so the file is the source
	# of truth and the only way to apply it is to restart.
	if [ "$TT_MANAGED" = docker ]; then
		if [ -r /proc/1/comm ] && [ "$(cat /proc/1/comm)" = xray ]; then
			echo "applying: stopping xray so your restart policy brings it back up."
			echo "  existing connections drop and clients reconnect on their own."
			printf '  If you started this container WITHOUT a restart policy, start it again:\n'
			printf '    docker start %s\n' "$(hostname)"
			kill -TERM 1
		else
			echo "written; takes effect the next time the container starts"
		fi
	elif command -v systemctl >/dev/null 2>&1 && systemctl is-active --quiet transtation 2>/dev/null; then
		echo "applying: restarting transtation.service"
		echo "  existing connections drop and clients reconnect on their own."
		systemctl restart transtation
	else
		echo "written; takes effect the next time the server starts"
	fi
}

# ------------------------------------------------------------------ sharing

share_uri() {
	_name=$1 _uuid=$2 _sid=$3
	_h=$HOST
	case "$_h" in *:*) _h="[$_h]" ;; esac
	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' \
		"$_uuid" "$_h" "$PORT" "$SNI" "$REALITY_PUB" "$_sid" "$_name"
}

cmd_show() {
	load_state
	load_runtime
	[ -f "$USERS" ] || die "no users yet"
	_want=${1:-}
	_found=0
	while IFS="$TAB" read -r n u s; do
		[ -n "$n" ] || continue
		[ -z "$_want" ] || [ "$_want" = "$n" ] || continue
		_found=1
		_uri=$(share_uri "$n" "$u" "$s")
		printf '\n  user: %s\n\n' "$n"
		printf '  !! The QR and the line below are your credential. Anyone holding either can\n'
		printf '     use your proxy, and a link that has been published stays fingerprintable\n'
		printf '     forever. Revoke with:  transtation user rm %s\n\n' "$n"
		if command -v qrencode >/dev/null 2>&1; then
			printf '%s' "$_uri" | qrencode -t ANSIUTF8 -m 2
			printf '\n'
		fi
		printf '  %s\n\n' "$_uri"
	done <"$USERS"
	[ "$_found" = 1 ] || die "no such user: $_want"
	# The URI is deliberately never written to container stdout during `run`:
	# the json-file log driver would persist this credential to
	# /var/lib/docker/containers/<id>/<id>-json.log, where it survives in every
	# log bundle anyone ever pastes into a support thread.
	printf '  This link was NOT written to the container log.\n\n'
}

# -------------------------------------------------------------- dest checks

# A dest whose TLS certificate chain is too large silently breaks REALITY: the
# handshake authenticates correctly and then dies partway through the borrowed
# certificate, so the server looks perfectly healthy while no client can
# connect. Measured against Xray 26.3.27, end to end:
#
#   dl.google.com        895   works        www.ibm.com      4331  works
#   one.one.one.one     2307   works        www.paypal.com   4883  works
#   www.cloudflare.com  2493   works        www.linkedin.com 5046  works
#   addons.mozilla.org  2843   works        www.microsoft.com 5879 FAILS
#   www.nicovideo.jp    3822   works
#
# The boundary sits between 5046 and 5879, so the warning fires above 5200.
CHAIN_LIMIT=5200

check_dest() {
	_ping=$(xray tls ping "$SNI" 2>&1) || true
	case "$_ping" in
		*"Handshake succeeded"*) ;;
		*)
			warn "TLS probe to $SNI failed from this host. Starting anyway, but clients
    may fail the Reality handshake. Investigate with: xray tls ping $SNI"
			return 0
			;;
	esac
	_chain=$(printf '%s' "$_ping" | sed -n "s/^Certificate chain's total length: *\([0-9][0-9]*\).*/\1/p" | head -1)
	[ -n "$_chain" ] || return 0
	if [ "$_chain" -gt "$CHAIN_LIMIT" ]; then
		warn "$SNI presents a $_chain-byte certificate chain. Above roughly $CHAIN_LIMIT bytes the
    REALITY handshake does not complete, and the failure is SILENT: this server
    will look healthy and no client will be able to connect.
    Pick a smaller target -- dl.google.com (895), www.cloudflare.com (2493) and
    addons.mozilla.org (2843) all have plenty of headroom.
    Confirm either way with:  transtation verify"
	fi
}

# ------------------------------------------------------- SNI auto-selection

# Candidates are filtered well below CHAIN_LIMIT so that a site adding one
# intermediate certificate later does not silently break the server.
CHAIN_SAFE=4500

# --- what the dest has to survive -----------------------------------------
#
# The dest is not just a TLS handshake donor. Your clients' ISP sees their
# traffic labelled with this name, and it will judge the VOLUME AND SHAPE of
# that traffic against what the name implies. Hours of sustained multi-gigabyte
# download addressed to a corporate brochure page is not a plausible thing for
# a human to be doing, and a DPI box that has already taken an interest in the
# address will start dropping the connection. Pulling the same bytes from a
# video service is what everyone does all evening.
#
# That is a judgement about the internet, not something a TLS probe can
# measure -- `accept-ranges` was tested for this and does not discriminate
# (dl.google.com answers "none" on its root path; addons.mozilla.org answers
# "bytes"). So the class below is curated, deliberately, and it dominates the
# score:
#
#   bulk   video, streaming, and real download/CDN hosts. Sustained multi-GB
#          transfer and hours-long connections are unremarkable here.
#   mixed  media-heavy portals. Moderate sustained traffic is fine; a torrent
#          of it is a bit odd.
#   light  corporate sites, marketing pages, DNS endpoints. Bulk transfer to
#          these looks exactly as wrong as it is. Last resort only.
#
# Weights are in milliseconds so they can be compared with the measured
# handshake latency directly. They are deliberately lopsided: being dropped by
# a DPI box is fatal, whereas a slower dest only adds to connection setup. Real
# candidate latencies span roughly 50-800ms, so a bulk dest wins essentially
# every realistic comparison, and a light one wins only when nothing else
# answered at all.
CLASS_BULK=0
CLASS_MIXED=800
CLASS_LIGHT=2500
# How much slower a same-region candidate may be and still win. Matching the
# dest to where the traffic is observed is what makes it unremarkable.
REGION_BONUS_MS=150

# Chain sizes and TLS versions measured 2026-08-06 from a live probe; every
# entry passed. Entries that failed and are therefore NOT here: abema.tv
# (TLS 1.2 only), www.hulu.com (4717-byte chain).
sni_regional() {
	case "$1" in
		JP) echo "bulk:www.nicovideo.jp bulk:tver.jp mixed:www.pixiv.net" ;;
		KR) echo "bulk:www.tving.com bulk:tv.naver.com bulk:www.wavve.com" ;;
		US | CA) echo "bulk:www.twitch.tv bulk:vimeo.com mixed:www.reddit.com" ;;
		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" ;;
		SG | HK | TW | MY | TH | VN | ID | PH) echo "bulk:www.vidio.com bulk:www.viu.com" ;;
		IN | PK | BD | LK) echo "bulk:www.hotstar.com bulk:www.zee5.com bulk:www.sonyliv.com" ;;
		BR | AR | CL | MX | CO | PE | UY) echo "bulk:globoplay.globo.com bulk:vimeo.com" ;;
		AU | NZ) echo "bulk:iview.abc.net.au bulk:www.stan.com.au" ;;
		*) echo "" ;;
	esac
}
# Always probed, whatever the region. Global streaming brands and real download
# hosts are plausible almost anywhere; the light entry exists only so that a
# server on a restricted network still gets an answer.
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"
SNI_FALLBACK=dl.google.com

# Pure function, so the weighting can be tested offline.
sni_score() {
	# class rtt_ms chain_bytes region_match(0|1) -> score, lower is better
	case "$1" in
		bulk) _cp=$CLASS_BULK ;;
		mixed) _cp=$CLASS_MIXED ;;
		*) _cp=$CLASS_LIGHT ;;
	esac
	_rb=0
	[ "$4" = 1 ] && _rb=$REGION_BONUS_MS
	# The chain term is a tiebreak, not a filter: a smaller chain is headroom
	# against the dest adding an intermediate certificate later.
	_sc=$(($2 + _cp + $3 / 100 - _rb))
	[ "$_sc" -lt 0 ] && _sc=0
	printf '%s' "$_sc"
}

# One candidate. `xray tls ping` performs a full TLS handshake, so timing that
# call gives the latency directly -- the earlier version made a second curl
# round trip per candidate to measure what the first one already cost.
# /proc/uptime is used rather than `date`, which in busybox has no %N.
sni_probe() {
	# "class:domain" region_match dir
	_cls=${1%%:*} _c=${1#*:} _rm=$2 _dir=$3
	read -r _t0 _ </proc/uptime
	_p=$(timeout 12 xray tls ping "$_c" 2>&1) || return 0
	read -r _t1 _ </proc/uptime
	case "$_p" in *"Handshake succeeded"*) ;; *) return 0 ;; esac
	# TLS 1.2 is a hard reject, not a penalty -- REALITY needs 1.3. This is not
	# theoretical: abema.tv looked like an ideal Japanese video dest and serves
	# only TLS 1.2.
	case "$_p" in *"TLS Version:"*"1.3"*) ;; *) return 0 ;; esac
	_chain=$(printf '%s' "$_p" | sed -n "s/^Certificate chain's total length: *\\([0-9][0-9]*\\).*/\\1/p" | head -1)
	[ -n "$_chain" ] && [ "$_chain" -le "$CHAIN_SAFE" ] || return 0
	_ms=$(awk -v a="$_t0" -v b="$_t1" 'BEGIN { printf "%d", (b - a) * 1000 }')
	[ -n "$_ms" ] && [ "$_ms" -gt 0 ] || _ms=1
	printf '%s %s %s %s %s %s\n' "$(sni_score "$_cls" "$_ms" "$_chain" "$_rm")" \
		"$_c" "$_chain" "$_ms" "$_cls" "$_rm" >"$_dir/$_c"
}

# Picks the dest this server should impersonate, then STICKS to it. The choice
# is persisted because every share link carries `sni=`: silently picking a
# different dest on a later boot would break every client already configured.
sni_auto() {
	# The region that matters is where your clients connect FROM -- the DPI that
	# judges the traffic is theirs, not your server's. We default to the
	# server's own country because most people put the server near or relevant
	# to their users; CLIENT_REGION overrides it.
	_loc=${CLIENT_REGION:-${1:-}}
	_dir=$(mktemp -d)
	printf 'selecting the best dest for this server'
	[ -n "$_loc" ] && printf ' (traffic observed from %s)' "$_loc"
	printf ' ...\n'

	_regional=$(sni_regional "$_loc")
	for _e in $_regional; do sni_probe "$_e" 1 "$_dir" & done
	for _e in $SNI_GLOBAL; do sni_probe "$_e" 0 "$_dir" & done
	wait

	_best=$(cat "$_dir"/* 2>/dev/null | sort -n | head -1)
	rm -rf "$_dir"

	if [ -n "$_best" ]; then
		# shellcheck disable=SC2086
		set -- $_best
		_sni=$2 _chain=$3 _rtt=$4 _cls=$5 _rm=$6
		case "$_cls" in
			bulk) _what="sustained heavy traffic is unremarkable here" ;;
			mixed) _what="media-heavy site, moderate traffic is unremarkable" ;;
			*) _what="LAST RESORT -- bulk traffic to this name looks odd; re-run 'transtation sni auto' from a less restricted network" ;;
		esac
		[ "$_rm" = 1 ] && _where="same region as your clients" || _where="global"
		_reason="$_what; $_where; ${_rtt}ms TLS handshake, ${_chain}-byte chain"
	else
		_sni=$SNI_FALLBACK
		_reason="FALLBACK -- nothing could be probed (no network yet?). Re-run: transtation sni auto"
	fi

	sni_write "$_sni" "$_reason"
	printf '  chose %s\n    %s\n' "$_sni" "$_reason"
}

sni_write() {
	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])?)+$' ||
		die "not a hostname: $(q "$1")"
	case "$1" in
		*apple* | *icloud*) die "Xray warns that apple/icloud targets get your IP blocked by the GFW." ;;
	esac
	# The reason is free text built partly from CLIENT_REGION; a newline in it
	# would inject a second KEY=value line into this file.
	_r=$(printf '%s' "$2" | tr -d '\n\r')
	_t=$(mktemp "$DATA/.sni.XXXXXX")
	printf 'SNI=%s\nSNI_REASON=%s\nSNI_CHOSEN=%s\n' "$1" "$_r" "$(date -u +%F)" >"$_t"
	chmod 600 "$_t"
	mv -f "$_t" "$SNIFILE"
}

sni_load() {
	SNI=""; SNI_REASON=""; SNI_CHOSEN=""
	while IFS='=' read -r _k _v; do
		case "$_k" in
			SNI) SNI=$_v ;;
			SNI_REASON) SNI_REASON=$_v ;;
			SNI_CHOSEN) SNI_CHOSEN=$_v ;;
		esac
	done <"$SNIFILE" 2>/dev/null
	[ -n "$SNI" ] || die "$SNIFILE is empty or corrupt -- fix it with: transtation sni auto"
}

cmd_sni() {
	case "${1:-}" in
		'')
			[ -s "$SNIFILE" ] || die "no stored selection -- the SNI is coming from your .env, or the server has not started yet"
			sni_load
			printf '  sni     %s\n  why     %s\n  since   %s\n' "$SNI" "${SNI_REASON:-?}" "${SNI_CHOSEN:-?}"
			;;
		auto | *.*)
			[ -z "${SNI:-}" ] || warn "SNI is set in your .env; that overrides whatever is stored here."
			cat >&2 <<-'EOF'
				Changing the dest invalidates every share link already issued: clients send
				the old name in their ClientHello and the server will no longer answer to it.
				Re-issue them afterwards with 'transtation show'.
			EOF
			if [ "$1" = auto ]; then
				_loc=$(curl -fsS --max-time 8 https://cloudflare.com/cdn-cgi/trace 2>/dev/null | sed -n 's/^loc=//p')
				sni_auto "$_loc"
			else
				sni_write "$1" "set by hand"
				printf '  set to %s\n' "$1"
			fi
			apply
			;;
		*) die "usage: transtation sni [auto | <domain>]" ;;
	esac
}

# The only check that exercises the actual REALITY path. The healthcheck cannot:
# it goes through the loopback SOCKS inbound and never touches REALITY at all,
# so a broken dest passes it forever.
cmd_verify() {
	load_state
	load_runtime
	[ -s "$USERS" ] || die "no users"
	_u=$(head -1 "$USERS" | cut -f2)
	_s=$(head -1 "$USERS" | cut -f3)
	_d=$(mktemp -d)
	cat >"$_d/c.json" <<-EOF
		{
		  "log": { "loglevel": "none" },
		  "inbounds": [{ "listen": "127.0.0.1", "port": 10809, "protocol": "socks",
		                 "settings": { "auth": "noauth", "udp": false } }],
		  "outbounds": [{
		    "protocol": "vless",
		    "settings": { "vnext": [{ "address": "127.0.0.1", "port": ${LISTEN_PORT:-$INTERNAL_PORT},
		      "users": [{ "id": "$_u", "encryption": "none", "flow": "xtls-rprx-vision" }] }] },
		    "streamSettings": { "network": "tcp", "security": "reality",
		      "realitySettings": { "fingerprint": "chrome", "serverName": "$SNI",
		        "publicKey": "$REALITY_PUB", "shortId": "$_s", "spiderX": "/" } }
		  }]
		}
	EOF
	xray run -format json -c "$_d/c.json" >/dev/null 2>&1 &
	_pid=$!
	# The 2s settle is measured, not arbitrary. If the probe client is not ready
	# its first outbound sits on a ~5s retry backoff, so racing to the earliest
	# request is slower than waiting:
	#   settle 2.0s, then request : 2.1s   <- what install.sh actually pays
	#   settle 0.5s, 3s cap, retry: 4.1s on a cold boot (0.6s once warm)
	# Repeat calls could be made faster, but the call that matters happens once,
	# right after installation, and that one is fastest this way. The retries
	# below are for hosts slower than the ones this was measured on, not speed.
	_out=""
	_i=0
	while [ "$_i" -lt 6 ]; do
		sleep 2
		_out=$(curl -fsS --max-time 10 --socks5-hostname 127.0.0.1:10809 https://cloudflare.com/cdn-cgi/trace 2>/dev/null) && break
		_out=""
		_i=$((_i + 1))
	done
	kill "$_pid" 2>/dev/null || true
	rm -rf "$_d"

	if [ -z "$_out" ]; then
		die "REALITY handshake FAILED against this server's own inbound.
    Clients will not be able to connect, however healthy the container looks.
    The usual cause is the dest: $SNI
    Check its certificate chain -- above roughly $CHAIN_LIMIT bytes REALITY cannot
    complete the handshake:
        xray tls ping $SNI
    Then pick a smaller one (dl.google.com, www.cloudflare.com, addons.mozilla.org),
    set SNI= in $(env_hint), and '$(restart_hint)'."
	fi
	printf 'REALITY handshake OK. Egress through the tunnel: %s\n' \
		"$(echo "$_out" | grep -E '^(ip|colo|warp)=' | tr '\n' ' ')"
}

# ------------------------------------------------------------------- health

trace() { curl -fsS --max-time 8 --socks5-hostname "$HEALTH_ADDR:$HEALTH_PORT" https://cloudflare.com/cdn-cgi/trace; }

cmd_health() {
	load_runtime
	_t=$(trace) || { echo "no egress at all through the proxy"; exit 1; }
	if [ "$WARP_READY" = 1 ]; then
		echo "$_t" | grep -qx 'warp=on' || {
			cat >&2 <<-'EOF'
				Reality is up, but the WARP tunnel is not passing traffic.
				  - some VPS providers block outbound UDP/2408 (registration is TCP, so it
				    succeeded anyway and this only shows up now)
				  - the WireGuard stack itself is fine: this image pins "noKernelTun": true, so
				    it always uses Xray's in-process userspace stack no matter what
				    capabilities the container holds.
				  fix: set WARP=0 in $(env_hint) and run '$(restart_hint)'. The proxy works
				       without WARP; it just egresses from this VPS's own IP.
			EOF
			exit 1
		}
	fi
	exit 0
}

cmd_status() {
	load_state
	load_runtime
	printf '\n  server        %s:%s\n' "$HOST" "$PORT"
	printf '  dest / SNI    %s\n' "$SNI"
	printf '                %s\n' "${SNI_REASON:-unknown}"
	printf '  clients       minClientVer=%s\n' "${MIN_CLIENT_VER:-0.0.0}"
	printf '  identity      created %s\n' "${CREATED:-?}"

	if [ "$WARP" = 1 ] && [ "$WARP_READY" = 0 ]; then
		printf '  egress        DIRECT -- WARP was requested but is NOT registered.\n'
		printf '                Every packet leaves from this VPS IP. Fix: transtation warp register\n'
	elif _t=$(trace 2>/dev/null); then
		printf '  egress        %s\n' "$(echo "$_t" | grep -E '^(ip|colo|loc|warp)=' | tr '\n' ' ')"
	else
		printf '  egress        UNREACHABLE -- no traffic is getting out through the proxy\n'
	fi

	if [ "$TT_MANAGED" = docker ]; then
		if [ -r /proc/1/comm ] && [ "$(cat /proc/1/comm)" = xray ]; then
			printf '  xray          running\n'
		else
			printf '  xray          NOT running as PID 1 of this container\n'
		fi
	elif command -v systemctl >/dev/null 2>&1 && systemctl is-active --quiet transtation 2>/dev/null; then
		printf '  xray          running under transtation.service\n'
	else
		printf '  xray          NOT running\n'
	fi
	printf '  users         %s\n' "$(awk -F'\t' 'NF>=3{printf "%s%s", (n++?", ":""), $1}' "$USERS" 2>/dev/null)"
	printf '  inbound       not verifiable from this host. If clients time out with no server\n'
	printf '                log line, open TCP %s in your provider firewall / security group.\n\n' "$PORT"
}

# ------------------------------------------------------------------ backup

cmd_backup() {
	# stdout only -- the caller decides where it lands and with what umask.
	[ -f "$STATE" ] || die "nothing to back up yet"
	# sni.chosen is included on purpose: every share link carries `sni=`, so a
	# restore that re-picked a different dest would invalidate all of them.
	# shellcheck disable=SC2046  # word splitting is the point: skip absent files
	tar -cz -C "$DATA" \
		state.env users.tsv \
		$([ -f "$SNIFILE" ] && echo sni.chosen) \
		$([ -f "$ACCOUNT" ] && echo wgcf-account.toml) \
		$([ -f "$PROFILE" ] && echo wgcf-profile.conf)
}

cmd_reset() {
	[ "${I_UNDERSTAND_THIS_DESTROYS_MY_KEYS:-}" = yes ] || die \
		"refusing. This destroys your Reality keypair and every issued link.
    If you are certain:
      I_UNDERSTAND_THIS_DESTROYS_MY_KEYS=yes transtation reset"
	rm -f "$STATE" "$USERS" "$CONF" "$RUNTIME" "$ACCOUNT" "$PROFILE" "$SNIFILE"
	echo "identity destroyed. The next start provisions a brand new server."
}

# ------------------------------------------------------------------ selftest

# Runs offline. This is the CI gate: it proves the rendered config is accepted
# by the pinned xray with WARP both on and off, that the x25519 parser survives
# every historical output format, and that the share URI is complete.
cmd_selftest() {
	_tmp=$(mktemp -d)
	trap 'rm -rf "$_tmp"' EXIT
	TT_DATA=$_tmp
	set_paths
	_fail=0
	# Never `cmd; _check $?` -- under `set -e` a failing test aborts the run
	# instead of reporting it. Always evaluate inside the if.
	_assert() { _d=$1; shift; if "$@"; then printf '  ok    %s\n' "$_d"; else printf '  FAIL  %s\n' "$_d"; _fail=1; fi; }
	_jq() { jq -e "$1" "$CONF" >/dev/null 2>&1; }
	# Subshell so that write_config's `die` cannot take the whole selftest with it.
	_renders() { (WARP=$1; write_config) >/dev/null 2>&1; }

	echo "transtation selftest"

	# 1. x25519 parser against all three historical output formats.
	_assert "x25519 parser: <=25.6.8 format" \
		test "$(printf 'Private key: AAA\nPublic key: BBB\n' | parse_x25519)" = "AAA BBB"
	_assert "x25519 parser: 25.9-26.1 format" \
		test "$(printf 'PrivateKey: AAA\nPassword: BBB\n' | parse_x25519)" = "AAA BBB"
	_assert "x25519 parser: >=26.3.27 format (Hash32 ignored)" \
		test "$(printf 'PrivateKey: AAA\nPassword (PublicKey): BBB\nHash32: CCC\n' | parse_x25519)" = "AAA BBB"

	# 2. real key generation reaches the 43-char assert.
	provision_identity
	load_state
	_assert "generated x25519 keypair is 43 chars each" \
		test "${#REALITY_PRIV}${#REALITY_PUB}" = "4343"

	# 3. render + xray validation, WARP OFF.
	user_add alice >/dev/null
	user_add bob >/dev/null
	HOST=203.0.113.10
	PORT=443
	SNI=www.microsoft.com
	DEST=www.microsoft.com:443
	_assert "config renders and xray accepts it (WARP off)" _renders 0
	_assert "no warp-out outbound emitted when WARP is unregistered" \
		_jq '.outbounds | map(.tag) | index("warp-out") == null'
	_assert "final route falls back to direct when WARP is off" \
		_jq '.routing.rules[-1].outboundTag == "direct"'

	# 4. render + xray validation, WARP ON (synthetic profile).
	printf '[Interface]\nPrivateKey = %s\nAddress = 172.16.0.2/32, 2606:4700:110:1:2:3:4:5/128\n' \
		"$(head -c 32 /dev/urandom | base64)" >"$PROFILE"
	_assert "config renders and xray accepts it (WARP on)" _renders 1
	_assert "warp outbound pins noKernelTun (userspace gVisor stack)" \
		_jq '.outbounds[0].settings.noKernelTun == true'
	_assert "warp outbound does not random-pick between A and AAAA" \
		_jq '.outbounds[0].settings.domainStrategy == "ForceIPv4v6"'
	_assert "warp endpoint is a literal IP, not a hostname" \
		_jq '.outbounds[0].settings.peers[0].endpoint | test("^[0-9.]+:[0-9]+$")'

	# 5. invariants that protect users.
	_assert "shortIds never contains the empty string" \
		_jq '.inbounds[0].streamSettings.realitySettings.shortIds | index("") == null'
	_assert "every shortId is even-length hex, at most 16 chars" \
		_jq '.inbounds[0].streamSettings.realitySettings.shortIds | all(test("^([0-9a-f]{2}){1,8}$"))'
	_assert "minClientVer pinned to 0.0.0 (no client silently rejected)" \
		_jq '.inbounds[0].streamSettings.realitySettings.minClientVer == "0.0.0"'
	_assert "outbound port 25 blocked (no open SMTP relay)" \
		_jq '[.routing.rules[] | select(.port == "25")] | length == 1'
	_assert "cloud metadata range 169.254.0.0/16 blocked" \
		_jq '[.routing.rules[] | select(.outboundTag == "block") | .ip // []] | flatten | index("169.254.0.0/16")'
	_assert "logs go to stdout, not an unrotated file" _jq '.log.error == ""'
	_assert "health SOCKS inbound is loopback-only" _jq '.inbounds[1].listen == "127.0.0.1"'
	# config.json inlines the REALITY private key and the WireGuard private key.
	# It was 0644 once, copied out of habit from a setup where xray ran as a
	# different user than the file's owner. Here it does not.
	_perm600() { [ "$(stat -c %a "$1" 2>/dev/null)" = 600 ]; }
	_assert "config.json is 0600 (it inlines both private keys)" _perm600 "$CONF"
	_assert "state.env is 0600" _perm600 "$STATE"
	_assert "users.tsv is 0600" _perm600 "$USERS"
	_assert "two users produce two clients and two shortIds" \
		_jq '(.inbounds[0].settings.clients | length) == 2 and (.inbounds[0].streamSettings.realitySettings.shortIds | length) == 2'

	# 6. SNI auto-selection: the weighting, the table, and the write boundary.
	_empty_region() { [ -z "$(sni_regional "$1")" ]; }
	_region_has_class() { case " $(sni_regional "$1") " in *" $2:"*) return 0 ;; *) return 1 ;; esac; }
	_assert "regional table lists a bulk-traffic dest for a known country" _region_has_class KR bulk
	_assert "regional table is empty for an unlisted country" _empty_region ZZ

	# Every entry must parse as class:domain with a class the scorer knows,
	# or it silently scores as "light" and never gets picked.
	_table_well_formed() {
		for _e in $(sni_regional JP) $(sni_regional KR) $(sni_regional US) $(sni_regional GB) \
			$(sni_regional SG) $(sni_regional IN) $(sni_regional BR) $(sni_regional AU) $SNI_GLOBAL; do
			case "$_e" in
				bulk:?*.?* | mixed:?*.?* | light:?*.?*) ;;
				*) return 1 ;;
			esac
		done
	}
	_assert "every candidate is class:domain with a known class" _table_well_formed
	_assert "the fallback dest is itself a probed global candidate" \
		sh -c "case ' $SNI_GLOBAL ' in *':$SNI_FALLBACK '*) exit 0 ;; *) exit 1 ;; esac"

	# The weighting is the whole point of this design, so it gets tested rather
	# than trusted. A far-away video service must beat a nearby corporate page:
	# that is exactly the swap that stopped the traffic being dropped.
	_beats() { [ "$(sni_score "$1" "$2" "$3" "$4")" -lt "$(sni_score "$5" "$6" "$7" "$8")" ]; }
	_assert "a distant bulk dest beats a fast light one" \
		_beats bulk 600 4000 0   light 30 900 0
	_assert "a distant bulk dest beats a fast mixed one" \
		_beats bulk 450 4000 0   mixed 40 900 0
	_assert "between two bulk dests, the nearer one wins" \
		_beats bulk 80 2000 0   bulk 300 2000 0
	_assert "a same-region dest wins a close race" \
		_beats bulk 200 2000 1   bulk 100 2000 0
	_assert "but the region bonus does not override a big latency gap" \
		_beats bulk 100 2000 0   bulk 400 2000 1
	_assert "a smaller certificate chain breaks a tie" \
		_beats bulk 100 900 0   bulk 100 4400 0
	_assert "scores never go negative" test "$(sni_score bulk 10 100 1)" = 0

	# Same injection class as the client's link parser, arriving by environment.
	_env_rejects() {
		(SNI=www.cloudflare.com DEST="" PORT=443 HOST=203.0.113.10
			eval "$1=\$2"
			validate_config_env) >/dev/null 2>&1 && return 1 || return 0
	}
	_assert "an IPv6 HOST is refused (the rest of the stack is IPv4-only)" \
		_env_rejects HOST '2406:da14:157d:8900::1'
	_assert "MIN_CLIENT_VER is not free text" _env_rejects MIN_CLIENT_VER '0.0.0", "show": true, "x": "'
	_assert "WARP_ENDPOINT must be an IPv4 address and port" _env_rejects WARP_ENDPOINT 'evil.example:2408'

	_sni_rejects() { (sni_write "$1" test) >/dev/null 2>&1 && return 1 || return 0; }
	_assert "sni_write rejects a non-hostname" _sni_rejects 'not a host; rm -rf /'
	_assert "sni_write rejects apple targets (Xray warns they get the IP blocked)" \
		_sni_rejects www.apple.com
	sni_write www.cloudflare.com "a reason with spaces and 'quotes'"
	sni_load
	_assert "sni_write round-trips through sni_load" test "$SNI" = www.cloudflare.com
	_assert "a reason containing spaces survives" \
		sh -c "case \"$SNI_REASON\" in *spaces*) exit 0 ;; *) exit 1 ;; esac"
	_perm600b() { [ "$(stat -c %a "$SNIFILE" 2>/dev/null)" = 600 ]; }
	_assert "sni.chosen is 0600" _perm600b

	# 7. share URI completeness.
	_u=$(head -1 "$USERS" | cut -f2)
	_s=$(head -1 "$USERS" | cut -f3)
	_uri=$(share_uri alice "$_u" "$_s")
	_missing=""
	for _k in encryption=none flow=xtls-rprx-vision security=reality sni= fp=chrome pbk= sid= spx= type=tcp headerType=none; do
		case "$_uri" in *"$_k"*) ;; *) _missing="$_missing $_k" ;; esac
	done
	_assert "share URI carries all 10 parameters (${_missing:-none missing})" test -z "$_missing"
	# `_assert desc ! cmd` cannot work -- `!` is a shell keyword and does not
	# survive "$@" expansion -- so the negation lives inside the helper.
	_uri_params_nonempty() { case "$_uri" in *"=&"* | *"=#"*) return 1 ;; *) return 0 ;; esac; }
	_assert "share URI has no empty parameter values" _uri_params_nonempty

	echo
	[ "$_fail" = 0 ] || die "selftest FAILED"
	echo "selftest passed"
}

# Everything here is interpolated into the generated JSON. It arrives from the
# operator rather than from a stranger, but a .env copied off a blog post is not
# meaningfully more trustworthy than a link someone sends you, and each check is
# one line. Kept as its own function so the selftest can attack it directly.
validate_config_env() {
	case "${HOST:-}" in
		*:*) die "HOST is an IPv6 address ($(q "$HOST")). transtation is IPv4-only for now:
    the client resolves A records only and the killswitch refuses an IPv6
    server, so a link naming this address would not work. Set HOST to an IPv4
    address or a name with an A record." ;;
	esac
	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])?)+$' ||
		die "SNI must be a hostname, got: $(q "$SNI")"
	case "$SNI" in
		*apple* | *icloud*) die "Xray warns that apple/icloud targets get your IP blocked by the GFW. Pick another SNI." ;;
	esac
	DEST=${DEST:-$SNI:443}
	printf '%s' "$DEST" | grep -Eq '^[A-Za-z0-9.-]+:[0-9]{1,5}$' || die "DEST must be host:port, got: $(q "$DEST")"
	PORT=${PORT:-443}
	printf '%s' "$PORT" | grep -Eq '^[0-9]{1,5}$' && [ "$PORT" -ge 1 ] && [ "$PORT" -le 65535 ] ||
		die "PORT must be 1-65535, got: $(q "$PORT")"
	# In a container we listen high and let dockerd perform the privileged bind,
	# which is why the image needs no capability at all. Natively there is
	# nothing in front of us, so we listen on the real port.
	[ "$TT_MANAGED" = docker ] && LISTEN_PORT=$INTERNAL_PORT || LISTEN_PORT=$PORT
	printf '%s' "${MIN_CLIENT_VER:-0.0.0}" | grep -Eq '^[0-9]{1,3}(\.[0-9]{1,3}){0,2}$' ||
		die "MIN_CLIENT_VER must look like 0.0.0, got: $(q "${MIN_CLIENT_VER:-}")"
	# Deliberately IPv4:port only, never a hostname: resolving the WARP endpoint
	# is exactly what produced the random IPv6 failures this design removed.
	printf '%s' "${WARP_ENDPOINT:-$WARP_ENDPOINT_DEFAULT}" | grep -Eq '^[0-9]{1,3}(\.[0-9]{1,3}){3}:[0-9]{1,5}$' ||
		die "WARP_ENDPOINT must be an IPv4 address and port, got: $(q "${WARP_ENDPOINT:-}")"
}

# --------------------------------------------------------------------- run

banner() {
	printf '\n  transtation -- listening on 0.0.0.0:%s\n' "${LISTEN_PORT:-$INTERNAL_PORT}"
	printf '  server        %s:%s\n' "$HOST" "$PORT"
	printf '  dest / SNI    %s  (%s)\n' "$SNI" "${SNI_REASON:-unknown}"
	if [ "${WARP:-1}" = 1 ] && ! warp_ready; then
		printf '  egress        DIRECT -- WARP requested but NOT registered (see the warning above)\n'
	elif warp_ready; then
		printf '  egress        WARP (Cloudflare)\n'
	else
		printf '  egress        DIRECT (WARP=0)\n'
	fi
	printf '  users         %s\n' "$(awk -F'\t' 'NF>=3{printf "%s%s", (n++?", ":""), $1}' "$USERS")"
	printf '  share link    transtation show      (never printed here -- it is a credential)\n\n'
}

cmd_run() {
	[ -d "$DATA" ] || die "$DATA does not exist"
	[ -w "$DATA" ] || die \
		"$DATA is not writable by uid $(id -u).
    If you replaced the named volume with a bind mount, either chown it:
      chown -R 10001:10001 ./data
    or switch back to the named volume, which inherits the right ownership."
	umask 077

	# --- identity: gated on its own artifact, written last and atomically ---
	[ -s "$STATE" ] || provision_identity
	load_state
	[ "${SCHEMA:-0}" -le "$SCHEMA_SUPPORTED" ] || die \
		"data schema $SCHEMA is newer than this image supports ($SCHEMA_SUPPORTED).
    You pinned back to an older tag. Pull the newer one, or restore a backup."
	migrate

	# --- WARP registration depends on nothing else here and is the single
	# slowest step (about 4s), so start it now and collect it after the dest has
	# been chosen. First boot pays max(warp, dest) instead of the sum.
	_warp_pid=""
	if warp_wanted && [ ! -f "$PROFILE" ]; then
		warp_register &
		_warp_pid=$!
	fi

	# --- where are we? one request, used for both the address and the region ---
	# -4 is not optional. On a dual-stack host curl prefers IPv6, so the address
	# discovered here -- the one that goes into every share link -- came back as
	# an IPv6 literal, which nothing else in this stack can use: the client
	# resolves A records only, its DNS strategy is ipv4_only, and the killswitch
	# refuses an IPv6 server outright. A container hid this, because Docker's
	# default bridge is IPv4-only.
	_trace=$(curl -4 -fsS --max-time 8 https://cloudflare.com/cdn-cgi/trace 2>/dev/null) || _trace=""
	[ -n "${HOST:-}" ] || HOST=$(printf '%s' "$_trace" | sed -n 's/^ip=//p')
	[ -n "${HOST:-}" ] || die \
		"could not auto-detect this host's public IPv4 address.
    Set HOST=<your.ip.or.domain> in $(env_hint) and start again.
    (transtation is IPv4-only for now; an IPv6-only host is not supported.)"
	LOC=$(printf '%s' "$_trace" | sed -n 's/^loc=//p')

	# --- SNI: env override, else the sticky auto-selection, else pick now ---
	_picked_now=0
	if [ -n "${SNI:-}" ]; then
		SNI_REASON="set in your .env"
	elif [ -s "$SNIFILE" ]; then
		sni_load
	else
		sni_auto "$LOC"
		sni_load
		_picked_now=1
	fi

	validate_config_env

	# The auto-selection already probed and filtered on exactly these criteria,
	# so re-running the check would just be a second round-trip.
	[ "$_picked_now" = 1 ] || [ "${SKIP_DEST_CHECK:-0}" = 1 ] || check_dest

	# --- first user: gated on users.tsv, not on state.env ---------------
	# This separation is the whole point. If provisioning is interrupted between
	# writing the identity and creating the first user, the next start resumes
	# instead of booting into a config with an empty shortIds array, which xray
	# rejects outright and which turns into a crash loop you can only escape by
	# destroying the volume.
	[ -s "$USERS" ] || user_add "${USER_NAME:-main}"

	# --- collect the WARP registration started above ---------------------
	[ -z "$_warp_pid" ] || wait "$_warp_pid" 2>/dev/null || true

	write_config
	write_runtime
	banner
	exec xray run -c "$CONF"
}

# --------------------------------------------------------------------- cli

usage() {
	cat <<-'EOF'
		transtation -- VLESS + REALITY proxy server

		  run                    start the server (default; this is what the image does)
		  show [name]            print the share link and QR for a user, or for all users
		  status                 server address, SNI, egress IP, users, WARP state
		  health                 exit 0 if traffic (and WARP, if enabled) is getting out
		  verify                 do a real REALITY handshake against this server -- the
		                         only check that proves clients can actually connect
		  sni                    show which dest this server impersonates, and why
		  sni auto               re-run the selection (invalidates existing links)
		  sni <domain>           set the dest by hand (invalidates existing links)
		  user add <name>        create a user with a fresh UUID and shortId, then apply
		  user rm <name>         revoke a user, then apply
		  user ls                list users
		  warp register          register or re-register a Cloudflare WARP identity
		  backup                 write a tar.gz of the identity to stdout
		  reset                  destroy the identity (requires an explicit env var)
		  selftest               offline self-check; no network, no state touched
	EOF
}

case "${1:-run}" in
	run) cmd_run ;;
	show) shift; cmd_show "${1:-}" ;;
	status) cmd_status ;;
	health) cmd_health ;;
	verify) cmd_verify ;;
	sni) shift; cmd_sni "${1:-}" ;;
	backup) cmd_backup ;;
	reset) cmd_reset ;;
	selftest) cmd_selftest ;;
	user)
		shift
		case "${1:-}" in
			add) shift; user_add "${1:-}"; apply ;;
			rm) shift; user_rm "${1:-}"; apply ;;
			ls | '') user_ls ;;
			*) die "unknown: user $1" ;;
		esac
		;;
	warp)
		shift
		case "${1:-}" in
			register)
				# No pre-emptive delete: warp_register promotes its result only on
				# success, so a failed retry leaves a working identity in place.
				warp_register && { echo "WARP registered."; apply; }
				;;
			*) die "unknown: warp ${1:-}" ;;
		esac
		;;
	-h | --help | help) usage ;;
	*) usage; die "unknown command: $1" ;;
esac
