Ghost IP 👻: Make a Remote Server Appear on Your Network via WireGuard and NDP Proxy

Stargazer ZJ

I have a home server (Server B) with both IPv4 and IPv6 addresses, but it isn’t publicly accessible. I also have a school server (Server A) with a publicly accessible, static IPv6 /64 subnet and a private IPv4 address on the school’s LAN. A /64 IPv6 subnet means my school server can assign any IPv6 address within that block to itself, and it will be globally reachable. So, why not give one to my home server?

To assign a IPv6 address to Server A itself, simply pick an unused address in the prefix and run ip -6 addr add NEW_ADDRESS/64 def $PUBLIC_INTERFACE. The address will be available immediately.

After some research, I succeeded in doing just that using WireGuard and NDP Proxy. I also gave my home server a private IPv4 address from the school’s LAN, allowing it to access school resources.

To be clear, here’s what this setup achieves:

  • Server B gets its own IP addresses in Server A’s public /64 IPv6 subnet and private IPv4 subnet. I call these “Ghost IPs.”
  • Full network access is established between Server B and all devices on Server A’s network, and vice-versa. Server B’s assigned public IPv6 address is reachable from anywhere in the world.
  • Split-tunneling by default: Server B’s general internet traffic goes directly out to the internet, not through Server A.
  • Devices on Server B’s original home network are still able to access Server B using its original local IP address.

Essentially, this is functionally equivalent to connecting Server B to the same switch as Server A with a virtual network cable. This equivalence is limited to Layer 3 (the Network Layer), as I’ll explain later.

Differences from FRP

It’s worth noting that in many cases, a tool like FRP is a much simpler solution. FRP can forward a single port or a range of ports from Server A to Server B, which is often enough for accessing simple services like SSH or a web server.

However, this WireGuard solution offers more isolation and flexibility:

  1. It forwards all ports (TCP, UDP, ICMP, etc.) by default, eliminating the need to reconfigure anything when you add a new service.
  2. Server B knows the actual IP addresses of connecting clients. This makes logging, firewalls, and IP-based access controls work correctly without extra configuration. It also improves compatibility with complex protocols that use random ports or initiate connections back to clients.
  3. Independent IP addresses for Server A and Server B mean services will never have port conflicts. An application on Server A listening on :: or 0.0.0.0 won’t interfere with Server B.

Fundamentally, WireGuard operates one layer down the networking stack from FRP, from Layer 4 (Transport) to Layer 3 (Network). This is why protocols like TCP, UDP, and ICMP all work seamlessly. An ICMP ping packet sent to Server B’s Ghost IP actually travels through the WireGuard tunnel to Server B’s kernel and back—something impossible with a Layer 4 tool.

The caveat is that you must manually assign static Ghost IPs for Server B. This is fine for IPv6 but can lead to IP conflicts on a typical IPv4 LAN that uses DHCP. Dynamic IP assignment protocols like DHCP (for IPv4) and parts of NDP (for IPv6) operate on Layer 2 (the Data Link Layer), so they cannot travel through WireGuard’s Layer 3 tunnel.

This article will focus on the simpler IPv6 setup first, then briefly cover the more complex IPv4 case. This is also the official solution to SJTU CS1952 Networking Lab Advanced 2.2.

Prerequisites

  • Two servers running Ubuntu 22.04+.
    • Server A: The server with a public static IPv6 /64 subnet (the school server).
    • Server B: The remote server that can initiate an outbound connection to Server A (the home server).
  • Root or sudo access on both servers.
  • Physical access to both servers. If you mess things up, you may lose ssh connection to your server!

Step 1: Install WireGuard

Run this on both Server A and Server B.

1
2
sudo apt update
sudo apt install wireguard -y

Step 2: Generate WireGuard Keys

Each server needs a key pair to establish a secure tunnel.

On Server A:

1
2
3
4
5
6
7
sudo -i
cd /etc/wireguard
wg genkey | tee server_a_private.key | wg pubkey > server_a_public.key
chmod 600 server_a_private.key
# Note down these keys
cat server_a_private.key
cat server_a_public.key

On Server B:

1
2
3
4
5
6
7
sudo -i
cd /etc/wireguard
wg genkey | tee server_b_private.key | wg pubkey > server_b_public.key
chmod 600 server_b_private.key
# Note down these keys
cat server_b_private.key
cat server_b_public.key

