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 9bc8c1632ec758b29f1bb2d549af45690dc177f8
parent 6b059e99395157d9479ec79dc9ccf8e9390e0514
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Thu, 6 Aug 2026 14:09:07 +0900

Adversarial review: fix a config-injection hole and a broken restore path

Findings came from attacking the thing rather than reading it, so each one has
a regression test that fails without the fix.

Share links controlled the client config. flow= and fp= were interpolated into
the generated sing-box document without validation, next to a comment claiming
every value was validated. A crafted link injecting "insecure":true rendered a
config that sing-box ACCEPTED (exit 0), and one injecting a second "server" key
put an attacker address in the document. A link is something somebody hands you
in a chat or as a QR code, so this is remote input. Both are allowlisted now,
along with SOCKS_PORT, DIRECT_SUFFIXES, MIN_CLIENT_VER and WARP_ENDPOINT, which
reach JSON the same way from the environment. Server-side validation moved into
validate_config_env so the selftest can attack it directly.

The documented disaster-recovery path did not work, in three separate ways.
Docker seeds a fresh named volume with the ownership of the image directory it
is first mounted into, so unpacking with alpine left /data owned by root and
the server refused to start. The backup is mode 0600, so the unpacking
container could not read it as a non-root user. And Compose prefixes volume
names with the project directory, so every documented command addressed
transtation-data while the real volume was <dir>_transtation-data -- a restore
would silently populate a brand new volume, the server would provision a fresh
identity, and every issued link would be dead with no error anywhere. The
volume name is pinned, and restore is a verb with guardrails: it refuses a file
that is not a backup and refuses to run while the server is up.

Also: the CI job would have gone red on its first run, because test.sh treats
shellcheck warnings as failures and there were six. All six files are clean
now. The uninstall prompt aborted the script where there is no controlling
terminal -- [ -r /dev/tty ] passes even then and the open fails with ENXIO --
and it now falls back to stdin and defaults to keeping the volume. Compose
writes progress to stderr, so the installer was leaking it. PORT set in the
environment is persisted to .env, or the published port and the port in the
share link disagree.

install.sh had never actually been executed before this review. It has now been
run end to end, along with backup, total volume loss, restore, and uninstall.

Diffstat:
MREADME.md | 17+++++++++++++++--
MTROUBLESHOOTING.md | 14++++++++++++--
Mclient/transtation-client | 29++++++++++++++++++++++++++++-
Minstall.sh | 74+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mserver/transtation | 52+++++++++++++++++++++++++++++++++++++++-------------
Mtest.sh | 17+++++++++++++++++
6 files changed, 174 insertion(+), 29 deletion(-)

diff --git a/README.md b/README.md
@@ -70,6 +70,7 @@ services:
       options: { max-size: "10m", max-file: "3" }
 volumes:
   transtation-data:
+    name: transtation-data      # pinned; otherwise Compose prefixes it with the directory name
 ```
 
 ### Day to day
@@ -80,6 +81,8 @@ transtation status            # address, SNI, egress IP, WARP state, users
 transtation user add alice    # new UUID + shortId, applied immediately
 transtation user rm alice     # revokes both
 transtation backup            # tar.gz of everything irreplaceable, mode 0600
+transtation restore <file>    # put one back (server must be stopped)
+transtation verify            # prove a client can actually complete the handshake
 transtation logs -f
 ```
 
@@ -419,10 +422,20 @@ It is the only thing on the box you cannot regenerate. Restoring is one command
 into a fresh volume:
 
 ```
-docker run --rm -v transtation-data:/data -v /root:/b alpine \
-  tar xzf /b/transtation-backup.tgz -C /data
+cd /opt/transtation && docker compose down
+transtation restore /root/transtation-backup.tgz
+docker compose up -d && transtation verify
 ```
 
