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 e70837eef8bda4a218c2f6c97288ab5d76855aad
parent 594fdc49e451a272800505d478b62a66db56d91d
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Thu, 6 Aug 2026 19:17:55 +0900

The client is a native install now, not a container

Shipping the client as a container never made sense and the README said so
itself: a container given --network host and CAP_NET_ADMIN "provides no
isolation whatsoever", and "for a permanent desktop setup the native sing-box
package is the better answer". The project was distributing the thing it
recommended against and not distributing the thing it recommended. Phones and
desktops -- most of the people who would use a proxy -- cannot run a container
at all, and on Linux the container covered only rootful Docker, where the
native binary is better anyway. The killswitch and the panic script were host
scripts from the first commit, which was the tell.

`install.sh client` now installs a verified static sing-box, a generated config
and a systemd unit, with the whole-host tunnel as the default. Proxy mode
survives behind --proxy. The client image, its Dockerfile and its GHCR package
are gone, so there is one image to build and publish instead of two.

The self-test for tun mode was wrong in a way the container version shared: it
accepted any reply as success, so it passed while routing was still settling
and the answer had gone out the ordinary way -- reporting success on a machine
that was not tunnelled. It now records the address before the tunnel exists and
requires it to have moved, and says "still your own address" and exits non-zero
when it has not. Confirmed in both directions against a live server and a dead
one.

NOTICE changes with it: sing-box is no longer redistributed. The installer
fetches the official release and verifies it against a hash in this repository,
which is a different obligation from shipping the binary, and the file now says
so.

Diffstat:
M.github/workflows/ci.yml | 16++++------------
MNOTICE | 31++++++++++++++-----------------
MREADME.md | 160+++++++++++++++++++++++++++++++++----------------------------------------------
Dclient/Dockerfile | 47-----------------------------------------------
Mclient/transtation-client | 106++++++++++++++++++++++++++++++++++++++-----------------------------------------
Minstall.sh | 243+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mtest.sh | 89++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
7 files changed, 344 insertion(+), 348 deletion(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
@@ -43,13 +43,6 @@ jobs:
     permissions:
       contents: write
       packages: write
-    strategy:
-      matrix:
-        include:
-          - name: transtation
-            dockerfile: server/Dockerfile
-          - name: transtation-client
-            dockerfile: client/Dockerfile
     steps:
       - uses: actions/checkout@v4
       - uses: docker/setup-qemu-action@v3
@@ -63,7 +56,7 @@ jobs:
       - id: meta
         uses: docker/metadata-action@v5
         with:
-          images: ghcr.io/${{ github.repository_owner }}/${{ matrix.name }}
+          images: ghcr.io/${{ github.repository_owner }}/transtation
           tags: |
             type=semver,pattern={{version}}
             type=semver,pattern=v{{version}}
@@ -74,7 +67,7 @@ jobs:
         uses: docker/build-push-action@v6
         with:
           context: .
-          file: ${{ matrix.dockerfile }}
+          file: server/Dockerfile
           platforms: linux/amd64,linux/arm64
           push: true
           tags: ${{ steps.meta.outputs.tags }}
@@ -89,12 +82,11 @@ jobs:
       # The digest goes into install.sh at release time so that the compose file
       # pins the image by content, not by a mutable tag.
       - name: digest
-        run: echo "${{ matrix.name }} -> ${{ steps.build.outputs.digest }}" >> "$GITHUB_STEP_SUMMARY"
+        run: echo "transtation -> ${{ steps.build.outputs.digest }}" >> "$GITHUB_STEP_SUMMARY"
 
       # The README tells people to check install.sh against a hash in the release
       # notes, so the release notes have to exist and have to carry that hash.
       - name: publish the release notes with the installer hash
-        if: matrix.name == 'transtation'
         env:
           GH_TOKEN: ${{ github.token }}
         run: |
@@ -109,7 +101,7 @@ jobs:
             echo "    less install.sh"
             echo "    sudo sh install.sh"
             echo
-            echo "Images: \`ghcr.io/${{ github.repository_owner }}/transtation:${{ github.ref_name }}\` and \`…/transtation-client:${{ github.ref_name }}\` (amd64, arm64)."
+            echo "Image: \`ghcr.io/${{ github.repository_owner }}/transtation:${{ github.ref_name }}\` (amd64, arm64). The client is a native install, not a container."
             echo "The installer resolves the tag to a digest at install time and pins the compose file to it."
           } > notes.md
           gh release create "${{ github.ref_name }}" --title "${{ github.ref_name }}" --notes-file notes.md || \
diff --git a/NOTICE b/NOTICE
@@ -4,8 +4,9 @@ Copyright (c) 2026 sqzer
 This project is licensed under the MIT License (see LICENSE).
 
 transtation itself contains no third-party source code. It is a set of shell
-scripts and Dockerfiles. The container images it builds, however, redistribute
-third-party binaries unmodified. Their licenses and obligations follow.
+scripts and one Dockerfile. The server image redistributes third-party binaries
+unmodified; the client installer downloads one rather than shipping it. Their
+licenses and obligations follow.
 
 ================================================================================
 Xray-core  --  redistributed in ghcr.io/sqzer-x/transtation
@@ -56,26 +57,23 @@ here through its documented client registration flow. Use of it is subject to
 Cloudflare's own terms.
 
 ================================================================================
-sing-box  --  redistributed in ghcr.io/sqzer-x/transtation-client
+sing-box  --  NOT redistributed; downloaded from upstream by the installer
 ================================================================================
 
 sing-box is licensed under the GNU General Public License, Version 3 or later
 (GPL-3.0-or-later).
 
   Upstream project : https://github.com/SagerNet/sing-box
-  Version shipped  : v1.13.16
-  Exact artifact   : https://github.com/SagerNet/sing-box/releases/download/v1.13.16/sing-box-1.13.16-linux-amd64.tar.gz
-                     https://github.com/SagerNet/sing-box/releases/download/v1.13.16/sing-box-1.13.16-linux-arm64.tar.gz
+  Version pinned   : v1.13.16
+  Fetched from     : https://github.com/SagerNet/sing-box/releases/download/v1.13.16/sing-box-1.13.16-linux-amd64-musl.tar.gz
+                     https://github.com/SagerNet/sing-box/releases/download/v1.13.16/sing-box-1.13.16-linux-arm64-musl.tar.gz
   Source Form      : https://github.com/SagerNet/sing-box/tree/v1.13.16
 
