tailscale-proxy: add the missing IKEv2 VPN front, and correct the stale exit node

This file has been unsafe to deploy for two separate reasons. Both are fixed here,
and the result is now byte-identical in effective config to what actually runs on
the client-02 host (verified by diffing `docker compose config` on both).

1. ts-vpn + ikev2 were missing entirely.

The IKEv2 VPN front has run on the host since 2026-09-05, but was never mirrored
here — this file defined only ts-proxy, proxy and telemt. Deploying it as-is would
have taken the VPN down for the Windows and iOS clients. Added both services, plus
ikev2-egress.sh, which the ikev2 service mounts and cannot start without.

ts-vpn is a SECOND Tailscale node in kernel/TUN mode (unlike ts-proxy, which is
userspace): it forwards raw IP packets rather than TCP streams, so a userspace
SOCKS5 is useless for it. It owns the network namespace that ikev2 joins via
network_mode, which is also why the ports and sysctls live on ts-vpn and not on
ikev2 — Docker forbids net.* sysctls on a network_mode:service container.

2. ts-proxy still pointed at the pre-migration exit node.

  TS_EXTRA_ARGS  --exit-node=100.121.234.85  ->  --exit-node=100.106.197.99
  TS_HOSTNAME / hostname  technohim-lan-proxy -> 02-tekhnohim-lan-proxy

100.121.234.85 has not existed since the 2026-08-27 tailnet migration. This is
worse than a dead setting: with the exit node absent this stack does NOT fail
closed — it silently egresses through the local ISP, so a redeploy from this repo
would have turned a censorship-avoiding proxy into a plain local-egress one while
still reporting healthy. This drift was recorded as an open issue in the migration
write-up on 2026-08-27; these are the exact substitutions it prescribed.

