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),
)
run(
'ip', 'netns', 'exec', name,
'wg', 'set', name, 'private-key', '/dev/stdin', stdin=interface['privatekey'],
)
for peer in peers:
run(
'ip', 'netns', 'exec', name,
'wg', 'set', name,
'peer', peer['publickey'],
'preshared-key', '/dev/stdin' if peer.get('presharedkey') else '/dev/null',
'endpoint', peer['endpoint'],
'persistent-keepalive', peer.get('persistentkeepalive', 0),
'allowed-ips', '0.0.0.0/0,::/0',
stdin=peer.get('presharedkey', ''),
)
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))
run('ip', '-n', name, 'link', 'set', name, 'up')
run('ip', '-n', name, 'route', 'add', 'default', 'dev', name)
def setup_resolv_conf(netns_dir, interface_config): def namespace_resolvconf_write(namespace):
if interface_config.get('dns'): content = '\n'.join(f'nameserver {server}' for server in namespace['dns-server'])
netns_dir.mkdir(parents=True, exist_ok=True) if content:
text = '\n'.join(f'nameserver {server}' for server in interface_config['dns']) NETNS_CONFIG_DIR.joinpath(namespace['name']).mkdir(parents=True, exist_ok=True)
netns_dir.joinpath('resolv.conf').write_text(text) NETNS_CONFIG_DIR.joinpath(namespace['name']).joinpath('resolv.conf').write_text(content)
def teardown(wg_dir, netns_dir, name, force=False): def namespace_teardown(namespace, check=True):
teardown_network_namespace(name, check=not force) if namespace.get('pre-down'):
teardown_resolv_conf(netns_dir/name) ip_netns_shell(namespace['pre-down'], netns=namespace)
for interface in namespace['interfaces']:
interface_teardown(interface, namespace)
namespace_delete(namespace)
namespace_resolvconf_delete(namespace)
if namespace.get('post-down'):
ip_netns_shell(namespace['post-down'], netns=namespace)
def teardown_network_namespace(name, check=True): def namespace_delete(namespace, check=True):
run('ip', '-n', name, 'route', 'delete', 'default', 'dev', name, check=check) ip('netns', 'delete', namespace['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): def namespace_resolvconf_delete(namespace):
resolv_conf = netns_dir/'resolv.conf' path = NETNS_CONFIG_DIR/namespace['name']/'resolv.conf'
if resolv_conf.exists(): if path.exists():
resolv_conf.unlink() path.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:
line = next(it)
if line.lower() == '[interface]':
it, result = parse_interface(it)
interface.update(result)
elif line.lower() == '[peer]':
it, result = parse_peer(it)
peers.append(result)
else:
raise ParserError(f'invalid line: {line}')
except ParserError as e:
raise ParserError(f'failed to parse wireguard configuration: {e}') from e
except StopIteration:
return interface, peers
def parse_interface(it):
result = dict()
for line in it:
if line.lower() in ('[interface]', '[peer]'):
return itertools.chain((line,), it), result
key, value = parse_pair(line)
if key in ('address', 'dns'):
result[key] = parse_items(value)
elif key in ('mtu', 'listenport', 'privatekey'):
result[key] = value
elif key in ('preup', 'postup', 'predown', 'postdown', 'saveconfig', 'table', 'fwmark'):
raise ParserError(f'unsupported interface key: {key}')
else:
raise ParserError(f'unknown interface key: {key}')
return iter(()), result
def parse_peer(it):
result = dict()
for line in it:
if line.lower() in ('[interface]', '[peer]'):
return itertools.chain((line,), it), result
key, value = parse_pair(line)
if key == 'allowedips':
result[key] = parse_items(value)
elif key in ('presharedkey', 'publickey', 'endpoint', 'persistentkeepalive'):
result[key] = value
else:
raise ParserError(f'unknown peer key: {key}')
return iter(()), result
def parse_pair(line): def interface_create(interface, namespace):
pair = line.split('=', maxsplit=1) ip('link', 'add', interface['name'], 'type', 'wireguard')
if len(pair) != 2: ip('link', 'set', interface['name'], 'netns', namespace['name'])
raise ParserError(f'invalid pair: {line}')
key, value = pair
return key.strip().lower(), value.strip()
def parse_items(text): def interface_configure_wireguard(interface, namespace):
return [item.strip() for item in text.split(',')] wg('set', interface['name'], 'listen-port', interface.get('listen-port', 0), netns=namespace)
wg('set', interface['name'], 'fwmark', interface.get('fwmark', 0), netns=namespace)
wg('set', interface['name'], 'private-key', '/dev/stdin', stdin=interface['private-key'], netns=namespace)
def run(*args, stdin=None, check=False): def interface_assign_addresses(interface, namespace):
args = [str(item) for item in args if item is not None] for address in interface['address']:
process = subprocess.run( ip('-n', namespace['name'], '-6' if ':' in address else '-4', 'address', 'add', address, 'dev', interface['name'])
args,
input=stdin,
text=True,
check=check,
)
class ParserError(Exception): def interface_bring_up(interface, namespace):
pass ip('-n', namespace['name'], 'link', 'set', 'dev', interface['name'], 'mtu', interface.get('mtu', 1420), 'up')
def interface_create_routes(interface, namespace):
for peer in interface['peers']:
for network in peer['allowed-ips']:
ip('-n', namespace['name'], '-6' if ':' in network else '-4', 'route', 'add', network, 'dev', interface['name'])
def interface_teardown(interface, namespace, check=True):
ip('-n', namespace['name'], 'link', 'set', interface['name'], 'down', check=check)
ip('-n', namespace['name'], 'link', 'delete', interface['name'], check=check)
def peer_setup(peer, interface, namespace):
options = [
'peer', peer['public-key'],
'preshared-key', '/dev/stdin' if peer.get('preshared-key') else '/dev/null',
]
if peer.get('endpoint'):
options.extend(('endpoint', peer.get('endpoint')))
options += [
'persistent-keepalive', peer.get('persistent-keepalive', 0),
'allowed-ips', ','.join(peer['allowed-ips']),
]
wg('set', interface['name'], *options, stdin=peer.get('preshared-key'), netns=namespace)
def wg(*args, **kwargs):
return ip_netns_exec('wg', *args, **kwargs)
def ip_netns_shell(*args, **kwargs):
return ip_netns_exec(SHELL, '-c', *args, **kwargs)
def ip_netns_exec(*args, netns=None, **kwargs):
return ip('netns', 'exec', netns['name'], *args, **kwargs)
def ip(*args, **kwargs):
return run('ip', *args, **kwargs)
def run(*args, stdin=None, check=True, capture=False):
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