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
commit 520ce170afd948599b35f1e96574e035cb7da715
parent 4baaab8b16bd411016bfc15b4e041af2cf03b4a1
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Thu, 6 Aug 2026 12:40:46 +0900

Fix three bugs that end-to-end testing found

- xray infers config format from the file extension, so validating a mktemp
file always failed with "Failed to get format of ...". Every boot died there.
Pass -format json.
- The default SNI www.microsoft.com does not work. Its 5879-byte certificate
chain overruns what REALITY can buffer, so the handshake authenticates and
then dies partway through the borrowed certificate. The container stays
healthy and no client can connect. Default is now dl.google.com (895 bytes);
measured boundary is between 5046 (works) and 5879 (fails).
- config.json was 0644 while inlining the REALITY and WireGuard private keys.

Adds `transtation verify`, which does a real REALITY handshake against the
server own inbound. It is the only check that catches the dest problem: the
healthcheck goes through a loopback SOCKS inbound and never touches REALITY,
so a broken dest passes it forever. install.sh now gates on it.

Also corrects an overstated warning: --cap-add NET_ADMIN does not break this
image, because the generated config pins noKernelTun: true and Xray then
skips the capability probe entirely. Verified by running it.

Diffstat:
M.env.example | 17++++++++++++++---
MREADME.md | 64+++++++++++++++++++++++++++++++++++++++++++++++-----------------
MTROUBLESHOOTING.md | 49++++++++++++++++++++++++++++++++++++-------------
Minstall.sh | 14++++++++++++++
Mserver/transtation | 128+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mtest.sh | 54+++++++++++++++++++++++++++++++++++++++++-------------
6 files changed, 268 insertion(+), 58 deletion(-)

diff --git a/.env.example b/.env.example
@@ -21,18 +21,29 @@
 
 # ---------------------------------------------------------------- camouflage
 
-# The real site your server impersonates. Must speak TLS 1.3 + X25519 + HTTP/2.
+# The real site your server impersonates. Must speak TLS 1.3 + X25519.
 #
 # RULE OF THUMB: pick a high-reputation site that is plausibly popular in your
 # server's region, and ideally hosted in the same cloud/ASN. A Tokyo VPS
 # borrowing a Japanese site's handshake looks ordinary; a Tokyo VPS borrowing a
 # Brazilian bank's does not.
 #
+# IT ALSO HAS TO HAVE A SMALL CERTIFICATE CHAIN. Above roughly 5200 bytes the
+# REALITY handshake does not complete, and the failure is silent: the server is
+# healthy, the port listens, and every client gets the real website instead.
+# Measured end to end against Xray 26.3.27:
+#
+#     dl.google.com        895  ok        www.ibm.com       4331  ok
+#     one.one.one.one     2307  ok        www.paypal.com    4883  ok
+#     www.cloudflare.com  2493  ok        www.linkedin.com  5046  ok
+#     addons.mozilla.org  2843  ok        www.microsoft.com 5879  BROKEN
+#     www.nicovideo.jp    3822  ok
+#
 # Do NOT use apple.com or icloud.com — Xray itself warns that those targets get
 # your IP blocked by the GFW, and transtation refuses them.
 #
-# Verify a candidate before committing to it:  docker compose exec proxy xray tls ping <domain>
-#SNI=www.microsoft.com
+# After changing this, always run:  transtation verify
+#SNI=dl.google.com
 
 # The address the Reality handshake is actually forwarded to. Defaults to
 # $SNI:443 and you almost never need to set this separately.
diff --git a/README.md b/README.md
@@ -236,7 +236,7 @@ what you want to change; every value is read fresh on each boot.
 |---|---|---|
 | `PORT` | `443` | public port |
 | `HOST` | auto-detected | address that goes into the share link |
-| `SNI` | `www.microsoft.com` | the site your server impersonates |
+| `SNI` | `dl.google.com` | the site your server impersonates |
 | `DEST` | `$SNI:443` | where the handshake is forwarded |
 | `WARP` | `1` | `0` to egress from the VPS IP instead |
 | `WARP_ENDPOINT` | `162.159.192.1:2408` | override if this anycast address does not handshake |
@@ -244,34 +244,64 @@ what you want to change; every value is read fresh on each boot.
 | `MIN_CLIENT_VER` | `0.0.0` | Reality client version floor |
 | `SKIP_DEST_CHECK` | `0` | skip the startup TLS probe of `$SNI` |
 
