Remove dependency on dns-manager and use dnspython directly instead
This commit is contained in:
51
README.md
51
README.md
@@ -1,6 +1,6 @@
|
|||||||
# DDNS Service
|
# DDNS Service
|
||||||
|
|
||||||
Dynamic DNS update service with CLI administration. Accepts HTTP(S) requests to update DNS A/AAAA records using the dns-manager library.
|
Dynamic DNS update service with CLI administration. Accepts HTTP(S) requests to update DNS A/AAAA records using RFC 2136 dynamic updates.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ pip install -r requirements.txt
|
|||||||
### Dependencies
|
### Dependencies
|
||||||
|
|
||||||
- Python 3.11+
|
- Python 3.11+
|
||||||
- dns-manager
|
- dnspython
|
||||||
- peewee
|
- peewee
|
||||||
- argon2-cffi
|
- argon2-cffi
|
||||||
- pymysql (for MariaDB support)
|
- pymysql (for MariaDB support)
|
||||||
@@ -81,9 +81,16 @@ ssl_key_file = "/etc/ddns-service/key.pem" # required if ssl = true
|
|||||||
path = "/var/lib/ddns-service/ddns.db" # required for sqlite
|
path = "/var/lib/ddns-service/ddns.db" # required for sqlite
|
||||||
|
|
||||||
[dns_service]
|
[dns_service]
|
||||||
# manager_config_file = "/etc/dns-manager/config.yml" # default
|
# dns_server = "localhost" # default: "localhost" (DNS server for RFC 2136 updates)
|
||||||
|
# dns_port = 53 # default: 53
|
||||||
|
# dns_timeout = 5 # default: 5 (seconds)
|
||||||
|
# ddns_default_key_file = "/etc/ddns-service/ddns.key" # optional, BIND TSIG key file
|
||||||
# cleanup_interval = 60 # default: 60 (seconds, expired records cleanup)
|
# cleanup_interval = 60 # default: 60 (seconds, expired records cleanup)
|
||||||
|
|
||||||
|
# Per-zone TSIG key overrides (optional)
|
||||||
|
# [dns_service.zone_keys]
|
||||||
|
# "dyn.example.com" = "/etc/ddns-service/dyn-example.key"
|
||||||
|
|
||||||
[defaults]
|
[defaults]
|
||||||
# dns_ttl = 60 # default: 60
|
# dns_ttl = 60 # default: 60
|
||||||
# expiry_ttl = 3600 # default: 3600
|
# expiry_ttl = 3600 # default: 3600
|
||||||
@@ -104,6 +111,44 @@ from_address = "ddns@example.com" # required if email.enabled
|
|||||||
# cleanup_interval = 60 # default: 60 (seconds, rate limiter cleanup)
|
# cleanup_interval = 60 # default: 60 (seconds, rate limiter cleanup)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### TSIG Authentication
|
||||||
|
|
||||||
|
For secure DNS updates, configure TSIG authentication:
|
||||||
|
|
||||||
|
1. Generate key on BIND server:
|
||||||
|
```bash
|
||||||
|
tsig-keygen -a hmac-sha256 ddns-key > /etc/bind/ddns.key
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Include in `named.conf` and configure zone:
|
||||||
|
```
|
||||||
|
include "/etc/bind/ddns.key";
|
||||||
|
|
||||||
|
zone "dyn.example.com" {
|
||||||
|
type master;
|
||||||
|
file "/var/lib/bind/dyn.example.com.zone";
|
||||||
|
update-policy {
|
||||||
|
grant ddns-key zonesub ANY;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Copy key file to ddns-service host and configure:
|
||||||
|
```toml
|
||||||
|
[dns_service]
|
||||||
|
ddns_default_key_file = "/etc/ddns-service/ddns.key"
|
||||||
|
```
|
||||||
|
|
||||||
|
Key file format (generated by `tsig-keygen`):
|
||||||
|
```
|
||||||
|
key "ddns-key" {
|
||||||
|
algorithm hmac-sha256;
|
||||||
|
secret "base64-encoded-secret";
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Without TSIG authentication, the DNS server must allow updates based on IP address (via `allow-update` directive).
|
||||||
|
|
||||||
### Endpoints
|
### Endpoints
|
||||||
|
|
||||||
Configure one or more HTTP endpoints. If no endpoints are defined, a default endpoint at `/update` is created with standard parameter names.
|
Configure one or more HTTP endpoints. If no endpoints are defined, a default endpoint at `/update` is created with standard parameter names.
|
||||||
|
|||||||
@@ -25,8 +25,15 @@ path = "/var/lib/ddns-service/ddns.db" # required for sqlite
|
|||||||
# database = "ddns" # required for mariadb
|
# database = "ddns" # required for mariadb
|
||||||
|
|
||||||
[dns_service]
|
[dns_service]
|
||||||
# manager_config_file = "/etc/dns-manager/config.yml" # default
|
# dns_server = "localhost" # default
|
||||||
# cleanup_interval = 60 # default, interval in seconds
|
# dns_port = 53 # default
|
||||||
|
# dns_timeout = 5 # default, seconds
|
||||||
|
# ddns_default_key_file = "/etc/ddns-service/ddns.key" # optional, BIND TSIG key
|
||||||
|
# cleanup_interval = 60 # default, seconds
|
||||||
|
|
||||||
|
# Per-zone TSIG key overrides (optional)
|
||||||
|
# [dns_service.zone_keys]
|
||||||
|
# "dyn.example.com" = "/etc/ddns-service/dyn-example.key"
|
||||||
|
|
||||||
[defaults]
|
[defaults]
|
||||||
# dns_ttl = 60 # default, DNS record TTL in seconds
|
# dns_ttl = 60 # default, DNS record TTL in seconds
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ version = {attr = "ddns_service.__version__"}
|
|||||||
name = "ddns_service"
|
name = "ddns_service"
|
||||||
dynamic = ["version"]
|
dynamic = ["version"]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dns-manager @ git+https://git.ccc-rheintal.ch/spacefreak/dns-manager.git",
|
"dnspython>=2.4.0",
|
||||||
"peewee>=3.17.0",
|
"peewee>=3.17.0",
|
||||||
"argon2-cffi>=23.1.0",
|
"argon2-cffi>=23.1.0",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -145,11 +145,32 @@ def load_config(config_path):
|
|||||||
cfg["database"].setdefault("backend", "sqlite")
|
cfg["database"].setdefault("backend", "sqlite")
|
||||||
|
|
||||||
cfg.setdefault("dns_service", {})
|
cfg.setdefault("dns_service", {})
|
||||||
cfg["dns_service"].setdefault("manager_config_file", "/etc/dns-manager/config.yml")
|
cfg["dns_service"].setdefault("dns_server", "localhost")
|
||||||
|
cfg["dns_service"].setdefault("dns_port", 53)
|
||||||
|
cfg["dns_service"].setdefault("dns_timeout", 5)
|
||||||
cfg["dns_service"].setdefault("cleanup_interval", 60)
|
cfg["dns_service"].setdefault("cleanup_interval", 60)
|
||||||
if cfg["dns_service"]["cleanup_interval"] < 1:
|
if cfg["dns_service"]["cleanup_interval"] < 1:
|
||||||
cfg["dns_service"]["cleanup_interval"] = 1
|
cfg["dns_service"]["cleanup_interval"] = 1
|
||||||
|
|
||||||
|
# Validate dns_server
|
||||||
|
if not cfg["dns_service"]["dns_server"]:
|
||||||
|
raise ConfigError("dns_service.dns_server cannot be empty")
|
||||||
|
|
||||||
|
# Validate ddns_default_key_file if provided
|
||||||
|
default_key = cfg["dns_service"].get("ddns_default_key_file")
|
||||||
|
if default_key and not os.path.isfile(default_key):
|
||||||
|
raise ConfigError(f"ddns_default_key_file not found: {default_key}")
|
||||||
|
|
||||||
|
# Validate zone_keys if provided
|
||||||
|
zone_keys = cfg["dns_service"].get("zone_keys", {})
|
||||||
|
if not isinstance(zone_keys, dict):
|
||||||
|
raise ConfigError("dns_service.zone_keys must be a table")
|
||||||
|
for zone, key_path in zone_keys.items():
|
||||||
|
if not isinstance(key_path, str):
|
||||||
|
raise ConfigError(f"dns_service.zone_keys.{zone} must be a string path")
|
||||||
|
if not os.path.isfile(key_path):
|
||||||
|
raise ConfigError(f"Zone key file not found for {zone}: {key_path}")
|
||||||
|
|
||||||
cfg.setdefault("defaults", {})
|
cfg.setdefault("defaults", {})
|
||||||
cfg["defaults"].setdefault("dns_ttl", 60)
|
cfg["defaults"].setdefault("dns_ttl", 60)
|
||||||
cfg["defaults"].setdefault("expiry_ttl", 3600)
|
cfg["defaults"].setdefault("expiry_ttl", 3600)
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
"""DNS operations using dns-manager library."""
|
"""DNS operations using RFC 2136 dynamic updates."""
|
||||||
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import stat
|
||||||
|
|
||||||
import dns.rdataset
|
import dns.name
|
||||||
|
import dns.query
|
||||||
|
import dns.rcode
|
||||||
import dns.rdatatype
|
import dns.rdatatype
|
||||||
from dnsmgr import DNSManager, name_from_text, rdata_from_text
|
import dns.tsigkeyring
|
||||||
|
import dns.update
|
||||||
|
|
||||||
|
|
||||||
def detect_ip_type(ip):
|
def detect_ip_type(ip):
|
||||||
@@ -27,8 +33,83 @@ class DNSError(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# TSIG algorithm name mapping (BIND short names -> dnspython FQDN form)
|
||||||
|
TSIG_ALGORITHMS = {
|
||||||
|
"hmac-md5": "hmac-md5.sig-alg.reg.int.",
|
||||||
|
"hmac-sha1": "hmac-sha1.",
|
||||||
|
"hmac-sha224": "hmac-sha224.",
|
||||||
|
"hmac-sha256": "hmac-sha256.",
|
||||||
|
"hmac-sha384": "hmac-sha384.",
|
||||||
|
"hmac-sha512": "hmac-sha512.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_bind_key_file(path):
|
||||||
|
"""
|
||||||
|
Parse BIND TSIG key file.
|
||||||
|
|
||||||
|
Format: key "keyname" { algorithm hmac-sha256; secret "base64..."; };
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Path to key file, or None.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (keyring, algorithm) or (None, None) if path is None.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
DNSError: If parsing fails.
|
||||||
|
"""
|
||||||
|
if not path:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check file permissions
|
||||||
|
file_stat = os.stat(path)
|
||||||
|
if file_stat.st_mode & stat.S_IROTH:
|
||||||
|
logging.warning(f"TSIG key file is world-readable: {path}")
|
||||||
|
|
||||||
|
with open(path, "r") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Extract key name
|
||||||
|
name_match = re.search(r'key\s+"([^"]+)"', content)
|
||||||
|
if not name_match:
|
||||||
|
raise DNSError(f"Invalid key file {path}: no key name found")
|
||||||
|
key_name = name_match.group(1)
|
||||||
|
|
||||||
|
# Ensure key name ends with dot for FQDN
|
||||||
|
if not key_name.endswith("."):
|
||||||
|
key_name = key_name + "."
|
||||||
|
|
||||||
|
# Extract algorithm
|
||||||
|
algo_match = re.search(r'algorithm\s+([^;]+);', content)
|
||||||
|
if not algo_match:
|
||||||
|
raise DNSError(f"Invalid key file {path}: no algorithm found")
|
||||||
|
algo_str = algo_match.group(1).strip().lower()
|
||||||
|
|
||||||
|
# Map to dnspython algorithm name
|
||||||
|
algorithm = TSIG_ALGORITHMS.get(algo_str)
|
||||||
|
if not algorithm:
|
||||||
|
raise DNSError(f"Unsupported TSIG algorithm in {path}: {algo_str}")
|
||||||
|
|
||||||
|
# Extract secret
|
||||||
|
secret_match = re.search(r'secret\s+"([^"]+)"', content)
|
||||||
|
if not secret_match:
|
||||||
|
raise DNSError(f"Invalid key file {path}: no secret found")
|
||||||
|
secret = secret_match.group(1)
|
||||||
|
|
||||||
|
keyring = dns.tsigkeyring.from_text({key_name: secret})
|
||||||
|
|
||||||
|
return keyring, dns.name.from_text(algorithm)
|
||||||
|
|
||||||
|
except DNSError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise DNSError(f"Failed to parse key file {path}: {e}")
|
||||||
|
|
||||||
|
|
||||||
class DNSService:
|
class DNSService:
|
||||||
"""DNS service for managing DNS records."""
|
"""DNS service for RFC 2136 dynamic updates."""
|
||||||
|
|
||||||
def __init__(self, config):
|
def __init__(self, config):
|
||||||
"""
|
"""
|
||||||
@@ -41,75 +122,133 @@ class DNSService:
|
|||||||
DNSError: If initialization fails.
|
DNSError: If initialization fails.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
config_file = config["dns_service"]["manager_config_file"]
|
dns_cfg = config["dns_service"]
|
||||||
self.manager = DNSManager(config_file)
|
self.server = dns_cfg["dns_server"]
|
||||||
logging.debug(f"DNS manager initialized: config={config_file}")
|
self.port = dns_cfg.get("dns_port", 53)
|
||||||
except Exception as e:
|
self.timeout = dns_cfg.get("dns_timeout", 5)
|
||||||
raise DNSError(f"Failed to initialize DNS manager: {e}")
|
|
||||||
|
|
||||||
def _get_zone(self, zone):
|
# Parse default TSIG key
|
||||||
"""Get zone object by name."""
|
default_key_file = dns_cfg.get("ddns_default_key_file")
|
||||||
zones = self.manager.get_zones(zone)
|
self.default_keyring, self.default_algorithm = parse_bind_key_file(
|
||||||
if not zones:
|
default_key_file
|
||||||
raise DNSError(f"Zone not found: {zone}")
|
)
|
||||||
zone_obj = zones[0]
|
|
||||||
self.manager.get_zone_content(zone_obj)
|
# Parse per-zone TSIG keys
|
||||||
return zone_obj
|
self.zone_keys = {}
|
||||||
|
zone_keys_cfg = dns_cfg.get("zone_keys", {})
|
||||||
|
for zone, key_path in zone_keys_cfg.items():
|
||||||
|
keyring, algorithm = parse_bind_key_file(key_path)
|
||||||
|
# Normalize zone name
|
||||||
|
if not zone.endswith("."):
|
||||||
|
zone = zone + "."
|
||||||
|
self.zone_keys[zone] = (keyring, algorithm)
|
||||||
|
|
||||||
|
if self.default_keyring or self.zone_keys:
|
||||||
|
logging.debug(
|
||||||
|
f"DNS service initialized: server={self.server}:{self.port} "
|
||||||
|
f"tsig=enabled zones={len(self.zone_keys)}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logging.debug(
|
||||||
|
f"DNS service initialized: server={self.server}:{self.port} "
|
||||||
|
f"tsig=disabled"
|
||||||
|
)
|
||||||
|
|
||||||
|
except DNSError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise DNSError(f"Failed to initialize DNS service: {e}")
|
||||||
|
|
||||||
|
def _get_key_for_zone(self, zone):
|
||||||
|
"""
|
||||||
|
Get TSIG key for a zone.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
zone: Zone name (will be normalized to FQDN).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (keyring, algorithm) or (None, None) for unauthenticated.
|
||||||
|
"""
|
||||||
|
# Normalize zone name
|
||||||
|
if not zone.endswith("."):
|
||||||
|
zone = zone + "."
|
||||||
|
|
||||||
|
# Check zone-specific key first
|
||||||
|
if zone in self.zone_keys:
|
||||||
|
return self.zone_keys[zone]
|
||||||
|
|
||||||
|
# Fall back to default key
|
||||||
|
return self.default_keyring, self.default_algorithm
|
||||||
|
|
||||||
|
def _make_update(self, zone):
|
||||||
|
"""
|
||||||
|
Create DNS update message for zone.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
zone: Zone name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dns.update.Update message object.
|
||||||
|
"""
|
||||||
|
# Normalize zone name
|
||||||
|
if not zone.endswith("."):
|
||||||
|
zone = zone + "."
|
||||||
|
|
||||||
|
keyring, algorithm = self._get_key_for_zone(zone)
|
||||||
|
|
||||||
|
if keyring:
|
||||||
|
return dns.update.Update(
|
||||||
|
zone,
|
||||||
|
keyring=keyring,
|
||||||
|
keyalgorithm=algorithm
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return dns.update.Update(zone)
|
||||||
|
|
||||||
|
def _send_update(self, update):
|
||||||
|
"""
|
||||||
|
Send DNS update message to server.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
update: dns.update.Update message object.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
DNSError: If update fails.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = dns.query.tcp(
|
||||||
|
update,
|
||||||
|
self.server,
|
||||||
|
port=self.port,
|
||||||
|
timeout=self.timeout
|
||||||
|
)
|
||||||
|
rcode = response.rcode()
|
||||||
|
if rcode != dns.rcode.NOERROR:
|
||||||
|
raise DNSError(f"DNS update failed: {dns.rcode.to_text(rcode)}")
|
||||||
|
|
||||||
|
except dns.exception.Timeout:
|
||||||
|
raise DNSError(f"DNS update timeout: {self.server}:{self.port}")
|
||||||
|
except DNSError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise DNSError(f"DNS update failed: {e}")
|
||||||
|
|
||||||
def _get_relative_name(self, hostname, zone):
|
def _get_relative_name(self, hostname, zone):
|
||||||
"""Get hostname relative to zone."""
|
|
||||||
if hostname.endswith("." + zone):
|
|
||||||
return hostname[:-len(zone) - 1]
|
|
||||||
return hostname
|
|
||||||
|
|
||||||
def _delete_record(self, zone_obj, name, rdtype):
|
|
||||||
"""Delete record if present."""
|
|
||||||
deleted = False
|
|
||||||
zone_obj.filter_by_name(name, zone_obj.origin)
|
|
||||||
|
|
||||||
node = zone_obj.get_node(name)
|
|
||||||
if not node:
|
|
||||||
return deleted
|
|
||||||
|
|
||||||
for rdataset in zone_obj.get_node(name):
|
|
||||||
if rdataset.rdtype == rdtype:
|
|
||||||
self.manager.delete_zone_record(zone_obj, name, rdataset)
|
|
||||||
deleted = True
|
|
||||||
|
|
||||||
return deleted
|
|
||||||
|
|
||||||
def delete_record(self, hostname, zone, record_type):
|
|
||||||
"""
|
"""
|
||||||
Delete DNS record(s) for the given hostname and record type.
|
Get hostname relative to zone.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
hostname: Fully qualified hostname.
|
hostname: Fully qualified hostname.
|
||||||
zone: DNS zone name.
|
zone: Zone name.
|
||||||
record_type: Record type (A or AAAA).
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if record was deleted.
|
Relative name for use in DNS update.
|
||||||
|
|
||||||
Raises:
|
|
||||||
DNSError: If delete fails.
|
|
||||||
"""
|
"""
|
||||||
|
# Strip zone suffix to get relative name
|
||||||
try:
|
zone_suffix = "." + zone
|
||||||
deleted = False
|
if hostname.endswith(zone_suffix):
|
||||||
zone_obj = self._get_zone(zone)
|
return hostname[:-len(zone_suffix)]
|
||||||
name = name_from_text(hostname, zone_obj.origin)
|
return hostname
|
||||||
rdtype = dns.rdatatype.from_text(record_type)
|
|
||||||
|
|
||||||
if self._delete_record(zone_obj, name, rdtype):
|
|
||||||
logging.debug(
|
|
||||||
f"DNS record deleted: hostname={hostname} "
|
|
||||||
f"zone={zone_obj.origin} type={record_type}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return deleted
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
raise DNSError(f"Failed to delete DNS record for {hostname}: {e}")
|
|
||||||
|
|
||||||
def update_record(self, hostname, zone, ip, ttl):
|
def update_record(self, hostname, zone, ip, ttl):
|
||||||
"""
|
"""
|
||||||
@@ -126,27 +265,51 @@ class DNSService:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
record_type, normalized_ip = detect_ip_type(ip)
|
record_type, normalized_ip = detect_ip_type(ip)
|
||||||
|
name = self._get_relative_name(hostname, zone)
|
||||||
|
|
||||||
zone_obj = self._get_zone(zone)
|
update = self._make_update(zone)
|
||||||
name = name_from_text(hostname, zone_obj.origin)
|
update.replace(name, ttl, record_type, normalized_ip)
|
||||||
rdtype = dns.rdatatype.from_text(record_type)
|
self._send_update(update)
|
||||||
|
|
||||||
# Delete existing record if present
|
|
||||||
self._delete_record(zone_obj, name, rdtype)
|
|
||||||
|
|
||||||
# Create new rdata
|
|
||||||
rdata = rdata_from_text(rdtype, normalized_ip, zone_obj.origin)
|
|
||||||
|
|
||||||
# Create rdataset with TTL
|
|
||||||
rdataset = dns.rdataset.Rdataset(rdata.rdclass, rdata.rdtype)
|
|
||||||
rdataset.update_ttl(ttl)
|
|
||||||
rdataset.add(rdata)
|
|
||||||
|
|
||||||
# Add the record
|
|
||||||
self.manager.add_zone_record(zone_obj, name, rdataset)
|
|
||||||
logging.debug(
|
logging.debug(
|
||||||
f"DNS record updated: hostname={hostname} zone={zone_obj.origin} "
|
f"DNS record updated: hostname={hostname} zone={zone} "
|
||||||
f"type={record_type} ip={normalized_ip} ttl={ttl}"
|
f"type={record_type} ip={normalized_ip} ttl={ttl}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
except DNSError:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise DNSError(f"Failed to update DNS record for {hostname}: {e}")
|
raise DNSError(f"Failed to update DNS record for {hostname}: {e}")
|
||||||
|
|
||||||
|
def delete_record(self, hostname, zone, record_type):
|
||||||
|
"""
|
||||||
|
Delete DNS record(s) for the given hostname and record type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hostname: Fully qualified hostname.
|
||||||
|
zone: DNS zone name.
|
||||||
|
record_type: Record type (A or AAAA).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True (for compatibility).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
DNSError: If delete fails.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
name = self._get_relative_name(hostname, zone)
|
||||||
|
|
||||||
|
update = self._make_update(zone)
|
||||||
|
update.delete(name, record_type)
|
||||||
|
self._send_update(update)
|
||||||
|
|
||||||
|
logging.debug(
|
||||||
|
f"DNS record deleted: hostname={hostname} zone={zone} "
|
||||||
|
f"type={record_type}"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except DNSError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise DNSError(f"Failed to delete DNS record for {hostname}: {e}")
|
||||||
|
|||||||
Reference in New Issue
Block a user