This article is incomplete as of yet. It describes how to setup a network namespace based seperation of concerns for arbitrary downloaders on a Debian system without leaking traffic from downloading via the main network/internet facing interfaces.

Install Packages

$ apt install jq bc openvpn
#Download netns-ctl from $SOURCE (not published yet) 
cd netns-ctl
$ sudo make install
$ sudo -i
mkdir -p /etc/netns/{protonvpn-ch,vm-down}/{network/{if-down.d,if-post-down.d,if-pre-up.d,if-up.d,netns-scripts,interfaces.d},iptables}
vim /etc/network/interfaces.d/pvpn-ch
allow-hotplug pvpn-ch
auto pvpn-ch
iface pvpn-ch inet static
 address 10.33.0.1
 netmask 255.255.255.252
vim /etc/network/interfaces.d/vm-down
allow-hotplug vm-down
auto vm-down
iface vm-down inet static
 address 10.33.0.9
 netmask 255.255.255.252
vim /etc/netns/protonvpn-ch/network/interfaces
auto main
iface main inet static
 address 10.33.0.2
 netmask 255.255.255.252
 up ip route add 185.159.157.0/24 via 10.33.0.1 dev main
 up ip route add 193.36.117.0/24 via 10.33.0.1 dev main

allow-hotplug vm-down
auto vm-down
iface vm-down inet static
 address 10.33.0.5
 netmask 255.255.255.252

auto lo
iface lo inet loopback
vim /etc/netns/vm-down/network/interfaces
allow-hotplug main
auto main
iface main inet static
 address 10.33.0.10
 netmask 255.255.255.252

allow-hotplug pvpn-ch
auto pvpn-ch
iface pvpn-ch inet static
 address 10.33.0.6
 netmask 255.255.255.252
 up ip route add default via 10.33.0.5 dev pvpn-ch
touch /etc/netns/protonvpn-ch/resolv.conf.vpn
vim /etc/netns/protonvpn-ch/resolv.conf
nameserver 127.0.0.1 
vim /etc/netns/vm-down/resolv.conf
nameserver 127.0.0.1
vim /etc/netns/vm-down/resolv.conf.vpn
nameserver 10.33.0.5
vim /etc/netns/protonvpn-ch/iptables/rules.v4
# Generated by xtables-save v1.8.2 on Tue Oct 20 19:35:40 2020
*filter
:INPUT ACCEPT [0:0]
:FORWARD ACCEPT [0:0]
:OUTPUT ACCEPT [0:0]
COMMIT
# Completed on Tue Oct 20 19:35:40 2020
# Generated by xtables-save v1.8.2 on Tue Oct 20 19:35:40 2020
*nat
:PREROUTING ACCEPT [0:0]
:INPUT ACCEPT [0:0]
:POSTROUTING ACCEPT [0:0]
:OUTPUT ACCEPT [0:0]
-A POSTROUTING -o tun0 -j MASQUERADE
COMMIT
# Completed on Tue Oct 20 19:35:40 2020
touch /etc/netns/protonvpn-ch/iptables/rules.v6
touch /etc/netns/vm-down/iptables/rules.v4
touch /etc/netns/vm-down/iptables/rules.v6
vim /etc/netns/protonvpn-ch/network/netns-scripts/protonvpn-ch
#!/bin/sh

set -e;
set -x;

TASK="$1";
NS="$2";

case "$TASK" in
        up-outer)
                ifup pvpn-ch
                ;;
        down-outer)
                ;;
        up-inner)
                echo "DUMMY"
                iptables-restore < /etc/netns/protonvpn-ch/iptables/rules.v4
                ip6tables-restore < /etc/netns/protonvpn-ch/iptables/rules.v6
                ;;
esac
chmod +x /etc/netns/protonvpn-ch/network/netns-scripts/protonvpn-ch

vim /etc/network/netns-ctl.conf
netns main
 pid 1
 auto link main-protonvpn-ch
 auto link main-vm-down
 end

