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
|
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
|
### Hostname Management
|
||||||
|
|
||||||
|
Hostnames are auto-created when users with permission send their first update.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List all hostnames
|
# List all hostnames
|
||||||
ddns-service hostname list
|
ddns-service hostname list
|
||||||
@@ -256,13 +287,6 @@ ddns-service hostname list
|
|||||||
# List hostnames for specific user
|
# List hostnames for specific user
|
||||||
ddns-service hostname list --user myuser
|
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
|
# Modify hostname TTLs
|
||||||
ddns-service hostname modify mypc dyn.example.com --dns-ttl 120
|
ddns-service hostname modify mypc dyn.example.com --dns-ttl 120
|
||||||
|
|
||||||
|
|||||||
@@ -96,10 +96,14 @@ def cleanup_expired(app, start_time=None):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
if hostname.last_ipv4 is None and hostname.last_ipv6 is None:
|
||||||
|
hostname.delete_instance()
|
||||||
|
else:
|
||||||
hostname.save()
|
hostname.save()
|
||||||
|
|
||||||
except DatabaseError as e:
|
except DatabaseError as e:
|
||||||
logging.error(
|
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}"
|
f"zone={hostname.zone}: {e}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from .models import (
|
|||||||
get_hostname,
|
get_hostname,
|
||||||
get_user,
|
get_user,
|
||||||
Hostname,
|
Hostname,
|
||||||
|
Permission,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -166,58 +167,6 @@ def cmd_hostname_list(args, app):
|
|||||||
return 0
|
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):
|
def cmd_hostname_delete(args, app):
|
||||||
"""Delete a hostname."""
|
"""Delete a hostname."""
|
||||||
try:
|
try:
|
||||||
@@ -325,3 +274,101 @@ def cmd_cleanup(args, app):
|
|||||||
return 1
|
return 1
|
||||||
|
|
||||||
return 0
|
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 .app import Application
|
||||||
from .cli import (
|
from .cli import (
|
||||||
cmd_cleanup,
|
cmd_cleanup,
|
||||||
cmd_hostname_add,
|
|
||||||
cmd_hostname_delete,
|
cmd_hostname_delete,
|
||||||
cmd_hostname_list,
|
cmd_hostname_list,
|
||||||
cmd_hostname_modify,
|
cmd_hostname_modify,
|
||||||
|
cmd_permission_add,
|
||||||
|
cmd_permission_delete,
|
||||||
|
cmd_permission_list,
|
||||||
cmd_user_add,
|
cmd_user_add,
|
||||||
cmd_user_delete,
|
cmd_user_delete,
|
||||||
cmd_user_email,
|
cmd_user_email,
|
||||||
@@ -81,14 +83,6 @@ def build_parser():
|
|||||||
hostname_list.add_argument("--user", help="Filter by username")
|
hostname_list.add_argument("--user", help="Filter by username")
|
||||||
hostname_list.set_defaults(func=cmd_hostname_list)
|
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(
|
hostname_delete = hostname_subparsers.add_parser(
|
||||||
"delete", help="Delete hostname"
|
"delete", help="Delete hostname"
|
||||||
)
|
)
|
||||||
@@ -105,6 +99,26 @@ def build_parser():
|
|||||||
hostname_modify.add_argument("--expiry-ttl", type=int, help="Expiry TTL")
|
hostname_modify.add_argument("--expiry-ttl", type=int, help="Expiry TTL")
|
||||||
hostname_modify.set_defaults(func=cmd_hostname_modify)
|
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 command
|
||||||
cleanup_parser = subparsers.add_parser("cleanup", help="Run cleanup manually")
|
cleanup_parser = subparsers.add_parser("cleanup", help="Run cleanup manually")
|
||||||
cleanup_parser.set_defaults(func=cmd_cleanup)
|
cleanup_parser.set_defaults(func=cmd_cleanup)
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from peewee import (
|
|||||||
Model,
|
Model,
|
||||||
DateTimeField,
|
DateTimeField,
|
||||||
DoesNotExist,
|
DoesNotExist,
|
||||||
fn,
|
|
||||||
ForeignKeyField,
|
ForeignKeyField,
|
||||||
IntegerField,
|
IntegerField,
|
||||||
SqliteDatabase,
|
SqliteDatabase,
|
||||||
@@ -29,12 +28,14 @@ __all__ = [
|
|||||||
'DATABASE_VERSION',
|
'DATABASE_VERSION',
|
||||||
'User',
|
'User',
|
||||||
'Hostname',
|
'Hostname',
|
||||||
|
'Permission',
|
||||||
'Version',
|
'Version',
|
||||||
'init_database',
|
'init_database',
|
||||||
'create_tables',
|
'create_tables',
|
||||||
'get_user',
|
|
||||||
'get_hostname',
|
'get_hostname',
|
||||||
'get_hostname_for_user',
|
'get_hostname_for_user',
|
||||||
|
'get_permission',
|
||||||
|
'get_user',
|
||||||
'DoesNotExist',
|
'DoesNotExist',
|
||||||
'EncodingError',
|
'EncodingError',
|
||||||
'DatabaseError',
|
'DatabaseError',
|
||||||
@@ -44,10 +45,11 @@ __all__ = [
|
|||||||
db = DatabaseProxy()
|
db = DatabaseProxy()
|
||||||
|
|
||||||
# Current database schema version
|
# Current database schema version
|
||||||
DATABASE_VERSION = 2
|
DATABASE_VERSION = 3
|
||||||
|
|
||||||
# Migration column mappings: key = target version
|
# Migration column mappings: key = target version
|
||||||
# Values: {table: {old_col: new_col}} - None value = drop column
|
# Values: {table: {old_col: new_col}} - None value = drop column
|
||||||
|
# Empty dict means no table schema changes (v3: new table only)
|
||||||
MIGRATION_COLUMN_MAPS = {
|
MIGRATION_COLUMN_MAPS = {
|
||||||
2: {
|
2: {
|
||||||
'hostnames': {
|
'hostnames': {
|
||||||
@@ -62,7 +64,8 @@ MIGRATION_COLUMN_MAPS = {
|
|||||||
'last_ipv6': 'last_ipv6',
|
'last_ipv6': 'last_ipv6',
|
||||||
'last_ipv6_update': 'last_ipv6_update',
|
'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):
|
class Version(BaseModel):
|
||||||
"""Database schema version for migrations."""
|
"""Database schema version for migrations."""
|
||||||
|
|
||||||
@@ -142,6 +161,7 @@ class Version(BaseModel):
|
|||||||
TABLE_TO_MODEL = {
|
TABLE_TO_MODEL = {
|
||||||
'users': User,
|
'users': User,
|
||||||
'hostnames': Hostname,
|
'hostnames': Hostname,
|
||||||
|
'permissions': Permission,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -166,6 +186,7 @@ def init_database(config: dict):
|
|||||||
actual_db = SqliteDatabase(db_path, pragmas={
|
actual_db = SqliteDatabase(db_path, pragmas={
|
||||||
'journal_mode': 'wal',
|
'journal_mode': 'wal',
|
||||||
'busy_timeout': 5000,
|
'busy_timeout': 5000,
|
||||||
|
'foreign_keys': 1,
|
||||||
})
|
})
|
||||||
db.initialize(actual_db)
|
db.initialize(actual_db)
|
||||||
logging.debug(f"Database backend: SQLite path={db_path}")
|
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):
|
def _migrate_sqlite(from_version: int, to_version: int):
|
||||||
"""Migrate SQLite from from_version to to_version."""
|
"""Migrate SQLite from from_version to to_version."""
|
||||||
|
if to_version == 3:
|
||||||
|
_migrate_v3_create_permissions()
|
||||||
|
return
|
||||||
|
|
||||||
db.execute_sql('PRAGMA foreign_keys=OFF')
|
db.execute_sql('PRAGMA foreign_keys=OFF')
|
||||||
try:
|
try:
|
||||||
tables = MIGRATION_COLUMN_MAPS[to_version]
|
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')
|
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):
|
def _migrate_mariadb(to_version: int):
|
||||||
"""Migrate MariaDB to target version using ALTER TABLE."""
|
"""Migrate MariaDB to target version using ALTER TABLE."""
|
||||||
if to_version == 2:
|
if to_version == 2:
|
||||||
@@ -256,6 +291,8 @@ def _migrate_mariadb(to_version: int):
|
|||||||
'ALTER TABLE hostnames ADD UNIQUE INDEX '
|
'ALTER TABLE hostnames ADD UNIQUE INDEX '
|
||||||
'hostnames_hostname_zone (hostname, zone)'
|
'hostnames_hostname_zone (hostname, zone)'
|
||||||
)
|
)
|
||||||
|
elif to_version == 3:
|
||||||
|
_migrate_v3_create_permissions()
|
||||||
|
|
||||||
|
|
||||||
def check_and_migrate():
|
def check_and_migrate():
|
||||||
@@ -290,7 +327,7 @@ def create_tables():
|
|||||||
check_and_migrate()
|
check_and_migrate()
|
||||||
return
|
return
|
||||||
|
|
||||||
db.create_tables([User, Hostname, Version])
|
db.create_tables([User, Hostname, Permission, Version])
|
||||||
Version.create(version=DATABASE_VERSION)
|
Version.create(version=DATABASE_VERSION)
|
||||||
logging.debug("Database tables created")
|
logging.debug("Database tables created")
|
||||||
|
|
||||||
@@ -316,6 +353,52 @@ def get_user(username: str) -> User:
|
|||||||
return User.get(User.username == username)
|
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:
|
def get_hostname(hostname: str, zone: str) -> Hostname:
|
||||||
"""
|
"""
|
||||||
Get hostname by name and zone.
|
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:
|
Args:
|
||||||
hostname: Hostname to look up (FQDN).
|
user: User requesting access.
|
||||||
user: User who should own the hostname.
|
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:
|
Returns:
|
||||||
Hostname instance.
|
Tuple of (Hostname, created) where created is True if auto-created.
|
||||||
|
|
||||||
Raises:
|
|
||||||
DoesNotExist: If hostname not found or not owned by user.
|
|
||||||
EncodingError: If hostname is invalid.
|
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
>>> user = get_user("alice")
|
>>> host, created = get_hostname_or_create(
|
||||||
>>> host = get_hostname_for_user("myhost.example.com", user)
|
... user, 'myhost', 'example.com', 60, 3600)
|
||||||
"""
|
"""
|
||||||
fqdn = fn.Concat(Hostname.hostname, '.', Hostname.zone)
|
try:
|
||||||
return Hostname.get(
|
return (
|
||||||
(fqdn == encode_dnsname(hostname)) &
|
Hostname.get(
|
||||||
(Hostname.user == user)
|
(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,
|
DoesNotExist,
|
||||||
EncodingError,
|
EncodingError,
|
||||||
get_hostname_for_user,
|
get_hostname_for_user,
|
||||||
get_user
|
get_user,
|
||||||
|
get_permission,
|
||||||
)
|
)
|
||||||
from argon2.exceptions import VerifyMismatchError
|
from argon2.exceptions import VerifyMismatchError
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
@@ -294,7 +295,7 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
|
|||||||
# Parse query parameters
|
# Parse query parameters
|
||||||
params = parse_qs(parsed.query)
|
params = parse_qs(parsed.query)
|
||||||
|
|
||||||
# Get credentials
|
# Process credentials parameters
|
||||||
username, password = self.parse_basic_auth()
|
username, password = self.parse_basic_auth()
|
||||||
if username is None:
|
if username is None:
|
||||||
username = extract_param(params, endpoint["params"]["username"])
|
username = extract_param(params, endpoint["params"]["username"])
|
||||||
@@ -308,7 +309,7 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
|
|||||||
client_ip
|
client_ip
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get hostname parameter
|
# Process hostname parameter
|
||||||
hostname_param = extract_param(params, endpoint["params"]["hostname"])
|
hostname_param = extract_param(params, endpoint["params"]["hostname"])
|
||||||
if not hostname_param:
|
if not hostname_param:
|
||||||
raise DDNSClientError(
|
raise DDNSClientError(
|
||||||
@@ -319,12 +320,6 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
|
|||||||
username=username
|
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
|
# Process myip parameter
|
||||||
ipv4 = None
|
ipv4 = None
|
||||||
myip = extract_param(params, endpoint["params"]["ipv4"])
|
myip = extract_param(params, endpoint["params"]["ipv4"])
|
||||||
@@ -342,8 +337,7 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
|
|||||||
STATUS_BADIP,
|
STATUS_BADIP,
|
||||||
client=client_ip,
|
client=client_ip,
|
||||||
username=username,
|
username=username,
|
||||||
hostname=hostname.hostname,
|
hostname=hostname_param,
|
||||||
zone=hostname.zone,
|
|
||||||
ip=myip
|
ip=myip
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -359,13 +353,12 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
|
|||||||
raise ValueError
|
raise ValueError
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise DDNSClientError(
|
raise DDNSClientError(
|
||||||
"Bad IP address",
|
"Bad IPv6 address",
|
||||||
400,
|
400,
|
||||||
STATUS_BADIP,
|
STATUS_BADIP,
|
||||||
client=client_ip,
|
client=client_ip,
|
||||||
username=username,
|
username=username,
|
||||||
hostname=hostname.hostname,
|
hostname=hostname_param,
|
||||||
zone=hostname.zone,
|
|
||||||
ipv6=myip6
|
ipv6=myip6
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -377,6 +370,13 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
|
|||||||
else:
|
else:
|
||||||
ipv6 = ip
|
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
|
# Process expiry_ttl parameter
|
||||||
expiry_ttl_param = extract_param(
|
expiry_ttl_param = extract_param(
|
||||||
params, endpoint["params"]["expiry_ttl"])
|
params, endpoint["params"]["expiry_ttl"])
|
||||||
@@ -393,30 +393,26 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
|
|||||||
STATUS_NOHOST,
|
STATUS_NOHOST,
|
||||||
client=client_ip,
|
client=client_ip,
|
||||||
username=username,
|
username=username,
|
||||||
hostname=hostname.hostname,
|
hostname=hostname_param,
|
||||||
zone=hostname.zone,
|
|
||||||
expiry_ttl=expiry_ttl_param
|
expiry_ttl=expiry_ttl_param
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate bounds
|
# Validate bounds
|
||||||
defaults = self.app.config["defaults"]
|
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 expiry_ttl == 0:
|
||||||
if not allow_zero:
|
if not defaults["expiry_ttl_allow_zero"]:
|
||||||
raise DDNSClientError(
|
raise DDNSClientError(
|
||||||
"Zero expiry_ttl not allowed",
|
"Zero expiry_ttl not allowed",
|
||||||
400,
|
400,
|
||||||
STATUS_NOHOST,
|
STATUS_NOHOST,
|
||||||
client=client_ip,
|
client=client_ip,
|
||||||
username=username,
|
username=username,
|
||||||
hostname=hostname.hostname,
|
hostname=hostname_param,
|
||||||
zone=hostname.zone,
|
|
||||||
expiry_ttl=expiry_ttl
|
expiry_ttl=expiry_ttl
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
ttl_min = defaults["expiry_ttl_min"]
|
||||||
if ttl_min is not None and expiry_ttl < ttl_min:
|
if ttl_min is not None and expiry_ttl < ttl_min:
|
||||||
raise DDNSClientError(
|
raise DDNSClientError(
|
||||||
"expiry_ttl below minimum",
|
"expiry_ttl below minimum",
|
||||||
@@ -424,11 +420,11 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
|
|||||||
STATUS_NOHOST,
|
STATUS_NOHOST,
|
||||||
client=client_ip,
|
client=client_ip,
|
||||||
username=username,
|
username=username,
|
||||||
hostname=hostname.hostname,
|
hostname=hostname_param,
|
||||||
zone=hostname.zone,
|
|
||||||
expiry_ttl=expiry_ttl,
|
expiry_ttl=expiry_ttl,
|
||||||
min=ttl_min
|
min=ttl_min
|
||||||
)
|
)
|
||||||
|
ttl_max = defaults["expiry_ttl_max"]
|
||||||
if ttl_max is not None and expiry_ttl > ttl_max:
|
if ttl_max is not None and expiry_ttl > ttl_max:
|
||||||
raise DDNSClientError(
|
raise DDNSClientError(
|
||||||
"expiry_ttl above maximum",
|
"expiry_ttl above maximum",
|
||||||
@@ -436,18 +432,20 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
|
|||||||
STATUS_NOHOST,
|
STATUS_NOHOST,
|
||||||
client=client_ip,
|
client=client_ip,
|
||||||
username=username,
|
username=username,
|
||||||
hostname=hostname.hostname,
|
hostname=hostname_param,
|
||||||
zone=hostname.zone,
|
|
||||||
expiry_ttl=expiry_ttl,
|
expiry_ttl=expiry_ttl,
|
||||||
max=ttl_max
|
max=ttl_max
|
||||||
)
|
)
|
||||||
|
|
||||||
# Process notify_change parameter
|
# Validate credentials
|
||||||
notify_change = extract_param(
|
user = self._authenticate(client_ip, username, password)
|
||||||
params, endpoint["params"]["notify_change"])
|
|
||||||
notify_change = (notify_change.lower() in
|
# Check hostname permission
|
||||||
["1", "y", "yes", "on", "true"]
|
hostname, created = self._get_hostname_for_user(
|
||||||
if notify_change else False)
|
client_ip,
|
||||||
|
user,
|
||||||
|
hostname_param
|
||||||
|
)
|
||||||
|
|
||||||
# Good rate limit check
|
# Good rate limit check
|
||||||
if self.app.good_limiter:
|
if self.app.good_limiter:
|
||||||
@@ -474,7 +472,8 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
|
|||||||
ipv4,
|
ipv4,
|
||||||
ipv6,
|
ipv6,
|
||||||
notify_change,
|
notify_change,
|
||||||
expiry_ttl
|
expiry_ttl,
|
||||||
|
created
|
||||||
)
|
)
|
||||||
|
|
||||||
def _authenticate(self, client_ip, username, password):
|
def _authenticate(self, client_ip, username, password):
|
||||||
@@ -498,18 +497,25 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
def _check_permissions(self, client_ip, user, hostname_param):
|
def _get_hostname_for_user(self, client_ip, user, hostname_param):
|
||||||
# Check hostname ownership
|
"""Check permissions and get/create hostname."""
|
||||||
code = None
|
code = None
|
||||||
|
|
||||||
try:
|
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:
|
except DoesNotExist:
|
||||||
code = 403
|
code = 403
|
||||||
except EncodingError:
|
except EncodingError:
|
||||||
code = 400
|
code = 400
|
||||||
|
|
||||||
if code:
|
|
||||||
raise DDNSClientError(
|
raise DDNSClientError(
|
||||||
"Access denied",
|
"Access denied",
|
||||||
code,
|
code,
|
||||||
@@ -519,8 +525,6 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
|
|||||||
hostname=hostname_param
|
hostname=hostname_param
|
||||||
)
|
)
|
||||||
|
|
||||||
return hostname
|
|
||||||
|
|
||||||
def _rollback_dns(self, hostname, old_ip, record_type):
|
def _rollback_dns(self, hostname, old_ip, record_type):
|
||||||
"""Roll back a DNS record to its previous value."""
|
"""Roll back a DNS record to its previous value."""
|
||||||
try:
|
try:
|
||||||
@@ -535,7 +539,7 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
|
|||||||
logging.error(f"DNS rollback failed ({record_type}): {e}")
|
logging.error(f"DNS rollback failed ({record_type}): {e}")
|
||||||
|
|
||||||
def _process_ip_update(self, client_ip, user, hostname, ipv4, ipv6,
|
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."""
|
"""Process IP update for hostname."""
|
||||||
now = now_utc()
|
now = now_utc()
|
||||||
|
|
||||||
@@ -631,10 +635,12 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
|
|||||||
STATUS_NOCHG,
|
STATUS_NOCHG,
|
||||||
ipv4=hostname.last_ipv4,
|
ipv4=hostname.last_ipv4,
|
||||||
ipv6=hostname.last_ipv6,
|
ipv6=hostname.last_ipv6,
|
||||||
expiry_ttl=hostname.expiry_ttl
|
expiry_ttl=hostname.expiry_ttl,
|
||||||
|
created=created
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
action = "Created" if created else "Updated"
|
||||||
changed_info = ""
|
changed_info = ""
|
||||||
if ipv4_changed:
|
if ipv4_changed:
|
||||||
changed_info += f" ipv4={ipv4}"
|
changed_info += f" ipv4={ipv4}"
|
||||||
@@ -643,7 +649,7 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
|
|||||||
if expiry_ttl_changed:
|
if expiry_ttl_changed:
|
||||||
changed_info += f" expiry_ttl={hostname.expiry_ttl}"
|
changed_info += f" expiry_ttl={hostname.expiry_ttl}"
|
||||||
logging.info(
|
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()}"
|
f"zone={hostname.zone}{changed_info} notify_change={str(notify_change).lower()}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -663,7 +669,8 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
|
|||||||
STATUS_GOOD,
|
STATUS_GOOD,
|
||||||
ipv4=hostname.last_ipv4,
|
ipv4=hostname.last_ipv4,
|
||||||
ipv6=hostname.last_ipv6,
|
ipv6=hostname.last_ipv6,
|
||||||
expiry_ttl=hostname.expiry_ttl
|
expiry_ttl=hostname.expiry_ttl,
|
||||||
|
created=created
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user