Step 3: Configure WireGuard on Server A (The School Server)

This configuration is the core of the setup. The script below automatically detects your network configuration, but you may manually override some values to adapt to your setup.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
sudo -i

# --- Define Variables (REPLACE 'PASTE_...' with your actual key) ---
SERVER_B_PUBLIC_KEY='PASTE_SERVER_B_PUBLIC_KEY_HERE'

# --- Auto-detect Network Configuration ---
# Find the public network interface
PUBLIC_INTERFACE=$(ip -6 route show default | awk '{print $5}')

# Get Server A's existing public IPv6 and its /64 prefix
SERVER_A_PUBLIC_IPV6=$(ip -6 addr show dev $PUBLIC_INTERFACE | awk '/inet6.*scope global/ && !/temporary/ {print $2}' | cut -d/ -f1 | head -n 1 )
PUBLIC_IPV6_PREFIX=$(echo $SERVER_A_PUBLIC_IPV6 | cut -d: -f1-4)

# Define the new "Ghost IP" for Server B from Server A's subnet
# Important: This must be an unused address in your /64 subnet.
SERVER_B_GHOST_IPV6="${PUBLIC_IPV6_PREFIX}::2"

# Get Server A's private key (if not already in a variable)
SERVER_A_PRIVATE_KEY=$(cat /etc/wireguard/server_a_private.key)

# --- Create WireGuard Configuration ---
cat > /etc/wireguard/wg0.conf << EOF
[Interface]
PrivateKey = $SERVER_A_PRIVATE_KEY
Address = fd00:1234:5678::1/64
ListenPort = 51820
# These PostUp/PostDown scripts automate the NDP proxy and firewall rules
PostUp = ip -6 neigh add proxy $SERVER_B_GHOST_IPV6 dev $PUBLIC_INTERFACE
PostUp = nft 'add table ip6 filter'
PostUp = nft 'list chain ip6 filter FORWARD' &>/dev/null || nft 'add chain ip6 filter FORWARD { type filter hook forward priority 0; policy accept; }'
PostUp = nft add rule ip6 filter FORWARD iifname "$PUBLIC_INTERFACE" oifname "wg0" ip6 daddr $SERVER_B_GHOST_IPV6 accept comment "Allow GhostIPv6 traffic in"
PostDown = ip -6 neigh del proxy $SERVER_B_GHOST_IPV6 dev $PUBLIC_INTERFACE
PostDown = nft delete rule ip6 filter FORWARD handle \$(nft -a list chain ip6 filter FORWARD | grep "Allow GhostIPv6 traffic in" | awk '{print \$NF}')

[Peer]
PublicKey = $SERVER_B_PUBLIC_KEY
AllowedIPs = fd00:1234:5678::2/128, $SERVER_B_GHOST_IPV6/128
EOF

Step 4: Configure WireGuard on Server B (The Home Server)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
sudo -i

# --- Define Variables (REPLACE with values from Server A) ---
SERVER_A_PUBLIC_IPV6="PASTE_SERVER_A_PUBLIC_IPV6_HERE"
SERVER_B_GHOST_IPV6="PASTE_SERVER_B_GHOST_IPV6_HERE"
SERVER_A_PUBLIC_KEY="PASTE_SERVER_A_PUBLIC_KEY_HERE"

# Get Server B's private key
SERVER_B_PRIVATE_KEY=$(cat /etc/wireguard/server_b_private.key)

# --- Create WireGuard Configuration ---
cat > /etc/wireguard/wg0.conf << EOF
[Interface]
PrivateKey = $SERVER_B_PRIVATE_KEY
Address = fd00:1234:5678::2/64, $SERVER_B_GHOST_IPV6/128
Table = off

[Peer]
PublicKey = $SERVER_A_PUBLIC_KEY
Endpoint = [$SERVER_A_PUBLIC_IPV6]:51820
AllowedIPs = ::/0
PersistentKeepalive = 25
EOF

Step 5: Modify Kernel Parameters on Server A

We need to enable IPv6 forwarding and NDP proxying on Server A’s kernel.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
sudo -i

# Find the public interface again if you're in a new session
PUBLIC_INTERFACE=$(ip -6 route show default | awk '{print $5}')

