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

test.sh (13610B)

      1 #!/bin/sh
      2 # transtation test suite. Builds the server image and runs its offline self-checks.
      3 #
      4 #   sh test.sh
      5 #
      6 # Needs Docker. Everything it runs is offline (--network none), so it is safe on
      7 # a laptop and it never registers a WARP identity -- hammering Cloudflare's
      8 # registration endpoint from CI is how a source IP gets blocked.
      9 set -eu
     10 cd "$(dirname "$0")"
     11 
     12 SERVER_IMG=${SERVER_IMG:-transtation-test-server}
     13 FIXTURE_URI='vless://8f3e21c4-7a09-4b2e-9d51-6c0f1a2b3c4d@203.0.113.10:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=www.microsoft.com&fp=chrome&pbk=6ZP9LtQm3vXk8sT2wYnBcRfJhGdA1uEoI0pZxCyN4Vs&sid=1c69b566b0480c74&spx=%2F&type=tcp&headerType=none#transtation-test'
     14 
     15 fail=0
     16 ok() { printf '  ok    %s\n' "$1"; }
     17 bad() { printf '  FAIL  %s\n' "$1"; fail=1; }
     18 check() { _d=$1; shift; if "$@" >/dev/null 2>&1; then ok "$_d"; else bad "$_d"; fi; }
     19 
     20 echo
     21 echo "== shell syntax =="
     22 for f in install.sh test.sh server/transtation client/transtation-client \
     23 	host/transtation-killswitch host/transtation-panic; do
     24 	check "sh -n $f" sh -n "$f"
     25 done
     26 if command -v shellcheck >/dev/null 2>&1; then
     27 	for f in install.sh server/transtation client/transtation-client \
     28 		host/transtation-killswitch host/transtation-panic; do
     29 		check "shellcheck $f" shellcheck -s sh -S warning "$f"
     30 	done
     31 else
     32 	echo "  skip  shellcheck (not installed)"
     33 fi
     34 
     35 echo
     36 echo "== the installer must ask for no capabilities and no devices =="
     37 # Least privilege: the server needs no capabilities and no devices, so the
     38 # installer must never quietly grant any. (The generated config also pins
     39 # "noKernelTun": true, which makes WARP independent of capabilities -- but the
     40 # compose file should still ask for nothing.)
     41 # Scoped to the compose block the installer actually writes, with comments
     42 # stripped -- an earlier version of this test matched its own warning comment
     43 # and "failed" on a correct file.
     44 server_compose() {
     45 	awk '/docker-compose\.yml" <<-EOF/ { f = 1; next } f && /^[\t ]*EOF$/ { f = 0 } f' install.sh |
     46 		sed 's/[[:space:]]*#.*//'
     47 }
     48 absent_in_compose() { ! server_compose | grep -qE "$1"; }
     49 uncommented_absent() { ! sed 's/[[:space:]]*#.*//' "$2" | grep -qE "$1"; }
     50 
     51 check "the compose block sets no cap_add" absent_in_compose 'cap_add'
     52 check "the compose block mounts no devices" absent_in_compose 'devices:'
     53 check "the compose block does not use network_mode: host" absent_in_compose 'network_mode'
     54 present_in_compose() { server_compose | grep -qE "$1"; }
     55 check "the compose block caps the log size (xray logs to stdout)" present_in_compose 'max-size'
     56 check "the compose block uses a named volume, not a bind mount" present_in_compose 'transtation-data:/data'
     57 check "no script ever runs 'nft flush ruleset'" uncommented_absent 'flush ruleset' host/transtation-killswitch
     58 
     59 # /usr/local/bin/xray is where the official Xray installer and most hand-rolled
     60 # setups put their binary. Installing ours there overwrote a working server, and
     61 # uninstalling then deleted it -- taking that server down. Ours live in a
     62 # private directory and the uninstall only ever removes that.
     63 no_clobber() { ! grep -qE "install -Dm?0?755 [^ ]+ /usr/local/bin/(xray|wgcf|sing-box)" install.sh; }
     64 no_foreign_rm() { ! grep -qE "rm -[rf]+ .*/usr/local/bin/(xray|wgcf|sing-box)" install.sh; }
     65 check "the installer never writes third-party binaries into /usr/local/bin" no_clobber
     66 check "the uninstaller never deletes third-party binaries it did not install" no_foreign_rm
     67 
     68 
     69 # The version is repeated in the installer and in every documented command.
     70 # It drifted five releases once, because bumping it was a hand-run sed, and the
     71 # README ended up telling people to install a version without the security
     72 # fixes. One check is cheaper than remembering.
     73 versions_agree() {
     74     _v=$(sed -n 's/^VERSION=${TT_VERSION:-\(v[0-9.]*\)}.*/\1/p' install.sh)
     75     [ -n "$_v" ] || return 1
     76     ! grep -oE 'v1\.[0-9]+\.[0-9]+' install.sh README.md | grep -v ":$_v$" | grep -q .
     77 }
     78 check "every documented version string matches install.sh" versions_agree
     79 # Hardening the compose file grants. The running process was verified to hold
     80 # CapEff 0000000000000000 with these in place.
     81 check "the compose block drops all capabilities" present_in_compose "cap_drop"
     82 check "the compose block sets no-new-privileges" present_in_compose "no-new-privileges"
     83 check "the compose block mounts the root filesystem read-only" present_in_compose "read_only"
     84 
     85 
     86 # The README says `transtation <verb>` means the same thing on both install
     87 # paths. The native wrapper was a pure passthrough once, so `backup` printed a
     88 # tarball of the Reality private key to the terminal and `restore` did not exist
     89 # -- and redirecting it by hand produced a 0644 file, because root's umask is
     90 # 022. Both wrappers must carry the same three verbs, and backup must be 0600.
     91 wrapper_native() {
     92 	awk '/install -Dm0755 \/dev\/stdin \/usr\/local\/bin\/transtation <<-.WRAP./{f=1;next}
     93 	     f && /^\tWRAP$/{exit} f' install.sh | sed 's/^\t\t//'
     94 }
     95 wrapper_parity() {
     96 	for _v in backup restore logs; do
     97 		wrapper_native | grep -q "^  $_v)" || return 1
     98 		grep -q "^		  $_v)" install.sh || return 1
     99 	done
    100 }
    101 wrapper_backup_is_0600() (
    102 	_t=$(mktemp -d) || return 1
    103 	trap 'rm -rf "$_t"' EXIT
    104 	mkdir -p "$_t/bin" "$_t/lib" "$_t/data"
    105 	wrapper_native | sed "s#^D=/var/lib/transtation#D=$_t/data#; s#^L=/usr/local/lib/transtation#L=$_t/lib#" \
    106 		>"$_t/bin/transtation"
    107 	printf '#!/bin/sh\ntar czf - -C "$TT_DATA" state.env\n' >"$_t/lib/server"
    108 	# Take the already-the-service-user branch, so no privilege drop is needed.
    109 	printf '#!/bin/sh\n[ "$1" = -un ] && { echo transtation; exit 0; }\nexec /usr/bin/id "$@"\n' >"$_t/bin/id"
    110 	chmod +x "$_t/bin/transtation" "$_t/bin/server" "$_t/lib/server" "$_t/bin/id" 2>/dev/null
    111 	: >"$_t/data/state.env"
    112 	PATH="$_t/bin:$PATH"
    113 	# root's umask is what made this a real hole; reproduce it.
    114 	umask 022
    115 	transtation backup "$_t/b.tgz" >/dev/null 2>&1 || return 1
    116 	[ "$(stat -c %a "$_t/b.tgz")" = 600 ] || return 1
    117 	# and it must refuse to overwrite, rather than silently replace an old one
    118 	transtation backup "$_t/b.tgz" >/dev/null 2>&1 && return 1
    119 	# a file that is not a backup must be refused before anything is unpacked
    120 	echo junk | gzip >"$_t/junk.tgz"
    121 	transtation restore "$_t/junk.tgz" >/dev/null 2>&1 && return 1
    122 	return 0
    123 )
    124 # `transtation show` straight after `systemctl start` used to be told to restart,
    125 # which loops you: waiting was all it needed. It now waits -- but only while the
    126 # service is actually coming up. A stopped server must still fail immediately,
    127 # or every command on a down server hangs for fifteen seconds.
    128 down_server_fails_fast() (
    129 	_d=$(mktemp -d) _b=$(mktemp -d) || return 1
    130 	trap 'rm -rf "$_d" "$_b"' EXIT
    131 	printf 'SCHEMA=1\nREALITY_PRIV=x\nREALITY_PUB=y\nCREATED=2026-01-01\n' >"$_d/state.env"
    132 	printf 'main\t8f3e21c4-7a09-4b2e-9d51-6c0f1a2b3c4d\t1c69b566b0480c74\n' >"$_d/users.tsv"
    133 	printf '#!/bin/sh\nexit 3\n' >"$_b/systemctl"   # "inactive"
    134 	chmod +x "$_b/systemctl"
    135 	_t0=$(date +%s)
    136 	PATH="$_b:$PATH" TT_MANAGED=systemd TT_DATA=$_d sh server/transtation status >/dev/null 2>&1 && return 1
    137 	[ $(( $(date +%s) - _t0 )) -lt 3 ]
    138 )
    139 check "both install paths implement the same transtation verbs" wrapper_parity
    140 check "a stopped server fails immediately instead of waiting for it" down_server_fails_fast
    141 check "native backup is 0600 even under root's umask, and will not clobber" wrapper_backup_is_0600
    142 
    143 
    144 if ! command -v docker >/dev/null 2>&1; then
    145 	echo
    146 	echo "  skip  image builds and runtime checks (docker not installed)"
    147 	echo
    148 	[ "$fail" = 0 ] || { echo "FAILED"; exit 1; }
    149 	echo "static checks passed"
    150 	exit 0
    151 fi
    152 
    153 echo
    154 echo "== build =="
    155 # buildx, not the legacy builder: the Dockerfiles use --platform=$BUILDPLATFORM
    156 # on the fetch stage so that cross-arch CI builds do not run curl and unzip
    157 # under QEMU, and the legacy builder cannot parse it.
    158 docker buildx version >/dev/null 2>&1 || {
    159 	echo "  FAIL  docker buildx is required (Arch: pacman -S docker-buildx)"
    160 	exit 1
    161 }
    162 # A failed build must be fatal. Everything below runs `docker run`, and a
    163 # missing image makes those commands fail in exactly the way a *passing*
    164 # negative test looks like -- which is how an earlier version of this file
    165 # reported all green against images that did not exist.
    166 docker buildx build --load -f server/Dockerfile -t "$SERVER_IMG" . >/dev/null 2>&1 ||
    167 	{ bad "server image builds"; exit 1; }
    168 ok "server image builds"
    169 docker image inspect "$SERVER_IMG" >/dev/null 2>&1 || { bad "server image exists"; exit 1; }
    170 
    171 echo
    172 echo "== server selftest (offline) =="
    173 docker run --rm --network none "$SERVER_IMG" selftest || fail=1
    174 
    175 echo
    176 echo "== server image runs unprivileged and carries no dead weight =="
    177 image_user_nonroot() {
    178 	_u=$(docker run --rm --network none --entrypoint id "$SERVER_IMG" -u) || return 1
    179 	[ -n "$_u" ] && [ "$_u" != 0 ]
    180 }
    181 no_geodata() {
    182 	_l=$(docker run --rm --network none --entrypoint ls "$SERVER_IMG" -1 /usr/local/share/xray 2>&1) || return 0
    183 	case "$_l" in *.dat*) return 1 ;; *) return 0 ;; esac
    184 }
    185 check "server image default user is not root" image_user_nonroot
    186 check "server image ships no geoip/geosite data" no_geodata
    187 
    188 echo
    189 echo "== the installer runs under a real POSIX /bin/sh, not just bash =="
    190 # `shift` with no positional parameters is a special-builtin error and a POSIX
    191 # shell EXITS on it, `|| true` notwithstanding. dash is /bin/sh on Debian and
    192 # Ubuntu, so `sh install.sh` with no arguments died there silently while every
    193 # bash-based test passed. Run the real thing, as a non-root user, in both
    194 # shells, and require the root message rather than merely a non-zero exit.
    195 posix_sh_ok() {
    196 	_o=$(docker run --rm -v "$PWD":/w:ro -u 65534 "$1" sh -c "cd /w && $2 install.sh" 2>&1) || true
    197 	case "$_o" in *"needs root"*) return 0 ;; *) return 1 ;; esac
    198 }
    199 check "install.sh survives dash (Debian, Ubuntu) with no arguments" posix_sh_ok debian:stable-slim dash
    200 check "install.sh survives busybox ash (Alpine) with no arguments" posix_sh_ok alpine:3.23 sh
    201 posix_sh_native_ok() {
    202 	_o=$(docker run --rm -v "$PWD":/w:ro -u 65534 "$1" sh -c "cd /w && $2 install.sh server --native" 2>&1) || true
    203 	case "$_o" in *"needs root"*) return 0 ;; *) return 1 ;; esac
    204 }
    205 check "the native server path dispatches under dash" posix_sh_native_ok debian:stable-slim dash
    206 
    207 echo
    208 echo "== client renders configs the real sing-box accepts =="
    209 # The client is a native install now, so this exercises it the way it actually
    210 # runs: a script on the host driving a sing-box binary. Fetch the pinned build
    211 # if the host has none.
    212 SB=$(command -v sing-box 2>/dev/null || true)
    213 if [ -z "$SB" ]; then
    214 	_v=$(sed -n 's/^SINGBOX_VERSION=${TT_SINGBOX_VERSION:-\(.*\)}$/\1/p' install.sh)
    215 	case "$(uname -m)" in
    216 		x86_64 | amd64) _a=amd64; _sum=$(sed -n 's/^SINGBOX_SHA_amd64=//p' install.sh) ;;
    217 		aarch64 | arm64) _a=arm64; _sum=$(sed -n 's/^SINGBOX_SHA_arm64=//p' install.sh) ;;
    218 	esac
    219 	_n="sing-box-${_v}-linux-${_a}-musl"
    220 	_t=$(mktemp -d)
    221 	if curl -fsSL -o "$_t/s.tgz" "https://github.com/SagerNet/sing-box/releases/download/v${_v}/${_n}.tar.gz" &&
    222 		echo "$_sum  $_t/s.tgz" | sha256sum -c - >/dev/null 2>&1; then
    223 		tar xzf "$_t/s.tgz" -C "$_t"
    224 		SB="$_t/$_n/sing-box"
    225 		ok "fetched and verified the pinned sing-box ($_v)"
    226 	else
    227 		bad "could not fetch a verified sing-box; client config checks skipped"
    228 	fi
    229 fi
    230 
    231 if [ -n "$SB" ]; then
    232 	client_check() {
    233 		TT_URI="$1" TT_URI_FILE=/nonexistent TT_SINGBOX="$SB" TT_RUN_DIR=$(mktemp -d) \
    234 			DIRECT_SUFFIXES=naver.com,go.kr sh client/transtation-client check >/dev/null 2>&1
    235 	}
    236 	check "renders both modes and sing-box accepts them" client_check "$FIXTURE_URI"
    237 
    238 	echo
    239 	echo "== client rejects hostile links and hostile settings =="
    240 	rejects() {
    241 		_out=$(TT_URI="$1" TT_URI_FILE=/nonexistent TT_SINGBOX="$SB" TT_RUN_DIR=$(mktemp -d) \
    242 			sh client/transtation-client check 2>&1) && return 1
    243 		case "$_out" in *transtation-client:*) return 0 ;; *) return 1 ;; esac
    244 	}
    245 	env_rejects() {
    246 		_out=$(TT_URI="$FIXTURE_URI" TT_URI_FILE=/nonexistent TT_SINGBOX="$SB" TT_RUN_DIR=$(mktemp -d) \
    247 			env "$1" sh client/transtation-client check 2>&1) && return 1
    248 		case "$_out" in *transtation-client:*) return 0 ;; *) return 1 ;; esac
    249 	}
    250 	check "rejects a non-vless scheme" rejects 'https://example.com'
    251 	check "rejects a bad uuid" rejects 'vless://not-a-uuid@203.0.113.10:443?pbk=6ZP9LtQm3vXk8sT2wYnBcRfJhGdA1uEoI0pZxCyN4Vs&sid=1c69b566b0480c74&sni=a.com'
    252 	check "rejects a short pbk" rejects 'vless://8f3e21c4-7a09-4b2e-9d51-6c0f1a2b3c4d@203.0.113.10:443?pbk=tooshort&sid=1c69b566b0480c74&sni=a.com'
    253 	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'
    254 	check "rejects a bad port" rejects 'vless://8f3e21c4-7a09-4b2e-9d51-6c0f1a2b3c4d@203.0.113.10:99999?pbk=6ZP9LtQm3vXk8sT2wYnBcRfJhGdA1uEoI0pZxCyN4Vs&sid=1c69b566b0480c74&sni=a.com'
    255 	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":"'
    256 	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=nonesuch'
    257 	check "rejects a non-numeric SOCKS_PORT" env_rejects 'SOCKS_PORT=1080, "sniff": true'
    258 	check "rejects DIRECT_SUFFIXES that is not a domain list" env_rejects 'DIRECT_SUFFIXES=a.com"],"outbound":"direct"},{"x":"'
    259 fi
    260 
    261 echo
    262 [ "$fail" = 0 ] || { echo "FAILED"; exit 1; }
    263 echo "all tests passed"
    264 echo