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

install.sh (30874B)

      1 #!/bin/sh
      2 # transtation installer.
      3 #
      4 #   sh install.sh                          install the server
      5 #   sh install.sh client                   install the client, proxy mode
      6 #   sh install.sh client --tun             install the client, whole-host tunnel
      7 #   sh install.sh client --tun --killswitch   ... and a fail-closed egress filter
      8 #   sh install.sh --uninstall              remove whichever is installed here
      9 #
     10 # `set -eu`, deliberately NOT `-o pipefail`: /bin/sh is dash on Debian and
     11 # Ubuntu -- the primary target -- and `curl | sh` ignores the shebang, so
     12 # pipefail would abort this script on its first line.
     13 #
     14 # Everything of substance lives in functions, and main() is invoked on the very
     15 # last line. If the download is truncated, nothing runs.
     16 set -eu
     17 
     18 VERSION=${TT_VERSION:-v1.0.18}
     19 IMAGE_SERVER=${TT_IMAGE_SERVER:-ghcr.io/sqzer-x/transtation}
     20 # Filled in by the release workflow. A tag is mutable -- the same stolen-token
     21 # compromise we already concede for Xray's .dgst would let someone retag
     22 # v1.0.18, and the hash-verified installer you read would vouch for nothing.
     23 SERVER_DIGEST=${TT_SERVER_DIGEST:-}
     24 
     25 # sing-box ships no checksum file, so these were computed once from the official
     26 # release artifacts and are reviewed as part of this repo. The -musl builds are
     27 # statically linked, so one binary runs on any distribution.
     28 XRAY_VERSION=${TT_XRAY_VERSION:-v26.3.27}
     29 WGCF_VERSION=${TT_WGCF_VERSION:-2.2.32}
     30 SINGBOX_VERSION=${TT_SINGBOX_VERSION:-1.13.16}
     31 SINGBOX_SHA_amd64=9ff0345fde4157a6bdab45a615668d41ccc93f6d0f361108042a48b8a49a9baa
     32 SINGBOX_SHA_arm64=3ea951c68f2eea10fd3ee8f8cc7794c12ccc7405afa99279a79e0b41cb183adf
     33 
     34 # Our copies of third-party binaries live here, never in /usr/local/bin. That
     35 # is where the official Xray installer and hand-rolled setups put theirs, and
     36 # writing there means overwriting somebody's working server -- and then deleting
     37 # it on uninstall. Which is exactly what happened once.
     38 PRIVLIB=/usr/local/lib/transtation
     39 
     40 DIR=${TT_DIR:-/opt/transtation}
     41 URI_DIR=/etc/transtation
     42 RAW=https://raw.githubusercontent.com/sqzer-x/transtation/$VERSION
     43 
     44 say() { printf '%s\n' "$*"; }
     45 # Under `curl | sh` stdin is the script itself, so a bare `read` swallows the
     46 # rest of this file. Under cron or CI there is no /dev/tty at all. Try the
     47 # terminal, fall back to stdin, and treat "neither" as no answer.
     48 ask() {
     49 	printf '%s' "$1"
     50 	REPLY=""
     51 	# `[ -r /dev/tty ]` is not enough: the node exists and looks readable even
     52 	# with no controlling terminal, and opening it then fails with ENXIO and
     53 	# prints to stderr. Try the open for real, suppress it, fall back to stdin.
     54 	if { read -r REPLY </dev/tty; } 2>/dev/null && [ -n "$REPLY" ]; then
     55 		return 0
     56 	fi
     57 	read -r REPLY 2>/dev/null || REPLY=""
     58 }
     59 step() { printf '  %-14s%s\n' "$1" "$2"; }
     60 die() { printf '\ntranstation: %s\n\n' "$*" >&2; exit 1; }
     61 
     62 server_image() { printf '%s:%s%s' "$IMAGE_SERVER" "$VERSION" "${SERVER_DIGEST:+@$SERVER_DIGEST}"; }
     63 
     64 need_root() {
     65 	[ "$(id -u)" = 0 ] || die "this needs root. Re-run as root, or with sudo:
     66     curl -fsSLO $RAW/install.sh && sudo sh install.sh"
     67 }
     68 
     69 check_arch() {
     70 	case "$(uname -m)" in
     71 		x86_64 | amd64 | aarch64 | arm64) ;;
     72 		*) die "unsupported architecture: $(uname -m). transtation ships amd64 and arm64 images only." ;;
     73 	esac
     74 }
     75 
     76 os_id() {
     77 	# shellcheck disable=SC1091
     78 	[ -r /etc/os-release ] && . /etc/os-release && printf '%s' "${ID:-unknown}" || printf 'unknown'
     79 }
     80 
     81 install_docker() {
     82 	if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
     83 		step docker "$(docker --version | cut -d, -f1), compose $(docker compose version --short)"
     84 		return 0
     85 	fi
     86 	say ""
     87 	step docker "not found -- installing"
     88 	case "$(os_id)" in
     89 		alpine)
     90 			# get.docker.com hard-fails on Alpine.
     91 			apk add --no-cache docker docker-cli-compose >/dev/null
     92 			rc-update add docker default >/dev/null 2>&1 || true
     93 			service docker start >/dev/null 2>&1 || true
     94 			;;
     95 		arch | archarm | manjaro | endeavouros)
     96 			# get.docker.com hard-fails on Arch too.
     97 			pacman -S --needed --noconfirm docker docker-compose >/dev/null
     98 			systemctl enable --now docker >/dev/null 2>&1 || true
     99 			;;
    100 		*)
    101 			say "                 NOTE: this runs https://get.docker.com, a ~780-line third-party"
    102 			say "                 script, as root. We do not review it. If you would rather not,"
    103 			say "                 install Docker yourself and run this installer again."
    104 			curl -fsSL https://get.docker.com | sh >/dev/null
    105 			systemctl enable --now docker >/dev/null 2>&1 || true
    106 			;;
    107 	esac
    108 	command -v docker >/dev/null 2>&1 || die "Docker installation failed. Install it yourself and re-run."
    109 	docker compose version >/dev/null 2>&1 || die "the Docker Compose plugin is missing. Install docker-compose-plugin and re-run."
    110 	step docker "$(docker --version | cut -d, -f1), compose $(docker compose version --short)"
    111 	say "                 note: installing Docker sets net.ipv4.ip_forward=1 on this host."
    112 }
    113 
    114 wait_healthy() {
    115 	_name=$1 _limit=${2:-120} _t=0
    116 	while [ "$_t" -lt "$_limit" ]; do
    117 		case "$(docker inspect -f '{{.State.Health.Status}}' "$_name" 2>/dev/null || echo none)" in
    118 			healthy) printf 'healthy (%ss)\n' "$_t"; return 0 ;;
    119 			unhealthy | none) ;;
    120 		esac
    121 		sleep 3
    122 		_t=$((_t + 3))
    123 		printf '.'
    124 	done
    125 	printf 'TIMED OUT (%ss)\n' "$_limit"
    126 	return 1
    127 }
    128 
    129 # --- native server -------------------------------------------------------
    130 
    131 # The two server installs both own /usr/local/bin/transtation and both want the
    132 # same port. Installing one over the other leaves the first running and
    133 # orphaned -- still holding a port, still serving its own identity, no longer
    134 # reachable by the command. Refuse instead.
    135 refuse_other_server() {
    136 	case "$1" in
    137 		container)
    138 			[ -e /etc/systemd/system/transtation.service ] &&
    139 				die "a native server is already installed here.
    140     Remove it first:  sh install.sh --uninstall
    141     Or keep it -- it is the same software."
    142 			;;
    143 		native)
    144 			{ [ -e "$DIR/docker-compose.yml" ] || docker ps -a --format '{{.Names}}' 2>/dev/null | grep -qx transtation; } &&
    145 				die "a containerised server is already installed here.
    146     Remove it first:  sh install.sh --uninstall
    147     Or keep it -- it is the same software."
    148 			;;
    149 	esac
    150 	return 0
    151 }
    152 
    153 
    154 fetch_verified_xray() {
    155 	case "$(uname -m)" in
    156 		x86_64 | amd64) _xz=Xray-linux-64.zip; _wa=amd64 ;;
    157 		aarch64 | arm64) _xz=Xray-linux-arm64-v8a.zip; _wa=arm64 ;;
    158 	esac
    159 	_t=$(mktemp -d)
    160 	_b="https://github.com/XTLS/Xray-core/releases/download/${XRAY_VERSION}"
    161 	curl -fsSL -o "$_t/$_xz" "$_b/$_xz" || { rm -rf "$_t"; die "could not download Xray ${XRAY_VERSION}"; }
    162 	curl -fsSL -o "$_t/$_xz.dgst" "$_b/$_xz.dgst" || { rm -rf "$_t"; die "could not download the Xray checksum"; }
    163 	_exp=$(awk '/^SHA2-256/ {print $2}' "$_t/$_xz.dgst")
    164 	# Same guard as the Dockerfile: an empty expectation makes sha256sum verify
    165 	# nothing at all.
    166 	printf '%s' "$_exp" | grep -Eq '^[0-9a-f]{64}$' ||
    167 		{ rm -rf "$_t"; die "no usable SHA2-256 line in $_xz.dgst"; }
    168 	( cd "$_t" && echo "$_exp  $_xz" | sha256sum -c - >/dev/null 2>&1 ) ||
    169 		{ rm -rf "$_t"; die "Xray checksum mismatch -- refusing to install it"; }
    170 	command -v unzip >/dev/null 2>&1 || install_pkg unzip
    171 	unzip -q -j "$_t/$_xz" xray -d "$_t"
    172 	install -Dm0755 "$_t/xray" "$PRIVLIB/xray"
    173 
    174 	_w="https://github.com/ViRb3/wgcf/releases/download/v${WGCF_VERSION}"
    175 	curl -fsSL -o "$_t/wgcf" "$_w/wgcf_${WGCF_VERSION}_linux_${_wa}" ||
    176 		{ rm -rf "$_t"; die "could not download wgcf"; }
    177 	curl -fsSL "$_w/checksums.txt" | grep " wgcf_${WGCF_VERSION}_linux_${_wa}\$" |
    178 		awk -v f="$_t/wgcf" '{print $1"  "f}' | sha256sum -c - >/dev/null 2>&1 ||
    179 		{ rm -rf "$_t"; die "wgcf checksum mismatch -- refusing to install it"; }
    180 	install -Dm0755 "$_t/wgcf" "$PRIVLIB/wgcf"
    181 	rm -rf "$_t"
    182 	step binaries "xray $("$PRIVLIB/xray" version | head -1 | cut -d' ' -f2) and wgcf ${WGCF_VERSION}, both checksum-verified, in $PRIVLIB"
    183 }
    184 
    185 install_pkg() {
    186 	case "$(os_id)" in
    187 		alpine) apk add --no-cache "$1" >/dev/null 2>&1 ;;
    188 		arch | archarm | manjaro | endeavouros) pacman -S --needed --noconfirm "$1" >/dev/null 2>&1 ;;
    189 		*) DEBIAN_FRONTEND=noninteractive apt-get -y -qq install "$1" >/dev/null 2>&1 ||
    190 			dnf -y -q install "$1" >/dev/null 2>&1 || true ;;
    191 	esac
    192 }
    193 
    194 install_server_native() {
    195 	need_root
    196 	check_arch
    197 	refuse_other_server native
    198 	say ""
    199 	say "  transtation $VERSION -- native install, no container runtime"
    200 	say "  https://github.com/sqzer-x/transtation"
    201 	say ""
    202 	step host "$(os_id) / $(uname -m) / root"
    203 	[ -d /run/systemd/system ] || die "the native install is managed by systemd, which is not running here.
    204     Use the container install instead:  sh install.sh"
    205 	command -v curl >/dev/null 2>&1 || die "curl is required"
    206 
    207 	fetch_verified_xray
    208 	install -Dm0755 "$(server_script)" "$PRIVLIB/server"
    209 
    210 	id -u transtation >/dev/null 2>&1 || useradd --system --no-create-home --shell /usr/sbin/nologin transtation 2>/dev/null ||
    211 		adduser -S -D -H -s /sbin/nologin transtation 2>/dev/null || true
    212 	install -d -o transtation -g transtation -m 0700 /var/lib/transtation
    213 	mkdir -p /etc/transtation
    214 	[ -e /etc/transtation/server.env ] || { : >/etc/transtation/server.env; chmod 600 /etc/transtation/server.env; }
    215 	[ -n "${PORT:-}" ] && ! grep -q '^PORT=' /etc/transtation/server.env &&
    216 		printf 'PORT=%s\n' "$PORT" >>/etc/transtation/server.env
    217 	# Every command has to run as the service user. Run as root, the script would
    218 	# create root-owned files in the state directory and the service -- which is
    219 	# not root -- would stop being able to read its own identity.
    220 	# backup/restore/logs are handled here rather than passed through, so that
    221 	# `transtation <verb>` means the same thing on both install paths -- the
    222 	# container wrapper below implements exactly these three the same way.
    223 	install -Dm0755 /dev/stdin /usr/local/bin/transtation <<-'WRAP'
    224 		#!/bin/sh
    225 		set -eu
    226 		D=/var/lib/transtation
    227 		L=/usr/local/lib/transtation
    228 		asuser() {
    229 		  if [ "$(id -un)" = transtation ]; then
    230 		    env TT_DATA=$D PATH=$L:"$PATH" $L/server "$@"
    231 		  elif command -v runuser >/dev/null 2>&1; then
    232 		    runuser -u transtation -- env TT_DATA=$D PATH=$L:"$PATH" $L/server "$@"
    233 		  else
    234 		    su -s /bin/sh transtation -c "exec env TT_DATA=$D PATH=$L:\$PATH $L/server \"\$@\"" -- sh "$@"
    235 		  fi
    236 		}
    237 		[ "$(id -un)" = transtation ] || [ "$(id -u)" = 0 ] ||
    238 		  { echo "transtation: run this as root or as the transtation user" >&2; exit 1; }
    239 		case "${1:-}" in
    240 		  backup)
    241 		    f=${2:-/root/transtation-backup.tgz}
    242 		    [ -e "$f" ] && { echo "$f exists -- refusing to overwrite" >&2; exit 1; }
    243 		    # root's umask is 022 on every target distro, which would leave a
    244 		    # world-readable tarball of your Reality private key, your WARP private
    245 		    # key and your WARP bearer token.
    246 		    umask 077
    247 		    asuser backup > "$f"
    248 		    echo "backed up to $f (mode 0600): reality key, users, WARP account."
    249 		    ;;
    250 		  restore)
    251 		    f=${2:-}
    252 		    [ -n "$f" ] && [ -r "$f" ] || { echo "usage: transtation restore <backup.tgz>" >&2; exit 1; }
    253 		    tar tzf "$f" 2>/dev/null | grep -q '^state.env$' ||
    254 		      { echo "$f does not look like a transtation backup" >&2; exit 1; }
    255 		    systemctl is-active --quiet transtation &&
    256 		      { echo "stop the server first:  systemctl stop transtation" >&2; exit 1; }
    257 		    # Unpacked as root, then handed back: the service user cannot create
    258 		    # files it does not already own, and tar would restore archived uids
    259 		    # that need not match this host's.
    260 		    tar xzf "$f" -C "$D"
    261 		    chown -R transtation:transtation "$D"
    262 		    echo "restored. start it again:  systemctl start transtation"
    263 		    ;;
    264 		  logs) shift; exec journalctl -u transtation "$@" ;;
    265 		  *) asuser "$@" ;;
    266 		esac
    267 	WRAP
    268 	step config "wrote /etc/transtation/server.env, data in /var/lib/transtation"
    269 
    270 	cat >/etc/systemd/system/transtation.service <<-'EOF'
    271 		[Unit]
    272 		Description=transtation server
    273 		After=network-online.target
    274 		Wants=network-online.target
    275 
    276 		[Service]
    277 		Type=simple
    278 		User=transtation
    279 		Group=transtation
    280 		Environment=TT_DATA=/var/lib/transtation
    281 		Environment=PATH=/usr/local/lib/transtation:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
    282 		EnvironmentFile=-/etc/transtation/server.env
    283 		ExecStart=/usr/local/lib/transtation/server run
    284 		Restart=on-failure
    285 		RestartSec=3s
    286 		# The container needs no capability because dockerd performs the
    287 		# privileged bind. Here nothing is in front of us, so we keep exactly
    288 		# one -- and nothing else is even reachable.
    289 		AmbientCapabilities=CAP_NET_BIND_SERVICE
    290 		CapabilityBoundingSet=CAP_NET_BIND_SERVICE
    291 		NoNewPrivileges=true
    292 		ProtectSystem=strict
    293 		ProtectHome=true
    294 		PrivateTmp=true
    295 		PrivateDevices=true
    296 		ProtectKernelTunables=true
    297 		ProtectControlGroups=true
    298 		RestrictSUIDSGID=true
    299 		ReadWritePaths=/var/lib/transtation
    300 
    301 		[Install]
    302 		WantedBy=multi-user.target
    303 	EOF
    304 	systemctl daemon-reload
    305 	printf '  %-14s' start
    306 	systemctl enable --now transtation.service >/dev/null 2>&1 || true
    307 	_i=0
    308 	while [ "$_i" -lt 40 ]; do
    309 		/usr/local/bin/transtation health >/dev/null 2>&1 && break
    310 		_i=$((_i + 1)); printf '.'; sleep 3
    311 	done
    312 	systemctl is-active --quiet transtation || {
    313 		say "FAILED"
    314 		say "  journalctl -u transtation -n 40"
    315 		exit 1
    316 	}
    317 	say "running"
    318 
    319 	printf '  %-14s' verify
    320 	/usr/local/bin/transtation verify || {
    321 		say ""
    322 		say "  The server is running but clients cannot complete the REALITY handshake."
    323 		say "      echo 'SNI=dl.google.com' >> /etc/transtation/server.env"
    324 		say "      systemctl restart transtation && transtation verify"
    325 		exit 1
    326 	}
    327 	say ""
    328 	/usr/local/bin/transtation status || true
    329 	cat <<-EOF
    330 		  DO THIS NOW -- it is the only irreplaceable thing on this box:
    331 		      transtation backup
    332 
    333 		  Your link:   transtation show
    334 		  Add a user:  transtation user add alice
    335 		  Logs:        journalctl -u transtation -f
    336 		  Uninstall:   sh install.sh --uninstall
    337 
    338 		  >> If a client cannot connect, open the port in your provider's firewall
    339 		     or security group. The healthcheck only proves OUTBOUND traffic works.
    340 
    341 	EOF
    342 }
    343 
    344 server_script() {
    345 	_d=$(dirname -- "$0" 2>/dev/null || echo .)
    346 	if [ -r "$_d/server/transtation" ]; then
    347 		printf '%s' "$_d/server/transtation"
    348 		return 0
    349 	fi
    350 	_f=$(mktemp)
    351 	curl -fsSL "$RAW/server/transtation" -o "$_f" || die "could not fetch the server script from $RAW"
    352 	printf '%s' "$_f"
    353 }
    354 
    355 # ------------------------------------------------------ server (container)
    356 
    357 install_server() {
    358 	need_root
    359 	check_arch
    360 	refuse_other_server container
    361 	say ""
    362 	say "  transtation $VERSION -- your own VLESS + REALITY proxy"
    363 	say "  https://github.com/sqzer-x/transtation"
    364 	say ""
    365 	step host "$(os_id) / $(uname -m) / root"
    366 	install_docker
    367 
    368 	mkdir -p "$DIR"
    369 	if [ -f "$DIR/docker-compose.yml" ]; then
    370 		step config "already present at $DIR -- reusing it (this is an upgrade, not a reinstall)"
    371 	else
    372 		cat >"$DIR/docker-compose.yml" <<-EOF
    373 			services:
    374 			  proxy:
    375 			    image: $(server_image)
    376 			    container_name: transtation
    377 			    restart: unless-stopped
    378 			    ports: ["\${PORT:-443}:8443"]
    379 			    volumes: ["transtation-data:/data"]
    380 			    env_file: .env
    381 			    # Hardening. The process already holds zero effective capabilities
    382 			    # (verified: CapEff 0000000000000000), so these mostly shrink what a
    383 			    # future change could quietly re-acquire.
    384 			    cap_drop: [ALL]
    385 			    security_opt: ["no-new-privileges:true"]
    386 			    read_only: true
    387 			    tmpfs: ["/tmp:rw,noexec,nosuid,size=16m"]
    388 			    logging:
    389 			      driver: json-file
    390 			      options: { max-size: "10m", max-file: "3" }
    391 			    # Do NOT add cap_add: NET_ADMIN or devices: /dev/net/tun here.
    392 			    # Both BREAK the WARP outbound. See README, "Capabilities".
    393 			volumes:
    394 			  transtation-data:
    395 			    # Pinned. Without an explicit name Docker Compose prefixes it with
    396 			    # the project name, which comes from the directory -- so the volume
    397 			    # would be called something else on every host and every documented
    398 			    # backup, restore and uninstall command would silently address a
    399 			    # volume that does not exist.
    400 			    name: transtation-data
    401 		EOF
    402 		: >"$DIR/.env"
    403 		chmod 600 "$DIR/.env"
    404 		# If PORT came from the environment, write it down. Compose would honour
    405 		# it for the published port while the container -- which reads only
    406 		# env_file -- would still think it is 443, and the share link would name
    407 		# the wrong port.
    408 		[ -n "${PORT:-}" ] && printf 'PORT=%s\n' "$PORT" >>"$DIR/.env"
    409 		step config "wrote $DIR/docker-compose.yml and $DIR/.env"
    410 	fi
    411 
    412 	install_wrapper
    413 	step wrapper "installed /usr/local/bin/transtation"
    414 
    415 	printf '  %-14s' image
    416 	docker pull -q "$(server_image)" >/dev/null 2>&1 || true
    417 	# Resolve the tag to the digest it points at *now* and pin the compose file
    418 	# to that. A tag is mutable; once this is written, `docker compose up` can
    419 	# only ever start the exact image this install verified. Rewritten on every
    420 	# run so re-running the installer is still how you upgrade.
    421 	_ref=$(docker image inspect --format '{{if .RepoDigests}}{{index .RepoDigests 0}}{{end}}' "$(server_image)" 2>/dev/null)
    422 	case "$_ref" in
    423 		*@sha256:*)
    424 			sed -i "s|^\( *image: \).*|\1$_ref|" "$DIR/docker-compose.yml"
    425 			say "pinned to ${_ref#*@}"
    426 			;;
    427 		*)
    428 			say "pulled $(server_image) (no digest available; the compose file pins by tag)"
    429 			;;
    430 	esac
    431 
    432 	printf '  %-14s' start
    433 	docker compose -f "$DIR/docker-compose.yml" --project-directory "$DIR" up -d >/dev/null 2>&1
    434 	if ! wait_healthy transtation 120; then
    435 		say ""
    436 		say "  The container is up but not healthy yet. Most common causes:"
    437 		say "    - the WARP tunnel is not passing traffic (some providers block outbound"
    438 		say "      UDP/2408 even though registration, which is TCP, succeeded). Fix:"
    439 		say "        sed -i 's/^WARP=1/WARP=0/' $DIR/.env || echo WARP=0 >> $DIR/.env"
    440 		say "        cd $DIR && docker compose up -d"
    441 		say "    - no outbound HTTPS from this host at all."
    442 		say ""
    443 		say "  Diagnose with:  transtation status   and   transtation logs"
    444 		exit 1
    445 	fi
    446 
    447 	# The healthcheck proves egress, but it goes through a loopback inbound and
    448 	# never touches REALITY -- so a dest whose certificate chain is too large
    449 	# produces a permanently "healthy" server that no client can connect to.
    450 	# This is the only check that catches it.
    451 	printf '  %-14s' verify
    452 	if transtation verify; then :; else
    453 		say ""
    454 		say "  The server is running but clients cannot complete the REALITY handshake."
    455 		say "  Fix the dest and try again:"
    456 		say "      echo 'SNI=dl.google.com' >> $DIR/.env"
    457 		say "      cd $DIR && docker compose up -d && transtation verify"
    458 		exit 1
    459 	fi
    460 
    461 	say ""
    462 	transtation status || true
    463 	_port=$(sed -n 's/^PORT=//p' "$DIR/.env" 2>/dev/null | tail -1)
    464 	cat <<-EOF
    465 		  DO THIS NOW -- it is the only irreplaceable thing on this box:
    466 		      transtation backup
    467 
    468 		  Your link:   transtation show
    469 		  Add a user:  transtation user add alice
    470 		  Uninstall:   sh install.sh --uninstall
    471 
    472 		  >> If a client cannot connect, open TCP ${_port:-443} in your provider's
    473 		     firewall or security group. The healthcheck above only proves OUTBOUND
    474 		     traffic works; inbound cannot be verified from this host. Note that
    475 		     Docker's published ports bypass ufw in both directions.
    476 
    477 	EOF
    478 }
    479 
    480 install_wrapper() {
    481 	cat >/usr/local/bin/transtation <<-EOF
    482 		#!/bin/sh
    483 		set -eu
    484 		C="docker compose -f $DIR/docker-compose.yml --project-directory $DIR"
    485 		case "\${1:-}" in
    486 		  backup)
    487 		    f=\${2:-/root/transtation-backup.tgz}
    488 		    [ -e "\$f" ] && { echo "\$f exists -- refusing to overwrite" >&2; exit 1; }
    489 		    # root's umask is 022 on every target distro, which would make a
    490 		    # world-readable tarball of your Reality private key, your WARP private
    491 		    # key and your WARP bearer token.
    492 		    umask 077
    493 		    \$C exec -T proxy transtation backup > "\$f"
    494 		    echo "backed up to \$f (mode 0600): reality key, users, WARP account."
    495 		    ;;
    496 		  restore)
    497 		    f=\${2:-}
    498 		    [ -n "\$f" ] && [ -r "\$f" ] || { echo "usage: transtation restore <backup.tgz>" >&2; exit 1; }
    499 		    tar tzf "\$f" 2>/dev/null | grep -q '^state.env$' || { echo "\$f does not look like a transtation backup" >&2; exit 1; }
    500 		    if \$C ps -q proxy 2>/dev/null | grep -q .; then
    501 		      echo "stop the server first:  cd $DIR && docker compose down" >&2; exit 1
    502 		    fi
    503 		    # --user 0 so it can read a 0600 archive; the transtation image so that
    504 		    # Docker seeds a fresh volume with the right /data ownership; tar then
    505 		    # restores each file's archived uid.
    506 		    docker run --rm --user 0 \\
    507 		      -v transtation-data:/data \\
    508 		      -v "\$(cd "\$(dirname "\$f")" && pwd)":/b:ro \\
    509 		      --entrypoint sh $(server_image) \\
    510 		      -c "tar xzf /b/\$(basename "\$f") -C /data"
    511 		    echo "restored. start it again:  cd $DIR && docker compose up -d"
    512 		    ;;
    513 		  logs) shift; exec \$C logs "\$@" ;;
    514 		  up|down|pull|restart) exec \$C "\$@" ;;
    515 		  *) exec \$C exec proxy transtation "\$@" ;;
    516 		esac
    517 	EOF
    518 	chmod 0755 /usr/local/bin/transtation
    519 }
    520 
    521 # ------------------------------------------------------------------ client
    522 
    523 # Run from a git checkout, or piped from the internet -- fetch what is missing.
    524 client_script() {
    525 	_d=$(dirname -- "$0" 2>/dev/null || echo .)
    526 	if [ -r "$_d/client/transtation-client" ]; then
    527 		printf '%s' "$_d/client/transtation-client"
    528 		return 0
    529 	fi
    530 	_f=$(mktemp)
    531 	curl -fsSL "$RAW/client/transtation-client" -o "$_f" ||
    532 		die "could not fetch the client from $RAW"
    533 	printf '%s' "$_f"
    534 }
    535 
    536 install_panic() {
    537 	install -Dm0755 /dev/stdin /usr/local/sbin/transtation-panic <<-'EOF'
    538 		#!/bin/sh
    539 		set -u
    540 		TUN_IFACE=${TUN_IFACE:-tt0}
    541 		nft delete table inet transtation 2>/dev/null
    542 		nft delete table ip6 transtation 2>/dev/null
    543 		for f in -4 -6; do
    544 		  ip $f rule list 2>/dev/null | sed -n 's/^\(90[0-9][0-9]\):.*/\1/p' | while read -r p; do
    545 		    ip $f rule del priority "$p" 2>/dev/null
    546 		  done
    547 		done
    548 		ip route flush table 2022 2>/dev/null
    549 		ip -6 route flush table 2022 2>/dev/null
    550 		ip link del "$TUN_IFACE" 2>/dev/null
    551 		systemctl stop transtation-client 2>/dev/null
    552 		echo "killswitch removed, policy routing flushed. You are back on direct egress."
    553 	EOF
    554 }
    555 
    556 install_sing_box() {
    557 	command -v sing-box >/dev/null 2>&1 && [ -z "${TT_FORCE_SINGBOX:-}" ] && {
    558 		step sing-box "$(sing-box version | head -1)  (already installed)"
    559 		SINGBOX=$(command -v sing-box)
    560 		return 0
    561 	}
    562 	case "$(uname -m)" in
    563 		x86_64 | amd64) _a=amd64; _sum=$SINGBOX_SHA_amd64 ;;
    564 		aarch64 | arm64) _a=arm64; _sum=$SINGBOX_SHA_arm64 ;;
    565 	esac
    566 	_n="sing-box-${SINGBOX_VERSION}-linux-${_a}-musl"
    567 	_t=$(mktemp -d)
    568 	curl -fsSL -o "$_t/sb.tgz" \
    569 		"https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VERSION}/${_n}.tar.gz" ||
    570 		die "could not download sing-box ${SINGBOX_VERSION}"
    571 	echo "$_sum  $_t/sb.tgz" | sha256sum -c - >/dev/null 2>&1 ||
    572 		{ rm -rf "$_t"; die "sing-box checksum mismatch -- refusing to install it"; }
    573 	tar xzf "$_t/sb.tgz" -C "$_t"
    574 	install -Dm0755 "$_t/$_n/sing-box" "$PRIVLIB/sing-box"
    575 	rm -rf "$_t"
    576 	SINGBOX=$PRIVLIB/sing-box
    577 	step sing-box "$($SINGBOX version | head -1)  (verified, statically linked)"
    578 }
    579 
    580 install_client() {
    581 	need_root
    582 	check_arch
    583 	_mode=tun _ks=0 _uri=""
    584 	for a in "$@"; do
    585 		case "$a" in
    586 			--tun) _mode=tun ;;
    587 			--proxy) _mode=proxy ;;
    588 			--killswitch) _ks=1 ;;
    589 			vless://*) _uri=$a ;;
    590 			*) die "unknown option: $a" ;;
    591 		esac
    592 	done
    593 
    594 	say ""
    595 	say "  transtation client $VERSION"
    596 	say ""
    597 
    598 	if [ -z "$_uri" ] && [ ! -r "$URI_DIR/uri" ]; then
    599 		say "  paste the link from your server (run 'transtation show' there):"
    600 		ask '  > '
    601 		_uri=$REPLY
    602 		[ -n "$_uri" ] || die "no link given. Pass it as an argument instead:
    603     sh install.sh client 'vless://...'"
    604 	fi
    605 	if [ -n "$_uri" ]; then
    606 		mkdir -p "$URI_DIR"
    607 		umask 077
    608 		printf '%s\n' "$_uri" >"$URI_DIR/uri"
    609 		chmod 600 "$URI_DIR/uri"
    610 		step link "stored at $URI_DIR/uri (0600)"
    611 	else
    612 		step link "reusing $URI_DIR/uri"
    613 	fi
    614 
    615 	install_sing_box
    616 	install -Dm0755 "$(client_script)" /usr/local/bin/transtation-client
    617 	install_panic
    618 	step scripts "installed /usr/local/bin/transtation-client and /usr/local/sbin/transtation-panic"
    619 
    620 	# Taken before the tunnel exists, so the self-test can tell "the tunnel is
    621 	# carrying traffic" from "a request happened to succeed". Without this the
    622 	# check passes while routing is still settling and the answer came out the
    623 	# ordinary way -- observed, and reported as success.
    624 	_before=$(curl -fsS --max-time 8 https://api.ipify.org 2>/dev/null || true)
    625 
    626 	[ -d /run/systemd/system ] || die "this needs systemd to manage the client.
    627     Without it, run it yourself:
    628       MODE=$_mode /usr/local/bin/transtation-client run"
    629 
    630 	cat >/etc/systemd/system/transtation-client.service <<-EOF
    631 		[Unit]
    632 		Description=transtation client ($_mode)
    633 		After=network-online.target
    634 		Wants=network-online.target
    635 
    636 		[Service]
    637 		Type=simple
    638 		Environment=MODE=$_mode
    639 		Environment=TT_SINGBOX=$SINGBOX
    640 		${DIRECT_SUFFIXES:+Environment=DIRECT_SUFFIXES=$DIRECT_SUFFIXES}
    641 		ExecStart=/usr/local/bin/transtation-client run
    642 		Restart=on-failure
    643 		RestartSec=3s
    644 		RuntimeDirectory=transtation
    645 		RuntimeDirectoryMode=0700
    646 		# Root is needed to own the routing table in tun mode; the bounding set
    647 		# keeps that to what sing-box actually uses.
    648 		CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_DAC_READ_SEARCH
    649 		NoNewPrivileges=true
    650 		ProtectHome=true
    651 		ProtectSystem=strict
    652 		ReadWritePaths=/run/transtation
    653 
    654 		[Install]
    655 		WantedBy=multi-user.target
    656 	EOF
    657 	systemctl daemon-reload
    658 	systemctl enable --now transtation-client.service >/dev/null 2>&1 || true
    659 	step service "transtation-client.service enabled ($_mode mode)"
    660 
    661 	if [ "$_ks" = 1 ]; then
    662 		[ "$_mode" = tun ] || die "--killswitch only makes sense with the whole-host tunnel"
    663 		curl -fsSL "$RAW/host/transtation-killswitch" -o /usr/local/sbin/transtation-killswitch ||
    664 			die "could not fetch the killswitch script from $RAW"
    665 		chmod 0755 /usr/local/sbin/transtation-killswitch
    666 		/usr/local/sbin/transtation-killswitch on
    667 	fi
    668 
    669 	printf '  %-14s' self-test
    670 	_probe=""
    671 	_n=0
    672 	while [ "$_n" -lt 15 ]; do
    673 		if [ "$_mode" = tun ]; then
    674 			_trace=$(curl -fsS --max-time 8 https://cloudflare.com/cdn-cgi/trace 2>/dev/null || true)
    675 			_ip=$(printf '%s' "$_trace" | sed -n 's/^ip=//p')
    676 			# The address has to have MOVED. A reply on its own only proves the
    677 			# network works, which it did before any of this was installed.
    678 			if [ -n "$_ip" ] && [ -n "$_before" ] && [ "$_ip" != "$_before" ]; then
    679 				_probe=$(printf '%s' "$_trace" | grep -E '^(ip|warp|colo)=' | tr '\n' ' ')
    680 			elif [ -n "$_ip" ] && [ -z "$_before" ]; then
    681 				_probe="$(printf '%s' "$_trace" | grep -E '^(ip|warp|colo)=' | tr '\n' ' ')(could not compare: no address before install)"
    682 			fi
    683 		else
    684 			_probe=$(curl -fsS --max-time 8 --socks5-hostname 127.0.0.1:1080 https://cloudflare.com/cdn-cgi/trace 2>/dev/null |
    685 				grep -E '^(ip|warp|colo)=' | tr '\n' ' ')
    686 		fi
    687 		[ -n "$_probe" ] && break
    688 		_n=$((_n + 1))
    689 		sleep 2
    690 	done
    691 	if [ -n "$_probe" ]; then
    692 		[ "$_mode" = tun ] && say "$_probe" || say "through the proxy: $_probe"
    693 	else
    694 		[ "$_mode" = tun ] && say "STILL YOUR OWN ADDRESS (${_before:-unknown})" || say "NO TRAFFIC"
    695 		say ""
    696 		say "  The client is running but traffic is not going through the tunnel. Look at:"
    697 		say "      journalctl -u transtation-client -n 40"
    698 		[ "$_mode" = tun ] && say "      sudo transtation-panic     # undo the tunnel and killswitch"
    699 		exit 1
    700 	fi
    701 
    702 	if [ "$_mode" = tun ]; then
    703 		cat <<-'EOF'
    704 
    705 			  Every program on this machine now goes through the tunnel, with no
    706 			  per-program configuration.
    707 
    708 			  stop:    sudo systemctl stop transtation-client
    709 			  logs:    journalctl -u transtation-client -f
    710 			  panic:   sudo transtation-panic        # undo everything, needs no network
    711 
    712 		EOF
    713 	else
    714 		cat <<-'EOF'
    715 
    716 			  THIS DOES NOT PUT YOUR MACHINE BEHIND THE PROXY. It opens a proxy on
    717 			  127.0.0.1:1080 and nothing else; each program has to be pointed at it.
    718 			  Re-run with --tun for the whole machine.
    719 
    720 			  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
    721 			    Those apply to that shell and what it starts, and nothing else.
    722 
    723 		EOF
    724 	fi
    725 }
    726 
    727 # --------------------------------------------------------------- uninstall
    728 
    729 uninstall() {
    730 	need_root
    731 	if [ -d "$DIR" ]; then
    732 		docker compose -f "$DIR/docker-compose.yml" --project-directory "$DIR" down >/dev/null 2>&1 || true
    733 		rm -f "$DIR/docker-compose.yml" "$DIR/.env" /usr/local/bin/transtation
    734 		rmdir "$DIR" 2>/dev/null || true
    735 		say "server removed."
    736 		ask 'also delete the data volume? That destroys your Reality key and every issued link. [y/N] '
    737 		say ""
    738 		case "$REPLY" in
    739 			y | Y)
    740 				if docker volume rm transtation-data >/dev/null 2>&1; then
    741 					say "volume deleted."
    742 				else
    743 					say "could not delete the volume (in use, or already gone):"
    744 					docker volume ls --format '  {{.Name}}' | grep transtation || say "  none found"
    745 				fi
    746 				;;
    747 			*) say "volume kept: 'docker volume ls | grep transtation-data'" ;;
    748 		esac
    749 	fi
    750 	if [ -e /etc/systemd/system/transtation.service ]; then
    751 		systemctl disable --now transtation.service >/dev/null 2>&1 || true
    752 		rm -f /etc/systemd/system/transtation.service
    753 		systemctl daemon-reload 2>/dev/null || true
    754 		rm -rf /usr/local/bin/transtation "$PRIVLIB"
    755 		say "native server removed. Its data is still in /var/lib/transtation."
    756 		ask 'also delete /var/lib/transtation? That destroys your Reality key and every issued link. [y/N] '
    757 		say ""
    758 		case "$REPLY" in
    759 			y | Y) rm -rf /var/lib/transtation; say "data deleted." ;;
    760 			*) say "data kept in /var/lib/transtation" ;;
    761 		esac
    762 	fi
    763 	if [ -e /etc/systemd/system/transtation-client.service ]; then
    764 		systemctl disable --now transtation-client.service >/dev/null 2>&1 || true
    765 		rm -f /etc/systemd/system/transtation-client.service
    766 		systemctl daemon-reload 2>/dev/null || true
    767 		rm -f /usr/local/bin/transtation-client
    768 		rm -f "$PRIVLIB/sing-box"
    769 		rmdir "$PRIVLIB" 2>/dev/null || true
    770 		say "client removed."
    771 	fi
    772 	rm -f /usr/local/sbin/transtation-panic
    773 	say "done. $URI_DIR was left in place; remove it yourself if you are finished with it."
    774 }
    775 
    776 main() {
    777 	# `shift` with no positional parameters is a special-builtin error, and a
    778 	# POSIX shell exits on those -- `|| true` does not save you. dash is
    779 	# /bin/sh on Debian and Ubuntu, so `sh install.sh` with no arguments used
    780 	# to die here, silently, on the primary target. bash (Arch's /bin/sh) is
    781 	# forgiving, which is exactly why local testing never saw it.
    782 	_cmd=${1:-server}
    783 	if [ $# -gt 0 ]; then shift; fi
    784 	case "$_cmd" in
    785 		server)
    786 			case "${1:-}" in
    787 				--native) install_server_native ;;
    788 				'') install_server ;;
    789 				*) die "unknown option: $1 (expected --native)" ;;
    790 			esac
    791 			;;
    792 		--native) install_server_native ;;
    793 		client) install_client "$@" ;;
    794 		--uninstall | uninstall) uninstall ;;
    795 		-h | --help)
    796 			sed -n '2,10p' "$0" 2>/dev/null || say "see https://github.com/sqzer-x/transtation"
    797 			;;
    798 		*) die "unknown command: $_cmd (expected: server, client, --uninstall)" ;;
    799 	esac
    800 }
    801 
    802 main "$@"