From ca3d536868771821b1d526aecbc42ab81b247974 Mon Sep 17 00:00:00 2001 From: Erik Thuning <boooink@gmail.com> Date: Wed, 26 Mar 2025 13:30:53 +0100 Subject: [PATCH] Added support for limited client validity A new /cron endpoint has been introduced, which triggers a cleanup routine. The cleanup routine loops over all existing clients and deletes all that are older than the value configured in config.ini. Periodically calling the cron endpoint is the responsibility of the server admin. --- api/__init__.py | 9 +++++- api/exceptions.py | 4 +++ api/wireguard.py | 81 ++++++++++++++++++++++++++++++++++++++++++---- config.ini.example | 7 ++++ requirements.txt | 1 + 5 files changed, 95 insertions(+), 7 deletions(-) diff --git a/api/__init__.py b/api/__init__.py index 42b11e5..3592098 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -14,7 +14,8 @@ from .wireguard import WireGuard login_path = '/login' callback_path = '/auth' -public_paths = [login_path, callback_path] +cron_path = '/cron' +public_paths = [login_path, callback_path, cron_path] user_cookie = 'username' token_cookie = 'token' access_cookie = 'access' @@ -119,6 +120,12 @@ def authorize(): return response +@app.route(cron_path) +def run_cron(): + app.wg.run_cleanup() + return Response() + + @app.route('/configs/') def list_configs() -> list: return app.wg.list_configs() diff --git a/api/exceptions.py b/api/exceptions.py index 0520ca2..e995594 100644 --- a/api/exceptions.py +++ b/api/exceptions.py @@ -1,3 +1,7 @@ class ClientLimitError(Exception): def __init__(self, message): self.message = message + +class ValiditySpecificationError(Exception): + def __init__(self, message): + self.message = message diff --git a/api/wireguard.py b/api/wireguard.py index 12f4fc0..befdec3 100644 --- a/api/wireguard.py +++ b/api/wireguard.py @@ -7,7 +7,9 @@ import ipaddress import json import subprocess -from .exceptions import ClientLimitError +from dateutil.relativedelta import relativedelta + +from .exceptions import ClientLimitError, ValiditySpecificationError confsuffix = '.conf' @@ -127,6 +129,24 @@ def delete_route(client_ip: str) -> None: # from the client config run_command('del', client_ip) +def parse_timestring(spec: str) -> relativedelta: + count, unit = spec.split() + try: + count = int(count) + except Exception: + raise ValiditySpecificationError( + f"'{spec}' is not recognized as a valid time specification") + if unit == 'year' or unit == 'years': + return relativedelta(years=count) + if unit == 'month' or unit == 'months': + return relativedelta(months=count) + if unit == 'week' or unit == 'weeks': + return relativedelta(weeks=count) + if unit == 'day' or unit == 'days': + return relativedelta(days=count) + raise ValiditySpecificationError( + f"'{spec}' is not recognized as a valid time specification") + class WireGuard: def __init__(self, config: dict): @@ -137,6 +157,12 @@ class WireGuard: self.configs_base = Path(config['configs_base']) self.max_clients = config.getint('user_client_limit', fallback=0) + client_validity = config.get('user_client_validity', fallback=0) + if client_validity: + self.client_validity = parse_timestring(client_validity) + else: + self.client_validity = 0 + self.server_config_base = None if 'server_extra_config' in config: self.server_config_base = Path(config['server_extra_config']) @@ -278,10 +304,15 @@ class WireGuard: self.wg_updated = True return client_ip - def update_config(self, - config_id: str, - name: str, - description: str) -> None: + def update_config(self, *args) -> None: + call_with_lock(self._unsafe_update_config, + args, + 10) + + def _unsafe_update_config(self, + config_id: str, + name: str, + description: str) -> None: with open(self.meta_filepath(config_id), 'r+') as mf: metadata = json.load(mf) metadata['name'] = name @@ -301,7 +332,12 @@ class WireGuard: 'created': metadata['created'], 'data': configdata} - def delete_config(self, config_id: str) -> None: + def delete_config(self, *args) -> None: + call_with_lock(self._unsafe_delete_config, + args, + 10) + + def _unsafe_delete_config(self, config_id: str) -> None: config_path = self.config_filepath(config_id) paths = [config_path, self.serverconfig_filepath(config_id), @@ -320,6 +356,39 @@ class WireGuard: self.log(f'{self.user_name}/{config_id}', 'Deleted config') self.wg_updated = True + def delete_many_configs(self, *args) -> None: + call_with_lock(self._unsafe_delete_many_configs, + args, + 10) + + def _unsafe_delete_many_configs(self, expired: dict) -> None: + for user_name, configs in expired.items(): + self.set_user(user_name) + for config_id in configs: + self._unsafe_delete_config(config_id) + + def run_cleanup(self) -> None: + if not self.client_validity: + return + + now = datetime.now() + expired = {} + + # Collect expired configs + for metafile in self.configs_base.glob('*/*'+metasuffix): + with open(metafile, 'r') as cf: + metadata = json.load(cf) + created = datetime.fromisoformat(metadata['created']) + config_id = metafile.stem + user_name = metafile.parent.name + if now > created + self.client_validity: + if user_name not in expired: + expired[user_name] = [] + expired[user_name].append(config_id) + + # Delete the expired configs in a separate step to minimize lock time + self.delete_many_configs(expired) + def update(self) -> None: if not self.wg_updated: return diff --git a/config.ini.example b/config.ini.example index 6694d59..2611cd3 100644 --- a/config.ini.example +++ b/config.ini.example @@ -45,6 +45,13 @@ client_extra_config = path/to/another/fragment # Defaults to unlimited, equivalent to setting this value to 0. user_client_limit = 3 +# Optional: +# The amount of time a client is valid after creation. +# Accepts strings of the format "N <time-units>", where time-units +# is days, weeks, months or years. +# Defaults to unlimited, equivalent to setting this value to 0. +user_client_validity = 0 + [security] # Optional. diff --git a/requirements.txt b/requirements.txt index 30692b7..7aa1ec5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ flask requests +python-dateutil