#!/bin/sh
# transtation-client -- connect this host to a transtation server.
#
#   MODE=tun    (default)  the whole machine's traffic goes through the tunnel
#   MODE=proxy             a local SOCKS5 + HTTP proxy, nothing captured
#
# 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}
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}

die() { printf 'transtation-client: %s\n' "$*" >&2; exit 1; }
# Anything taken from a share link and quoted back at the operator goes through
# this first. A link is attacker-supplied, error messages land on a terminal,
# and raw escape sequences let a crafted link paint whatever it likes there.
q() { printf '%s' "$1" | tr -dc '[:print:]' | cut -c1-64; }
warn() { printf 'transtation-client: %s\n' "$*" >&2; }

# --------------------------------------------------------------- share link

parse_uri() {
	_u=$1
	case "$_u" in
		vless://*) ;;
		*) die "not a vless:// link. Get one from your server with: transtation show" ;;
	esac
	_rest=${_u#vless://}
	_rest=${_rest%%#*}
	QUERY=""
	case "$_rest" in *\?*)
		QUERY=${_rest#*\?}
		_rest=${_rest%%\?*}
		;;
	esac
	case "$_rest" in *@*) ;; *) die "link has no '@' -- it is truncated" ;; esac
	UUID=${_rest%%@*}
	_hp=${_rest#*@}
	case "$_hp" in
		\[*\]:*)
			HOST=${_hp%%]*}
			HOST=${HOST#[}
			PORT=${_hp##*]:}
			;;
		*:*)
			HOST=${_hp%%:*}
			PORT=${_hp##*:}
			;;
		*) die "link has no port" ;;
	esac

	SNI=""; PBK=""; SID=""; FP=""; FLOW=""
	_q=$QUERY
	while [ -n "$_q" ]; do
		_kv=${_q%%&*}
		case "$_q" in *\&*) _q=${_q#*&} ;; *) _q="" ;; esac
		_k=${_kv%%=*}
		_v=${_kv#*=}
		case "$_k" in
			sni | serverName) SNI=$_v ;;
			pbk | publicKey) PBK=$_v ;;
			sid | shortId) SID=$_v ;;
			fp | fingerprint) FP=$_v ;;
			flow) FLOW=$_v ;;
		esac
	done
}

validate() {
	# Trust boundary: every one of these values is interpolated into a JSON
	# document below. Validate before, not after.
	printf '%s' "$UUID" | grep -Eiq '^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$' ||
		die "bad uuid in link"
	[ "${#PBK}" -eq 43 ] ||
		die "bad pbk: expected 43 characters, got ${#PBK}. Reissue the link with 'transtation show'."
	printf '%s' "$PBK" | grep -Eq '^[A-Za-z0-9_-]{43}$' || die "pbk is not base64url"
	printf '%s' "$SID" | grep -Eq '^([0-9a-fA-F]{2}){1,8}$' ||
		die "bad sid: expected 2-16 hex characters, got '$(q "$SID")'"
	printf '%s' "$SNI" | grep -Eq '^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$' || die "bad or missing sni: '$(q "$SNI")'"
	[ -n "$HOST" ] || die "link has no server address"
	printf '%s' "$PORT" | grep -Eq '^[0-9]{1,5}$' && [ "$PORT" -ge 1 ] && [ "$PORT" -le 65535 ] ||
		die "bad port: $(q "$PORT")"
	# sing-box hard-errors with "uTLS is required by reality client" if the
	# 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: $(q "$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: $(q "$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: $(q "$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: $(q "$_d")"
		done
	fi

	# Resolve once, here, so the tunnel's own resolver never has to look up the
	# server name mid-flight -- and so the killswitch's bootstrap rule stays
	# valid for as long as the tunnel does.
	case "$HOST" in
		*[!0-9.]*)
			_ip=$(resolve4 "$HOST") || true
			[ -n "$_ip" ] || die "cannot resolve $(q "$HOST") -- check this host's DNS, or put the server's IP in the link"
			printf 'resolved %s -> %s\n' "$HOST" "$_ip"
			SERVER_IP=$_ip
			;;
		*) SERVER_IP=$HOST ;;
	esac
}

# getent comes from musl-utils and nslookup from busybox. Try both rather than
# assuming which one a given base image happens to carry.
resolve4() {
	if command -v getent >/dev/null 2>&1; then
		_r=$(getent ahostsv4 "$1" 2>/dev/null | awk 'NR==1 {print $1}')
		[ -n "$_r" ] && { printf '%s' "$_r"; return 0; }
	fi
	nslookup "$1" 2>/dev/null |
		awk '/^Address:/ && $2 ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/ { print $2; exit }'
}

# ------------------------------------------------------------------ configs

json_suffix_array() {
	# "a.com,b.com" -> "\"a.com\", \"b.com\"" ; empty input -> empty output
	printf '%s' "${1:-}" | tr ',' '\n' |
		sed 's/^[[:space:]]*//;s/[[:space:]]*$//' |
		awk 'NF { printf "%s\"%s\"", (n++ ? ", " : ""), $0 }'
}

vless_outbound() {
	if [ -n "$FLOW" ]; then _flow_kv="\"flow\": \"$FLOW\","; else _flow_kv=""; fi
	cat <<-EOF
		    {
		      "type": "vless",
		      "tag": "proxy",
		      "server": "$SERVER_IP",
		      "server_port": $PORT,
		      "uuid": "$UUID",
		      $_flow_kv
		      "packet_encoding": "xudp",
		      "tls": {
		        "enabled": true,
		        "server_name": "$SNI",
		        "utls": { "enabled": true, "fingerprint": "$FP" },
		        "reality": { "enabled": true, "public_key": "$PBK", "short_id": "$SID" }
		      }
		    }
	EOF
}

