Thank you for all your hints in the comments. Based on them I have come up with two possible solutions.
Solution 1: ip rules
Assuming that I have two interfaces eth1
and eth2
, and eth1
should be the default device to use for outgoing connections, I will define a second routing table that will use eth2
and set up an ip rule that uses this routing table in response to incoming requests on eth2
:
iptables -t mangle -A INPUT -j CONNMARK -i eth2 --set-mark 2
iptables -t mangle -A OUTPUT -j CONNMARK -m connmark --mark 2 --restore-mark
ip route add 192.168.1.0/24 dev eth2 table 1234
ip route add default via 192.168.1.1 table 1234
ip rule add fwmark 2 lookup 1234
(For IPv6, repeat the same using ip6tables
and ip -6
.)
CONNMARK
adds a mark to the connection (including the response) as opposed to MARK
, which adds a mark to the (incoming) packet only. The INPUT
rule sets the connmark
on requests coming in on eth2
and their responses. The OUTPUT
rule with --restore-mark
will copy the connmark
into a mark
, which is necessary because the fwmark
matcher of the ip rule matches only mark
s but not connmark
s.
In the OUTPUT
rule I added -m connmark --mark 2
to make sure that the mark is only set for packages that have a connmark
of 2
. I added this because otherwise it was overriding the mark of all outgoing connections, which was for example breaking Wireguard which relies on its own marks.
Routed connections
The above rules only match incoming connections on eth2
that are dealt with by the machine itself, but it does not match connections that are routed to another machine. To support that use case, use these additional rules:
iptables -t mangle -A PREROUTING -j CONNMARK -m connmark --mark 2 --restore-mark
iptables -t mangle -A PREROUTING -j CONNMARK -i eth2 --set-mark 2
The second rule will mark every packet coming in through eth2
, while the first rule will match every packet whose connection already has a connmark of 2
and will copy that mark from the connection to the packet (making sure that the response to a packet that originally came in through eth2
gets marked as well).
For this to work, the route destinations need to be added to table 1234
as well!
Solution 2: ip namespaces
With this solution, the two network interfaces will be completely separated from each other. Each process will be assigned to one of the namespaces and will thus see only one of the two network interfaces. This means that services that should be reachable through both network interfaces (for example ssh) need to be started twice, once for each namespace. Since the processes and network interfaces are strictly separated from each other, the response is automatically sent over the right interface. As far as I understand, this even means that when I SSH onto the machine over one specific network interface, any outgoing network requests that I initiate from that SSH session will always use that same network interface.
I have not tried out this solution, since it is not quite perfect for my use case, but from the information that I could find online it should be configured like this:
ip netns add net2
ip link set eth2 netns net2
You can then run any command in the scope of namespace net2
by using ip netns exec net2 <command>
. Any DHCP clients or other software configuring or using the network interface would have to be started this way. It seems to me that there is no wide support for namespaces among the network configuration solutions of the different Linux distributions yet, so some manual scripting will probably be necessary to get this to work.