WireGuard 1:1 Static NAT for Improved Visibility and Reduced Intranet Configuration

There are seemingly infinite possibilities when it comes to configuring WireGuard as a “VPN Server”. Specifically, allowing a remote peer to access the intranet on the “server” end can be accomplished in a variety of ways. In this discussion we’ll assume that the WireGuard Server is running on Ubuntu Server 22.04.

TL;DR

Use the following shell script to automatically apply the a 1:1 Static NAT network configuration for WireGuard:

#!/bin/bash

show_help() {
    cat << 'EOF'
# ==============================================================================
# SCRIPT: wgconfnat (WireGuard 1:1 NAT & Proxy ARP Configuration Manager)
# AUTHOR: LJU 2026-09-17
# 
# DESCRIPTION:
#   Automates dynamic 1:1 SNAT/DNAT iptables rules, Proxy ARP entries, and /32 IP
#   aliases for WireGuard peers based on AllowedIPs suffixes in the interface conf.
#   When invoked without arguments, provides a colorized active NAT audit.
#
# Typically stored at /usr/local/sbin
#
# USAGE:
#   wgconfnat                                       - Display usage & currently applied 1:1 NAT config
#   wgconfnat <up|down>             - Enable/disable NAT mapping (auto-detects LAN interface)
#   wgconfnat <up|down>   [eth_if]  - Enable/disable NAT using explicit LAN interface
#
# PARAMETERS:
#   up|down   - Specifies whether to apply (up) or remove (down) the configuration
#   wg_if     - The Wireguard interface to configure (%i can be used within the WireGuard config file.
#   base_ip   - The starting intranet ip address to map peers to. This should always be on your local subnet, 
#                outside of your DHCP scope. For example, if your internal subnet is 10.10.0.0/24 (10.10.0.1 - 10.10.0.254)
#                and your DHCP scope is 10.10.0.1 - 10.10.0.99, then you could specify a starting a base_ip of 10.10.0.100
#                and a peer with AllowedIP 10.10.20.12 would be mapped to 10.10.0.112.
#   eth_if    - The explicit LAN interface to use for mapping (Optional)
#
# This will typically be called by the PostUp and PreDown directives in the [Interface] block 
#  of a WireGuard Interface Config like this:
#   PostUp = /usr/local/sbin/wgconfnat up %i 10.10.0.100     - Bring UP NAT for wg0 with base host IP 10.10.0.100
#   PreDown = /usr/local/sbin/wgconfnat down %i 10.10.0.100 eth0 - Take DOWN NAT for wg0 explicitly targeting eth0
#
# DYNAMIC MAPPING LOGIC:
#   1. Resolves WireGuard network prefix from 'Address =' in /etc/wireguard/.conf
#   2. Extracts peer IP suffixes from 'AllowedIPs =' entries (e.g., suffix '5' from 10.10.7.5/32).
#   3. Calculates mapped LAN IP dynamically: BASE_OCTET + peer_suffix (e.g., 100 + 5 = 10.10.3.105).
#   4. Applies/Removes 1:1 SNAT/DNAT iptables rules, Proxy ARP entries, and /32 IP aliases on LAN interface.
#
# ==============================================================================
EOF
}

# --- ANSI COLOR CODES ---
GREEN=$(tput setaf 2 2>/dev/null || echo -e "\033[0;32m")
YELLOW=$(tput setaf 3 2>/dev/null || echo -e "\033[0;33m")
CYAN=$(tput setaf 6 2>/dev/null || echo -e "\033[0;36m")
BRIGHT=$(tput bold 2>/dev/null || echo -e "\033[1m")
NORMAL=$(tput sgr0 2>/dev/null || echo -e "\033[0m")

ACTION="$1"
WG_IF="$2"
BASE_MAPPED_IP="$3"

