Add fail-closed WireGuard container gateway
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
FROM debian:13-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
ca-certificates iproute2 iptables procps socat wireguard-tools \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY entrypoint.sh /usr/local/sbin/mike-ai-wireguard-entrypoint
|
||||
COPY healthcheck.sh /usr/local/sbin/mike-ai-wireguard-healthcheck
|
||||
RUN chmod 0755 /usr/local/sbin/mike-ai-wireguard-entrypoint \
|
||||
/usr/local/sbin/mike-ai-wireguard-healthcheck
|
||||
|
||||
ENTRYPOINT ["/usr/local/sbin/mike-ai-wireguard-entrypoint"]
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SOURCE=${WG_CONFIG_SOURCE:-/run/secrets/fritz-athena.conf}
|
||||
RUNTIME=/run/wireguard/wg0.conf
|
||||
|
||||
[ -s "$SOURCE" ] || { echo "WireGuard configuration is missing" >&2; exit 1; }
|
||||
install -d -m 0700 /run/wireguard
|
||||
|
||||
# Fritzbox exports global DNS directives and wg-quick hooks. DNS is assigned
|
||||
# per application container by Docker; executable hooks are deliberately not
|
||||
# accepted from a secret file. All cryptographic values remain untouched.
|
||||
awk '
|
||||
/^[[:space:]]*(DNS|Table|PreUp|PostUp|PreDown|PostDown|SaveConfig)[[:space:]]*=/ { next }
|
||||
/^\[Interface\][[:space:]]*$/ { print; print "Table = off"; next }
|
||||
{ print }
|
||||
' "$SOURCE" >"$RUNTIME"
|
||||
chmod 0600 "$RUNTIME"
|
||||
|
||||
# Docker deliberately keeps /proc/sys read-only inside this narrowly
|
||||
# privileged container. Table=off prevents wg-quick from trying to modify
|
||||
# global policy-routing sysctls; the two required routes are installed below.
|
||||
physical_default=$(ip -4 route show default | head -n 1)
|
||||
physical_gateway=$(printf '%s\n' "$physical_default" | awk '{for (i=1; i<=NF; i++) if ($i == "via") print $(i+1)}')
|
||||
physical_device=$(printf '%s\n' "$physical_default" | awk '{for (i=1; i<=NF; i++) if ($i == "dev") print $(i+1)}')
|
||||
|
||||
wg-quick up "$RUNTIME"
|
||||
|
||||
# Keep the encrypted peer itself reachable over Docker's physical network,
|
||||
# then make the tunnel the namespace default. Connected Docker routes remain
|
||||
# intact for the reverse proxies and internal service discovery.
|
||||
endpoint=$(wg show wg0 endpoints | awk 'NR == 1 { print $2 }')
|
||||
case "$endpoint" in
|
||||
\[*\]:*) endpoint_ip=${endpoint#\[}; endpoint_ip=${endpoint_ip%%\]*} ;;
|
||||
*:*) endpoint_ip=${endpoint%:*} ;;
|
||||
*) endpoint_ip= ;;
|
||||
esac
|
||||
|
||||
if [ -n "$endpoint_ip" ] && [ -n "$physical_gateway" ] && [ -n "$physical_device" ]; then
|
||||
case "$endpoint_ip" in
|
||||
*:*) : ;; # Docker gateway networks are intentionally IPv4-only.
|
||||
*) ip -4 route replace "$endpoint_ip/32" via "$physical_gateway" dev "$physical_device" ;;
|
||||
esac
|
||||
fi
|
||||
ip -4 route replace default dev wg0
|
||||
ip -6 route replace default dev wg0 2>/dev/null || true
|
||||
|
||||
cleanup() {
|
||||
kill "${proxy_ui_pid:-}" "${proxy_router_pid:-}" 2>/dev/null || true
|
||||
wg-quick down "$RUNTIME" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
# Forward only explicitly selected Docker networks into the tunnel. The
|
||||
# gateway itself is the only container that receives NET_ADMIN.
|
||||
iptables -P FORWARD DROP
|
||||
iptables -A FORWARD -o wg0 -j ACCEPT
|
||||
iptables -A FORWARD -i wg0 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
|
||||
iptables -t nat -A POSTROUTING -o wg0 -j MASQUERADE
|
||||
|
||||
# Nothing is published on the physical host. These listeners exist only in
|
||||
# the WireGuard container namespace and forward VPN clients to internal names.
|
||||
socat TCP-LISTEN:8080,bind=0.0.0.0,reuseaddr,fork TCP:open-webui:8080 &
|
||||
proxy_ui_pid=$!
|
||||
socat TCP-LISTEN:8081,bind=0.0.0.0,reuseaddr,fork TCP:router:8081 &
|
||||
proxy_router_pid=$!
|
||||
|
||||
wait "$proxy_ui_pid"
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
wg show wg0 >/dev/null
|
||||
ip link show wg0 | grep -q 'state UNKNOWN\|state UP'
|
||||
latest=$(wg show wg0 latest-handshakes | cut -f2 | head -n 1)
|
||||
now=$(date +%s)
|
||||
[ "${latest:-0}" -gt 0 ]
|
||||
[ $((now - latest)) -lt 180 ]
|
||||
kill -0 1
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
network_bridge() {
|
||||
id=$(docker network inspect -f '{{.Id}}' "$1")
|
||||
printf 'br-%.12s\n' "$id"
|
||||
}
|
||||
|
||||
wait_network() {
|
||||
count=0
|
||||
until docker network inspect "$1" >/dev/null 2>&1; do
|
||||
count=$((count + 1))
|
||||
[ "$count" -lt 60 ] || return 1
|
||||
sleep 2
|
||||
done
|
||||
}
|
||||
|
||||
install_rule() {
|
||||
network=$1 subnet=$2 gateway=$3 table=$4 priority=$5
|
||||
bridge=$(network_bridge "$network")
|
||||
ip rule del from "$subnet" table "$table" priority "$priority" 2>/dev/null || true
|
||||
ip rule add from "$subnet" table "$table" priority "$priority"
|
||||
# A fresh host has no FIB object for the custom table yet; iproute2 returns
|
||||
# an error in that perfectly normal case.
|
||||
ip route flush table "$table" 2>/dev/null || true
|
||||
ip route add "$subnet" dev "$bridge" scope link table "$table"
|
||||
ip route add default via "$gateway" dev "$bridge" table "$table"
|
||||
}
|
||||
|
||||
wait_network mike-ai_frontend
|
||||
wait_network mike-ai-tools-egress
|
||||
|
||||
# Preserve all east/west Docker communication before source-policy routing.
|
||||
ip rule del to 172.30.0.0/16 lookup main priority 11000 2>/dev/null || true
|
||||
ip rule add to 172.30.0.0/16 lookup main priority 11000
|
||||
|
||||
# The gateway's encrypted outer packets must leave through the host's normal
|
||||
# uplink. Without these narrow exceptions they would match the source rules
|
||||
# below and be routed straight back into the gateway (a routing loop).
|
||||
ip rule del from 172.30.10.254/32 lookup main priority 11010 2>/dev/null || true
|
||||
ip rule add from 172.30.10.254/32 lookup main priority 11010
|
||||
ip rule del from 172.30.50.254/32 lookup main priority 11011 2>/dev/null || true
|
||||
ip rule add from 172.30.50.254/32 lookup main priority 11011
|
||||
|
||||
install_rule mike-ai_frontend 172.30.10.0/24 172.30.10.254 51821 12010
|
||||
install_rule mike-ai-tools-egress 172.30.50.0/24 172.30.50.254 51825 12050
|
||||
|
||||
# Drop any route/conntrack decisions learned before the policy rules existed.
|
||||
# Compose will wait for the gateway healthcheck before exposing dependants.
|
||||
if [ "$(docker inspect -f '{{.State.Running}}' mike-ai-wireguard-gateway 2>/dev/null || true)" = true ]; then
|
||||
docker restart mike-ai-wireguard-gateway >/dev/null
|
||||
fi
|
||||
@@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=Route Mike AI Docker egress through the WireGuard gateway container
|
||||
After=docker.service
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/mike-ai-container-vpn-guard
|
||||
RemainAfterExit=yes
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Reference in New Issue
Block a user