Add and use permission table, update CLI and README.md

This commit is contained in:
2026-02-08 02:42:49 +01:00
parent eafe106bf1
commit 81ffdc9925
6 changed files with 337 additions and 141 deletions

View File

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

View File

@@ -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}"
)

View File

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

View File

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

View File

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

View File

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