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'
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()

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

@ -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

@ -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.

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