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'
|