139 lines
3.4 KiB
Python
139 lines
3.4 KiB
Python
"""Hostname and zone validation with punycode support."""
|
|
|
|
import re
|
|
|
|
|
|
# Valid hostname label pattern (after punycode encoding)
|
|
LABEL_PATTERN = re.compile(r'^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$', re.IGNORECASE)
|
|
|
|
|
|
class ValidationError(Exception):
|
|
"""Raised when validation fails."""
|
|
pass
|
|
|
|
|
|
def encode_hostname(hostname):
|
|
"""
|
|
Encode hostname to ASCII using punycode (IDNA).
|
|
|
|
Args:
|
|
hostname: Hostname string, possibly with unicode characters.
|
|
|
|
Returns:
|
|
ASCII-encoded hostname.
|
|
|
|
Raises:
|
|
ValidationError: If hostname is invalid.
|
|
"""
|
|
hostname = hostname.lower().strip()
|
|
|
|
if not hostname:
|
|
raise ValidationError("Hostname cannot be empty")
|
|
|
|
# Remove trailing dot if present
|
|
if hostname.endswith('.'):
|
|
hostname = hostname[:-1]
|
|
|
|
if len(hostname) > 253:
|
|
raise ValidationError("Hostname too long (max 253 characters)")
|
|
|
|
try:
|
|
# Encode each label using IDNA
|
|
labels = hostname.split('.')
|
|
encoded_labels = []
|
|
|
|
for label in labels:
|
|
if not label:
|
|
raise ValidationError("Empty label in hostname")
|
|
|
|
# Encode to punycode if needed
|
|
try:
|
|
encoded = label.encode('idna').decode('ascii')
|
|
except UnicodeError as e:
|
|
raise ValidationError(f"Invalid label '{label}': {e}")
|
|
|
|
if len(encoded) > 63:
|
|
raise ValidationError(
|
|
f"Label '{label}' too long (max 63 characters)"
|
|
)
|
|
|
|
if not LABEL_PATTERN.match(encoded):
|
|
raise ValidationError(f"Invalid label format: '{label}'")
|
|
|
|
encoded_labels.append(encoded)
|
|
|
|
return '.'.join(encoded_labels)
|
|
|
|
except ValidationError:
|
|
raise
|
|
except Exception as e:
|
|
raise ValidationError(f"Invalid hostname '{hostname}': {e}")
|
|
|
|
|
|
def encode_zone(zone):
|
|
"""
|
|
Encode zone name to ASCII using punycode (IDNA).
|
|
|
|
Args:
|
|
zone: Zone name string, possibly with unicode characters.
|
|
|
|
Returns:
|
|
ASCII-encoded zone name.
|
|
|
|
Raises:
|
|
ValidationError: If zone is invalid.
|
|
"""
|
|
if not zone:
|
|
raise ValidationError("Zone cannot be empty")
|
|
|
|
# Zone validation is same as hostname
|
|
return encode_hostname(zone)
|
|
|
|
|
|
def validate_hostname_in_zone(hostname, zone):
|
|
"""
|
|
Validate and encode hostname and zone, ensuring hostname is in zone.
|
|
|
|
Args:
|
|
hostname: Hostname string.
|
|
zone: Zone string.
|
|
|
|
Returns:
|
|
Tuple of (encoded_hostname, encoded_zone).
|
|
|
|
Raises:
|
|
ValidationError: If validation fails.
|
|
"""
|
|
encoded_hostname = encode_hostname(hostname)
|
|
encoded_zone = encode_zone(zone)
|
|
|
|
# Check hostname ends with zone
|
|
if not (encoded_hostname == encoded_zone or
|
|
encoded_hostname.endswith('.' + encoded_zone)):
|
|
raise ValidationError(
|
|
f"Hostname '{hostname}' is not in zone '{zone}'"
|
|
)
|
|
|
|
return encoded_hostname, encoded_zone
|
|
|
|
|
|
def get_relative_name(hostname, zone):
|
|
"""
|
|
Get the relative name (hostname without zone suffix).
|
|
|
|
Args:
|
|
hostname: Encoded hostname.
|
|
zone: Encoded zone.
|
|
|
|
Returns:
|
|
Relative name (e.g., 'mypc' from 'mypc.dyn.example.com').
|
|
"""
|
|
if hostname == zone:
|
|
return '@'
|
|
|
|
suffix = '.' + zone
|
|
if hostname.endswith(suffix):
|
|
return hostname[:-len(suffix)]
|
|
|
|
return hostname
|