2

TL;DR; I'm trying to setup a bunch of internet facing services (web, smtp, other) on a machine running on my LAN and forward traffic to it from a public facing VPS machine using Wireguard in such a way that the source IPs for that traffic are preserved (i.e. not breaking fail2ban and friends). It's kicking my butt...

I'm trying to rework my homelab/homeserver setup a bit and I'm well into the pulling my hair out phase.

I have a single Ubuntu server machine (we'll call this "backend") that hosts a bunch of services (HTTP, HTTPS, SMTP, IMAP) via. Docker. This machine is hidden behind NAT, and I'd like it to stay that way.

I have a lightweight VPS machine (also Ubuntu) (we'll call this "frontend"). Let's pretend it has the well known IP of 123.456.789.123.

I would like to create a Wireguard tunnel between the two machines and have inbound traffic to the "frontend" VPS (80,443,25,465,587,993, ...) be forwarded to the appropriate service on the "backend" in such a way that:

  • I can physically move the backend machine and it'll "just work" (i.e. the "backend" initiates the tunnel)
  • The source IP address for connections is visible to the underlying services on the VPS (i.e. I don't want to just use something like rinetd, Caddy, or masquerade connections since that won't work for the non-proxy friendly traffic)
  • I don't want ALL traffic from the backend machine to be dumped through the frontend, only stuff that originated from the frontend.
  • I want to be able to access these services from other containers on the VPS (i.e. hairpin NAT type thing - specifically several of the containers need to connect to the SMTP server container)

Here is a rough picture of what I have in mind...

Here is a diagram of what I have in mind

The closest resource I've found on this is the following (specifically the example on Policy Routing) but I think my use of Docker is complicating this.

https://www.procustodibus.com/blog/2022/09/wireguard-port-forward-from-internet/#default-route

Also this post:

https://unix.stackexchange.com/questions/708264/vps-port-forwarding-without-snat-masquerade-using-source-based-routing

(I'll focus on HTTP for the samples below)

With the following Wireguard configuration on the "frontend":

[Interface]
PrivateKey = ###
Address = 10.99.1.2
ListenPort = 51822

# packet forwarding
PreUp = sysctl -w net.ipv4.ip_forward=1

# port forwarding (HTTP) // repeat for each port
PreUp = iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 80 -j DNAT --to-destination 10.99.1.1
PostDown = iptables -t nat -D PREROUTING -i eth0 -p tcp --dport 80 -j DNAT --to-destination 10.99.1.1

# remote settings for the private server
[Peer]
PublicKey = ###
AllowedIPs = 10.99.1.1

And the wireguard configuration for the "backend".

[Interface]
PrivateKey = ####
Address = 10.99.1.1
Table = vpsrt

PreUp = sysctl -w net.ipv4.ip_forward=1

PreUp = ip rule add from 10.99.1.1 table vpsrt priority 1
PostDown = ip rule del from 10.99.1.1 table vpsrt priority 1

# remote settings for the public server
[Peer]
PublicKey = ####
Endpoint = 123.456.789.123:51822
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = 25

Interestingly, with the above I can ping the "backend" from the "frontend" but not the reverse. I'm not sure if that's a first indication that I'm doing something wrong.

With the above configuration, if I run a webserver directly on the "backend" machine (i.e. python3 -m http.server 80), it works like a charm.

Specifically:

  • From Internet: curl http://123.456.789.123 works (source IP = client IP! Yay!)
  • From Frontend: curl http://10.99.1.1 works (source IP = 10.99.1.2)
  • From Backend: curl http://10.99.1.1 works (source IP = 10.99.1.1)
  • From Backend: curl http://123.456.789.123 mostly works (source IP = public IP for my LAN, would prefer this traffic was kept internal)

Great success!

Problem: I can't make this work with containerized services running on the "backend"

However... if I were to then move my HTTP server into a container, it stops working...

services:
  whoami:
    image: "containous/whoami"
    ports:
     - 80:80
    restart: always

I think the issue is that I don't really have a service running on port 80 on the backend machine but, instead, I have a service running on some Docker assigned internal IP and then a iptables rule forwarding traffic on port 80 to that container. Unfortunately, I still have a bunch of gaps in my knowledge here and I'm not sure how to troubleshoot or resolve this.

Things I've tried:

  • Forwarding all traffic from the "backend" to the "frontend" (removing the policy based routing table and PreUp/PostDown from the "backend" configuration and adding masquerading to the "frontend"). This got close to working but now I'm dumping all my traffic through the VPS (meh) and hairpinning doesn't work.
  • I tried assigning static IPs to the containers for the Mail and Web Server. I changed my "frontend" configuration to use those IPs, and added them to the AllowedIPs on the server. I couldn't get this working.

1 Answer 1

3

Your analysis is correct: it's not working with containerized services because connections to containers are forwarded (as if they were on another host) instead of bound to a local port on the backend host directly.

You don't need to change anything with your WireGuard configuration; you do need to do a little more work to route responses from the containers back through the WireGuard tunnel. There are two approaches you can take:

  1. Add a policy routing rule for each container or network
  2. Mark WireGuard connections & add a rule for reply traffic

1. Add a policy routing rule for each container or network

This is the simpler approach, but the downside is that each of these containers will use the WireGuard tunnel for all of their external outbound traffic. Any external outbound traffic they initiate (eg for software updates or connections to third-party services) or attempts they make to reply to other external connections (eg from other hosts on your LAN) won't work unless you add higher-priority rules to exempt that particular traffic.

To use this approach, add a policy routing rule on the backend host for the IP address (or network) of each container that you want to use the WireGuard tunnel. For example, if the IP address of your Docker web server is 172.17.0.2 and the IP address of your Docker mail server is 172.17.0.3, add these two rules:

ip rule add from 172.17.0.2 table vpsrt priority 2
ip rule add from 172.17.0.3 table vpsrt priority 3

Alternatively, if you wanted to route all the traffic from your entire 172.17.0.0/16 Docker network through the WireGuard tunnel, you could add just one rule for 172.17.0.0/16 instead of individual rules for individual containers:

ip rule add from 172.17.0.0/16 table vpsrt priority 2

2. Mark WireGuard connections & add a rule for reply traffic

This is the more complicated approach, but the upside is that it will apply only to connections that originated inbound through the WireGuard tunnel -- leaving all other traffic unmolested.

The background you need to know for this is that you can apply one arbitrary 32-bit "mark" value to individual packets (aka fwmark aka firewall mark aka nfmark aka netfilter mark aka packet mark); and a separate "mark" value to bidirectional connection flows (aka ctmark aka connmark aka connection mark). Linux policy routing interacts only with the packet mark; but you also need to use the connection mark in order to track which packets are replies to connections that were originally initiated inbound through the WireGuard tunnel.

The first step of this approach is to mark new connections with a connection mark of 1 when they initially come into the backend host through the WireGuard tunnel:

iptables -t mangle -A PREROUTING -i wg0 -m state --state NEW -j CONNMARK --set-mark 1

The second step is to set the packet mark to 1 whenever the connection mark is 1 and the packet hasn't come in through the WireGuard interface (ie for reply packets from your Docker networks):

iptables -t mangle -A PREROUTING ! -i wg0 -m connmark --mark 1 -j MARK --set-mark 1

And the third step is to route packets with a packet mark of 1 using your custom vpsrt table (which will send them out the WireGuard tunnel):

ip rule add fwmark 1 table vpsrt priority 2

The mark value of 1 is completely arbitrary -- you could use any 32-bit value (and you could use different values for the connection mark and packet mark); bear in mind that there is only one mark field for each connection or packet, so if you need to mark other connections/packets for other purposes, you will need to use different mark values to distinguish between them.

1
  • I thought "reverse SNAT" would be performed before route decision, but turns out it is indeed "post-routing" just like "normal SNAT", and therefore ip rules need to match with the "original" source addresses.
    – Tom Yan
    Commented Oct 27, 2023 at 12:17

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .