host/transtation-killswitch (6666B)
1 #!/bin/sh
2 # transtation-killswitch -- fail-closed egress filter for MODE=tun.
3 #
4 # Separate from the client service on purpose. Firewall rules are kernel state
5 # that outlives whatever created them, and SIGKILL or OOM bypasses any cleanup
6 # trap the client might install -- so a service that both closes the network and
7 # can die without warning is a service that can strand you. Worse, under
8 # `Restart=always` one that applies the killswitch and then fails to start would
9 # sit in a loop re-closing your network faster than you can open it. Turning it
10 # on and off stays an explicit, separate act.
11 #
12 # transtation-killswitch on [--persist]
13 # transtation-killswitch off
14 # transtation-killswitch status
15 set -eu
16
17 URI_FILE=${TT_URI_FILE:-/etc/transtation/uri}
18 TUN_IFACE=${TUN_IFACE:-tt0}
19 UNIT=/etc/systemd/system/transtation-killswitch.service
20
21 die() { printf 'transtation-killswitch: %s\n' "$*" >&2; exit 1; }
22
23 [ "$(id -u)" = 0 ] || die "must run as root"
24 command -v nft >/dev/null 2>&1 || die "nftables is not installed on this host"
25
26 server_endpoint() {
27 [ -r "$URI_FILE" ] || die "cannot read $URI_FILE -- set TT_URI_FILE, or SERVER_IP and SERVER_PORT"
28 _u=$(cat "$URI_FILE")
29 _r=${_u#vless://}
30 _r=${_r%%#*}
31 _r=${_r%%\?*}
32 _hp=${_r#*@}
33 case "$_hp" in
34 \[*\]:*)
35 _h=${_hp%%]*}
36 _h=${_h#[}
37 _p=${_hp##*]:}
38 ;;
39 *:*)
40 _h=${_hp%%:*}
41 _p=${_hp##*:}
42 ;;
43 *) die "cannot parse a host:port out of $URI_FILE" ;;
44 esac
45 case "$_h" in
46 *:*)
47 # An IPv6 literal would be spliced into an `ip daddr` rule and nft
48 # would reject the whole ruleset, leaving the host with no killswitch
49 # at all rather than a broken one. transtation is IPv4-only end to end
50 # anyway: the client resolves the server with getent ahostsv4 and the
51 # tun DNS strategy is ipv4_only.
52 die "the server address in $URI_FILE is IPv6 ($_h).
53 transtation is IPv4-only for now, so the killswitch cannot be built for it.
54 Use an IPv4 address or a name with an A record, or set SERVER_IP yourself." ;;
55 *[!0-9.]*)
56 _h=$(getent ahostsv4 "$_h" 2>/dev/null | awk 'NR==1 {print $1}')
57 [ -n "$_h" ] || die "cannot resolve the server address in $URI_FILE to an IPv4 address"
58 ;;
59 esac
60 SERVER_IP=${SERVER_IP:-$_h}
61 SERVER_PORT=${SERVER_PORT:-$_p}
62 }
63
64 ks_on() {
65 server_endpoint
66
67 _admin=""
68 if [ -n "${ADMIN_CIDR:-}" ]; then
69 _admin="ip daddr $ADMIN_CIDR accept"
70 fi
71
72 _allow=""
73 if [ -n "${KILLSWITCH_ALLOW:-}" ]; then
74 # Raw nft expressions, ';'-separated. No mini-DSL: whoever runs this
75 # already holds NET_ADMIN over the host's network namespace, so there is
76 # no privilege boundary here to defend.
77 _allow=$(printf '%s' "$KILLSWITCH_ALLOW" | tr ';' '\n' | sed 's/^[[:space:]]*/ /')
78 fi
79
80 # Additive and scoped to our own table. NEVER `nft flush ruleset`: on a
81 # modern host that also wipes the iptables-nft tables Docker installs and
82 # silently breaks every container's networking. A DROP in any one table
83 # still drops, so scoping costs nothing in correctness.
84 nft delete table inet transtation 2>/dev/null || true
85 nft delete table ip6 transtation 2>/dev/null || true
86
87 nft -f - <<-EOF
88 table inet transtation {
89 chain out {
90 type filter hook output priority filter; policy drop;
91 oif lo accept
92 ct state established,related accept
93 ct state invalid drop
94 oifname "$TUN_IFACE" accept
95 meta mark 0x1 accept
96 ip daddr { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, 100.64.0.0/10, 224.0.0.0/4 } accept
97 udp dport { 67, 68 } accept
98 ip daddr $SERVER_IP tcp dport $SERVER_PORT accept
99 ip daddr $SERVER_IP udp dport $SERVER_PORT accept
100 $_admin
101 $_allow
102 limit rate 5/minute log prefix "transtation-drop: "
103 }
104 }
105 table ip6 transtation {
106 chain out {
107 type filter hook output priority filter; policy drop;
108 oif lo accept
109 ct state established,related accept
110 }
111 }
112 EOF
113
114 cat <<-EOF
115 killswitch ON (table inet transtation, output hook, policy drop)
116 allowed out: loopback, established, $TUN_IFACE, fwmark 0x1 (split tunnel),
117 RFC1918 + CGNAT + link-local + multicast, DHCP,
118 $SERVER_IP:$SERVER_PORT${ADMIN_CIDR:+, admin $ADMIN_CIDR}
119 IPv6 is dropped entirely except loopback and established.
120 Your existing nftables rules were NOT modified; this is a separate table.
121
122 LIMIT: this is an output-hook chain. It covers this host's own processes.
123 Traffic FORWARDED from your other containers does not traverse the
124 output hook and is NOT covered by it.
125
126 off: transtation-killswitch off
127 panic: transtation-panic
128 EOF
129
130 if [ -n "${SSH_CONNECTION:-}" ]; then
131 cat <<-EOF
132
133 NOTE: you are over SSH. Your session survives on the established rule, and
134 new inbound SSH connections keep working too -- their reply packets are
135 already ESTABLISHED. What stops is this host's own OUTBOUND traffic when
136 the tunnel is down: DNS, package managers, monitoring agents. Set
137 ADMIN_CIDR=<your.network/mask> if you want an explicit carve-out.
138 EOF
139 fi
140
141 if [ "${1:-}" = --persist ]; then
142 [ -d /run/systemd/system ] || {
143 cat <<-EOF
144
145 --persist needs systemd, which is not running here. To make this survive a
146 reboot on your own init, run this at boot:
147 transtation-killswitch on
148 EOF
149 return 0
150 }
151 cat >"$UNIT" <<-EOF
152 [Unit]
153 Description=transtation killswitch
154 Before=network-pre.target
155 Wants=network-pre.target
156
157 [Service]
158 Type=oneshot
159 RemainAfterExit=yes
160 ExecStart=/usr/local/sbin/transtation-killswitch on
161 ExecStop=/usr/local/sbin/transtation-killswitch off
162
163 [Install]
164 WantedBy=multi-user.target
165 EOF
166 systemctl daemon-reload
167 systemctl enable transtation-killswitch.service >/dev/null
168 echo " persisted: enabled transtation-killswitch.service"
169 echo " (a broken fail-closed state now survives a reboot -- transtation-panic still clears it)"
170 fi
171 }
172
173 ks_off() {
174 nft delete table inet transtation 2>/dev/null || true
175 nft delete table ip6 transtation 2>/dev/null || true
176 if [ -f "$UNIT" ]; then
177 systemctl disable transtation-killswitch.service >/dev/null 2>&1 || true
178 rm -f "$UNIT"
179 systemctl daemon-reload 2>/dev/null || true
180 fi
181 echo "killswitch OFF (table inet transtation deleted; nothing else touched)"
182 }
183
184 ks_status() {
185 if nft list table inet transtation >/dev/null 2>&1; then
186 echo "killswitch is ON"
187 nft list table inet transtation
188 else
189 echo "killswitch is OFF (no table inet transtation)"
190 fi
191 }
192
193 case "${1:-}" in
194 on) shift; ks_on "${1:-}" ;;
195 off) ks_off ;;
196 status) ks_status ;;
197 *)
198 echo "usage: transtation-killswitch on [--persist] | off | status" >&2
199 exit 2
200 ;;
201 esac