netns protonvpn-ch
 pid foreign
 auto link main-protonvpn-ch
 auto link protonvpn-ch-vm-down
 end

netns vm-down
 pid foreign
 auto link main-vm-down
 auto link protonvpn-ch-vm-down
 end

link main-protonvpn-ch
 iface main in protonvpn-ch
 iface pvpn-ch in main
 end

link protonvpn-ch-vm-down
 iface vm-down in protonvpn-ch
 iface pvpn-ch in vm-down
 end

link main-vm-down
 iface main in vm-down
 iface vm-down in main

TODO: Maybe have netns-ctl balk if there are auto links but no link tags?

vim /etc/systemd/system/netns\@.service
[Unit]
Description=Network Namespace %i
Wants=network-pre.target
Before=network-pre.target

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=bash -c 'mkdir -p /run/netns &&  touch /run/netns/"%i" && mount --bind /proc/self/ns/net /run/netns/"%i"'
ExecStop=/usr/bin/ip netns delete "%i"
KillMode=none

[Install]
WantedBy=multi-user.target
vim /etc/systemd/system/netns-ctl\@.service
[Unit]
Description=Network Namespace - CTL %i
Wants=network-pre.target
Before=network-pre.target
After=netns@%i.service
BindsTo=netns@%i.service

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/netns-ctl netnsup %i
ExecStop=/usr/local/sbin/netns-ctl netnsdown %i
KillMode=none

[Install]
WantedBy=multi-user.target
mkdir -p /etc/systemd/system/netns@{protonvpn-ch,vm-down}.service.d/
$ vim /etc/systemd/system/netns@protonvpn-ch.service.d/override.conf
[Service]
PrivateNetwork=yes
[Unit]
After=netns@protonvpn-ch.service
BindsTo=netns@protonvpn-ch.service

$ vim /etc/systemd/system/netns@vm-down.service.d/override.conf
[Service]
PrivateNetwork=yes

[Unit]
After=netns@protonvpn-ch.service
mkdir /etc/systemd/system/openvpn@protonvpn-ch.service.d/
$ vim /etc/systemd/system/openvpn@protonvpn-ch.service.d/override.conf
[Unit]
BindsTo = netns@protonvpn-ch.service
JoinsNamespaceOf = netns@protonvpn-ch.service
After = netns@protonvpn-ch.service

[Service]
PrivateNetwork = true
BindPaths=/etc/netns/protonvpn-ch/resolv.conf:/etc/resolv.conf
BindPaths=/etc/netns/protonvpn-ch/resolv.conf.vpn:/etc/resolv.conf.vpn
mkdir /etc/openvpn/protonvpn/
touch /etc/openvpn/protonvpn/login.conf
chmod 600 /etc/openvpn/protonvpn/login.conf
vim /etc/openvpn/protonvpn/login.conf
username
password
mkdir /etc/openvpn/protonvpn/ch/
vim /etc/openvpn/protonvpn/ch/ch.ovpn (modified ProtonVPN CH Config)
#
# The MIT License (MIT)
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR # OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
# ==============================================================================

client
dev tun
proto udp

remote ch.protonvpn.com 80
remote ch.protonvpn.com 443
remote ch.protonvpn.com 4569
remote ch.protonvpn.com 1194
remote ch.protonvpn.com 5060

remote-random
resolv-retry infinite
nobind
cipher AES-256-CBC
auth SHA512
comp-lzo no
verb 3

tun-mtu 1500
tun-mtu-extra 32
mssfix 1450
persist-key
persist-tun

reneg-sec 0

remote-cert-tls server
auth-user-pass
pull
fast-io


<ca>
...
</ca>

key-direction 1
<tls-auth>
...
</tls-auth>

cd /etc/openvpn/protonvpn/
pull-filter ignore redirect-gateway
redirect-gateway local
auth-user-pass login.conf
script-security 2
up ch-test
down ch-test
ifconfig-noexec
route-noexec
route-up ch-test

