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 4baaab8b16bd411016bfc15b4e041af2cf03b4a1
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Thu, 6 Aug 2026 12:04:06 +0900

transtation: self-provisioning VLESS+REALITY proxy in a container

Server and client images, an installer, a host-owned killswitch and an offline
test suite. The server holds no capabilities: Xray probes for CAP_NET_ADMIN and
takes its userspace gVisor network stack when it is absent, which is what
Docker gives it by default. Adding the capability breaks WARP rather than
helping it, so that is tested for.

Identity is written once and gated per artifact so an interrupted first boot
resumes instead of crash-looping; the Xray config is derived and regenerated
from the image on every boot, which is what makes an image upgrade safe.

Diffstat:
A.env.example | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A.github/workflows/ci.yml | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A.gitignore | 6++++++
ALICENSE | 21+++++++++++++++++++++
ANOTICE | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AREADME.md | 390+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATROUBLESHOOTING.md | 154+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aclient/Dockerfile | 47+++++++++++++++++++++++++++++++++++++++++++++++
Aclient/transtation-client | 354+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahost/transtation-killswitch | 192+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahost/transtation-panic | 25+++++++++++++++++++++++++
Ainstall.sh | 356+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aserver/Dockerfile | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aserver/transtation | 635+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest.sh | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
15 files changed, 2595 insertion(+), 0 deletion(-)

