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 baeb91ca9dfea9c052773ccff92ea054c135ce72
parent d824acd8909cfc22aa1248338151a3f7dc436376
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Thu, 6 Aug 2026 18:24:38 +0900

Make the README's two promises true, and correct four stale claims

Adversarial pass over the user-facing docs. Two of them were promises the
project did not keep.

The first line of the README tells you to check install.sh against a hash in
the release notes. There were no releases and therefore no hash. The publish
workflow now creates the release and puts the installer's sha256 in it, so the
instruction can be followed.

The curl-pipe-sh section claimed release builds pin the image by digest. They
never did: the digest variables were always empty and the installer said so in
its own output, which nobody reconciled with the README. Rather than delete the
claim, the installer now earns it -- it resolves the tag to the digest it points
at during installation and rewrites the compose file to that digest, so from
then on `docker compose up` can only start the image the install verified. Re-
running the installer is still how you upgrade, because the line is rewritten
every run.

Corrected: transtation-panic is not six lines any more; the tested section said
"healthy in 12s" three paragraphs above a measured 7.3s; arm64 was described as
only running its selftest when it had been run as a server with a client
passing traffic through it; and rootless Docker and Docker Desktop were listed
as working when only rootless Podman was exercised.

Also fixes the client's panic banner, whose box drawing was misaligned and
whose `$p` and `$(seq ...)` were being expanded by the heredoc instead of
printed, so the commands it told you to run at 3am were wrong.

