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