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.
This commit is contained in:
Erik Thuning 2025-03-26 13:30:53 +01:00
parent 20107335d8
commit ca3d536868
5 changed files with 95 additions and 7 deletions

@ -14,7 +14,8 @@ from .wireguard import WireGuard
login_path = '/login' login_path = '/login'
callback_path = '/auth' callback_path = '/auth'
public_paths = [login_path, callback_path] cron_path = '/cron'
public_paths = [login_path, callback_path, cron_path]
user_cookie = 'username' user_cookie = 'username'
token_cookie = 'token' token_cookie = 'token'
access_cookie = 'access' access_cookie = 'access'
@ -119,6 +120,12 @@ def authorize():
return response return response
@app.route(cron_path)
def run_cron():
app.wg.run_cleanup()
return Response()
@app.route('/configs/') @app.route('/configs/')
def list_configs() -> list: def list_configs() -> list:
return app.wg.list_configs() return app.wg.list_configs()

@ -1,3 +1,7 @@
class ClientLimitError(Exception): class ClientLimitError(Exception):
def __init__(self, message): def __init__(self, message):
self.message = message self.message = message
class ValiditySpecificationError(Exception):
def __init__(self, message):
self.message = message

@ -7,7 +7,9 @@ import ipaddress
import json import json
import subprocess import subprocess
from .exceptions import ClientLimitError from dateutil.relativedelta import relativedelta
from .exceptions import ClientLimitError, ValiditySpecificationError
confsuffix = '.conf' confsuffix = '.conf'
@ -127,6 +129,24 @@ def delete_route(client_ip: str) -> None:
# from the client config # from the client config
run_command('del', client_ip) 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: class WireGuard:
def __init__(self, config: dict): def __init__(self, config: dict):
@ -137,6 +157,12 @@ class WireGuard:
self.configs_base = Path(config['configs_base']) self.configs_base = Path(config['configs_base'])
self.max_clients = config.getint('user_client_limit', fallback=0) 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 self.server_config_base = None
if 'server_extra_config' in config: if 'server_extra_config' in config:
self.server_config_base = Path(config['server_extra_config']) self.server_config_base = Path(config['server_extra_config'])
@ -278,10 +304,15 @@ class WireGuard:
self.wg_updated = True self.wg_updated = True
return client_ip return client_ip
def update_config(self, def update_config(self, *args) -> None:
config_id: str, call_with_lock(self._unsafe_update_config,
name: str, args,
description: str) -> None: 10)
def _unsafe_update_config(self,
config_id: str,
name: str,
description: str) -> None:
with open(self.meta_filepath(config_id), 'r+') as mf: with open(self.meta_filepath(config_id), 'r+') as mf:
metadata = json.load(mf) metadata = json.load(mf)
metadata['name'] = name metadata['name'] = name
@ -301,7 +332,12 @@ class WireGuard:
'created': metadata['created'], 'created': metadata['created'],
'data': configdata} '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) config_path = self.config_filepath(config_id)
paths = [config_path, paths = [config_path,
self.serverconfig_filepath(config_id), self.serverconfig_filepath(config_id),
@ -320,6 +356,39 @@ class WireGuard:
self.log(f'{self.user_name}/{config_id}', 'Deleted config') self.log(f'{self.user_name}/{config_id}', 'Deleted config')
self.wg_updated = True 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: def update(self) -> None:
if not self.wg_updated: if not self.wg_updated:
return return

@ -45,6 +45,13 @@ client_extra_config = path/to/another/fragment
# Defaults to unlimited, equivalent to setting this value to 0. # Defaults to unlimited, equivalent to setting this value to 0.
user_client_limit = 3 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] [security]
# Optional. # Optional.

@ -1,2 +1,3 @@
flask flask
requests requests
python-dateutil