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