render_proxy() {
	# No "dns" and no "route" block on purpose. Adding DNS configuration here
	# makes leaks MORE likely, not fewer: what actually prevents them is the
	# caller using socks5h:// so the proxy resolves, plus the server address
	# already being a literal IP.
	#
	# Loopback only. This runs on the host, so binding anything wider would put
	# an open proxy on the network.
	cat <<-EOF
		{
		  "log": { "level": "warn" },
		  "inbounds": [
		    {
		      "type": "mixed",
		      "tag": "in",
		      "listen": "127.0.0.1",
		      "listen_port": $SOCKS_PORT
		    }
		  ],
		  "outbounds": [
		$(vless_outbound)
		  ]
		}
	EOF
}

render_tun() {
	_direct=$(json_suffix_array "${DIRECT_SUFFIXES:-}")
	if [ -n "$_direct" ]; then
		_dns_rule="{ \"domain_suffix\": [$_direct], \"server\": \"local\" },"
		_route_rule="{ \"domain_suffix\": [$_direct], \"outbound\": \"direct\" },"
	else
		_dns_rule=""
		_route_rule=""
	fi
	cat <<-EOF
		{
		  "log": { "level": "warn" },
		  "dns": {
		    "servers": [
		      { "tag": "doh", "type": "https", "server": "1.1.1.1", "detour": "proxy" },
		      { "tag": "local", "type": "local" }
		    ],
		    "rules": [
		      $_dns_rule
		      { "domain_suffix": ["$SNI"], "server": "local" }
		    ],
		    "final": "doh",
		    "strategy": "ipv4_only"
		  },
		  "inbounds": [
		    {
		      "type": "tun",
		      "tag": "tun-in",
		      "interface_name": "$TUN_IFACE",
		      "address": ["$TUN_ADDR"],
		      "mtu": 1500,
		      "auto_route": true,
		      "strict_route": true,
		      "stack": "mixed"
		    }
		  ],
		  "outbounds": [
		$(vless_outbound),
		    { "type": "direct", "tag": "direct", "routing_mark": 1 }
		  ],
		  "route": {
		    "auto_detect_interface": true,
		    "default_domain_resolver": "local",
		    "rules": [
		      { "action": "sniff" },
		      { "protocol": "dns", "action": "hijack-dns" },
		      { "ip_is_private": true, "outbound": "direct" },
		      $_route_rule
		      { "domain_suffix": [".local", ".lan", ".internal", ".home", ".arpa"], "outbound": "direct" }
		    ],
		    "final": "proxy"
		  }
		}
	EOF
}
# routing_mark 1 on the direct outbound is not decoration: the killswitch
# accepts `meta mark 0x1`, and without the mark every DIRECT_SUFFIXES
# destination leaves unmarked on the physical NIC, is not on the tun interface,
# and hits policy drop -- i.e. turning the killswitch on would kill the split
# tunnel it exists to protect.

# --------------------------------------------------------------- preflight

preflight_tun() {
	[ "$(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
	command -v ip >/dev/null 2>&1 || die "iproute2 is not installed; MODE=tun needs 'ip'"
}

panic_banner() {
	cat <<-EOF

		  IF YOUR NETWORK EVER DIES, RUN THIS:

		      sudo nft delete table inet transtation
		      for p in \$(seq 9000 9010); do
		        while sudo ip rule del priority \$p 2>/dev/null; do :; done
		      done
		      sudo ip link del $TUN_IFACE
		  (or simply:  sudo transtation-panic)

	EOF
}

# ------------------------------------------------------------------- panic

cmd_panic() {
	nft delete table inet transtation 2>/dev/null || true
	nft delete table ip6 transtation 2>/dev/null || true
	# sing-box's own ip rules live at priority 9000-9010 and are NOT bound to
	# the interface, so they outlive a SIGKILLed container.
	# Delete exactly the rules that are there rather than probing every priority
	# in the range: several rules can share a priority, and listing first is both
	# bounded by reality and correct for duplicates.
	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 || true
		done
	done
	ip route flush table 2022 2>/dev/null || true
	ip -6 route flush table 2022 2>/dev/null || true
	ip link del "$TUN_IFACE" 2>/dev/null || true
	echo "killswitch removed, policy routing flushed. You are back on direct egress."
}

# --------------------------------------------------------------------- run

cmd_run() {
	if [ -n "${TT_URI:-}" ]; then
		URI=$TT_URI
		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. 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

	mkdir -p "$RUN_DIR"
	chmod 700 "$RUN_DIR"
	case "${MODE:-tun}" in
		tun)
			preflight_tun
			render_tun >"$CONF"
			;;
		proxy)
			render_proxy >"$CONF"
			;;
		*) die "MODE must be 'tun' or 'proxy', got: $(q "${MODE:-}")" ;;
	esac
	chmod 600 "$CONF"

	"$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:-tun}" in
		tun)
			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 "$SINGBOX" run -c "$CONF"
}

case "${1:-run}" in
	run) cmd_run ;;
	panic) cmd_panic ;;
	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" && "$SINGBOX" check -c "$CONF" && echo "  ok    proxy-mode config accepted by sing-box"
		DIRECT_SUFFIXES=${DIRECT_SUFFIXES:-example.com,example.net} render_tun >"$CONF" &&
			"$SINGBOX" check -c "$CONF" && echo "  ok    tun-mode config accepted by sing-box"
		;;
	*) die "unknown command: $1 (expected run, panic or check)" ;;
esac
