IPv4 and IPv6
How to get IPv4 support in an IPv6 only server?
An IPv6-only EC2 instance is completely isolated from the legacy IPv4 internet. It has no public IPv4 address, meaning any attempt to pull a Docker image, reach an IPv4-only API, or simply git clone from GitHub will silently time out.
I fix this by routing outbound IPv4 traffic through Cloudflare's dual-stack WARP network using a WireGuard tunnel. The IPv4 packets go into the tunnel over the native IPv6 network, emerge from a Cloudflare data center that actually has IPv4, and reach the internet.
Inbound web traffic continues to flow through my reverse proxy over IPv6. My SSH session stays strictly on native IPv6. The tunnel is solely an outbound escape hatch for legacy traffic.
The Black-Box Problem
I do not use the official warp-cli daemon for this. Installing an opaque, root-level daemon that aggressively modifies host routing tables on a remote, headless server is a recipe for an instant SSH lockout. Instead, I use wgcf—an open-source tool that simply completes the WARP registration API handshake and hands you back a standard, raw WireGuard configuration file.
This means I maintain absolute control over the routing table via standard wireguard-tools. Nothing intercepts my traffic before I have explicitly authorized it.
The AllowedIPs Landmine
The default WireGuard config generated by wgcf includes AllowedIPs = 0.0.0.0/0, ::/0. Do not ever bring the tunnel up with this default.
WireGuard's AllowedIPs directive dictates what traffic gets vacuumed into the tunnel. If you use the default, you route your active IPv6 SSH session directly into the void. Worse, you route 169.254.169.254—the AWS Instance Metadata Service (IMDS)—into Cloudflare. The moment IMDS disappears, AWS IAM roles break, CloudWatch agents crash, and your instance effectively goes deaf and blind.
To survive, we need a mathematical quarantine: an AllowedIPs list that includes the entire IPv4 internet, minus every local, private, and AWS-reserved IP range.
Implementation Guide
1. The Bootstrapping Problem
Because the server cannot reach IPv4, it cannot download the wgcf binary from GitHub. You must download it to your local machine and SCP it over your existing IPv6 SSH connection.
Local Machine:
wget https://github.com/ViRb3/wgcf/releases/download/v2.2.30/wgcf_2.2.30_linux_amd64 -O wgcf
scp -P 22 wgcf <your_ssh_alias>:~/wgcfTarget Server:
sudo dnf install -y wireguard-tools
chmod +x ~/wgcf
sudo mv ~/wgcf /usr/local/bin/wgcf2. Generating the Identity
Run the registration. This creates an account and provisions the initial tunnel configuration.
wgcf register
wgcf generateThis spits out wgcf-profile.conf. Leave it alone for a moment.
3. Computing the Quarantine
I refuse to hand-jam CIDR blocks. Doing manual subnet math to exclude the AWS metadata service is exactly how you accidentally leak a routing rule and lock yourself out. I wrote a Python script to do the math and run 18 sanity checks against the output before I ever touch the config.
Save this as compute_wgcf_allowedips.py on the server and run it:
#!/usr/bin/env python3
import ipaddress, sys
DEFAULT_EXCLUDE = [
"10.0.0.0/8", # RFC1918
"172.16.0.0/12", # RFC1918 (also covers WARP internal IP 172.16.0.2)
"192.168.0.0/16", # RFC1918
"169.254.0.0/16", # Link-local — AWS IMDS
"127.0.0.0/8", # Loopback
"100.64.0.0/10", # Shared address space (CGNAT)
"162.159.192.1/32", # Cloudflare WARP UDP endpoint (prevents infinite loop)
"224.0.0.0/4", # Multicast
"240.0.0.0/4", # Reserved
]
def subtract_networks(base, exclude):
result = list(base)
for excl in exclude:
new_result = []
for net in result:
if net.overlaps(excl):
new_result.extend(net.address_exclude(excl))
else:
new_result.append(net)
result = new_result
return sorted(ipaddress.collapse_addresses(result), key=lambda n: (n.network_address, n.prefixlen))
def main():
exclude_nets = [ipaddress.ip_network(c, strict=True) for c in DEFAULT_EXCLUDE]
result = subtract_networks([ipaddress.ip_network("0.0.0.0/0")], exclude_nets)
cidrs = [str(n) for n in result]
print("AllowedIPs = " + ", ".join(cidrs))
if __name__ == "__main__":
main()Run it: python3 compute_wgcf_allowedips.py.
Copy the massive AllowedIPs string it outputs. It should contain roughly 75 distinct CIDR blocks.
4. Applying the Configuration
Open the generated wgcf-profile.conf.
- Wipe the default
AllowedIPsline. - Paste the massive computed string from the script.
- Ensure there is absolutely no mention of
::/0in the file.
Move it into place:
sudo cp wgcf-profile.conf /etc/wireguard/wgcf.conf5. The Verification Gauntlet
Do not enable the systemd autostart yet. If your config is broken and you reboot, it will intercept your SSH session on startup and you will lose the instance. Bring it up manually first:
sudo wg-quick up wgcfNow, verify the network state exactly in this order:
| Test | Command | Expected Result |
|---|---|---|
| SSH Intact | (Keep typing in your terminal) | Session remains active. No hanging. |
| IPv4 Outbound | curl -4 https://ifconfig.me | Returns a Cloudflare IPv4 address. |
| IPv6 Untouched | curl -6 https://ifconfig.me | Returns your VPS's native IPv6 address. |
| IMDS Alive | curl -s http://169.254.169.254/latest/meta-data/ | Responds instantly. If it hangs, kill the tunnel immediately (sudo wg-quick down wgcf). |
| GitHub Reachable | git ls-remote https://github.com/ViRb3/wgcf.git HEAD | Returns a commit hash. |
6. Autostart
Only after every single check in the gauntlet passes perfectly should you tell systemd to keep this alive across reboots.
sudo systemctl enable wg-quick@wgcfEmergency Recovery
If you ignore the warnings, enable a broken config to autostart, and lock yourself out of SSH, you are fucked.
Your only recovery path is to stop the instance, detach the root EBS volume, mount it to a temporary rescue instance in the same Availability Zone, and manually delete the /etc/systemd/system/multi-user.target.wants/wg-quick@wgcf.service symlink. Detach, reattach to the original instance, and boot.