diff --git a/README.md b/README.md
@@ -70,6 +70,7 @@ services:
options: { max-size: "10m", max-file: "3" }
volumes:
transtation-data:
+ name: transtation-data # pinned; otherwise Compose prefixes it with the directory name
```
### Day to day
@@ -80,6 +81,8 @@ 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 restore <file> # put one back (server must be stopped)
+transtation verify # prove a client can actually complete the handshake
transtation logs -f
```
@@ -419,10 +422,20 @@ 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
+cd /opt/transtation && docker compose down
+transtation restore /root/transtation-backup.tgz
+docker compose up -d && transtation verify
```
+This started life as a documented `tar` one-liner and failed twice in testing,
+so it is a command now. Getting it right by hand means knowing that Docker
+seeds a fresh named volume with the ownership of the image directory it is
+first mounted into — so unpacking with a generic image leaves `/data` owned by
+root and the server refuses to start — *and* that the backup is mode 0600, so
+the unpacking container has to run as root to read it at all. `restore` also
+refuses to run while the server is up, and refuses a file that is not a
+transtation backup.
+
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.
diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md
@@ -168,10 +168,20 @@ or turn WARP off entirely (`WARP=0`) so you egress from your own VPS address.
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
+cd /opt/transtation && docker compose down
+transtation restore /root/transtation-backup.tgz
+docker compose up -d && transtation verify
```
+This started life as a documented `tar` one-liner and failed twice in testing,
+so it is a command now. Getting it right by hand means knowing that Docker
+seeds a fresh named volume with the ownership of the image directory it is
+first mounted into — so unpacking with a generic image leaves `/data` owned by
+root and the server refuses to start — *and* that the backup is mode 0600, so
+the unpacking container has to run as root to read it at all. `restore` also
+refuses to run while the server is up, and refuses a file that is not a
+transtation backup.
+
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/transtation-client b/client/transtation-client
@@ -86,6 +86,32 @@ validate() {
# fingerprint is absent, where Xray-based clients would tolerate it.
FP=${FP:-chrome}
FLOW=${FLOW:-xtls-rprx-vision}
+ # ALLOWLISTS, not pattern checks. These two land inside a JSON document, and
+ # a link is something somebody hands you -- in a chat, on a forum, as a QR
+ # code. Left open, `fp=chrome"},"insecure":true,"utls":{"enabled":true,
+ # "fingerprint":"chrome` renders a config that sing-box happily accepts,
+ # which is how a "friendly" link turns into someone else's server with
+ # certificate checking switched off.
+ case "$FLOW" in
+ xtls-rprx-vision | none | '') ;;
+ *) die "unsupported flow in link: $FLOW (expected xtls-rprx-vision)" ;;
+ esac
+ [ "$FLOW" = none ] && FLOW=""
+ case "$FP" in
+ chrome | firefox | edge | safari | ios | android | random | randomized | 360 | qq) ;;
+ *) die "unsupported uTLS fingerprint in link: $FP" ;;
+ esac
+
+ # Operator-supplied, but the same class of hole and just as cheap to close.
+ printf '%s' "$SOCKS_PORT" | grep -Eq '^[0-9]{1,5}$' &&
+ [ "$SOCKS_PORT" -ge 1 ] && [ "$SOCKS_PORT" -le 65535 ] ||
+ die "SOCKS_PORT must be 1-65535, got: $SOCKS_PORT"
+ if [ -n "${DIRECT_SUFFIXES:-}" ]; then
+ for _d in $(printf '%s' "$DIRECT_SUFFIXES" | tr ',' ' '); do
+ printf '%s' "$_d" | grep -Eq '^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$' ||
+ die "DIRECT_SUFFIXES entry is not a domain suffix: $_d"
+ done
+ fi
# 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
@@ -122,6 +148,7 @@ json_suffix_array() {
}
vless_outbound() {
+ if [ -n "$FLOW" ]; then _flow_kv="\"flow\": \"$FLOW\","; else _flow_kv=""; fi
cat <<-EOF
{
"type": "vless",
@@ -129,7 +156,7 @@ vless_outbound() {
"server": "$SERVER_IP",
"server_port": $PORT,
"uuid": "$UUID",
- "flow": "$FLOW",
+ $_flow_kv
"packet_encoding": "xudp",
"tls": {
"enabled": true,
diff --git a/install.sh b/install.sh
@@ -24,11 +24,25 @@ IMAGE_CLIENT=${TT_IMAGE_CLIENT:-ghcr.io/sqzer-x/transtation-client}
SERVER_DIGEST=${TT_SERVER_DIGEST:-}
CLIENT_DIGEST=${TT_CLIENT_DIGEST:-}
-DIR=/opt/transtation
+DIR=${TT_DIR:-/opt/transtation}
URI_DIR=/etc/transtation
RAW=https://raw.githubusercontent.com/sqzer-x/transtation/$VERSION
say() { printf '%s\n' "$*"; }
+# Under `curl | sh` stdin is the script itself, so a bare `read` swallows the
+# rest of this file. Under cron or CI there is no /dev/tty at all. Try the
+# terminal, fall back to stdin, and treat "neither" as no answer.
+ask() {
+ printf '%s' "$1"
+ REPLY=""
+ # `[ -r /dev/tty ]` is not enough: the node exists and looks readable even
+ # with no controlling terminal, and opening it then fails with ENXIO and
+ # prints to stderr. Try the open for real, suppress it, fall back to stdin.
+ if { read -r REPLY </dev/tty; } 2>/dev/null && [ -n "$REPLY" ]; then
+ return 0
+ fi
+ read -r REPLY 2>/dev/null || REPLY=""
+}
step() { printf ' %-14s%s\n' "$1" "$2"; }
die() { printf '\ntranstation: %s\n\n' "$*" >&2; exit 1; }
@@ -37,7 +51,7 @@ client_image() { printf '%s:%s%s' "$IMAGE_CLIENT" "$VERSION" "${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 $*"
+ curl -fsSLO $RAW/install.sh && sudo sh install.sh"
}
check_arch() {
@@ -132,9 +146,20 @@ install_server() {
# Both BREAK the WARP outbound. See README, "Capabilities".
volumes:
transtation-data:
+ # Pinned. Without an explicit name Docker Compose prefixes it with
+ # the project name, which comes from the directory -- so the volume
+ # would be called something else on every host and every documented
+ # backup, restore and uninstall command would silently address a
+ # volume that does not exist.
+ name: transtation-data
EOF
: >"$DIR/.env"
chmod 600 "$DIR/.env"
+ # If PORT came from the environment, write it down. Compose would honour
+ # it for the published port while the container -- which reads only
+ # env_file -- would still think it is 443, and the share link would name
+ # the wrong port.
+ [ -n "${PORT:-}" ] && printf 'PORT=%s\n' "$PORT" >>"$DIR/.env"
step config "wrote $DIR/docker-compose.yml and $DIR/.env"
fi
[ -n "$SERVER_DIGEST" ] || say " note: image pinned by tag, not digest (unreleased build)"
@@ -147,7 +172,7 @@ install_server() {
say "pulled $(server_image)"
printf ' %-14s' start
- docker compose -f "$DIR/docker-compose.yml" --project-directory "$DIR" up -d >/dev/null
+ docker compose -f "$DIR/docker-compose.yml" --project-directory "$DIR" up -d >/dev/null 2>&1
if ! wait_healthy transtation 120; then
say ""
say " The container is up but not healthy yet. Most common causes:"
@@ -210,6 +235,23 @@ install_wrapper() {
\$C exec -T proxy transtation backup > "\$f"
echo "backed up to \$f (mode 0600): reality key, users, WARP account."
;;
+ restore)
+ f=\${2:-}
+ [ -n "\$f" ] && [ -r "\$f" ] || { echo "usage: transtation restore <backup.tgz>" >&2; exit 1; }
+ tar tzf "\$f" 2>/dev/null | grep -q '^state.env$' || { echo "\$f does not look like a transtation backup" >&2; exit 1; }
+ if \$C ps -q proxy 2>/dev/null | grep -q .; then
+ echo "stop the server first: cd $DIR && docker compose down" >&2; exit 1
+ fi
+ # --user 0 so it can read a 0600 archive; the transtation image so that
+ # Docker seeds a fresh volume with the right /data ownership; tar then
+ # restores each file's archived uid.
+ docker run --rm --user 0 \\
+ -v transtation-data:/data \\
+ -v "\$(cd "\$(dirname "\$f")" && pwd)":/b:ro \\
+ --entrypoint sh $(server_image) \\
+ -c "tar xzf /b/\$(basename "\$f") -C /data"
+ echo "restored. start it again: cd $DIR && docker compose up -d"
+ ;;
logs) shift; exec \$C logs "\$@" ;;
up|down|pull|restart) exec \$C "\$@" ;;
*) exec \$C exec proxy transtation "\$@" ;;
@@ -240,8 +282,10 @@ install_client() {
if [ -z "$_uri" ] && [ ! -r "$URI_DIR/uri" ]; then
say " paste the link from your server (run 'transtation show' there):"
- printf ' > '
- read -r _uri
+ ask ' > '
+ _uri=$REPLY
+ [ -n "$_uri" ] || die "no link given. Pass it as an argument instead:
+ sh install.sh client 'vless://...'"
fi
if [ -n "$_uri" ]; then
mkdir -p "$URI_DIR"
@@ -258,10 +302,11 @@ install_client() {
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 \
- ${DIRECT_SUFFIXES:+-e DIRECT_SUFFIXES="$DIRECT_SUFFIXES"} \
+ -e MODE=tun "$@" \
-v "$URI_DIR":"$URI_DIR":ro \
"$(client_image)" >/dev/null
install -Dm0755 /dev/stdin /usr/local/sbin/transtation-panic <<-'EOF'
@@ -336,10 +381,17 @@ uninstall() {
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." ;;
+ ask 'also delete the data volume? That destroys your Reality key and every issued link. [y/N] '
+ say ""
+ case "$REPLY" in
+ y | Y)
+ if docker volume rm transtation-data >/dev/null 2>&1; then
+ say "volume deleted."
+ else
+ say "could not delete the volume (in use, or already gone):"
+ docker volume ls --format ' {{.Name}}' | grep transtation || say " none found"
+ fi
+ ;;
*) say "volume kept: 'docker volume ls | grep transtation-data'" ;;
esac
fi
diff --git a/server/transtation b/server/transtation
@@ -565,8 +565,11 @@ sni_write() {
case "$1" in
*apple* | *icloud*) die "Xray warns that apple/icloud targets get your IP blocked by the GFW." ;;
esac
+ # The reason is free text built partly from CLIENT_REGION; a newline in it
+ # would inject a second KEY=value line into this file.
+ _r=$(printf '%s' "$2" | tr -d '\n\r')
_t=$(mktemp "$DATA/.sni.XXXXXX")
- printf 'SNI=%s\nSNI_REASON=%s\nSNI_CHOSEN=%s\n' "$1" "$2" "$(date -u +%F)" >"$_t"
+ printf 'SNI=%s\nSNI_REASON=%s\nSNI_CHOSEN=%s\n' "$1" "$_r" "$(date -u +%F)" >"$_t"
chmod 600 "$_t"
mv -f "$_t" "$SNIFILE"
}
@@ -683,6 +686,7 @@ cmd_status() {
printf ' dest / SNI %s\n' "$SNI"
printf ' %s\n' "${SNI_REASON:-unknown}"
printf ' clients minClientVer=%s\n' "${MIN_CLIENT_VER:-0.0.0}"
+ printf ' identity created %s\n' "${CREATED:-?}"
if [ "$WARP" = 1 ] && [ "$WARP_READY" = 0 ]; then
printf ' egress DIRECT -- WARP was requested but is NOT registered.\n'
@@ -710,6 +714,7 @@ cmd_backup() {
[ -f "$STATE" ] || die "nothing to back up yet"
# sni.chosen is included on purpose: every share link carries `sni=`, so a
# restore that re-picked a different dest would invalidate all of them.
+ # shellcheck disable=SC2046 # word splitting is the point: skip absent files
tar -cz -C "$DATA" \
state.env users.tsv \
$([ -f "$SNIFILE" ] && echo sni.chosen) \
@@ -846,6 +851,15 @@ cmd_selftest() {
_beats bulk 100 900 0 bulk 100 4400 0
_assert "scores never go negative" test "$(sni_score bulk 10 100 1)" = 0
+ # Same injection class as the client's link parser, arriving by environment.
+ _env_rejects() {
+ (SNI=www.cloudflare.com DEST="" PORT=443
+ eval "$1=\$2"
+ validate_config_env) >/dev/null 2>&1 && return 1 || return 0
+ }
+ _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'
+
_sni_rejects() { (sni_write "$1" test) >/dev/null 2>&1 && return 1 || return 0; }
_assert "sni_write rejects a non-hostname" _sni_rejects 'not a host; rm -rf /'
_assert "sni_write rejects apple targets (Xray warns they get the IP blocked)" \
@@ -877,6 +891,29 @@ cmd_selftest() {
echo "selftest passed"
}
+# Everything here is interpolated into the generated JSON. It arrives from the
+# operator rather than from a stranger, but a .env copied off a blog post is not
+# 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() {
+ 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"
+ printf '%s' "${MIN_CLIENT_VER:-0.0.0}" | grep -Eq '^[0-9]{1,3}(\.[0-9]{1,3}){0,2}$' ||
+ die "MIN_CLIENT_VER must look like 0.0.0, got: ${MIN_CLIENT_VER:-}"
+ # Deliberately IPv4:port only, never a hostname: resolving the WARP endpoint
+ # is exactly what produced the random IPv6 failures this design removed.
+ printf '%s' "${WARP_ENDPOINT:-$WARP_ENDPOINT_DEFAULT}" | grep -Eq '^[0-9]{1,3}(\.[0-9]{1,3}){3}:[0-9]{1,5}$' ||
+ die "WARP_ENDPOINT must be an IPv4 address and port, got: ${WARP_ENDPOINT:-}"
+}
+
# --------------------------------------------------------------------- run
banner() {
@@ -931,18 +968,7 @@ cmd_run() {
_picked_now=1
fi
- # Validated before anything else touches it: SNI reaches a JSON document,
- # and a value sourced from a file would otherwise 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"
+ validate_config_env
# The auto-selection already probed and filtered on exactly these criteria,
# so re-running the check would just be a second round-trip.
diff --git a/test.sh b/test.sh
@@ -124,6 +124,23 @@ 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
+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":"'
+
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
|