rewrite for v1

Allow multiple interfaces per namespace.
Change configuration format to json.
pull/4/head
dadevel 4 years ago committed by Daniel
parent e225ad0bfe
commit 812e027bc0

@ -1,7 +1,7 @@
# wg-netns # wg-netns
[wg-quick](https://git.zx2c4.com/wireguard-tools/about/src/man/wg-quick.8) for linux network namespaces. [wg-quick](https://git.zx2c4.com/wireguard-tools/about/src/man/wg-quick.8) with support for linux network namespaces.
A simple python script that implements the steps described at [wireguard.com/netns](https://www.wireguard.com/netns/#ordinary-containerization). It's a simple python script that implements the steps described at [wireguard.com/netns](https://www.wireguard.com/netns/#ordinary-containerization).
## Setup ## Setup
@ -20,21 +20,105 @@ mkdir -p ~/.local/bin/ && curl -o ~/.local/bin/wg-netns https://raw.githubuserco
## Usage ## Usage
Instead of running `wg-quick up my-vpn` run `wg-netns up my-vpn`. First, create a configuration profile.
You can find two examples below.
`./mini.json`:
~~~ json
{
"name": "ns-example",
"interfaces": [
{
"name": "wg-example",
"address": ["10.10.10.192/32", "fc00:dead:beef::192/128"],
"private-key": "4bvaEZHI...",
"peers": [
{
"public-key": "bELgMXGt...",
"endpoint": "vpn.example.com:51820",
"allowed-ips": ["0.0.0.0/0", "::/0"]
}
]
}
]
}
~~~
`./maxi.json`:
~~~ json
{
"name": "ns-example",
"dns-server": ["10.10.10.1", "10.10.10.2"],
"pre-up": "some shell command",
"post-up": "some shell command",
"pred-own": "some shell command",
"post-down": "some shell command",
"interfaces": [
{
"name": "wg-site-a",
"address": ["10.10.11.172/32", "fc00:dead:beef:1::172/128"],
"listen-port": 51821,
"fwmark": 51821,
"private-key": "nFkQQjN+...",
"mtu": 1420,
"peers": [
{
"public-key": "Kx+wpJpj...",
"preshared-key": "5daskLoW...",
"endpoint": "a.example.com:51821",
"persistent-keepalive": 25,
"allowed-ips": ["10.10.11.0/24", "fc00:dead:beef:1::/64"]
}
]
},
{
"name": "wg-site-b",
"address": ["10.10.12.172/32", "fc00:dead:beef:2::172/128"],
"listen-port": 51822,
"fwmark": 51822,
"private-key": "guYPuE3X...",
"mtu": 1420,
"peers": [
{
"public-key": "NvZMoyrg...",
"preshared-key": "cFQuyIX/...",
"endpoint": "b.example.com:51822",
"persistent-keepalive": 25,
"allowed-ips": ["10.10.12.0/24", "fc00:dead:beef:2::/64"]
}
]
}
]
}
~~~
Now it's time to setup your new network namespace and all associated wireguard interfaces.
~~~ bash
wg-netns up ./example.json
~~~
You can verify the success with a combination of `ip` and `wg`.
~~~ bash
ip netns exec ns-example wg show
~~~
Now you can spawn a shell in the new network namespace. Or you can spawn a shell inside the netns.
~~~ bash ~~~ bash
ip netns exec my-vpn bash -i ip netns exec ns-example bash -i
~~~ ~~~
Or connect a container to it. Or connect a container to it.
~~~ bash ~~~ bash
podman run -it --rm --network ns:/var/run/netns/my-vpn alpine wget -O - https://ipinfo.io podman run -it --rm --network ns:/var/run/netns/ns-example docker.io/alpine wget -O - https://ipinfo.io
~~~ ~~~
Or do whatever you want. Or do whatever else you want.
### System Service ### System Service
@ -42,22 +126,22 @@ You can find a `wg-quick@.service` equivalent at [wg-netns@.service](./wg-netns@
### Port Forwarding ### Port Forwarding
Forward TCP traffic from outside a network namespace to a port inside a network namespace with `socat`. With `socat` you can forward TCP traffic from outside a network namespace to a port inside a network namespace.
~~~ bash ~~~ bash
socat tcp-listen:$LHOST,reuseaddr,fork "exec:ip netns exec $NETNS socat stdio 'tcp-connect:$RHOST',nofork" socat tcp-listen:$LHOST,reuseaddr,fork "exec:ip netns exec $NETNS socat stdio 'tcp-connect:$RHOST',nofork"
~~~ ~~~
Example: All connections to port 1234/tcp in the main netns are forwarded into the *my-vpn* netns to port 5678/tcp. Example: All connections to port 1234/tcp in the main netns are forwarded into the *ns-example* namespace to port 5678/tcp.
~~~ bash ~~~ bash
# terminal 1, create netns and start http server inside # terminal 1, create netns and start http server inside
wg-netns up my-vpn wg-netns up ns-example
echo hello > ./hello.txt hello > ./hello.txt
ip netns exec my-vpn python3 -m http.server 5678 ip netns exec ns-example python3 -m http.server 5678
# terminal 2, setup port forwarding # terminal 2, setup port forwarding
socat tcp-listen:1234,reuseaddr,fork "exec:ip netns exec my-vpn socat stdio 'tcp-connect:127.0.0.1:5678',nofork" socat tcp-listen:1234,reuseaddr,fork "exec:ip netns exec ns-example socat stdio 'tcp-connect:127.0.0.1:5678',nofork"
# terminal 3, test # terminal 3, test access
curl http://127.0.0.1:1234/hello.txt curl http://127.0.0.1:1234/hello.txt
~~~ ~~~

@ -1,223 +1,202 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from argparse import ArgumentParser, RawDescriptionHelpFormatter from argparse import ArgumentParser, RawDescriptionHelpFormatter
from pathlib import Path from pathlib import Path
import itertools import json
import os import os
import subprocess import subprocess
import sys import sys
NETNS_CONFIG_DIR = '/etc/netns'
DEBUG_LEVEL = 0
def main(args): def main(args):
main_parser = ArgumentParser( global NETNS_CONFIG_DIR
global DEBUG_LEVEL
entrypoint = ArgumentParser(
formatter_class=RawDescriptionHelpFormatter, formatter_class=RawDescriptionHelpFormatter,
epilog=( epilog=(
'environment variables:\n' 'environment variables:\n'
' WGNETNS_WG_DIR wireguard config directory, default: /etc/wireguard\n' f' NETNS_CONFIG_DIR network namespace config directory, default: {NETNS_CONFIG_DIR}\n'
' WGNETNS_NETNS_DIR network namespace config directory, default: /etc/netns\n' f' DEBUG_LEVEL print stack traces, default: {DEBUG_LEVEL}\n'
' WGNETNS_DEBUG print stack traces\n'
), ),
) )
main_parser.add_argument(
'--wg-dir',
type=lambda x: Path(x).expanduser(),
default=os.environ.get('WGNETNS_WG_DIR', '/etc/wireguard'),
metavar='DIRECTORY',
help='override WGNETNS_WG_DIR',
)
main_parser.add_argument(
'--netns-dir',
type=lambda x: Path(x).expanduser(),
default=os.environ.get('WGNETNS_NETNS_DIR', '/etc/netns'),
metavar='DIRECTORY',
help='override WGNETNS_NETNS_DIR',
)
subparsers = main_parser.add_subparsers(dest='command', required=True)
parser = subparsers.add_parser('up', help='set up interface') subparsers = entrypoint.add_subparsers(dest='action', required=True)
parser.add_argument('name', help='configuration name')
parser = subparsers.add_parser('down', help='tear down interface') parser = subparsers.add_parser('up', help='setup namespace and associated interfaces')
parser.add_argument('-f', '--force', help='ignore errors') parser.add_argument('profile', type=lambda x: Path(x).expanduser(), help='path to profile')
parser.add_argument('name', help='configuration name')
parser = subparsers.add_parser('status', help='show status info') parser = subparsers.add_parser('down', help='teardown namespace and associated interfaces')
parser.add_argument('name', help='configuration name') parser.add_argument('-f', '--force', action='store_true', help='ignore errors')
parser.add_argument('profile', type=lambda x: Path(x).expanduser(), help='path to profile')
opts = main_parser.parse_args(args) opts = entrypoint.parse_args(args)
commands = dict(
up=setup_wrapped,
down=teardown,
status=print_status,
)
fn = commands[opts.command]
fn(opts.wg_dir, opts.netns_dir, opts.name)
try:
NETNS_CONFIG_DIR = Path(os.environ.get('NETNS_CONFIG_DIR', NETNS_CONFIG_DIR))
DEBUG_LEVEL = int(os.environ.get('DEBUG_LEVEL', DEBUG_LEVEL))
except Exception as e:
raise RuntimeError(f'failed to load environment variable: {e} (e.__class__.__name__)') from e
def print_status(wg_dir, netns_dir, name): if opts.action == 'up':
run('ip', 'netns', 'exec', name, 'wg', 'show', name) setup_action(opts.profile)
elif opts.action == 'down':
teardown_action(opts.profile, check=not opts.force)
else:
raise RuntimeError('congratulations, you reached unreachable code')
def setup_wrapped(*args): def setup_action(path):
namespace = profile_read(path)
try: try:
setup(*args) namespace_setup(namespace)
except KeyboardInterrupt:
namespace_teardown(namespace, check=False)
except Exception as e: except Exception as e:
teardown(*args, force=True) namespace_teardown(namespace, check=False)
raise raise
def setup(wg_dir, netns_dir, name): def teardown_action(path, check=True):
interface_config, peer_config = parse_wireguard_config(wg_dir.joinpath(name).with_suffix('.conf')) namespace = profile_read(path)
setup_network_namespace(name) namespace_teardown(namespace, check=check)
setup_wireguard_interface(name, interface_config, peer_config)
setup_resolv_conf(netns_dir/name, interface_config)
def profile_read(path):
with open(path) as file:
return json.load(file)
def setup_network_namespace(name):
run('ip', 'netns', 'add', name)
run('ip', '-n', name, 'link', 'set', 'dev', 'lo', 'up')
def namespace_setup(namespace):
if namespace.get('pre-up'):
ip_netns_shell(namespace['pre-up'], netns=namespace)
namespace_create(namespace)
namespace_resolvconf_write(namespace)
for interface in namespace['interfaces']:
interface_setup(interface, namespace)
if namespace.get('post-up'):
ip_netns_shell(namespace['post-up'], netns=namespace)
def setup_wireguard_interface(name, interface, peers):
run('ip', 'link', 'add', name, 'type', 'wireguard') def namespace_create(namespace):
run('ip', 'link', 'set', name, 'netns', name) ip('netns', 'add', namespace['name'])
run( ip('-n', namespace['name'], 'link', 'set', 'dev', 'lo', 'up')
'ip', 'netns', 'exec', name,
'wg', 'set', name, 'listen-port', interface.get('listenport', 0),
) def namespace_resolvconf_write(namespace):
run( content = '\n'.join(f'nameserver {server}' for server in namespace['dns-server'])
'ip', 'netns', 'exec', name, if content:
'wg', 'set', name, 'private-key', '/dev/stdin', stdin=interface['privatekey'], NETNS_CONFIG_DIR.joinpath(namespace['name']).mkdir(parents=True, exist_ok=True)
) NETNS_CONFIG_DIR.joinpath(namespace['name']).joinpath('resolv.conf').write_text(content)
for peer in peers:
run(
'ip', 'netns', 'exec', name, def namespace_teardown(namespace, check=True):
'wg', 'set', name, if namespace.get('pre-down'):
'peer', peer['publickey'], ip_netns_shell(namespace['pre-down'], netns=namespace)
'preshared-key', '/dev/stdin' if peer.get('presharedkey') else '/dev/null', for interface in namespace['interfaces']:
'endpoint', peer['endpoint'], interface_teardown(interface, namespace)
'persistent-keepalive', peer.get('persistentkeepalive', 0), namespace_delete(namespace)
'allowed-ips', '0.0.0.0/0,::/0', namespace_resolvconf_delete(namespace)
stdin=peer.get('presharedkey', ''), if namespace.get('post-down'):
) ip_netns_shell(namespace['post-down'], netns=namespace)
for addr in interface['address']:
run('ip', '-n', name, '-6' if ':' in addr else '-4', 'address', 'add', addr, 'dev', name)
run('ip', '-n', name, 'link', 'set', name, 'mtu', interface.get('mtu', 1420)) def namespace_delete(namespace, check=True):
run('ip', '-n', name, 'link', 'set', name, 'up') ip('netns', 'delete', namespace['name'], check=check)
run('ip', '-n', name, 'route', 'add', 'default', 'dev', name)
def namespace_resolvconf_delete(namespace):
def setup_resolv_conf(netns_dir, interface_config): path = NETNS_CONFIG_DIR/namespace['name']/'resolv.conf'
if interface_config.get('dns'): if path.exists():
netns_dir.mkdir(parents=True, exist_ok=True) path.unlink()
text = '\n'.join(f'nameserver {server}' for server in interface_config['dns'])
netns_dir.joinpath('resolv.conf').write_text(text)
def teardown(wg_dir, netns_dir, name, force=False):
teardown_network_namespace(name, check=not force)
teardown_resolv_conf(netns_dir/name)
def teardown_network_namespace(name, check=True):
run('ip', '-n', name, 'route', 'delete', 'default', 'dev', name, check=check)
run('ip', '-n', name, 'link', 'set', name, 'down', check=check)
run('ip', '-n', name, 'link', 'delete', name, check=check)
run('ip', 'netns', 'delete', name, check=check)
def teardown_resolv_conf(netns_dir):
resolv_conf = netns_dir/'resolv.conf'
if resolv_conf.exists():
resolv_conf.unlink()
try: try:
netns_dir.rmdir() NETNS_CONFIG_DIR.rmdir()
except OSError: except OSError:
pass pass
def parse_wireguard_config(path): def interface_setup(interface, namespace):
with open(path) as file: interface_create(interface, namespace)
it = iter( interface_configure_wireguard(interface, namespace)
line.strip() for peer in interface['peers']:
for line in file peer_setup(peer, interface, namespace)
if line.strip() and not line.startswith('#') interface_assign_addresses(interface, namespace)
) interface_bring_up(interface, namespace)
interface = dict() interface_create_routes(interface, namespace)
peers = list()
try:
while True: def interface_create(interface, namespace):
line = next(it) ip('link', 'add', interface['name'], 'type', 'wireguard')
if line.lower() == '[interface]': ip('link', 'set', interface['name'], 'netns', namespace['name'])
it, result = parse_interface(it)
interface.update(result)
elif line.lower() == '[peer]': def interface_configure_wireguard(interface, namespace):
it, result = parse_peer(it) wg('set', interface['name'], 'listen-port', interface.get('listen-port', 0), netns=namespace)
peers.append(result) wg('set', interface['name'], 'fwmark', interface.get('fwmark', 0), netns=namespace)
else: wg('set', interface['name'], 'private-key', '/dev/stdin', stdin=interface['private-key'], netns=namespace)
raise ParserError(f'invalid line: {line}')
except ParserError as e:
raise ParserError(f'failed to parse wireguard configuration: {e}') from e def interface_assign_addresses(interface, namespace):
except StopIteration: for address in interface['address']:
return interface, peers ip('-n', namespace['name'], '-6' if ':' in address else '-4', 'address', 'add', address, 'dev', interface['name'])
def parse_interface(it): def interface_bring_up(interface, namespace):
result = dict() ip('-n', namespace['name'], 'link', 'set', 'dev', interface['name'], 'mtu', interface.get('mtu', 1420), 'up')
for line in it:
if line.lower() in ('[interface]', '[peer]'):
return itertools.chain((line,), it), result def interface_create_routes(interface, namespace):
key, value = parse_pair(line) for peer in interface['peers']:
if key in ('address', 'dns'): for network in peer['allowed-ips']:
result[key] = parse_items(value) ip('-n', namespace['name'], '-6' if ':' in network else '-4', 'route', 'add', network, 'dev', interface['name'])
elif key in ('mtu', 'listenport', 'privatekey'):
result[key] = value
elif key in ('preup', 'postup', 'predown', 'postdown', 'saveconfig', 'table', 'fwmark'): def interface_teardown(interface, namespace, check=True):
raise ParserError(f'unsupported interface key: {key}') ip('-n', namespace['name'], 'link', 'set', interface['name'], 'down', check=check)
else: ip('-n', namespace['name'], 'link', 'delete', interface['name'], check=check)
raise ParserError(f'unknown interface key: {key}')
return iter(()), result
def peer_setup(peer, interface, namespace):
options = [
def parse_peer(it): 'peer', peer['public-key'],
result = dict() 'preshared-key', '/dev/stdin' if peer.get('preshared-key') else '/dev/null',
for line in it: ]
if line.lower() in ('[interface]', '[peer]'): if peer.get('endpoint'):
return itertools.chain((line,), it), result options.extend(('endpoint', peer.get('endpoint')))
key, value = parse_pair(line) options += [
if key == 'allowedips': 'persistent-keepalive', peer.get('persistent-keepalive', 0),
result[key] = parse_items(value) 'allowed-ips', ','.join(peer['allowed-ips']),
elif key in ('presharedkey', 'publickey', 'endpoint', 'persistentkeepalive'): ]
result[key] = value wg('set', interface['name'], *options, stdin=peer.get('preshared-key'), netns=namespace)
else:
raise ParserError(f'unknown peer key: {key}')
return iter(()), result def wg(*args, **kwargs):
return ip_netns_exec('wg', *args, **kwargs)
def parse_pair(line):
pair = line.split('=', maxsplit=1) def ip_netns_shell(*args, **kwargs):
if len(pair) != 2: return ip_netns_exec(SHELL, '-c', *args, **kwargs)
raise ParserError(f'invalid pair: {line}')
key, value = pair
return key.strip().lower(), value.strip() def ip_netns_exec(*args, netns=None, **kwargs):
return ip('netns', 'exec', netns['name'], *args, **kwargs)
def parse_items(text):
return [item.strip() for item in text.split(',')] def ip(*args, **kwargs):
return run('ip', *args, **kwargs)
def run(*args, stdin=None, check=False):
args = [str(item) for item in args if item is not None]
process = subprocess.run(
args,
input=stdin,
text=True,
check=check,
)
class ParserError(Exception): def run(*args, stdin=None, check=True, capture=False):
pass args = [str(item) if item is not None else '' for item in args]
if DEBUG_LEVEL:
print('>', ' '.join(args), file=sys.stderr)
process = subprocess.run(args, input=stdin, text=True, capture_output=capture)
if check and process.returncode != 0:
error = process.stderr.strip() if process.stderr else f'exit code {process.returncode}'
raise RuntimeError(f'subprocess failed: {" ".join(args)}: {error}')
return process.stdout
if __name__ == '__main__': if __name__ == '__main__':
@ -225,7 +204,7 @@ if __name__ == '__main__':
main(sys.argv[1:]) main(sys.argv[1:])
sys.exit(0) sys.exit(0)
except Exception as e: except Exception as e:
if os.environ.get('WGNETNS_DEBUG'): if DEBUG_LEVEL:
raise raise
print(f'error: {e} ({e.__class__.__name__})', file=sys.stderr) print(f'error: {e} ({e.__class__.__name__})', file=sys.stderr)
sys.exit(2) sys.exit(2)

@ -5,11 +5,11 @@ After=network-online.target nss-lookup.target
[Service] [Service]
Type=oneshot Type=oneshot
RemainAfterExit=yes
Environment=WG_ENDPOINT_RESOLUTION_RETRIES=infinity Environment=WG_ENDPOINT_RESOLUTION_RETRIES=infinity
ExecStart=wg-netns up %i ExecStart=wg-netns up ./%i.json
ExecStop=wg-netns down %i ExecStop=wg-netns down ./%i.json
WorkingDirectory=%E/wg-netns
RemainAfterExit=yes
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

Loading…
Cancel
Save