From 81ffdc9925daca7cc8835cdca2be1c87d955f4ba Mon Sep 17 00:00:00 2001 From: Thomas Oettli Date: Sun, 8 Feb 2026 02:42:49 +0100 Subject: [PATCH] Add and use permission table, update CLI and README.md --- README.md | 38 +++++++-- src/ddns_service/cleanup.py | 8 +- src/ddns_service/cli.py | 151 +++++++++++++++++++++++------------- src/ddns_service/main.py | 32 +++++--- src/ddns_service/models.py | 140 ++++++++++++++++++++++++++++----- src/ddns_service/server.py | 109 ++++++++++++++------------ 6 files changed, 337 insertions(+), 141 deletions(-) diff --git a/README.md b/README.md index 9736b47..f9d7c5c 100644 --- a/README.md +++ b/README.md @@ -247,8 +247,39 @@ ddns-service user passwd myuser ddns-service user email myuser new@example.com ``` +### Permission Management + +Permissions control which hostnames users can update. + +```bash +# List all permissions +ddns-service permission list + +# List permissions for specific user +ddns-service permission list --user myuser + +# Add permission for exact hostname +ddns-service permission add myuser mypc dyn.example.com + +# Add wildcard permission (any hostname in zone) +ddns-service permission add myuser '*' dyn.example.com + +# Add suffix wildcard (e.g., *.home matches foo.home, bar.home) +ddns-service permission add myuser '*.home' dyn.example.com + +# Delete permission +ddns-service permission delete myuser mypc dyn.example.com +``` + +**Pattern matching:** +- `*` - matches any hostname in the zone +- `*.suffix` - matches hostnames ending in `.suffix` (recursive) +- `exact` - matches only that exact hostname + ### Hostname Management +Hostnames are auto-created when users with permission send their first update. + ```bash # List all hostnames ddns-service hostname list @@ -256,13 +287,6 @@ ddns-service hostname list # List hostnames for specific user ddns-service hostname list --user myuser -# Add hostname -ddns-service hostname add myuser mypc dyn.example.com - -# Add hostname with custom TTLs -ddns-service hostname add myuser mypc dyn.example.com \ - --dns-ttl 60 --expiry-ttl 7200 - # Modify hostname TTLs ddns-service hostname modify mypc dyn.example.com --dns-ttl 120 diff --git a/src/ddns_service/cleanup.py b/src/ddns_service/cleanup.py index 0b48a16..74cb53b 100644 --- a/src/ddns_service/cleanup.py +++ b/src/ddns_service/cleanup.py @@ -96,10 +96,14 @@ def cleanup_expired(app, start_time=None): continue try: - hostname.save() + if hostname.last_ipv4 is None and hostname.last_ipv6 is None: + hostname.delete_instance() + else: + hostname.save() + except DatabaseError as e: logging.error( - f"DB save failed after retries: hostname={hostname.hostname} " + f"DB operation failed after retries: hostname={hostname.hostname} " f"zone={hostname.zone}: {e}" ) diff --git a/src/ddns_service/cli.py b/src/ddns_service/cli.py index 7cb7258..04d1016 100644 --- a/src/ddns_service/cli.py +++ b/src/ddns_service/cli.py @@ -12,6 +12,7 @@ from .models import ( get_hostname, get_user, Hostname, + Permission, User, ) @@ -166,58 +167,6 @@ def cmd_hostname_list(args, app): return 0 -def cmd_hostname_add(args, app): - """Add a hostname.""" - username = args.username - - try: - # Get user - try: - user = get_user(username) - except DoesNotExist: - print(f"Error: User '{username}' not found.") - return 1 - - # Check if hostname+zone exists - try: - hostname = get_hostname(args.hostname, args.zone) - print(f"Error: Hostname '{hostname.hostname}' in zone '{hostname.zone}' exists.") - return 1 - except EncodingError as e: - print(f"Error: {e}") - return 1 - except DoesNotExist: - pass - - # Get TTLs from args or config defaults - config = app.config - - dns_ttl = args.dns_ttl - if dns_ttl is None: - dns_ttl = config["defaults"]["dns_ttl"] - expiry_ttl = args.expiry_ttl - if expiry_ttl is None: - expiry_ttl = config["defaults"]["expiry_ttl"] - - # Create hostname - hostname = Hostname.create( - user=user, - hostname=args.hostname, - zone=args.zone, - dns_ttl=dns_ttl, - expiry_ttl=expiry_ttl - ) - print( - f"Hostname '{hostname.hostname}' in zone '{hostname.zone}' added " - f"for user '{username}'." - ) - except DatabaseError as e: - print(f"Database error: {e}") - return 1 - - return 0 - - def cmd_hostname_delete(args, app): """Delete a hostname.""" try: @@ -325,3 +274,101 @@ def cmd_cleanup(args, app): return 1 return 0 + + +def cmd_permission_list(args, app): + """List permissions.""" + query = Permission.select(Permission, User.username).join(User) + + if args.user: + try: + user = get_user(args.user) + query = query.where(Permission.user == user) + except DoesNotExist: + print(f"Error: User '{args.user}' not found.") + return 1 + + query = query.order_by( + User.username, Permission.zone, Permission.hostname_pattern) + + permissions = list(query) + if not permissions: + print("No permissions found.") + return 0 + + print(f"\n{'User':<20} {'Pattern':<30} {'Zone':<30}") + print("-" * 80) + for p in permissions: + print(f"{p.user.username:<20} {p.hostname_pattern:<30} {p.zone:<30}") + return 0 + + +def cmd_permission_add(args, app): + """Add a permission.""" + username = args.username + pattern = args.hostname_pattern + zone = args.zone + + try: + user = get_user(username) + except DoesNotExist: + print(f"Error: User '{username}' not found.") + return 1 + + # Validate pattern + if pattern != '*' and not pattern.startswith('*.') and '*' in pattern: + print("Error: Invalid pattern. Use '*', '*.suffix', or exact.") + return 1 + + # Check if permission exists + exists = Permission.select().where( + (Permission.user == user) & + (Permission.hostname_pattern == pattern) & + (Permission.zone == zone) + ).exists() + if exists: + print("Error: Permission already exists.") + return 1 + + try: + Permission.create( + user=user, + hostname_pattern=pattern, + zone=zone + ) + print(f"Permission added: {username} {pattern} {zone}") + except DatabaseError as e: + print(f"Database error: {e}") + return 1 + + return 0 + + +def cmd_permission_delete(args, app): + """Delete a permission.""" + username = args.username + pattern = args.hostname_pattern + zone = args.zone + + try: + user = get_user(username) + except DoesNotExist: + print(f"Error: User '{username}' not found.") + return 1 + + try: + perm = Permission.get( + (Permission.user == user) & + (Permission.hostname_pattern == pattern) & + (Permission.zone == zone) + ) + perm.delete_instance() + print(f"Permission deleted: {username} {pattern} {zone}") + except DoesNotExist: + print("Error: Permission not found.") + return 1 + except DatabaseError as e: + print(f"Database error: {e}") + return 1 + + return 0 diff --git a/src/ddns_service/main.py b/src/ddns_service/main.py index fc86eb3..a0102b9 100644 --- a/src/ddns_service/main.py +++ b/src/ddns_service/main.py @@ -11,10 +11,12 @@ from . import __version__ from .app import Application from .cli import ( cmd_cleanup, - cmd_hostname_add, cmd_hostname_delete, cmd_hostname_list, cmd_hostname_modify, + cmd_permission_add, + cmd_permission_delete, + cmd_permission_list, cmd_user_add, cmd_user_delete, cmd_user_email, @@ -81,14 +83,6 @@ def build_parser(): hostname_list.add_argument("--user", help="Filter by username") hostname_list.set_defaults(func=cmd_hostname_list) - hostname_add = hostname_subparsers.add_parser("add", help="Add hostname") - hostname_add.add_argument("username", help="Username") - hostname_add.add_argument("hostname", help="Hostname (FQDN)") - hostname_add.add_argument("zone", help="DNS zone") - hostname_add.add_argument("--dns-ttl", type=int, help="DNS record TTL") - hostname_add.add_argument("--expiry-ttl", type=int, help="Expiry TTL") - hostname_add.set_defaults(func=cmd_hostname_add) - hostname_delete = hostname_subparsers.add_parser( "delete", help="Delete hostname" ) @@ -105,6 +99,26 @@ def build_parser(): hostname_modify.add_argument("--expiry-ttl", type=int, help="Expiry TTL") hostname_modify.set_defaults(func=cmd_hostname_modify) + # Permission commands + perm_parser = subparsers.add_parser("permission", help="Permissions") + perm_subparsers = perm_parser.add_subparsers(dest="permission_command") + + perm_list = perm_subparsers.add_parser("list", help="List permissions") + perm_list.add_argument("--user", help="Filter by username") + perm_list.set_defaults(func=cmd_permission_list) + + perm_add = perm_subparsers.add_parser("add", help="Add permission") + perm_add.add_argument("username", help="Username") + perm_add.add_argument("hostname_pattern", help="Pattern (*, *.suffix, exact)") + perm_add.add_argument("zone", help="DNS zone") + perm_add.set_defaults(func=cmd_permission_add) + + perm_delete = perm_subparsers.add_parser("delete", help="Delete permission") + perm_delete.add_argument("username", help="Username") + perm_delete.add_argument("hostname_pattern", help="Hostname pattern") + perm_delete.add_argument("zone", help="DNS zone") + perm_delete.set_defaults(func=cmd_permission_delete) + # Cleanup command cleanup_parser = subparsers.add_parser("cleanup", help="Run cleanup manually") cleanup_parser.set_defaults(func=cmd_cleanup) diff --git a/src/ddns_service/models.py b/src/ddns_service/models.py index 9051b65..93d85d6 100644 --- a/src/ddns_service/models.py +++ b/src/ddns_service/models.py @@ -13,7 +13,6 @@ from peewee import ( Model, DateTimeField, DoesNotExist, - fn, ForeignKeyField, IntegerField, SqliteDatabase, @@ -29,12 +28,14 @@ __all__ = [ 'DATABASE_VERSION', 'User', 'Hostname', + 'Permission', 'Version', 'init_database', 'create_tables', - 'get_user', 'get_hostname', 'get_hostname_for_user', + 'get_permission', + 'get_user', 'DoesNotExist', 'EncodingError', 'DatabaseError', @@ -44,10 +45,11 @@ __all__ = [ db = DatabaseProxy() # Current database schema version -DATABASE_VERSION = 2 +DATABASE_VERSION = 3 # Migration column mappings: key = target version # Values: {table: {old_col: new_col}} - None value = drop column +# Empty dict means no table schema changes (v3: new table only) MIGRATION_COLUMN_MAPS = { 2: { 'hostnames': { @@ -62,7 +64,8 @@ MIGRATION_COLUMN_MAPS = { 'last_ipv6': 'last_ipv6', 'last_ipv6_update': 'last_ipv6_update', } - } + }, + 3: {} # New permissions table, populated from hostnames } @@ -129,6 +132,22 @@ class Hostname(BaseModel): ) +class Permission(BaseModel): + """Permission grants access to hostname patterns for users.""" + + id = AutoField() + user = ForeignKeyField(User, backref="permissions", on_delete="CASCADE") + hostname_pattern = CharField(max_length=255) # '*', '*.suffix', or exact + zone = CharField(max_length=255) + created_at = DateTimeFieldUTC(default=now_utc) + + class Meta: + table_name = "permissions" + indexes = ( + (('user', 'hostname_pattern', 'zone'), True), + ) + + class Version(BaseModel): """Database schema version for migrations.""" @@ -142,6 +161,7 @@ class Version(BaseModel): TABLE_TO_MODEL = { 'users': User, 'hostnames': Hostname, + 'permissions': Permission, } @@ -166,6 +186,7 @@ def init_database(config: dict): actual_db = SqliteDatabase(db_path, pragmas={ 'journal_mode': 'wal', 'busy_timeout': 5000, + 'foreign_keys': 1, }) db.initialize(actual_db) logging.debug(f"Database backend: SQLite path={db_path}") @@ -238,6 +259,10 @@ def _migrate_table_sqlite(model_class, from_version: int, column_map: dict): def _migrate_sqlite(from_version: int, to_version: int): """Migrate SQLite from from_version to to_version.""" + if to_version == 3: + _migrate_v3_create_permissions() + return + db.execute_sql('PRAGMA foreign_keys=OFF') try: tables = MIGRATION_COLUMN_MAPS[to_version] @@ -248,6 +273,16 @@ def _migrate_sqlite(from_version: int, to_version: int): db.execute_sql('PRAGMA foreign_keys=ON') +def _migrate_v3_create_permissions(): + """Create permissions table and populate from existing hostnames.""" + db.create_tables([Permission]) + db.execute_sql( + 'INSERT INTO permissions ' + '(user_id, hostname_pattern, zone, created_at) ' + 'SELECT user_id, hostname, zone, CURRENT_TIMESTAMP FROM hostnames' + ) + + def _migrate_mariadb(to_version: int): """Migrate MariaDB to target version using ALTER TABLE.""" if to_version == 2: @@ -256,6 +291,8 @@ def _migrate_mariadb(to_version: int): 'ALTER TABLE hostnames ADD UNIQUE INDEX ' 'hostnames_hostname_zone (hostname, zone)' ) + elif to_version == 3: + _migrate_v3_create_permissions() def check_and_migrate(): @@ -290,7 +327,7 @@ def create_tables(): check_and_migrate() return - db.create_tables([User, Hostname, Version]) + db.create_tables([User, Hostname, Permission, Version]) Version.create(version=DATABASE_VERSION) logging.debug("Database tables created") @@ -316,6 +353,52 @@ def get_user(username: str) -> User: return User.get(User.username == username) +def get_permission(user, fqdn): + """ + Get permission for user to access FQDN. + Patterns: + '*' - matches any hostname + '*.suffix' - matches hostname ends with .suffix + 'exact' - matches hostname == pattern + + Args: + user: User to check permission for. + fqdn: Full qualified domain name. + + Returns: + Permission instance. + + Raises: + DoesNotExist: If permission not found. + EncodingError: If fqdn is invalid. + """ + + fqdn = encode_dnsname(fqdn) + + permissions = Permission.select().where(Permission.user == user) + + for perm in permissions: + if not fqdn.endswith(f".{perm.zone}"): + continue + + pattern = perm.hostname_pattern + + if pattern == '*': + return perm + + hostname = fqdn.removesuffix(f".{perm.zone}") + + if pattern.startswith('*.'): + suffix = pattern[1:] # Remove '*' + if hostname.endswith(suffix): + return perm + + elif hostname == pattern: + return perm + + raise DoesNotExist + + def get_hostname(hostname: str, zone: str) -> Hostname: """ Get hostname by name and zone. @@ -342,27 +425,44 @@ def get_hostname(hostname: str, zone: str) -> Hostname: ) -def get_hostname_for_user(hostname: str, user: User): +def get_hostname_for_user( + user: User, hostname: str, zone: str, dns_ttl: int, expiry_ttl: int): """ - Get hostname owned by specific user. + Get hostname if it exists or create a new intance. Args: - hostname: Hostname to look up (FQDN). - user: User who should own the hostname. + user: User requesting access. + hostname: Hostname (e.g., 'myhost'). + zone: Zone (e.g., 'example.com'). + dns_ttl: Expiry TTL for auto-created hostnames. + expiry_ttl: Expiry TTL for auto-created hostnames. Returns: - Hostname instance. - - Raises: - DoesNotExist: If hostname not found or not owned by user. - EncodingError: If hostname is invalid. + Tuple of (Hostname, created) where created is True if auto-created. Example: - >>> user = get_user("alice") - >>> host = get_hostname_for_user("myhost.example.com", user) + >>> host, created = get_hostname_or_create( + ... user, 'myhost', 'example.com', 60, 3600) """ - fqdn = fn.Concat(Hostname.hostname, '.', Hostname.zone) - return Hostname.get( - (fqdn == encode_dnsname(hostname)) & - (Hostname.user == user) + try: + return ( + Hostname.get( + (Hostname.user == user) & + (Hostname.hostname == hostname) & + (Hostname.zone == zone) + ), + False + ) + except DoesNotExist: + pass + + return ( + Hostname( + user=user, + hostname=hostname, + zone=zone, + dns_ttl=dns_ttl, + expiry_ttl=expiry_ttl, + ), + True ) diff --git a/src/ddns_service/server.py b/src/ddns_service/server.py index e3c4086..5ff6284 100644 --- a/src/ddns_service/server.py +++ b/src/ddns_service/server.py @@ -30,7 +30,8 @@ from .models import ( DoesNotExist, EncodingError, get_hostname_for_user, - get_user + get_user, + get_permission, ) from argon2.exceptions import VerifyMismatchError from concurrent.futures import ThreadPoolExecutor @@ -294,7 +295,7 @@ class DDNSRequestHandler(BaseHTTPRequestHandler): # Parse query parameters params = parse_qs(parsed.query) - # Get credentials + # Process credentials parameters username, password = self.parse_basic_auth() if username is None: username = extract_param(params, endpoint["params"]["username"]) @@ -308,7 +309,7 @@ class DDNSRequestHandler(BaseHTTPRequestHandler): client_ip ) - # Get hostname parameter + # Process hostname parameter hostname_param = extract_param(params, endpoint["params"]["hostname"]) if not hostname_param: raise DDNSClientError( @@ -319,12 +320,6 @@ class DDNSRequestHandler(BaseHTTPRequestHandler): username=username ) - # Validate credentials - user = self._authenticate(client_ip, username, password) - - # Check hostname ownership - hostname = self._check_permissions(client_ip, user, hostname_param) - # Process myip parameter ipv4 = None myip = extract_param(params, endpoint["params"]["ipv4"]) @@ -342,8 +337,7 @@ class DDNSRequestHandler(BaseHTTPRequestHandler): STATUS_BADIP, client=client_ip, username=username, - hostname=hostname.hostname, - zone=hostname.zone, + hostname=hostname_param, ip=myip ) @@ -359,13 +353,12 @@ class DDNSRequestHandler(BaseHTTPRequestHandler): raise ValueError except ValueError: raise DDNSClientError( - "Bad IP address", + "Bad IPv6 address", 400, STATUS_BADIP, client=client_ip, username=username, - hostname=hostname.hostname, - zone=hostname.zone, + hostname=hostname_param, ipv6=myip6 ) @@ -377,6 +370,13 @@ class DDNSRequestHandler(BaseHTTPRequestHandler): else: ipv6 = ip + # Process notify_change parameter + notify_change = extract_param( + params, endpoint["params"]["notify_change"]) + notify_change = (notify_change.lower() in + ["1", "y", "yes", "on", "true"] + if notify_change else False) + # Process expiry_ttl parameter expiry_ttl_param = extract_param( params, endpoint["params"]["expiry_ttl"]) @@ -393,30 +393,26 @@ class DDNSRequestHandler(BaseHTTPRequestHandler): STATUS_NOHOST, client=client_ip, username=username, - hostname=hostname.hostname, - zone=hostname.zone, + hostname=hostname_param, expiry_ttl=expiry_ttl_param ) # Validate bounds defaults = self.app.config["defaults"] - allow_zero = defaults.get("expiry_ttl_allow_zero", True) - ttl_min = defaults.get("expiry_ttl_min") - ttl_max = defaults.get("expiry_ttl_max") if expiry_ttl == 0: - if not allow_zero: + if not defaults["expiry_ttl_allow_zero"]: raise DDNSClientError( "Zero expiry_ttl not allowed", 400, STATUS_NOHOST, client=client_ip, username=username, - hostname=hostname.hostname, - zone=hostname.zone, + hostname=hostname_param, expiry_ttl=expiry_ttl ) else: + ttl_min = defaults["expiry_ttl_min"] if ttl_min is not None and expiry_ttl < ttl_min: raise DDNSClientError( "expiry_ttl below minimum", @@ -424,11 +420,11 @@ class DDNSRequestHandler(BaseHTTPRequestHandler): STATUS_NOHOST, client=client_ip, username=username, - hostname=hostname.hostname, - zone=hostname.zone, + hostname=hostname_param, expiry_ttl=expiry_ttl, min=ttl_min ) + ttl_max = defaults["expiry_ttl_max"] if ttl_max is not None and expiry_ttl > ttl_max: raise DDNSClientError( "expiry_ttl above maximum", @@ -436,18 +432,20 @@ class DDNSRequestHandler(BaseHTTPRequestHandler): STATUS_NOHOST, client=client_ip, username=username, - hostname=hostname.hostname, - zone=hostname.zone, + hostname=hostname_param, expiry_ttl=expiry_ttl, max=ttl_max ) - # Process notify_change parameter - notify_change = extract_param( - params, endpoint["params"]["notify_change"]) - notify_change = (notify_change.lower() in - ["1", "y", "yes", "on", "true"] - if notify_change else False) + # Validate credentials + user = self._authenticate(client_ip, username, password) + + # Check hostname permission + hostname, created = self._get_hostname_for_user( + client_ip, + user, + hostname_param + ) # Good rate limit check if self.app.good_limiter: @@ -474,7 +472,8 @@ class DDNSRequestHandler(BaseHTTPRequestHandler): ipv4, ipv6, notify_change, - expiry_ttl + expiry_ttl, + created ) def _authenticate(self, client_ip, username, password): @@ -498,28 +497,33 @@ class DDNSRequestHandler(BaseHTTPRequestHandler): return user - def _check_permissions(self, client_ip, user, hostname_param): - # Check hostname ownership + def _get_hostname_for_user(self, client_ip, user, hostname_param): + """Check permissions and get/create hostname.""" code = None try: - hostname = get_hostname_for_user(hostname_param, user) + perm = get_permission(user, hostname_param) + hostname_param = hostname_param.removesuffix(f".{perm.zone}") + return get_hostname_for_user( + user, + hostname_param, + perm.zone, + self.app.config["defaults"]["dns_ttl"], + self.app.config["defaults"]["expiry_ttl"] + ) except DoesNotExist: code = 403 except EncodingError: code = 400 - if code: - raise DDNSClientError( - "Access denied", - code, - STATUS_NOHOST, - client=client_ip, - username=user.username, - hostname=hostname_param - ) - - return hostname + raise DDNSClientError( + "Access denied", + code, + STATUS_NOHOST, + client=client_ip, + username=user.username, + hostname=hostname_param + ) def _rollback_dns(self, hostname, old_ip, record_type): """Roll back a DNS record to its previous value.""" @@ -535,7 +539,7 @@ class DDNSRequestHandler(BaseHTTPRequestHandler): logging.error(f"DNS rollback failed ({record_type}): {e}") def _process_ip_update(self, client_ip, user, hostname, ipv4, ipv6, - notify_change, expiry_ttl): + notify_change, expiry_ttl, created): """Process IP update for hostname.""" now = now_utc() @@ -631,10 +635,12 @@ class DDNSRequestHandler(BaseHTTPRequestHandler): STATUS_NOCHG, ipv4=hostname.last_ipv4, ipv6=hostname.last_ipv6, - expiry_ttl=hostname.expiry_ttl + expiry_ttl=hostname.expiry_ttl, + created=created ) return + action = "Created" if created else "Updated" changed_info = "" if ipv4_changed: changed_info += f" ipv4={ipv4}" @@ -643,7 +649,7 @@ class DDNSRequestHandler(BaseHTTPRequestHandler): if expiry_ttl_changed: changed_info += f" expiry_ttl={hostname.expiry_ttl}" logging.info( - f"Updated: client={client_ip} hostname={hostname.hostname} " + f"{action}: client={client_ip} hostname={hostname.hostname} " f"zone={hostname.zone}{changed_info} notify_change={str(notify_change).lower()}" ) @@ -663,7 +669,8 @@ class DDNSRequestHandler(BaseHTTPRequestHandler): STATUS_GOOD, ipv4=hostname.last_ipv4, ipv6=hostname.last_ipv6, - expiry_ttl=hostname.expiry_ttl + expiry_ttl=hostname.expiry_ttl, + created=created )