From 112163094a3fd75dad857136a8dcb6ae7283a4d2 Mon Sep 17 00:00:00 2001 From: Walter Oggioni Date: Mon, 9 Sep 2024 22:22:53 +0800 Subject: [PATCH] initial commit --- .gitea/workflows/build.yaml | 27 +++++++ .gitignore | 5 ++ conf/example.toml | 17 ++++ pyproject.toml | 53 +++++++++++++ requirements-dev.txt | 149 ++++++++++++++++++++++++++++++++++++ requirements.txt | 21 +++++ src/wg_builder/__init__.py | 0 src/wg_builder/builder.py | 143 ++++++++++++++++++++++++++++++++++ src/wg_builder/cli.py | 35 +++++++++ 9 files changed, 450 insertions(+) create mode 100644 .gitea/workflows/build.yaml create mode 100644 .gitignore create mode 100644 conf/example.toml create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 src/wg_builder/__init__.py create mode 100644 src/wg_builder/builder.py create mode 100644 src/wg_builder/cli.py diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..01e31f8 --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -0,0 +1,27 @@ +name: CI +on: + push: + branches: [ master ] +jobs: + build: + runs-on: woryzen + steps: + - name: Checkout sources + uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + cache: 'pip' + - name: Create virtualenv + run: | + python -m venv .venv + .venv/bin/pip install -r requirements-dev.txt + - name: Execute build + run: | + .venv/bin/python -m build + - name: Publish artifacts + env: + TWINE_REPOSITORY_URL: ${{ vars.PYPI_REGISTRY_URL }} + TWINE_USERNAME: ${{ vars.PUBLISHER_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PUBLISHER_TOKEN }} + run: | + .venv/bin/python -m twine upload --repository gitea dist/*{.whl,tar.gz} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..480ac38 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.venv +.idea +*.pyc +*.egg-info +__pycache__ diff --git a/conf/example.toml b/conf/example.toml new file mode 100644 index 0000000..959e73c --- /dev/null +++ b/conf/example.toml @@ -0,0 +1,17 @@ +interface_name = "docker-cluster" +netmask = "10.45.62.0/24" +dns = [ "192.168.78.7", "1.1.1.1"] + +[[server]] +name = "odroid-c4" +address = '155.24.76.11' +port = 1234 + +[[peer]] +name = "odroid-hc1" + +[[peer]] +name = "woryzen" + +[[peer]] +name = "sugo14" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..51f08ab --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,53 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "wg_builder" +version = "0.0.1" +authors = [ + { name="Walter Oggioni", email="oggioni.walter@gmail.com" }, +] +description = "Wireguard network builder" +readme = "README.md" +requires-python = ">=3.12" +classifiers = [ + 'Development Status :: 3 - Alpha', + 'Topic :: Utilities', + 'License :: OSI Approved :: MIT License', + 'Intended Audience :: System Administrators', + 'Intended Audience :: Developers', + 'Environment :: Console', + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python :: 3', +] +dependencies = [ + 'python-wireguard', + 'toml', + 'netaddr', + 'typing_extensions', + 'pwo' +] + +[project.optional-dependencies] +dev = [ + 'build', 'pip-tools', 'mypy', 'ipdb', 'twine' +] + + +[project.urls] +"Homepage" = "https://gitea.woggioni.net/woggioni/wg_builder" +"Bug Tracker" = "https://gitea.woggioni.net/woggioni/wg_builder/issues" + +[project.scripts] +wg-builder = "wg_builder.cli:main" + +[tool.mypy] +python_version = "3.12" +disallow_untyped_defs = true +show_error_codes = true +no_implicit_optional = true +warn_return_any = true +warn_unused_ignores = true +exclude = ["scripts", "docs", "test"] +strict = true \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..61c60ca --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,149 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --extra=dev --output-file=requirements-dev.txt pyproject.toml +# +--index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple +--extra-index-url https://pypi.org/simple + +asttokens==2.4.1 + # via stack-data +build==1.2.2 + # via + # pip-tools + # wg_builder (pyproject.toml) +certifi==2024.8.30 + # via requests +cffi==1.17.1 + # via cryptography +charset-normalizer==3.3.2 + # via requests +click==8.1.7 + # via pip-tools +cryptography==43.0.1 + # via secretstorage +decorator==5.1.1 + # via + # ipdb + # ipython +docutils==0.21.2 + # via readme-renderer +executing==2.1.0 + # via stack-data +idna==3.8 + # via requests +importlib-metadata==8.4.0 + # via twine +ipdb==0.13.13 + # via wg_builder (pyproject.toml) +ipython==8.27.0 + # via ipdb +jaraco-classes==3.4.0 + # via keyring +jaraco-context==6.0.1 + # via keyring +jaraco-functools==4.0.2 + # via keyring +jedi==0.19.1 + # via ipython +jeepney==0.8.0 + # via + # keyring + # secretstorage +keyring==25.3.0 + # via twine +markdown-it-py==3.0.0 + # via rich +matplotlib-inline==0.1.7 + # via ipython +mdurl==0.1.2 + # via markdown-it-py +more-itertools==10.5.0 + # via + # jaraco-classes + # jaraco-functools +mypy==1.11.2 + # via wg_builder (pyproject.toml) +mypy-extensions==1.0.0 + # via mypy +netaddr==1.3.0 + # via wg_builder (pyproject.toml) +nh3==0.2.18 + # via readme-renderer +packaging==24.1 + # via build +parso==0.8.4 + # via jedi +pexpect==4.9.0 + # via ipython +pip-tools==7.4.1 + # via wg_builder (pyproject.toml) +pkginfo==1.10.0 + # via twine +prompt-toolkit==3.0.47 + # via ipython +ptyprocess==0.7.0 + # via pexpect +pure-eval==0.2.3 + # via stack-data +pwo==0.0.2 + # via wg_builder (pyproject.toml) +pycparser==2.22 + # via cffi +pygments==2.18.0 + # via + # ipython + # readme-renderer + # rich +pyproject-hooks==1.1.0 + # via + # build + # pip-tools +python-wireguard==0.2.2 + # via wg_builder (pyproject.toml) +readme-renderer==44.0 + # via twine +requests==2.32.3 + # via + # requests-toolbelt + # twine +requests-toolbelt==1.0.0 + # via twine +rfc3986==2.0.0 + # via twine +rich==13.8.0 + # via twine +secretstorage==3.3.3 + # via keyring +six==1.16.0 + # via asttokens +stack-data==0.6.3 + # via ipython +toml==0.10.2 + # via wg_builder (pyproject.toml) +traitlets==5.14.3 + # via + # ipython + # matplotlib-inline +twine==5.1.1 + # via wg_builder (pyproject.toml) +typing-extensions==4.7.1 + # via + # mypy + # pwo + # wg_builder (pyproject.toml) +urllib3==2.2.2 + # via + # requests + # twine +wcwidth==0.2.13 + # via prompt-toolkit +wheel==0.44.0 + # via pip-tools +zipp==3.20.1 + # via importlib-metadata + +# The following packages are considered to be unsafe in a requirements file: +# pip +# setuptools diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ba7a8d8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,21 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --output-file=requirements.txt pyproject.toml +# +--index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple +--extra-index-url https://pypi.org/simple + +netaddr==1.3.0 + # via wg_builder (pyproject.toml) +pwo==0.0.2 + # via wg_builder (pyproject.toml) +python-wireguard==0.2.2 + # via wg_builder (pyproject.toml) +toml==0.10.2 + # via wg_builder (pyproject.toml) +typing-extensions==4.7.1 + # via + # pwo + # wg_builder (pyproject.toml) diff --git a/src/wg_builder/__init__.py b/src/wg_builder/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/wg_builder/builder.py b/src/wg_builder/builder.py new file mode 100644 index 0000000..bbd18da --- /dev/null +++ b/src/wg_builder/builder.py @@ -0,0 +1,143 @@ +import tarfile +from base64 import b64encode +from os import makedirs +from pathlib import Path +from random import SystemRandom +from tarfile import TarInfo +from textwrap import dedent + +import toml +from netaddr import IPNetwork +from netaddr.ip import IPAddress +from pwo import Maybe +from python_wireguard import Key + +random = SystemRandom() +from io import BytesIO +import codecs + +def genPsk() -> str: + return b64encode(random.randbytes(32)).decode('UTF-8') + + +def build_network(input_file: Path = Path('/dev/stdin'), output_folder: Path = Path('.'), archive=False, **kwargs): + with open(input_file) as conf: + configuration = toml.load(conf) + + if 'dns' in configuration: + dns = tuple(IPAddress(address) for address in configuration['dns']) + else: + dns = tuple() + + network_name = Maybe.of_nullable(configuration.get('network_name')).or_else(input_file.stem) + network = IPNetwork(configuration['netmask']) + address_it = network.iter_hosts() + + class Server: + endpoint: str + address: str + port: int + private_key: str + public_key: str + + def __init__(self, conf): + self.name = conf['name'] + self.private_key, self.public_key = Key.key_pair() + self.address = str(address_it.__next__()) + self.endpoint = conf['address'] + self.port = conf['port'] + + class Peer: + name: str + private_key: str + public_key: str + allowed_ip: str + + def __init__(self, conf): + self.name = conf['name'] + self.private_key, self.public_key = Key.key_pair() + self.allowed_ip = str(address_it.__next__()) + + servers = [Server(server) for server in configuration['server']] + + peers = [Peer(peer) for peer in configuration['peer']] + + pre_shared_keys = {(server, peer): genPsk() for peer in peers for server in servers} + + + def write_server_conf(server, file_obj): + text = dedent(f"""\ + [Interface] + Address = {server.address} + ListenPort = {server.port} + PrivateKey = {str(server.private_key)} + """) + file_obj.write(text) + for peer in peers: + text = dedent(f"""\ + + [Peer] + PublicKey = {peer.public_key} + AllowedIPs = {peer.allowed_ip} + PresharedKey = {pre_shared_keys[(server, peer)]} + """) + file_obj.write(text) + + def write_client_conf(peer, file_obj): + text = dedent(f"""\ + [Interface] + Address = {peer.allowed_ip}/32 + PrivateKey = {str(peer.private_key)} + """) + dns_config = (Maybe.of(dns) + .filter(lambda it: len(it) > 0) + .map(lambda it: f'DNS = {', '.join((str(addr) for addr in it))}\n') + .or_else('')) + file_obj.write(text + dns_config) + for server in servers: + text = dedent(f"""\ + + [Peer] + PublicKey = {server.public_key} + PresharedKey = {pre_shared_keys[(server, peer)]} + Endpoint = {server.endpoint}:{server.port} + AllowedIPs = {str(network)} + """) + file_obj.write(text) + + makedirs(output_folder, exist_ok=True) + for server in servers: + if archive: + with tarfile.open(output_folder / f'{server.name}.tar.gz', 'w:gz') as archive: + with BytesIO() as bio: + writer = codecs.getwriter('utf-8')(bio) + write_server_conf(server, writer) + name = f'/etc/wireguard/{network_name}.conf' + tarinfo = TarInfo(name) + tarinfo.size = bio.tell() + bio.seek(0) + archive.addfile(tarinfo, bio) + else: + server_folder = output_folder / f'{server.name}' + makedirs(server_folder, exist_ok=True) + with (open(server_folder / f'{network_name}.conf', 'w') as f): + write_server_conf(server, f) + + for peer in peers: + if archive: + with tarfile.open(output_folder / f'{peer.name}.tar.gz', 'w:gz') as archive: + with BytesIO() as bio: + writer = codecs.getwriter('utf-8')(bio) + write_client_conf(peer, writer) + name = f'/etc/wireguard/{network_name}.conf' + tarinfo = TarInfo(name) + tarinfo.size = bio.tell() + bio.seek(0) + archive.addfile(tarinfo, bio) + else: + peer_folder = output_folder / f'{peer.name}' + makedirs(peer_folder, exist_ok=True) + with (open(peer_folder / f'{network_name}.conf', 'w') as f): + write_client_conf(peer, f) + + diff --git a/src/wg_builder/cli.py b/src/wg_builder/cli.py new file mode 100644 index 0000000..236cb9a --- /dev/null +++ b/src/wg_builder/cli.py @@ -0,0 +1,35 @@ +from typing_extensions import Optional, List + +import argparse +import sys + +from pathlib import Path +from .builder import build_network + +def main(args: Optional[List[str]] = None): + parser = argparse.ArgumentParser(description="A simple CLI program to create Wireguard networks") + parser.add_argument( + '-i', + '--input-file', + help='Input file (defaults to stdin)', + type=Path, + ) + parser.add_argument( + '-o', + '--output-folder', + help='Output folder (defaults to .)', + type=Path, + ) + parser.add_argument( + '-a', + '--archive', + help='store the configuration in archive files', + action='store_true', + ) + parser.set_defaults(func=build_network) + args = parser.parse_args(args) + args.func(**vars(args)) + + +if __name__ == '__main__': + main(sys.argv)