Add and use permission table, update CLI and README.md
This commit is contained in:
38
README.md
38
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
|
||||
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user