2
0
mirror of https://github.com/dadevel/wg-netns synced 2024-10-30 21:20:12 +00:00

rewrite for v1

Allow multiple interfaces per namespace.
Change configuration format to json.
This commit is contained in:
dadevel 2020-12-06 22:53:31 +01:00 committed by Daniel
parent e225ad0bfe
commit 812e027bc0
3 changed files with 249 additions and 186 deletions

112
README.md
View File

@ -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.
Now you can spawn a shell in the new network namespace. `./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 ~~~ bash
ip netns exec my-vpn bash -i 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
~~~
Or you can spawn a shell inside the netns.
~~~ bash
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
~~~ ~~~

View File

@ -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)
def print_status(wg_dir, netns_dir, name):
run('ip', 'netns', 'exec', name, 'wg', 'show', name)
def setup_wrapped(*args):
try: try:
setup(*args) 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: except Exception as e:
teardown(*args, force=True) raise RuntimeError(f'failed to load environment variable: {e} (e.__class__.__name__)') from e
if opts.action == 'up':
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_action(path):
namespace = profile_read(path)
try:
namespace_setup(namespace)
except KeyboardInterrupt:
namespace_teardown(namespace, check=False)
except Exception as e:
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 setup_network_namespace(name): def profile_read(path):
run('ip', 'netns', 'add', name) with open(path) as file:
run('ip', '-n', name, 'link', 'set', 'dev', 'lo', 'up') return json.load(file)
def setup_wireguard_interface(name, interface, peers): def namespace_setup(namespace):
run('ip', 'link', 'add', name, 'type', 'wireguard') if namespace.get('pre-up'):
run('ip', 'link', 'set', name, 'netns', name) ip_netns_shell(namespace['pre-up'], netns=namespace)
run( namespace_create(namespace)
'ip', 'netns', 'exec', name, namespace_resolvconf_write(namespace)
'wg', 'set', name, 'listen-port', interface.get('listenport', 0), for interface in namespace['interfaces']:
) interface_setup(interface, namespace)
run( if namespace.get('post-up'):
'ip', 'netns', 'exec', name, ip_netns_shell(namespace['post-up'], netns=namespace)
'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_create(namespace):
if interface_config.get('dns'): ip('netns', 'add', namespace['name'])
netns_dir.mkdir(parents=True, exist_ok=True) ip('-n', namespace['name'], 'link', 'set', 'dev', 'lo', 'up')
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): def namespace_resolvconf_write(namespace):
teardown_network_namespace(name, check=not force) content = '\n'.join(f'nameserver {server}' for server in namespace['dns-server'])
teardown_resolv_conf(netns_dir/name) if content:
NETNS_CONFIG_DIR.joinpath(namespace['name']).mkdir(parents=True, exist_ok=True)
NETNS_CONFIG_DIR.joinpath(namespace['name']).joinpath('resolv.conf').write_text(content)
def teardown_network_namespace(name, check=True): def namespace_teardown(namespace, check=True):
run('ip', '-n', name, 'route', 'delete', 'default', 'dev', name, check=check) if namespace.get('pre-down'):
run('ip', '-n', name, 'link', 'set', name, 'down', check=check) ip_netns_shell(namespace['pre-down'], netns=namespace)
run('ip', '-n', name, 'link', 'delete', name, check=check) for interface in namespace['interfaces']:
run('ip', 'netns', 'delete', name, check=check) 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_resolv_conf(netns_dir): def namespace_delete(namespace, check=True):
resolv_conf = netns_dir/'resolv.conf' ip('netns', 'delete', namespace['name'], check=check)
if resolv_conf.exists():
resolv_conf.unlink()
def namespace_resolvconf_delete(namespace):
path = NETNS_CONFIG_DIR/namespace['name']/'resolv.conf'
if path.exists():
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): def interface_create(interface, namespace):
result = dict() ip('link', 'add', interface['name'], 'type', 'wireguard')
for line in it: ip('link', 'set', interface['name'], 'netns', namespace['name'])
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): def interface_configure_wireguard(interface, namespace):
result = dict() wg('set', interface['name'], 'listen-port', interface.get('listen-port', 0), netns=namespace)
for line in it: wg('set', interface['name'], 'fwmark', interface.get('fwmark', 0), netns=namespace)
if line.lower() in ('[interface]', '[peer]'): wg('set', interface['name'], 'private-key', '/dev/stdin', stdin=interface['private-key'], netns=namespace)
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_assign_addresses(interface, namespace):
pair = line.split('=', maxsplit=1) for address in interface['address']:
if len(pair) != 2: ip('-n', namespace['name'], '-6' if ':' in address else '-4', 'address', 'add', address, 'dev', interface['name'])
raise ParserError(f'invalid pair: {line}')
key, value = pair
return key.strip().lower(), value.strip()
def parse_items(text): def interface_bring_up(interface, namespace):
return [item.strip() for item in text.split(',')] ip('-n', namespace['name'], 'link', 'set', 'dev', interface['name'], 'mtu', interface.get('mtu', 1420), 'up')
def run(*args, stdin=None, check=False): def interface_create_routes(interface, namespace):
args = [str(item) for item in args if item is not None] for peer in interface['peers']:
process = subprocess.run( for network in peer['allowed-ips']:
args, ip('-n', namespace['name'], '-6' if ':' in network else '-4', 'route', 'add', network, 'dev', interface['name'])
input=stdin,
text=True,
check=check,
)
class ParserError(Exception): def interface_teardown(interface, namespace, check=True):
pass 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)

View File

@ -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