+This started life as a documented `tar` one-liner and failed twice in testing,
+so it is a command now. Getting it right by hand means knowing that Docker
+seeds a fresh named volume with the ownership of the image directory it is
+first mounted into — so unpacking with a generic image leaves `/data` owned by
+root and the server refuses to start — *and* that the backup is mode 0600, so
+the unpacking container has to run as root to read it at all. `restore` also
+refuses to run while the server is up, and refuses a file that is not a
+transtation backup.
+
 Upgrades: re-run `install.sh`, or `docker compose pull && docker compose up -d`.
 Your identity lives in the volume and survives; the config is regenerated from
 the new image every boot, so schema changes come along for free.
diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md
@@ -168,10 +168,20 @@ or turn WARP off entirely (`WARP=0`) so you egress from your own VPS address.
 If you have a backup, it is one command into a fresh volume:
 
 ```
-docker run --rm -v transtation-data:/data -v /root:/b alpine \
-  tar xzf /b/transtation-backup.tgz -C /data
+cd /opt/transtation && docker compose down
+transtation restore /root/transtation-backup.tgz
+docker compose up -d && transtation verify
 ```
 
+This started life as a documented `tar` one-liner and failed twice in testing,
+so it is a command now. Getting it right by hand means knowing that Docker
+seeds a fresh named volume with the ownership of the image directory it is
+first mounted into — so unpacking with a generic image leaves `/data` owned by
+root and the server refuses to start — *and* that the backup is mode 0600, so
+the unpacking container has to run as root to read it at all. `restore` also
+refuses to run while the server is up, and refuses a file that is not a
+transtation backup.
+
 If you do not, there is nothing to recover: a new install generates a new
 keypair, and every previously issued link is dead. Run `transtation backup` now
 on the install you still have.
diff --git a/client/transtation-client b/client/transtation-client
@@ -86,6 +86,32 @@ validate() {
 	# fingerprint is absent, where Xray-based clients would tolerate it.
 	FP=${FP:-chrome}
 	FLOW=${FLOW:-xtls-rprx-vision}
+	# ALLOWLISTS, not pattern checks. These two land inside a JSON document, and
+	# a link is something somebody hands you -- in a chat, on a forum, as a QR
+	# code. Left open, `fp=chrome"},"insecure":true,"utls":{"enabled":true,
+	# "fingerprint":"chrome` renders a config that sing-box happily accepts,
+	# which is how a "friendly" link turns into someone else's server with
+	# certificate checking switched off.
+	case "$FLOW" in
+		xtls-rprx-vision | none | '') ;;
+		*) die "unsupported flow in link: $FLOW (expected xtls-rprx-vision)" ;;
+	esac
+	[ "$FLOW" = none ] && FLOW=""
+	case "$FP" in
+		chrome | firefox | edge | safari | ios | android | random | randomized | 360 | qq) ;;
+		*) die "unsupported uTLS fingerprint in link: $FP" ;;
+	esac
+
+	# Operator-supplied, but the same class of hole and just as cheap to close.
+	printf '%s' "$SOCKS_PORT" | grep -Eq '^[0-9]{1,5}$' &&
+		[ "$SOCKS_PORT" -ge 1 ] && [ "$SOCKS_PORT" -le 65535 ] ||
+		die "SOCKS_PORT must be 1-65535, got: $SOCKS_PORT"
+	if [ -n "${DIRECT_SUFFIXES:-}" ]; then
+		for _d in $(printf '%s' "$DIRECT_SUFFIXES" | tr ',' ' '); do
+			printf '%s' "$_d" | grep -Eq '^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$' ||
+				die "DIRECT_SUFFIXES entry is not a domain suffix: $_d"
+		done
+	fi
 
 	# Resolve once, here, so the container's resolver never has to look up the
 	# server name mid-flight -- and so the killswitch's bootstrap rule stays