diff --git a/.env.example b/.env.example
@@ -0,0 +1,75 @@
+# transtation server configuration.
+#
+# NEVER COMMIT THIS FILE. Copy it to .env and edit. Every value here is
+# optional — with no .env at all the server provisions itself and works.
+#
+# These are CONFIG, not identity. They are read fresh on every boot, so
+# changing one and running `docker compose up -d` takes effect immediately.
+# Your Reality keypair and user UUIDs live in the data volume and are never
+# touched by anything in this file.
+
+# ---------------------------------------------------------------- networking
+
+# Public port clients connect to. 443 blends in best; change it only if
+# something else on the host already owns 443.
+#PORT=443
+
+# Public address clients dial. Auto-detected from cloudflare.com/cdn-cgi/trace
+# on first boot. Set it if the host is behind NAT, has several public IPs, or
+# you point a domain at it.
+#HOST=203.0.113.10
+
+# ---------------------------------------------------------------- camouflage
+
+# The real site your server impersonates. Must speak TLS 1.3 + X25519 + HTTP/2.
+#
+# RULE OF THUMB: pick a high-reputation site that is plausibly popular in your
+# server's region, and ideally hosted in the same cloud/ASN. A Tokyo VPS
+# borrowing a Japanese site's handshake looks ordinary; a Tokyo VPS borrowing a
+# Brazilian bank's does not.
+#
+# Do NOT use apple.com or icloud.com — Xray itself warns that those targets get
+# your IP blocked by the GFW, and transtation refuses them.
+#
+# Verify a candidate before committing to it:  docker compose exec proxy xray tls ping <domain>
+#SNI=www.microsoft.com
+
+# The address the Reality handshake is actually forwarded to. Defaults to
+# $SNI:443 and you almost never need to set this separately.
+#DEST=www.microsoft.com:443
+
+# ------------------------------------------------------------------- egress
+
+# Cloudflare WARP egress. On by default: your traffic leaves from a Cloudflare
+# IP instead of your VPS's, so the destination never sees your server's address
+# and cloud-IP blocklists do not apply.
+#
+# Set WARP=0 to egress directly from the VPS IP instead. That is simpler and
+# faster, but exposes the address that is tied to your name and payment method.
+# It also means outbound port 25 is not blocked for you by Cloudflare — which
+# is why transtation blocks it itself.
+#WARP=1
+
+# WARP endpoint. Pinned to a literal IP on purpose: resolving
+# engage.cloudflareclient.com can return an IPv6 address on a host with no IPv6
+# route, and Xray picks between the A and AAAA records at random. Override only
+# if this anycast address does not handshake from your provider.
+#WARP_ENDPOINT=162.159.192.1:2408
+
+# ------------------------------------------------------------------- clients
+
+# Name of the user created on first boot.
+#USER_NAME=main
+
+# Minimum client version the Reality server accepts. transtation pins this to
+# 0.0.0 so no client is ever silently rejected. Newer Xray versions default it
+# to a real floor, and the failure mode is nasty: the server starts fine, the
+# port listens, and rejected clients see the real target website instead of an
+# error. Raise it only if you know every client you own is newer.
+#MIN_CLIENT_VER=0.0.0
+
+# ------------------------------------------------------------------ advanced
+
+# Skip the startup TLS probe against $SNI. The probe only warns; it never
+# blocks startup. Set to 1 on hosts with slow or filtered outbound HTTPS.
+#SKIP_DEST_CHECK=0
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
@@ -0,0 +1,82 @@
+name: ci
+
+on:
+  push:
+    branches: [main]
+    tags: ["v*"]
+  pull_request:
+  workflow_dispatch:
+
+permissions:
+  contents: read
+
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+
+      - name: shellcheck
+        run: sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck
+
+      # Builds both images for amd64 only and runs their offline self-checks.
+      # Everything runs with --network none, so CI never registers a WARP
+      # identity: hammering Cloudflare's registration endpoint from shared
+      # runner IPs is how an entire address range gets rate-limited.
+      - name: test
+        run: sh test.sh
+
+  publish:
+    needs: test
+    if: startsWith(github.ref, 'refs/tags/v')
+    runs-on: ubuntu-latest
+    permissions:
+      contents: read
+      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
+      - uses: docker/setup-buildx-action@v3
+      - uses: docker/login-action@v3
+        with:
+          registry: ghcr.io
+          username: ${{ github.actor }}
+          password: ${{ secrets.GITHUB_TOKEN }}
+
+      - id: meta
+        uses: docker/metadata-action@v5
+        with:
+          images: ghcr.io/${{ github.repository_owner }}/${{ matrix.name }}
+          tags: |
+            type=semver,pattern={{version}}
+            type=semver,pattern=v{{version}}
+            type=raw,value=latest,enable={{is_default_branch}}
+            type=sha,format=long
+
+      - id: build
+        uses: docker/build-push-action@v6
+        with:
+          context: .
+          file: ${{ matrix.dockerfile }}
+          platforms: linux/amd64,linux/arm64
+          push: true
+          tags: ${{ steps.meta.outputs.tags }}
+          labels: ${{ steps.meta.outputs.labels }}
+          # Provenance and SBOM attestations are off: they add a manifest list
+          # entry that some older clients choke on, and the compose file is
+          # already pinned by digest, which closes the mutable-tag hole for
+          # free. Turn them on when a downstream consumer asks for them.
+          provenance: false
+          sbom: false
+
+      # 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"
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1,6 @@
+# Never commit this. It is not secret by itself, but a fork that commits it
+# leaks your server address, SNI and port, and people copy .env into infra repos.
+.env
+
+# Backups produced by `transtation backup` — these DO contain private keys.
+*.tgz
diff --git a/LICENSE b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 sqzer
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/NOTICE b/NOTICE
@@ -0,0 +1,90 @@
+transtation
+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.
+
+================================================================================
+Xray-core  --  redistributed in ghcr.io/sqzer-x/transtation
+================================================================================
+
+Xray-core is licensed under the Mozilla Public License, Version 2.0 (MPL-2.0).
+
+  Upstream project : https://github.com/XTLS/Xray-core
+  Version shipped  : v26.3.27
+  Exact artifact   : https://github.com/XTLS/Xray-core/releases/download/v26.3.27/Xray-linux-64.zip
+                     https://github.com/XTLS/Xray-core/releases/download/v26.3.27/Xray-linux-arm64-v8a.zip
+  Source Form      : https://github.com/XTLS/Xray-core/tree/v26.3.27
+
+The `xray` binary in our image is the official upstream release artifact,
+byte-for-byte unmodified. We do not patch, recompile, or statically link it
+into anything. It is copied into the image as-is.
+
+The Source Code Form of Xray-core is governed by the MPL-2.0. A copy of that
+license is available at https://mozilla.org/MPL/2.0/ and is distributed with
+the upstream source at the URL above.
+
+The image ships no GeoIP or GeoSite data files. Those carry their own separate
+licenses (CC-BY-SA / GPL-3.0 depending on the dataset) and none of them apply
+here.
+
+================================================================================
+wgcf  --  redistributed in ghcr.io/sqzer-x/transtation
+================================================================================
+
+wgcf is licensed under the MIT License.
+
+  Upstream project : https://github.com/ViRb3/wgcf
+  Version shipped  : v2.2.32
+  Exact artifact   : https://github.com/ViRb3/wgcf/releases/download/v2.2.32/wgcf_2.2.32_linux_amd64
+                     https://github.com/ViRb3/wgcf/releases/download/v2.2.32/wgcf_2.2.32_linux_arm64
+
+The `wgcf` binary is the official upstream release artifact, unmodified. It is
+used only once, at first boot, to register a Cloudflare WARP identity. It is
+not part of the data path.
+
+Upstream's own disclaimer, reproduced verbatim:
+
+  "This project is in no way affiliated with, authorized, maintained, sponsored
+   or endorsed by Cloudflare or any of its affiliates or subsidiaries."
+
+The same applies to transtation. Cloudflare WARP is a Cloudflare service, used
+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 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
+  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.
+
+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.
+
+================================================================================
+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
+`apk info -L <package>` inside the image for per-package details.
diff --git a/README.md b/README.md
@@ -0,0 +1,390 @@
+# transtation
+
+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.0/install.sh
+sha256sum install.sh      # compare against the hash in the v1.0.0 release notes
+less install.sh           # please actually read it
+sudo sh install.sh
+```
+
+That is the whole server install. It generates its own Reality keypair, its own
+user, registers a WARP identity, and prints a `vless://` link and a QR code you
+scan with any client app.
+
+---
+
+## This is not a new idea, and you should know the alternatives
+
+[`reality-ezpz`](https://github.com/aleskxyz/reality-ezpz) already does Reality
++ WARP + share link + QR over Docker Compose. [`3x-ui`](https://github.com/MHSanaei/3x-ui)
+owns the web-panel end of this space with 44k stars and does far more than this
+does. If you want a panel, multiple users with quotas, subscription links or a
+Telegram bot, **use 3x-ui and stop reading here.**
+
+transtation exists because of a narrower set of preferences:
+
+- **A published, digest-pinned image**, not a 2,700-line bash generator you run
+  on your host.
+- **Zero capabilities.** The container holds no `NET_ADMIN`, mounts no
+  `/dev/net/tun`, and does not use host networking. WARP runs in Xray's
+  in-process userspace network stack.
+- **Tracking upstream.** Xray ships roughly monthly and its config schema moves.
+  If this project stops keeping up within a year it is worse than what it
+  replaced, and the right thing to do then is archive it and point at
+  reality-ezpz. That is a promise, not a disclaimer.
+
+---
+
+## Server
+
+### Install
+
+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.0/install.sh | sudo sh
+```
+
+The installer needs root, refuses anything other than x86_64/aarch64, installs
+Docker if it is missing, writes `/opt/transtation/{docker-compose.yml,.env}`,
+and waits for the container to report healthy. Running it again is an upgrade:
+it pulls and restarts, and never re-provisions.
+
+If you already run Docker and prefer to do it yourself:
+
+```yaml
+# /opt/transtation/docker-compose.yml
+services:
+  proxy:
+    image: ghcr.io/sqzer-x/transtation:v1.0.0
+    container_name: transtation
+    restart: unless-stopped
+    ports: ["443:8443"]
+    volumes: ["transtation-data:/data"]
+    env_file: .env
+    logging:
+      driver: json-file
+      options: { max-size: "10m", max-file: "3" }
+volumes:
+  transtation-data:
+```
+
+### Day to day
+
+```
+transtation show              # share link + QR for every user
+transtation status            # address, SNI, egress IP, WARP state, users
+transtation user add alice    # new UUID + shortId, applied immediately
+transtation user rm alice     # revokes both
+transtation backup            # tar.gz of everything irreplaceable, mode 0600
+transtation logs -f
+```
+
+`transtation user add` and `user rm` restart Xray to apply. Existing connections
+drop and clients reconnect on their own; Xray has no config reload signal.
+
+### Open the port
+
+The healthcheck only proves **outbound** traffic works. Nothing on the server
+can prove inbound does. If clients time out and the server log shows nothing at
+all, open TCP 443 in your provider's firewall or security group. Note that
+Docker's published ports **bypass `ufw`** in both directions, so a ufw rule is
+neither necessary nor sufficient; Oracle Cloud additionally needs the port
+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:
+
+| 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.
+
+### Proxy mode (default) — a local SOCKS5 + HTTP proxy
+
+```
+sudo sh install.sh client
+```
+
+or by hand:
+
+```
+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.0
+```
+
+```
+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
+```
+
+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.
+
+**Not covered:** QUIC (browsers do not send UDP over SOCKS, so it silently falls
+back to TCP), and any application that ignores proxy environment variables.
+**No killswitch is needed here** — nothing is redirected, so if the container
+dies, proxied connections simply fail. That is this mode's biggest advantage.
+
+### Tun mode — the whole host
+
+```
+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.0
+```
+
+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.
+
+**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 detects both cases at startup and says so.
+
+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.
+
+### Killswitch and recovery
+
+`--killswitch` installs a host-owned nftables table with a `policy drop` output
+chain. It permits loopback, established connections, the tun interface, the
+split-tunnel fwmark, RFC1918/CGNAT/link-local/multicast, DHCP, and your server's
+address and port. Everything else is dropped, so nothing leaks when the tunnel
+is down.
+
+It is **additive** — a separate `table inet transtation` — and removing it is
+one command. Nothing in this project ever runs `nft flush ruleset`, which on a
+modern host would also wipe the iptables-nft tables Docker installs and silently
+break every container on the box.
+
+```
+sudo transtation-killswitch on [--persist]
+sudo transtation-killswitch off
+sudo transtation-killswitch status
+```
+
+Persistence is opt-in. By default the killswitch dies at reboot, because reboot
+is the first thing everyone tries when the network stops working.
+
+**Two limits, stated plainly.** It is an output-hook chain: it covers this host's
+own processes, and traffic *forwarded* from your other containers does not
+traverse it. And if you test it by SIGKILLing the client, note that sing-box's
+own ip rules at priority 9000–9010 survive — they are not bound to the
+interface.
+
+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
+boot, so they are in `docker logs` and in your scrollback.
+
+---
+
+## Configuration
+
+Nothing is required. Copy `.env.example` to `/opt/transtation/.env` and set only
+what you want to change; every value is read fresh on each boot.
+
+| Variable | Default | What it does |
+|---|---|---|
+| `PORT` | `443` | public port |
+| `HOST` | auto-detected | address that goes into the share link |
+| `SNI` | `www.microsoft.com` | the site your server impersonates |
+| `DEST` | `$SNI:443` | where the handshake is forwarded |
+| `WARP` | `1` | `0` to egress from the VPS IP instead |
+| `WARP_ENDPOINT` | `162.159.192.1:2408` | override if this anycast address does not handshake |
+| `USER_NAME` | `main` | name of the user created on first boot |
+| `MIN_CLIENT_VER` | `0.0.0` | Reality client version floor |
+| `SKIP_DEST_CHECK` | `0` | skip the startup TLS probe of `$SNI` |
+
+**Choosing an SNI matters more than anything else here.** Pick a high-reputation
+site that speaks TLS 1.3 + X25519 + HTTP/2, is plausibly popular where your
+server lives, and ideally sits in the same cloud or ASN. A Tokyo VPS borrowing a
+Japanese site's handshake looks ordinary; borrowing a Brazilian bank's does not.
+Check a candidate before committing:
+
+```
+docker compose exec proxy xray tls ping example.com
+```
+
+Apple and iCloud domains are rejected outright — Xray itself warns that they get
+your IP blocked by the GFW.
+
+---
+
+## Capabilities: do not add any
+
+This is the single easiest way to break a working install, so it gets its own
+section.
+
+| | Needed? | |
+|---|---|---|
+| `cap_add: NET_ADMIN` | **no — actively harmful** | Xray probes for `CAP_NET_ADMIN` up front and takes kernel TUN when it finds it. `createKernelTun` writes to `/proc/sys/.../rp_filter` *before* opening the device, Docker mounts `/proc/sys` read-only, and there is no fallback to userspace. WARP dies. |
+| `--device /dev/net/tun` | no | the gVisor network stack runs in-process |
+| `CAP_NET_BIND_SERVICE` | no | it listens on 8443 inside; `-p 443:8443` does the privileged bind in dockerd |
+| `--network host` | no, and must not be used | it would expose the loopback SOCKS inbound the healthcheck uses, turning your server into an open relay |
+
+To confirm the good path is live:
+
+```
+docker compose logs proxy | grep -o 'Using .* TUN'    # -> Using gVisor TUN
+```
+
+---
+
+## What this does not protect you from
+
+- **Your account.** The VPS is rented in your name with your card. Reality hides
+  *what* the traffic is, not *whose* server it is.
+- **Traffic analysis over time.** Reality is very good against a prober that
+  connects and looks; it is not a defence against an adversary correlating flow
+  timing and volume across a link they fully observe.
+- **The endpoint.** Sites still see a browser fingerprint, cookies, and a login.
+- **Publishing your link.** A `vless://` link is a credential *and* a permanent
+  fingerprint of your server. Once it is public, revoke it (`transtation user rm`)
+  — the shortId goes with it.
+- **BitTorrent.** The default routing blocks *unencrypted* BitTorrent
+  handshakes. Encrypted BT (MSE/PE, the modern default) and DHT pass straight
+  through. You have no DMCA protection here; your provider will forward the
+  notice to you.
+- **Outbound abuse from your own box.** Port 25 is blocked by default so a
+  WARP-less install is not an open SMTP relay, and the cloud metadata range
+  `169.254.0.0/16` is blocked so a client of your proxy cannot read your
+  instance's IAM credentials. Everything else your users do leaves from your IP.
+
+## About WARP
+
+With `WARP=1` (the default) traffic exits from a Cloudflare address instead of
+your VPS's. Your server's IP never reaches the destination, and cloud-IP
+blocklists do not apply to you.
+
+The costs are real: a shared exit pool means more CAPTCHAs, the exit's geography
+is Cloudflare's choice and not your VPS's, the free tier is throttled under
+sustained load, and MTU drops to 1280. Registration also depends on an
+undocumented third-party API. If any of that bothers you, set `WARP=0` — the
+proxy works exactly the same, it just egresses from your own address.
+
+If registration fails, the server **starts anyway** with direct egress and says
+so on every boot and in `transtation status`. It never retries in a loop:
+hammering Cloudflare's registration endpoint is how a source IP, historically a
+whole region, gets blocked.
+
+---
+
+## Backup, upgrade, uninstall
+
+```
+transtation backup                        # -> /root/transtation-backup.tgz, mode 0600
+```
+
+That tarball holds your Reality private key, your users, and your WARP account.
+It is the only thing on the box you cannot regenerate. Restoring is one command
+into a fresh volume:
+
+```
+docker run --rm -v transtation-data:/data -v /root:/b alpine \
+  tar xzf /b/transtation-backup.tgz -C /data
+```
+
+Upgrades: re-run `install.sh`, or `docker compose pull && docker compose up -d`.
+Your identity lives in the volume and survives; the config is regenerated from
+the new image every boot, so schema changes come along for free.
+
+```
+sudo sh install.sh --uninstall
+```
+
+It removes the containers and the compose file, and asks separately before
+deleting the data volume.
+
+---
+
+## About `curl | sh`
+
+It is a real objection and this project does not wave it away:
+
+1. **You cannot review what you pipe.** So the documented form downloads the
+   script, checks its hash against the release notes, and lets you read it. Do
+   that.
+2. **A truncated download could execute half a script.** Everything here lives
+   in functions and `main "$@"` is the last line, so a truncated file runs
+   nothing.
+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.
+
+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
+transfer, **not** against a compromised upstream — Xray publishes no signatures.
+sing-box publishes no checksum file at all, so its hashes are pinned in
+`client/Dockerfile` and reviewed as part of this repo.
+
+## Building and testing yourself
+
+```
+git clone https://github.com/sqzer-x/transtation && cd transtation
+sh test.sh
+```
+
+Builds both images and runs their offline self-checks: the config renders and is
+accepted by the pinned Xray with WARP on *and* off, the `xray x25519` parser
+survives all three historical output formats, every invariant that protects you
+(no empty shortId, port 25 blocked, metadata range blocked, loopback-only health
+inbound) holds, both client configs are accepted by sing-box, and malformed
+share links are rejected. Everything runs with `--network none`; the test suite
+never registers a WARP identity.
+
+## License
+
+MIT. See [LICENSE](LICENSE). Third-party binaries redistributed in the images —
+Xray-core (MPL-2.0), wgcf (MIT), sing-box (GPL-3.0-or-later) — and their
+obligations are listed in [NOTICE](NOTICE).
diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md
@@ -0,0 +1,154 @@
+# Troubleshooting
+
+Organised by what you actually see, not by what is actually wrong.
+
+---
+
+## The client connects to nothing. No error, no server log line — and the real website loads instead.
+
+This is the worst failure in the whole ecosystem, because everything looks
+healthy. The server starts clean, the port listens, and every rejected
+connection falls through to the genuine site your server impersonates, so a
+browser pointed at your server shows you `www.microsoft.com`.
+
+It means the handshake was rejected before Xray logged anything. Causes, in
+order of likelihood:
+
+1. **Wrong `pbk`, `sid` or `sni` in the link.** Regenerate it — never hand-edit
+   it, and never reuse a link from an older install:
+   ```
+   transtation show
+   ```
+2. **`minClientVer`.** Newer Xray versions default the Reality server to a
+   client-version floor, and a client below it gets exactly this behaviour.
+   transtation pins `minClientVer=0.0.0` so this cannot happen; if you set
+   `MIN_CLIENT_VER` yourself, unset it.
+3. **Missing `fp` in a hand-built link.** sing-box, NekoBox and Hiddify hard-fail
+   with `uTLS is required by reality client`; Xray-based clients tolerate it.
+   The generated link always carries `fp=chrome`.
+4. **Your SNI stopped supporting TLS 1.3 or X25519.** Check it and pick another:
+   ```
+   docker compose exec proxy xray tls ping www.microsoft.com
+   ```
+
+---
+
+## `transtation status` says `egress DIRECT — WARP requested but NOT registered`
+
+Registration failed, and the server started anyway rather than crash-looping.
+This is not cosmetic: **every packet is leaving from your VPS's own IP**, which
+is the address tied to your name and payment method.
+
+```
+transtation warp register
+```
+
+Cloudflare rate-limits registration per source IP for about fifteen minutes, so
+if you have just retried a few times, wait. If it keeps failing, either accept
+direct egress and silence the warning with `WARP=0` in `/opt/transtation/.env`,
+or check that this host has working outbound HTTPS.
+
+---
+
+## The container is `unhealthy`, but Reality itself works
+
+Almost always: **your provider blocks outbound UDP/2408**. WARP registration is
+TCP and succeeds; the data plane is UDP and never comes up.
+
+```
+docker compose logs proxy | grep -o 'Using .* TUN'
+```
+
+- prints `Using gVisor TUN` → good, the userspace path is live; the problem is
+  UDP egress. Set `WARP=0` in `.env` and `docker compose up -d`.
+- prints `Using kernel TUN`, or the container dies with a `read-only file
+  system` error mentioning `rp_filter` → **you added `--cap-add NET_ADMIN`.
+  Remove it.** With that capability Xray tries kernel TUN, writes to
+  `/proc/sys` before opening the device, hits Docker's read-only `/proc/sys`,
+  and fails with no fallback.
+
+Docker does not restart unhealthy containers by itself. `unhealthy` is a report,
+not a self-heal.
+
+---
+
+## Clients time out and the server log is completely silent
+
+Nothing is reaching Xray. The healthcheck cannot detect this — it only proves
+*outbound* traffic works, and inbound cannot be verified from the server at all.
+
+1. Open TCP 443 in your **provider's** firewall or security group. This is the
+   answer roughly nine times out of ten.
+2. Oracle Cloud also needs it opened in the instance's own iptables.
+3. Docker's published ports **bypass `ufw`** in both directions, so a ufw rule
+   neither helps nor hurts. Do not go looking there.
+4. Confirm something is actually listening:
+   ```
+   docker compose ps
+   ss -tlnp | grep :443
+   ```
+
+---
+
+## Tun mode: the container starts and exits, or the host's traffic is untouched
+
+Read the startup message — each impossible situation gets its own sentence.
+
+| Message | Meaning |
+|---|---|
+| `no /dev/net/tun in this container` | add `--device /dev/net/tun`. If the *host* has none: `sudo modprobe tun && echo tun \| sudo tee /etc/modules-load.d/tun.conf` |
+| `cannot manage host routing` | either `--cap-add NET_ADMIN` is missing, or this is rootless Docker/Podman, where tun mode cannot work at all. Use proxy mode. On Fedora/RHEL also try `sudo setsebool -P container_use_devices=true` |
+| `docker0 is not visible` | you are on bridge networking. Add `--network host`, or the tunnel comes up in the container's own namespace and your host never uses it |
+
+---
+
+## The network is completely dead
+
+```
+sudo transtation-panic
+```
+
+It removes the killswitch table, sweeps sing-box's leftover ip rules at
+priority 9000–9010, flushes routing table 2022 and deletes `tt0`. It needs
+neither Docker nor a working network.
+
+If `transtation-panic` is not installed, the same thing by hand:
+
+```
+sudo nft delete table inet transtation
+for p in $(seq 9000 9010); do sudo ip rule del priority $p 2>/dev/null; done
+sudo ip link del tt0
+```
+
+Note that a SIGKILLed sing-box leaves those ip rules behind even though the tun
+device is gone — including `strict_route`'s unreachable rule, which is not bound
+to the interface. That is why the loop above matters and deleting the interface
+alone is not enough.
+
+---
+
+## Everything works except one site, which blocks you
+
+That is WARP's shared exit pool, not a bug. Either route that site around the
+tunnel:
+
+```
+docker run ... -e DIRECT_SUFFIXES=thatsite.com,othersite.net ...
+```
+
+or turn WARP off entirely (`WARP=0`) so you egress from your own VPS address.
+
+---
+
+## I lost the volume / moved to a new host
+
+If you have a backup, it is one command into a fresh volume:
+
+```
+docker run --rm -v transtation-data:/data -v /root:/b alpine \
+  tar xzf /b/transtation-backup.tgz -C /data
+```
+
+If you do not, there is nothing to recover: a new install generates a new
+keypair, and every previously issued link is dead. Run `transtation backup` now
+on the install you still have.
diff --git a/client/Dockerfile b/client/Dockerfile
@@ -0,0 +1,47 @@
+# 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
@@ -0,0 +1,354 @@
+#!/bin/sh
+# transtation-client -- connect to a transtation server.
+#
+#   MODE=proxy (default)  local SOCKS5 + HTTP proxy. Unprivileged.
+#   MODE=tun              transparent whole-host tunnel. Linux, rootful.
+#
+# 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`.
+set -eu
+
+URI_FILE=${TT_URI_FILE:-/etc/transtation/uri}
+CONF=/run/transtation.json
+TUN_IFACE=tt0
+TUN_ADDR=172.19.0.1/30
+SOCKS_PORT=${SOCKS_PORT:-1080}
+
+die() { printf 'transtation-client: %s\n' "$*" >&2; exit 1; }
+warn() { printf 'transtation-client: %s\n' "$*" >&2; }
+
+# --------------------------------------------------------------- share link
+
+parse_uri() {
+	_u=$1
+	case "$_u" in
+		vless://*) ;;
+		*) die "not a vless:// link. Get one from your server with: transtation show" ;;
+	esac
+	_rest=${_u#vless://}
+	_rest=${_rest%%#*}
+	QUERY=""
+	case "$_rest" in *\?*)
+		QUERY=${_rest#*\?}
+		_rest=${_rest%%\?*}
+		;;
+	esac
+	case "$_rest" in *@*) ;; *) die "link has no '@' -- it is truncated" ;; esac
+	UUID=${_rest%%@*}
+	_hp=${_rest#*@}
+	case "$_hp" in
+		\[*\]:*)
+			HOST=${_hp%%]*}
+			HOST=${HOST#[}
+			PORT=${_hp##*]:}
+			;;
+		*:*)
+			HOST=${_hp%%:*}
+			PORT=${_hp##*:}
+			;;
+		*) die "link has no port" ;;
+	esac
+
+	SNI=""; PBK=""; SID=""; FP=""; FLOW=""
+	_q=$QUERY
+	while [ -n "$_q" ]; do
+		_kv=${_q%%&*}
+		case "$_q" in *\&*) _q=${_q#*&} ;; *) _q="" ;; esac
+		_k=${_kv%%=*}
+		_v=${_kv#*=}
+		case "$_k" in
+			sni | serverName) SNI=$_v ;;
+			pbk | publicKey) PBK=$_v ;;
+			sid | shortId) SID=$_v ;;
+			fp | fingerprint) FP=$_v ;;
+			flow) FLOW=$_v ;;
+		esac
+	done
+}
+
+validate() {
+	# Trust boundary: every one of these values is interpolated into a JSON
+	# document below. Validate before, not after.
+	printf '%s' "$UUID" | grep -Eiq '^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$' ||
+		die "bad uuid in link"
+	[ "${#PBK}" -eq 43 ] ||
+		die "bad pbk: expected 43 characters, got ${#PBK}. Reissue the link with 'transtation show'."
+	printf '%s' "$PBK" | grep -Eq '^[A-Za-z0-9_-]{43}$' || die "pbk is not base64url"
+	printf '%s' "$SID" | grep -Eq '^([0-9a-fA-F]{2}){1,8}$' ||
+		die "bad sid: expected 2-16 hex characters, got '$SID'"
+	printf '%s' "$SNI" | grep -Eq '^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$' || die "bad or missing sni"
+	[ -n "$HOST" ] || die "link has no server address"
+	printf '%s' "$PORT" | grep -Eq '^[0-9]{1,5}$' && [ "$PORT" -ge 1 ] && [ "$PORT" -le 65535 ] ||
+		die "bad port: $PORT"
+	# sing-box hard-errors with "uTLS is required by reality client" if the
+	# fingerprint is absent, where Xray-based clients would tolerate it.
+	FP=${FP:-chrome}
+	FLOW=${FLOW:-xtls-rprx-vision}
+
+	# Resolve once, here, so the container's resolver never has to look up the
+	# server name mid-flight -- and so the killswitch's bootstrap rule stays
+	# valid for as long as the tunnel does.
+	case "$HOST" in
+		*[!0-9.]*)
+			_ip=$(resolve4 "$HOST") || true
+			[ -n "$_ip" ] || die "cannot resolve $HOST from inside the container"
+			printf 'resolved %s -> %s\n' "$HOST" "$_ip"
+			SERVER_IP=$_ip
+			;;
+		*) SERVER_IP=$HOST ;;
+	esac
+}
+
+# getent comes from musl-utils and nslookup from busybox. Try both rather than
+# assuming which one a given base image happens to carry.
+resolve4() {
+	if command -v getent >/dev/null 2>&1; then
+		_r=$(getent ahostsv4 "$1" 2>/dev/null | awk 'NR==1 {print $1}')
+		[ -n "$_r" ] && { printf '%s' "$_r"; return 0; }
+	fi
+	nslookup "$1" 2>/dev/null |
+		awk '/^Address:/ && $2 ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/ { print $2; exit }'
+}
+
+# ------------------------------------------------------------------ configs
+
+json_suffix_array() {
+	# "a.com,b.com" -> "\"a.com\", \"b.com\"" ; empty input -> empty output
+	printf '%s' "${1:-}" | tr ',' '\n' |
+		sed 's/^[[:space:]]*//;s/[[:space:]]*$//' |
+		awk 'NF { printf "%s\"%s\"", (n++ ? ", " : ""), $0 }'
+}
+
+vless_outbound() {
+	cat <<-EOF
+		    {
+		      "type": "vless",
+		      "tag": "proxy",
+		      "server": "$SERVER_IP",
+		      "server_port": $PORT,
+		      "uuid": "$UUID",
+		      "flow": "$FLOW",
+		      "packet_encoding": "xudp",
+		      "tls": {
+		        "enabled": true,
+		        "server_name": "$SNI",
+		        "utls": { "enabled": true, "fingerprint": "$FP" },
+		        "reality": { "enabled": true, "public_key": "$PBK", "short_id": "$SID" }
+		      }
+		    }
+	EOF
+}
+
+render_proxy() {
+	# No "dns" and no "route" block on purpose. Adding DNS configuration here
+	# makes leaks MORE likely, not fewer: what actually prevents them is the
+	# 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.
+	cat <<-EOF
+		{
+		  "log": { "level": "warn" },
+		  "inbounds": [
+		    {
+		      "type": "mixed",
+		      "tag": "in",
+		      "listen": "0.0.0.0",
+		      "listen_port": $SOCKS_PORT
+		    }
+		  ],
+		  "outbounds": [
+		$(vless_outbound)
+		  ]
+		}
+	EOF
+}
+
+render_tun() {
+	_direct=$(json_suffix_array "${DIRECT_SUFFIXES:-}")
+	if [ -n "$_direct" ]; then
+		_dns_rule="{ \"domain_suffix\": [$_direct], \"server\": \"local\" },"
+		_route_rule="{ \"domain_suffix\": [$_direct], \"outbound\": \"direct\" },"
+	else
+		_dns_rule=""
+		_route_rule=""
+	fi
+	cat <<-EOF
+		{
+		  "log": { "level": "warn" },
+		  "dns": {
+		    "servers": [
+		      { "tag": "doh", "type": "https", "server": "1.1.1.1", "detour": "proxy" },
+		      { "tag": "local", "type": "local" }
+		    ],
+		    "rules": [
+		      $_dns_rule
+		      { "domain_suffix": ["$SNI"], "server": "local" }
+		    ],
+		    "final": "doh",
+		    "strategy": "ipv4_only"
+		  },
+		  "inbounds": [
+		    {
+		      "type": "tun",
+		      "tag": "tun-in",
+		      "interface_name": "$TUN_IFACE",
+		      "address": ["$TUN_ADDR"],
+		      "mtu": 1500,
+		      "auto_route": true,
+		      "strict_route": true,
+		      "stack": "mixed"
+		    }
+		  ],
+		  "outbounds": [
+		$(vless_outbound),
+		    { "type": "direct", "tag": "direct", "routing_mark": 1 }
+		  ],
+		  "route": {
+		    "auto_detect_interface": true,
+		    "default_domain_resolver": "local",
+		    "rules": [
+		      { "action": "sniff" },
+		      { "protocol": "dns", "action": "hijack-dns" },
+		      { "ip_is_private": true, "outbound": "direct" },
+		      $_route_rule
+		      { "domain_suffix": [".local", ".lan", ".internal", ".home", ".arpa"], "outbound": "direct" }
+		    ],
+		    "final": "proxy"
+		  }
+		}
+	EOF
+}
+# routing_mark 1 on the direct outbound is not decoration: the killswitch
+# accepts `meta mark 0x1`, and without the mark every DIRECT_SUFFIXES
+# destination leaves unmarked on the physical NIC, is not on the tun interface,
+# and hits policy drop -- i.e. turning the killswitch on would kill the split
+# tunnel it exists to protect.
+
+# --------------------------------------------------------------- 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"
+	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."
+}
+
+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 sudo ip rule del priority \$p; done │
+		  │   sudo ip link del $TUN_IFACE                                              │
+		  └───────────────────────────────────────────────────────────────────┘
+		  (or:  sudo transtation-panic  --  or the 'panic' verb of this image)
+
+	EOF
+}
+
+# ------------------------------------------------------------------- panic
+
+cmd_panic() {
+	nft delete table inet transtation 2>/dev/null || true
+	nft delete table ip6 transtation 2>/dev/null || true
+	# sing-box's own ip rules live at priority 9000-9010 and are NOT bound to
+	# the interface, so they outlive a SIGKILLed container.
+	_p=9000
+	while [ "$_p" -le 9010 ]; do
+		ip -4 rule del priority "$_p" 2>/dev/null || true
+		ip -6 rule del priority "$_p" 2>/dev/null || true
+		_p=$((_p + 1))
+	done
+	ip route flush table 2022 2>/dev/null || true
+	ip -6 route flush table 2022 2>/dev/null || true
+	ip link del "$TUN_IFACE" 2>/dev/null || true
+	echo "killswitch removed, policy routing flushed. You are back on direct egress."
+}
+
+# --------------------------------------------------------------------- run
+
+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"
+	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 ..."
+	fi
+
+	parse_uri "$URI"
+	validate
+
+	case "${MODE:-proxy}" in
+		proxy)
+			render_proxy >"$CONF"
+			;;
+		tun)
+			preflight_tun
+			render_tun >"$CONF"
+			;;
+		*) die "MODE must be 'proxy' or 'tun', got: ${MODE:-}" ;;
+	esac
+
+	sing-box check -c "$CONF" || die "generated config rejected by $(sing-box 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'
+			;;
+		tun)
+			printf '  mode     tun -- whole-host transparent tunnel on %s\n' "$TUN_IFACE"
+			printf '  direct   %s\n' "${DIRECT_SUFFIXES:-(none)}"
+			panic_banner
+			;;
+	esac
+
+	exec sing-box run -c "$CONF"
+}
+
+case "${1:-run}" in
+	run) cmd_run ;;
+	panic) cmd_panic ;;
+	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.
+		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"
+		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"
+		;;
+	*) die "unknown command: $1 (expected run, panic or check)" ;;
+esac
diff --git a/host/transtation-killswitch b/host/transtation-killswitch
@@ -0,0 +1,192 @@
+#!/bin/sh
+# transtation-killswitch -- fail-closed egress filter for MODE=tun.
+#
+# Host-owned on purpose. The client container never writes host firewall state:
+# with --network host its rules would land in the host's network namespace as
+# ordinary kernel state that outlives the container, and SIGKILL / OOM / a
+# dockerd crash bypasses any cleanup trap it might install. Worse, under
+# `restart: unless-stopped` a container that applies the killswitch and then
+# fails to start would sit in a loop re-closing your network faster than you
+# can open it.
+#
+#   transtation-killswitch on [--persist]
+#   transtation-killswitch off
+#   transtation-killswitch status
+set -eu
+
+URI_FILE=${TT_URI_FILE:-/etc/transtation/uri}
+TUN_IFACE=${TUN_IFACE:-tt0}
+UNIT=/etc/systemd/system/transtation-killswitch.service
+
+die() { printf 'transtation-killswitch: %s\n' "$*" >&2; exit 1; }
+
+[ "$(id -u)" = 0 ] || die "must run as root"
+command -v nft >/dev/null 2>&1 || die "nftables is not installed on this host"
+
+server_endpoint() {
+	[ -r "$URI_FILE" ] || die "cannot read $URI_FILE -- set TT_URI_FILE, or SERVER_IP and SERVER_PORT"
+	_u=$(cat "$URI_FILE")
+	_r=${_u#vless://}
+	_r=${_r%%#*}
+	_r=${_r%%\?*}
+	_hp=${_r#*@}
+	case "$_hp" in
+		\[*\]:*)
+			_h=${_hp%%]*}
+			_h=${_h#[}
+			_p=${_hp##*]:}
+			;;
+		*:*)
+			_h=${_hp%%:*}
+			_p=${_hp##*:}
+			;;
+		*) die "cannot parse a host:port out of $URI_FILE" ;;
+	esac
+	case "$_h" in
+		*[!0-9.]*)
+			_h=$(getent ahostsv4 "$_h" | awk 'NR==1 {print $1}')
+			[ -n "$_h" ] || die "cannot resolve the server address in $URI_FILE"
+			;;
+	esac
+	SERVER_IP=${SERVER_IP:-$_h}
+	SERVER_PORT=${SERVER_PORT:-$_p}
+}
+
+ks_on() {
+	server_endpoint
+
+	_admin=""
+	if [ -n "${ADMIN_CIDR:-}" ]; then
+		_admin="ip daddr $ADMIN_CIDR accept"
+	fi
+
+	_allow=""
+	if [ -n "${KILLSWITCH_ALLOW:-}" ]; then
+		# Raw nft expressions, ';'-separated. No mini-DSL: whoever runs this
+		# already holds NET_ADMIN over the host's network namespace, so there is
+		# no privilege boundary here to defend.
+		_allow=$(printf '%s' "$KILLSWITCH_ALLOW" | tr ';' '\n' | sed 's/^[[:space:]]*/    /')
+	fi
+
+	# Additive and scoped to our own table. NEVER `nft flush ruleset`: on a
+	# modern host that also wipes the iptables-nft tables Docker installs and
+	# silently breaks every container's networking. A DROP in any one table
+	# still drops, so scoping costs nothing in correctness.
+	nft delete table inet transtation 2>/dev/null || true
+	nft delete table ip6 transtation 2>/dev/null || true
+
+	nft -f - <<-EOF
+		table inet transtation {
+		  chain out {
+		    type filter hook output priority filter; policy drop;
+		    oif lo accept
+		    ct state established,related accept
+		    ct state invalid drop
+		    oifname "$TUN_IFACE" accept
+		    meta mark 0x1 accept
+		    ip daddr { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, 100.64.0.0/10, 224.0.0.0/4 } accept
+		    udp dport { 67, 68 } accept
+		    ip daddr $SERVER_IP tcp dport $SERVER_PORT accept
+		    ip daddr $SERVER_IP udp dport $SERVER_PORT accept
+		    $_admin
+		$_allow
+		    limit rate 5/minute log prefix "transtation-drop: "
+		  }
+		}
+		table ip6 transtation {
+		  chain out {
+		    type filter hook output priority filter; policy drop;
+		    oif lo accept
+		    ct state established,related accept
+		  }
+		}
+	EOF
+
+	cat <<-EOF
+		killswitch ON  (table inet transtation, output hook, policy drop)
+		  allowed out: loopback, established, $TUN_IFACE, fwmark 0x1 (split tunnel),
+		               RFC1918 + CGNAT + link-local + multicast, DHCP,
+		               $SERVER_IP:$SERVER_PORT${ADMIN_CIDR:+, admin $ADMIN_CIDR}
+		  IPv6 is dropped entirely except loopback and established.
+		  Your existing nftables rules were NOT modified; this is a separate table.
+
+		  LIMIT: this is an output-hook chain. It covers this host's own processes.
+		         Traffic FORWARDED from your other containers does not traverse the
+		         output hook and is NOT covered by it.
+
+		  off:   transtation-killswitch off
+		  panic: transtation-panic
+	EOF
+
+	if [ -n "${SSH_CONNECTION:-}" ]; then
+		cat <<-EOF
+
+			  NOTE: you are over SSH. Your session survives on the established rule, and
+			  new inbound SSH connections keep working too -- their reply packets are
+			  already ESTABLISHED. What stops is this host's own OUTBOUND traffic when
+			  the tunnel is down: DNS, package managers, monitoring agents. Set
+			  ADMIN_CIDR=<your.network/mask> if you want an explicit carve-out.
+		EOF
+	fi
+
+	if [ "${1:-}" = --persist ]; then
+		[ -d /run/systemd/system ] || {
+			cat <<-EOF
+
+				  --persist needs systemd, which is not running here. To make this survive a
+				  reboot on your own init, run this at boot:
+				      transtation-killswitch on
+			EOF
+			return 0
+		}
+		cat >"$UNIT" <<-EOF
+			[Unit]
+			Description=transtation killswitch
+			Before=network-pre.target
+			Wants=network-pre.target
+
+			[Service]
+			Type=oneshot
+			RemainAfterExit=yes
+			ExecStart=/usr/local/sbin/transtation-killswitch on
+			ExecStop=/usr/local/sbin/transtation-killswitch off
+
+			[Install]
+			WantedBy=multi-user.target
+		EOF
+		systemctl daemon-reload
+		systemctl enable transtation-killswitch.service >/dev/null
+		echo "  persisted: enabled transtation-killswitch.service"
+		echo "  (a broken fail-closed state now survives a reboot -- transtation-panic still clears it)"
+	fi
+}
+
+ks_off() {
+	nft delete table inet transtation 2>/dev/null || true
+	nft delete table ip6 transtation 2>/dev/null || true
+	if [ -f "$UNIT" ]; then
+		systemctl disable transtation-killswitch.service >/dev/null 2>&1 || true
+		rm -f "$UNIT"
+		systemctl daemon-reload 2>/dev/null || true
+	fi
+	echo "killswitch OFF (table inet transtation deleted; nothing else touched)"
+}
+
+ks_status() {
+	if nft list table inet transtation >/dev/null 2>&1; then
+		echo "killswitch is ON"
+		nft list table inet transtation
+	else
+		echo "killswitch is OFF (no table inet transtation)"
+	fi
+}
+
+case "${1:-}" in
+	on) shift; ks_on "${1:-}" ;;
+	off) ks_off ;;
+	status) ks_status ;;
+	*)
+		echo "usage: transtation-killswitch on [--persist] | off | status" >&2
+		exit 2
+		;;
+esac
diff --git a/host/transtation-panic b/host/transtation-panic
@@ -0,0 +1,25 @@
+#!/bin/sh
+# transtation-panic -- get this machine back on direct egress, right now.
+#
+# Deliberately tiny and dependency-free: it must work when Docker is broken,
+# when there is no network at all, and when you are reading it off a phone at
+# 3am. It removes the killswitch, sweeps the ip rules sing-box leaves behind
+# when it is SIGKILLed (those are not bound to the interface, so they outlive
+# the tun device), and deletes the tun interface.
+set -u
+
+TUN_IFACE=${TUN_IFACE:-tt0}
+
+nft delete table inet transtation 2>/dev/null
+nft delete table ip6 transtation 2>/dev/null
+for p in $(seq 9000 9010); do
+	ip -4 rule del priority "$p" 2>/dev/null
+	ip -6 rule del priority "$p" 2>/dev/null
+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, $TUN_IFACE deleted."
+echo "you are back on direct egress."
diff --git a/install.sh b/install.sh
@@ -0,0 +1,356 @@
+#!/bin/sh
+# transtation installer.
+#
+#   sh install.sh                          install the server
+#   sh install.sh client                   install the client, proxy mode
+#   sh install.sh client --tun             install the client, whole-host tunnel
+#   sh install.sh client --tun --killswitch   ... and a fail-closed egress filter
+#   sh install.sh --uninstall              remove whichever is installed here
+#
+# `set -eu`, deliberately NOT `-o pipefail`: /bin/sh is dash on Debian and
+# Ubuntu -- the primary target -- and `curl | sh` ignores the shebang, so
+# pipefail would abort this script on its first line.
+#
+# Everything of substance lives in functions, and main() is invoked on the very
+# last line. If the download is truncated, nothing runs.
+set -eu
+
+VERSION=${TT_VERSION:-v1.0.0}
+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.0, and the hash-verified installer you read would vouch for nothing.
+SERVER_DIGEST=${TT_SERVER_DIGEST:-}
+CLIENT_DIGEST=${TT_CLIENT_DIGEST:-}
+
+DIR=/opt/transtation
+URI_DIR=/etc/transtation
+RAW=https://raw.githubusercontent.com/sqzer-x/transtation/$VERSION
+
+say() { printf '%s\n' "$*"; }
+step() { printf '  %-14s%s\n' "$1" "$2"; }
+die() { printf '\ntranstation: %s\n\n' "$*" >&2; exit 1; }
+
+server_image() { printf '%s:%s%s' "$IMAGE_SERVER" "$VERSION" "${SERVER_DIGEST:+@$SERVER_DIGEST}"; }
+client_image() { printf '%s:%s%s' "$IMAGE_CLIENT" "$VERSION" "${CLIENT_DIGEST:+@$CLIENT_DIGEST}"; }
+
+need_root() {
+	[ "$(id -u)" = 0 ] || die "this needs root. Re-run as root, or with sudo:
+    curl -fsSLO $RAW/install.sh && sudo sh install.sh $*"
+}
+
+check_arch() {
+	case "$(uname -m)" in
+		x86_64 | amd64 | aarch64 | arm64) ;;
+		*) die "unsupported architecture: $(uname -m). transtation ships amd64 and arm64 images only." ;;
+	esac
+}
+
+os_id() {
+	# shellcheck disable=SC1091
+	[ -r /etc/os-release ] && . /etc/os-release && printf '%s' "${ID:-unknown}" || printf 'unknown'
+}
+
+install_docker() {
+	if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
+		step docker "$(docker --version | cut -d, -f1), compose $(docker compose version --short)"
+		return 0
+	fi
+	say ""
+	step docker "not found -- installing"
+	case "$(os_id)" in
+		alpine)
+			# get.docker.com hard-fails on Alpine.
+			apk add --no-cache docker docker-cli-compose >/dev/null
+			rc-update add docker default >/dev/null 2>&1 || true
+			service docker start >/dev/null 2>&1 || true
+			;;
+		arch | archarm | manjaro | endeavouros)
+			# get.docker.com hard-fails on Arch too.
+			pacman -S --needed --noconfirm docker docker-compose >/dev/null
+			systemctl enable --now docker >/dev/null 2>&1 || true
+			;;
+		*)
+			say "                 NOTE: this runs https://get.docker.com, a ~780-line third-party"
+			say "                 script, as root. We do not review it. If you would rather not,"
+			say "                 install Docker yourself and run this installer again."
+			curl -fsSL https://get.docker.com | sh >/dev/null
+			systemctl enable --now docker >/dev/null 2>&1 || true
+			;;
+	esac
+	command -v docker >/dev/null 2>&1 || die "Docker installation failed. Install it yourself and re-run."
+	docker compose version >/dev/null 2>&1 || die "the Docker Compose plugin is missing. Install docker-compose-plugin and re-run."
+	step docker "$(docker --version | cut -d, -f1), compose $(docker compose version --short)"
+	say "                 note: installing Docker sets net.ipv4.ip_forward=1 on this host."
+}
+
+wait_healthy() {
+	_name=$1 _limit=${2:-120} _t=0
+	while [ "$_t" -lt "$_limit" ]; do
+		case "$(docker inspect -f '{{.State.Health.Status}}' "$_name" 2>/dev/null || echo none)" in
+			healthy) printf 'healthy (%ss)\n' "$_t"; return 0 ;;
+			unhealthy | none) ;;
+		esac
+		sleep 3
+		_t=$((_t + 3))
+		printf '.'
+	done
+	printf 'TIMED OUT (%ss)\n' "$_limit"
+	return 1
+}
+
+# ------------------------------------------------------------------ server
+
+install_server() {
+	need_root
+	check_arch
+	say ""
+	say "  transtation $VERSION -- your own VLESS + REALITY proxy"
+	say "  https://github.com/sqzer-x/transtation"
+	say ""
+	step host "$(os_id) / $(uname -m) / root"
+	install_docker
+
+	mkdir -p "$DIR"
+	if [ -f "$DIR/docker-compose.yml" ]; then
+		step config "already present at $DIR -- reusing it (this is an upgrade, not a reinstall)"
+	else
+		cat >"$DIR/docker-compose.yml" <<-EOF
+			services:
+			  proxy:
+			    image: $(server_image)
+			    container_name: transtation
+			    restart: unless-stopped
+			    ports: ["\${PORT:-443}:8443"]
+			    volumes: ["transtation-data:/data"]
+			    env_file: .env
+			    logging:
+			      driver: json-file
+			      options: { max-size: "10m", max-file: "3" }
+			    # Do NOT add cap_add: NET_ADMIN or devices: /dev/net/tun here.
+			    # Both BREAK the WARP outbound. See README, "Capabilities".
+			volumes:
+			  transtation-data:
+		EOF
+		: >"$DIR/.env"
+		chmod 600 "$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)"
+
+	printf '  %-14s' start
+	docker compose -f "$DIR/docker-compose.yml" --project-directory "$DIR" up -d >/dev/null
+	if ! wait_healthy transtation 120; then
+		say ""
+		say "  The container is up but not healthy yet. Most common causes:"
+		say "    - the WARP tunnel is not passing traffic (some providers block outbound"
+		say "      UDP/2408 even though registration, which is TCP, succeeded). Fix:"
+		say "        sed -i 's/^WARP=1/WARP=0/' $DIR/.env || echo WARP=0 >> $DIR/.env"
+		say "        cd $DIR && docker compose up -d"
+		say "    - no outbound HTTPS from this host at all."
+		say ""
+		say "  Diagnose with:  transtation status   and   transtation logs"
+		exit 1
+	fi
+
+	say ""
+	transtation status || true
+	_port=$(sed -n 's/^PORT=//p' "$DIR/.env" 2>/dev/null | tail -1)
+	cat <<-EOF
+		  DO THIS NOW -- it is the only irreplaceable thing on this box:
+		      transtation backup
+
+		  Your link:   transtation show
+		  Add a user:  transtation user add alice
+		  Uninstall:   sh install.sh --uninstall
+
+		  >> If a client cannot connect, open TCP ${_port:-443} in your provider's
+		     firewall or security group. The healthcheck above only proves OUTBOUND
+		     traffic works; inbound cannot be verified from this host. Note that
+		     Docker's published ports bypass ufw in both directions.
+
+	EOF
+}
+
+install_wrapper() {
+	cat >/usr/local/bin/transtation <<-EOF
+		#!/bin/sh
+		set -eu
+		C="docker compose -f $DIR/docker-compose.yml --project-directory $DIR"
+		case "\${1:-}" in
+		  backup)
+		    f=\${2:-/root/transtation-backup.tgz}
+		    [ -e "\$f" ] && { echo "\$f exists -- refusing to overwrite" >&2; exit 1; }
+		    # root's umask is 022 on every target distro, which would make a
+		    # world-readable tarball of your Reality private key, your WARP private
+		    # key and your WARP bearer token.
+		    umask 077
+		    \$C exec -T proxy transtation backup > "\$f"
+		    echo "backed up to \$f (mode 0600): reality key, users, WARP account."
+		    ;;
+		  logs) shift; exec \$C logs "\$@" ;;
+		  up|down|pull|restart) exec \$C "\$@" ;;
+		  *) exec \$C exec proxy transtation "\$@" ;;
+		esac
+	EOF
+	chmod 0755 /usr/local/bin/transtation
+}
+
+# ------------------------------------------------------------------ client
+
+install_client() {
+	need_root
+	check_arch
+	_tun=0 _ks=0 _uri=""
+	for a in "$@"; do
+		case "$a" in
+			--tun) _tun=1 ;;
+			--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"
+	say ""
+
+	if [ -z "$_uri" ] && [ ! -r "$URI_DIR/uri" ]; then
+		say "  paste the link from your server (run 'transtation show' there):"
+		printf '  > '
+		read -r _uri
+	fi
+	if [ -n "$_uri" ]; then
+		mkdir -p "$URI_DIR"
+		umask 077
+		printf '%s\n' "$_uri" >"$URI_DIR/uri"
+		chmod 600 "$URI_DIR/uri"
+		step link "stored at $URI_DIR/uri (0600)"
+	else
+		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"
+		docker run -d --name transtation-client --restart unless-stopped \
+			--network host --cap-add NET_ADMIN --device /dev/net/tun \
+			-e MODE=tun \
+			${DIRECT_SUFFIXES:+-e DIRECT_SUFFIXES="$DIRECT_SUFFIXES"} \
+			-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 p in $(seq 9000 9010); do
+			  ip -4 rule del priority "$p" 2>/dev/null
+			  ip -6 rule del priority "$p" 2>/dev/null
+			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, no root privileges used at runtime"
+		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
+	fi
+
+	sleep 4
+	printf '  %-14s' self-test
+	if [ "$_tun" = 1 ]; then
+		curl -fsS --max-time 10 https://cloudflare.com/cdn-cgi/trace 2>/dev/null |
+			grep -E '^(ip|warp|colo)=' | tr '\n' ' ' || printf 'no answer yet'
+	else
+		curl -fsS --max-time 10 --socks5-hostname 127.0.0.1:1080 https://cloudflare.com/cdn-cgi/trace 2>/dev/null |
+			grep -E '^(ip|warp|colo)=' | tr '\n' ' ' || printf 'no answer yet -- check: docker logs transtation-client'
+	fi
+	say ""
+
+	if [ "$_tun" = 0 ]; then
+		cat <<-'EOF'
+
+			  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
+
+			    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".
+			    Not covered by this mode: QUIC (it degrades to TCP) and any app that
+			    ignores proxy environment variables.
+
+			  Whole-system tunnel instead:  sudo sh install.sh client --tun --killswitch
+
+		EOF
+	else
+		say ""
+		say "  If your network ever dies:  sudo transtation-panic"
+		say ""
+	fi
+}
+
+# --------------------------------------------------------------- uninstall
+
+uninstall() {
+	need_root
+	if [ -d "$DIR" ]; then
+		docker compose -f "$DIR/docker-compose.yml" --project-directory "$DIR" down >/dev/null 2>&1 || true
+		rm -f "$DIR/docker-compose.yml" "$DIR/.env" /usr/local/bin/transtation
+		rmdir "$DIR" 2>/dev/null || true
+		say "server removed."
+		printf 'also delete the data volume? That destroys your Reality key and every issued link. [y/N] '
+		read -r a
+		case "$a" in
+			y | Y) docker volume rm transtation-data >/dev/null 2>&1 && say "volume deleted." ;;
+			*) 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
+		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."
+}
+
+main() {
+	case "${1:-server}" in
+		server) shift 2>/dev/null || true; install_server ;;
+		client) shift; install_client "$@" ;;
+		--uninstall | uninstall) uninstall ;;
+		-h | --help)
+			sed -n '2,10p' "$0" 2>/dev/null || say "see https://github.com/sqzer-x/transtation"
+			;;
+		*) die "unknown command: $1 (expected: server, client, --uninstall)" ;;
+	esac
+}
+
+main "$@"
diff --git a/server/Dockerfile b/server/Dockerfile
@@ -0,0 +1,64 @@
+# syntax=docker/dockerfile:1
+#
+# transtation server -- Xray-core with a VLESS/REALITY inbound and an optional
+# Cloudflare WARP outbound.
+#
+# The image holds NO capabilities and needs NO devices. Xray's WireGuard
+# outbound probes for CAP_NET_ADMIN up front and takes its in-process gVisor
+# network stack when it is absent -- which is exactly Docker's default. Adding
+# --cap-add NET_ADMIN makes it try kernel TUN instead, which writes to
+# /proc/sys before opening the device, hits Docker's read-only /proc/sys, and
+# fails with no fallback. Do not add capabilities to this image.
+
+FROM --platform=$BUILDPLATFORM alpine:3.23 AS fetch
+ARG TARGETARCH
+# Latest stable Xray. XTLS marks nearly every other release "prerelease".
+ARG XRAY_VERSION=v26.3.27
+ARG WGCF_VERSION=2.2.32
+RUN apk add --no-cache curl unzip && set -eu; \
+    case "$TARGETARCH" in \
+      amd64) XZ=Xray-linux-64.zip;        WA=amd64 ;; \
+      arm64) XZ=Xray-linux-arm64-v8a.zip; WA=arm64 ;; \
+      *) echo "transtation: unsupported TARGETARCH=$TARGETARCH (amd64 and arm64 only)" >&2; exit 1 ;; \
+    esac; \
+    B=https://github.com/XTLS/Xray-core/releases/download/${XRAY_VERSION}; \
+    curl -fsSLO "$B/$XZ"; \
+    curl -fsSLO "$B/$XZ.dgst"; \
+    EXP=$(awk '/^SHA2-256/ {print $2}' "$XZ.dgst"); \
+    # Without this guard a changed .dgst format yields an empty expectation and
+    # `sha256sum -c` happily verifies nothing. The label is "SHA2-256= <hex>"
+    # (openssl style), not "SHA256".
+    printf '%s' "$EXP" | grep -Eq '^[0-9a-f]{64}$' || { echo "no usable SHA2-256 line in $XZ.dgst" >&2; exit 1; }; \
+    echo "$EXP  $XZ" | sha256sum -c -; \
+    unzip -j "$XZ" xray -d /out; \
+    W=https://github.com/ViRb3/wgcf/releases/download/v${WGCF_VERSION}; \
+    curl -fsSL -o /out/wgcf "$W/wgcf_${WGCF_VERSION}_linux_${WA}"; \
+    curl -fsSL "$W/checksums.txt" | grep " wgcf_${WGCF_VERSION}_linux_${WA}\$" \
+      | awk '{print $1"  /out/wgcf"}' | sha256sum -c -; \
+    chmod 0755 /out/xray /out/wgcf
+
+FROM alpine:3.23
+RUN apk add --no-cache ca-certificates curl jq libqrencode-tools \
+ && adduser -D -u 10001 xray \
+ && mkdir -p /data \
+ && chown xray:xray /data \
+ && chmod 0700 /data
+# /data is created and owned here so that a fresh NAMED VOLUME inherits this
+# ownership on first mount. A bind mount does not -- see the error message in
+# `transtation run` if you use one.
+
+COPY --from=fetch /out/xray /out/wgcf /usr/local/bin/
+COPY server/transtation /usr/local/bin/transtation
+COPY NOTICE /NOTICE
+
+# No geoip.dat / geosite.dat on purpose: 29 MiB for one rule that an explicit
+# CIDR list replaces. Any "geoip:" or "geosite:" routing rule will fail at
+# startup with file-not-found.
+
+USER xray
+VOLUME /data
+EXPOSE 8443
+HEALTHCHECK --interval=5m --timeout=15s --retries=3 --start-period=45s \
+  CMD ["/usr/local/bin/transtation", "health"]
+ENTRYPOINT ["/usr/local/bin/transtation"]
+CMD ["run"]
diff --git a/server/transtation b/server/transtation
@@ -0,0 +1,635 @@
+#!/bin/sh
+# transtation -- self-provisioning VLESS + REALITY proxy server, optional Cloudflare WARP egress.
+# https://github.com/sqzer-x/transtation
+#
+# Everything in /data except config.json and runtime.env is IDENTITY: written
+# once, atomically, and never rewritten. Everything else is DERIVED and
+# regenerated from this image on every boot. That split is what makes an image
+# upgrade safe and a half-finished first boot resumable.
+set -eu
+
+SCHEMA_SUPPORTED=1
+INTERNAL_PORT=8443
+HEALTH_ADDR=127.0.0.1
+HEALTH_PORT=10808
+# Cloudflare's public WARP peer key. Not a secret; identical for every user.
+WARP_PEER_PUBKEY='bmXOC+F1FxEMF9dyiK2H5/1SUtzH0JuVo51h2wPfgyo='
+WARP_ENDPOINT_DEFAULT='162.159.192.1:2408'
+TAB=$(printf '\t')
+
+set_paths() {
+	DATA=${TT_DATA:-/data}
+	STATE="$DATA/state.env"
+	USERS="$DATA/users.tsv"
+	CONF="$DATA/config.json"
+	RUNTIME="$DATA/runtime.env"
+	ACCOUNT="$DATA/wgcf-account.toml"
+	PROFILE="$DATA/wgcf-profile.conf"
+}
+set_paths
+
+die() { printf 'transtation: %s\n' "$*" >&2; exit 1; }
+warn() { printf 'transtation: %s\n' "$*" >&2; }
+
+# ---------------------------------------------------------------- identity
+
+# Xray has shipped three different `xray x25519` output formats:
+#   <=25.6.8   "Private key: <b64>"        / "Public key: <b64>"
+#   25.9-26.1  "PrivateKey: <b64>"         / "Password: <b64>"
+#   >=26.3.27  "PrivateKey: <b64>"         / "Password (PublicKey): <b64>"  + "Hash32: <b64>"
+# Parse by label, never by position. The ^ anchors keep "Password (PublicKey)"
+# from also matching the private rule, and keep Hash32 -- which belongs to VLESS
+# Encryption, not REALITY -- from matching either.
+parse_x25519() {
+	awk -F':[[:space:]]*' '
+		/^Private ?[Kk]ey/            { p = $2 }
+		/^(Public ?[Kk]ey|Password)/  { q = $2 }
+		END { gsub(/[[:space:]]/, "", p); gsub(/[[:space:]]/, "", q); print p " " q }'
+}
+
+provision_identity() {
+	_pair=$(xray x25519 | parse_x25519)
+	_priv=${_pair% *}
+	_pub=${_pair#* }
+	# An x25519 key is 32 bytes in base64url without padding == 43 characters.
+	# This assert is the tripwire for a fourth output format.
+	[ "${#_priv}" -eq 43 ] && [ "${#_pub}" -eq 43 ] || die \
+		"could not parse 'xray x25519' (priv=${#_priv}ch pub=${#_pub}ch).
+    Xray changed its output format. Open an issue with the output of: xray x25519"
+
+	_t=$(mktemp "$DATA/.state.XXXXXX")
+	printf 'SCHEMA=%s\nREALITY_PRIV=%s\nREALITY_PUB=%s\nCREATED=%s\n' \
+		"$SCHEMA_SUPPORTED" "$_priv" "$_pub" "$(date -u +%F)" >"$_t"
+	chmod 600 "$_t"
+	# Atomic. The existence of state.env IS the "identity provisioned" marker,
+	# so it must appear only once everything in it is durable.
+	mv -f "$_t" "$STATE"
+}
+
+migrate() {
+	# case "$SCHEMA" in 1) ...upgrade to 2...;& 2) ...;; esac -- nothing yet.
+	:
+}
+
+# ------------------------------------------------------------------- users
+
+user_add() {
+	_name=${1:?usage: transtation user add <name>}
+	case "$_name" in
+		'' | *[!A-Za-z0-9_-]*) die "name must match [A-Za-z0-9_-]{1,32}" ;;
+	esac
+	[ "${#_name}" -le 32 ] || die "name must be 32 characters or fewer"
+	if [ -f "$USERS" ] && grep -q "^$_name$TAB" "$USERS"; then
+		die "user '$_name' already exists"
+	fi
+	_uuid=$(xray uuid)
+	# 8 random bytes == 16 hex chars, the maximum REALITY accepts. Never "",
+	# which hex-decodes to the same all-zero shortId as "00" and leaves the
+	# public key -- which is printed in every share link you hand out -- as the
+	# only thing gating the handshake.
+	_sid=$(head -c 8 /dev/urandom | od -An -tx1 | tr -d ' \n')
+	umask 077
+	printf '%s\t%s\t%s\n' "$_name" "$_uuid" "$_sid" >>"$USERS"
+	chmod 600 "$USERS"
+	printf 'added user %s\n' "$_name"
+}
+
+user_rm() {
+	_name=${1:?usage: transtation user rm <name>}
+	[ -f "$USERS" ] || die "no users file"
+	grep -q "^$_name$TAB" "$USERS" || die "no such user: $_name"
+	_n=$(wc -l <"$USERS")
+	[ "$_n" -gt 1 ] || die "refusing to remove the last user -- the server cannot start with none"
+	_t=$(mktemp "$DATA/.users.XXXXXX")
+	grep -v "^$_name$TAB" "$USERS" >"$_t"
+	chmod 600 "$_t"
+	mv -f "$_t" "$USERS"
+	printf 'removed user %s (its UUID and shortId are both revoked)\n' "$_name"
+}
+
+user_ls() {
+	[ -f "$USERS" ] || die "no users yet"
+	printf 'NAME\tUUID\n'
+	while IFS="$TAB" read -r n u _; do
+		[ -n "$n" ] || continue
+		printf '%s\t%s\n' "$n" "$u"
+	done <"$USERS"
+}
+
+# ------------------------------------------------------------------ config
+
+warp_wanted() { [ "${WARP:-1}" = 1 ]; }
+warp_ready() { warp_wanted && [ -f "$PROFILE" ]; }
+
+warp_register() {
+	# ONE attempt. Never a retry loop: hammering Cloudflare's registration
+	# endpoint is how a source IP -- historically a whole region -- gets blocked.
+	if wgcf register --accept-tos --config "$ACCOUNT" >/dev/null 2>&1 &&
+		wgcf generate --config "$ACCOUNT" --profile "$PROFILE" >/dev/null 2>&1; then
+		chmod 600 "$ACCOUNT" "$PROFILE"
+		return 0
+	fi
+	rm -f "$PROFILE"
+	warn "Cloudflare WARP registration failed (rate limit, or the API changed).
+    Cloudflare rate-limits registration per source IP for roughly 15 minutes.
+    Continuing with DIRECT egress from this VPS's own IP -- the proxy works.
+      retry:            transtation warp register
+      turn off for good: set WARP=0 in your .env and 'docker compose up -d'"
+	return 1
+}
+
+render_warp_outbound() {
+	_priv=$(sed -n 's/^[[:space:]]*PrivateKey[[:space:]]*=[[:space:]]*//p' "$PROFILE" | head -1)
+	_addrs=$(sed -n 's/^[[:space:]]*Address[[:space:]]*=[[:space:]]*//p' "$PROFILE" | head -1)
+	[ -n "$_priv" ] && [ -n "$_addrs" ] || die "$PROFILE is unreadable or truncated -- try: transtation warp register"
+	_addr_json=$(printf '%s' "$_addrs" | tr ',' '\n' |
+		sed 's/^[[:space:]]*//;s/[[:space:]]*$//' |
+		awk 'NF { printf "%s\"%s\"", (n++ ? ", " : ""), $0 }')
+	cat <<-EOF
+		    {
+		      "tag": "warp-out",
+		      "protocol": "wireguard",
+		      "settings": {
+		        "secretKey": "$_priv",
+		        "address": [$_addr_json],
+		        "peers": [
+		          {
+		            "publicKey": "$WARP_PEER_PUBKEY",
+		            "endpoint": "${WARP_ENDPOINT:-$WARP_ENDPOINT_DEFAULT}",
+		            "allowedIPs": ["0.0.0.0/0", "::/0"],
+		            "keepAlive": 25
+		          }
+		        ],
+		        "mtu": 1280,
+		        "noKernelTun": true,
+		        "domainStrategy": "ForceIPv4v6"
+		      }
+		    },
+	EOF
+}
+
+# NOTE: this image ships no geoip.dat / geosite.dat (they are 62% of the image
+# and nothing else needed them). Any "geoip:" or "geosite:" rule added below
+# will fail at startup with a file-not-found error. Use literal CIDRs.
+render_config() {
+	_clients=$(awk -F'\t' 'NF>=3 { printf "%s{ \"id\": \"%s\", \"flow\": \"xtls-rprx-vision\" }", (n++ ? ",\n          " : ""), $2 }' "$USERS")
+	_sids=$(awk -F'\t' 'NF>=3 { printf "%s\"%s\"", (n++ ? ", " : ""), $3 }' "$USERS")
+	[ -n "$_clients" ] || die "no users defined -- refusing to render a config REALITY would reject"
+
+	if warp_ready; then
+		_warp_out=$(render_warp_outbound)
+		_final_out=warp-out
+	else
+		_warp_out=""
+		_final_out=direct
+	fi
+
+	cat <<-EOF
+		{
+		  "log": { "loglevel": "warning", "access": "none", "error": "" },
+		  "dns": { "servers": ["1.1.1.1", "1.0.0.1"] },
+		  "inbounds": [
+		    {
+		      "tag": "reality-in",
+		      "listen": "0.0.0.0",
+		      "port": $INTERNAL_PORT,
+		      "protocol": "vless",
+		      "settings": {
+		        "clients": [
+		          $_clients
+		        ],
+		        "decryption": "none"
+		      },
+		      "streamSettings": {
+		        "network": "tcp",
+		        "security": "reality",
+		        "realitySettings": {
+		          "show": false,
+		          "dest": "$DEST",
+		          "xver": 0,
+		          "serverNames": ["$SNI"],
+		          "privateKey": "$REALITY_PRIV",
+		          "shortIds": [$_sids],
+		          "minClientVer": "${MIN_CLIENT_VER:-0.0.0}"
+		        }
+		      },
+		      "sniffing": { "enabled": true, "destOverride": ["http", "tls", "quic"], "routeOnly": true }
+		    },
+		    {
+		      "tag": "health-in",
+		      "listen": "$HEALTH_ADDR",
+		      "port": $HEALTH_PORT,
+		      "protocol": "socks",
+		      "settings": { "auth": "noauth", "udp": false }
+		    }
+		  ],
+		  "outbounds": [
+		$_warp_out    { "tag": "direct", "protocol": "freedom" },
+		    { "tag": "block", "protocol": "blackhole" }
+		  ],
+		  "routing": {
+		    "domainStrategy": "IPIfNonMatch",
+		    "rules": [
+		      {
+		        "type": "field",
+		        "ip": [
+		          "127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
+		          "169.254.0.0/16", "100.64.0.0/10",
+		          "::1/128", "fc00::/7", "fe80::/10"
+		        ],
+		        "outboundTag": "block"
+		      },
+		      { "type": "field", "port": "25", "outboundTag": "block" },
+		      { "type": "field", "protocol": ["bittorrent"], "outboundTag": "block" },
+		      { "type": "field", "network": "tcp,udp", "outboundTag": "$_final_out" }
+		    ]
+		  }
+		}
+	EOF
+}
+
+# 169.254.0.0/16 in that block list is load-bearing, not decoration: without it
+# any client of your proxy can reach 169.254.169.254 and read your instance's
+# cloud metadata, including IAM credentials.
+
+write_config() {
+	_t=$(mktemp "$DATA/.config.XXXXXX")
+	render_config >"$_t"
+	jq -e . "$_t" >/dev/null 2>&1 || { rm -f "$_t"; die "generated config is not valid JSON -- this is a transtation bug"; }
+	xray run -test -c "$_t" >/dev/null 2>&1 || {
+		xray run -test -c "$_t" >&2 || true
+		rm -f "$_t"
+		die "generated config was rejected by xray -- this is a transtation bug"
+	}
+	chmod 644 "$_t"
+	mv -f "$_t" "$CONF"
+}
+
+write_runtime() {
+	# Derived, rewritten every boot. `show`/`status`/`health` run as separate
+	# `docker exec` processes and would otherwise not see an auto-detected HOST.
+	_t=$(mktemp "$DATA/.runtime.XXXXXX")
+	printf 'HOST=%s\nPORT=%s\nSNI=%s\nDEST=%s\nWARP=%s\nWARP_READY=%s\n' \
+		"$HOST" "$PORT" "$SNI" "$DEST" "${WARP:-1}" "$(warp_ready && echo 1 || echo 0)" >"$_t"
+	chmod 600 "$_t"
+	mv -f "$_t" "$RUNTIME"
+}
+
+load_runtime() {
+	[ -f "$RUNTIME" ] || die "server has not started yet -- run 'docker compose up -d' first"
+	# shellcheck source=/dev/null
+	. "$RUNTIME"
+}
+
+apply() {
+	# `user add` / `warp register` arrive as separate `docker exec` processes with
+	# none of run()'s shell state, so reload both halves before re-rendering.
+	# shellcheck source=/dev/null
+	. "$STATE"
+	load_runtime
+	write_config
+	if [ -r /proc/1/comm ] && [ "$(cat /proc/1/comm)" = xray ]; then
+		echo "restarting xray to apply (existing connections drop; clients reconnect automatically)"
+		kill -TERM 1
+	else
+		echo "written; takes effect the next time the container starts"
+	fi
+}
+
+# ------------------------------------------------------------------ sharing
+
+share_uri() {
+	_name=$1 _uuid=$2 _sid=$3
+	_h=$HOST
+	case "$_h" in *:*) _h="[$_h]" ;; esac
+	printf 'vless://%s@%s:%s?encryption=none&flow=xtls-rprx-vision&security=reality&sni=%s&fp=chrome&pbk=%s&sid=%s&spx=%%2F&type=tcp&headerType=none#transtation-%s\n' \
+		"$_uuid" "$_h" "$PORT" "$SNI" "$REALITY_PUB" "$_sid" "$_name"
+}
+
+cmd_show() {
+	# shellcheck source=/dev/null
+	. "$STATE"
+	load_runtime
+	[ -f "$USERS" ] || die "no users yet"
+	_want=${1:-}
+	_found=0
+	while IFS="$TAB" read -r n u s; do
+		[ -n "$n" ] || continue
+		[ -z "$_want" ] || [ "$_want" = "$n" ] || continue
+		_found=1
+		_uri=$(share_uri "$n" "$u" "$s")
+		printf '\n  user: %s\n\n' "$n"
+		printf '  !! The QR and the line below are your credential. Anyone holding either can\n'
+		printf '     use your proxy, and a link that has been published stays fingerprintable\n'
+		printf '     forever. Revoke with:  transtation user rm %s\n\n' "$n"
+		if command -v qrencode >/dev/null 2>&1; then
+			printf '%s' "$_uri" | qrencode -t ANSIUTF8 -m 2
+			printf '\n'
+		fi
+		printf '  %s\n\n' "$_uri"
+	done <"$USERS"
+	[ "$_found" = 1 ] || die "no such user: $_want"
+	# The URI is deliberately never written to container stdout during `run`:
+	# the json-file log driver would persist this credential to
+	# /var/lib/docker/containers/<id>/<id>-json.log, where it survives in every
+	# log bundle anyone ever pastes into a support thread.
+	printf '  This link was NOT written to the container log.\n\n'
+}
+
+# ------------------------------------------------------------------- health
+
+trace() { curl -fsS --max-time 8 --socks5-hostname "$HEALTH_ADDR:$HEALTH_PORT" https://cloudflare.com/cdn-cgi/trace; }
+
+cmd_health() {
+	load_runtime
+	_t=$(trace) || { echo "no egress at all through the proxy"; exit 1; }
+	if [ "$WARP_READY" = 1 ]; then
+		echo "$_t" | grep -qx 'warp=on' || {
+			cat >&2 <<-'EOF'
+				Reality is up, but the WARP tunnel is not passing traffic.
+				  - some VPS providers block outbound UDP/2408 (registration is TCP, so it
+				    succeeded anyway and this only shows up now)
+				  - if you added --cap-add NET_ADMIN, REMOVE it. It makes Xray try kernel TUN,
+				    which fails on Docker's read-only /proc/sys with no fallback to userspace.
+				  fix: set WARP=0 in your .env and run 'docker compose up -d'. The proxy works
+				       without WARP; it just egresses from this VPS's own IP.
+			EOF
+			exit 1
+		}
+	fi
+	exit 0
+}
+
+cmd_status() {
+	# shellcheck source=/dev/null
+	. "$STATE"
+	load_runtime
+	printf '\n  server        %s:%s\n' "$HOST" "$PORT"
+	printf '  dest / SNI    %s\n' "$SNI"
+	printf '  clients       minClientVer=%s\n' "${MIN_CLIENT_VER:-0.0.0}"
+
+	if [ "$WARP" = 1 ] && [ "$WARP_READY" = 0 ]; then
+		printf '  egress        DIRECT -- WARP was requested but is NOT registered.\n'
+		printf '                Every packet leaves from this VPS IP. Fix: transtation warp register\n'
+	elif _t=$(trace 2>/dev/null); then
+		printf '  egress        %s\n' "$(echo "$_t" | grep -E '^(ip|colo|loc|warp)=' | tr '\n' ' ')"
+	else
+		printf '  egress        UNREACHABLE -- no traffic is getting out through the proxy\n'
+	fi
+
+	if [ -r /proc/1/comm ] && [ "$(cat /proc/1/comm)" = xray ]; then
+		printf '  xray          running\n'
+	else
+		printf '  xray          NOT running as PID 1\n'
+	fi
+	printf '  users         %s\n' "$(awk -F'\t' 'NF>=3{printf "%s%s", (n++?", ":""), $1}' "$USERS" 2>/dev/null)"
+	printf '  inbound       not verifiable from this host. If clients time out with no server\n'
+	printf '                log line, open TCP %s in your provider firewall / security group.\n\n' "$PORT"
+}
+
+# ------------------------------------------------------------------ backup
+
+cmd_backup() {
+	# stdout only -- the caller decides where it lands and with what umask.
+	[ -f "$STATE" ] || die "nothing to back up yet"
+	tar -cz -C "$DATA" \
+		state.env users.tsv \
+		$([ -f "$ACCOUNT" ] && echo wgcf-account.toml) \
+		$([ -f "$PROFILE" ] && echo wgcf-profile.conf)
+}
+
+cmd_reset() {
+	[ "${I_UNDERSTAND_THIS_DESTROYS_MY_KEYS:-}" = yes ] || die \
+		"refusing. This destroys your Reality keypair and every issued link.
+    If you are certain:
+      docker compose run --rm -e I_UNDERSTAND_THIS_DESTROYS_MY_KEYS=yes proxy reset"
+	rm -f "$STATE" "$USERS" "$CONF" "$RUNTIME" "$ACCOUNT" "$PROFILE"
+	echo "identity destroyed. The next start provisions a brand new server."
+}
+
+# ------------------------------------------------------------------ selftest
+
+# Runs offline. This is the CI gate: it proves the rendered config is accepted
+# by the pinned xray with WARP both on and off, that the x25519 parser survives
+# every historical output format, and that the share URI is complete.
+cmd_selftest() {
+	_tmp=$(mktemp -d)
+	trap 'rm -rf "$_tmp"' EXIT
+	TT_DATA=$_tmp
+	set_paths
+	_fail=0
+	# Never `cmd; _check $?` -- under `set -e` a failing test aborts the run
+	# instead of reporting it. Always evaluate inside the if.
+	_assert() { _d=$1; shift; if "$@"; then printf '  ok    %s\n' "$_d"; else printf '  FAIL  %s\n' "$_d"; _fail=1; fi; }
+	_jq() { jq -e "$1" "$CONF" >/dev/null 2>&1; }
+	# Subshell so that write_config's `die` cannot take the whole selftest with it.
+	_renders() { (WARP=$1; write_config) >/dev/null 2>&1; }
+
+	echo "transtation selftest"
+
+	# 1. x25519 parser against all three historical output formats.
+	_assert "x25519 parser: <=25.6.8 format" \
+		test "$(printf 'Private key: AAA\nPublic key: BBB\n' | parse_x25519)" = "AAA BBB"
+	_assert "x25519 parser: 25.9-26.1 format" \
+		test "$(printf 'PrivateKey: AAA\nPassword: BBB\n' | parse_x25519)" = "AAA BBB"
+	_assert "x25519 parser: >=26.3.27 format (Hash32 ignored)" \
+		test "$(printf 'PrivateKey: AAA\nPassword (PublicKey): BBB\nHash32: CCC\n' | parse_x25519)" = "AAA BBB"
+
+	# 2. real key generation reaches the 43-char assert.
+	provision_identity
+	# shellcheck source=/dev/null
+	. "$STATE"
+	_assert "generated x25519 keypair is 43 chars each" \
+		test "${#REALITY_PRIV}${#REALITY_PUB}" = "4343"
+
+	# 3. render + xray validation, WARP OFF.
+	user_add alice >/dev/null
+	user_add bob >/dev/null
+	HOST=203.0.113.10
+	PORT=443
+	SNI=www.microsoft.com
+	DEST=www.microsoft.com:443
+	_assert "config renders and xray accepts it (WARP off)" _renders 0
+	_assert "no warp-out outbound emitted when WARP is unregistered" \
+		_jq '.outbounds | map(.tag) | index("warp-out") == null'
+	_assert "final route falls back to direct when WARP is off" \
+		_jq '.routing.rules[-1].outboundTag == "direct"'
+
+	# 4. render + xray validation, WARP ON (synthetic profile).
+	printf '[Interface]\nPrivateKey = %s\nAddress = 172.16.0.2/32, 2606:4700:110:1:2:3:4:5/128\n' \
+		"$(head -c 32 /dev/urandom | base64)" >"$PROFILE"
+	_assert "config renders and xray accepts it (WARP on)" _renders 1
+	_assert "warp outbound pins noKernelTun (userspace gVisor stack)" \
+		_jq '.outbounds[0].settings.noKernelTun == true'
+	_assert "warp outbound does not random-pick between A and AAAA" \
+		_jq '.outbounds[0].settings.domainStrategy == "ForceIPv4v6"'
+	_assert "warp endpoint is a literal IP, not a hostname" \
+		_jq '.outbounds[0].settings.peers[0].endpoint | test("^[0-9.]+:[0-9]+$")'
+
+	# 5. invariants that protect users.
+	_assert "shortIds never contains the empty string" \
+		_jq '.inbounds[0].streamSettings.realitySettings.shortIds | index("") == null'
+	_assert "every shortId is even-length hex, at most 16 chars" \
+		_jq '.inbounds[0].streamSettings.realitySettings.shortIds | all(test("^([0-9a-f]{2}){1,8}$"))'
+	_assert "minClientVer pinned to 0.0.0 (no client silently rejected)" \
+		_jq '.inbounds[0].streamSettings.realitySettings.minClientVer == "0.0.0"'
+	_assert "outbound port 25 blocked (no open SMTP relay)" \
+		_jq '[.routing.rules[] | select(.port == "25")] | length == 1'
+	_assert "cloud metadata range 169.254.0.0/16 blocked" \
+		_jq '[.routing.rules[] | select(.outboundTag == "block") | .ip // []] | flatten | index("169.254.0.0/16")'
+	_assert "logs go to stdout, not an unrotated file" _jq '.log.error == ""'
+	_assert "health SOCKS inbound is loopback-only" _jq '.inbounds[1].listen == "127.0.0.1"'
+	_assert "two users produce two clients and two shortIds" \
+		_jq '(.inbounds[0].settings.clients | length) == 2 and (.inbounds[0].streamSettings.realitySettings.shortIds | length) == 2'
+
+	# 6. share URI completeness.
+	_u=$(head -1 "$USERS" | cut -f2)
+	_s=$(head -1 "$USERS" | cut -f3)
+	_uri=$(share_uri alice "$_u" "$_s")
+	_missing=""
+	for _k in encryption=none flow=xtls-rprx-vision security=reality sni= fp=chrome pbk= sid= spx= type=tcp headerType=none; do
+		case "$_uri" in *"$_k"*) ;; *) _missing="$_missing $_k" ;; esac
+	done
+	_assert "share URI carries all 10 parameters (${_missing:-none missing})" test -z "$_missing"
+	# `_assert desc ! cmd` cannot work -- `!` is a shell keyword and does not
+	# survive "$@" expansion -- so the negation lives inside the helper.
+	_uri_params_nonempty() { case "$_uri" in *"=&"* | *"=#"*) return 1 ;; *) return 0 ;; esac; }
+	_assert "share URI has no empty parameter values" _uri_params_nonempty
+
+	echo
+	[ "$_fail" = 0 ] || die "selftest FAILED"
+	echo "selftest passed"
+}
+
+# --------------------------------------------------------------------- run
+
+banner() {
+	printf '\n  transtation -- listening on 0.0.0.0:%s inside the container\n' "$INTERNAL_PORT"
+	printf '  server        %s:%s\n' "$HOST" "$PORT"
+	printf '  dest / SNI    %s\n' "$SNI"
+	if [ "${WARP:-1}" = 1 ] && ! warp_ready; then
+		printf '  egress        DIRECT -- WARP requested but NOT registered (see the warning above)\n'
+	elif warp_ready; then
+		printf '  egress        WARP (Cloudflare)\n'
+	else
+		printf '  egress        DIRECT (WARP=0)\n'
+	fi
+	printf '  users         %s\n' "$(awk -F'\t' 'NF>=3{printf "%s%s", (n++?", ":""), $1}' "$USERS")"
+	printf '  share link    transtation show      (never printed here -- it is a credential)\n\n'
+}
+
+cmd_run() {
+	[ -d "$DATA" ] || die "$DATA does not exist"
+	[ -w "$DATA" ] || die \
+		"$DATA is not writable by uid $(id -u).
+    If you replaced the named volume with a bind mount, either chown it:
+      chown -R 10001:10001 ./data
+    or switch back to the named volume, which inherits the right ownership."
+	umask 077
+
+	# --- identity: gated on its own artifact, written last and atomically ---
+	[ -s "$STATE" ] || provision_identity
+	# shellcheck source=/dev/null
+	. "$STATE"
+	[ "${SCHEMA:-0}" -le "$SCHEMA_SUPPORTED" ] || die \
+		"data schema $SCHEMA is newer than this image supports ($SCHEMA_SUPPORTED).
+    You pinned back to an older tag. Pull the newer one, or restore a backup."
+	migrate
+
+	# --- config: read from the environment every boot, never persisted ---
+	SNI=${SNI:-www.microsoft.com}
+	# Validated before anything else touches it: SNI reaches a JSON document and
+	# an unvalidated value sourced from a file would be a shell injection.
+	printf '%s' "$SNI" | grep -Eq '^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$' ||
+		die "SNI must be a hostname, got: $SNI"
+	case "$SNI" in
+		*apple* | *icloud*) die "Xray warns that apple/icloud targets get your IP blocked by the GFW. Pick another SNI." ;;
+	esac
+	DEST=${DEST:-$SNI:443}
+	printf '%s' "$DEST" | grep -Eq '^[A-Za-z0-9.-]+:[0-9]{1,5}$' || die "DEST must be host:port, got: $DEST"
+	PORT=${PORT:-443}
+	printf '%s' "$PORT" | grep -Eq '^[0-9]{1,5}$' && [ "$PORT" -ge 1 ] && [ "$PORT" -le 65535 ] ||
+		die "PORT must be 1-65535, got: $PORT"
+
+	if [ -z "${HOST:-}" ]; then
+		HOST=$(curl -fsS --max-time 8 https://cloudflare.com/cdn-cgi/trace 2>/dev/null | sed -n 's/^ip=//p') || true
+	fi
+	[ -n "${HOST:-}" ] || die \
+		"could not auto-detect this host's public address.
+    Set HOST=<your.ip.or.domain> in your .env and start again."
+
+	if [ "${SKIP_DEST_CHECK:-0}" != 1 ]; then
+		xray tls ping "$SNI" >/dev/null 2>&1 ||
+			warn "TLS probe to $SNI failed from this host. Starting anyway, but clients may
+    fail the Reality handshake. Verify with: xray tls ping $SNI"
+	fi
+
+	# --- first user: gated on users.tsv, not on state.env ---------------
+	# This separation is the whole point. If provisioning is interrupted between
+	# writing the identity and creating the first user, the next start resumes
+	# instead of booting into a config with an empty shortIds array, which xray
+	# rejects outright and which turns into a crash loop you can only escape by
+	# destroying the volume.
+	[ -s "$USERS" ] || user_add "${USER_NAME:-main}"
+
+	# --- WARP: gated on the profile file, one attempt, never fatal ------
+	if warp_wanted && [ ! -f "$PROFILE" ]; then
+		warp_register || true
+	fi
+
+	write_config
+	write_runtime
+	banner
+	exec xray run -c "$CONF"
+}
+
+# --------------------------------------------------------------------- cli
+
+usage() {
+	cat <<-'EOF'
+		transtation -- VLESS + REALITY proxy server
+
+		  run                    start the server (default; this is what the image does)
+		  show [name]            print the share link and QR for a user, or for all users
+		  status                 server address, SNI, egress IP, users, WARP state
+		  health                 exit 0 if traffic (and WARP, if enabled) is getting out
+		  user add <name>        create a user with a fresh UUID and shortId, then apply
+		  user rm <name>         revoke a user, then apply
+		  user ls                list users
+		  warp register          register or re-register a Cloudflare WARP identity
+		  backup                 write a tar.gz of the identity to stdout
+		  reset                  destroy the identity (requires an explicit env var)
+		  selftest               offline self-check; no network, no state touched
+	EOF
+}
+
+case "${1:-run}" in
+	run) cmd_run ;;
+	show) shift; cmd_show "${1:-}" ;;
+	status) cmd_status ;;
+	health) cmd_health ;;
+	backup) cmd_backup ;;
+	reset) cmd_reset ;;
+	selftest) cmd_selftest ;;
+	user)
+		shift
+		case "${1:-}" in
+			add) shift; user_add "${1:-}"; apply ;;
+			rm) shift; user_rm "${1:-}"; apply ;;
+			ls | '') user_ls ;;
+			*) die "unknown: user $1" ;;
+		esac
+		;;
+	warp)
+		shift
+		case "${1:-}" in
+			register)
+				rm -f "$PROFILE"
+				warp_register && { echo "WARP registered."; apply; }
+				;;
+			*) die "unknown: warp ${1:-}" ;;
+		esac
+		;;
+	-h | --help | help) usage ;;
+	*) usage; die "unknown command: $1" ;;
+esac
diff --git a/test.sh b/test.sh
@@ -0,0 +1,104 @@
+#!/bin/sh
+# transtation test suite. Builds both images and runs their offline self-checks.
+#
+#   sh test.sh
+#
+# Needs Docker. Everything it runs is offline (--network none), so it is safe on
+# a laptop and it never registers a WARP identity -- hammering Cloudflare's
+# registration endpoint from CI is how a source IP gets blocked.
+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
+ok() { printf '  ok    %s\n' "$1"; }
+bad() { printf '  FAIL  %s\n' "$1"; fail=1; }
+check() { _d=$1; shift; if "$@" >/dev/null 2>&1; then ok "$_d"; else bad "$_d"; fi; }
+
+echo
+echo "== shell syntax =="
+for f in install.sh test.sh server/transtation client/transtation-client \
+	host/transtation-killswitch host/transtation-panic; do
+	check "sh -n $f" sh -n "$f"
+done
+if command -v shellcheck >/dev/null 2>&1; then
+	for f in install.sh server/transtation client/transtation-client \
+		host/transtation-killswitch host/transtation-panic; do
+		check "shellcheck $f" shellcheck -s sh -S warning "$f"
+	done
+else
+	echo "  skip  shellcheck (not installed)"
+fi
+
+echo
+echo "== the installer must never hand capabilities to the server =="
+# Adding NET_ADMIN makes Xray attempt kernel TUN, which writes to /proc/sys
+# before opening the device, hits Docker's read-only /proc/sys, and fails with
+# no fallback to userspace. This is the single easiest way to break a working
+# install, so it gets a test rather than only a comment.
+# Scoped to the compose block the installer actually writes, with comments
+# stripped -- an earlier version of this test matched its own warning comment
+# and "failed" on a correct file.
+server_compose() {
+	awk '/docker-compose\.yml" <<-EOF/ { f = 1; next } f && /^[\t ]*EOF$/ { f = 0 } f' install.sh |
+		sed 's/[[:space:]]*#.*//'
+}
+absent_in_compose() { ! server_compose | grep -qE "$1"; }
+uncommented_absent() { ! sed 's/[[:space:]]*#.*//' "$2" | grep -qE "$1"; }
+
+check "the compose block sets no cap_add" absent_in_compose 'cap_add'
+check "the compose block mounts no devices" absent_in_compose 'devices:'
+check "the compose block does not use network_mode: host" absent_in_compose 'network_mode'
+present_in_compose() { server_compose | grep -qE "$1"; }
+check "the compose block caps the log size (xray logs to stdout)" present_in_compose 'max-size'
+check "the compose block uses a named volume, not a bind mount" present_in_compose 'transtation-data:/data'
+check "no script ever runs 'nft flush ruleset'" uncommented_absent 'flush ruleset' host/transtation-killswitch
+
+if ! command -v docker >/dev/null 2>&1; then
+	echo
+	echo "  skip  image builds and runtime checks (docker not installed)"
+	echo
+	[ "$fail" = 0 ] || { echo "FAILED"; exit 1; }
+	echo "static checks passed"
+	exit 0
+fi
+
+echo
+echo "== build =="
+docker build -q -f server/Dockerfile -t "$SERVER_IMG" . >/dev/null && ok "server image builds"
+docker build -q -f client/Dockerfile -t "$CLIENT_IMG" . >/dev/null && ok "client image builds"
+
+echo
+echo "== server selftest (offline) =="
+docker run --rm --network none "$SERVER_IMG" selftest || fail=1
+
+echo
+echo "== server image holds no capabilities and runs as a non-root user =="
+check "server image default user is not root" \
+	sh -c "test \"\$(docker run --rm --network none --entrypoint id $SERVER_IMG -u)\" != 0"
+check "server image ships no geodata (it would be dead weight)" \
+	sh -c "! docker run --rm --network none --entrypoint ls $SERVER_IMG /usr/local/share/xray 2>/dev/null | grep -q dat"
+
+echo
+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 =="
+rejects() { ! docker run --rm --network none -e TT_URI="$1" "$CLIENT_IMG" check >/dev/null 2>&1; }
+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'
+
+echo
+[ "$fail" = 0 ] || { echo "FAILED"; exit 1; }
+echo "all tests passed"
+echo