keepalive 10 120
status /etc/openvpn/protonvpn/ch/openvpn-status.log
#route-down ch-test
#
#
auth-retry nointeract
vim /etc/openvpn/protonvpn/ch/ch-test
#!/bin/bash
#
# Parses DHCP options from openvpn to update resolv.conf
# To use set as 'up' and 'down' script in your openvpn *.conf:
# up /etc/openvpn/update-resolv-conf
# down /etc/openvpn/update-resolv-conf
#
# Used snippets of resolvconf script by Thomas Hood and Chris Hanson.
# Licensed under the GNU GPL.  See /usr/share/common-licenses/GPL.
#
# Example envs set from openvpn:
#
#     foreign_option_1='dhcp-option DNS 193.43.27.132'
#     foreign_option_2='dhcp-option DNS 193.43.27.133'
#     foreign_option_3='dhcp-option DOMAIN be.bnc.ch'
#

set -ex

[ "$script_type" ] || exit 0
[ "$dev" ] || exit 0

split_into_parts()
{
        part1="$1"
        part2="$2"
        part3="$3"
}

ROUTING_TABLE=666

echo "MODE START: $script_type"

case "$script_type" in
  up)
        NMSRVRS=""
        SRCHS=""
        foreign_options=$(printf '%s\n' ${!foreign_option_*} | sort -t _ -k 3 -g)
        for optionvarname in ${foreign_options} ; do
                option="${!optionvarname}"
                echo "$option"
                split_into_parts $option
                if [ "$part1" = "dhcp-option" ] ; then
                        if [ "$part2" = "DNS" ] ; then
                                NMSRVRS="${NMSRVRS:+$NMSRVRS }$part3"
                        elif [ "$part2" = "DOMAIN" ] ; then
                                SRCHS="${SRCHS:+$SRCHS }$part3"
                        fi
                fi
        done
        R=""
        [ "$SRCHS" ] && R="search $SRCHS
"
        for NS in $NMSRVRS ; do
                R="${R}nameserver $NS
