This commit is contained in:
Max 2026-05-25 09:40:15 +08:00 committed by GitHub
commit 500f2b92d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 203 additions and 5 deletions

View file

@ -38,6 +38,7 @@
<label>resourceId</label>
<type>text</type>
<advanced>true</advanced>
<style>optional_setting service_azure</style>
</field>
<field>
<id>account.username</id>
@ -51,6 +52,13 @@
<type>password</type>
<help>Password associated with this account</help>
</field>
<field>
<id>account.token_secret</id>
<label>Token secret</label>
<type>password</type>
<style>optional_setting service_desec-v4 service_desec-v6</style>
<help>Token secret for the domain. This is not your deSEC account password.</help>
</field>
<field>
<id>account.wildcard</id>
<label>Wildcard</label>
@ -58,6 +66,20 @@
<style>optional_setting service_dyndns2 service_woima service_cloudflare service_easydns service_custom</style>
<help>add a DNS wildcard CNAME record that points to the configured host.</help>
</field>
<field>
<id>account.prune_a</id>
<label>Prune A</label>
<type>checkbox</type>
<style>optional_setting service_desec-v6</style>
<help>Delete existing A (IPv4) records when this IPv6 domain updates. Leave unchecked to preserve them.</help>
</field>
<field>
<id>account.prune_aaaa</id>
<label>Prune AAAA</label>
<type>checkbox</type>
<style>optional_setting service_desec-v4</style>
<help>Delete existing AAAA (IPv6) records when this IPv4 domain updates. Leave unchecked to preserve them.</help>
</field>
<field>
<id>account.zone</id>
<label>Zone</label>

View file

@ -1,6 +1,6 @@
<model>
<mount>//OPNsense/DynDNS</mount>
<version>1.5.1</version>
<version>1.5.2</version>
<description>Dynamic DNS client</description>
<items>
<general>
@ -113,6 +113,16 @@
<Required>N</Required>
<Mask>/^[^\n]*$/</Mask>
</password>
<!--
Keep this as TextField to support cloning and to avoid
confusion about the state of migrated deSEC accounts. The
dialog still renders it as a protected password input.
Users who can access this GUI can trivially extract it anyway.
-->
<token_secret type="TextField">
<Required>N</Required>
<Mask>/^[^\n]*$/</Mask>
</token_secret>
<resourceId type="TextField">
<Required>N</Required>
<Mask>/^[^\n]*$/</Mask>
@ -131,6 +141,14 @@
<Default>0</Default>
<Required>Y</Required>
</wildcard>
<prune_a type="BooleanField">
<Default>0</Default>
<Required>Y</Required>
</prune_a>
<prune_aaaa type="BooleanField">
<Default>0</Default>
<Required>Y</Required>
</prune_aaaa>
<zone type="HostnameField">
<Required>N</Required>
<IpAllowed>N</IpAllowed>

View file

@ -0,0 +1,44 @@
<?php
namespace OPNsense\DynDNS\Migrations;
use OPNsense\Base\BaseModelMigration;
class M1_5_2 extends BaseModelMigration
{
public function run($model)
{
foreach ($model->accounts->account->iterateItems() as $account) {
$service = (string)$account->service;
if ($service == 'desec-v4' || $service == 'desec-v6') {
/*
* Older deSEC entries used "password" to store the token secret;
* the deSEC account password was never supported. Copy the value
* to the explicit field, but leave the old value so unchanged
* migrated entries can roll back. Entries created after this
* migration only use token_secret.
*/
$legacy_token = $account->password->getValue();
if ((string)$account->token_secret == '' && $legacy_token != '') {
$account->token_secret = $legacy_token;
}
/*
* deSEC never used "username" as an account login. If set at
* all, it could only mirror Hostname(s), which is already the
* authoritative update target.
*/
$account->username = '';
/*
* The original deSEC path deleted the opposite address family
* when "preserve" was omitted. Existing accounts keep that
* behavior; new accounts get the model defaults and preserve.
*/
if ($service == 'desec-v4') {
$account->prune_aaaa = '1';
} else {
$account->prune_a = '1';
}
}
}
}
}

View file

@ -57,8 +57,8 @@ POSSIBILITY OF SUCH DAMAGE.
updateServiceControlUI('dyndns');
}
});
$("#account\\.service").change(function(){
let service = $(this).val();
function updateAccountServiceControls() {
let service = $("#account\\.service").val();
$("#frm_DialogAccount .optional_setting").each(function(){
let this_item = $(this);
if (this_item.hasClass("service_"+service)) {
@ -69,6 +69,22 @@ POSSIBILITY OF SUCH DAMAGE.
this_item.prop( "disabled", true );
}
});
let is_desec = ['desec-v4', 'desec-v6'].includes(service);
// deSEC's dynDNS "username" was never an account login; the only
// useful value was a duplicate of Hostname(s), so migration clears
// it. Use Hostname(s) plus Token secret and keep legacy Password
// hidden for rollback without exposing or rewriting it.
$("#account\\.username, #account\\.password")
.prop("disabled", is_desec)
.closest("tr")
.toggle(!is_desec);
}
$("#account\\.service").change(updateAccountServiceControls);
$(document).ajaxComplete(function(event, xhr, settings) {
if (settings.url && settings.url.indexOf('/api/dyndns/accounts/get_item/') !== -1) {
updateAccountServiceControls();
}
});
$('#DialogAccount').on('shown.bs.modal', function (e) {
$("#account\\.service").change();

View file

@ -0,0 +1,97 @@
import syslog
import requests
from . import BaseAccount
class DeSEC(BaseAccount):
_checked_values = {'1', 'true', 'yes', 'on'}
_preserve_value = 'preserve'
_user_agent = 'OPNsense-dyndns'
_services = {
'desec-v4': {
'label': 'deSEC (IPv4)',
'server': 'update.dedyn.io',
'address_param': 'myipv4',
'other_param': 'myipv6',
'prune_setting': 'prune_aaaa'
},
'desec-v6': {
'label': 'deSEC (IPv6)',
'server': 'update6.dedyn.io',
'address_param': 'myipv6',
'other_param': 'myipv4',
'prune_setting': 'prune_a'
}
}
@classmethod
def known_services(cls):
return {key: item['label'] for key, item in cls._services.items()}
@classmethod
def match(cls, account):
return account.get('service') in cls._services
@classmethod
def _is_checked(cls, value):
return value is True or str(value).lower() in cls._checked_values
def _token_secret(self):
# Legacy deSEC accounts stored the token secret in "password"; a deSEC
# account password was never accepted by this backend.
return self.settings.get('token_secret') or self.settings.get('password') or ''
def _address_parameters(self, service_settings):
# deSEC prunes the other address family when its parameter is empty.
# New accounts preserve by default; migrated accounts may set prune_* to
# keep the historic behavior.
other_address = (
''
if self._is_checked(self.settings.get(service_settings['prune_setting'], False))
else self._preserve_value
)
return {
'hostname': self.settings.get('hostnames'),
service_settings['address_param']: str(self.current_address),
service_settings['other_param']: other_address
}
def _request_options(self, service_settings):
uri_proto = 'https' if self.settings.get('force_ssl', False) else 'http'
# deSEC's dynDNS "username" is the domain being updated, not an
# account login. We already send that via the hostname parameter, so use
# token authentication and keep the generic Username field irrelevant.
return {
'url': f"{uri_proto}://{service_settings['server']}/nic/update",
'params': self._address_parameters(service_settings),
'headers': {
'User-Agent': self._user_agent,
'Authorization': f"Token {self._token_secret()}"
}
}
def execute(self):
if not super().execute():
return False
service_settings = self._services[self.settings.get('service')]
req = requests.get(**self._request_options(service_settings))
if 200 <= req.status_code < 300:
if self.is_verbose:
syslog.syslog(
syslog.LOG_NOTICE,
"Account %s set new ip %s [%s]" % (self.description, self.current_address, req.text.strip())
)
self.update_state(address=self.current_address, status=req.text.split()[0] if req.text else '')
return True
syslog.syslog(
syslog.LOG_ERR,
"Account %s failed to set new ip %s [%d - %s]" % (
self.description, self.current_address, req.status_code, req.text.replace('\n', '')
)
)
return False

View file

@ -34,8 +34,6 @@ class DynDNS2(BaseAccount):
_services = {
'dyndns2': 'members.dyndns.org',
'desec-v4': 'update.dedyn.io',
'desec-v6': 'update6.dedyn.io',
'dns-o-matic': 'updates.dnsomatic.com',
'dynu': 'api.dynu.com',
'he-net': 'dyn.dns.he.net',

View file

@ -17,8 +17,11 @@
"resourceId": {{ account.resourceId | default('') | tojson }},
"username": {{ account.username | default('') | tojson }},
"password": {{ account.password | default('') | tojson }},
"token_secret": {{ account.token_secret | default('') | tojson }},
"hostnames": "{{ account.hostnames }}",
"wildcard": {{ "true" if account.wildcard == '1' else "false"}},
"prune_a": {{ "true" if account.prune_a|default('0') == '1' else "false"}},
"prune_aaaa": {{ "true" if account.prune_aaaa|default('0') == '1' else "false"}},
"zone": "{{ account.zone }}",
"checkip": "{{ account.checkip }}",
"interface": "{% if account.interface %}{{physical_interface(account.interface)}}{% endif %}",