-The `sing-box` binary in the client image is the official upstream release
-artifact, byte-for-byte unmodified. transtation does not link against it, embed
-it, or derive from it; it invokes it as a separate program with a generated
-configuration file. This is mere aggregation on a storage medium, and does not
-place transtation's own MIT-licensed scripts under the GPL.
-
-Complete corresponding source for the redistributed binary is available at the
-Source Form URL above.
+transtation does not ship sing-box. `install.sh client` downloads the official
+release artifact from the URL above, verifies it against a SHA-256 recorded in
+this repository, and installs it. Nothing here links against it, embeds it or
+derives from it; the client script invokes it as a separate program with a
+generated configuration file.
 
 Per upstream's request, transtation is not affiliated with or endorsed by the
 sing-box project, and does not use its name to imply endorsement.
@@ -84,7 +82,6 @@ sing-box project, and does not use its name to imply endorsement.
 Alpine Linux base image
 ================================================================================
 
-Both images are built FROM alpine:3.23 and install `ca-certificates`, `curl`,
-`jq`, `libqrencode-tools`, `nftables`, and `iproute2` from the Alpine package
-repositories. Those packages are distributed under their own licenses; run
+The server image is built FROM alpine:3.23 and installs `ca-certificates`,
+`curl`, `jq` and `libqrencode-tools` from the Alpine package repositories. Those packages are distributed under their own licenses; run
 `apk info -L <package>` inside the image for per-package details.
diff --git a/README.md b/README.md
@@ -4,8 +4,8 @@ Your own VLESS + REALITY proxy, with optional Cloudflare WARP egress, in one
 container on any Linux box that has Docker.
 
 ```
-curl -fsSLO https://raw.githubusercontent.com/sqzer-x/transtation/v1.0.11/install.sh
-sha256sum install.sh      # compare against the hash in the v1.0.11 release notes
+curl -fsSLO https://raw.githubusercontent.com/sqzer-x/transtation/v1.0.12/install.sh
+sha256sum install.sh      # compare against the hash in the v1.0.12 release notes
 less install.sh           # please actually read it
 sudo sh install.sh
 ```
@@ -45,7 +45,7 @@ transtation exists because of a narrower set of preferences:
 Two-step form above is the recommended one. If you are going to pipe it anyway:
 
 ```
-curl -fsSL https://raw.githubusercontent.com/sqzer-x/transtation/v1.0.11/install.sh | sudo sh
+curl -fsSL https://raw.githubusercontent.com/sqzer-x/transtation/v1.0.12/install.sh | sudo sh
 ```
 
 The installer needs root, refuses anything other than x86_64/aarch64, installs
@@ -59,7 +59,7 @@ If you already run Docker and prefer to do it yourself:
 # /opt/transtation/docker-compose.yml
 services:
   proxy:
-    image: ghcr.io/sqzer-x/transtation:v1.0.11
+    image: ghcr.io/sqzer-x/transtation:v1.0.12
     container_name: transtation
     restart: unless-stopped
     ports: ["443:8443"]
@@ -106,42 +106,71 @@ opened in the instance's own iptables.
 
 ## Client
 
-You do not need this project's client at all. Any app that speaks VLESS +
-REALITY works with the link:
+You may well not need this project's client. The server prints a `vless://`
+link, and every platform already has a good client that takes one:
 
 | Platform | Use |
 |---|---|
 | Android | v2rayNG, Hiddify |
 | iOS | sing-box, Streisand, Shadowrocket |
 | Windows / macOS | v2rayN, Hiddify, sing-box |
-| Linux desktop, permanently | the native `sing-box` package plus upstream's systemd unit |
 
-The container is for headless Linux and for people who would rather not install
-anything.
+What those do not cover well is a Linux machine you want *entirely* behind the
+proxy, with a fail-closed killswitch, managed by the init system. That is what
+this client is for, and it installs natively — a verified static `sing-box`
+binary, a generated config and a systemd unit. **No Docker on the client.**
 
-### Proxy mode (default) — a local SOCKS5 + HTTP proxy
+Capturing a machine's traffic means owning its routing table. A container
+handed `--network host` and `CAP_NET_ADMIN` to do that isolates nothing; it is
+a packaging format with extra failure modes, and it excludes every phone and
+desktop anyway.
 
