From 615ba8bb86c87d8004cab0b2d7e6a2b078fc02fe Mon Sep 17 00:00:00 2001 From: Garrett Mills Date: Sat, 26 Sep 2026 17:18:50 -0500 Subject: [PATCH] Add wireguard-sidecar --- .gitignore | 1 + .woodpecker.yaml | 15 ++++ jump-box/Dockerfile | 2 - wireguard-sidecar/Dockerfile | 12 +++ wireguard-sidecar/Makefile | 8 ++ wireguard-sidecar/entrypoint.sh | 80 +++++++++++++++++ wireguard-sidecar/wait-vpn-ready.sh | 6 ++ wireguard-sidecar/wg-convert-config.sh | 113 +++++++++++++++++++++++++ 8 files changed, 235 insertions(+), 2 deletions(-) create mode 100644 wireguard-sidecar/Dockerfile create mode 100644 wireguard-sidecar/Makefile create mode 100644 wireguard-sidecar/entrypoint.sh create mode 100644 wireguard-sidecar/wait-vpn-ready.sh create mode 100644 wireguard-sidecar/wg-convert-config.sh diff --git a/.gitignore b/.gitignore index 92ecf48..5eb1ae1 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 # User-specific stuff +.idea .idea/**/workspace.xml .idea/**/tasks.xml .idea/**/usage.statistics.xml diff --git a/.woodpecker.yaml b/.woodpecker.yaml index bbd88b5..497631b 100644 --- a/.woodpecker.yaml +++ b/.woodpecker.yaml @@ -30,3 +30,18 @@ steps: password: from_secret: registry_password depends_on: [] + + - name: build-wireguard-sidecar + image: woodpeckerci/plugin-docker-buildx:6.1.1 + settings: + registry: registry.apps.millslan.net + repo: registry.apps.millslan.net/garrettmills/wireguard-sidecar + tags: latest + platforms: linux/amd64 + context: wireguard-sidecar + dockerfile: wireguard-sidecar/Dockerfile + username: + from_secret: registry_username + password: + from_secret: registry_password + depends_on: [] diff --git a/jump-box/Dockerfile b/jump-box/Dockerfile index 5dc8acf..e765c4e 100644 --- a/jump-box/Dockerfile +++ b/jump-box/Dockerfile @@ -1,7 +1,5 @@ FROM fedora:44 -# TODO: k8s client - ENV TARGET_USER=garrettmills ENV TARGET_UID=1000 ENV TARGET_GID=1000 diff --git a/wireguard-sidecar/Dockerfile b/wireguard-sidecar/Dockerfile new file mode 100644 index 0000000..cbb443e --- /dev/null +++ b/wireguard-sidecar/Dockerfile @@ -0,0 +1,12 @@ +FROM fedora:latest + +RUN dnf install -y wg-quick curl iproute openresolv iptables-nft procps-ng \ + && dnf clean all -y + +COPY entrypoint.sh /entrypoint.sh +COPY wg-convert-config.sh /wg-convert-config.sh +COPY wait-vpn-ready.sh /wait-vpn-ready.sh +RUN chmod +x /entrypoint.sh /wg-convert-config.sh /wait-vpn-ready.sh + +ENV WG_TARGET=wg0 +CMD ["/entrypoint.sh"] diff --git a/wireguard-sidecar/Makefile b/wireguard-sidecar/Makefile new file mode 100644 index 0000000..25d416e --- /dev/null +++ b/wireguard-sidecar/Makefile @@ -0,0 +1,8 @@ + +.PHONY: image +image: Dockerfile + docker build -t ${DOCKER_REGISTRY}/wireguard-sidecar:latest -f Dockerfile . + +.PHONY: push +push: + docker push ${DOCKER_REGISTRY}/wireguard-sidecar:latest diff --git a/wireguard-sidecar/entrypoint.sh b/wireguard-sidecar/entrypoint.sh new file mode 100644 index 0000000..a1715ea --- /dev/null +++ b/wireguard-sidecar/entrypoint.sh @@ -0,0 +1,80 @@ +#!/bin/bash -e + +ACTING_AS_PID1="${ACTING_AS_PID1:-yes}" +WG_TARGET="${WG_TARGET:-wg0}" +REAL_PUBLIC_IP="$(curl -4 ifconfig.me)" +CURRENT_PUBLIC_IP="${REAL_PUBLIC_IP}" +DETECT_LEAK="${DETECT_LEAK:-yes}" + +wgs::log() { + echo "[wgs]" "$(date -u +%Y-%m-%dT%H:%M:%S%Z)" "$@" +} + +wgs::convert_wg_config() { + # Some directives in a wg-config do not work well in containers, e.g. "DNS" + # So, convert the ones we can to a container-safe equivalent. + /wg-convert-config.sh "/etc/wireguard/${WG_TARGET}.conf" "/etc/wireguard/ctr${WG_TARGET}.conf" +} + +wgs::up() { + wgs::convert_wg_config + wg-quick up "ctr${WG_TARGET}" + + # wg-quick's AllowedIPs=0.0.0.0/0 installs "lookup main suppress_prefixlength 0", + # which hides the pod's default route so everything else falls into the tunnel. + # That catches replies to inbound connections too, and the web UI goes dark for + # any client outside this node's pod subnet. Replies are sourced from the pod + # IP, so keying on that restores them without touching outbound traffic, which + # is routed before a source is chosen and so never matches this rule. + # Deleted first because the pod's netns outlives the container: on a restart + # the previous rule is still installed and a second add would stack. + ip -4 rule del from "$POD_IP" lookup main priority 100 2>/dev/null || true + ip -4 rule add from "$POD_IP" lookup main priority 100 +} + +wgs::refresh_public_ip() { + CURRENT_PUBLIC_IP="$(curl -4 ifconfig.me)" +} + +wgs::public_ip_is_masked() { + wgs::refresh_public_ip + [ "$CURRENT_PUBLIC_IP" != "$REAL_PUBLIC_IP" ] && return 0 || return 1 +} + +wgs::down() { + wgs::log "Cleaning up..." + ip -4 rule del from "$POD_IP" lookup main priority 100 2>/dev/null || true + wg-quick down "ctr${WG_TARGET}" || true + rm -f /vpn-online.touch + exit 0 +} + +wgs::pid1() { + wgs::log "Starting PID1..." + trap wgs::down INT TERM + + wgs::log "Our real public IP is: ${REAL_PUBLIC_IP}" + + wgs::up + wgs::refresh_public_ip + wgs::log "Our new public IP is: ${CURRENT_PUBLIC_IP}" + + if [ "$DETECT_LEAK" = yes ] && [ "$CURRENT_PUBLIC_IP" = "$REAL_PUBLIC_IP" ]; then + wgs::log "Failed to mask IP on startup" + wgs::down + fi + + touch /vpn-online.touch + + while :; do + sleep 60 + if [ "$DETECT_LEAK" = yes ] && ! wgs::public_ip_is_masked; then + wgs::log "Detected a leak; stopping" + wgs::down + fi + done +} + +if [ "$ACTING_AS_PID1" = yes ]; then + wgs::pid1 +fi diff --git a/wireguard-sidecar/wait-vpn-ready.sh b/wireguard-sidecar/wait-vpn-ready.sh new file mode 100644 index 0000000..2bf218f --- /dev/null +++ b/wireguard-sidecar/wait-vpn-ready.sh @@ -0,0 +1,6 @@ +#!/bin/bash -e + +while ! [ -f /vpn-online.touch ]; do + echo "Waiting for VPN to come online..." + sleep 5 +done diff --git a/wireguard-sidecar/wg-convert-config.sh b/wireguard-sidecar/wg-convert-config.sh new file mode 100644 index 0000000..94314fe --- /dev/null +++ b/wireguard-sidecar/wg-convert-config.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# +# wg-convert-config — rewrite a WireGuard config's DNS= line as an equivalent +# PostUp command, for containers where openresolv/resolvconf can't run. +# +# usage: wg-convert-config [out.conf] +# wg-convert-config - # write to stdout +# +# With no output path, writes ./ctr. + +set -euo pipefail + +prog=${0##*/} + +usage() { + cat >&2 < [out.conf] + +Moves DNS= out of [Interface] and appends an equivalent PostUp that writes +/etc/resolv.conf directly. Handles comma-separated addresses (v4 and v6) and +treats non-IP values as search domains. Use - as out.conf for stdout. +Defaults to ./ctr. +EOF + exit "${1:-2}" +} + +case ${1:-} in + -h | --help) usage 0 ;; + "" | -*) usage ;; +esac +(($# <= 2)) || usage + +src=$1 +dst=${2:-ctr${src##*/}} + +[[ -r $src ]] || { printf '%s: cannot read %s\n' "$prog" "$src" >&2; exit 1; } +if [[ $dst != - && -e $dst && $src -ef $dst ]]; then + printf '%s: refusing to overwrite the source file\n' "$prog" >&2 + exit 1 +fi + +convert() { + awk ' + BEGIN { q = "\047" } + + function flushpend( i) { + for (i = 1; i <= npend; i++) print pend[i] + npend = 0 + } + + function emit( i, out) { + if (!nns && !nsd) return + out = "PostUp = printf " q "%s\\n" q + for (i = 1; i <= nns; i++) out = out " " q "nameserver " ns[i] q + if (nsd) { + out = out " " q "search" + for (i = 1; i <= nsd; i++) out = out " " sd[i] + out = out q + } + print out " > /etc/resolv.conf" + if (nns > 3) + printf("%s: %d nameservers emitted; glibc reads only the first 3\n", + prog, nns) > "/dev/stderr" + nns = 0; nsd = 0 + } + + # Section header: close out the [Interface] block before leaving it. + /^[[:space:]]*\[/ { + if (iface) emit() + flushpend() + iface = (tolower($0) ~ /^[[:space:]]*\[interface\][[:space:]]*$/) + print + next + } + + # DNS = a, b, c (keys are case-insensitive, # starts a comment) + iface && tolower($0) ~ /^[[:space:]]*dns[[:space:]]*=/ { + s = $0 + sub(/#.*/, "", s) + sub(/^[^=]*=/, "", s) + gsub(/,/, " ", s) + n = split(s, a, /[[:space:]]+/) + for (i = 1; i <= n; i++) { + if (a[i] == "") continue + if (a[i] ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/ || + (a[i] ~ /:/ && a[i] ~ /^[0-9a-fA-F:.%]+$/)) + ns[++nns] = a[i] + else + sd[++nsd] = a[i] + } + next + } + + # Hold blank lines so the PostUp lands above them, not under [Peer]. + iface && /^[[:space:]]*$/ { pend[++npend] = $0; next } + + { flushpend(); print } + + END { if (iface) emit(); flushpend() } + ' prog="$prog" "$src" +} + +if [[ $dst == - ]]; then + convert +else + tmp=$(mktemp -- "$dst.XXXXXX") + trap 'rm -f -- "$tmp"' EXIT + convert > "$tmp" + chmod --reference="$src" -- "$tmp" 2>/dev/null || chmod 600 -- "$tmp" + mv -- "$tmp" "$dst" + trap - EXIT + printf '%s: wrote %s\n' "$prog" "$dst" >&2 +fi