"
        done
        echo -n "$R" > /etc/resolv.conf.vpn # ip netns exec `ip netns identify` tee /etc/resolv.conf
        cat /etc/resolv.conf.vpn

        #ip link set  dev "$dev"  up  netns "vm-down"  mtu "$tun_mtu" 2>&1 | logger
        #ip netns exec "vm-down" date 2>&1 | logger

        ip link set dev "$dev" up mtu "$tun_mtu"

        # set device address
        netmask4="${ifconfig_netmask:-30}"
        netbits6="${ifconfig_ipv6_netbits:-112}"

        if [ -n "$ifconfig_local" ]; then
            if [ -n "$ifconfig_remote" ]; then
                   /sbin/ip -4 addr add \
                       local "$ifconfig_local" \
                       peer "$ifconfig_remote/$netmask4" \
                       ${ifconfig_broadcast:+broadcast "$ifconfig_broadcast"} \
                       dev "$dev"
            else
                   /sbin/ip -4 addr add \
                       local "$ifconfig_local/$netmask4" \
                       ${ifconfig_broadcast:+broadcast "$ifconfig_broadcast"} \
                       dev "$dev"
            fi
            #ip -4 route
            #ip -4 route | grep "src $ifconfig_local" || true
            #ip -4 route | grep "src $ifconfig_local" | xargs -iIII -n 1 echo ip -4 route add III table $ROUTING_TABLE
            #ip -4 route | grep "src $ifconfig_local" | xargs -iIII -n 1 ip -4 route add III table $ROUTING_TABLE
            #10.0.1.4/30 dev vm-down proto kernel scope link src 10.0.1.6
        fi
        if [ -n "$IPV6" -a -n "$ifconfig_ipv6_local" ]; then
            if [ -n "$ifconfig_ipv6_remote" ]; then
                   /sbin/ip -6 addr add \
                      local "$ifconfig_ipv6_local" \
                      peer "$ifconfig_ipv6_remote/$netbits6" \
                      dev "$dev"
            else
                   /sbin/ip -6 addr add \
                      local "$ifconfig_ipv6_local/$netbits6" \
                      dev "$dev"
            fi
        fi

        ;;
  route-up)
        i=1

        while
            eval net=\"\$route_network_$i\"
            eval mask=\"\$route_netmask_$i\"
            eval gw=\"\$route_gateway_$i\"
            eval mtr=\"\$route_metric_$i\"
            [ -n "$net" ]
        do
            /sbin/ip -4 route add  "$net/$mask"  via "$gw"  ${mtr:+metric "$mtr"} table $ROUTING_TABLE
            i=$(( i + 1 ))
        done
        if [ -n "$route_vpn_gateway" ]; then
            /sbin/ip -4 route add  default  via "$route_vpn_gateway" table $ROUTING_TABLE
        fi

        if [ -n "$IPV6" ]; then
            i=1
            while
                # There doesn't seem to be $route_ipv6_metric_<n>
                # according to the manpage.
                eval net=\"\$route_ipv6_network_$i\"
                eval gw=\"\$route_ipv6_gateway_$i\"
                [ -n "$net" ]
            do
                /sbin/ip -6 route add  "$net"  via "$gw"  metric 100 table $ROUTING_TABLE
                i=$(( i + 1 ))
            done
            # There's no $route_vpn_gateway for IPv6. It's not
            # documented if OpenVPN includes default route in
            # $route_ipv6_*. Set default route to remote VPN
            # endpoint address if there is one. Use higher metric
            # than $route_ipv6_* routes to give preference to a
            # possible default route in them.
            if [ -n "$ifconfig_ipv6_remote" ]; then
                /sbin/ip -6 route add  default \
                    via "$ifconfig_ipv6_remote"  metric 200 table $ROUTING_TABLE
            fi
        fi

        ip rule add iif vm-down lookup $ROUTING_TABLE
        ;;
  down)
        ip rule del iif vm-down lookup $ROUTING_TABLE
        echo > /etc/resolv.conf.vpn
        ;;
esac

echo "MODE END: $script_type"
chmod +x /etc/openvpn/protonvpn/ch-test
ln -s /etc/openvpn/protonvpn/ch/ch.ovpn /etc/openvpn/protonvpn-ch.conf
vim /etc/systemd/system/openvpn\@protonvpn-ch.service.d/override.conf
[Unit]
BindsTo = netns@protonvpn-ch.service
JoinsNamespaceOf = netns@protonvpn-ch.service
After = netns-ctl@protonvpn-ch.service

[Service]
PrivateNetwork = true
BindPaths=/etc/netns/protonvpn-ch/resolv.conf:/etc/resolv.conf
BindPaths=/etc/netns/protonvpn-ch/resolv.conf.vpn:/etc/resolv.conf.vpn

TODO: Upstream "foreign" mode for netns-ctl

systemctl enable --now netns@main netns-ctl@main
systemctl enable --now netns@protonvpn-ch netns-ctl@protonvpn-ch
systemctl enable --now netns@vm-down netns-ctl@vm-down
vim /etc/systemd/system/dnsmasq-netns\@.service
[Unit]
Description=dnsmasq - A lightweight DHCP and caching DNS server
Requires=network.target
Wants=netns-ctl@%i.service
After=network.target netns-ctl@%i.service
BindsTo = netns-ctl@%i.service
JoinsNamespaceOf = netns@%i.service

[Service]
Type=forking
PIDFile=/run/dnsmasq/dnsmasq-netns-%i.pid

# Test the config file and refuse starting if it is not valid.
ExecStartPre=/usr/sbin/dnsmasq --conf-dir /etc/dnsmasq/netns/%i/,*.conf --resolv=/etc/netns/%i/resolv.conf.vpn --test