# Enable kernel parameters
sysctl -w net.ipv6.conf.$PUBLIC_INTERFACE.forwarding=1
sysctl -w net.ipv6.conf.wg0.forwarding=1
sysctl -w net.ipv6.conf.$PUBLIC_INTERFACE.proxy_ndp=1

# Make these changes permanent across reboots
cat > /etc/sysctl.d/99-ghost-ip.conf <<EOF
net.ipv6.conf.$PUBLIC_INTERFACE.forwarding=1
net.ipv6.conf.wg0.forwarding=1
net.ipv6.conf.$PUBLIC_INTERFACE.proxy_ndp=1
EOF

Step 6: Start the Services and Verify

Now, let’s bring the tunnel up and check that everything is working.

On Server A:

1
2
3
4
5
6
7
8
9
10
11
# Enable and start the WireGuard service
sudo systemctl enable --now wg-quick@wg0

# --- Verification ---
# Check the WireGuard interface status (after starting wireguard on Server B)
sudo wg show
# You should see a peer with the latest handshake.

# Check that the NDP proxy entry exists
ip -6 neigh show proxy
# Should show your $SERVER_B_GHOST_IPV6 proxied on the public interface.

On Server B:

1
2
3
4
5
6
7
8
9
10
11
# Enable and start the WireGuard service
sudo systemctl enable --now wg-quick@wg0

# --- Verification ---
# Check the WireGuard interface status
sudo wg show
# You should see a peer with the latest handshake and endpoint info.

# Check that the Ghost IP is assigned to the wg0 interface
ip -6 addr show dev wg0
# Should show both fd00:1234:5678::2 and your public $SERVER_B_GHOST_IPV6.

Step 7: Test the Connection

From any third device on the internet (like your laptop), you should now be able to reach Server B using its new public Ghost IP.

1
2
# From a third machine (e.g., your laptop)
ping $SERVER_B_GHOST_IPV6

How It Works: A Brief Explanation

Our setup relies on three key components:

  1. Neighbor Discovery Proxy: Server A announces to its network that a server with IP address SERVER_B_GHOST_IPV6 exists and can be reached through Server A. In IPv6, this is done using Neighbor Discovery Protocol (NDP) proxying. The command ip -6 neigh add proxy ... does just that. This is only required when Server A’s IPv6 address is acquired through Stateless Address Autoconfiguration (SLAAC). There’s another case that the entire /64 prefix is given exclusively to the server, through Prefix Delegation (PD), which is typical for a cloud VM.

  2. WireGuard Tunnel: A standard, secure Layer 3 tunnel is established between the two servers.

  3. Customized Routing:

    • On Server A, we need to forward packets destined for SERVER_B_GHOST_IPV6 from the public interface to the wireguard interface. The FORWARD chain is created in the nftables firewall, if not already created by docker or ufw. nft add rule ip6 filter adds the forwarding rule.
    • On Server B, since we want the entire Internet to be able to connect to Server B via its ghost IP, but Server B should connect to the Internet directly by default (unless responding to a client or manually specified), we must turn Wireguard’s automatic routing off by Table = off and setup the rules manually, which is no additional rules in IPv6’s case.

Troubleshooting

If things aren’t working, here’s a checklist to debug the issue.

  1. Check wg show on both servers. Is there a successful handshake? If not, check your public keys and Server A’s firewall (ensure port 51820 UDP is open).

  2. Check ip addr show dev wg0 on Server B. Does it have the public Ghost IP?

  3. Check ip -6 neigh show proxy on Server A. Is the proxy entry present for the Ghost IP?

  4. Use tcpdump to trace packets. This is the most powerful tool.

    • Start a continuous ping to $SERVER_B_GHOST_IPV6 from a third machine (Server C).
    • On Server A, public interface: sudo tcpdump -i $PUBLIC_INTERFACE -n icmp6. Do you see the incoming Echo Request packets from Server C?
    • On Server A, WireGuard interface: sudo tcpdump -i wg0 -n icmp6. Do you see the Echo Request packets being forwarded into the tunnel? If not, check the forwarding rule (nft list chain inet filter FORWARD).
    • On Server B, WireGuard interface: sudo tcpdump -i wg0 -n icmp6. Do you see the Echo Request packets arriving? Do you see Echo Reply packets being sent back? If not, check Server B’s configuration.
  5. Managing nftables rules:

    • View rules with handles: sudo nft -a list chain ip6 filter FORWARD
    • Delete a specific rule: sudo nft delete rule ip6 filter FORWARD handle <number>
    • Warning: Do not run nft flush ruleset! Other applications like Docker rely on nftables. If you mess things up, restarting the Docker service (sudo systemctl restart docker) can often restore its rules.
  6. Selecting interface manually: Server B can connect to the general internet via Server A by manually selecting the wg0 interface, for example ping -I wg0. This also helps when the interface selection rules does not work as expected.

