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

153 lines
6.5 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Forms for the dynamicsdns module.
"""
from django import forms
from django.core import validators
from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy
from plinth import cfg
from plinth.modules.dynamicdns import get_config
from plinth.utils import format_lazy
class DomainForm(forms.Form):
"""Form to add/edit a domain in the Dynamic DNS client."""
help_update_url = \
gettext_lazy('The Variables &lt;User&gt;, &lt;Pass&gt;, &lt;Ip&gt;, '
'&lt;Domain&gt; may be used within the URL. For details '
'see the update URL templates of the example providers.')
help_service_type = \
gettext_lazy('Please choose an update protocol according to your '
'provider. If your provider does not support the GnuDIP '
'protocol or your provider is not listed you may use '
'the update URL of your provider.')
help_server = \
gettext_lazy('Please do not enter a URL here (like '
'"https://example.com/") but only the hostname of the '
'GnuDIP server (like "example.com").')
help_domain = format_lazy(
gettext_lazy('The public domain name you want to use to reach your '
'{box_name}.'), box_name=gettext_lazy(cfg.box_name))
help_disable_ssl_cert_check = \
gettext_lazy('Use this option if your provider uses self signed '
'certificates.')
help_use_http_basic_auth = \
gettext_lazy('If this option is selected, your username and password '
'will be used for HTTP basic authentication.')
help_password = \
gettext_lazy('Leave this field empty if you want to keep your '
'current password.')
help_username = \
gettext_lazy('The username that was used when the account was '
'created.')
help_ip_type = \
gettext_lazy('Select based on what your ISP provides: a public IPv4 '
'address, a public IPv6 address, or both.')
provider_choices = (('gnudip', gettext_lazy('GnuDIP')),
('noip.com', 'noip.com'), ('freedns.afraid.org',
'freedns.afraid.org'),
('other', gettext_lazy('Other update URL')))
ip_type_choices = (('ipv4', gettext_lazy('IPv4 address only')),
('ipv6', gettext_lazy('IPv6 address only')),
('both', gettext_lazy('Both IPv4 and IPv6 addresses')))
service_type = forms.ChoiceField(label=gettext_lazy('Service Type'),
help_text=help_service_type,
choices=provider_choices)
server = forms.CharField(
label=gettext_lazy('GnuDIP Server Address'), required=False,
help_text=help_server, validators=[
validators.RegexValidator(r'^[\w-]{1,63}(\.[\w-]{1,63})*$',
gettext_lazy('Invalid server name'))
])
update_url = forms.CharField(label=gettext_lazy('Update URL'),
required=False, help_text=help_update_url)
disable_ssl_cert_check = forms.BooleanField(
label=gettext_lazy('Accept all SSL certificates'),
help_text=help_disable_ssl_cert_check, required=False)
use_http_basic_auth = forms.BooleanField(
label=gettext_lazy('Use HTTP basic authentication'),
help_text=help_use_http_basic_auth, required=False)
domain = forms.CharField(
label=gettext_lazy('Domain Name'), help_text=help_domain,
required=True, validators=[
validators.RegexValidator(r'^[\w-]{1,63}(\.[\w-]{1,63})*$',
gettext_lazy('Invalid domain name'))
])
username = forms.CharField(label=gettext_lazy('Username'), required=False,
help_text=help_username)
password = forms.CharField(label=gettext_lazy('Password'),
widget=forms.PasswordInput(), required=False,
help_text=help_password)
show_password = forms.BooleanField(label=gettext_lazy('Show password'),
required=False)
ip_type = forms.ChoiceField(label=gettext_lazy('IP Address Type'),
help_text=help_ip_type,
choices=ip_type_choices)
def clean(self):
"""Further validate and transform field data."""
cleaned_data = super().clean()
update_url = cleaned_data.get('update_url')
password = cleaned_data.get('password')
service_type = cleaned_data.get('service_type')
old_password = self.initial.get('password')
if not password:
# If password is not set, use old password
cleaned_data['password'] = old_password
message = _('This field is required.')
if service_type == 'gnudip':
for field_name in ['server', 'username', 'password']:
if not cleaned_data.get(field_name):
self.add_error(field_name, message)
else:
if not update_url:
self.add_error('update_url', message)
param_map = (('username', '<User>'), ('password', '<Pass>'))
for field_name, param in param_map:
if (update_url and param in update_url
and not cleaned_data.get(field_name)):
self.add_error(field_name, message)
if cleaned_data.get('use_http_basic_auth'):
for field_name in ('username', 'password'):
if not cleaned_data.get(field_name):
self.add_error(field_name, message)
if service_type == 'gnudip':
cleaned_data['ip_type'] = 'ipv4'
del cleaned_data['show_password']
return cleaned_data
def clean_domain(self):
"""Validate the domain field."""
# Domain name is not case sensitive, but Let's Encrypt
# certificate paths use lower-case domain name.
domain = self.cleaned_data['domain'].lower()
initial_domain = self.initial.get('domain')
if initial_domain != domain and domain in get_config()['domains']:
raise ValidationError(_('Domain already exists.'))
return domain