-**This does not put your machine behind the proxy.** It opens a proxy on
-`127.0.0.1:1080` and nothing else. Every program that should use it has to be
-pointed at it, one at a time. Your browser keeps using your own address until
-you configure it; so does everything started from your desktop, and every
-systemd service. If what you want is "all of this machine's traffic", that is
-[tun mode](#tun-mode--the-whole-host), not this.
+### Whole-host tunnel (default)
 
 ```
 sudo sh install.sh client
 ```
 
-or by hand:
+It asks for the link, verifies and installs sing-box, writes
+`transtation-client.service`, starts it, and then checks that this machine's
+public address actually **changed** — a reply on its own only proves the network
+works, which it did before you installed anything. If the address did not move,
+the installer says so and exits non-zero.
+
+```
+stop:    sudo systemctl stop transtation-client
+logs:    journalctl -u transtation-client -f
+panic:   sudo transtation-panic        # undo everything, needs no network
+```
+
+Everything the machine sends goes through the tunnel with no per-program
+configuration — including traffic from containers running on it.
+
+`DIRECT_SUFFIXES` is a comma-separated list of domain suffixes that bypass the
+tunnel entirely — for sites that block proxy IPs, or a bank that objects to a
+foreign exit. Those domains also resolve locally rather than through the tunnel,
+so you get the geographically correct answer before connecting.
+
+```
+DIRECT_SUFFIXES=example.com,example.net sudo sh install.sh client --killswitch
+```
+
+Needs Linux, root and systemd. The tunnel interface is created directly on the
+host, so there is no rootless story to get wrong and nothing to be confused
+about on macOS or Windows — use one of the apps above there.
+
+### Proxy mode — a local SOCKS5 + HTTP proxy
 
 ```
-install -Dm600 /dev/stdin /etc/transtation/uri   # paste the vless:// link, then Ctrl-D
-docker run -d --name transtation-client --restart unless-stopped \
-  -p 127.0.0.1:1080:1080 \
-  -v /etc/transtation:/etc/transtation:ro \
-  ghcr.io/sqzer-x/transtation-client:v1.0.11
+sudo sh install.sh client --proxy
 ```
 
+**This does not put your machine behind the proxy.** It opens a proxy on
+`127.0.0.1:1080` and nothing else. Every program that should use it has to be
+pointed at it, one at a time. Your browser keeps using your own address until
+you configure it; so does everything started from your desktop, and every
+systemd service.
+
 ```
 export ALL_PROXY=socks5h://127.0.0.1:1080
 export HTTPS_PROXY=http://127.0.0.1:1080 HTTP_PROXY=http://127.0.0.1:1080
@@ -163,73 +192,17 @@ DNS locally and leaks every hostname you visit. In Firefox, set the SOCKS host
 in the connection settings and tick "Proxy DNS when using SOCKS v5" — the
 environment variables above will not reach it.
 
-No capabilities, no devices, no host networking, so nothing here needs a
-rootful container runtime — it was run under rootless Podman as well as Docker.
-
-Client-side settings, all optional:
-
-| Variable | Default | What it does |
-|---|---|---|
-| `MODE` | `proxy` | `tun` for the whole-host tunnel |
-| `SOCKS_PORT` | `1080` | port the mixed SOCKS5/HTTP inbound listens on inside the container |
-| `DIRECT_SUFFIXES` | *(empty)* | comma-separated domain suffixes that bypass the tunnel (`tun` mode) |
-| `TT_URI_FILE` | `/etc/transtation/uri` | where the share link is read from |
-| `TT_URI` | *(unset)* | the link inline — visible in `docker inspect`, so prefer the file |
-
 Two more things this mode will not do even for a program you *have* pointed at
 it: QUIC (browsers do not send UDP over SOCKS, so it silently falls back to
 TCP), and anything that reads no proxy setting at all.
 
-**No killswitch is needed here** — nothing is redirected, so if the container
-dies, proxied connections simply fail and everything else was never going
-through it in the first place. That is this mode's biggest advantage and its
-biggest limitation, and they are the same sentence.
-
-### Tun mode — the whole host
-<a id="tun-mode--the-whole-host"></a>
-
-This is the one that captures everything the machine sends, with no per-program
-configuration.
-
-```
-sudo sh install.sh client --tun --killswitch
-```
-
-or by hand:
-
-```
-docker run -d --name transtation-client --restart unless-stopped \
-  --network host --cap-add NET_ADMIN --device /dev/net/tun \
-  -e MODE=tun \
-  -e DIRECT_SUFFIXES=example.com,example.net \
-  -v /etc/transtation:/etc/transtation:ro \
-  ghcr.io/sqzer-x/transtation-client:v1.0.11
-```
-
-Exactly three extra flags. `--privileged` is **not** needed and must not be
-used. `--network host` is not optional: without it the tunnel comes up inside
-the container's own network namespace and your host's traffic is untouched.
+Settings, all optional:
 
-**Linux only, rootful Docker only.** On Docker Desktop (Windows, macOS)
-`--network host` joins the *virtual machine's* namespace, so tun mode there is
-not unsupported, it is architecturally incapable. Under rootless Docker or
-Podman it cannot work either: a capability held in a child user namespace does
-not authorise operations on a network namespace owned by the initial one. The
-container tests for that at startup and refuses with the reason rather than
-half-starting.
-
-Be honest with yourself about what this buys: a container with `--network host`
-and `CAP_NET_ADMIN` provides **no isolation whatsoever**, while adding the
-SELinux/AppArmor/rootless matrix and losing systemd-resolved integration (the
-container has no `resolvectl`, so sing-box falls back to hijacking port 53). For
-a permanent desktop setup the native `sing-box` package with upstream's hardened
-systemd unit is the better answer. Tun mode ships because it is genuinely useful
-on a headless box, not because it is more secure.
-
-`DIRECT_SUFFIXES` is a comma-separated list of domain suffixes that bypass the
-tunnel entirely — for sites that block proxy IPs, or a bank that objects to a
-foreign exit. Those domains also resolve locally rather than through the tunnel,
-so you get the geographically correct answer before connecting.
+| Variable | Default | What it does |
+|---|---|---|
+| `DIRECT_SUFFIXES` | *(empty)* | comma-separated domain suffixes that bypass the tunnel |
+| `SOCKS_PORT` | `1080` | proxy mode's listening port |
+| `TT_URI_FILE` | `/etc/transtation/uri` | where the share link is read from |
 
 ### Killswitch and recovery
 
@@ -265,10 +238,9 @@ If your network ever dies:
 sudo transtation-panic
 ```
 
-Small, and it needs neither a network nor Docker. It also exists as a verb of
-the client image (`docker run --rm --network host --cap-add NET_ADMIN <image>
-panic`), and the same commands are printed in the client's startup banner every
-boot, so they are in `docker logs` and in your scrollback.
+Small, and it needs neither a network nor Docker. The same commands are printed
+in the client's startup banner every boot, so they are in
+`journalctl -u transtation-client` and in your scrollback.
 
 ---
 
@@ -538,11 +510,13 @@ host's own proxy stopped, so nothing was measured through a second proxy:
   the container left `curl` timing out rather than leaking. `transtation-panic`
   restored direct egress with no rules, no interface and no table left behind,
   and without disturbing Tailscale's rules or other containers.
-- **Rootless.** `MODE=proxy` works under rootless Podman. `MODE=tun` there
-  refuses with the reason rather than half-starting; under rootful Podman it
-  works.
-- **arm64.** The published arm64 image was run as a server under emulation,
-  with a client on the other architecture passing traffic through it.
+- **The client, natively.** `install.sh client` on this laptop: verified
+  sing-box, systemd unit, tunnel up, host egress moved off the ISP address,
+  `DIRECT_SUFFIXES` domains still on their real path, other containers on the
+  host carried too, and `transtation-panic` putting it all back. Pointed at a
+  dead server it reports "still your own address" and exits non-zero.
+- **arm64.** The published arm64 server image was run under emulation with a
+  client on the other architecture passing traffic through it.
 - **Timings, measured rather than asserted.** On that VPS: 7.3 s from
   `docker compose up` to healthy on a first boot that generates keys, picks a
   dest and registers with WARP; 14.4 s to a proven-working proxy. Most of the
diff --git a/client/Dockerfile b/client/Dockerfile
@@ -1,47 +0,0 @@
-# syntax=docker/dockerfile:1
-#
-# transtation client -- sing-box with a VLESS/REALITY outbound.
-#
-#   MODE=proxy (default)  local SOCKS5 + HTTP on :1080. No capabilities, no
-#                         devices, no host network. Works under rootless Docker
-#                         and Podman.
-#   MODE=tun              transparent whole-host tunnel. Needs --network host,
-#                         --cap-add NET_ADMIN and --device /dev/net/tun, and
-#                         only works on Linux with rootful Docker.
-#
-# sing-box publishes no checksum file, so the hashes below were computed once
-# from the official release artifacts and are reviewed as part of this repo.
-# Bump them together with SINGBOX_VERSION.
-
-FROM --platform=$BUILDPLATFORM alpine:3.23 AS fetch
-ARG TARGETARCH
-ARG SINGBOX_VERSION=1.13.16
-ARG SHA256_AMD64=9ff0345fde4157a6bdab45a615668d41ccc93f6d0f361108042a48b8a49a9baa
-ARG SHA256_ARM64=3ea951c68f2eea10fd3ee8f8cc7794c12ccc7405afa99279a79e0b41cb183adf
-RUN apk add --no-cache curl && set -eu; \
-    case "$TARGETARCH" in \
-      amd64) SUM=$SHA256_AMD64 ;; \
-      arm64) SUM=$SHA256_ARM64 ;; \
-      *) echo "transtation: unsupported TARGETARCH=$TARGETARCH (amd64 and arm64 only)" >&2; exit 1 ;; \
-    esac; \
-    # The -musl build is statically linked and ships no libcronet.so; the plain
-    # linux-<arch> build is dynamically linked against glibc and will not run
-    # on Alpine.
-    N=sing-box-${SINGBOX_VERSION}-linux-${TARGETARCH}-musl; \
-    curl -fsSLO "https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VERSION}/${N}.tar.gz"; \
-    echo "$SUM  ${N}.tar.gz" | sha256sum -c -; \
-    tar xzf "${N}.tar.gz"; \
-    install -D -m 0755 "${N}/sing-box" /out/sing-box
-
-FROM alpine:3.23
-# nftables + iproute2 are here only for the `panic` verb, so that the recovery
-# path works from an image the user has already pulled, with no network.
-RUN apk add --no-cache ca-certificates curl musl-utils nftables iproute2
-COPY --from=fetch /out/sing-box /usr/local/bin/sing-box
-COPY client/transtation-client /usr/local/bin/transtation-client
-COPY NOTICE /NOTICE
-
-ENV MODE=proxy
-EXPOSE 1080
-ENTRYPOINT ["/usr/local/bin/transtation-client"]
-CMD ["run"]
diff --git a/client/transtation-client b/client/transtation-client
@@ -1,17 +1,23 @@
 #!/bin/sh
