From ea9501f719167b8357deaff72de4fd54d2cc59d4 Mon Sep 17 00:00:00 2001 From: pipistrello Date: Wed, 9 Sep 2026 23:06:04 +0300 Subject: [PATCH] tailscale-proxy: add the missing IKEv2 VPN front, and correct the stale exit node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tailscale-proxy/docker-compose.yaml | 72 +++++++++++++++++++++++++-- tailscale-proxy/ikev2-egress.sh | 76 +++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 3 deletions(-) create mode 100755 tailscale-proxy/ikev2-egress.sh diff --git a/tailscale-proxy/docker-compose.yaml b/tailscale-proxy/docker-compose.yaml index 18f2ae1..4021f78 100644 --- a/tailscale-proxy/docker-compose.yaml +++ b/tailscale-proxy/docker-compose.yaml @@ -2,15 +2,15 @@ services: ts-proxy: image: tailscale/tailscale:v1.98.9 container_name: ts-proxy - hostname: technohim-lan-proxy + hostname: 02-tekhnohim-lan-proxy restart: unless-stopped env_file: ["/mnt/containers/tailscale-proxy/ts.env"] environment: - TS_HOSTNAME: technohim-lan-proxy + TS_HOSTNAME: 02-tekhnohim-lan-proxy TS_STATE_DIR: /var/lib/tailscale TS_USERSPACE: "true" 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" volumes: ["/mnt/containers/tailscale-proxy/state:/var/lib/tailscale:z"] networks: [proxynet] @@ -74,6 +74,72 @@ services: # critical service. Prometheus metrics are scraped by Zabbix on :9092. 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: proxynet: name: tailscale-proxynet diff --git a/tailscale-proxy/ikev2-egress.sh b/tailscale-proxy/ikev2-egress.sh new file mode 100755 index 0000000..2f72c12 --- /dev/null +++ b/tailscale-proxy/ikev2-egress.sh @@ -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 + 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 + 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