Diffstat:
M.github/workflows/ci.yml | 26+++++++++++++++++++++++++-
MREADME.md | 38+++++++++++++++++++++-----------------
MTROUBLESHOOTING.md | 4+++-
Mclient/transtation-client | 13+++++++------
Minstall.sh | 22+++++++++++++++++-----
5 files changed, 73 insertion(+), 30 deletion(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
@@ -41,7 +41,7 @@ jobs:
     if: startsWith(github.ref, 'refs/tags/v')
     runs-on: ubuntu-latest
     permissions:
-      contents: read
+      contents: write
       packages: write
     strategy:
       matrix:
@@ -90,3 +90,27 @@ jobs:
       # pins the image by content, not by a mutable tag.
       - name: digest
         run: echo "${{ matrix.name }} -> ${{ 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: |
+          sum=$(sha256sum install.sh | cut -d" " -f1)
+          {
+            echo "\`\`\`"
+            echo "$sum  install.sh"
+            echo "\`\`\`"
+            echo
+            echo "    curl -fsSLO https://raw.githubusercontent.com/${{ github.repository }}/${{ github.ref_name }}/install.sh"
+            echo "    sha256sum install.sh    # must match the line above"
+            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 "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 || \
+            gh release edit "${{ github.ref_name }}" --notes-file notes.md
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.9/install.sh
-sha256sum install.sh      # compare against the hash in the v1.0.9 release notes
+curl -fsSLO https://raw.githubusercontent.com/sqzer-x/transtation/v1.0.10/install.sh
+sha256sum install.sh      # compare against the hash in the v1.0.10 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.9/install.sh | sudo sh
+curl -fsSL https://raw.githubusercontent.com/sqzer-x/transtation/v1.0.10/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.9
+    image: ghcr.io/sqzer-x/transtation:v1.0.10
     container_name: transtation
     restart: unless-stopped
     ports: ["443:8443"]
@@ -132,7 +132,7 @@ install -Dm600 /dev/stdin /etc/transtation/uri   # paste the vless:// link, then
 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.9
+  ghcr.io/sqzer-x/transtation-client:v1.0.10
 ```
 
 ```
@@ -144,8 +144,8 @@ The **`h`** in `socks5h` is load-bearing: plain `socks5://` makes curl resolve
 DNS locally and leaks every hostname you visit. In Firefox, tick "Proxy DNS when
 using SOCKS v5".
 
-No capabilities, no devices, no host networking. Works under rootless Docker,
-rootless Podman and Docker Desktop.
+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:
 
@@ -176,7 +176,7 @@ docker run -d --name transtation-client --restart unless-stopped \
   -e MODE=tun \
   -e DIRECT_SUFFIXES=example.com,example.net \
   -v /etc/transtation:/etc/transtation:ro \
-  ghcr.io/sqzer-x/transtation-client:v1.0.9
+  ghcr.io/sqzer-x/transtation-client:v1.0.10
 ```
 
 Exactly three extra flags. `--privileged` is **not** needed and must not be
@@ -188,7 +188,8 @@ the container's own network namespace and your host's traffic is untouched.
 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 detects both cases at startup and says so.
+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
@@ -237,9 +238,9 @@ If your network ever dies:
 sudo transtation-panic
 ```
 
-Six lines, no network and no Docker needed. It also exists as a verb of the
-client image (`docker run --rm --network host --cap-add NET_ADMIN <image> panic`),
-and the three raw commands are printed in the client's startup banner every
+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.
 
 ---
@@ -476,8 +477,10 @@ It is a real objection and this project does not wave it away:
 3. **It installs Docker via `get.docker.com`**, a ~780-line third-party script,
    as root. The installer tells you before it does, and skips the step entirely
    if Docker is already present.
-4. **The image tag is mutable.** Release builds pin the image by SHA-256 digest
-   in the compose file, so a retagged image will not be pulled.
+4. **The image tag is mutable.** The installer resolves the tag to the digest
+   it points at during installation and writes *that* into the compose file, so
+   from then on `docker compose up` can only start the exact image the install
+   verified. Re-running the installer is how you move to a newer one.
 
 The Xray binary is verified inside the Dockerfile against the `.dgst` file from
 the same GitHub release. That defends against a bad mirror or a truncated
@@ -491,8 +494,8 @@ Everything below was run, on a Korean client against an AWS Tokyo VPS with the
 host's own proxy stopped, so nothing was measured through a second proxy:
 
 - **Server on a real VPS.** `install.sh` on Ubuntu 24.04: installs Docker,
-  pulls the image, healthy in 12s, `verify` completes a REALITY handshake and
-  reports `warp=on colo=NRT`.
+  pulls and digest-pins the image, and `verify` completes a REALITY handshake
+  reporting `warp=on colo=NRT`.
 - **Client to server across the internet, no tunnels.** Client container in
   Korea to a Tokyo VPS with the host's own proxy stopped, so nothing was
   measured through a second proxy. Steady-state request latency ≈0.20 s,
@@ -511,7 +514,8 @@ host's own proxy stopped, so nothing was measured through a second proxy:
 - **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 runs its selftest under emulation.
+- **arm64.** The published arm64 image was run as a server 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/TROUBLESHOOTING.md b/TROUBLESHOOTING.md
@@ -140,7 +140,9 @@ If `transtation-panic` is not installed, the same thing by hand:
 ```
 sudo nft delete table inet transtation
 # several rules can share one priority, so delete until each one is empty
-for p in $(seq 9000 9010); do while sudo ip rule del priority $p 2>/dev/null; do :; done; done
+for p in $(seq 9000 9010); do
+  while sudo ip rule del priority $p 2>/dev/null; do :; done
+done
 sudo ip link del tt0
 ```
 
diff --git a/client/transtation-client b/client/transtation-client
@@ -287,12 +287,13 @@ preflight_tun() {
 panic_banner() {
 	cat <<-EOF
 
-		  ┌─ IF YOUR NETWORK EVER DIES, RUN THIS ─────────────────────────────┐
-		  │   sudo nft delete table inet transtation 2>/dev/null              │
-		  │   for p in \$(seq 9000 9010); do while sudo ip rule del priority \$p;  │
-		  │     do :; done; done                                             │
-		  │   sudo ip link del $TUN_IFACE                                              │
-		  └───────────────────────────────────────────────────────────────────┘
+		  IF YOUR NETWORK EVER DIES, RUN THIS:
+
+		      sudo nft delete table inet transtation
+		      for p in \$(seq 9000 9010); do
+		        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)
 
 	EOF
diff --git a/install.sh b/install.sh
@@ -15,12 +15,12 @@
 # last line. If the download is truncated, nothing runs.
 set -eu
 
-VERSION=${TT_VERSION:-v1.0.9}
+VERSION=${TT_VERSION:-v1.0.10}
 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.9, and the hash-verified installer you read would vouch for nothing.
+# v1.0.10, and the hash-verified installer you read would vouch for nothing.
 SERVER_DIGEST=${TT_SERVER_DIGEST:-}
 CLIENT_DIGEST=${TT_CLIENT_DIGEST:-}
 
@@ -169,14 +169,26 @@ install_server() {
 		[ -n "${PORT:-}" ] && printf 'PORT=%s\n' "$PORT" >>"$DIR/.env"
 		step config "wrote $DIR/docker-compose.yml and $DIR/.env"
 	fi
-	[ -n "$SERVER_DIGEST" ] || say "                 note: image pinned by tag, not digest (unreleased build)"
 
 	install_wrapper
 	step wrapper "installed /usr/local/bin/transtation"
 
 	printf '  %-14s' image
-	docker compose -f "$DIR/docker-compose.yml" --project-directory "$DIR" pull -q 2>/dev/null || true
-	say "pulled $(server_image)"
+	docker pull -q "$(server_image)" >/dev/null 2>&1 || true
+	# Resolve the tag to the digest it points at *now* and pin the compose file
+	# to that. A tag is mutable; once this is written, `docker compose up` can
+	# only ever start the exact image this install verified. Rewritten on every
+	# run so re-running the installer is still how you upgrade.
+	_ref=$(docker image inspect --format '{{if .RepoDigests}}{{index .RepoDigests 0}}{{end}}' "$(server_image)" 2>/dev/null)
+	case "$_ref" in
+		*@sha256:*)
+			sed -i "s|^\( *image: \).*|\1$_ref|" "$DIR/docker-compose.yml"
+			say "pinned to ${_ref#*@}"
+			;;
+		*)
+			say "pulled $(server_image) (no digest available; the compose file pins by tag)"
+			;;
+	esac
 
 	printf '  %-14s' start
 	docker compose -f "$DIR/docker-compose.yml" --project-directory "$DIR" up -d >/dev/null 2>&1