diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..e514923 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,50 @@ +name: CI + +on: + push: + branches: ["main"] + tags: ["v*"] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + PYTHON_VERSION: 3.11 + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ env.PYTHON_VERSION }} + uses: actions/setup-python@v4 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: pip cache + uses: actions/cache@v3 + with: + path: ${{ steps.pip-cache.outputs.dir }} + key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools wheel + + - name: Build Python package + run: python setup.py sdist + + - name: Upload package to artifacts + uses: actions/upload-artifact@v2 + with: + name: python-packages + path: | + dist/*.whl + dist/*.tar.gz \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1cf48c9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,357 @@ +# Created by https://www.toptal.com/developers/gitignore/api/linux,macos,windows,jetbrains+all,visualstudiocode,python +# Edit at https://www.toptal.com/developers/gitignore?templates=linux,macos,windows,jetbrains+all,visualstudiocode,python + +### JetBrains+all ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### JetBrains+all Patch ### +# Ignore everything but code style settings and run configurations +# that are supposed to be shared within teams. + +.idea/* + +!.idea/codeStyles +!.idea/runConfigurations + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/linux,macos,windows,jetbrains+all,visualstudiocode,python diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..505f5eb --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +include README.md +recursive-include wpm/static * +recursive-include wpm/templates * diff --git a/README.md b/README.md new file mode 100644 index 0000000..922a6cb --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# Wireguard Peer Manager: django-wpm + +Wireguard Peer Manager is used for managing Road Warrior VPN endpoints and consists of two components. Users and +administrators utilize the [Django app](https://github.com/secshellnet/wpm-django) for graphical management of +Wireguard peers. It communicates with the router's REST API (for VyOS +[this project](https://github.com/secshellnet/wpm-api-vyos)) to apply the Wireguard configuration. + +After the user logs in, they can easily create new Wireguard peers (each device should have its own peer to enable +simultaneous connections) and view or delete the Wireguard configuration. Due to this abstraction layer, end-users don't +need access to the router to manage their peers but also don't have to open a ticket every time they want to make a new +device VPN-enabled. + +When creating new peers, the browser generates an asymmetric key pair upon the user's request and also a pre-shared key +(PSK), which needs to be set up on both sides for the tunnel to function. The private key is not sent to the server and +not stored in the browser, so the user must save it directly. + +Afterward, the user sees the newly created peer in the list of registered peers with a red status indicator. Once it +changes to green (usually taking no longer than 30 seconds), the peer is usable. + +All clients connecting to the same router use the same Wireguard endpoint (in our case, the interface `wg100`), +differing only in the IPv4 and IPv6 addresses used within the tunnel. + +## Quick start + +1. Add `wpm.apps.WpmConfig` to your `INSTALLED_APPS` setting like this: + ```py + INSTALLED_APPS = [ + # ..., + "wpm.apps.WpmConfig", + ] + ``` + +2. Run `python manage.py migrate` to create the wpm models. + +3. Start the development server and visit http://127.0.0.1:8000/admin/wpm to use the app. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7fd0478 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ['setuptools>=40.8.0'] +build-backend = 'setuptools.build_meta' diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..7e302e6 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,29 @@ +[metadata] +name = django-wpm +version = 0.1 +description = A Django app to manage wireguard peers on wireguard capeable routers. +long_description = file: README.md +url = https://github.com/secshellnet/wpm-django +author = Nico Felbinger +author_email = 26925347+felbinger@users.noreply.github.com +classifiers = + Environment :: Web Environment + Framework :: Django + Framework :: Django :: 4.2 + Intended Audience :: Developers + License :: OSI Approved :: BSD License + Operating System :: OS Independent + Programming Language :: Python + Programming Language :: Python :: 3 + Programming Language :: Python :: 3 :: Only + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Topic :: Internet :: WWW/HTTP + Topic :: Internet :: WWW/HTTP :: Dynamic Content + +[options] +include_package_data = true +packages = find: +python_requires = >=3.8 +install_requires = + Django >= 4.2 \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..6068493 --- /dev/null +++ b/setup.py @@ -0,0 +1,3 @@ +from setuptools import setup + +setup() diff --git a/wpm/__init__.py b/wpm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/wpm/admin.py b/wpm/admin.py new file mode 100644 index 0000000..75a1e36 --- /dev/null +++ b/wpm/admin.py @@ -0,0 +1,141 @@ +from django.contrib import admin +from django.forms import CharField, ModelForm, Textarea, TextInput, BooleanField +from django.utils.html import format_html +from django.utils.translation import gettext_lazy as _ + +from wpm.models import Peer, WireguardEndpoint, DNSServer +from wpm.signals import delete_handler + +WG_CONFIG = """# Secure Shell Networks: {gateway} + +[Interface] +PrivateKey = ### PRIVATE KEY ### +Address = {addresses} +DNS = {dns} + +[Peer] +PublicKey = b5V840pvHj0JPyjh6IAvEtIaEK0XNsabssvk6iNEpDc= +Endpoint = {endpoint} +AllowedIPs = 0.0.0.0/0,::/0 +PersistentKeepalive = 30 +""" + + +class NewPeerAdminForm(ModelForm): + """ + form to create new peers + """ + model = Peer + private_key = CharField( + required=False, help_text=_("This key has been generated in the browser." + "Feel free to overwrite the public key below to use your own key.")) + configure_psk = BooleanField(required=False, label=_("configure psk")) + + class Meta: + model = Peer + fields = ("endpoint", "name", "private_key", "public_key", "configure_psk", "psk",) + widgets = { + "name": TextInput(attrs={'size': 45}), + "private_key": TextInput(attrs={'size': 45}), + "public_key": TextInput(attrs={'size': 45}), + "psk": TextInput(attrs={'size': 45}), + } + + class Media: + js = ("admin/js/wireguard.js", "admin/js/wireguard_add_peer.js", ) + + +class PeerAdminForm(ModelForm): + """ + form to create new peers + """ + model = Peer + config = CharField(widget=Textarea(attrs={'rows': 16, 'cols': 100}), required=False) + + class Meta: + model = Peer + fields = ("endpoint", "name", "tunnel_ipv4", "tunnel_ipv6", "public_key", "psk", "config") + + class Media: + js = ("admin/js/wireguard_show_peer.js", ) + + def __init__(self, *args, **kwargs): + super(PeerAdminForm, self).__init__(*args, **kwargs) + # set all fields to read only / disabled, so they are being displayed as paragraph + for field in self.Meta.fields: + self.fields[field].disabled = True + + +@admin.register(Peer) +class PeerAdmin(admin.ModelAdmin): + + class Media: + css = { + "all": ("https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css",) + } + js = ("admin/js/wireguard_overview.js",) + + list_display_links = ("name", "tunnel_ips") + + def valid_color(self, obj): + color = "#4cb34c" if obj.valid else "#e50a0a" + return format_html(f'') + valid_color.short_description = _("valid") + + def get_list_display(self, request): + if request.user.is_superuser: + return "owner", "endpoint", "name", "tunnel_ips", "valid_color", + return "endpoint", "name", "tunnel_ips", "valid_color", + + def get_list_filter(self, request): + if request.user.is_superuser: + return "owner", "valid", "endpoint", + return "valid", "endpoint", + + def save_model(self, request, obj, form, change): + if not obj.pk: + obj.owner = request.user + + # prevent creation of peers, if user has no first / lastname set + if not obj.owner.first_name or not obj.owner.last_name: + print(f"Unable to create peer for {obj.owner}, has no real name configured!") + return + + super().save_model(request, obj, form, change) + + def get_form(self, request, obj: Peer = None, **kwargs): + # when the peer doesn't exist yet, we show the formular + # where the user can choose a name and whether a psk should be configured + if not obj: + form = NewPeerAdminForm + else: + # otherwise we use the PeerAdminForm which includes a config field + form = PeerAdminForm + form.base_fields["config"].initial = WG_CONFIG.format(**{ + "gateway": obj.endpoint.name, + "addresses": obj.tunnel_ips(), + "endpoint": f"{obj.endpoint.endpoint_ip}:{obj.endpoint.endpoint_port}", + "dns": ','.join([dns.ip_address for dns in obj.endpoint.dns_server.all()]), + }) + + return form + + def has_delete_permission(self, request, obj: Peer = None): + # invalid objects can't be deleted for asynchronous reasons + if obj: + return obj.valid + return False + + def delete_model(self, request, obj): + delete_handler(obj) + + +@admin.register(WireguardEndpoint) +class WireguardEndpointAdmin(admin.ModelAdmin): + list_display = ("name", "ipv4_net", "ipv6_net", "endpoint_ip", "endpoint_port", "public_key", "wpm_api_port",) + list_display_links = ("name", "ipv4_net", "ipv6_net", "endpoint_ip", "endpoint_port", "public_key",) + + +@admin.register(DNSServer) +class DNSServerAdmin(admin.ModelAdmin): + list_display = ("ip_address",) diff --git a/wpm/apps.py b/wpm/apps.py new file mode 100644 index 0000000..aead859 --- /dev/null +++ b/wpm/apps.py @@ -0,0 +1,17 @@ +from apscheduler.schedulers.background import BackgroundScheduler +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class WpmConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "wpm" + verbose_name = _("wpm") + + def ready(self): + from wpm.services.check_valid import check_valid + import wpm.signals + + scheduler = BackgroundScheduler() + scheduler.add_job(check_valid, 'interval', seconds=5) + scheduler.start() diff --git a/wpm/migrations/0001_initial.py b/wpm/migrations/0001_initial.py new file mode 100644 index 0000000..0027b3f --- /dev/null +++ b/wpm/migrations/0001_initial.py @@ -0,0 +1,54 @@ +# Generated by Django 3.1.14 on 2023-09-30 17:47 + +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='WireguardEndpoint', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, unique=True, validators=[django.core.validators.RegexValidator(message='Name must be a fully qualified domain name.', regex='^[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$')])), + ('ipv4_net', models.CharField(max_length=15, validators=[django.core.validators.RegexValidator(message='IPv4 network must be in CIDR notation, e.g., "192.168.0.0/24".', regex='^\\d+\\.\\d+\\.\\d+\\.\\d+/\\d+$')])), + ('ipv6_net', models.CharField(blank=True, max_length=39, null=True, validators=[django.core.validators.RegexValidator(message='IPv6 network must be in CIDR notation, e.g., "2001:0db8::/32".', regex='^[0-9a-fA-F:/]+$')])), + ('endpoint_ip', models.GenericIPAddressField()), + ('endpoint_port', models.PositiveIntegerField()), + ('public_key', models.CharField(max_length=44, validators=[django.core.validators.RegexValidator(message='Invalid wireguard public key.', regex='^[A-Za-z0-9+/]{43}=$')], verbose_name='public key')), + ], + options={ + 'verbose_name': 'wireguard endpoint', + 'verbose_name_plural': 'wireguard endpoints', + }, + ), + migrations.CreateModel( + name='Peer', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=32, unique=True, validators=[django.core.validators.RegexValidator(message='Only upper/lowercase letters and numbers. Up to 32 characters allowed.', regex='^[A-Za-z0-9]{1,32}$')], verbose_name='name')), + ('tunnel_ipv4', models.GenericIPAddressField(unique=True, verbose_name='tunnel ipv4')), + ('tunnel_ipv6', models.GenericIPAddressField(unique=True, verbose_name='tunnel ipv6')), + ('public_key', models.CharField(max_length=44, validators=[django.core.validators.RegexValidator(message='Invalid wireguard public key.', regex='^[A-Za-z0-9+/]{43}=$')], verbose_name='public key')), + ('psk', models.CharField(blank=True, default=None, max_length=44, null=True, validators=[django.core.validators.RegexValidator(message='Invalid wireguard psk.', regex='^[A-Za-z0-9+/]{43}=$')], verbose_name='psk')), + ('created', models.DateTimeField(default=django.utils.timezone.now, verbose_name='created')), + ('valid', models.BooleanField(default=False, verbose_name='valid')), + ('endpoint', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='wpm.wireguardendpoint', verbose_name='wireguard endpoint')), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='owner')), + ], + options={ + 'verbose_name': 'peer', + 'verbose_name_plural': 'peers', + }, + ), + ] diff --git a/wpm/migrations/0002_auto_20231001_1220.py b/wpm/migrations/0002_auto_20231001_1220.py new file mode 100644 index 0000000..12cd517 --- /dev/null +++ b/wpm/migrations/0002_auto_20231001_1220.py @@ -0,0 +1,47 @@ +# Generated by Django 3.1.14 on 2023-10-01 10:20 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('wpm', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='DNSServer', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('ip_address', models.GenericIPAddressField(verbose_name='ip address')), + ], + options={ + 'verbose_name': 'dns server', + 'verbose_name_plural': 'dns servers', + }, + ), + migrations.AddField( + model_name='wireguardendpoint', + name='wpm_api_port', + field=models.PositiveIntegerField(default=8080, validators=[django.core.validators.MaxValueValidator(65535)]), + preserve_default=False, + ), + migrations.AddField( + model_name='wireguardendpoint', + name='wpm_api_token', + field=models.CharField(default='invalid', max_length=64, verbose_name='api token'), + preserve_default=False, + ), + migrations.AlterField( + model_name='wireguardendpoint', + name='endpoint_port', + field=models.PositiveIntegerField(validators=[django.core.validators.MaxValueValidator(65535)]), + ), + migrations.AddField( + model_name='wireguardendpoint', + name='dns_server', + field=models.ManyToManyField(to='wpm.DNSServer'), + ), + ] diff --git a/wpm/migrations/0003_peer_last_action.py b/wpm/migrations/0003_peer_last_action.py new file mode 100644 index 0000000..2609c79 --- /dev/null +++ b/wpm/migrations/0003_peer_last_action.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.14 on 2023-10-01 13:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('wpm', '0002_auto_20231001_1220'), + ] + + operations = [ + migrations.AddField( + model_name='peer', + name='last_action', + field=models.IntegerField(blank=True, choices=[(0, 'created'), (1, 'deleted')], null=True, verbose_name='last_action'), + ), + ] diff --git a/wpm/migrations/0004_auto_20231011_1703.py b/wpm/migrations/0004_auto_20231011_1703.py new file mode 100644 index 0000000..62e6a70 --- /dev/null +++ b/wpm/migrations/0004_auto_20231011_1703.py @@ -0,0 +1,28 @@ +# Generated by Django 3.1.14 on 2023-10-11 15:03 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('wpm', '0003_peer_last_action'), + ] + + operations = [ + migrations.AlterField( + model_name='peer', + name='last_action', + field=models.IntegerField(choices=[(0, 'created'), (1, 'deleted')], default=0, verbose_name='last_action'), + ), + migrations.AlterField( + model_name='peer', + name='name', + field=models.CharField(max_length=32, validators=[django.core.validators.RegexValidator(message='Only upper/lowercase letters and numbers. Up to 32 characters allowed.', regex='^[A-Za-z0-9]{1,32}$')], verbose_name='name'), + ), + migrations.AlterUniqueTogether( + name='peer', + unique_together={('name', 'endpoint')}, + ), + ] diff --git a/wpm/migrations/__init__.py b/wpm/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/wpm/models.py b/wpm/models.py new file mode 100644 index 0000000..c9c271e --- /dev/null +++ b/wpm/models.py @@ -0,0 +1,137 @@ +from ipaddress import IPv4Network, IPv4Address, IPv6Address, IPv6Network +from random import randint + +from django.conf import settings +from django.core.validators import RegexValidator, MaxValueValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + + +class DNSServer(models.Model): + + class Meta: + verbose_name = _("dns server") + verbose_name_plural = _("dns servers") + + ip_address = models.GenericIPAddressField(_("ip address")) + + def __str__(self): + return self.ip_address + + +class WireguardEndpoint(models.Model): + + class Meta: + verbose_name = _("wireguard endpoint") + verbose_name_plural = _("wireguard endpoints") + + name = models.CharField(max_length=255, unique=True, validators=[ + RegexValidator( + regex=r'^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', + message='Name must be a fully qualified domain name.', + ) + ]) + + ipv4_net = models.CharField(max_length=15, validators=[ + RegexValidator( + regex=r'^\d+\.\d+\.\d+\.\d+/\d+$', + message='IPv4 network must be in CIDR notation, e.g., "192.168.0.0/24".', + ), + ]) + ipv6_net = models.CharField(max_length=39, null=True, blank=True, validators=[ + RegexValidator( + regex=r'^[0-9a-fA-F:/]+$', + message='IPv6 network must be in CIDR notation, e.g., "2001:0db8::/32".', + ), + ]) + + endpoint_ip = models.GenericIPAddressField() + endpoint_port = models.PositiveIntegerField(validators=[MaxValueValidator(65535)]) + dns_server = models.ManyToManyField(DNSServer) + + public_key = models.CharField(_("public key"), max_length=44, validators=[ + RegexValidator( + regex=r'^[A-Za-z0-9+/]{43}=$', + message="Invalid wireguard public key." + ) + ]) + + wpm_api_token = models.CharField(_("api token"), max_length=64) + wpm_api_port = models.PositiveIntegerField(validators=[MaxValueValidator(65535)]) + + def __str__(self): + return self.name + + +class Peer(models.Model): + + class Meta: + verbose_name = _("peer") + verbose_name_plural = _("peers") + + # ensure each peer is unique for the scope of the endpoint + unique_together = ("name", "endpoint",) + + class Action(models.IntegerChoices): + CREATED = 0, _("created") + DELETED = 1, _("deleted") + + endpoint = models.ForeignKey(WireguardEndpoint, on_delete=models.CASCADE, verbose_name=_("wireguard endpoint")) + owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, verbose_name=_("owner")) + name = models.CharField(_("name"), max_length=32, validators=[ + RegexValidator( + regex=r'^[A-Za-z0-9]{1,32}$', + message="Only upper/lowercase letters and numbers. Up to 32 characters allowed." + ) + ]) + tunnel_ipv4 = models.GenericIPAddressField(_("tunnel ipv4"), unique=True) + tunnel_ipv6 = models.GenericIPAddressField(_("tunnel ipv6"), unique=True) + public_key = models.CharField(_("public key"), max_length=44, validators=[ + RegexValidator( + regex=r'^[A-Za-z0-9+/]{43}=$', + message="Invalid wireguard public key." + ) + ]) + psk = models.CharField(_("psk"), max_length=44, default=None, null=True, blank=True, validators=[ + RegexValidator( + regex=r'^[A-Za-z0-9+/]{43}=$', + message="Invalid wireguard psk." + ) + ]) + created = models.DateTimeField(_("created"), default=timezone.now) + valid = models.BooleanField(_("valid"), default=False) + + last_action = models.IntegerField(_("last_action"), choices=Action.choices, default=Action.CREATED) + + def save(self, *args, **kwargs): + if self.pk: + return super(Peer, self).save(*args, **kwargs) + + # generate ipv4 tunnel address: get all ipv4 addresses (vyos is using the first address) + all_addresses = list(IPv4Network(self.endpoint.ipv4_net).hosts())[1:] + used_addresses = set(IPv4Address(p.tunnel_ipv4) for p in Peer.objects.filter(endpoint=self.endpoint).all()) + self.tunnel_ipv4 = str(next(addr for addr in all_addresses if addr not in used_addresses)) + + # generate ipv6 tunnel address: get all ipv4 addresses (vyos is using the first address) + used_addresses6 = [IPv6Address(p.tunnel_ipv6) for p in Peer.objects.filter(endpoint=self.endpoint).all()] + used_addresses6.append(next(IPv6Network(self.endpoint.ipv6_net).hosts())) + + def _get_random(): + # Which of the network.num_addresses we want to select? + addr_no = randint(0, IPv6Network(self.endpoint.ipv6_net).num_addresses) + # Create the random address by converting to a 128-bit integer, adding addr_no and converting back + network_int = int.from_bytes(IPv6Network(self.endpoint.ipv6_net).network_address.packed, byteorder="big") + addr_int = network_int + addr_no + return IPv6Address(addr_int.to_bytes(16, byteorder="big")) + while (ipv6_addr := _get_random()) in used_addresses6: + pass + + self.tunnel_ipv6 = str(ipv6_addr) + super(Peer, self).save(*args, **kwargs) + + def __str__(self) -> str: + return self.name + + def tunnel_ips(self) -> str: + return f"{self.tunnel_ipv4}/32, {self.tunnel_ipv6}/128" diff --git a/wpm/services/check_valid.py b/wpm/services/check_valid.py new file mode 100644 index 0000000..ee6bc53 --- /dev/null +++ b/wpm/services/check_valid.py @@ -0,0 +1,27 @@ +import requests + +from wpm.models import Peer + + +def check_valid(): + for peer in Peer.objects.filter(valid=False).all(): + identifier = f"{peer.owner.first_name.upper()}-{peer.owner.last_name.upper()}-{peer.name}" + resp = requests.get( + f"http://{peer.endpoint.name}:{peer.endpoint.wpm_api_port}/api/peer/{identifier}", + headers={ + "Authorization": f"Bearer {peer.endpoint.wpm_api_token}", + }, + ) + + if resp.status_code != 200: + print(f"WPM-API on {peer.endpoint.name} responded with HTTP status {resp.status_code}") + return + + if peer.last_action == Peer.Action.CREATED and resp.json().get("StatusResponse").get("valid"): + peer.valid = True + peer._skip_api_call = True + peer.save() + del peer._skip_api_call + + if peer.last_action == Peer.Action.DELETED and not resp.json().get("StatusResponse").get("valid"): + peer.delete() diff --git a/wpm/signals.py b/wpm/signals.py new file mode 100644 index 0000000..060090b --- /dev/null +++ b/wpm/signals.py @@ -0,0 +1,59 @@ +import requests +from django.db.models.signals import post_save +from django.dispatch import receiver + +from wpm.models import Peer + + +@receiver(post_save, sender=Peer) +def add_peer_handler(sender, instance: Peer, **kwargs): + # the flag _skip_api_call can be used to prevent triggering + # an infinite loop when saving the instance inside the post_save signal handler + # also when updated by the check_valid function and deleting the peer, this flag is being used + if hasattr(instance, '_skip_api_call'): + return + + instance._skip_api_call = True + + # prevent creation of peers, if user has no first / lastname set + # the save_model method shouldn't even create the object, + # so this signal handler should have never been invoked + if not instance.owner.first_name or not instance.owner.last_name: + print(f"SIGNAL HANDLER: Unable to create peer for {instance.owner}, has no real name configured!") + return + + requests.post( + f"http://{instance.endpoint.name}:{instance.endpoint.wpm_api_port}/api/peer/", + headers={ + "Authorization": f"Bearer {instance.endpoint.wpm_api_token}", + }, + json={ + "userIdentifier": f"{instance.owner.first_name.upper()}-{instance.owner.last_name.upper()}", + "peerIdentifier": instance.name, + "publicKey": instance.public_key, + "psk": instance.psk, + "tunnelIpv4": instance.tunnel_ipv4, + "tunnelIpv6": instance.tunnel_ipv6, + }, + ) + instance.last_action = Peer.Action.CREATED + instance.save() + + del instance._skip_api_call + + +def delete_handler(instance: Peer): + # mark instance for deletion + instance.valid = False + instance.last_action = Peer.Action.DELETED + instance._skip_api_call = True + instance.save() + del instance._skip_api_call + + identifier = f"{instance.owner.first_name.upper()}-{instance.owner.last_name.upper()}-{instance.name}" + requests.delete( + f"http://{instance.endpoint.name}:{instance.endpoint.wpm_api_port}/api/peer/{identifier}", + headers={ + "Authorization": f"Bearer {instance.endpoint.wpm_api_token}", + }, + ) diff --git a/wpm/static/admin/js/wireguard.js b/wpm/static/admin/js/wireguard.js new file mode 100644 index 0000000..d8169ba --- /dev/null +++ b/wpm/static/admin/js/wireguard.js @@ -0,0 +1,182 @@ +(function() { + function gf(init) { + var r = new Float64Array(16); + if (init) { + for (var i = 0; i < init.length; ++i) + r[i] = init[i]; + } + return r; + } + + function pack(o, n) { + var b, m = gf(), t = gf(); + for (var i = 0; i < 16; ++i) + t[i] = n[i]; + carry(t); + carry(t); + carry(t); + for (var j = 0; j < 2; ++j) { + m[0] = t[0] - 0xffed; + for (var i = 1; i < 15; ++i) { + m[i] = t[i] - 0xffff - ((m[i - 1] >> 16) & 1); + m[i - 1] &= 0xffff; + } + m[15] = t[15] - 0x7fff - ((m[14] >> 16) & 1); + b = (m[15] >> 16) & 1; + m[14] &= 0xffff; + cswap(t, m, 1 - b); + } + for (var i = 0; i < 16; ++i) { + o[2 * i] = t[i] & 0xff; + o[2 * i + 1] = t[i] >> 8; + } + } + + function carry(o) { + var c; + for (var i = 0; i < 16; ++i) { + o[(i + 1) % 16] += (i < 15 ? 1 : 38) * Math.floor(o[i] / 65536); + o[i] &= 0xffff; + } + } + + function cswap(p, q, b) { + var t, c = ~(b - 1); + for (var i = 0; i < 16; ++i) { + t = c & (p[i] ^ q[i]); + p[i] ^= t; + q[i] ^= t; + } + } + + function add(o, a, b) { + for (var i = 0; i < 16; ++i) + o[i] = (a[i] + b[i]) | 0; + } + + function subtract(o, a, b) { + for (var i = 0; i < 16; ++i) + o[i] = (a[i] - b[i]) | 0; + } + + function multmod(o, a, b) { + var t = new Float64Array(31); + for (var i = 0; i < 16; ++i) { + for (var j = 0; j < 16; ++j) + t[i + j] += a[i] * b[j]; + } + for (var i = 0; i < 15; ++i) + t[i] += 38 * t[i + 16]; + for (var i = 0; i < 16; ++i) + o[i] = t[i]; + carry(o); + carry(o); + } + + function invert(o, i) { + var c = gf(); + for (var a = 0; a < 16; ++a) + c[a] = i[a]; + for (var a = 253; a >= 0; --a) { + multmod(c, c, c); + if (a !== 2 && a !== 4) + multmod(c, c, i); + } + for (var a = 0; a < 16; ++a) + o[a] = c[a]; + } + + function clamp(z) { + z[31] = (z[31] & 127) | 64; + z[0] &= 248; + } + + function generatePublicKey(privateKey) { + var r, z = new Uint8Array(32); + var a = gf([1]), + b = gf([9]), + c = gf(), + d = gf([1]), + e = gf(), + f = gf(), + _121665 = gf([0xdb41, 1]), + _9 = gf([9]); + for (var i = 0; i < 32; ++i) + z[i] = privateKey[i]; + clamp(z); + for (var i = 254; i >= 0; --i) { + r = (z[i >>> 3] >>> (i & 7)) & 1; + cswap(a, b, r); + cswap(c, d, r); + add(e, a, c); + subtract(a, a, c); + add(c, b, d); + subtract(b, b, d); + multmod(d, e, e); + multmod(f, a, a); + multmod(a, c, a); + multmod(c, b, e); + add(e, a, c); + subtract(a, a, c); + multmod(b, a, a); + subtract(c, d, f); + multmod(a, c, _121665); + add(a, a, d); + multmod(c, c, a); + multmod(a, d, f); + multmod(d, b, _9); + multmod(b, e, e); + cswap(a, b, r); + cswap(c, d, r); + } + invert(c, c); + multmod(a, a, c); + pack(z, a); + return z; + } + + function generatePresharedKey() { + var privateKey = new Uint8Array(32); + window.crypto.getRandomValues(privateKey); + return privateKey; + } + + function generatePrivateKey() { + var privateKey = generatePresharedKey(); + clamp(privateKey); + return privateKey; + } + + function encodeBase64(dest, src) { + var input = Uint8Array.from([(src[0] >> 2) & 63, ((src[0] << 4) | (src[1] >> 4)) & 63, ((src[1] << 2) | (src[2] >> 6)) & 63, src[2] & 63]); + for (var i = 0; i < 4; ++i) + dest[i] = input[i] + 65 + + (((25 - input[i]) >> 8) & 6) - + (((51 - input[i]) >> 8) & 75) - + (((61 - input[i]) >> 8) & 15) + + (((62 - input[i]) >> 8) & 3); + } + + function keyToBase64(key) { + var i, base64 = new Uint8Array(44); + for (i = 0; i < 32 / 3; ++i) + encodeBase64(base64.subarray(i * 4), key.subarray(i * 3)); + encodeBase64(base64.subarray(i * 4), Uint8Array.from([key[i * 3 + 0], key[i * 3 + 1], 0])); + base64[43] = 61; + return String.fromCharCode.apply(null, base64); + } + + window.wireguard = { + generateKeypair: function() { + var privateKey = generatePrivateKey(); + var publicKey = generatePublicKey(privateKey); + return { + publicKey: keyToBase64(publicKey), + privateKey: keyToBase64(privateKey) + }; + }, + generatePSK: function() { + return keyToBase64(generatePresharedKey()); + }, + }; +})(); diff --git a/wpm/static/admin/js/wireguard_add_peer.js b/wpm/static/admin/js/wireguard_add_peer.js new file mode 100644 index 0000000..e704772 --- /dev/null +++ b/wpm/static/admin/js/wireguard_add_peer.js @@ -0,0 +1,32 @@ +document.addEventListener("DOMContentLoaded", function(){ + // hide psk field, until checkbox gets checked + document.querySelector(".field-psk").style.display = "none"; + + // create new html element for private key + const djangoField_privateKey = document.getElementById("id_private_key"); + const div_privateKey = djangoField_privateKey.parentNode; + const p_privateKey = document.createElement("p") + + // generate wireguard keypair + let wg = window.wireguard.generateKeypair(); + p_privateKey.innerText = wg.privateKey; + delete wg.privateKey; + document.getElementById("id_public_key").value = wg.publicKey; + delete wg.publicKey; + + // add paragraph and delete original django field for private key + div_privateKey.appendChild(p_privateKey); + div_privateKey.removeChild(djangoField_privateKey) + + // event listener for the "configure psk" checkbox + document.getElementById("id_configure_psk").addEventListener('change', e => { + if (e.target.checked) { + document.getElementById("id_psk").value = window.wireguard.generatePSK(); + document.querySelector(".field-psk").style.display = "block"; + document.getElementById("id_psk").readOnly = true; + } else { + document.querySelector(".field-psk").style.display = "none"; + } + }); +}); + diff --git a/wpm/static/admin/js/wireguard_overview.js b/wpm/static/admin/js/wireguard_overview.js new file mode 100644 index 0000000..1818db0 --- /dev/null +++ b/wpm/static/admin/js/wireguard_overview.js @@ -0,0 +1,34 @@ +function rgbToHex(rgb) { + // Extract RGB components using regex + let rgbValues = rgb.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/); + + // Convert RGB to hex + if (!rgbValues) { + return rgb; // Return the input if not a valid RGB string + } + + // Convert each component to hexadecimal and concatenate them + let hexColor = '#'; + for (let i = 1; i <= 3; i++) { + let hex = parseInt(rgbValues[i]).toString(16); + hexColor += hex.length === 1 ? '0' + hex : hex; + } + + return hexColor; +} + +// reload overview page, if at least one of the peers is invalid, every 10 seconds +// to stay updated (backend will at some point know that the peer is valid) +document.addEventListener("DOMContentLoaded", function() { + let nodeList = document.querySelectorAll('.field-valid_color'); + let foundInvalid = Array.from(nodeList).some(field => { + return rgbToHex(field.children[0].style.color) === "#e50a0a"; + }); + + if (foundInvalid) { + // reload page every 10 seconds if one of the peers is invalid + setInterval(function() { + window.location.reload(); + }, 10000); + } +}); \ No newline at end of file diff --git a/wpm/static/admin/js/wireguard_show_peer.js b/wpm/static/admin/js/wireguard_show_peer.js new file mode 100644 index 0000000..2140d57 --- /dev/null +++ b/wpm/static/admin/js/wireguard_show_peer.js @@ -0,0 +1,36 @@ +function selectToParagraph(field) { + const parent = field.parentNode; + const p = document.createElement("p") + p.innerText = field.options[field.value].text; + parent.appendChild(p); + parent.removeChild(field) +} + +function inputToParagraph(field) { + const parent = field.parentNode; + const p = document.createElement("p") + p.innerText = field.value; + parent.appendChild(p); + parent.removeChild(field) +} + +function inputToCode(field) { + const parent = field.parentNode; + const pre = document.createElement("pre") + pre.innerText = field.value; + parent.appendChild(pre); + parent.removeChild(field) +} + +document.addEventListener("DOMContentLoaded", function(){ + // TODO do at least the toParagraph transformations in django (read only fields - see without dark reader!) + selectToParagraph(document.getElementById("id_endpoint")); + inputToParagraph(document.getElementById("id_name")); + inputToParagraph(document.getElementById("id_tunnel_ipv4")); + inputToParagraph(document.getElementById("id_tunnel_ipv6")); + inputToParagraph(document.getElementById("id_public_key")); + inputToParagraph(document.getElementById("id_psk")); + inputToCode(document.getElementById("id_config")); + document.getElementById("id_tunnel_ipv4").innerText += "/32"; + document.getElementById("id_tunnel_ipv6").innerText += "/128"; +});