-**Choosing an SNI matters more than anything else here.** Pick a high-reputation
-site that speaks TLS 1.3 + X25519 + HTTP/2, is plausibly popular where your
-server lives, and ideally sits in the same cloud or ASN. A Tokyo VPS borrowing a
-Japanese site's handshake looks ordinary; borrowing a Brazilian bank's does not.
-Check a candidate before committing:
+**Choosing an SNI matters more than anything else here**, for two independent
+reasons.
+
+*Camouflage.* Pick a high-reputation site that speaks TLS 1.3 + X25519, is
+plausibly popular where your server lives, and ideally sits in the same cloud or
+ASN. A Tokyo VPS borrowing a Japanese site's handshake looks ordinary; borrowing
+a Brazilian bank's does not. Apple and iCloud domains are rejected outright —
+Xray itself warns that they get your IP blocked by the GFW.
+
+*Certificate size.* **Above roughly 5200 bytes of certificate chain the REALITY
+handshake does not complete**, and the failure is silent in the worst way: the
+container reports healthy, the port listens, and every client is quietly handed
+the real website instead of your proxy. Measured end to end against Xray
+26.3.27:
+
+| dest | chain | | dest | chain |
+|---|---|---|---|---|
+| `dl.google.com` | 895 ✓ | | `www.ibm.com` | 4331 ✓ |
+| `one.one.one.one` | 2307 ✓ | | `www.paypal.com` | 4883 ✓ |
+| `www.cloudflare.com` | 2493 ✓ | | `www.linkedin.com` | 5046 ✓ |
+| `addons.mozilla.org` | 2843 ✓ | | `www.microsoft.com` | 5879 ✗ |
+| `www.nicovideo.jp` | 3822 ✓ | | | |
+
+transtation warns at startup when the chain is too big, and
 
 ```
-docker compose exec proxy xray tls ping example.com
+transtation verify
 ```
 
-Apple and iCloud domains are rejected outright — Xray itself warns that they get
-your IP blocked by the GFW.
+does a genuine REALITY handshake against the server's own inbound. It is the
+only check that proves clients can actually connect — the healthcheck goes
+through a loopback inbound and never touches REALITY at all. Run it after
+changing `SNI`. `install.sh` runs it for you and refuses to finish if it fails.
 
----
+To inspect a candidate yourself:
 
-## Capabilities: do not add any
+```
+docker compose exec proxy xray tls ping example.com
+```
 
-This is the single easiest way to break a working install, so it gets its own
-section.
+---
+
+## Capabilities: none, and none needed
 
 | | Needed? | |
 |---|---|---|
-| `cap_add: NET_ADMIN` | **no — actively harmful** | Xray probes for `CAP_NET_ADMIN` up front and takes kernel TUN when it finds it. `createKernelTun` writes to `/proc/sys/.../rp_filter` *before* opening the device, Docker mounts `/proc/sys` read-only, and there is no fallback to userspace. WARP dies. |
-| `--device /dev/net/tun` | no | the gVisor network stack runs in-process |
-| `CAP_NET_BIND_SERVICE` | no | it listens on 8443 inside; `-p 443:8443` does the privileged bind in dockerd |
+| `cap_add: NET_ADMIN` | no | the generated config pins `"noKernelTun": true`, so WARP always runs in Xray's in-process userspace stack regardless of what the container holds. Adding the capability buys nothing and widens the container's privileges. |
+| `--device /dev/net/tun` | no | the userspace network stack needs no TUN device |
+| `CAP_NET_BIND_SERVICE` | no | it listens on 8443 inside; `-p 443:8443` does the privileged bind in dockerd, which also keeps this working under rootless Docker |
 | `--network host` | no, and must not be used | it would expose the loopback SOCKS inbound the healthcheck uses, turning your server into an open relay |
 
