Sunil Mohan Adapa a10ba40001
dynamicdns: Use only IPv4 for GnuDIP protocol
- The following messages was seen on the ddns.freedombox.org server:
"Unserviceable IP address from <ipv6_address>: user <username>.fbx.one - IP:
<ipv6_address>". This is due to code that checks for validity of incoming IP
address and fails. The current configuration only handles IPv4 address. Even if
this restriction is lifted, GnuDIP code does not contain code to add/remove AAAA
records.

- Fix this by forcing GnuDIP HTTP update requests to go on IPv4.

Tests:

- Copy the code for _request_get_ipv4() into a python3 console and run
_request_get_ipv4('https://ddns.freedombox.org/ip'). Do this on a dual stack
machine with both public IPv4 and IPv6 addresses. Only IPv4 address returned.
Changing the AF to AF_INET6 returns only the IPv6 address.

- Take a test DDNS account offline. Configure it in FreedomBox stable VM. The IP
address is properly updated.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
2025-11-21 22:03:10 -05:00

109 lines
3.5 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-or-later
"""
GnuDIP client for updating Dynamic DNS records.
"""
import hashlib
import logging
import socket
from html.parser import HTMLParser
import requests
logger = logging.getLogger(__name__)
class MetaTagParser(HTMLParser):
"""Extracts name and content from HTML meta tags as a dictionary."""
def __init__(self) -> None:
"""Initialize the meta tags."""
super().__init__()
self.meta: dict[str, str] = {}
def handle_starttag(self, tag: str,
attrs: list[tuple[str, str | None]]) -> None:
"""Handle encountering an opening HTML tag during parsing."""
if tag.lower() == 'meta':
attr_dict = dict(attrs)
name = attr_dict.get('name')
content = attr_dict.get('content')
if name and content:
self.meta[name] = content
def _extract_content_from_meta_tags(html: str) -> dict[str, str]:
"""Return a dict of {name: content} for all meta tags in the HTML."""
parser = MetaTagParser()
parser.feed(html)
return parser.meta
def _check_required_keys(dictionary: dict[str, str], keys: list[str]) -> None:
missing_keys = [key for key in keys if key not in dictionary]
if missing_keys:
raise ValueError(
f"Missing required keys in response: {', '.join(missing_keys)}")
def _request_get_ipv4(*args, **kwargs):
"""Make a IPv4-only request.
XXX: This monkey-patches socket.getaddrinfo which may causes issues when
running multiple threads. With urllib3 >= 2.4 (Trixie has 2.3), it is
possible to implement more cleanly. Use a session for requests library. In
the session add custom adapter for https:. In the adapter, override
creation of pool manager, and pass socket_family parameter.
"""
original = socket.getaddrinfo
def getaddrinfo_ipv4(*args, **kwargs):
return original(args[0], args[1], socket.AF_INET, *args[3:], **kwargs)
socket.getaddrinfo = getaddrinfo_ipv4
try:
return requests.get(*args, **kwargs)
finally:
socket.getaddrinfo = original
def update(server: str, domain: str, username: str,
password: str) -> tuple[bool, str | None]:
"""Update Dynamic DNS record using GnuDIP protocol.
Protocol documentation:
https://gnudip2.sourceforge.net/gnudip-www/latest/gnudip/html/protocol.html
GnuDIP at least as deployed on the FreedomBox foundation servers does not
support IPv6 (it does have any code to update AAAA records). So, make a
request only using IPv4 stack.
"""
domain = domain.removeprefix(username + '.')
password_digest = hashlib.md5(password.encode()).hexdigest()
http_server = f'https://{server}/gnudip/cgi-bin/gdipupdt.cgi'
response = _request_get_ipv4(http_server)
salt_response = _extract_content_from_meta_tags(response.text)
_check_required_keys(salt_response, ['salt', 'time', 'sign'])
salt = salt_response['salt']
password_digest = hashlib.md5(
f'{password_digest}.{salt}'.encode()).hexdigest()
query_params = {
'salt': salt,
'time': salt_response['time'],
'sign': salt_response['sign'],
'user': username,
'domn': domain,
'pass': password_digest,
'reqc': '2'
}
update_response = _request_get_ipv4(http_server, params=query_params)
update_result = _extract_content_from_meta_tags(update_response.text)
_check_required_keys(update_result, ['retc'])
result = (int(update_result['retc']) == 0)
return result, update_result.get('addr')