rewrite with dataclasses

Adds 'managed' option and yaml support, renames env vars and can load profiles by name.
Closes #4.
pull/8/head v2.0.0
dadevel 2 years ago
parent 42f67672f0
commit 59a67b445c
No known key found for this signature in database
GPG Key ID: 1A8A9735430193D5

@ -10,6 +10,7 @@ Requirements:
- Python 3.7 or newer - Python 3.7 or newer
- `ip` from iproute2 - `ip` from iproute2
- `wg` from wireguard-tools - `wg` from wireguard-tools
- optional: [pyyaml](https://pypi.org/project/PyYAML/) python package for configuration files in YAML format, otherwise only JSON is supported
Installation: Installation:
@ -21,9 +22,9 @@ sudo ./wg-netns/setup.sh
## Usage ## Usage
First, create a configuration profile. First, create a configuration profile.
You can find two examples below. JSON and YAML file formats are supported.
`./mini.json`: Minimal JSON example:
~~~ json ~~~ json
{ {
@ -45,59 +46,76 @@ You can find two examples below.
} }
~~~ ~~~
`./maxi.json`: Full YAML example:
~~~ json ~~~ yaml
{ # name of the network namespace
"name": "ns-example", name: ns-example
"dns-server": ["10.10.10.1", "10.10.10.2"], # if false, the netns itself won't be created or deleted, just the interfaces inside it
"pre-up": "some shell command", managed: true
"post-up": "some shell command", # list of dns servers, if empty dns servers from default netns will be used
"pred-own": "some shell command", dns-server: [10.10.10.1, 10.10.10.2]
"post-down": "some shell command", # shell hooks, e.g. to set firewall rules
"interfaces": [ pre-up: echo pre-up
{ post-up: echo post-up
"name": "wg-site-a", pre-own: echo pre-down
"address": ["10.10.11.172/32", "fc00:dead:beef:1::172/128"], post-down: echo post-down
"listen-port": 51821, # list of wireguard interfaces inside the netns
"fwmark": 51821, interfaces:
"private-key": "nFkQQjN+...", # interface name, required
"mtu": 1420, - name: wg-site-a
"peers": [ # list of ip addresses, at least one entry required
{ address:
"public-key": "Kx+wpJpj...", - 10.10.11.172/32
"preshared-key": "5daskLoW...", - fc00:dead:beef:1::172/128
"endpoint": "a.example.com:51821", private-key: nFkQQjN+...
"persistent-keepalive": 25, # optional settings
"allowed-ips": ["10.10.11.0/24", "fc00:dead:beef:1::/64"] listen-port: 51821
} fwmark: 21
] mtu: 1420
}, # list of wireguard peers
{ peers:
"name": "wg-site-b", # public key is required
"address": ["10.10.12.172/32", "fc00:dead:beef:2::172/128"], - public-key: Kx+wpJpj...
"listen-port": 51822, # optional settings
"fwmark": 51822, preshared-key: 5daskLoW...
"private-key": "guYPuE3X...", endpoint: a.example.com:51821
"mtu": 1420, persistent-keepalive: 25
"peers": [ # list of ips the peer is allowed to use, at least one entry required
{ allowed-ips:
"public-key": "NvZMoyrg...", - 10.10.11.0/24
"preshared-key": "cFQuyIX/...", - fc00:dead:beef:1::/64
"endpoint": "b.example.com:51822", # by default the networks specified in 'allowed-ips' are routed over the interface, 'routes' can be used to overwrite this behaivor
"persistent-keepalive": 25, routes:
"allowed-ips": ["10.10.12.0/24", "fc00:dead:beef:2::/64"] - 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
private-key: guYPuE3X...
listen-port: 51822
fwmark: 22
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. Now it's time to setup your new network namespace and all associated wireguard interfaces.
~~~ bash ~~~ bash
wg-netns up ./example.json wg-netns up ./example.yaml
~~~
Profiles stored under `/etc/wireguard/` can be referenced by their name.
~~~ bash
wg-netns up example
~~~ ~~~
You can verify the success with a combination of `ip` and `wg`. You can verify the success with a combination of `ip` and `wg`.
@ -115,7 +133,7 @@ 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/ns-example docker.io/alpine wget -O - https://ipinfo.io podman run -it --rm --network ns:/run/netns/ns-example alpine wget -O - https://ipinfo.io
~~~ ~~~
Or do whatever else you want. Or do whatever else you want.
@ -123,6 +141,11 @@ Or do whatever else you want.
### System Service ### System Service
You can find a `wg-quick@.service` equivalent at [wg-netns@.service](./wg-netns@.service). You can find a `wg-quick@.service` equivalent at [wg-netns@.service](./wg-netns@.service).
Place your profile in `/etc/wireguard/`, e.g. `example.json`, then start the service.
~~~ bash
systemctl start wg-netns@example.service
~~~
### Port Forwarding ### Port Forwarding
@ -132,12 +155,12 @@ With `socat` you can forward TCP traffic from outside a network namespace to a p
socat tcp-listen:$OUTSIDE_PORT,reuseaddr,fork "exec:ip netns exec $NETNS_NAME socat stdio 'tcp-connect:$INSIDE_PORT',nofork" socat tcp-listen:$OUTSIDE_PORT,reuseaddr,fork "exec:ip netns exec $NETNS_NAME socat stdio 'tcp-connect:$INSIDE_PORT',nofork"
~~~ ~~~
Example: All connections to port 1234/tcp in the main netns are forwarded into the *ns-example* namespace to port 5678/tcp. Example: All connections to port 1234/tcp in the main/default netns are forwarded to port 5678/tcp in the *ns-example* namespace.
~~~ bash ~~~ bash
# terminal 1, create netns and start http server inside # terminal 1, create netns and start http server inside
wg-netns up ns-example wg-netns up ns-example
hello > ./hello.txt echo 'Hello from ns-example!' > ./hello.txt
ip netns exec ns-example 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 ns-example 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"

@ -1,200 +1,288 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from __future__ import annotations
from argparse import ArgumentParser, RawDescriptionHelpFormatter from argparse import ArgumentParser, RawDescriptionHelpFormatter
from pathlib import Path from pathlib import Path
from typing import Any, Optional
import dataclasses
import json import json
import os import os
import subprocess import subprocess
import sys import sys
NETNS_CONFIG_DIR = '/etc/netns' try:
DEBUG_LEVEL = 0 import yaml
SHELL = '/bin/sh' YAML_SUPPORTED = True
except ModuleNotFoundError:
YAML_SUPPORTED = False
WIREGUARD_DIR = Path('/etc/wireguard')
NETNS_DIR = Path('/etc/netns')
VERBOSE = 0
SHELL = Path('/bin/sh')
def main(args): def main(args):
global NETNS_CONFIG_DIR global WIREGUARD_DIR
global DEBUG_LEVEL global NETNS_DIR
global VERBOSE
global SHELL global SHELL
entrypoint = ArgumentParser( entrypoint = ArgumentParser(
formatter_class=RawDescriptionHelpFormatter, formatter_class=RawDescriptionHelpFormatter,
epilog=( epilog=(
'environment variables:\n' 'environment variables:\n'
f' NETNS_CONFIG_DIR network namespace config directory, default: {NETNS_CONFIG_DIR}\n' f' WG_PROFILE_DIR wireguard config dir, default: {WIREGUARD_DIR}\n'
f' DEBUG_LEVEL print stack traces, default: {DEBUG_LEVEL}\n' f' WG_NETNS_DIR network namespace config dir, default: {NETNS_DIR}\n'
f' SHELL program for execution of shell hooks, default: {SHELL}\n' f' WG_VERBOSE print detailed output if 1, default: {VERBOSE}\n'
f' WG_SHELL program for execution of shell hooks, default: {SHELL}\n'
), ),
) )
subparsers = entrypoint.add_subparsers(dest='action', required=True) subparsers = entrypoint.add_subparsers(dest='action', required=True, metavar='ACTION')
parser = subparsers.add_parser('up', help='setup namespace and associated interfaces') parser = subparsers.add_parser('up', help='setup namespace and associated interfaces')
parser.add_argument('profile', type=lambda x: Path(x).expanduser(), help='path to profile') parser.add_argument('profile', type=lambda x: Path(x).expanduser(), metavar='PROFILE', help='name or path of profile')
parser = subparsers.add_parser('down', help='teardown namespace and associated interfaces') parser = subparsers.add_parser('down', help='teardown namespace and associated interfaces')
parser.add_argument('-f', '--force', action='store_true', help='ignore errors') 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') parser.add_argument('profile', type=lambda x: Path(x).expanduser(), metavar='PROFILE', help='name or path of profile')
opts = entrypoint.parse_args(args) opts = entrypoint.parse_args(args)
try: try:
NETNS_CONFIG_DIR = Path(os.environ.get('NETNS_CONFIG_DIR', NETNS_CONFIG_DIR)) WIREGUARD_DIR = Path(os.environ.get('WG_PROFILE_DIR', WIREGUARD_DIR))
DEBUG_LEVEL = int(os.environ.get('DEBUG_LEVEL', DEBUG_LEVEL)) NETNS_DIR = Path(os.environ.get('WG_NETNS_DIR', NETNS_DIR))
SHELL = Path(os.environ.get('SHELL', SHELL)) VERBOSE = int(os.environ.get('WG_VERBOSE', VERBOSE))
SHELL = Path(os.environ.get('WG_SHELL', SHELL))
except Exception as e: except Exception as e:
raise RuntimeError(f'failed to load environment variable: {e} (e.__class__.__name__)') from e raise RuntimeError(f'failed to load environment variable: {e} (e.__class__.__name__)') from e
namespace = Namespace.from_profile(opts.profile)
if opts.action == 'up': if opts.action == 'up':
setup_action(opts.profile) try:
namespace.setup()
except KeyboardInterrupt:
namespace.teardown(check=False)
except Exception:
namespace.teardown(check=False)
raise
elif opts.action == 'down': elif opts.action == 'down':
teardown_action(opts.profile, check=not opts.force) namespace.teardown(check=not opts.force)
else: else:
raise RuntimeError('congratulations, you reached unreachable code') raise RuntimeError('congratulations, you reached unreachable code')
def setup_action(path): @dataclasses.dataclass
namespace = profile_read(path) class Peer:
try: name: str
namespace_setup(namespace) public_key: str
except KeyboardInterrupt: preshared_key: Optional[str] = None
namespace_teardown(namespace, check=False) endpoint: Optional[str] = None
except Exception as e: persistent_keepalive: int = 0
namespace_teardown(namespace, check=False) allowed_ips: list[str] = dataclasses.field(default_factory=list)
raise routes: Optional[list[str]] = None
@classmethod
def teardown_action(path, check=True): def from_dict(cls, data: dict[str, Any]) -> Peer:
namespace = profile_read(path) data = {key.replace('-', '_'): value for key, value in data.items()}
namespace_teardown(namespace, check=check) return cls(**data)
def setup(self, interface: Interface, namespace: Namespace) -> Peer:
def profile_read(path): options = [
with open(path) as file: 'peer', self.public_key,
return json.load(file) 'preshared-key', '/dev/stdin' if self.preshared_key else '/dev/null',
'persistent-keepalive', self.persistent_keepalive,
]
def namespace_setup(namespace): if self.endpoint:
if namespace.get('pre-up'): options.extend(('endpoint', self.endpoint))
ip_netns_shell(namespace['pre-up'], netns=namespace) if self.allowed_ips:
namespace_create(namespace) options.extend(('allowed-ips', ','.join(self.allowed_ips)))
namespace_resolvconf_write(namespace) wg('set', interface.name, *options, stdin=self.preshared_key, netns=namespace.name)
for interface in namespace['interfaces']: return self
interface_setup(interface, namespace)
if namespace.get('post-up'):
ip_netns_shell(namespace['post-up'], netns=namespace) @dataclasses.dataclass
class Interface:
name: str
def namespace_create(namespace): public_key: str
ip('netns', 'add', namespace['name']) private_key: str
ip('-n', namespace['name'], 'link', 'set', 'dev', 'lo', 'up') address: list[str] = dataclasses.field(default_factory=list)
listen_port: int = 0
fwmark: int = 0
def namespace_resolvconf_write(namespace): mtu: int = 1420
content = '\n'.join(f'nameserver {server}' for server in namespace.get('dns-server', ())) peers: list[Peer] = dataclasses.field(default_factory=list)
if content:
NETNS_CONFIG_DIR.joinpath(namespace['name']).mkdir(parents=True, exist_ok=True) @classmethod
NETNS_CONFIG_DIR.joinpath(namespace['name']).joinpath('resolv.conf').write_text(content) def from_dict(cls, data: dict[str, Any]) -> Interface:
peers = data.pop('peers', list())
peers = [Peer.from_dict({key.replace('-', '_'): value for key, value in peer.items()}) for peer in peers]
def namespace_teardown(namespace, check=True): return cls(**data, peers=peers)
if namespace.get('pre-down'):
ip_netns_shell(namespace['pre-down'], netns=namespace) def setup(self, namespace: Namespace) -> Interface:
for interface in namespace['interfaces']: self._create(namespace)
interface_teardown(interface, namespace) self._configure_wireguard(namespace)
namespace_delete(namespace) for peer in self.peers:
namespace_resolvconf_delete(namespace) peer.setup(self, namespace)
if namespace.get('post-down'): self._assign_addresses(namespace)
ip_netns_shell(namespace['post-down'], netns=namespace) self._bring_up(namespace)
self._create_routes(namespace)
return self
def namespace_delete(namespace, check=True):
ip('netns', 'delete', namespace['name'], check=check) def _create(self, namespace: Namespace) -> None:
ip('link', 'add', self.name, 'type', 'wireguard')
ip('link', 'set', self.name, 'netns', namespace.name)
def namespace_resolvconf_delete(namespace):
path = NETNS_CONFIG_DIR/namespace['name']/'resolv.conf' def _configure_wireguard(self, namespace: Namespace) -> None:
if path.exists(): wg('set', self.name, 'listen-port', self.listen_port, netns=namespace.name)
path.unlink() wg('set', self.name, 'fwmark', self.fwmark, netns=namespace.name)
try: wg('set', self.name, 'private-key', '/dev/stdin', stdin=self.private_key, netns=namespace.name)
NETNS_CONFIG_DIR.rmdir()
except OSError: def _assign_addresses(self, namespace: Namespace) -> None:
pass for address in self.address:
ip('-n', namespace.name, '-6' if ':' in address else '-4', 'address', 'add', address, 'dev', self.name)
def interface_setup(interface, namespace): def _bring_up(self, namespace: Namespace) -> None:
interface_create(interface, namespace) ip('-n', namespace.name, 'link', 'set', 'dev', self.name, 'mtu', self.mtu, 'up')
interface_configure_wireguard(interface, namespace)
for peer in interface['peers']: def _create_routes(self, namespace: Namespace):
peer_setup(peer, interface, namespace) for peer in self.peers:
interface_assign_addresses(interface, namespace) networks = peer.routes if peer.routes is not None else peer.allowed_ips
interface_bring_up(interface, namespace) for network in networks:
interface_create_routes(interface, namespace) ip('-n', namespace.name, '-6' if ':' in network else '-4', 'route', 'add', network, 'dev', self.name)
def teardown(self, namespace: Namespace, check=True) -> Interface:
def interface_create(interface, namespace): if self.exists(namespace):
ip('link', 'add', interface['name'], 'type', 'wireguard') ip('-n', namespace.name, 'link', 'set', self.name, 'down', check=check)
ip('link', 'set', interface['name'], 'netns', namespace['name']) ip('-n', namespace.name, 'link', 'delete', self.name, check=check)
return self
def interface_configure_wireguard(interface, namespace): def exists(self, namespace: Namespace) -> bool:
wg('set', interface['name'], 'listen-port', interface.get('listen-port', 0), netns=namespace) try:
wg('set', interface['name'], 'fwmark', interface.get('fwmark', 0), netns=namespace) ip('-n', namespace.name, 'link', 'show', self.name, capture=True)
wg('set', interface['name'], 'private-key', '/dev/stdin', stdin=interface['private-key'], netns=namespace) return True
except Exception:
return False
def interface_assign_addresses(interface, namespace):
for address in interface['address']:
ip('-n', namespace['name'], '-6' if ':' in address else '-4', 'address', 'add', address, 'dev', interface['name']) @dataclasses.dataclass
class Namespace:
name: str
def interface_bring_up(interface, namespace): pre_up: Optional[str] = None
ip('-n', namespace['name'], 'link', 'set', 'dev', interface['name'], 'mtu', interface.get('mtu', 1420), 'up') post_up: Optional[str] = None
pre_down: Optional[str] = None
post_down: Optional[str] = None
def interface_create_routes(interface, namespace): managed: bool = True
for peer in interface['peers']: dns_server: list[str] = dataclasses.field(default_factory=list)
networks = peer['routes'] if 'routes' in peer else peer.get('allowed-ips', ()) interfaces: list[Interface] = dataclasses.field(default_factory=list)
for network in networks:
ip('-n', namespace['name'], '-6' if ':' in network else '-4', 'route', 'add', network, 'dev', interface['name']) @classmethod
def from_profile(cls, path: Path) -> Namespace:
try:
def interface_teardown(interface, namespace, check=True): return cls.from_dict(cls._read_profile(cls._find_profile(path)))
ip('-n', namespace['name'], 'link', 'set', interface['name'], 'down', check=check) except Exception as e:
ip('-n', namespace['name'], 'link', 'delete', interface['name'], check=check) raise RuntimeError('failed to load profile') from e
@staticmethod
def peer_setup(peer, interface, namespace): def _find_profile(profile: Path) -> Path:
options = [ if not profile.is_file() and profile.name == profile.as_posix(): # path does not contain '/' and '.'
'peer', peer['public-key'], for extension in ('yaml', 'yml', 'json'):
'preshared-key', '/dev/stdin' if peer.get('preshared-key') else '/dev/null', path = WIREGUARD_DIR/f'{profile.name}.{extension}'
'persistent-keepalive', peer.get('persistent-keepalive', 0), if path.is_file():
] return path
if peer.get('endpoint'): return profile
options.extend(('endpoint', peer.get('endpoint')))
if peer.get('allowed-ips'): @staticmethod
options.extend(('allowed-ips', ','.join(peer['allowed-ips']))) def _read_profile(profile: Path) -> dict[str, Any]:
wg('set', interface['name'], *options, stdin=peer.get('preshared-key'), netns=namespace) with open(profile) as file:
if profile.suffix in ('.yaml', '.yml'):
if not YAML_SUPPORTED:
def wg(*args, **kwargs): raise RuntimeError(f'can not load profile in yaml format if pyyaml library is not installed')
return ip_netns_exec('wg', *args, **kwargs) return yaml.safe_load(file)
elif profile.suffix == '.json':
return json.load(file)
def ip_netns_shell(*args, **kwargs): else:
return ip_netns_exec(SHELL, '-c', *args, **kwargs) raise RuntimeError(f'unsupported file format {profile.suffix.removeprefix(".")}')
@classmethod
def ip_netns_exec(*args, netns=None, **kwargs): def from_dict(cls, data: dict[str, Any]) -> Namespace:
return ip('netns', 'exec', netns['name'], *args, **kwargs) data = {key.replace('-', '_'): value for key, value in data.items()}
interfaces = data.pop('interfaces', list())
interfaces = [Interface.from_dict({key.replace('-', '_'): value for key, value in interface.items()}) for interface in interfaces]
def ip(*args, **kwargs): return cls(**data, interfaces=interfaces)
return run('ip', *args, **kwargs)
def setup(self) -> Namespace:
if self.pre_up:
def run(*args, stdin=None, check=True, capture=False): ip_netns_eval(self.pre_up, netns=self.name)
if self.managed:
self._create()
self._write_resolvconf()
for interface in self.interfaces:
interface.setup(self)
if self.post_up:
ip_netns_eval(self.post_up, netns=self.name)
return self
def teardown(self, check=True) -> Namespace:
if self.pre_down:
ip_netns_eval(self.pre_down, netns=self.name)
for interface in self.interfaces:
interface.teardown(self, check=check)
if self.managed and self.exists():
self._delete(check)
self._delete_resolvconf()
if self.post_down:
ip_netns_eval(self.post_down, netns=self.name)
return self
def exists(self) -> bool:
namespaces = json.loads(ip('-j', 'netns', 'list', capture=True))
return self.name in {namespace['name'] for namespace in namespaces}
def _create(self) -> None:
ip('netns', 'add', self.name)
ip('-n', self.name, 'link', 'set', 'dev', 'lo', 'up')
def _delete(self, check=True) -> None:
ip('netns', 'delete', self.name, check=check)
@property
def _resolvconf_path(self) -> Path:
return NETNS_DIR/self.name/'resolv.conf'
def _write_resolvconf(self) -> None:
if self.dns_server:
self._resolvconf_path.parent.mkdir(parents=True, exist_ok=True)
content = '\n'.join(f'nameserver {server}' for server in self.dns_server)
self._resolvconf_path.write_text(content)
def _delete_resolvconf(self) -> None:
if self._resolvconf_path.exists():
self._resolvconf_path.unlink()
try:
NETNS_DIR.rmdir()
except OSError:
pass
def wg(*args, netns: str = None, stdin: str = None, check=True, capture=False) -> str:
return ip_netns_exec('wg', *args, netns=netns, stdin=stdin, check=check, capture=capture)
def ip_netns_eval(*args, netns: str = None, stdin: str = None, check=True, capture=False) -> str:
return ip_netns_exec(SHELL, '-c', *args, netns=netns, stdin=stdin, check=check, capture=capture)
def ip_netns_exec(*args, netns: str = None, stdin: str = None, check=True, capture=False) -> str:
return ip('netns', 'exec', netns, *args, stdin=stdin, check=check, capture=capture)
def ip(*args, stdin: str = None, check=True, capture=False) -> str:
return run('ip', *args, stdin=stdin, check=check, capture=capture)
def run(*args, stdin: str = None, check=True, capture=False) -> str:
args = [str(item) if item is not None else '' for item in args] args = [str(item) if item is not None else '' for item in args]
if DEBUG_LEVEL: if VERBOSE:
print('>', ' '.join(args), file=sys.stderr) print('>', ' '.join(args), file=sys.stderr)
process = subprocess.run(args, input=stdin, text=True, capture_output=capture) process = subprocess.run(args, input=stdin, text=True, capture_output=capture)
if check and process.returncode != 0: if check and process.returncode != 0:
@ -208,7 +296,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 DEBUG_LEVEL:
raise
print(f'error: {e} ({e.__class__.__name__})', file=sys.stderr) print(f'error: {e} ({e.__class__.__name__})', file=sys.stderr)
sys.exit(2) if VERBOSE:
raise
sys.exit(1)

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

Loading…
Cancel
Save