-# transtation-client -- connect to a transtation server.
+# transtation-client -- connect this host to a transtation server.
 #
-#   MODE=proxy (default)  local SOCKS5 + HTTP proxy. Unprivileged.
-#   MODE=tun              transparent whole-host tunnel. Linux, rootful.
+#   MODE=tun    (default)  the whole machine's traffic goes through the tunnel
+#   MODE=proxy             a local SOCKS5 + HTTP proxy, nothing captured
 #
-# The share link is a credential. Mount it as a file rather than passing it in
-# the environment or on the command line: argv is world-readable through
-# /proc/<pid>/cmdline and lands in shell history, and environment variables are
-# visible in `docker inspect`.
+# This runs on the host, not in a container. Capturing a machine's traffic means
+# owning its routing table, and a container that has been given --network host
+# and CAP_NET_ADMIN to do that is not isolating anything -- it is a package
+# format with extra failure modes. Phones and desktops cannot run one at all.
+# So: a static binary, a rendered config and a systemd unit.
+#
+# The share link is a credential. It is read from a file, and the generated
+# config is written under /run so it never touches the disk.
 set -eu
 
 URI_FILE=${TT_URI_FILE:-/etc/transtation/uri}
-CONF=/run/transtation.json
+RUN_DIR=${TT_RUN_DIR:-/run/transtation}
+CONF="$RUN_DIR/config.json"
+SINGBOX=${TT_SINGBOX:-sing-box}
 TUN_IFACE=tt0
 TUN_ADDR=172.19.0.1/30
 SOCKS_PORT=${SOCKS_PORT:-1080}
