transtation.git

transtation

~ Your own VLESS + REALITY proxy with optional Cloudflare WARP egress. Server in a container or native; native client with a fail-closed killswitch.
git clone https://github.com/debuman/transtation.git
commit 9cd85e9b0a33b2c044616594183302c3bc45e8a1
parent f028ebe2bb3525f6dab6facffb8922b2f8f0af5a
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Thu, 6 Aug 2026 20:19:08 +0900

The native install took down an existing Xray server, twice over

Found by running the native path on a VPS that already ran Xray, which is the
normal case for anyone this project is aimed at.

/usr/local/bin/xray is where the official XTLS installer and every hand-rolled
setup put the binary. The native install wrote ours over it, so the existing
service silently started running our build, and the uninstall then deleted the
file outright -- the other service failed with 203/EXEC and stayed down. wgcf
had the same problem, and the client had it waiting for sing-box.

Third-party binaries now live in /usr/local/lib/transtation, which is ours
alone. The systemd unit and the command wrapper put that directory on PATH, and
the uninstall removes only that directory. Verified by planting sentinel
binaries at both paths: they survive install and uninstall untouched, while our
own copies work. Two static checks keep it that way.

Also fixes the address the native install advertises. On a dual-stack host curl
prefers IPv6, so auto-detection returned an IPv6 literal and every share link
named an address nothing in this stack can use -- the client resolves A records
only, its DNS strategy is ipv4_only, and the killswitch refuses an IPv6 server.
Discovery is pinned to IPv4 and an IPv6 HOST is rejected with the reason. A
container had hidden this because Docker's default bridge is IPv4-only.

And the two server installs no longer stack. They own the same command and want
the same port, so installing one over the other left the first running,
orphaned and unreachable. Each refuses if the other is present.

Diffstat:
MREADME.md | 8++++----
Minstall.sh | 58++++++++++++++++++++++++++++++++++++++++++++++------------
Mserver/transtation | 23+++++++++++++++++++----
Mtest.sh | 10++++++++++
4 files changed, 79 insertion(+), 20 deletion(-)

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.13/install.sh
-sha256sum install.sh      # compare against the hash in the v1.0.13 release notes
+curl -fsSLO https://raw.githubusercontent.com/sqzer-x/transtation/v1.0.14/install.sh
+sha256sum install.sh      # compare against the hash in the v1.0.14 release notes
 less install.sh           # please actually read it
 sudo sh install.sh
 ```
@@ -46,8 +46,8 @@ how you feel about a container runtime on your VPS.
 ### In a container (default)
 
 ```