# --- TROUBLESHOOTING MODE (No Parameters Provided) ---
if [ -z "$ACTION" ]; then
    echo "${GREEN}"
    show_help
    echo "${NORMAL}"
    echo "${BRIGHT}--- CURRENTLY APPLIED 1:1 NAT CONFIGURATION ---${NORMAL}"
    
    echo -e "\n${YELLOW}[1] Active /32 IP Aliases:${NORMAL}"
    ALIASES=$(ip addr show | grep -E "inet .*\/32")
    if [ -n "$ALIASES" ]; then
        echo "$ALIASES"
    else
        echo "    (None found)"
    fi

    echo -e "\n${YELLOW}[2] Proxy ARP Entries:${NORMAL}"
    PROXY_ARP=$(ip neigh show proxy)
    if [ -n "$PROXY_ARP" ]; then
        echo "$PROXY_ARP"
    else
        echo "    (None found)"
    fi

    echo -e "\n${YELLOW}[3] Active 1:1 NAT Rules (iptables):${NORMAL}"
    NAT_RULES=$(iptables -t nat -S 2>/dev/null | grep -E 'SNAT|DNAT')
    if [ -n "$NAT_RULES" ]; then
        echo "$NAT_RULES"
    else
        echo "    (None found)"
    fi

    echo -e "\n${YELLOW}[4] Active WireGuard Forwarding Rules (iptables):${NORMAL}"
    FWD_RULES=$(iptables -S FORWARD 2>/dev/null | grep -E 'ACCEPT' | grep -E '\-i wg|\-o wg')
    if [ -n "$FWD_RULES" ]; then
        echo "$FWD_RULES"
    else
        echo "    (None found)"
    fi

    echo -e "\n${CYAN}=================================================================${NORMAL}\n"
    exit 0
fi

# --- ARGUMENT VALIDATION ---
if [ -z "$WG_IF" ] || [ -z "$BASE_MAPPED_IP" ]; then
    echo "[ERROR] Missing required parameters." >&2
    echo "Usage: wgconfnat <up|down>   [eth_interface]" >&2
    exit 1
fi

# 1. Interface Resolution:
#    a. Use 4th argument if explicitly provided.
#    b. Detect interface facing the BASE_MAPPED_IP network using kernel route table.
#    c. Fall back to system default route if route lookup is empty.
if [ -n "$4" ]; then
    ETH_IF="$4"
else
    ETH_IF=$(ip route get "$BASE_MAPPED_IP" 2>/dev/null | awk '/dev/ {for(i=1;i<=NF;i++) if($i=="dev") print $(i+1)}' | head -n 1)
    
    if [ -z "$ETH_IF" ]; then
        ETH_IF=$(ip route show default 2>/dev/null | awk '/default/ {print $5}' | head -n 1)
    fi
fi

if [ -z "$ETH_IF" ]; then
    echo "[ERROR] Could not detect LAN interface for $BASE_MAPPED_IP and no default interface found!" >&2
    exit 1
fi

CONF_FILE="/etc/wireguard/${WG_IF}.conf"

if [ ! -f "$CONF_FILE" ]; then
    echo "[ERROR] Configuration file $CONF_FILE not found." >&2
    exit 1
fi

