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
|