@@ -178,9 +184,8 @@ render_proxy() {
 	# caller using socks5h:// so the proxy resolves, plus the server address
 	# already being a literal IP.
 	#
-	# listen is 0.0.0.0 *inside the container* -- binding 127.0.0.1 there would
-	# make the published port unreachable. Loopback confinement comes from the
-	# "127.0.0.1:" prefix on the docker -p flag, which the docs always use.
+	# Loopback only. This runs on the host, so binding anything wider would put
+	# an open proxy on the network.
 	cat <<-EOF
 		{
 		  "log": { "level": "warn" },
@@ -188,7 +193,7 @@ render_proxy() {
 		    {
 		      "type": "mixed",
 		      "tag": "in",
-		      "listen": "0.0.0.0",
+		      "listen": "127.0.0.1",
 		      "listen_port": $SOCKS_PORT
 		    }
 		  ],
@@ -263,25 +268,16 @@ render_tun() {
 # --------------------------------------------------------------- preflight
 
 preflight_tun() {
-	[ -e /dev/net/tun ] || die \
-		"no /dev/net/tun in this container. Add:  --device /dev/net/tun
-    If the HOST has no tun device either:
-      sudo modprobe tun && echo tun | sudo tee /etc/modules-load.d/tun.conf
-    Do NOT add --cap-add SYS_MODULE to let the container load it."
-
-	if ! ip rule add priority 32765 from all lookup main 2>/dev/null; then
-		die "cannot manage host routing. Either --cap-add NET_ADMIN is missing, or this
-    is rootless Docker/Podman -- where MODE=tun CANNOT work at all, because a
-    capability held in a child user namespace does not authorise operations on
-    a network namespace owned by the initial user namespace. Use MODE=proxy.
-    On Fedora/RHEL also try:  sudo setsebool -P container_use_devices=true"
+	[ "$(id -u)" = 0 ] || die "MODE=tun needs root: it creates a network interface and
+    rewrites this machine's routing table. Run it under the systemd unit the
+    installer sets up, or with sudo."
+	if [ ! -e /dev/net/tun ]; then
+		modprobe tun 2>/dev/null || true
+		[ -e /dev/net/tun ] || die "no /dev/net/tun on this host, and 'modprobe tun' did not
+    create one. Load the module and make it stick:
+      modprobe tun && echo tun > /etc/modules-load.d/tun.conf"
 	fi
-	ip rule del priority 32765 2>/dev/null || true
-
-	[ -e /sys/class/net/docker0 ] || warn \
-		"docker0 is not visible from here, which usually means bridge networking.
-    MODE=tun needs --network host; without it the tunnel comes up inside the
-    container's own namespace and host traffic is untouched."
+	command -v ip >/dev/null 2>&1 || die "iproute2 is not installed; MODE=tun needs 'ip'"
 }
 
 panic_banner() {
@@ -294,7 +290,7 @@ panic_banner() {
 		        while sudo ip rule del priority \$p 2>/dev/null; do :; done
 		      done
 		      sudo ip link del $TUN_IFACE
-		  (or:  sudo transtation-panic  --  or the 'panic' verb of this image)
+		  (or simply:  sudo transtation-panic)
 
 	EOF
 }
@@ -325,51 +321,50 @@ cmd_panic() {
 cmd_run() {
 	if [ -n "${TT_URI:-}" ]; then
 		URI=$TT_URI
-		warn "reading the link from TT_URI. That is visible in 'docker inspect';
-    prefer mounting it:  -v /etc/transtation:/etc/transtation:ro"
+		warn "reading the link from TT_URI. That is visible in /proc and in this
+    process's environment; prefer the file at $URI_FILE (mode 0600)."
 	elif [ -r "$URI_FILE" ]; then
 		URI=$(cat "$URI_FILE")
 	else
-		die "no share link. Mount one:
-      install -Dm600 /dev/stdin /etc/transtation/uri   # paste the vless:// link
-      docker run ... -v /etc/transtation:/etc/transtation:ro ..."
+		die "no share link. Put one where this can read it:
+      install -Dm600 /dev/stdin $URI_FILE   # paste the vless:// link, then Ctrl-D"
 	fi
 
 	parse_uri "$URI"
 	validate
 
-	case "${MODE:-proxy}" in
-		proxy)
-			render_proxy >"$CONF"
-			;;
+	mkdir -p "$RUN_DIR"
+	chmod 700 "$RUN_DIR"
+	case "${MODE:-tun}" in
 		tun)
 			preflight_tun
 			render_tun >"$CONF"
 			;;
-		*) die "MODE must be 'proxy' or 'tun', got: ${MODE:-}" ;;
+		proxy)
+			render_proxy >"$CONF"
+			;;
+		*) die "MODE must be 'tun' or 'proxy', got: $(q "${MODE:-}")" ;;
 	esac
+	chmod 600 "$CONF"
 
-	sing-box check -c "$CONF" || die "generated config rejected by $(sing-box version | head -1)"
+	"$SINGBOX" check -c "$CONF" || die "generated config rejected by $("$SINGBOX" version | head -1)"
 
 	printf '\n  transtation-client\n'
 	printf '  server   %s:%s  (sni %s)\n' "$SERVER_IP" "$PORT" "$SNI"
-	case "${MODE:-proxy}" in
-		proxy)
-			printf '  mode     proxy -- SOCKS5 + HTTP on :%s inside the container\n' "$SOCKS_PORT"
-			printf '  use it   export ALL_PROXY=socks5h://127.0.0.1:%s\n' "$SOCKS_PORT"
-			printf '           the "h" matters: plain socks5:// resolves DNS locally and leaks\n'
-			printf '           every hostname you visit.\n'
-			printf '  no killswitch is needed in this mode: nothing is redirected, so if this\n'
-			printf '  container dies, proxied connections simply fail.\n\n'
-			;;
+	case "${MODE:-tun}" in
 		tun)
-			printf '  mode     tun -- whole-host transparent tunnel on %s\n' "$TUN_IFACE"
+			printf '  mode     tun -- everything this machine sends goes through the tunnel\n'
 			printf '  direct   %s\n' "${DIRECT_SUFFIXES:-(none)}"
 			panic_banner
 			;;
+		proxy)
+			printf '  mode     proxy -- SOCKS5 + HTTP on 127.0.0.1:%s, nothing is captured\n' "$SOCKS_PORT"
+			printf '           each program has to be pointed at it; MODE=tun is the one\n'
+			printf '           that covers the whole machine.\n\n'
+			;;
 	esac
 
-	exec sing-box run -c "$CONF"
+	exec "$SINGBOX" run -c "$CONF"
 }
 
 case "${1:-run}" in
@@ -378,12 +373,13 @@ case "${1:-run}" in
 	check)
 		# Render both modes and validate them. Used by test.sh; needs no network
 		# beyond resolving the server name, so `check` accepts a literal IP link.
+		RUN_DIR=${TT_RUN_DIR:-$(mktemp -d)}; CONF="$RUN_DIR/config.json"
 		URI=${TT_URI:-$(cat "$URI_FILE")}
 		parse_uri "$URI"
 		validate
-		render_proxy >"$CONF" && sing-box check -c "$CONF" && echo "  ok    proxy-mode config accepted by sing-box"
+		render_proxy >"$CONF" && "$SINGBOX" check -c "$CONF" && echo "  ok    proxy-mode config accepted by sing-box"
 		DIRECT_SUFFIXES=${DIRECT_SUFFIXES:-example.com,example.net} render_tun >"$CONF" &&
-			sing-box check -c "$CONF" && echo "  ok    tun-mode config accepted by sing-box"
+			"$SINGBOX" check -c "$CONF" && echo "  ok    tun-mode config accepted by sing-box"
 		;;
 	*) die "unknown command: $1 (expected run, panic or check)" ;;
 esac
diff --git a/install.sh b/install.sh
@@ -15,15 +15,22 @@
 # last line. If the download is truncated, nothing runs.
 set -eu
 
-VERSION=${TT_VERSION:-v1.0.11}
+VERSION=${TT_VERSION:-v1.0.12}
 IMAGE_SERVER=${TT_IMAGE_SERVER:-ghcr.io/sqzer-x/transtation}
 IMAGE_CLIENT=${TT_IMAGE_CLIENT:-ghcr.io/sqzer-x/transtation-client}
 # Filled in by the release workflow. A tag is mutable -- the same stolen-token
 # compromise we already concede for Xray's .dgst would let someone retag
-# v1.0.11, and the hash-verified installer you read would vouch for nothing.
+# v1.0.12, and the hash-verified installer you read would vouch for nothing.
 SERVER_DIGEST=${TT_SERVER_DIGEST:-}
 CLIENT_DIGEST=${TT_CLIENT_DIGEST:-}
 
