--- apiVersion: v1 kind: ConfigMap metadata: name: amneziawg-scripts data: firewall-up.sh: | #!/usr/bin/env bash set -euo pipefail PORT="${1:-5847}" VPN_CIDR="${2:-10.8.0.0/16}" POLICY_FILE="${3:-/run/amnezia/policy/policy.conf}" FORWARD_CHAIN="AMNEZIAWG-FORWARD" POLICY_CHAIN_A="AMNEZIAWG-POLICY-A" POLICY_CHAIN_B="AMNEZIAWG-POLICY-B" IPTABLES=(iptables -w 30) external_interface() { ip route get 1.1.1.1 | awk '{for (i=1;i<=NF;i++) if ($i=="dev") {print $(i+1); exit}}' } ensure_rule() { local mode="$1" shift local table_args=() if [ "${1:-}" = "-t" ]; then table_args=("$1" "$2") shift 2 fi local chain="$1" shift if ! "${IPTABLES[@]}" "${table_args[@]}" -C "${chain}" "$@" >/dev/null 2>&1; then "${IPTABLES[@]}" "${table_args[@]}" "${mode}" "${chain}" "$@" fi } delete_rule() { local table_args=() if [ "${1:-}" = "-t" ]; then table_args=("$1" "$2") shift 2 fi local chain="$1" shift while "${IPTABLES[@]}" "${table_args[@]}" -D "${chain}" "$@" >/dev/null 2>&1; do true done } ensure_chain() { "${IPTABLES[@]}" -N "$1" >/dev/null 2>&1 || true } valid_ipv4_32() { local value="$1" [[ "${value}" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}/32$ ]] || return 1 local address="${value%/32}" local octet IFS=. read -r -a octets <<< "${address}" [ "${#octets[@]}" -eq 4 ] || return 1 for octet in "${octets[@]}"; do [[ "${octet}" =~ ^[0-9]+$ ]] || return 1 [ "${octet}" -le 255 ] || return 1 done } routed_through_awg0() { ip -4 route get "${1%/32}" 2>/dev/null | grep -Eq '(^|[[:space:]])dev awg0([[:space:]]|$)' } active_policy_chain() { "${IPTABLES[@]}" -S "${FORWARD_CHAIN}" 2>/dev/null \ | awk '$1 == "-A" && $3 == "-j" && $4 ~ /^AMNEZIAWG-POLICY-[AB]$/ { print $4; exit }' } apply_policy() { local active inactive source destination extra active="$(active_policy_chain || true)" if [ "${active}" = "${POLICY_CHAIN_A}" ]; then inactive="${POLICY_CHAIN_B}" else inactive="${POLICY_CHAIN_A}" fi ensure_chain "${inactive}" "${IPTABLES[@]}" -F "${inactive}" if [ -f "${POLICY_FILE}" ]; then while read -r source destination extra; do [ -n "${source:-}" ] || continue [[ "${source}" == \#* ]] && continue if [ -n "${extra:-}" ] \ || ! valid_ipv4_32 "${source}" \ || ! valid_ipv4_32 "${destination:-}" \ || ! routed_through_awg0 "${source}" \ || ! routed_through_awg0 "${destination}"; then echo "Invalid or out-of-tunnel policy line: ${source:-} ${destination:-} ${extra:-}" >&2 "${IPTABLES[@]}" -F "${inactive}" return 1 fi "${IPTABLES[@]}" -A "${inactive}" \ -i awg0 -o awg0 -s "${source}" -d "${destination}" \ -m comment --comment amneziawg-group-pair -j ACCEPT done < "${POLICY_FILE}" fi # This DROP is deliberately restricted to packets entering and leaving # awg0. It cannot match Kubernetes, CNI, kubelet, Tailscale, or host traffic. "${IPTABLES[@]}" -A "${inactive}" \ -i awg0 -o awg0 -s "${VPN_CIDR}" -d "${VPN_CIDR}" \ -m comment --comment amneziawg-client-isolation -j DROP "${IPTABLES[@]}" -A "${inactive}" \ -i awg0 -o "${EXT_IF}" -s "${VPN_CIDR}" \ -m conntrack --ctstate NEW,ESTABLISHED,RELATED \ -m comment --comment amneziawg-internet-out -j ACCEPT "${IPTABLES[@]}" -A "${inactive}" \ -i "${EXT_IF}" -o awg0 -d "${VPN_CIDR}" \ -m conntrack --ctstate ESTABLISHED,RELATED \ -m comment --comment amneziawg-internet-return -j ACCEPT "${IPTABLES[@]}" -A "${inactive}" -j RETURN # Insert the complete new policy before removing the old jump. There is # never a window with a partially built or absent active policy. "${IPTABLES[@]}" -I "${FORWARD_CHAIN}" 1 -j "${inactive}" if [ -n "${active}" ]; then "${IPTABLES[@]}" -D "${FORWARD_CHAIN}" -j "${active}" fi } EXT_IF="$(external_interface || true)" if [ -z "${EXT_IF}" ]; then EXT_IF="$(ip route show default | awk '{print $5; exit}')" fi if [ -z "${EXT_IF}" ]; then echo "Unable to detect external interface" exit 1 fi sysctl -w net.ipv4.ip_forward=1 sysctl -w net.ipv4.conf.awg0.send_redirects=0 # Remove rules written by older revisions. In particular, the unqualified # tailscale UDP DROP also blocks Flannel VXLAN (UDP/8472). delete_rule INPUT -i tailscale0 -p udp -m comment --comment amneziawg-block-tailscale -j DROP delete_rule INPUT -i "${EXT_IF}" -p udp -m comment --comment amneziawg-allow-external -j ACCEPT delete_rule INPUT -i "${EXT_IF}" -p udp --dport "${PORT}" -m comment --comment amneziawg-allow-external -j ACCEPT delete_rule INPUT -i tailscale0 -p udp --dport "${PORT}" -m comment --comment amneziawg-block-tailscale -j DROP delete_rule INPUT -i awg0 -m comment --comment amneziawg-awg-input -j ACCEPT delete_rule FORWARD -i awg0 -m comment --comment amneziawg-forward-in -j ACCEPT delete_rule FORWARD -o awg0 -m comment --comment amneziawg-forward-out -j ACCEPT delete_rule INPUT -m comment --comment amneziawg-input-jump -j AMNEZIAWG-INPUT "${IPTABLES[@]}" -F AMNEZIAWG-INPUT >/dev/null 2>&1 || true "${IPTABLES[@]}" -X AMNEZIAWG-INPUT >/dev/null 2>&1 || true ensure_chain "${FORWARD_CHAIN}" ensure_chain "${POLICY_CHAIN_A}" ensure_chain "${POLICY_CHAIN_B}" if ! "${IPTABLES[@]}" -S "${FORWARD_CHAIN}" 2>/dev/null | grep -q -- '-j RETURN'; then "${IPTABLES[@]}" -A "${FORWARD_CHAIN}" -j RETURN fi apply_policy delete_rule "${FORWARD_CHAIN}" -i awg0 -m comment --comment amneziawg-forward-in -j ACCEPT delete_rule "${FORWARD_CHAIN}" -o awg0 -m comment --comment amneziawg-forward-out -j ACCEPT # The jump is first, but non-awg traffic immediately RETURNs to the original # host FORWARD chain without an ACCEPT or DROP decision. delete_rule FORWARD -m comment --comment amneziawg-forward-jump -j "${FORWARD_CHAIN}" "${IPTABLES[@]}" -I FORWARD 1 \ -m comment --comment amneziawg-forward-jump -j "${FORWARD_CHAIN}" ensure_rule -A -t nat POSTROUTING -s "${VPN_CIDR}" -o "${EXT_IF}" -m comment --comment amneziawg-masquerade -j MASQUERADE /scripts/firewall-check.sh "${PORT}" "${VPN_CIDR}" firewall-down.sh: | #!/usr/bin/env bash set -euo pipefail PORT="${1:-5847}" VPN_CIDR="${2:-10.8.0.0/16}" FORWARD_CHAIN="AMNEZIAWG-FORWARD" POLICY_CHAIN_A="AMNEZIAWG-POLICY-A" POLICY_CHAIN_B="AMNEZIAWG-POLICY-B" IPTABLES=(iptables -w 30) external_interface() { ip route get 1.1.1.1 | awk '{for (i=1;i<=NF;i++) if ($i=="dev") {print $(i+1); exit}}' } delete_rule() { local table_args=() if [ "${1:-}" = "-t" ]; then table_args=("$1" "$2") shift 2 fi local chain="$1" shift while "${IPTABLES[@]}" "${table_args[@]}" -D "${chain}" "$@" >/dev/null 2>&1; do true done } EXT_IF="$(external_interface || true)" if [ -z "${EXT_IF}" ]; then EXT_IF="$(ip route show default | awk '{print $5; exit}')" fi if [ -n "${EXT_IF}" ]; then delete_rule INPUT -i "${EXT_IF}" -p udp -m comment --comment amneziawg-allow-external -j ACCEPT delete_rule INPUT -i "${EXT_IF}" -p udp --dport "${PORT}" -m comment --comment amneziawg-allow-external -j ACCEPT delete_rule -t nat POSTROUTING -s "${VPN_CIDR}" -o "${EXT_IF}" -m comment --comment amneziawg-masquerade -j MASQUERADE fi delete_rule INPUT -i tailscale0 -p udp --dport "${PORT}" -m comment --comment amneziawg-block-tailscale -j DROP delete_rule INPUT -i tailscale0 -p udp -m comment --comment amneziawg-block-tailscale -j DROP delete_rule INPUT -i awg0 -m comment --comment amneziawg-awg-input -j ACCEPT delete_rule FORWARD -i awg0 -m comment --comment amneziawg-forward-in -j ACCEPT delete_rule FORWARD -o awg0 -m comment --comment amneziawg-forward-out -j ACCEPT delete_rule INPUT -m comment --comment amneziawg-input-jump -j AMNEZIAWG-INPUT delete_rule FORWARD -m comment --comment amneziawg-forward-jump -j "${FORWARD_CHAIN}" "${IPTABLES[@]}" -F AMNEZIAWG-INPUT >/dev/null 2>&1 || true "${IPTABLES[@]}" -X AMNEZIAWG-INPUT >/dev/null 2>&1 || true "${IPTABLES[@]}" -F "${FORWARD_CHAIN}" >/dev/null 2>&1 || true "${IPTABLES[@]}" -F "${POLICY_CHAIN_A}" >/dev/null 2>&1 || true "${IPTABLES[@]}" -F "${POLICY_CHAIN_B}" >/dev/null 2>&1 || true "${IPTABLES[@]}" -X "${POLICY_CHAIN_A}" >/dev/null 2>&1 || true "${IPTABLES[@]}" -X "${POLICY_CHAIN_B}" >/dev/null 2>&1 || true "${IPTABLES[@]}" -X "${FORWARD_CHAIN}" >/dev/null 2>&1 || true firewall-check.sh: | #!/usr/bin/env bash set -euo pipefail PORT="${1:-5847}" VPN_CIDR="${2:-10.8.0.0/16}" FORWARD_CHAIN="AMNEZIAWG-FORWARD" IPTABLES=(iptables -w 5) external_interface() { ip route get 1.1.1.1 | awk '{for (i=1;i<=NF;i++) if ($i=="dev") {print $(i+1); exit}}' } EXT_IF="$(external_interface || true)" if [ -z "${EXT_IF}" ]; then EXT_IF="$(ip route show default | awk '{print $5; exit}')" fi if [ -z "${EXT_IF}" ]; then echo "Unable to detect external interface" >&2 exit 1 fi if "${IPTABLES[@]}" -C INPUT -i tailscale0 -p udp -m comment --comment amneziawg-block-tailscale -j DROP >/dev/null 2>&1; then echo "Unsafe broad UDP DROP on tailscale0 is present" >&2 exit 1 fi if "${IPTABLES[@]}" -C INPUT -i "${EXT_IF}" -p udp -m comment --comment amneziawg-allow-external -j ACCEPT >/dev/null 2>&1; then echo "Unsafe broad UDP ACCEPT on ${EXT_IF} is present" >&2 exit 1 fi if "${IPTABLES[@]}" -C INPUT -m comment --comment amneziawg-input-jump -j AMNEZIAWG-INPUT >/dev/null 2>&1; then echo "Unexpected AmneziaWG INPUT chain is present" >&2 exit 1 fi FIRST_FORWARD_RULE="$("${IPTABLES[@]}" -S FORWARD | grep '^-A FORWARD ' | sed -n '1p')" [[ "${FIRST_FORWARD_RULE}" == *"--comment amneziawg-forward-jump"* ]] [[ "${FIRST_FORWARD_RULE}" == *"-j ${FORWARD_CHAIN}"* ]] ACTIVE_POLICY="$("${IPTABLES[@]}" -S "${FORWARD_CHAIN}" 2>/dev/null \ | awk '$1 == "-A" && $3 == "-j" && $4 ~ /^AMNEZIAWG-POLICY-[AB]$/ { print $4; exit }')" [ -n "${ACTIVE_POLICY}" ] "${IPTABLES[@]}" -C "${ACTIVE_POLICY}" \ -i awg0 -o awg0 -s "${VPN_CIDR}" -d "${VPN_CIDR}" \ -m comment --comment amneziawg-client-isolation -j DROP "${IPTABLES[@]}" -C "${ACTIVE_POLICY}" \ -i awg0 -o "${EXT_IF}" -s "${VPN_CIDR}" \ -m conntrack --ctstate NEW,ESTABLISHED,RELATED \ -m comment --comment amneziawg-internet-out -j ACCEPT "${IPTABLES[@]}" -C "${ACTIVE_POLICY}" \ -i "${EXT_IF}" -o awg0 -d "${VPN_CIDR}" \ -m conntrack --ctstate ESTABLISHED,RELATED \ -m comment --comment amneziawg-internet-return -j ACCEPT "${IPTABLES[@]}" -t nat -C POSTROUTING -s "${VPN_CIDR}" -o "${EXT_IF}" -m comment --comment amneziawg-masquerade -j MASQUERADE [ "$(sysctl -n net.ipv4.conf.awg0.send_redirects)" = "0" ] awg show awg0 >/dev/null 2>&1 run.sh: | #!/usr/bin/env bash set -euo pipefail SERVER_CONFIG="/etc/amnezia/server/awg0.conf" CLIENTS_DIR="${AMNEZIAWG_CLIENTS_DIR:-/run/amnezia/clients}" POLICY_FILE="${AMNEZIAWG_POLICY_FILE:-/run/amnezia/policy/policy.conf}" CONFIG_GENERATION="${AMNEZIAWG_CONFIG_GENERATION:-/run/amnezia/config-generation}" RUNTIME_CONFIG="/run/amnezia/awg0.conf" SYNC_CONFIG="/run/amnezia/awg0.sync.conf" STATUS_FILE="/run/amnezia/reload-status" RELOAD_INTERVAL="${AMNEZIAWG_RELOAD_INTERVAL:-10}" cleanup() { if awg show awg0 >/dev/null 2>&1; then awg-quick down "${RUNTIME_CONFIG}" || ip link delete awg0 || true fi } render_config() { mkdir -p "$(dirname "${RUNTIME_CONFIG}")" local tmp_config="${RUNTIME_CONFIG}.tmp" cp "${SERVER_CONFIG}" "${tmp_config}" chmod 0600 "${tmp_config}" local clients_found=0 for client_config in "${CLIENTS_DIR}"/*; do [ -f "${client_config}" ] || continue [ -s "${client_config}" ] || continue printf '\n' >> "${tmp_config}" cat "${client_config}" >> "${tmp_config}" clients_found=1 done if [ "${clients_found}" = "0" ]; then echo "No client peer configs found in ${CLIENTS_DIR}; starting without peers" fi mv "${tmp_config}" "${RUNTIME_CONFIG}" chmod 0600 "${RUNTIME_CONFIG}" } runtime_config_hash() { if [ -f "${CONFIG_GENERATION}" ]; then sha256sum "${CONFIG_GENERATION}" | awk '{print $1}' else printf 'missing\n' fi } write_reload_status() { local state="${1}" local hash="${2:-}" local applied_at_ms="" if [ "${state}" = "applied" ]; then applied_at_ms="$(($(date +%s) * 1000))" fi mkdir -p "$(dirname "${STATUS_FILE}")" { printf 'state=%s\n' "${state}" printf 'hash=%s\n' "${hash}" printf 'applied_at_ms=%s\n' "${applied_at_ms}" } > "${STATUS_FILE}.tmp" mv "${STATUS_FILE}.tmp" "${STATUS_FILE}" } apply_live_config() { # Fail closed during a peer/key and policy transition. /dev/null produces # an Internet-only policy with all awg0-to-awg0 traffic isolated. /scripts/firewall-up.sh 5847 10.8.0.0/16 /dev/null render_config awg-quick strip "${RUNTIME_CONFIG}" > "${SYNC_CONFIG}" chmod 0600 "${SYNC_CONFIG}" awg syncconf awg0 "${SYNC_CONFIG}" /scripts/firewall-up.sh 5847 10.8.0.0/16 "${POLICY_FILE}" } watch_client_config() { local last_hash="${1}" while true; do sleep "${RELOAD_INTERVAL}" & wait "$!" || return 0 local current_hash current_hash="$(runtime_config_hash)" if [ "${current_hash}" = "${last_hash}" ]; then continue fi echo "Detected AmneziaWG client peer config change; applying with awg syncconf" if apply_live_config; then last_hash="${current_hash}" write_reload_status applied "${current_hash}" awg show awg0 || true else echo "ERROR: failed to hot-reload AmneziaWG client peer config" >&2 write_reload_status error "${current_hash}" fi done } trap cleanup EXIT trap 'exit 0' TERM INT initial_hash="$(runtime_config_hash)" render_config cleanup awg-quick up "${RUNTIME_CONFIG}" awg show awg0 || true write_reload_status applied "${initial_hash}" watch_client_config "${initial_hash}" client-secret-sync.sh: | #!/usr/bin/env bash set -euo pipefail CLIENT_SECRET="${AMNEZIAWG_CLIENT_SECRET:-amneziawg-clients}" CLIENT_SECRET_KEY="${AMNEZIAWG_CLIENT_SECRET_KEY:-peers.conf}" CLIENTS_DIR="${AMNEZIAWG_CLIENTS_DIR:-/run/amnezia/clients}" PEERS_FILE="${CLIENTS_DIR}/peers.conf" POLICY_FILE="${AMNEZIAWG_POLICY_FILE:-/run/amnezia/policy/policy.conf}" CONFIG_GENERATION="${AMNEZIAWG_CONFIG_GENERATION:-/run/amnezia/config-generation}" SYNC_INTERVAL="${AMNEZIAWG_CLIENT_SECRET_SYNC_INTERVAL:-5}" NAMESPACE="${POD_NAMESPACE:-$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace)}" write_empty_once() { mkdir -p "${CLIENTS_DIR}" if [ ! -f "${PEERS_FILE}" ]; then : > "${PEERS_FILE}" chmod 0600 "${PEERS_FILE}" fi mkdir -p "$(dirname "${POLICY_FILE}")" if [ ! -f "${POLICY_FILE}" ]; then : > "${POLICY_FILE}" chmod 0600 "${POLICY_FILE}" fi { sha256sum "${PEERS_FILE}" sha256sum "${POLICY_FILE}" } | sha256sum | awk '{print $1}' > "${CONFIG_GENERATION}" } sync_once() { mkdir -p "${CLIENTS_DIR}" mkdir -p "$(dirname "${POLICY_FILE}")" local snapshot="${CONFIG_GENERATION}.snapshot" local peers_tmp="${PEERS_FILE}.tmp" local policy_tmp="${POLICY_FILE}.tmp" local peers_encoded policy_encoded generation if ! kubectl get secret "${CLIENT_SECRET}" -n "${NAMESPACE}" \ -o "go-template={{ with index .data \"${CLIENT_SECRET_KEY}\" }}{{ . }}{{ end }}{{ \"\n\" }}{{ with index .data \"policy.conf\" }}{{ . }}{{ end }}{{ \"\n\" }}" \ > "${snapshot}" 2>/dev/null; then echo "WARN: failed to read Secret ${NAMESPACE}/${CLIENT_SECRET}; keeping current peers" >&2 rm -f "${snapshot}" write_empty_once return 0 fi peers_encoded="$(sed -n '1p' "${snapshot}")" policy_encoded="$(sed -n '2p' "${snapshot}")" rm -f "${snapshot}" if [ -n "${peers_encoded}" ]; then printf '%s' "${peers_encoded}" | base64 -d > "${peers_tmp}" else : > "${peers_tmp}" fi if [ -n "${policy_encoded}" ]; then printf '%s' "${policy_encoded}" | base64 -d > "${policy_tmp}" else # Missing policy is fail-closed: no client-to-client pairs are allowed. : > "${policy_tmp}" fi chmod 0600 "${peers_tmp}" "${policy_tmp}" generation="$({ sha256sum "${peers_tmp}" sha256sum "${policy_tmp}" } | sha256sum | awk '{print $1}')" if [ -f "${CONFIG_GENERATION}" ] \ && [ "$(sed -n '1p' "${CONFIG_GENERATION}")" = "${generation}" ]; then rm -f "${peers_tmp}" "${policy_tmp}" return 0 fi mv "${policy_tmp}" "${POLICY_FILE}" mv "${peers_tmp}" "${PEERS_FILE}" printf '%s\n' "${generation}" > "${CONFIG_GENERATION}.tmp" mv "${CONFIG_GENERATION}.tmp" "${CONFIG_GENERATION}" echo "Synced AmneziaWG peers and policy from Secret ${NAMESPACE}/${CLIENT_SECRET}" } if [ "${1:-}" = "once" ]; then sync_once exit 0 fi while true; do sync_once || true sleep "${SYNC_INTERVAL}" done status-patch.sh: | #!/usr/bin/env bash set -euo pipefail STATUS_FILE="/run/amnezia/reload-status" PATCH_INTERVAL="${AMNEZIAWG_STATUS_PATCH_INTERVAL:-5}" NAMESPACE="${POD_NAMESPACE:-$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace)}" : "${POD_NAME:?POD_NAME is required}" last_file_hash="" patch_status() { local state="unknown" local hash="" local applied_at_ms="" # The file is generated by run.sh and contains only shell assignments. # shellcheck disable=SC1090 source "${STATUS_FILE}" kubectl patch pod "${POD_NAME}" -n "${NAMESPACE}" --type merge -p "{\"metadata\":{\"annotations\":{\"amnezia-fellow.hexor.cy/client-secret-reload-status\":\"${state}\",\"amnezia-fellow.hexor.cy/client-secret-applied-at-ms\":\"${applied_at_ms}\",\"amnezia-fellow.hexor.cy/client-secret-applied-hash\":\"${hash}\"}}}" } while true; do if [ -f "${STATUS_FILE}" ]; then file_hash="$(sha256sum "${STATUS_FILE}" | awk '{print $1}')" if [ "${file_hash}" != "${last_file_hash}" ]; then patch_status || true last_file_hash="${file_hash}" fi fi sleep "${PATCH_INTERVAL}" done