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

client/transtation-client (13022B)

      1 #!/bin/sh
      2 # transtation-client -- connect this host to a transtation server.
      3 #
      4 #   MODE=tun    (default)  the whole machine's traffic goes through the tunnel
      5 #   MODE=proxy             a local SOCKS5 + HTTP proxy, nothing captured
      6 #
      7 # This runs on the host, not in a container. Capturing a machine's traffic means
      8 # owning its routing table, and a container that has been given --network host
      9 # and CAP_NET_ADMIN to do that is not isolating anything -- it is a package
     10 # format with extra failure modes. Phones and desktops cannot run one at all.
     11 # So: a static binary, a rendered config and a systemd unit.
     12 #
     13 # The share link is a credential. It is read from a file, and the generated
     14 # config is written under /run so it never touches the disk.
     15 set -eu
     16 
     17 URI_FILE=${TT_URI_FILE:-/etc/transtation/uri}
     18 RUN_DIR=${TT_RUN_DIR:-/run/transtation}
     19 CONF="$RUN_DIR/config.json"
     20 SINGBOX=${TT_SINGBOX:-sing-box}
     21 TUN_IFACE=tt0
     22 TUN_ADDR=172.19.0.1/30
     23 SOCKS_PORT=${SOCKS_PORT:-1080}
     24 
     25 die() { printf 'transtation-client: %s\n' "$*" >&2; exit 1; }
     26 # Anything taken from a share link and quoted back at the operator goes through
     27 # this first. A link is attacker-supplied, error messages land on a terminal,
     28 # and raw escape sequences let a crafted link paint whatever it likes there.
     29 q() { printf '%s' "$1" | tr -dc '[:print:]' | cut -c1-64; }
     30 warn() { printf 'transtation-client: %s\n' "$*" >&2; }
     31 
     32 # --------------------------------------------------------------- share link
     33 
     34 parse_uri() {
     35 	_u=$1
     36 	case "$_u" in
     37 		vless://*) ;;
     38 		*) die "not a vless:// link. Get one from your server with: transtation show" ;;
     39 	esac
     40 	_rest=${_u#vless://}
     41 	_rest=${_rest%%#*}
     42 	QUERY=""
     43 	case "$_rest" in *\?*)
     44 		QUERY=${_rest#*\?}
     45 		_rest=${_rest%%\?*}
     46 		;;
     47 	esac
     48 	case "$_rest" in *@*) ;; *) die "link has no '@' -- it is truncated" ;; esac
     49 	UUID=${_rest%%@*}
     50 	_hp=${_rest#*@}
     51 	case "$_hp" in
     52 		\[*\]:*)
     53 			HOST=${_hp%%]*}
     54 			HOST=${HOST#[}
     55 			PORT=${_hp##*]:}
     56 			;;
     57 		*:*)
     58 			HOST=${_hp%%:*}
     59 			PORT=${_hp##*:}
     60 			;;
     61 		*) die "link has no port" ;;
     62 	esac
     63 
     64 	SNI=""; PBK=""; SID=""; FP=""; FLOW=""
     65 	_q=$QUERY
     66 	while [ -n "$_q" ]; do
     67 		_kv=${_q%%&*}
     68 		case "$_q" in *\&*) _q=${_q#*&} ;; *) _q="" ;; esac
     69 		_k=${_kv%%=*}
     70 		_v=${_kv#*=}
     71 		case "$_k" in
     72 			sni | serverName) SNI=$_v ;;
     73 			pbk | publicKey) PBK=$_v ;;
     74 			sid | shortId) SID=$_v ;;
     75 			fp | fingerprint) FP=$_v ;;
     76 			flow) FLOW=$_v ;;
     77 		esac
     78 	done
     79 }
     80 
     81 validate() {
     82 	# Trust boundary: every one of these values is interpolated into a JSON
     83 	# document below. Validate before, not after.
     84 	printf '%s' "$UUID" | grep -Eiq '^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$' ||
     85 		die "bad uuid in link"
     86 	[ "${#PBK}" -eq 43 ] ||
     87 		die "bad pbk: expected 43 characters, got ${#PBK}. Reissue the link with 'transtation show'."
     88 	printf '%s' "$PBK" | grep -Eq '^[A-Za-z0-9_-]{43}$' || die "pbk is not base64url"
     89 	printf '%s' "$SID" | grep -Eq '^([0-9a-fA-F]{2}){1,8}$' ||
     90 		die "bad sid: expected 2-16 hex characters, got '$(q "$SID")'"
     91 	printf '%s' "$SNI" | grep -Eq '^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$' || die "bad or missing sni: '$(q "$SNI")'"
     92 	[ -n "$HOST" ] || die "link has no server address"
     93 	printf '%s' "$PORT" | grep -Eq '^[0-9]{1,5}$' && [ "$PORT" -ge 1 ] && [ "$PORT" -le 65535 ] ||
     94 		die "bad port: $(q "$PORT")"
     95 	# sing-box hard-errors with "uTLS is required by reality client" if the
     96 	# fingerprint is absent, where Xray-based clients would tolerate it.
     97 	FP=${FP:-chrome}
     98 	FLOW=${FLOW:-xtls-rprx-vision}
     99 	# ALLOWLISTS, not pattern checks. These two land inside a JSON document, and
    100 	# a link is something somebody hands you -- in a chat, on a forum, as a QR
    101 	# code. Left open, `fp=chrome"},"insecure":true,"utls":{"enabled":true,
    102 	# "fingerprint":"chrome` renders a config that sing-box happily accepts,
    103 	# which is how a "friendly" link turns into someone else's server with
    104 	# certificate checking switched off.
    105 	case "$FLOW" in
    106 		xtls-rprx-vision | none | '') ;;
    107 		*) die "unsupported flow in link: $(q "$FLOW") (expected xtls-rprx-vision)" ;;
    108 	esac
    109 	[ "$FLOW" = none ] && FLOW=""
    110 	case "$FP" in
    111 		chrome | firefox | edge | safari | ios | android | random | randomized | 360 | qq) ;;
    112 		*) die "unsupported uTLS fingerprint in link: $(q "$FP")" ;;
    113 	esac
    114 
    115 	# Operator-supplied, but the same class of hole and just as cheap to close.
    116 	printf '%s' "$SOCKS_PORT" | grep -Eq '^[0-9]{1,5}$' &&
    117 		[ "$SOCKS_PORT" -ge 1 ] && [ "$SOCKS_PORT" -le 65535 ] ||
    118 		die "SOCKS_PORT must be 1-65535, got: $(q "$SOCKS_PORT")"
    119 	if [ -n "${DIRECT_SUFFIXES:-}" ]; then
    120 		for _d in $(printf '%s' "$DIRECT_SUFFIXES" | tr ',' ' '); do
    121 			printf '%s' "$_d" | grep -Eq '^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$' ||
    122 				die "DIRECT_SUFFIXES entry is not a domain suffix: $(q "$_d")"
    123 		done
    124 	fi
    125 
    126 	# Resolve once, here, so the tunnel's own resolver never has to look up the
    127 	# server name mid-flight -- and so the killswitch's bootstrap rule stays
    128 	# valid for as long as the tunnel does.
    129 	case "$HOST" in
    130 		*[!0-9.]*)
    131 			_ip=$(resolve4 "$HOST") || true
    132 			[ -n "$_ip" ] || die "cannot resolve $(q "$HOST") -- check this host's DNS, or put the server's IP in the link"
    133 			printf 'resolved %s -> %s\n' "$HOST" "$_ip"
    134 			SERVER_IP=$_ip
    135 			;;
    136 		*) SERVER_IP=$HOST ;;
    137 	esac
    138 }
    139 
    140 # getent comes from musl-utils and nslookup from busybox. Try both rather than
    141 # assuming which one a given base image happens to carry.
    142 resolve4() {
    143 	if command -v getent >/dev/null 2>&1; then
    144 		_r=$(getent ahostsv4 "$1" 2>/dev/null | awk 'NR==1 {print $1}')
    145 		[ -n "$_r" ] && { printf '%s' "$_r"; return 0; }
    146 	fi
    147 	nslookup "$1" 2>/dev/null |
    148 		awk '/^Address:/ && $2 ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/ { print $2; exit }'
    149 }
    150 
    151 # ------------------------------------------------------------------ configs
    152 
    153 json_suffix_array() {
    154 	# "a.com,b.com" -> "\"a.com\", \"b.com\"" ; empty input -> empty output
    155 	printf '%s' "${1:-}" | tr ',' '\n' |
    156 		sed 's/^[[:space:]]*//;s/[[:space:]]*$//' |
    157 		awk 'NF { printf "%s\"%s\"", (n++ ? ", " : ""), $0 }'
    158 }
    159 
    160 vless_outbound() {
    161 	if [ -n "$FLOW" ]; then _flow_kv="\"flow\": \"$FLOW\","; else _flow_kv=""; fi
    162 	cat <<-EOF
    163 		    {
    164 		      "type": "vless",
    165 		      "tag": "proxy",
    166 		      "server": "$SERVER_IP",
    167 		      "server_port": $PORT,
    168 		      "uuid": "$UUID",
    169 		      $_flow_kv
    170 		      "packet_encoding": "xudp",
    171 		      "tls": {
    172 		        "enabled": true,
    173 		        "server_name": "$SNI",
    174 		        "utls": { "enabled": true, "fingerprint": "$FP" },
    175 		        "reality": { "enabled": true, "public_key": "$PBK", "short_id": "$SID" }
    176 		      }
    177 		    }
    178 	EOF
    179 }
    180 
    181 render_proxy() {
    182 	# No "dns" and no "route" block on purpose. Adding DNS configuration here
    183 	# makes leaks MORE likely, not fewer: what actually prevents them is the
    184 	# caller using socks5h:// so the proxy resolves, plus the server address
    185 	# already being a literal IP.
    186 	#
    187 	# Loopback only. This runs on the host, so binding anything wider would put
    188 	# an open proxy on the network.
    189 	cat <<-EOF
    190 		{
    191 		  "log": { "level": "warn" },
    192 		  "inbounds": [
    193 		    {
    194 		      "type": "mixed",
    195 		      "tag": "in",
    196 		      "listen": "127.0.0.1",
    197 		      "listen_port": $SOCKS_PORT
    198 		    }
    199 		  ],
    200 		  "outbounds": [
    201 		$(vless_outbound)
    202 		  ]
    203 		}
    204 	EOF
    205 }
    206 
    207 render_tun() {
    208 	_direct=$(json_suffix_array "${DIRECT_SUFFIXES:-}")
    209 	if [ -n "$_direct" ]; then
    210 		_dns_rule="{ \"domain_suffix\": [$_direct], \"server\": \"local\" },"
    211 		_route_rule="{ \"domain_suffix\": [$_direct], \"outbound\": \"direct\" },"
    212 	else
    213 		_dns_rule=""
    214 		_route_rule=""
    215 	fi
    216 	cat <<-EOF
    217 		{
    218 		  "log": { "level": "warn" },
    219 		  "dns": {
    220 		    "servers": [
    221 		      { "tag": "doh", "type": "https", "server": "1.1.1.1", "detour": "proxy" },
    222 		      { "tag": "local", "type": "local" }
    223 		    ],
    224 		    "rules": [
    225 		      $_dns_rule
    226 		      { "domain_suffix": ["$SNI"], "server": "local" }
    227 		    ],
    228 		    "final": "doh",
    229 		    "strategy": "ipv4_only"
    230 		  },
    231 		  "inbounds": [
    232 		    {
    233 		      "type": "tun",
    234 		      "tag": "tun-in",
    235 		      "interface_name": "$TUN_IFACE",
    236 		      "address": ["$TUN_ADDR"],
    237 		      "mtu": 1500,
    238 		      "auto_route": true,
    239 		      "strict_route": true,
    240 		      "stack": "mixed"
    241 		    }
    242 		  ],
    243 		  "outbounds": [
    244 		$(vless_outbound),
    245 		    { "type": "direct", "tag": "direct", "routing_mark": 1 }
    246 		  ],
    247 		  "route": {
    248 		    "auto_detect_interface": true,
    249 		    "default_domain_resolver": "local",
    250 		    "rules": [
    251 		      { "action": "sniff" },
    252 		      { "protocol": "dns", "action": "hijack-dns" },
    253 		      { "ip_is_private": true, "outbound": "direct" },
    254 		      $_route_rule
    255 		      { "domain_suffix": [".local", ".lan", ".internal", ".home", ".arpa"], "outbound": "direct" }
    256 		    ],
    257 		    "final": "proxy"
    258 		  }
    259 		}
    260 	EOF
    261 }
    262 # routing_mark 1 on the direct outbound is not decoration: the killswitch
    263 # accepts `meta mark 0x1`, and without the mark every DIRECT_SUFFIXES
    264 # destination leaves unmarked on the physical NIC, is not on the tun interface,
    265 # and hits policy drop -- i.e. turning the killswitch on would kill the split
    266 # tunnel it exists to protect.
    267 
    268 # --------------------------------------------------------------- preflight
    269 
    270 preflight_tun() {
    271 	[ "$(id -u)" = 0 ] || die "MODE=tun needs root: it creates a network interface and
    272     rewrites this machine's routing table. Run it under the systemd unit the
    273     installer sets up, or with sudo."
    274 	if [ ! -e /dev/net/tun ]; then
    275 		modprobe tun 2>/dev/null || true
    276 		[ -e /dev/net/tun ] || die "no /dev/net/tun on this host, and 'modprobe tun' did not
    277     create one. Load the module and make it stick:
    278       modprobe tun && echo tun > /etc/modules-load.d/tun.conf"
    279 	fi
    280 	command -v ip >/dev/null 2>&1 || die "iproute2 is not installed; MODE=tun needs 'ip'"
    281 }
    282 
    283 panic_banner() {
    284 	cat <<-EOF
    285 
    286 		  IF YOUR NETWORK EVER DIES, RUN THIS:
    287 
    288 		      sudo nft delete table inet transtation
    289 		      for p in \$(seq 9000 9010); do
    290 		        while sudo ip rule del priority \$p 2>/dev/null; do :; done
    291 		      done
    292 		      sudo ip link del $TUN_IFACE
    293 		  (or simply:  sudo transtation-panic)
    294 
    295 	EOF
    296 }
    297 
    298 # ------------------------------------------------------------------- panic
    299 
    300 cmd_panic() {
    301 	nft delete table inet transtation 2>/dev/null || true
    302 	nft delete table ip6 transtation 2>/dev/null || true
    303 	# sing-box's own ip rules live at priority 9000-9010 and are NOT bound to
    304 	# the interface, so they outlive a SIGKILLed container.
    305 	# Delete exactly the rules that are there rather than probing every priority
    306 	# in the range: several rules can share a priority, and listing first is both
    307 	# bounded by reality and correct for duplicates.
    308 	for _f in -4 -6; do
    309 		ip $_f rule list 2>/dev/null | sed -n 's/^\(90[0-9][0-9]\):.*/\1/p' | while read -r _p; do
    310 			ip $_f rule del priority "$_p" 2>/dev/null || true
    311 		done
    312 	done
    313 	ip route flush table 2022 2>/dev/null || true
    314 	ip -6 route flush table 2022 2>/dev/null || true
    315 	ip link del "$TUN_IFACE" 2>/dev/null || true
    316 	echo "killswitch removed, policy routing flushed. You are back on direct egress."
    317 }
    318 
    319 # --------------------------------------------------------------------- run
    320 
    321 cmd_run() {
    322 	if [ -n "${TT_URI:-}" ]; then
    323 		URI=$TT_URI
    324 		warn "reading the link from TT_URI. That is visible in /proc and in this
    325     process's environment; prefer the file at $URI_FILE (mode 0600)."
    326 	elif [ -r "$URI_FILE" ]; then
    327 		URI=$(cat "$URI_FILE")
    328 	else
    329 		die "no share link. Put one where this can read it:
    330       install -Dm600 /dev/stdin $URI_FILE   # paste the vless:// link, then Ctrl-D"
    331 	fi
    332 
    333 	parse_uri "$URI"
    334 	validate
    335 
    336 	mkdir -p "$RUN_DIR"
    337 	chmod 700 "$RUN_DIR"
    338 	case "${MODE:-tun}" in
    339 		tun)
    340 			preflight_tun
    341 			render_tun >"$CONF"
    342 			;;
    343 		proxy)
    344 			render_proxy >"$CONF"
    345 			;;
    346 		*) die "MODE must be 'tun' or 'proxy', got: $(q "${MODE:-}")" ;;
    347 	esac
    348 	chmod 600 "$CONF"
    349 
    350 	"$SINGBOX" check -c "$CONF" || die "generated config rejected by $("$SINGBOX" version | head -1)"
    351 
    352 	printf '\n  transtation-client\n'
    353 	printf '  server   %s:%s  (sni %s)\n' "$SERVER_IP" "$PORT" "$SNI"
    354 	case "${MODE:-tun}" in
    355 		tun)
    356 			printf '  mode     tun -- everything this machine sends goes through the tunnel\n'
    357 			printf '  direct   %s\n' "${DIRECT_SUFFIXES:-(none)}"
    358 			panic_banner
    359 			;;
    360 		proxy)
    361 			printf '  mode     proxy -- SOCKS5 + HTTP on 127.0.0.1:%s, nothing is captured\n' "$SOCKS_PORT"
    362 			printf '           each program has to be pointed at it; MODE=tun is the one\n'
    363 			printf '           that covers the whole machine.\n\n'
    364 			;;
    365 	esac
    366 
    367 	exec "$SINGBOX" run -c "$CONF"
    368 }
    369 
    370 case "${1:-run}" in
    371 	run) cmd_run ;;
    372 	panic) cmd_panic ;;
    373 	check)
    374 		# Render both modes and validate them. Used by test.sh; needs no network
    375 		# beyond resolving the server name, so `check` accepts a literal IP link.
    376 		RUN_DIR=${TT_RUN_DIR:-$(mktemp -d)}; CONF="$RUN_DIR/config.json"
    377 		URI=${TT_URI:-$(cat "$URI_FILE")}
    378 		parse_uri "$URI"
    379 		validate
    380 		render_proxy >"$CONF" && "$SINGBOX" check -c "$CONF" && echo "  ok    proxy-mode config accepted by sing-box"
    381 		DIRECT_SUFFIXES=${DIRECT_SUFFIXES:-example.com,example.net} render_tun >"$CONF" &&
    382 			"$SINGBOX" check -c "$CONF" && echo "  ok    tun-mode config accepted by sing-box"
    383 		;;
    384 	*) die "unknown command: $1 (expected run, panic or check)" ;;
    385 esac