+# sing-box ships no checksum file, so these were computed once from the official
+# release artifacts and are reviewed as part of this repo. The -musl builds are
+# statically linked, so one binary runs on any distribution.
+SINGBOX_VERSION=${TT_SINGBOX_VERSION:-1.13.16}
+SINGBOX_SHA_amd64=9ff0345fde4157a6bdab45a615668d41ccc93f6d0f361108042a48b8a49a9baa
+SINGBOX_SHA_arm64=3ea951c68f2eea10fd3ee8f8cc7794c12ccc7405afa99279a79e0b41cb183adf
+
 DIR=${TT_DIR:-/opt/transtation}
 URI_DIR=/etc/transtation
 RAW=https://raw.githubusercontent.com/sqzer-x/transtation/$VERSION
@@ -281,19 +288,76 @@ install_wrapper() {
 
 # ------------------------------------------------------------------ client
 
+# Run from a git checkout, or piped from the internet -- fetch what is missing.
+client_script() {
+	_d=$(dirname -- "$0" 2>/dev/null || echo .)
+	if [ -r "$_d/client/transtation-client" ]; then
+		printf '%s' "$_d/client/transtation-client"
+		return 0
+	fi
+	_f=$(mktemp)
+	curl -fsSL "$RAW/client/transtation-client" -o "$_f" ||
+		die "could not fetch the client from $RAW"
+	printf '%s' "$_f"
+}
+
+install_panic() {
+	install -Dm0755 /dev/stdin /usr/local/sbin/transtation-panic <<-'EOF'
+		#!/bin/sh
+		set -u
+		TUN_IFACE=${TUN_IFACE:-tt0}
+		nft delete table inet transtation 2>/dev/null
+		nft delete table ip6 transtation 2>/dev/null
+		for f in -4 -6; do
+		  ip $f rule list 2>/dev/null | sed -n 's/^\(90[0-9][0-9]\):.*/\1/p' | while read -r p; do
+		    ip $f rule del priority "$p" 2>/dev/null
+		  done
+		done
+		ip route flush table 2022 2>/dev/null
+		ip -6 route flush table 2022 2>/dev/null
+		ip link del "$TUN_IFACE" 2>/dev/null
+		systemctl stop transtation-client 2>/dev/null
+		echo "killswitch removed, policy routing flushed. You are back on direct egress."
+	EOF
+}
+
+install_sing_box() {
+	command -v sing-box >/dev/null 2>&1 && [ -z "${TT_FORCE_SINGBOX:-}" ] && {
+		step sing-box "$(sing-box version | head -1)  (already installed)"
+		SINGBOX=$(command -v sing-box)
+		return 0
+	}
+	case "$(uname -m)" in
+		x86_64 | amd64) _a=amd64; _sum=$SINGBOX_SHA_amd64 ;;
+		aarch64 | arm64) _a=arm64; _sum=$SINGBOX_SHA_arm64 ;;
+	esac
+	_n="sing-box-${SINGBOX_VERSION}-linux-${_a}-musl"
+	_t=$(mktemp -d)
+	curl -fsSL -o "$_t/sb.tgz" \
+		"https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VERSION}/${_n}.tar.gz" ||
+		die "could not download sing-box ${SINGBOX_VERSION}"
+	echo "$_sum  $_t/sb.tgz" | sha256sum -c - >/dev/null 2>&1 ||
+		{ rm -rf "$_t"; die "sing-box checksum mismatch -- refusing to install it"; }
+	tar xzf "$_t/sb.tgz" -C "$_t"
+	install -Dm0755 "$_t/$_n/sing-box" /usr/local/bin/sing-box
+	rm -rf "$_t"
+	SINGBOX=/usr/local/bin/sing-box
+	step sing-box "$($SINGBOX version | head -1)  (verified, statically linked)"
+}
+
 install_client() {
 	need_root
 	check_arch
-	_tun=0 _ks=0 _uri=""
+	_mode=tun _ks=0 _uri=""
 	for a in "$@"; do
 		case "$a" in
-			--tun) _tun=1 ;;
+			--tun) _mode=tun ;;
+			--proxy) _mode=proxy ;;
 			--killswitch) _ks=1 ;;
 			vless://*) _uri=$a ;;
 			*) die "unknown option: $a" ;;
 		esac
 	done
-	[ "$_ks" = 0 ] || [ "$_tun" = 1 ] || die "--killswitch only makes sense together with --tun"
 
 	say ""
 	say "  transtation client $VERSION"