@@ -122,6 +148,7 @@ json_suffix_array() {
 }
 
 vless_outbound() {
+	if [ -n "$FLOW" ]; then _flow_kv="\"flow\": \"$FLOW\","; else _flow_kv=""; fi
 	cat <<-EOF
 		    {
 		      "type": "vless",
@@ -129,7 +156,7 @@ vless_outbound() {
 		      "server": "$SERVER_IP",
 		      "server_port": $PORT,
 		      "uuid": "$UUID",
-		      "flow": "$FLOW",
+		      $_flow_kv
 		      "packet_encoding": "xudp",
 		      "tls": {
 		        "enabled": true,
diff --git a/install.sh b/install.sh
@@ -24,11 +24,25 @@ IMAGE_CLIENT=${TT_IMAGE_CLIENT:-ghcr.io/sqzer-x/transtation-client}
 SERVER_DIGEST=${TT_SERVER_DIGEST:-}
 CLIENT_DIGEST=${TT_CLIENT_DIGEST:-}
 
-DIR=/opt/transtation
+DIR=${TT_DIR:-/opt/transtation}
 URI_DIR=/etc/transtation
 RAW=https://raw.githubusercontent.com/sqzer-x/transtation/$VERSION
 
 say() { printf '%s\n' "$*"; }
+# Under `curl | sh` stdin is the script itself, so a bare `read` swallows the
+# rest of this file. Under cron or CI there is no /dev/tty at all. Try the
+# terminal, fall back to stdin, and treat "neither" as no answer.
+ask() {
+	printf '%s' "$1"
+	REPLY=""
+	# `[ -r /dev/tty ]` is not enough: the node exists and looks readable even
+	# with no controlling terminal, and opening it then fails with ENXIO and
+	# prints to stderr. Try the open for real, suppress it, fall back to stdin.
+	if { read -r REPLY </dev/tty; } 2>/dev/null && [ -n "$REPLY" ]; then
+		return 0
+	fi
+	read -r REPLY 2>/dev/null || REPLY=""
+}
 step() { printf '  %-14s%s\n' "$1" "$2"; }
 die() { printf '\ntranstation: %s\n\n' "$*" >&2; exit 1; }
 
@@ -37,7 +51,7 @@ client_image() { printf '%s:%s%s' "$IMAGE_CLIENT" "$VERSION" "${CLIENT_DIGEST:+@
 
 need_root() {
 	[ "$(id -u)" = 0 ] || die "this needs root. Re-run as root, or with sudo:
-    curl -fsSLO $RAW/install.sh && sudo sh install.sh $*"
+    curl -fsSLO $RAW/install.sh && sudo sh install.sh"
 }
 
 check_arch() {
@@ -132,9 +146,20 @@ install_server() {
 			    # Both BREAK the WARP outbound. See README, "Capabilities".
 			volumes:
 			  transtation-data:
+			    # Pinned. Without an explicit name Docker Compose prefixes it with
+			    # the project name, which comes from the directory -- so the volume
+			    # would be called something else on every host and every documented
+			    # backup, restore and uninstall command would silently address a
+			    # volume that does not exist.
+			    name: transtation-data
 		EOF
 		: >"$DIR/.env"
 		chmod 600 "$DIR/.env"
+		# If PORT came from the environment, write it down. Compose would honour
+		# it for the published port while the container -- which reads only
+		# env_file -- would still think it is 443, and the share link would name
+		# the wrong port.
+		[ -n "${PORT:-}" ] && printf 'PORT=%s\n' "$PORT" >>"$DIR/.env"
 		step config "wrote $DIR/docker-compose.yml and $DIR/.env"
 	fi
 	[ -n "$SERVER_DIGEST" ] || say "                 note: image pinned by tag, not digest (unreleased build)"
@@ -147,7 +172,7 @@ install_server() {
 	say "pulled $(server_image)"
 
 	printf '  %-14s' start
-	docker compose -f "$DIR/docker-compose.yml" --project-directory "$DIR" up -d >/dev/null
+	docker compose -f "$DIR/docker-compose.yml" --project-directory "$DIR" up -d >/dev/null 2>&1
 	if ! wait_healthy transtation 120; then
 		say ""
 		say "  The container is up but not healthy yet. Most common causes:"
@@ -210,6 +235,23 @@ install_wrapper() {
 		    \$C exec -T proxy transtation backup > "\$f"
 		    echo "backed up to \$f (mode 0600): reality key, users, WARP account."
 		    ;;
+		  restore)
+		    f=\${2:-}
+		    [ -n "\$f" ] && [ -r "\$f" ] || { echo "usage: transtation restore <backup.tgz>" >&2; exit 1; }
+		    tar tzf "\$f" 2>/dev/null | grep -q '^state.env$' || { echo "\$f does not look like a transtation backup" >&2; exit 1; }
+		    if \$C ps -q proxy 2>/dev/null | grep -q .; then
+		      echo "stop the server first:  cd $DIR && docker compose down" >&2; exit 1
+		    fi
+		    # --user 0 so it can read a 0600 archive; the transtation image so that
+		    # Docker seeds a fresh volume with the right /data ownership; tar then
+		    # restores each file's archived uid.
+		    docker run --rm --user 0 \\
+		      -v transtation-data:/data \\
+		      -v "\$(cd "\$(dirname "\$f")" && pwd)":/b:ro \\
+		      --entrypoint sh $(server_image) \\
+		      -c "tar xzf /b/\$(basename "\$f") -C /data"
+		    echo "restored. start it again:  cd $DIR && docker compose up -d"
+		    ;;
 		  logs) shift; exec \$C logs "\$@" ;;
 		  up|down|pull|restart) exec \$C "\$@" ;;
 		  *) exec \$C exec proxy transtation "\$@" ;;
@@ -240,8 +282,10 @@ install_client() {
 
 	if [ -z "$_uri" ] && [ ! -r "$URI_DIR/uri" ]; then
 		say "  paste the link from your server (run 'transtation show' there):"
-		printf '  > '
-		read -r _uri
+		ask '  > '
+		_uri=$REPLY
+		[ -n "$_uri" ] || die "no link given. Pass it as an argument instead:
+    sh install.sh client 'vless://...'"
 	fi
 	if [ -n "$_uri" ]; then
 		mkdir -p "$URI_DIR"
@@ -258,10 +302,11 @@ install_client() {
 
 	if [ "$_tun" = 1 ]; then
 		step mode "tun -- whole-host transparent tunnel"
+		set --
+		[ -n "${DIRECT_SUFFIXES:-}" ] && set -- -e "DIRECT_SUFFIXES=$DIRECT_SUFFIXES"
 		docker run -d --name transtation-client --restart unless-stopped \
 			--network host --cap-add NET_ADMIN --device /dev/net/tun \
-			-e MODE=tun \
-			${DIRECT_SUFFIXES:+-e DIRECT_SUFFIXES="$DIRECT_SUFFIXES"} \
+			-e MODE=tun "$@" \
 			-v "$URI_DIR":"$URI_DIR":ro \
 			"$(client_image)" >/dev/null
 		install -Dm0755 /dev/stdin /usr/local/sbin/transtation-panic <<-'EOF'
@@ -336,10 +381,17 @@ uninstall() {
 		rm -f "$DIR/docker-compose.yml" "$DIR/.env" /usr/local/bin/transtation
 		rmdir "$DIR" 2>/dev/null || true
 		say "server removed."
-		printf 'also delete the data volume? That destroys your Reality key and every issued link. [y/N] '
-		read -r a
-		case "$a" in
-			y | Y) docker volume rm transtation-data >/dev/null 2>&1 && say "volume deleted." ;;
+		ask 'also delete the data volume? That destroys your Reality key and every issued link. [y/N] '
+		say ""
+		case "$REPLY" in
+			y | Y)
+				if docker volume rm transtation-data >/dev/null 2>&1; then
+					say "volume deleted."
+				else
+					say "could not delete the volume (in use, or already gone):"
+					docker volume ls --format '  {{.Name}}' | grep transtation || say "  none found"
+				fi
+				;;
 			*) say "volume kept: 'docker volume ls | grep transtation-data'" ;;
 		esac
 	fi
diff --git a/server/transtation b/server/transtation
@@ -565,8 +565,11 @@ sni_write() {
 	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" "$2" "$(date -u +%F)" >"$_t"
+	printf 'SNI=%s\nSNI_REASON=%s\nSNI_CHOSEN=%s\n' "$1" "$_r" "$(date -u +%F)" >"$_t"
 	chmod 600 "$_t"
 	mv -f "$_t" "$SNIFILE"
 }
@@ -683,6 +686,7 @@ cmd_status() {
 	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'
@@ -710,6 +714,7 @@ cmd_backup() {
 	[ -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) \
@@ -846,6 +851,15 @@ cmd_selftest() {
 		_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
+			eval "$1=\$2"
+			validate_config_env) >/dev/null 2>&1 && return 1 || return 0
+	}
+	_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)" \
@@ -877,6 +891,29 @@ cmd_selftest() {
 	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() {
+	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: $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: $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: $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: ${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: ${WARP_ENDPOINT:-}"
+}
+
 # --------------------------------------------------------------------- run
 
 banner() {
@@ -931,18 +968,7 @@ cmd_run() {
 		_picked_now=1
 	fi
 
-	# Validated before anything else touches it: SNI reaches a JSON document,
-	# and a value sourced from a file would otherwise 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])?)+$' ||
-		die "SNI must be a hostname, got: $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: $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: $PORT"
+	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.
diff --git a/test.sh b/test.sh
@@ -124,6 +124,23 @@ 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'
 check "rejects an odd-length sid" rejects 'vless://8f3e21c4-7a09-4b2e-9d51-6c0f1a2b3c4d@203.0.113.10:443?pbk=6ZP9LtQm3vXk8sT2wYnBcRfJhGdA1uEoI0pZxCyN4Vs&sid=abc&sni=a.com'
+# A share link is something somebody hands you -- in a chat, on a forum, as a
+# QR code. Both of these once rendered a config that sing-box ACCEPTED, which
+# is how a "friendly" link becomes someone else's server with certificate
+# checking turned off.
+check "rejects JSON injection through flow=" rejects 'vless://8f3e21c4-7a09-4b2e-9d51-6c0f1a2b3c4d@203.0.113.10:443?pbk=6ZP9LtQm3vXk8sT2wYnBcRfJhGdA1uEoI0pZxCyN4Vs&sid=1c69b566b0480c74&sni=a.com&flow=xtls-rprx-vision","server":"6.6.6.6","x":"'
+check "rejects JSON injection through fp=" rejects 'vless://8f3e21c4-7a09-4b2e-9d51-6c0f1a2b3c4d@203.0.113.10:443?pbk=6ZP9LtQm3vXk8sT2wYnBcRfJhGdA1uEoI0pZxCyN4Vs&sid=1c69b566b0480c74&sni=a.com&fp=chrome"},"insecure":true,"utls":{"enabled":true,"fingerprint":"chrome'
+check "rejects an unknown uTLS fingerprint" rejects 'vless://8f3e21c4-7a09-4b2e-9d51-6c0f1a2b3c4d@203.0.113.10:443?pbk=6ZP9LtQm3vXk8sT2wYnBcRfJhGdA1uEoI0pZxCyN4Vs&sid=1c69b566b0480c74&sni=a.com&fp=nonesuch'
+
+echo
+echo "== client rejects hostile operator environment =="
+env_rejects() {
+	_out=$(docker run --rm --network none -e TT_URI="$FIXTURE_URI" "$@" "$CLIENT_IMG" check 2>&1) && return 1
+	case "$_out" in *transtation-client:*) return 0 ;; *) return 1 ;; esac
+}
+check "rejects a non-numeric SOCKS_PORT" env_rejects -e 'SOCKS_PORT=1080, "sniff": true'
+check "rejects DIRECT_SUFFIXES that is not a domain list" env_rejects -e 'DIRECT_SUFFIXES=a.com"],"outbound":"direct"},{"x":"'
+
 check "rejects a bad port" rejects 'vless://8f3e21c4-7a09-4b2e-9d51-6c0f1a2b3c4d@203.0.113.10:99999?pbk=6ZP9LtQm3vXk8sT2wYnBcRfJhGdA1uEoI0pZxCyN4Vs&sid=1c69b566b0480c74&sni=a.com'
 
 echo