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