# 2. Dynamically extract the WireGuard subnet prefix from [Interface] Address (e.g. "10.10.7.")
WG_PREFIX=$(grep -iE '^\s*Address\s*=' "$CONF_FILE" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.' | head -n 1)

if [ -z "$WG_PREFIX" ]; then
    echo "[ERROR] Could not extract WireGuard subnet prefix from $CONF_FILE" >&2
    exit 1
fi

# 3. Extract base IP components for calculation (e.g. "10.10.3." and "100" from "10.10.3.100")
MAPPED_SUBNET=$(echo "$BASE_MAPPED_IP" | awk -F. '{print $1"."$2"."$3"."}')
BASE_OCTET=$(echo "$BASE_MAPPED_IP" | awk -F. '{print $4}')

# 4. Configure Kernel sysctl Settings
if [ "$ACTION" = "up" ]; then
    sysctl -w net.ipv4.ip_forward=1 >/dev/null
    sysctl -w net.ipv4.conf.all.proxy_arp=1 >/dev/null
    sysctl -w "net.ipv4.conf.${ETH_IF}.proxy_arp=1" >/dev/null
fi

# 5. Extract allowed peer IP suffixes (last octet) from [Peer] entries
SUFFIXES=$(grep -iE '^\s*AllowedIPs\s*=' "$CONF_FILE" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | awk -F. '{print $4}' | sort -u)

for i in $SUFFIXES; do
    # Calculate target last octet dynamically using integer math
    CALC_OCTET=$(( BASE_OCTET + i ))

    # --- VALIDATION CHECK (SKIP & CONTINUE) ---
    if [ "$CALC_OCTET" -gt 254 ] || [ "$CALC_OCTET" -lt 1 ]; then
        echo "[WARNING] Skipping Peer suffix .$i ($BASE_OCTET + $i = $CALC_OCTET): Exceeds valid IPv4 host range (1-254)!" >&2
        continue
    fi

    MAPPED_IP="${MAPPED_SUBNET}${CALC_OCTET}"
    PEER_IP="${WG_PREFIX}${i}"

    if [ "$ACTION" = "up" ]; then
        ip addr add "${MAPPED_IP}/32" dev "$ETH_IF" 2>/dev/null
        ip neigh add proxy "$MAPPED_IP" dev "$ETH_IF" 2>/dev/null
        iptables -t nat -A POSTROUTING -s "${PEER_IP}/32" -o "$ETH_IF" -j SNAT --to-source "$MAPPED_IP"
        iptables -t nat -A PREROUTING -i "$ETH_IF" -d "${MAPPED_IP}/32" -j DNAT --to-destination "$PEER_IP"
    elif [ "$ACTION" = "down" ]; then
        ip neigh del proxy "$MAPPED_IP" dev "$ETH_IF" 2>/dev/null
        ip addr del "${MAPPED_IP}/32" dev "$ETH_IF" 2>/dev/null
        iptables -t nat -D POSTROUTING -s "${PEER_IP}/32" -o "$ETH_IF" -j SNAT --to-source "$MAPPED_IP" 2>/dev/null
        iptables -t nat -D PREROUTING -i "$ETH_IF" -d "${MAPPED_IP}/32" -j DNAT --to-destination "$PEER_IP" 2>/dev/null
    fi
done

# 6. Interface-wide forwarding rules
if [ "$ACTION" = "up" ]; then
    iptables -A FORWARD -i "$WG_IF" -o "$ETH_IF" -j ACCEPT
    iptables -A FORWARD -i "$ETH_IF" -o "$WG_IF" -m state --state RELATED,ESTABLISHED -j ACCEPT
elif [ "$ACTION" = "down" ]; then
    iptables -D FORWARD -i "$WG_IF" -o "$ETH_IF" -j ACCEPT 2>/dev/null
    iptables -D FORWARD -i "$ETH_IF" -o "$WG_IF" -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null
fi

The whole story…

Dynamic NAT (MASQUERADE / Port Address Translation / Source NAT)

This method is pretty simple to configure and allows remote peers to access intranet hosts with no additional network configuration. To accomplish this, the WireGuard config file (usually at /etc/wireguard/wg0.conf) will look something like this:

[Interface]
Address = 10.10.6.1/24
PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
ListenPort = 51820
PrivateKey = <ServerPrivateKey>

[Peer]
PublicKey = <PeerPublicKey>
AllowedIPs = 10.10.6.10/32

The PostUp and PostDown directives determine how the traffic is routed from the WireGuard peer to and from the intranet. In this case, the WireGuard subnet is 10.10.6.0/24 (10.10.6.1 – 10.10.6.254), with the WireGuard Server having an address of 10.10.6.1 and the single remote peer is given an address of 10.10.6.10.

The disadvantage of this approach is a lack of visibility of remote peers. All traffic coming from remote peers appears to come from the intranet IP address of the WireGuard Server. This makes it difficult to correlate peers to specific intranet activity.

Direct Routing (No NAT / Plain IP Forwarding)

This method also looks simple in the config file, with just some subtle changes to the PostUp and PostDown directives in the [Interface] block:

PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT
PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT

This allows routing of the WireGuard IPs directly to the intranet subnet. Intranet hosts will see the connection coming from each remote WireGuard peer IP, rather than the Intranet IP of the WireGuard Server. The disadvantage is that, in order to respond appropriately, intranet hosts must require additional configuration.

Subnet Routing

In order to know where to respond to a request from the WireGuard subnet, intranet hosts must have routing information. Say the WireGuard Server’s intranet IP is 10.10.0.66 and the WireGuard subnet is 10.10.6.0/24. Intranet hosts must know to route traffic for addresses in the 10.10.6.1 – 10.10.6.254 range to 10.10.0.66.

This can be accomplished with a Classless Static Route configured on the DHCP server. For hosts with static IP configurations, the route must be manually added to the host configuration. For a Windows host, a persistent static route can be added with the following command:

route -p add 10.10.6.0 MASK 255.255.255.0 10.10.0.66

Host Firewall

Then there’s the issue of intranet hosts’ internal firewalls. Windows hosts are generally configured to only allow incoming traffic from the local subnet, in this case 10.10.0.0/22 (10.10.0.1 – 10.10.3.254). Traffic from the WireGuard subnet, 10.10.6.0/24, would be rejected. To resolve this, incoming firewall rules need to be updated to allow incoming traffic from WireGuard peers. For instance, to allow file sharing to WireGuard peers, the scope of the File and Printer Sharing rules can be updated to include the WireGuard subnet using Powershell:

Set-NetFirewallRule -DisplayGroup "File and Printer Sharing" ` -RemoteAddress @('10.10.0.0/22', '10.10.6.0/24')

This can be automated using group policy, but it can become unnecessarily complicated.

Static 1:1 NAT (1:1 SNAT/DNAT with Proxy ARP)

This method is requires the most complicated configuration on the WireGuard server but, similar to Dynamic NAT, it requires no additional intranet host configuration and, similar to Direct Routing, provides each remote WireGuard peer with a unique intranet IP address—the best of both worlds.

On Linux systems, in this case Ubuntu 22.04, this approach can be implemented by the following WireGuard configuration, which includes comments explaining each network configuration directive:

[Interface]
Address = 10.10.6.1/24
ListenPort = 5182 t0
PrivateKey = <ServerPrivateKey>

# --- TUNNEL STARTUP (PostUp) ---

# Enables IPv4 packet routing in the Linux kernel so traffic can flow between wg0 and eth0.
PostUp = sysctl -w net.ipv4.ip_forward=1

# Enables Proxy ARP globally across all system interfaces.
PostUp = sysctl -w net.ipv4.conf.all.proxy_arp=1

# Enables Proxy ARP specifically on the physical LAN interface (eth0).
PostUp = sysctl -w net.ipv4.conf.eth0.proxy_arp=1

# Loop through peer ID numbers 10 through 30 to configure per-peer 1:1 NAT and ARP proxying:
# 1. 'ip addr add': Binds a virtual /32 LAN IP alias (10.10.3.X) to eth0 so the server owns the IP on the LAN.
# 2. 'ip neigh add proxy': Forces the kernel to reply to LAN ARP requests ("who-has 10.10.3.X") with eth0's MAC address.
# 3. 'iptables ... SNAT': Translates outbound WG peer traffic (10.10.6.X) to appear as its unique LAN alias (10.10.3.X).
# 4. 'iptables ... DNAT': Translates inbound LAN replies sent to 10.10.3.X back to the remote WG peer IP (10.10.6.X).
PostUp = for i in $(seq 10 30); do ip addr add 10.10.3.$i/32 dev eth0; ip neigh add proxy 10.10.3.$i dev eth0; iptables -t nat -A POSTROUTING -s 10.10.6.$i/32 -o eth0 -j SNAT --to-source 10.10.3.$i; iptables -t nat -A PREROUTING -i eth0 -d 10.10.3.$i/32 -j DNAT --to-destination 10.10.6.$i; done

# Allows all outbound traffic originating from the WireGuard tunnel (wg0) to pass out to the physical LAN (eth0).
PostUp = iptables -A FORWARD -i wg0 -o eth0 -j ACCEPT

# Allows return traffic from the physical LAN (eth0) back into the WireGuard tunnel (wg0) for established connections.
PostUp = iptables -A FORWARD -i eth0 -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT


# --- TUNNEL TEARDOWN (PreDown) ---

# Loops through IDs 10 to 30 to remove the proxy ARP entries and unbind the virtual /32 LAN IP aliases from eth0.
PreDown = for i in $(seq 10 30); do ip neigh del proxy 10.10.3.$i dev eth0; ip addr del 10.10.3.$i/32 dev eth0; done

# Flushes (deletes) all NAT POSTROUTING translation rules created during startup.
PreDown = iptables -t nat -F POSTROUTING

# Flushes (deletes) all NAT PREROUTING translation rules created during startup.
PreDown = iptables -t nat -F PREROUTING

# Removes the firewall forwarding rule that allowed wg0-to-eth0 outbound traffic.
PreDown = iptables -D FORWARD -i wg0 -o eth0 -j ACCEPT

# Removes the firewall forwarding rule that allowed return eth0-to-wg0 traffic.
PreDown = iptables -D FORWARD -i eth0 -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT

[Peer]
PublicKey = <PeerPublicKey>
AllowedIPs = 10.10.6.10/32

The example above uses a range of IP address suffixes from 10 through 30, as defined by the $(seq 10 30)  syntax in each for loop. To target specific addresses rather than a range, the for loop can be written as for i in 10 15 22 45; do..., where the suffixes 10, 15, 22, 45 are represented as a space-separated list.