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