pax_global_header00006660000000000000000000000064147337545740014535gustar00rootroot0000000000000052 comment=bcc28dcabcaa223b6ad0dec63571449eb9bf560e dadevel-wg-netns-bcc28dc/000077500000000000000000000000001473375457400154535ustar00rootroot00000000000000dadevel-wg-netns-bcc28dc/.github/000077500000000000000000000000001473375457400170135ustar00rootroot00000000000000dadevel-wg-netns-bcc28dc/.github/dependabot.yml000066400000000000000000000001441473375457400216420ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: pip directory: / schedule: interval: daily dadevel-wg-netns-bcc28dc/.gitignore000066400000000000000000000000151473375457400174370ustar00rootroot00000000000000__pycache__/ dadevel-wg-netns-bcc28dc/LICENSE000066400000000000000000000020471473375457400164630ustar00rootroot00000000000000MIT License Copyright (c) 2020 Daniel 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. dadevel-wg-netns-bcc28dc/README.md000066400000000000000000000136361473375457400167430ustar00rootroot00000000000000# wg-netns [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). ## Setup Requirements: - Python 3.7 or newer - `ip` from iproute2 - `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: a) With [pipx](https://github.com/pypa/pipx). ~~~ bash pipx install git+https://github.com/dadevel/wg-netns.git@main ~~~ b) With `pip`. ~~~ bash pip install --user git+https://github.com/dadevel/wg-netns.git@main ~~~ c) As standalone script. ~~~ bash curl -o ~/.local/bin/wg-netns https://raw.githubusercontent.com/dadevel/wg-netns/main/wgnetns/main.py chmod +x ~/.local/bin/wg-netns ~~~ ## Usage First, create a configuration profile. JSON and YAML file formats are supported. Minimal JSON example: ~~~ 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"] } ] } ] } ~~~ Full YAML example: ~~~ yaml # name of the network namespace where the interface is moved into, if null the default namespace is used name: ns-example # namespace where the interface is initialized, if null the default namespace is used base_netns: null # if false, the netns itself won't be created or deleted, just the interfaces inside it managed: true # list of dns servers, if empty dns servers from default netns will be used dns-server: [10.10.10.1, 10.10.10.2] # shell hooks, e.g. to set firewall rules, two formats are supported pre-up: echo pre-up from managed netns post-up: - host-namespace: true command: echo post-up from host netns - host-namespace: false command: echo post-up from managed netns pre-down: echo pre-down from managed netns post-down: echo post-down from managed netns # list of wireguard interfaces inside the netns interfaces: # interface name, required - name: wg-site-a # list of ip addresses, at least one entry required address: - 10.10.11.172/32 - fc00:dead:beef:1::172/128 # can also be set via "wg set wg-site-a $key" private-key: nFkQQjN+... # optional settings listen-port: 51821 fwmark: 21 mtu: 1420 # list of wireguard peers peers: # public key is required - public-key: Kx+wpJpj... # optional settings preshared-key: 5daskLoW... endpoint: a.example.com:51821 persistent-keepalive: 25 # list of ips the peer is allowed to use, at least one entry required allowed-ips: - 10.10.11.0/24 - fc00:dead:beef:1::/64 # by default the networks specified in 'allowed-ips' are routed over the interface, 'routes' can be used to overwrite this behaivor routes: - 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. ~~~ bash 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`. ~~~ bash ip netns exec ns-example wg show ~~~ You can also spawn a shell inside the netns. ~~~ bash ip netns exec ns-example bash -i ~~~ ### Systemd Service You can find a `wg-quick@.service` equivalent at [wg-netns@.service](./extras/wg-netns@.service). Place your profile in `/etc/wireguard/`, e.g. `example.json`, then start the service. ~~~ bash curl -o /etc/systemd/system/wg-netns@.service https://raw.githubusercontent.com/dadevel/wg-netns/main/extras/wg-netns@.service systemctl enable --now wg-netns@example.service ~~~ If you are using SELinux, you have to change the SELinux context label, e.g. to `bin_t`, otherwise the service will not find the executable. ~~~ bash chcon -t bin_t /root/.local/bin/wg-netns ~~~ ### Podman Integration A podman container can be easily attached to a network namespace created by `wg-netns`. The example below starts a container connected to a netns named *ns-example*. ~~~ bash podman run -it --rm --network ns:/run/netns/ns-example docker.io/library/alpine wget -q -O - https://ipinfo.io ~~~ ### Port Forwarding with Socat [netns-publish](./extras/netns-publish.sh) is a small wrapper around `socat` that can forward TCP traffic from outside a network namespace to a port inside a network namespace. Example: All connections to port 1234/tcp in the main/default netns are forwarded to port 5678/tcp in the *ns-example* namespace. ~~~ bash # terminal 1, create netns and start http server inside wg-netns up ns-example echo 'Hello from ns-example!' > ./hello.txt ip netns exec ns-example python3 -m http.server 5678 # terminal 2, setup port forwarding ./extras/netns-publish.sh 1234 ns-example 127.0.0.1:5678 # terminal 3, test access curl http://127.0.0.1:1234/hello.txt ~~~ ### WireGuard with DynDNS If your WireGuard server endpoint is a DynDNS domain you can use the [wg-resolve](./extras/wg-resolve/) script to periodically check the connectivity and re-resolve the endpoint if necessary. ### Firefox in Network Namespace Start a dedicated Firefox profile with working audio inside the netns created by `wg-netns`. ~~~ bash sudo ip netns exec ns-example sudo -u "$USER" "HOME=$HOME" "PULSE_SERVER=/run/user/$(id -u)/pulse/native" "PULSE_COOKIE=$HOME/.config/pulse/cookie" firefox -P vpn ~~~ dadevel-wg-netns-bcc28dc/extras/000077500000000000000000000000001473375457400167615ustar00rootroot00000000000000dadevel-wg-netns-bcc28dc/extras/netns-publish.sh000077500000000000000000000003561473375457400221170ustar00rootroot00000000000000#!/bin/sh set -eu if [ $# -ne 3 ]; then echo 'usage: netns-publish PUBLIC_PORT NETNS_NAME NETNS_ADDRESS:NETNS_PORT' exit 1 fi exec socat tcp-listen:"$1",reuseaddr,fork "exec:ip netns exec $2 socat stdio 'tcp-connect:$3',nofork" dadevel-wg-netns-bcc28dc/extras/wg-netns@.service000066400000000000000000000014531473375457400222100ustar00rootroot00000000000000[Unit] Description=WireGuard Network Namespace (%i) Wants=network-online.target nss-lookup.target After=network-online.target nss-lookup.target [Service] Type=oneshot Environment=WG_ENDPOINT_RESOLUTION_RETRIES=infinity Environment=WG_VERBOSE=1 ExecStart=wg-netns up %i ExecStop=wg-netns down %i RemainAfterExit=yes WorkingDirectory=%E/wireguard ConfigurationDirectory=wireguard ConfigurationDirectoryMode=0700 CapabilityBoundingSet=CAP_NET_ADMIN CAP_SYS_ADMIN LimitNOFILE=4096 LimitNPROC=512 LockPersonality=true MemoryDenyWriteExecute=true NoNewPrivileges=true ProtectClock=true ProtectHostname=true RemoveIPC=true RestrictAddressFamilies=AF_INET AF_INET6 AF_NETLINK RestrictNamespaces=mnt net RestrictRealtime=true RestrictSUIDSGID=true SystemCallArchitectures=native [Install] WantedBy=multi-user.target dadevel-wg-netns-bcc28dc/extras/wg-resolve/000077500000000000000000000000001473375457400210535ustar00rootroot00000000000000dadevel-wg-netns-bcc28dc/extras/wg-resolve/config.env000066400000000000000000000004471473375457400230370ustar00rootroot00000000000000# network namespace name WG_NAMESPACE=netns0 # local wireguard interface name WG_INTERFACE=wg0 # server wireguard public key WG_PEER=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX # server wireguard interface ip WG_GATEWAY=192.168.100.1 # server dyndns domain WG_ENDPOINT=vpn.dyndns.example:51820 dadevel-wg-netns-bcc28dc/extras/wg-resolve/wg-resolve.sh000077500000000000000000000006721473375457400235110ustar00rootroot00000000000000#!/bin/sh set -eu # dependencies: cut, getent, ip, ping and wg WG_ENDPOINT_DOMAIN="${WG_ENDPOINT%%:*}" WG_ENDPOINT_PORT="${WG_ENDPOINT##*:}" if ! ip netns exec "$WG_NAMESPACE" ping -q -c 1 -W "${WG_TIMEOUT:-5}" "$WG_GATEWAY"; then echo 'probe failed, resolving endpoint' ip netns exec "$WG_NAMESPACE" wg set "$WG_INTERFACE" peer "$WG_PEER" endpoint "$(getent hosts -- "$WG_ENDPOINT_DOMAIN" | cut -d ' ' -f 1):$WG_ENDPOINT_PORT" fi dadevel-wg-netns-bcc28dc/extras/wg-resolve/wg-resolve@.service000066400000000000000000000004471473375457400246340ustar00rootroot00000000000000[Unit] Description=WireGuard Endpoint Resolver (%i) Wants=network-online.target nss-lookup.target After=network-online.target nss-lookup.target [Service] Type=oneshot EnvironmentFile=%E/wireguard/%i.env Environment=WG_ENDPOINT_RESOLUTION_RETRIES=infinity ExecStart=/usr/local/lib/wg-resolve.sh dadevel-wg-netns-bcc28dc/extras/wg-resolve/wg-resolve@.timer000066400000000000000000000002061473375457400243050ustar00rootroot00000000000000[Unit] Description=Minutely WireGuard Endpoint Resolver [Timer] OnCalendar=minutely AccuracySec=5s [Install] WantedBy=timers.target dadevel-wg-netns-bcc28dc/poetry.lock000066400000000000000000000176361473375457400176640ustar00rootroot00000000000000# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. [[package]] name = "pyyaml" version = "6.0.1" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.6" files = [ {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, ] [metadata] lock-version = "2.0" python-versions = "^3.7" content-hash = "26f0d3bbc0e7914fb97929a54b78c3c81fa915b86abdd44d69321cf5b462cd0d" dadevel-wg-netns-bcc28dc/pyproject.toml000066400000000000000000000006131473375457400203670ustar00rootroot00000000000000[tool.poetry] name = "wgnetns" version = "2.3.5" description = "wg-quick for network namespaces" authors = ["dadevel "] license = "MIT" [tool.poetry.scripts] wg-netns = "wgnetns.main:main" [tool.poetry.dependencies] python = "^3.7" pyyaml = "^6.0" [tool.poetry.dev-dependencies] [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" dadevel-wg-netns-bcc28dc/wgnetns/000077500000000000000000000000001473375457400171405ustar00rootroot00000000000000dadevel-wg-netns-bcc28dc/wgnetns/main.py000077500000000000000000000357241473375457400204540ustar00rootroot00000000000000#!/usr/bin/env python3 from __future__ import annotations from argparse import ArgumentParser, RawDescriptionHelpFormatter from pathlib import Path from typing import Any, Optional import dataclasses import getpass import json import os import subprocess import sys try: import yaml YAML_SUPPORTED = True except ModuleNotFoundError: YAML_SUPPORTED = False yaml = NotImplemented WIREGUARD_DIR = Path('/etc/wireguard') NETNS_DIR = Path('/etc/netns') VERBOSE = 0 SHELL = Path('/bin/sh') def main(): try: cli(sys.argv[1:]) sys.exit(0) except Exception as e: print(f'error: {e} ({e.__class__.__name__})', file=sys.stderr) if VERBOSE: raise sys.exit(1) def cli(args): global WIREGUARD_DIR global NETNS_DIR global VERBOSE global SHELL entrypoint = ArgumentParser( formatter_class=RawDescriptionHelpFormatter, epilog=( 'environment variables:\n' f' WG_PROFILE_DIR wireguard config dir, default: {WIREGUARD_DIR}\n' f' WG_NETNS_DIR network namespace config dir, default: {NETNS_DIR}\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, metavar='ACTION') parser = subparsers.add_parser('up', help='setup namespace and associated interfaces') 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.add_argument('-f', '--force', action='store_true', help='ignore errors') parser.add_argument('profile', type=lambda x: Path(x).expanduser(), metavar='PROFILE', help='name or path of profile') parser = subparsers.add_parser('list', help='list network namespaces') parser = subparsers.add_parser('switch', help='open shell in namespace') parser.add_argument('netns', metavar='NETNS', help='network namespace name') parser = subparsers.add_parser('exec', help='run command in namespace') parser.add_argument('netns', metavar='NETNS', help='network namespace name') parser.add_argument('command', nargs='+', help='command') opts = entrypoint.parse_args(args) try: WIREGUARD_DIR = Path(os.environ.get('WG_PROFILE_DIR', WIREGUARD_DIR)) NETNS_DIR = Path(os.environ.get('WG_NETNS_DIR', NETNS_DIR)) VERBOSE = int(os.environ.get('WG_VERBOSE', VERBOSE)) SHELL = Path(os.environ.get('WG_SHELL', SHELL)) except Exception as e: raise RuntimeError(f'failed to load environment variable: {e} (e.__class__.__name__)') from e if opts.action == 'up': _conditional_elevate() namespace = Namespace.from_profile(opts.profile) try: namespace.setup() except KeyboardInterrupt: namespace.teardown(check=False) except Exception: namespace.teardown(check=False) raise elif opts.action == 'down': _conditional_elevate() namespace = Namespace.from_profile(opts.profile) namespace.teardown(check=not opts.force) elif opts.action == 'list': output = ip('-json', 'netns', capture=True) if not output: return data = json.loads(output) print('\n'.join(item['name'] for item in data)) elif opts.action == 'switch': os.execvp('sudo', ['ip', 'ip', 'netns', 'exec', opts.netns, 'sudo', '-u', getpass.getuser(), os.environ['SHELL'], '-i']) elif opts.action == 'exec': os.execvp('sudo', ['ip', 'ip', 'netns', 'exec', opts.netns, 'sudo', '-u', getpass.getuser(), *opts.command]) else: raise RuntimeError('congratulations, you reached unreachable code') def _conditional_elevate() -> None: if os.getuid() != 0 and os.isatty(sys.stdin.fileno()): os.execvp('sudo', [sys.argv[0], *sys.argv]) @dataclasses.dataclass class Peer: public_key: str preshared_key: Optional[str] = None name: Optional[str] = None endpoint: Optional[str] = None persistent_keepalive: int = 0 allowed_ips: list[str] = dataclasses.field(default_factory=list) routes: Optional[list[str]] = None @classmethod def from_dict(cls, data: dict[str, Any]) -> Peer: data = {key.replace('-', '_'): value for key, value in data.items()} return cls(**data) def setup(self, interface: Interface, namespace: str|None) -> Peer: options = [ 'peer', self.public_key, 'preshared-key', '/dev/stdin' if self.preshared_key else '/dev/null', 'persistent-keepalive', self.persistent_keepalive, ] if self.endpoint: options.extend(('endpoint', self.endpoint)) if self.allowed_ips: options.extend(('allowed-ips', ','.join(self.allowed_ips))) wg('set', interface.name, *options, stdin=self.preshared_key, netns=namespace) return self @dataclasses.dataclass class Interface: name: str base_netns: str|None = None private_key: Optional[str] = None public_key: Optional[str] = None address: list[str] = dataclasses.field(default_factory=list) listen_port: int = 0 fwmark: int = 0 mtu: int = 1420 peers: list[Peer] = dataclasses.field(default_factory=list) @classmethod def from_dict(cls, data: dict[str, Any], base_netns: str|None = None) -> Interface: peers = data.pop('peers', list()) peers = [Peer.from_dict({key.replace('-', '_'): value for key, value in peer.items()}) for peer in peers] return cls(**data, peers=peers, base_netns=base_netns) def setup(self, namespace: Namespace) -> Interface: self._create() self._configure_wireguard() for peer in self.peers: peer.setup(self, self.base_netns) self._assign_namespace(namespace.name) self._assign_addresses(namespace.name) self._bring_up(namespace.name) self._create_routes(namespace.name) return self def _create(self) -> None: ip('link', 'add', self.name, 'type', 'wireguard', netns=self.base_netns) def _configure_wireguard(self) -> None: wg('set', self.name, 'listen-port', self.listen_port, netns=self.base_netns) wg('set', self.name, 'fwmark', self.fwmark, netns=self.base_netns) if self.private_key: wg('set', self.name, 'private-key', '/dev/stdin', stdin=self.private_key, netns=self.base_netns) def _assign_namespace(self, namespace: str|None) -> None: ip('link', 'set', self.name, 'netns', namespace if namespace else '1', netns=self.base_netns) def _assign_addresses(self, namespace: str|None) -> None: for address in self.address: ip('-6' if ':' in address else '-4', 'address', 'add', address, 'dev', self.name, netns=namespace) def _bring_up(self, namespace: str|None) -> None: ip('link', 'set', 'dev', self.name, 'mtu', self.mtu, 'up', netns=namespace) def _create_routes(self, namespace: str|None): for peer in self.peers: networks = peer.routes if peer.routes is not None else peer.allowed_ips for network in networks: ip('-6' if ':' in network else '-4', 'route', 'add', network, 'dev', self.name, netns=namespace) def teardown(self, namespace: Namespace, check=True) -> Interface: if self.exists(namespace): ip('link', 'set', self.name, 'down', check=check, netns=namespace.name) ip('link', 'delete', self.name, check=check, netns=namespace.name) return self def exists(self, namespace: Namespace) -> bool: try: ip('link', 'show', self.name, capture=True, netns=namespace.name) return True except Exception: return False @dataclasses.dataclass class ScriptletItem: command: str host_namespace: bool = False @classmethod def from_str(cls, data: str) -> ScriptletItem: return cls(command=data) @classmethod def from_dict(cls, data: dict[str, Any]) -> ScriptletItem: data = {key.replace('-', '_'): value for key, value in data.items()} host_namespace = bool(data.pop('host_namespace', None)) return cls(**data, host_namespace=host_namespace) def run(self, netns: str|None): if self.host_namespace or not netns: host_eval(self.command) else: ip_netns_eval(self.command, netns=netns) @dataclasses.dataclass class Scriptlet: items: list[ScriptletItem] = dataclasses.field(default_factory=list) @classmethod def from_value(cls, data) -> Scriptlet: if isinstance(data, list): return cls.from_list(data) elif isinstance(data, str): return cls.from_singleton(data) else: raise RuntimeError(f'unsupported scriptlet type: {data.__class__.__name__}') @classmethod def from_list(cls, data: list[Any]) -> Scriptlet: items = [ScriptletItem.from_dict(item) for item in data] return cls(items=items) @classmethod def from_singleton(cls, data) -> Scriptlet: item = ScriptletItem.from_str(data) return cls(items=[item]) def run(self, netns: str|None): for item in self.items: item.run(netns=netns) @dataclasses.dataclass class Namespace: name: str|None pre_up: Optional[Scriptlet] = None post_up: Optional[Scriptlet] = None pre_down: Optional[Scriptlet] = None post_down: Optional[Scriptlet] = None managed: bool = True dns_server: list[str] = dataclasses.field(default_factory=list) interfaces: list[Interface] = dataclasses.field(default_factory=list) @classmethod def from_profile(cls, path: Path) -> Namespace: try: return cls.from_dict(cls._read_profile(cls._find_profile(path))) except Exception as e: raise RuntimeError(f'failed to load profile: {e}') from e @staticmethod def _find_profile(profile: Path) -> Path: if not profile.is_file() and profile.name == profile.as_posix(): # path does not contain '/' and '.' for extension in ('yaml', 'yml', 'json'): path = WIREGUARD_DIR/f'{profile.name}.{extension}' if path.is_file(): return path return profile @staticmethod def _read_profile(profile: Path) -> dict[str, Any]: with open(profile) as file: if profile.suffix in ('.yaml', '.yml'): if not YAML_SUPPORTED: raise RuntimeError(f'can not load profile in yaml format if pyyaml library is not installed') return yaml.safe_load(file) elif profile.suffix == '.json': return json.load(file) else: raise RuntimeError(f'unsupported file format {profile.suffix.removeprefix(".")}') @classmethod def from_dict(cls, data: dict[str, Any]) -> Namespace: data = {key.replace('-', '_'): value for key, value in data.items()} scriptlets = {key: data.pop(key, None) for key in ['pre_up', 'post_up', 'pre_down', 'post_down']} scriptlets = {key: Scriptlet.from_value(value) for key, value in scriptlets.items() if value is not None} interfaces = data.pop('interfaces', list()) base_netns = data.pop('base_netns', None) interfaces = [Interface.from_dict({key.replace('-', '_'): value for key, value in interface.items()}, base_netns=base_netns) for interface in interfaces] return cls(**data, **scriptlets, interfaces=interfaces) # type: ignore def setup(self) -> Namespace: if self.managed and self.name: self._create() self._write_resolvconf() if self.pre_up: self.pre_up.run(netns=self.name) for interface in self.interfaces: interface.setup(self) if self.post_up: self.post_up.run(netns=self.name) return self def teardown(self, check=True) -> Namespace: if self.pre_down: self.pre_down.run(netns=self.name) for interface in self.interfaces: interface.teardown(self, check=check) if self.post_down: self.post_down.run(netns=self.name) if self.managed and self.exists(): self._delete(check) self._delete_resolvconf() 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('link', 'set', 'dev', 'lo', 'up', netns=self.name) def _delete(self, check=True) -> None: ip('netns', 'delete', self.name, check=check) @property def _resolvconf_path(self) -> Path: assert self.name 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 = None, stdin: str|None = None, check=True, capture=False) -> str: if netns: return ip_netns_exec('wg', *args, netns=netns, stdin=stdin, check=check, capture=capture) else: return run('wg', *args, stdin=stdin, check=check, capture=capture) def ip_netns_eval(*args, netns: str, stdin: str|None = 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, stdin: str|None = None, check=True, capture=False) -> str: return ip('netns', 'exec', netns, *args, stdin=stdin, check=check, capture=capture) def ip(*args, stdin: str|None = None, netns: str|None =None, check=True, capture=False) -> str: return run('ip', *(['-n', netns] if netns else []), *args, stdin=stdin, check=check, capture=capture) def host_eval(*args, stdin: str|None = None, check=True, capture=False) -> str: return run(SHELL, '-c', *args, stdin=stdin, check=check, capture=capture) def run(*args, stdin: str|None = None, check=True, capture=False) -> str: args = [str(item) if item is not None else '' for item in args] if VERBOSE: 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__': main()