mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-08-26 12:46:08 +00:00
- 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>
116 lines
3.7 KiB
Python
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')
|