-To confirm the good path is live:
+That `noKernelTun` pin is load-bearing rather than decorative. Left unset, Xray
+probes for `CAP_NET_ADMIN` and takes the kernel-TUN path when it finds it — and
+`createKernelTun` writes to `/proc/sys/.../rp_filter` *before* opening the
+device, which fails on Docker's read-only `/proc/sys` with no fallback. Pinning
+it makes the container's behaviour independent of its capabilities.
+
+To confirm:
 
 ```
 docker compose logs proxy | grep -o 'Using .* TUN'    # -> Using gVisor TUN
diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md
@@ -11,22 +11,43 @@ healthy. The server starts clean, the port listens, and every rejected
 connection falls through to the genuine site your server impersonates, so a
 browser pointed at your server shows you `www.microsoft.com`.
 
-It means the handshake was rejected before Xray logged anything. Causes, in
-order of likelihood:
+First, settle it in one command:
 
-1. **Wrong `pbk`, `sid` or `sni` in the link.** Regenerate it — never hand-edit
+```
+transtation verify
+```
+
+That does a real REALITY handshake against the server's own inbound. If it
+fails, the problem is the server, not your client — and note that the
+healthcheck cannot tell you this, because it goes through a loopback inbound
+that never touches REALITY.
+
+Causes, in order of likelihood:
+
+1. **The dest's certificate chain is too big.** Above roughly 5200 bytes the
+   handshake dies partway through the borrowed certificate. `www.microsoft.com`
+   (5879 bytes) is the notable trap — it looks like the safest possible choice
+   and does not work. Check and replace:
+   ```
+   docker compose exec proxy xray tls ping www.microsoft.com   # look at the chain length
+   echo 'SNI=dl.google.com' >> /opt/transtation/.env
+   cd /opt/transtation && docker compose up -d && transtation verify
+   ```
+   Known-good: `dl.google.com` (895), `www.cloudflare.com` (2493),
+   `addons.mozilla.org` (2843), `www.nicovideo.jp` (3822).
+2. **Wrong `pbk`, `sid` or `sni` in the link.** Regenerate it — never hand-edit
    it, and never reuse a link from an older install:
    ```
    transtation show
    ```
-2. **`minClientVer`.** Newer Xray versions default the Reality server to a
+3. **`minClientVer`.** Newer Xray versions default the Reality server to a
    client-version floor, and a client below it gets exactly this behaviour.
    transtation pins `minClientVer=0.0.0` so this cannot happen; if you set
    `MIN_CLIENT_VER` yourself, unset it.
-3. **Missing `fp` in a hand-built link.** sing-box, NekoBox and Hiddify hard-fail
+4. **Missing `fp` in a hand-built link.** sing-box, NekoBox and Hiddify hard-fail
    with `uTLS is required by reality client`; Xray-based clients tolerate it.
    The generated link always carries `fp=chrome`.
-4. **Your SNI stopped supporting TLS 1.3 or X25519.** Check it and pick another:
+5. **Your SNI stopped supporting TLS 1.3 or X25519.** Check it and pick another:
    ```
    docker compose exec proxy xray tls ping www.microsoft.com
    ```
@@ -59,13 +80,15 @@ TCP and succeeds; the data plane is UDP and never comes up.
 docker compose logs proxy | grep -o 'Using .* TUN'
 ```
 
-- prints `Using gVisor TUN` → good, the userspace path is live; the problem is
-  UDP egress. Set `WARP=0` in `.env` and `docker compose up -d`.
-- prints `Using kernel TUN`, or the container dies with a `read-only file
-  system` error mentioning `rp_filter` → **you added `--cap-add NET_ADMIN`.
-  Remove it.** With that capability Xray tries kernel TUN, writes to
-  `/proc/sys` before opening the device, hits Docker's read-only `/proc/sys`,
-  and fails with no fallback.
+It should print `Using gVisor TUN. NoKernelTun is set to true.` — that is the
+normal, expected path and it holds even if the container was given extra
+capabilities. So if you see it, the WireGuard stack is fine and the problem is
+UDP egress: set `WARP=0` in `.env` and `docker compose up -d`.
+
+If instead it prints `Using kernel TUN`, or the container dies with a
+`read-only file system` error mentioning `rp_filter`, then something removed
+the `noKernelTun` pin from the generated config. That is a transtation bug —
+please report it.
 
 Docker does not restart unhealthy containers by itself. `unhealthy` is a report,
 not a self-heal.