-curl -fsSLO https://raw.githubusercontent.com/sqzer-x/transtation/v1.0.13/install.sh
-sha256sum install.sh      # compare against the hash in the v1.0.13 release notes
+curl -fsSLO https://raw.githubusercontent.com/sqzer-x/transtation/v1.0.14/install.sh
+sha256sum install.sh      # compare against the hash in the v1.0.14 release notes
 less install.sh           # please actually read it
 sudo sh install.sh
 ```
diff --git a/install.sh b/install.sh
@@ -15,12 +15,12 @@
 # last line. If the download is truncated, nothing runs.
 set -eu
 
-VERSION=${TT_VERSION:-v1.0.13}
+VERSION=${TT_VERSION:-v1.0.14}
 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.13, and the hash-verified installer you read would vouch for nothing.
+# v1.0.14, and the hash-verified installer you read would vouch for nothing.
 SERVER_DIGEST=${TT_SERVER_DIGEST:-}
 CLIENT_DIGEST=${TT_CLIENT_DIGEST:-}
 
@@ -33,6 +33,12 @@ SINGBOX_VERSION=${TT_SINGBOX_VERSION:-1.13.16}
 SINGBOX_SHA_amd64=9ff0345fde4157a6bdab45a615668d41ccc93f6d0f361108042a48b8a49a9baa
 SINGBOX_SHA_arm64=3ea951c68f2eea10fd3ee8f8cc7794c12ccc7405afa99279a79e0b41cb183adf
 
+# Our copies of third-party binaries live here, never in /usr/local/bin. That
+# is where the official Xray installer and hand-rolled setups put theirs, and
+# writing there means overwriting somebody's working server -- and then deleting
+# it on uninstall. Which is exactly what happened once.
+PRIVLIB=/usr/local/lib/transtation
+
 DIR=${TT_DIR:-/opt/transtation}
 URI_DIR=/etc/transtation
 RAW=https://raw.githubusercontent.com/sqzer-x/transtation/$VERSION
@@ -125,6 +131,29 @@ wait_healthy() {
 
 # --- native server -------------------------------------------------------
 
+# The two server installs both own /usr/local/bin/transtation and both want the
+# same port. Installing one over the other leaves the first running and
+# orphaned -- still holding a port, still serving its own identity, no longer
+# reachable by the command. Refuse instead.
+refuse_other_server() {
+	case "$1" in
+		container)
+			[ -e /etc/systemd/system/transtation.service ] &&
+				die "a native server is already installed here.
+    Remove it first:  sh install.sh --uninstall
+    Or keep it -- it is the same software."
+			;;
+		native)
+			{ [ -e "$DIR/docker-compose.yml" ] || docker ps -a --format '{{.Names}}' 2>/dev/null | grep -qx transtation; } &&
+				die "a containerised server is already installed here.
+    Remove it first:  sh install.sh --uninstall
+    Or keep it -- it is the same software."
+			;;
+	esac
+	return 0
+}
+
+
 fetch_verified_xray() {
 	case "$(uname -m)" in
 		x86_64 | amd64) _xz=Xray-linux-64.zip; _wa=amd64 ;;
@@ -143,7 +172,7 @@ fetch_verified_xray() {
 		{ rm -rf "$_t"; die "Xray checksum mismatch -- refusing to install it"; }
 	command -v unzip >/dev/null 2>&1 || install_pkg unzip
 	unzip -q -j "$_t/$_xz" xray -d "$_t"
-	install -Dm0755 "$_t/xray" /usr/local/bin/xray
+	install -Dm0755 "$_t/xray" "$PRIVLIB/xray"
 
 	_w="https://github.com/ViRb3/wgcf/releases/download/v${WGCF_VERSION}"
 	curl -fsSL -o "$_t/wgcf" "$_w/wgcf_${WGCF_VERSION}_linux_${_wa}" ||
@@ -151,9 +180,9 @@ fetch_verified_xray() {
 	curl -fsSL "$_w/checksums.txt" | grep " wgcf_${WGCF_VERSION}_linux_${_wa}\$" |
 		awk -v f="$_t/wgcf" '{print $1"  "f}' | sha256sum -c - >/dev/null 2>&1 ||
 		{ rm -rf "$_t"; die "wgcf checksum mismatch -- refusing to install it"; }
-	install -Dm0755 "$_t/wgcf" /usr/local/bin/wgcf
+	install -Dm0755 "$_t/wgcf" "$PRIVLIB/wgcf"
 	rm -rf "$_t"
-	step binaries "xray $(/usr/local/bin/xray version | head -1 | cut -d' ' -f2) and wgcf ${WGCF_VERSION}, both checksum-verified"
+	step binaries "xray $("$PRIVLIB/xray" version | head -1 | cut -d' ' -f2) and wgcf ${WGCF_VERSION}, both checksum-verified, in $PRIVLIB"
 }
 
 install_pkg() {
@@ -168,6 +197,7 @@ install_pkg() {
 install_server_native() {
 	need_root
 	check_arch
+	refuse_other_server native
 	say ""
 	say "  transtation $VERSION -- native install, no container runtime"
 	say "  https://github.com/sqzer-x/transtation"
@@ -178,7 +208,7 @@ install_server_native() {
 	command -v curl >/dev/null 2>&1 || die "curl is required"
 
 	fetch_verified_xray
-	install -Dm0755 "$(server_script)" /usr/local/lib/transtation/server
+	install -Dm0755 "$(server_script)" "$PRIVLIB/server"
 
 	id -u transtation >/dev/null 2>&1 || useradd --system --no-create-home --shell /usr/sbin/nologin transtation 2>/dev/null ||
 		adduser -S -D -H -s /sbin/nologin transtation 2>/dev/null || true
@@ -194,13 +224,13 @@ install_server_native() {
 		#!/bin/sh
 		set -eu
 		if [ "$(id -un)" = transtation ]; then
-		  exec env TT_DATA=/var/lib/transtation /usr/local/lib/transtation/server "$@"
+		  exec env TT_DATA=/var/lib/transtation PATH=/usr/local/lib/transtation:$PATH /usr/local/lib/transtation/server "$@"
 		fi
 		[ "$(id -u)" = 0 ] || { echo "transtation: run this as root or as the transtation user" >&2; exit 1; }
 		if command -v runuser >/dev/null 2>&1; then
-		  exec runuser -u transtation -- env TT_DATA=/var/lib/transtation /usr/local/lib/transtation/server "$@"
+		  exec runuser -u transtation -- env TT_DATA=/var/lib/transtation PATH=/usr/local/lib/transtation:$PATH /usr/local/lib/transtation/server "$@"
 		fi
-		exec su -s /bin/sh transtation -c 'exec env TT_DATA=/var/lib/transtation /usr/local/lib/transtation/server "$@"' -- sh "$@"
+		exec su -s /bin/sh transtation -c 'exec env TT_DATA=/var/lib/transtation PATH=/usr/local/lib/transtation:$PATH /usr/local/lib/transtation/server "$@"' -- sh "$@"
 	WRAP
 	step config "wrote /etc/transtation/server.env, data in /var/lib/transtation"
 
@@ -215,6 +245,7 @@ install_server_native() {
 		User=transtation
 		Group=transtation
 		Environment=TT_DATA=/var/lib/transtation
+		Environment=PATH=/usr/local/lib/transtation:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
 		EnvironmentFile=-/etc/transtation/server.env
 		ExecStart=/usr/local/lib/transtation/server run
 		Restart=on-failure
@@ -293,6 +324,7 @@ server_script() {
 install_server() {
 	need_root
 	check_arch
+	refuse_other_server container
 	say ""
 	say "  transtation $VERSION -- your own VLESS + REALITY proxy"
 	say "  https://github.com/sqzer-x/transtation"
@@ -506,9 +538,9 @@ install_sing_box() {
 	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
+	install -Dm0755 "$_t/$_n/sing-box" "$PRIVLIB/sing-box"
 	rm -rf "$_t"
-	SINGBOX=/usr/local/bin/sing-box
+	SINGBOX=$PRIVLIB/sing-box
 	step sing-box "$($SINGBOX version | head -1)  (verified, statically linked)"
 }
 
@@ -686,7 +718,7 @@ uninstall() {
 		systemctl disable --now transtation.service >/dev/null 2>&1 || true
 		rm -f /etc/systemd/system/transtation.service
 		systemctl daemon-reload 2>/dev/null || true
-		rm -rf /usr/local/bin/transtation /usr/local/lib/transtation /usr/local/bin/xray /usr/local/bin/wgcf
+		rm -rf /usr/local/bin/transtation "$PRIVLIB"
 		say "native server removed. Its data is still in /var/lib/transtation."
 		ask 'also delete /var/lib/transtation? That destroys your Reality key and every issued link. [y/N] '
 		say ""
@@ -700,6 +732,8 @@ uninstall() {
 		rm -f /etc/systemd/system/transtation-client.service
 		systemctl daemon-reload 2>/dev/null || true
 		rm -f /usr/local/bin/transtation-client
+		rm -f "$PRIVLIB/sing-box"
+		rmdir "$PRIVLIB" 2>/dev/null || true
 		say "client removed."
 	fi
 	rm -f /usr/local/sbin/transtation-panic
diff --git a/server/transtation b/server/transtation
@@ -932,10 +932,12 @@ cmd_selftest() {
 
 	# Same injection class as the client's link parser, arriving by environment.
 	_env_rejects() {
-		(SNI=www.cloudflare.com DEST="" PORT=443
+		(SNI=www.cloudflare.com DEST="" PORT=443 HOST=203.0.113.10
 			eval "$1=\$2"
 			validate_config_env) >/dev/null 2>&1 && return 1 || return 0
 	}
+	_assert "an IPv6 HOST is refused (the rest of the stack is IPv4-only)" \
+		_env_rejects HOST '2406:da14:157d:8900::1'
 	_assert "MIN_CLIENT_VER is not free text" _env_rejects MIN_CLIENT_VER '0.0.0", "show": true, "x": "'
 	_assert "WARP_ENDPOINT must be an IPv4 address and port" _env_rejects WARP_ENDPOINT 'evil.example:2408'
 
@@ -975,6 +977,12 @@ cmd_selftest() {
 # meaningfully more trustworthy than a link someone sends you, and each check is
 # one line. Kept as its own function so the selftest can attack it directly.
 validate_config_env() {
+	case "${HOST:-}" in
+		*:*) die "HOST is an IPv6 address ($(q "$HOST")). transtation is IPv4-only for now:
+    the client resolves A records only and the killswitch refuses an IPv6
+    server, so a link naming this address would not work. Set HOST to an IPv4
+    address or a name with an A record." ;;
+	esac
 	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: $(q "$SNI")"
 	case "$SNI" in
@@ -1041,11 +1049,18 @@ cmd_run() {
 	fi
 
 	# --- where are we? one request, used for both the address and the region ---
-	_trace=$(curl -fsS --max-time 8 https://cloudflare.com/cdn-cgi/trace 2>/dev/null) || _trace=""
+	# -4 is not optional. On a dual-stack host curl prefers IPv6, so the address
+	# discovered here -- the one that goes into every share link -- came back as
+	# an IPv6 literal, which nothing else in this stack can use: the client
+	# resolves A records only, its DNS strategy is ipv4_only, and the killswitch
+	# refuses an IPv6 server outright. A container hid this, because Docker's
+	# default bridge is IPv4-only.
+	_trace=$(curl -4 -fsS --max-time 8 https://cloudflare.com/cdn-cgi/trace 2>/dev/null) || _trace=""
 	[ -n "${HOST:-}" ] || HOST=$(printf '%s' "$_trace" | sed -n 's/^ip=//p')
 	[ -n "${HOST:-}" ] || die \
-		"could not auto-detect this host's public address.
-    Set HOST=<your.ip.or.domain> in your .env and start again."
+		"could not auto-detect this host's public IPv4 address.
+    Set HOST=<your.ip.or.domain> in $(env_hint) and start again.
+    (transtation is IPv4-only for now; an IPv6-only host is not supported.)"
 	LOC=$(printf '%s' "$_trace" | sed -n 's/^loc=//p')
 
 	# --- SNI: env override, else the sticky auto-selection, else pick now ---
diff --git a/test.sh b/test.sh
@@ -56,6 +56,16 @@ check "the compose block caps the log size (xray logs to stdout)" present_in_com
 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
 
+# /usr/local/bin/xray is where the official Xray installer and most hand-rolled
+# setups put their binary. Installing ours there overwrote a working server, and
+# uninstalling then deleted it -- taking that server down. Ours live in a
+# private directory and the uninstall only ever removes that.
+no_clobber() { ! grep -qE "install -Dm?0?755 [^ ]+ /usr/local/bin/(xray|wgcf|sing-box)" install.sh; }
+no_foreign_rm() { ! grep -qE "rm -[rf]+ .*/usr/local/bin/(xray|wgcf|sing-box)" install.sh; }
+check "the installer never writes third-party binaries into /usr/local/bin" no_clobber
+check "the uninstaller never deletes third-party binaries it did not install" no_foreign_rm
+
+
 # The version is repeated in the installer and in every documented command.
 # It drifted five releases once, because bumping it was a hand-run sed, and the
 # README ended up telling people to install a version without the security