@@ -316,64 +380,76 @@ install_client() {
 		step link "reusing $URI_DIR/uri"
 	fi
 
-	install_docker
-	docker rm -f transtation-client >/dev/null 2>&1 || true
-
-	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 "$@" \
-			-v "$URI_DIR":"$URI_DIR":ro \
-			"$(client_image)" >/dev/null
-		install -Dm0755 /dev/stdin /usr/local/sbin/transtation-panic <<-'EOF'
-			#!/bin/sh
-			set -u
-			TUN_IFACE=${TUN_IFACE:-tt0}
-			nft delete table inet transtation 2>/dev/null
-			nft delete table ip6 transtation 2>/dev/null
-			for f in -4 -6; do
-			  ip $f rule list 2>/dev/null | sed -n 's/^\(90[0-9][0-9]\):.*/\1/p' | while read -r p; do
-			    ip $f rule del priority "$p" 2>/dev/null
-			  done
-			done
-			ip route flush table 2022 2>/dev/null
-			ip -6 route flush table 2022 2>/dev/null
-			ip link del "$TUN_IFACE" 2>/dev/null
-			docker rm -f transtation-client 2>/dev/null >/dev/null
-			echo "killswitch removed, policy routing flushed. You are back on direct egress."
-		EOF
-		step panic "installed /usr/local/sbin/transtation-panic"
-		if [ "$_ks" = 1 ]; then
-			curl -fsSL "$RAW/host/transtation-killswitch" -o /usr/local/sbin/transtation-killswitch 2>/dev/null ||
-				die "could not fetch the killswitch script from $RAW"
-			chmod 0755 /usr/local/sbin/transtation-killswitch
-			/usr/local/sbin/transtation-killswitch on
-		fi
-	else
-		step mode "proxy -- local SOCKS5 + HTTP on 127.0.0.1:1080"
-		docker run -d --name transtation-client --restart unless-stopped \
-			-p 127.0.0.1:1080:1080 \
-			-v "$URI_DIR":"$URI_DIR":ro \
-			"$(client_image)" >/dev/null
+	install_sing_box
+	install -Dm0755 "$(client_script)" /usr/local/bin/transtation-client
+	install_panic
+	step scripts "installed /usr/local/bin/transtation-client and /usr/local/sbin/transtation-panic"
+
+	# Taken before the tunnel exists, so the self-test can tell "the tunnel is
+	# carrying traffic" from "a request happened to succeed". Without this the
+	# check passes while routing is still settling and the answer came out the
+	# ordinary way -- observed, and reported as success.
+	_before=$(curl -fsS --max-time 8 https://api.ipify.org 2>/dev/null || true)
+
+	[ -d /run/systemd/system ] || die "this needs systemd to manage the client.
+    Without it, run it yourself:
+      MODE=$_mode /usr/local/bin/transtation-client run"
+
+	cat >/etc/systemd/system/transtation-client.service <<-EOF
+		[Unit]
+		Description=transtation client ($_mode)
+		After=network-online.target
+		Wants=network-online.target
+
+		[Service]
+		Type=simple
+		Environment=MODE=$_mode
+		Environment=TT_SINGBOX=$SINGBOX
+		${DIRECT_SUFFIXES:+Environment=DIRECT_SUFFIXES=$DIRECT_SUFFIXES}
+		ExecStart=/usr/local/bin/transtation-client run
+		Restart=on-failure
+		RestartSec=3s
+		RuntimeDirectory=transtation
+		RuntimeDirectoryMode=0700
+		# Root is needed to own the routing table in tun mode; the bounding set
+		# keeps that to what sing-box actually uses.
+		CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_DAC_READ_SEARCH
+		NoNewPrivileges=true
+		ProtectHome=true
+		ProtectSystem=strict
+		ReadWritePaths=/run/transtation
+
+		[Install]
+		WantedBy=multi-user.target
+	EOF
+	systemctl daemon-reload
+	systemctl enable --now transtation-client.service >/dev/null 2>&1 || true
+	step service "transtation-client.service enabled ($_mode mode)"
+
+	if [ "$_ks" = 1 ]; then
+		[ "$_mode" = tun ] || die "--killswitch only makes sense with the whole-host tunnel"
+		curl -fsSL "$RAW/host/transtation-killswitch" -o /usr/local/sbin/transtation-killswitch ||
+			die "could not fetch the killswitch script from $RAW"
+		chmod 0755 /usr/local/sbin/transtation-killswitch
+		/usr/local/sbin/transtation-killswitch on
 	fi
 
 	printf '  %-14s' self-test
-	# The result has to be captured and tested. An earlier version piped curl
-	# into grep into tr with `|| printf "no answer yet"` on the end -- which hangs
-	# off `tr`, the last command in the pipeline, and `tr` always succeeds. The
-	# fallback could never fire, so a client that was not passing any traffic at
-	# all still finished with a success banner.
 	_probe=""
 	_n=0
-	while [ "$_n" -lt 6 ]; do
-		if [ "$_tun" = 1 ]; then
-			_probe=$(curl -fsS --max-time 10 https://cloudflare.com/cdn-cgi/trace 2>/dev/null |
-				grep -E '^(ip|warp|colo)=' | tr '\n' ' ')
+	while [ "$_n" -lt 15 ]; do
+		if [ "$_mode" = tun ]; then
+			_trace=$(curl -fsS --max-time 8 https://cloudflare.com/cdn-cgi/trace 2>/dev/null || true)
+			_ip=$(printf '%s' "$_trace" | sed -n 's/^ip=//p')
+			# The address has to have MOVED. A reply on its own only proves the
+			# network works, which it did before any of this was installed.
+			if [ -n "$_ip" ] && [ -n "$_before" ] && [ "$_ip" != "$_before" ]; then
+				_probe=$(printf '%s' "$_trace" | grep -E '^(ip|warp|colo)=' | tr '\n' ' ')
+			elif [ -n "$_ip" ] && [ -z "$_before" ]; then
+				_probe="$(printf '%s' "$_trace" | grep -E '^(ip|warp|colo)=' | tr '\n' ' ')(could not compare: no address before install)"
+			fi
 		else
-			_probe=$(curl -fsS --max-time 10 --socks5-hostname 127.0.0.1:1080 https://cloudflare.com/cdn-cgi/trace 2>/dev/null |
+			_probe=$(curl -fsS --max-time 8 --socks5-hostname 127.0.0.1:1080 https://cloudflare.com/cdn-cgi/trace 2>/dev/null |
 				grep -E '^(ip|warp|colo)=' | tr '\n' ' ')
 		fi
 		[ -n "$_probe" ] && break
@@ -381,40 +457,38 @@ install_client() {
 		sleep 2
 	done
 	if [ -n "$_probe" ]; then
-		[ "$_tun" = 1 ] && say "$_probe" || say "through the proxy: $_probe"
+		[ "$_mode" = tun ] && say "$_probe" || say "through the proxy: $_probe"
 	else
-		say "NO TRAFFIC"
+		[ "$_mode" = tun ] && say "STILL YOUR OWN ADDRESS (${_before:-unknown})" || say "NO TRAFFIC"
 		say ""
-		say "  The client started but nothing is getting through. Look at:"
-		say "      docker logs transtation-client"
-		[ "$_tun" = 1 ] && say "      sudo transtation-panic     # to undo the tunnel and killswitch"
+		say "  The client is running but traffic is not going through the tunnel. Look at:"
+		say "      journalctl -u transtation-client -n 40"
+		[ "$_mode" = tun ] && say "      sudo transtation-panic     # undo the tunnel and killswitch"
 		exit 1
 	fi
 
-	if [ "$_tun" = 0 ]; then
+	if [ "$_mode" = tun ]; then
 		cat <<-'EOF'
 
-			  THIS DOES NOT PUT YOUR MACHINE BEHIND THE PROXY. It opens a proxy and
-			  nothing else; each program has to be pointed at it. Your browser and
-			  anything your desktop starts keep using your own address until you
-			  configure them. For "all of this machine's traffic", use --tun instead.
+			  Every program on this machine now goes through the tunnel, with no
+			  per-program configuration.
 
-			  export ALL_PROXY=socks5h://127.0.0.1:1080 HTTPS_PROXY=http://127.0.0.1:1080 HTTP_PROXY=http://127.0.0.1:1080
+			  stop:    sudo systemctl stop transtation-client
+			  logs:    journalctl -u transtation-client -f
+			  panic:   sudo transtation-panic        # undo everything, needs no network
 
-			    Those apply to that shell and what it starts, and nothing else.
-			    The "h" in socks5h is load-bearing: plain socks5:// makes curl resolve DNS
-			    locally and leaks every hostname you visit. Firefox needs its own
-			    connection settings; it does not read these variables.
-			    Not covered even for a program you did point at it: QUIC (it degrades to
-			    TCP) and anything that reads no proxy setting at all.
+		EOF
+	else
+		cat <<-'EOF'
+
+			  THIS DOES NOT PUT YOUR MACHINE BEHIND THE PROXY. It opens a proxy on
+			  127.0.0.1:1080 and nothing else; each program has to be pointed at it.
+			  Re-run with --tun for the whole machine.
 
-			  Whole-system tunnel instead:  sudo sh install.sh client --tun --killswitch
+			  export ALL_PROXY=socks5h://127.0.0.1:1080 HTTPS_PROXY=http://127.0.0.1:1080 HTTP_PROXY=http://127.0.0.1:1080
+			    Those apply to that shell and what it starts, and nothing else.
 
 		EOF
-	else
-		say ""
-		say "  If your network ever dies:  sudo transtation-panic"
-		say ""
 	fi
 }
 
@@ -441,14 +515,13 @@ uninstall() {
 			*) say "volume kept: 'docker volume ls | grep transtation-data'" ;;
 		esac
 	fi
-	if docker ps -a --format '{{.Names}}' 2>/dev/null | grep -qx transtation-client; then
-		docker rm -f transtation-client >/dev/null
+	if [ -e /etc/systemd/system/transtation-client.service ]; then
+		systemctl disable --now transtation-client.service >/dev/null 2>&1 || true
+		rm -f /etc/systemd/system/transtation-client.service
+		systemctl daemon-reload 2>/dev/null || true
+		rm -f /usr/local/bin/transtation-client
 		say "client removed."
 	fi
-	if [ -x /usr/local/sbin/transtation-killswitch ]; then
-		/usr/local/sbin/transtation-killswitch off || true
-		rm -f /usr/local/sbin/transtation-killswitch
-	fi
 	rm -f /usr/local/sbin/transtation-panic
 	say "done. $URI_DIR was left in place; remove it yourself if you are finished with it."
 }
diff --git a/test.sh b/test.sh
@@ -10,7 +10,6 @@ set -eu
 cd "$(dirname "$0")"
 
 SERVER_IMG=${SERVER_IMG:-transtation-test-server}
-CLIENT_IMG=${CLIENT_IMG:-transtation-test-client}
 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'
 
 fail=0
@@ -99,11 +98,7 @@ docker buildx version >/dev/null 2>&1 || {
 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) =="
@@ -137,42 +132,58 @@ check "install.sh survives dash (Debian, Ubuntu) with no arguments" posix_sh_ok
 check "install.sh survives busybox ash (Alpine) with no arguments" posix_sh_ok alpine:3.23 sh
 
 echo
-echo "== client config renders and sing-box accepts it =="
-docker run --rm --network none \
-	-e TT_URI="$FIXTURE_URI" \
-	-e DIRECT_SUFFIXES=naver.com,go.kr \
-	"$CLIENT_IMG" check || fail=1
-
-echo
-echo "== client rejects malformed links at the trust boundary =="
-# 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'
-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 "== client renders configs the real sing-box accepts =="
+# The client is a native install now, so this exercises it the way it actually
+# runs: a script on the host driving a sing-box binary. Fetch the pinned build
+# if the host has none.
+SB=$(command -v sing-box 2>/dev/null || true)
+if [ -z "$SB" ]; then
+	_v=$(sed -n 's/^SINGBOX_VERSION=${TT_SINGBOX_VERSION:-\(.*\)}$/\1/p' install.sh)
+	case "$(uname -m)" in
+		x86_64 | amd64) _a=amd64; _sum=$(sed -n 's/^SINGBOX_SHA_amd64=//p' install.sh) ;;
+		aarch64 | arm64) _a=arm64; _sum=$(sed -n 's/^SINGBOX_SHA_arm64=//p' install.sh) ;;
+	esac
+	_n="sing-box-${_v}-linux-${_a}-musl"
+	_t=$(mktemp -d)
+	if curl -fsSL -o "$_t/s.tgz" "https://github.com/SagerNet/sing-box/releases/download/v${_v}/${_n}.tar.gz" &&
+		echo "$_sum  $_t/s.tgz" | sha256sum -c - >/dev/null 2>&1; then
+		tar xzf "$_t/s.tgz" -C "$_t"
+		SB="$_t/$_n/sing-box"
+		ok "fetched and verified the pinned sing-box ($_v)"
+	else
+		bad "could not fetch a verified sing-box; client config checks skipped"
+	fi
+fi
 
-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":"'
+if [ -n "$SB" ]; then
+	client_check() {
+		TT_URI="$1" TT_URI_FILE=/nonexistent TT_SINGBOX="$SB" TT_RUN_DIR=$(mktemp -d) \
+			DIRECT_SUFFIXES=naver.com,go.kr sh client/transtation-client check >/dev/null 2>&1
+	}
+	check "renders both modes and sing-box accepts them" client_check "$FIXTURE_URI"
 
-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
+	echo "== client rejects hostile links and hostile settings =="
+	rejects() {
+		_out=$(TT_URI="$1" TT_URI_FILE=/nonexistent TT_SINGBOX="$SB" TT_RUN_DIR=$(mktemp -d) \
+			sh client/transtation-client check 2>&1) && return 1
+		case "$_out" in *transtation-client:*) return 0 ;; *) return 1 ;; esac
+	}
+	env_rejects() {
+		_out=$(TT_URI="$FIXTURE_URI" TT_URI_FILE=/nonexistent TT_SINGBOX="$SB" TT_RUN_DIR=$(mktemp -d) \
+			env "$1" sh client/transtation-client 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'
+	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'
+	check "rejects a bad port" rejects 'vless://8f3e21c4-7a09-4b2e-9d51-6c0f1a2b3c4d@203.0.113.10:99999?pbk=6ZP9LtQm3vXk8sT2wYnBcRfJhGdA1uEoI0pZxCyN4Vs&sid=1c69b566b0480c74&sni=a.com'
+	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=nonesuch'
+	check "rejects a non-numeric SOCKS_PORT" env_rejects 'SOCKS_PORT=1080, "sniff": true'
+	check "rejects DIRECT_SUFFIXES that is not a domain list" env_rejects 'DIRECT_SUFFIXES=a.com"],"outbound":"direct"},{"x":"'
+fi
 
 echo
 [ "$fail" = 0 ] || { echo "FAILED"; exit 1; }