Sunil Mohan Adapa 62deda0ec7
dynamicdns: Allow updating both IPv4 and IPv6 records together
- Change the IPv6 checkbox into an option to select from IPv4 only, IPv6 only,
or IPv4 and IPv6. Update functional tests.

- Add type hints everywhere.

- Separate out the generic URL update method to its own module. Add unit tests.

- Migrate old configuration to new configuration on first read.

- Store multiple IP addresses in the status. Support showing old status format
of single IP address as well. Show multiple IP addresses in the status table.

- Handle errors during update using exceptions for clearer code.

- For generic URL based updates, capture return code, stdout and stdin in error
message.

Tests:

- Adding a new GnuDIP domain leads to configuration setting ip_type = ipv4

- When adding a new dynamic domain, when generic option is selected IP Address
  Type is shown. When GnuDIP is selected, the field is hidden.

- IP Address Type field is as expected. Options and description are as expected.

- When a new domain is added for generic domain, the value of ip_type is set as
  expected. Changing the value to other values works. Configuration is updated
  as expected. Option values and description are as expected.

- The column for IP Address is now 'IP Addresses'. When multiple IP addresses
  are present in the status, they are shown in multiple lines in the tables as
  <pre>. When no IP addresses are present, a '-' is shown in the column.

- When generic domain update fails, subprocess failure code, stderr and stdout
  are shown in the error message.

- Unit tests work.

- Functional tests work.

- After 5 minute period, attempts are made to update the domain

- For GnuDIP and generic method:

  - IP address lookup works for IPv4 and IPv6.

  - IP address updates works for IPv4 (tested) and IPv6 (untested).

  - In 'both' configuration, when IPv6 update fails, IPv4 still succeeds.

  - Updates are skipped if the record is up-to-date.

  - Successful updates are performed otherwise.

  - Wrong password leads to proper error being shown.

- When the old status is stored, it is updated during read and proper status is
  shown.

- When configuration has old use_ipv6 key, then it is migrated when newer
  version of the service is started. True value is converted as 'ipv6', False
  value is converted as 'ipv4'. 'null' key is properly removed.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
2026-08-15 08:49:43 -04:00

116 lines
3.7 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
from typing import Literal
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, ip_type: Literal['ipv4', 'ipv6'], domain: str,
username: str, password: str) -> 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.
"""
if ip_type == 'ipv6':
raise NotImplementedError
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)
if not result:
raise Exception('Server responded with an error')
return update_result.get('addr')