Extending to the IPv4 case

The IPv4 setup is more complex. In our first key component of the setup, the Address Resolution Protocol (ARP) proxy is used in IPv4 instead of the IPv6-specific NDP. The configuration interface is similar, though. In the third component, additional routing rules and kernel parameters need to be set. I won’t dive into a full explaination here and will simply provide an example configuration.

IPv4 & IPv6 Full Setup

Note:

  • 192.168.10.2/16 and fd00:1234:5678::1/64 are private subnets within the Wireguard scope.
  • 10.100.100.10/28 is Server B’s ghost IPv4, in Server A’s LAN CIDR. 2001:db8:1::2 is Server B’s ghost IPv6.
  • 10.20.20.10/16 is Server B’s original IPv4.

Warning: Make sure the three IPv4 ranges above do not overlap with each other! Or your devices may lose connection to the Internet.

Server A:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[Interface]
PrivateKey = ...
Address = fd00:1234:5678::1/64, 192.168.10.1/16
ListenPort = 51820

# IPv4 configuration
PostUp = ip neigh add proxy 10.100.100.10 dev enp0s25
PostUp = nft add rule ip filter FORWARD iifname "enp0s25" oifname "wg0" ip daddr 10.100.100.10 accept comment \"wg0-ipv4-rule\"
PostUp = nft add rule ip nat POSTROUTING iifname "wg0" oifname "enp0s25" ip saddr 192.168.10.2 snat to 10.100.100.10 comment \"wg0-snat-rule\"

PostDown = ip neigh del proxy 10.100.100.10 dev enp0s25
PostDown = nft delete rule ip filter FORWARD handle $(nft -a list chain ip filter FORWARD | grep "wg0-ipv4-rule" | awk '{print $NF}' | tr -d '#')
PostDown = nft delete rule ip nat POSTROUTING handle $(nft -a list chain ip nat POSTROUTING | grep "wg0-snat-rule" | awk '{print $NF}' | tr -d '#')

# IPv6 configuration
PostUp = ip -6 neigh add proxy 2001:db8:1::2 dev enp0s25
PostUp = nft add rule ip6 filter FORWARD iifname "enp0s25" oifname "wg0" ip6 daddr 2001:db8:1::2 accept comment \"wg0-ipv6-rule-1\"
PostDown = ip -6 neigh del proxy 2001:db8:1::2 dev enp0s25
PostDown = nft delete rule ip6 filter FORWARD handle $(nft -a list chain ip6 filter FORWARD | grep "wg0-ipv6-rule-1" | awk '{print $NF}' | tr -d '#')

[Peer]
PublicKey = ...
AllowedIPs = fd00:1234:5678::2/128, 2001:db8:1::2/128, 10.100.100.10/32, 192.168.10.2/32

Server B:

1
2
3
4
5
6
7
8
9
10
11
12
[Interface]
PrivateKey = ...
Address = fd00:1234:5678::2/64, 2001:db8:1::2/128, 192.168.10.2/16, 10.100.100.10/28
Table = off
PostUp = ip rule add from 10.100.100.10 lookup 200; ip route add default dev wg0 table 200
PostDown = ip rule del from 10.100.100.10 lookup 200; ip route del default dev wg0 table 200 2>/dev/null || true

[Peer]
PublicKey = ...
Endpoint = ...
AllowedIPs = ::/0, 0.0.0.0/0
PersistentKeepalive = 25
  • Title: Ghost IP 👻: Make a Remote Server Appear on Your Network via WireGuard and NDP Proxy
  • Author: Stargazer ZJ
  • Created at : 2025-07-23 19:44:18
  • Updated at : 2025-07-26 17:33:27
  • Link: https://ji-z.net/2025/07/23/Ghost-IP-Make-a-Remote-Server-Appear-on-Your-Network-via-WireGuard-and-NDP-Proxy/
  • License: This work is licensed under CC BY-NC-SA 4.0.