Not included, deliberately: ts-vpn.env and ikev2.env, which hold TS_AUTHKEY and the
VPN credentials. Like every other config in this stack they are staged on the host
by hand — a git push does not deploy config here, and these must never enter git.
Both extracted files were scanned for secrets before being added.
This commit is contained in:
pipistrello
2026-09-09 23:06:04 +03:00
parent 331c274b8b
commit ea9501f719
2 changed files with 145 additions and 3 deletions
+69 -3
View File
@@ -2,15 +2,15 @@ services:
ts-proxy: ts-proxy:
image: tailscale/tailscale:v1.98.9 image: tailscale/tailscale:v1.98.9
container_name: ts-proxy container_name: ts-proxy
hostname: technohim-lan-proxy hostname: 02-tekhnohim-lan-proxy
restart: unless-stopped restart: unless-stopped
env_file: ["/mnt/containers/tailscale-proxy/ts.env"] env_file: ["/mnt/containers/tailscale-proxy/ts.env"]
environment: environment:
TS_HOSTNAME: technohim-lan-proxy TS_HOSTNAME: 02-tekhnohim-lan-proxy
TS_STATE_DIR: /var/lib/tailscale TS_STATE_DIR: /var/lib/tailscale
TS_USERSPACE: "true" TS_USERSPACE: "true"
TS_ACCEPT_DNS: "false" TS_ACCEPT_DNS: "false"
TS_EXTRA_ARGS: "--exit-node=100.121.234.85 --reset" TS_EXTRA_ARGS: "--exit-node=100.106.197.99 --reset"
TS_TAILSCALED_EXTRA_ARGS: "--socks5-server=0.0.0.0:1055 --outbound-http-proxy-listen=0.0.0.0:1056" TS_TAILSCALED_EXTRA_ARGS: "--socks5-server=0.0.0.0:1055 --outbound-http-proxy-listen=0.0.0.0:1056"
volumes: ["/mnt/containers/tailscale-proxy/state:/var/lib/tailscale:z"] volumes: ["/mnt/containers/tailscale-proxy/state:/var/lib/tailscale:z"]
networks: [proxynet] networks: [proxynet]
@@ -74,6 +74,72 @@ services:
# critical service. Prometheus metrics are scraped by Zabbix on :9092. # critical service. Prometheus metrics are scraped by Zabbix on :9092.
networks: [proxynet] networks: [proxynet]
# ---------------------------------------------------------------------------
# IKEv2 VPN front for iOS/macOS/Windows/Android (added 2026-09-05).
#
# ts-vpn is a SECOND Tailscale exit-node client, in KERNEL (TUN) mode - unlike
# ts-proxy it must forward raw IP packets, not TCP streams, so a userspace
# SOCKS5 is useless here. It owns the network namespace that the IKEv2 server
# (ikev2) joins via network_mode. Inside that namespace Tailscale's policy
# routing sends the default route into the tunnel (route table 52), so VPN
# clients egress from the Hetzner exit node.
# --netfilter-mode=off : Tailscale installs NO nft chains here; NAT + MSS
# clamp for the VPN subnet are done by ikev2-egress.sh (same netns).
# sysctls live here (Docker forbids net.* sysctls on a network_mode:service
# container) - hwdsl2 wants ip_forward=1 and rp_filter/redirects off.
# Ports are published HERE (the namespace owner), LAN-bound; the router
# already forwards WAN udp/500 + udp/4500 to 192.168.0.35.
# If ts-vpn is ever restarted alone, restart ikev2 too - a network_mode:service
# container keeps a stale namespace otherwise (`docker compose up -d`).
ts-vpn:
image: tailscale/tailscale:v1.98.9
container_name: ts-vpn
hostname: 02-tekhnohim-lan-vpn
restart: unless-stopped
env_file: ["/mnt/containers/tailscale-proxy/ts-vpn.env"] # TS_AUTHKEY=... 0600, NOT in git
environment:
TS_HOSTNAME: 02-tekhnohim-lan-vpn
TS_STATE_DIR: /var/lib/tailscale
TS_USERSPACE: "false"
TS_ACCEPT_DNS: "false"
TS_EXTRA_ARGS: "--exit-node=100.106.197.99 --netfilter-mode=off --reset"
cap_add: [NET_ADMIN]
devices: ["/dev/net/tun:/dev/net/tun"]
sysctls:
net.ipv4.ip_forward: "1"
net.ipv4.conf.all.rp_filter: "0"
net.ipv4.conf.default.rp_filter: "0"
net.ipv4.conf.all.send_redirects: "0"
net.ipv4.conf.default.send_redirects: "0"
net.ipv4.conf.all.accept_redirects: "0"
net.ipv4.conf.default.accept_redirects: "0"
volumes: ["/mnt/containers/tailscale-proxy/ts-vpn-state:/var/lib/tailscale:z"]
ports:
- "192.168.0.35:500:500/udp"
- "192.168.0.35:4500:4500/udp"
networks: [proxynet]
# hwdsl2 IPsec server in IKEv2-only mode (no PSK/L2TP/XAuth exposure). It
# generates ready-to-import client profiles: .mobileconfig (iOS/macOS),
# .p12 (Windows/Linux), .sswan (Android). Certificates/clients persist in the
# ikev2/ volume. ikev2-egress.sh runs alongside run.sh and keeps
# - VPN subnet -> tailscale0 MASQUERADE + MSS clamp (exit-node egress),
# - IKE/ESP replies on the docker bridge (ip rules ahead of Tailscale's),
# - the office LAN reachable from the VPN (route via the bridge gateway).
ikev2:
image: hwdsl2/ipsec-vpn-server@sha256:2e939ffe5913c9a34a3f5b72d75a97e9a2f16824d223bf042b88a567ffeabe9b # :latest of 2026-08-28
container_name: ts-lan-ikev2
restart: unless-stopped
depends_on: [ts-vpn]
network_mode: "service:ts-vpn"
env_file: ["/mnt/containers/tailscale-proxy/ikev2.env"]
cap_add: [NET_ADMIN]
volumes:
- "/mnt/containers/tailscale-proxy/ikev2:/etc/ipsec.d:z"
- "/lib/modules:/lib/modules:ro"
- "/mnt/containers/tailscale-proxy/ikev2-egress.sh:/usr/local/bin/ikev2-egress.sh:ro,z"
entrypoint: ["/bin/sh", "-c", "/usr/local/bin/ikev2-egress.sh & exec /opt/src/run.sh"]
networks: networks:
proxynet: proxynet:
name: tailscale-proxynet name: tailscale-proxynet
+76
View File
@@ -0,0 +1,76 @@
#!/bin/sh
# ikev2-egress.sh - runs inside ts-lan-ikev2, which shares ts-vpn's network
# namespace. Tailscale (kernel mode, --netfilter-mode=off) only installs the
# policy routing (default -> tailscale0 via table 52); this loop owns the rest
# and is idempotent, so a container restart or a late tailscale0 is harmless.
#
# 1. ip rules ahead of Tailscale's 52xx rules: replies of the IKE/NAT-T/ESP
# server itself must leave via the docker bridge (main table), otherwise
# the SYN-ACK-equivalent goes into the tunnel and no client ever connects.
# 2. FORWARD accepts for VPN<->tailscale0 ahead of hwdsl2's final -j DROP, and
# MASQUERADE everything leaving tailscale0 to the node's own 100.x address -
# the exit node's WireGuard AllowedIPs drops any other source.
# 3. MSS clamp to the tunnel PMTU (tailscale0 is MTU 1280; hwdsl2 clamps 1360).
# 4. Pin the Libreswan conn to eth0's address: hwdsl2 writes left=%defaultroute,
# which resolves through Tailscale's policy routing to the tailscale0 address,
# so IKE packets arriving on the bridge match no conn (NO_PROPOSAL_CHOSEN).
# 5. LAN access: one ip rule sends LAN_NET out the docker bridge (main table)
# instead of table 52, plus FORWARD accepts and a MASQUERADE to the bridge
# address - LAN hosts have no route back to the VPN pool. Scoped to LAN_NET
# only: every other destination, including the docker bridges themselves,
# still falls through to table 52 = tailscale0. Set LAN_NET empty to restore
# the original egress-only behaviour.
# The LAN block sits OUTSIDE the tailscale0 guard on purpose: LAN access does not
# depend on the exit node, so a client still reaches the office if the tunnel is
# down (internet then fails closed, which is what we want).
TS_IF=tailscale0
LAN_IF=eth0
VPN_NET="192.168.43.0/24" # hwdsl2 IKEv2 address pool (rightaddresspool)
LAN_NET="192.168.0.0/22" # client-02 head office (Kirochnaya). Branch sites
# are NOT routed here - see the deployment note.
log() { echo "[ikev2-egress] $*"; }
ensure_ipt() { # ensure_ipt <table> <chain> <rule...>
t=$1; c=$2; shift 2
iptables -w -t "$t" -C "$c" "$@" 2>/dev/null || { iptables -w -t "$t" -A "$c" "$@" && log "iptables -t $t -A $c $*"; }
}
ensure_ipt_first() { # like ensure_ipt but inserts at the top (hwdsl2 ends FORWARD with -j DROP)
t=$1; c=$2; shift 2
iptables -w -t "$t" -C "$c" "$@" 2>/dev/null || { iptables -w -t "$t" -I "$c" 1 "$@" && log "iptables -t $t -I $c $*"; }
}
ensure_rule() { # ensure_rule <pref> <selector...>
p=$1; shift
ip rule list | grep -q "^$p:" || { ip rule add pref "$p" "$@" && log "ip rule $p: $*"; }
}
fix_left() {
conf=/etc/ipsec.d/ikev2.conf
[ -f "$conf" ] || return 0
ip4=$(ip -4 -o addr show dev eth0 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1)
[ -n "$ip4" ] || return 0
grep -q "^ left=$ip4\$" "$conf" && return 0
sed -i "s|^ left=.*| left=$ip4|" "$conf" && log "ikev2.conf: left=$ip4 (was %defaultroute -> tailscale0)"
if ipsec auto --replace ikev2-cp >/dev/null 2>&1; then log "ipsec auto --replace ikev2-cp"; fi
}
while :; do
fix_left
if [ -n "$LAN_NET" ] && ip link show "$LAN_IF" >/dev/null 2>&1; then
ensure_rule 5010 to "$LAN_NET" lookup main
ensure_ipt_first filter FORWARD -s "$VPN_NET" -d "$LAN_NET" -o "$LAN_IF" -j ACCEPT
ensure_ipt_first filter FORWARD -d "$VPN_NET" -s "$LAN_NET" -i "$LAN_IF" \
-m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
ensure_ipt nat POSTROUTING -s "$VPN_NET" -d "$LAN_NET" -o "$LAN_IF" -j MASQUERADE
fi
if ip link show "$TS_IF" >/dev/null 2>&1; then
ensure_rule 5001 ipproto udp sport 500 lookup main
ensure_rule 5002 ipproto udp sport 4500 lookup main
ensure_rule 5003 ipproto 50 lookup main
ensure_ipt_first filter FORWARD -s "$VPN_NET" -o "$TS_IF" -j ACCEPT
ensure_ipt_first filter FORWARD -d "$VPN_NET" -i "$TS_IF" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
ensure_ipt nat POSTROUTING -o "$TS_IF" -j MASQUERADE
ensure_ipt mangle FORWARD -o "$TS_IF" -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu
fi
sleep 30
done