# We run dnsmasq via the /etc/init.d/dnsmasq script which acts as a
# wrapper picking up extra configuration files and then execs dnsmasq
# itself, when called with the "systemd-exec" function.
ExecStart=/usr/sbin/dnsmasq --conf-dir /etc/dnsmasq/netns/%i/,*.conf --resolv=/etc/netns/%i/resolv.conf.vpn -x /run/dnsmasq/dnsmasq-netns-%i.pid

# The systemd-*-resolvconf functions configure (and deconfigure)
# resolvconf to work with the dnsmasq DNS server. They're called like
# this to get correct error handling (ie don't start-resolvconf if the
# dnsmasq daemon fails to start.
#ExecStartPost=/etc/init.d/dnsmasq systemd-start-resolvconf
#ExecStop=/etc/init.d/dnsmasq systemd-stop-resolvconf


ExecReload=/bin/kill -HUP $MAINPID

PrivateNetwork = true
BindPaths=/etc/netns/%i/resolv.conf:/etc/resolv.conf
BindPaths=/etc/netns/%i/resolv.conf.vpn:/etc/resolv.conf.vpn

[Install]
WantedBy=multi-user.target
mkdir /etc/dnsmasq/netns/{protonvpn-ch,vm-down}
vim /etc/dnsmasq/netns/protonvpn-ch/dnsmasq.conf
interface=main
interface=vm-down
interface=lo
no-dhcp-interface=main
no-dhcp-interface=tun0
no-dhcp-interface=lo
no-hosts
vim /etc/dnsmasq/netns/vm-down/dnsmasq.conf
interface=eth1-down
interface=lo
no-dhcp-interface=pvpn-ch
no-hosts
vim /etc/hosts
#PVPN-CH START
127.0.0.1 ch.protonvpn.com ch-12.protonvpn.com
127.0.0.1 ch.protonvpn.com node-ch-03.protonvpn.net
127.0.0.1 ch.protonvpn.com node-ch-02.protonvpn.net
#PVPN-CH STOP

$ vim /root/newpvpnch.sh
#!/bin/bash

SERVERS="`curl https://account.protonvpn.com/api/vpn/logicals | jq -r '[.LogicalServers[] | select( .ExitCountry == "CH" and .Tier >= 2 and .Load < 40 and .Features == 4 and .Status == 1) | { "entryIP": .Servers[0].EntryIP, "exitIP": .Servers[0].ExitIP,  "score": .Score, "load": .Load, obj: ., "domain": .Servers[0].Domain }] | sort_by(.score)[:3] | .[] | "\(.entryIP) ch.protonvpn.com \(.domain)"' | sort -R ` "
echo -e "$SERVERS"
perl -e '$/=undef; my $string = <STDIN>; $string =~ s/#PVPN-CH START.+#PVPN-CH STOP/#PVPN-CH START\n'"$SERVERS"'\n#PVPN-CH STOP/igs; print $string;' < /etc/hosts > /etc/hosts.tmp
cat /etc/hosts.tmp
cp /etc/hosts{,.bak}
mv /etc/hosts.tmp /etc/hosts

systemctl restart openvpn@protonvpn-ch
journalctl --follow -u openvpn@protonvpn-ch

$ chmod +x /root/newpvpnch.sh
systemctl enable --now dnsmasq-netns@vm-down.service
systemctl enable --now dnsmasq-netns@protonvpn-ch.service

TODO: persist firewall masquerade for outgoing traffic for the vpn connection bash iptables -t nat -I POSTROUTING -s 10.33.0.2 -o br0 -j MASQUERADE TODO: create firewall rules on all NS to limit traffic between main and protonvpn-ch/vm-down

/root/new/pvpnch.sh

Wait until VPN is connected(if it does not connect, debug) and then this should work: bash ip netns exec vm-down ping google.de ip netns exec vm-down curl ipinfo.io