diff --git a/install.sh b/install.sh
@@ -161,6 +161,20 @@ install_server() {
 		exit 1
 	fi
 
+	# The healthcheck proves egress, but it goes through a loopback inbound and
+	# never touches REALITY -- so a dest whose certificate chain is too large
+	# produces a permanently "healthy" server that no client can connect to.
+	# This is the only check that catches it.
+	printf '  %-14s' verify
+	if transtation verify; then :; else
+		say ""
+		say "  The server is running but clients cannot complete the REALITY handshake."
+		say "  Fix the dest and try again:"
+		say "      echo 'SNI=dl.google.com' >> $DIR/.env"
+		say "      cd $DIR && docker compose up -d && transtation verify"
+		exit 1
+	fi
+
 	say ""
 	transtation status || true
 	_port=$(sed -n 's/^PORT=//p' "$DIR/.env" 2>/dev/null | tail -1)
diff --git a/server/transtation b/server/transtation
@@ -256,12 +256,18 @@ write_config() {
 	_t=$(mktemp "$DATA/.config.XXXXXX")
 	render_config >"$_t"
 	jq -e . "$_t" >/dev/null 2>&1 || { rm -f "$_t"; die "generated config is not valid JSON -- this is a transtation bug"; }
-	xray run -test -c "$_t" >/dev/null 2>&1 || {
-		xray run -test -c "$_t" >&2 || true
+	# -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"
 	}
-	chmod 644 "$_t"
+	# 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"
 }
 
@@ -289,7 +295,13 @@ apply() {
 	load_runtime
 	write_config
 	if [ -r /proc/1/comm ] && [ "$(cat /proc/1/comm)" = xray ]; then
-		echo "restarting xray to apply (existing connections drop; clients reconnect automatically)"
+		# 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. Stopping
+		# PID 1 stops the container; Docker's restart policy brings it back.
+		echo "applying: stopping xray so your restart policy brings it back up."
+		echo "  existing connections drop and clients reconnect on their own."
+		echo "  If you started this container WITHOUT a restart policy, start it again:"
+		echo "    docker start \$(hostname)"
 		kill -TERM 1
 	else
 		echo "written; takes effect the next time the container starts"
@@ -336,6 +348,91 @@ cmd_show() {
 	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
+}
+
+# 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() {
+	# shellcheck source=/dev/null
+	. "$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": $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=$!
+	sleep 2
+	_out=$(curl -fsS --max-time 20 --socks5-hostname 127.0.0.1:10809 https://cloudflare.com/cdn-cgi/trace 2>/dev/null) || true
+	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 your .env, and 'docker compose up -d'."
+	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; }
@@ -349,8 +446,9 @@ cmd_health() {
 				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)
-				  - if you added --cap-add NET_ADMIN, REMOVE it. It makes Xray try kernel TUN,
-				    which fails on Docker's read-only /proc/sys with no fallback to userspace.
+				  - 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 your .env and run 'docker compose up -d'. The proxy works
 				       without WARP; it just egresses from this VPS's own IP.
 			EOF
@@ -479,6 +577,13 @@ cmd_selftest() {
 		_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'
 
@@ -537,7 +642,7 @@ cmd_run() {
 	migrate
 
 	# --- config: read from the environment every boot, never persisted ---
-	SNI=${SNI:-www.microsoft.com}
+	SNI=${SNI:-dl.google.com}
 	# Validated before anything else touches it: SNI reaches a JSON document and
 	# an unvalidated value sourced from a file would be a shell injection.
 	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])?)+$' ||
@@ -558,11 +663,7 @@ cmd_run() {
 		"could not auto-detect this host's public address.
     Set HOST=<your.ip.or.domain> in your .env and start again."
 
-	if [ "${SKIP_DEST_CHECK:-0}" != 1 ]; then
-		xray tls ping "$SNI" >/dev/null 2>&1 ||
-			warn "TLS probe to $SNI failed from this host. Starting anyway, but clients may
-    fail the Reality handshake. Verify with: xray tls ping $SNI"
-	fi
+	[ "${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
@@ -593,6 +694,8 @@ usage() {
 		  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
 		  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
@@ -608,6 +711,7 @@ case "${1:-run}" in
 	show) shift; cmd_show "${1:-}" ;;
 	status) cmd_status ;;
 	health) cmd_health ;;
+	verify) cmd_verify ;;
 	backup) cmd_backup ;;
 	reset) cmd_reset ;;
 	selftest) cmd_selftest ;;
diff --git a/test.sh b/test.sh
@@ -34,11 +34,11 @@ else
 fi
 
 echo
-echo "== the installer must never hand capabilities to the server =="
-# Adding NET_ADMIN makes Xray attempt kernel TUN, which writes to /proc/sys
-# before opening the device, hits Docker's read-only /proc/sys, and fails with
-# no fallback to userspace. This is the single easiest way to break a working
-# install, so it gets a test rather than only a comment.
+echo "== the installer must ask for no capabilities and no devices =="
+# Least privilege: the server needs no capabilities and no devices, so the
+# installer must never quietly grant any. (The generated config also pins
+# "noKernelTun": true, which makes WARP independent of capabilities -- but the
+# compose file should still ask for nothing.)
 # Scoped to the compose block the installer actually writes, with comments
 # stripped -- an earlier version of this test matched its own warning comment
 # and "failed" on a correct file.
