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