@@ -68,19 +68,42 @@ fi
 
 echo
 echo "== build =="
-docker build -q -f server/Dockerfile -t "$SERVER_IMG" . >/dev/null && ok "server image builds"
-docker build -q -f client/Dockerfile -t "$CLIENT_IMG" . >/dev/null && ok "client image builds"
+# buildx, not the legacy builder: the Dockerfiles use --platform=$BUILDPLATFORM
+# on the fetch stage so that cross-arch CI builds do not run curl and unzip
+# under QEMU, and the legacy builder cannot parse it.
+docker buildx version >/dev/null 2>&1 || {
+	echo "  FAIL  docker buildx is required (Arch: pacman -S docker-buildx)"
+	exit 1
+}
+# A failed build must be fatal. Everything below runs `docker run`, and a
+# missing image makes those commands fail in exactly the way a *passing*
+# negative test looks like -- which is how an earlier version of this file
+# reported all green against images that did not exist.
+docker buildx build --load -f server/Dockerfile -t "$SERVER_IMG" . >/dev/null 2>&1 ||
+	{ bad "server image builds"; exit 1; }
+ok "server image builds"
+docker buildx build --load -f client/Dockerfile -t "$CLIENT_IMG" . >/dev/null 2>&1 ||
+	{ bad "client image builds"; exit 1; }
+ok "client image builds"
+docker image inspect "$SERVER_IMG" >/dev/null 2>&1 || { bad "server image exists"; exit 1; }
+docker image inspect "$CLIENT_IMG" >/dev/null 2>&1 || { bad "client image exists"; exit 1; }
 
 echo
 echo "== server selftest (offline) =="
 docker run --rm --network none "$SERVER_IMG" selftest || fail=1
 
 echo
-echo "== server image holds no capabilities and runs as a non-root user =="
-check "server image default user is not root" \
-	sh -c "test \"\$(docker run --rm --network none --entrypoint id $SERVER_IMG -u)\" != 0"
-check "server image ships no geodata (it would be dead weight)" \
-	sh -c "! docker run --rm --network none --entrypoint ls $SERVER_IMG /usr/local/share/xray 2>/dev/null | grep -q dat"
+echo "== server image runs unprivileged and carries no dead weight =="
+image_user_nonroot() {
+	_u=$(docker run --rm --network none --entrypoint id "$SERVER_IMG" -u) || return 1
+	[ -n "$_u" ] && [ "$_u" != 0 ]
+}
+no_geodata() {
+	_l=$(docker run --rm --network none --entrypoint ls "$SERVER_IMG" -1 /usr/local/share/xray 2>&1) || return 0
+	case "$_l" in *.dat*) return 1 ;; *) return 0 ;; esac
+}
+check "server image default user is not root" image_user_nonroot
+check "server image ships no geoip/geosite data" no_geodata
 
 echo
 echo "== client config renders and sing-box accepts it =="
@@ -91,7 +114,12 @@ docker run --rm --network none \
 
 echo
 echo "== client rejects malformed links at the trust boundary =="
-rejects() { ! docker run --rm --network none -e TT_URI="$1" "$CLIENT_IMG" check >/dev/null 2>&1; }
+# Not just "the command failed" -- it must have failed with OUR validation
+# error. Otherwise a broken image passes every one of these.
+rejects() {
+	_out=$(docker run --rm --network none -e TT_URI="$1" "$CLIENT_IMG" check 2>&1) && return 1
+	case "$_out" in *transtation-client:*) return 0 ;; *) return 1 ;; esac
+}
 check "rejects a non-vless scheme" rejects 'https://example.com'
 check "rejects a bad uuid" rejects 'vless://not-a-uuid@203.0.113.10:443?pbk=6ZP9LtQm3vXk8sT2wYnBcRfJhGdA1uEoI0pZxCyN4Vs&sid=1c69b566b0480c74&sni=a.com'
 check "rejects a short pbk" rejects 'vless://8f3e21c4-7a09-4b2e-9d51-6c0f1a2b3c4d@203.0.113.10:443?pbk=tooshort&sid=1c69b566b0480c74&sni=a.com'