summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Breunig <christian@breunig.cc>2023-12-01 08:20:02 +0100
committerGitHub <noreply@github.com>2023-12-01 08:20:02 +0100
commita6d4dd18e256c4bb1b62f53209b592468d499766 (patch)
tree697688318da1afeb6e4446cbdf036df707ae1375
parent12957f9f1aa4ab35c460c03dfb3e213639b25a71 (diff)
parent2d9b0055d1235f377bd2bf392ee48e4363448eb4 (diff)
downloadvyos-1x-a6d4dd18e256c4bb1b62f53209b592468d499766.tar.gz
vyos-1x-a6d4dd18e256c4bb1b62f53209b592468d499766.zip
Merge pull request #2554 from indrajitr/ddclient-update-20231128
ddclient: T5791: Update dynamic dns configuration path for consistency
-rw-r--r--data/templates/dns-dynamic/ddclient.conf.j248
-rw-r--r--interface-definitions/dns-dynamic.xml.in226
-rw-r--r--interface-definitions/include/dns/dynamic-service-host-name-server.xml.i35
-rw-r--r--interface-definitions/include/dns/dynamic-service-wait-expiry-time.xml.i28
-rw-r--r--interface-definitions/include/dns/dynamic-service-zone.xml.i14
-rw-r--r--interface-definitions/include/version/dns-dynamic-version.xml.i2
-rwxr-xr-xsmoketest/scripts/cli/test_service_dns_dynamic.py130
-rwxr-xr-xsrc/completion/list_ddclient_protocols.sh2
-rwxr-xr-xsrc/conf_mode/dns_dynamic.py92
-rw-r--r--src/migration-scripts/dns-dynamic/2-to-385
-rwxr-xr-xsrc/validators/ddclient-protocol2
11 files changed, 354 insertions, 310 deletions
diff --git a/data/templates/dns-dynamic/ddclient.conf.j2 b/data/templates/dns-dynamic/ddclient.conf.j2
index 356b8d0d0..30afb9e64 100644
--- a/data/templates/dns-dynamic/ddclient.conf.j2
+++ b/data/templates/dns-dynamic/ddclient.conf.j2
@@ -29,44 +29,28 @@ cache={{ config_file | replace('.conf', '.cache') }}
{# ddclient default (web=dyndns) doesn't support ssl and results in process lockup #}
web=googledomains
{# ddclient default (use=ip) results in confusing warning message in log #}
-use=disabled
+use=no
-{% if address is vyos_defined %}
-{% for address, service_cfg in address.items() %}
-{% if service_cfg.rfc2136 is vyos_defined %}
-{% for name, config in service_cfg.rfc2136.items() %}
-{% if config.description is vyos_defined %}
+{% if name is vyos_defined %}
+{% for service, config in name.items() %}
+{% if config.description is vyos_defined %}
# {{ config.description }}
-{% endif %}
-{% for host in config.host_name if config.host_name is vyos_defined %}
-
-# RFC2136 dynamic DNS configuration for {{ name }}: [{{ config.zone }}, {{ host }}]
-{# Don't append 'new-style' compliant suffix ('usev4', 'usev6', 'ifv4', 'ifv6' etc.)
- to the properties since 'nsupdate' doesn't support that yet. #}
-{{ render_config(host, address, service_cfg.web_options,
- protocol='nsupdate', server=config.server, zone=config.zone,
- password=config.key, ttl=config.ttl) }}
-{% endfor %}
-{% endfor %}
{% endif %}
-{% if service_cfg.service is vyos_defined %}
-{% for name, config in service_cfg.service.items() %}
-{% if config.description is vyos_defined %}
-
-# {{ config.description }}
-{% endif %}
-{% for host in config.host_name if config.host_name is vyos_defined %}
-{% set ip_suffixes = ['v4', 'v6'] if config.ip_version == 'both'
- else [config.ip_version[2:]] %}
+{% for host in config.host_name if config.host_name is vyos_defined %}
+{# ip_suffixes can be either of ['v4'], ['v6'], ['v4', 'v6'] for all protocols except 'nsupdate'
+ ip_suffixes must be [''] for nsupdate since it doesn't support usevX/wantipvX yet #}
+{% set ip_suffixes = ['v4', 'v6'] if config.ip_version == 'both'
+ else ([config.ip_version[2:]] if config.protocol != 'nsupdate'
+ else ['']) %}
+{% set password = config.key if config.protocol == 'nsupdate'
+ else config.password %}
-# Web service dynamic DNS configuration for {{ name }}: [{{ config.protocol }}, {{ host }}]
-{{ render_config(host, address, service_cfg.web_options, ip_suffixes,
+# Web service dynamic DNS configuration for {{ service }}: [{{ config.protocol }}, {{ host }}]
+{{ render_config(host, config.address, config.web_options, ip_suffixes,
protocol=config.protocol, server=config.server, zone=config.zone,
- login=config.username, password=config.password, ttl=config.ttl,
+ login=config.username, password=password, ttl=config.ttl,
min_interval=config.wait_time, max_interval=config.expiry_time) }}
-{% endfor %}
-{% endfor %}
-{% endif %}
+{% endfor %}
{% endfor %}
{% endif %}
diff --git a/interface-definitions/dns-dynamic.xml.in b/interface-definitions/dns-dynamic.xml.in
index 32c5af9b6..f089f0e52 100644
--- a/interface-definitions/dns-dynamic.xml.in
+++ b/interface-definitions/dns-dynamic.xml.in
@@ -12,27 +12,48 @@
<help>Dynamic DNS</help>
</properties>
<children>
- <tagNode name="address">
+ <tagNode name="name">
<properties>
- <help>Obtain IP address to send Dynamic DNS update for</help>
+ <help>Dynamic DNS configuration</help>
<valueHelp>
<format>txt</format>
- <description>Use interface to obtain the IP address</description>
+ <description>Dynamic DNS service name</description>
</valueHelp>
- <valueHelp>
- <format>web</format>
- <description>Use HTTP(S) web request to obtain the IP address</description>
- </valueHelp>
- <completionHelp>
- <script>${vyos_completion_dir}/list_interfaces</script>
- <list>web</list>
- </completionHelp>
- <constraint>
- #include <include/constraint/interface-name.xml.i>
- <regex>web</regex>
- </constraint>
</properties>
<children>
+ #include <include/generic-description.xml.i>
+ <leafNode name="protocol">
+ <properties>
+ <help>ddclient protocol used for Dynamic DNS service</help>
+ <completionHelp>
+ <script>${vyos_completion_dir}/list_ddclient_protocols.sh</script>
+ </completionHelp>
+ <constraint>
+ <validator name="ddclient-protocol"/>
+ </constraint>
+ </properties>
+ </leafNode>
+ <leafNode name="address">
+ <properties>
+ <help>Obtain IP address to send Dynamic DNS update for</help>
+ <valueHelp>
+ <format>txt</format>
+ <description>Use interface to obtain the IP address</description>
+ </valueHelp>
+ <valueHelp>
+ <format>web</format>
+ <description>Use HTTP(S) web request to obtain the IP address</description>
+ </valueHelp>
+ <completionHelp>
+ <script>${vyos_completion_dir}/list_interfaces</script>
+ <list>web</list>
+ </completionHelp>
+ <constraint>
+ #include <include/constraint/interface-name.xml.i>
+ <regex>web</regex>
+ </constraint>
+ </properties>
+ </leafNode>
<node name="web-options">
<properties>
<help>Options when using HTTP(S) web request to obtain the IP address</help>
@@ -50,88 +71,117 @@
</leafNode>
</children>
</node>
- <tagNode name="rfc2136">
+ <leafNode name="ip-version">
<properties>
- <help>RFC2136 nsupdate configuration</help>
+ <help>IP address version to use</help>
<valueHelp>
- <format>txt</format>
- <description>RFC2136 nsupdate service name</description>
+ <format>_ipv4</format>
+ <description>Use only IPv4 address</description>
+ </valueHelp>
+ <valueHelp>
+ <format>_ipv6</format>
+ <description>Use only IPv6 address</description>
</valueHelp>
+ <valueHelp>
+ <format>both</format>
+ <description>Use both IPv4 and IPv6 address</description>
+ </valueHelp>
+ <completionHelp>
+ <list>ipv4 ipv6 both</list>
+ </completionHelp>
+ <constraint>
+ <regex>(ipv[46]|both)</regex>
+ </constraint>
+ <constraintErrorMessage>IP Version must be literal 'ipv4', 'ipv6' or 'both'</constraintErrorMessage>
</properties>
- <children>
- #include <include/generic-description.xml.i>
- #include <include/dns/dynamic-service-host-name-server.xml.i>
- #include <include/dns/dynamic-service-wait-expiry-time.xml.i>
- <leafNode name="key">
- <properties>
- <help>File containing the TSIG secret key shared with remote DNS server</help>
- <valueHelp>
- <format>filename</format>
- <description>File in /config/auth directory</description>
- </valueHelp>
- <constraint>
- <validator name="file-path" argument="--strict --parent-dir /config/auth"/>
- </constraint>
- </properties>
- </leafNode>
- #include <include/dns/time-to-live.xml.i>
- #include <include/dns/dynamic-service-zone.xml.i>
- </children>
- </tagNode>
- <tagNode name="service">
+ <defaultValue>ipv4</defaultValue>
+ </leafNode>
+ <leafNode name="host-name">
+ <properties>
+ <help>Hostname to register with Dynamic DNS service</help>
+ <constraint>
+ #include <include/constraint/host-name.xml.i>
+ <regex>(\@|\*)[-.A-Za-z0-9]*</regex>
+ </constraint>
+ <constraintErrorMessage>Host-name must be alphanumeric, can contain hyphens and can be prefixed with '@' or '*'</constraintErrorMessage>
+ <multi/>
+ </properties>
+ </leafNode>
+ <leafNode name="server">
<properties>
- <help>Dynamic DNS configuration</help>
+ <help>Remote Dynamic DNS server to send updates to</help>
+ <valueHelp>
+ <format>ipv4</format>
+ <description>IPv4 address of the remote server</description>
+ </valueHelp>
+ <valueHelp>
+ <format>ipv6</format>
+ <description>IPv6 address of the remote server</description>
+ </valueHelp>
+ <valueHelp>
+ <format>hostname</format>
+ <description>Fully qualified domain name of the remote server</description>
+ </valueHelp>
+ <constraint>
+ <validator name="ip-address"/>
+ <validator name="fqdn"/>
+ </constraint>
+ <constraintErrorMessage>Remote server must be IP address or fully qualified domain name</constraintErrorMessage>
+ </properties>
+ </leafNode>
+ <leafNode name="zone">
+ <properties>
+ <help>DNS zone to be updated</help>
<valueHelp>
<format>txt</format>
- <description>Dynamic DNS service name</description>
+ <description>Name of DNS zone</description>
</valueHelp>
+ <constraint>
+ <validator name="fqdn"/>
+ </constraint>
</properties>
- <children>
- #include <include/generic-description.xml.i>
- #include <include/dns/dynamic-service-host-name-server.xml.i>
- #include <include/dns/dynamic-service-wait-expiry-time.xml.i>
- #include <include/generic-username.xml.i>
- #include <include/generic-password.xml.i>
- #include <include/dns/time-to-live.xml.i>
- <leafNode name="protocol">
- <properties>
- <help>ddclient protocol used for Dynamic DNS service</help>
- <completionHelp>
- <script>${vyos_completion_dir}/list_ddclient_protocols.sh</script>
- </completionHelp>
- <constraint>
- <validator name="ddclient-protocol"/>
- </constraint>
- </properties>
- </leafNode>
- #include <include/dns/dynamic-service-zone.xml.i>
- <leafNode name="ip-version">
- <properties>
- <help>IP address version to use</help>
- <valueHelp>
- <format>_ipv4</format>
- <description>Use only IPv4 address</description>
- </valueHelp>
- <valueHelp>
- <format>_ipv6</format>
- <description>Use only IPv6 address</description>
- </valueHelp>
- <valueHelp>
- <format>both</format>
- <description>Use both IPv4 and IPv6 address</description>
- </valueHelp>
- <completionHelp>
- <list>ipv4 ipv6 both</list>
- </completionHelp>
- <constraint>
- <regex>(ipv[46]|both)</regex>
- </constraint>
- <constraintErrorMessage>IP Version must be literal 'ipv4', 'ipv6' or 'both'</constraintErrorMessage>
- </properties>
- <defaultValue>ipv4</defaultValue>
- </leafNode>
- </children>
- </tagNode>
+ </leafNode>
+ #include <include/generic-username.xml.i>
+ #include <include/generic-password.xml.i>
+ <leafNode name="key">
+ <properties>
+ <help>File containing TSIG authentication key for RFC2136 nsupdate on remote DNS server</help>
+ <valueHelp>
+ <format>filename</format>
+ <description>File in /config/auth directory</description>
+ </valueHelp>
+ <constraint>
+ <validator name="file-path" argument="--strict --parent-dir /config/auth"/>
+ </constraint>
+ </properties>
+ </leafNode>
+ #include <include/dns/time-to-live.xml.i>
+ <leafNode name="wait-time">
+ <properties>
+ <help>Time in seconds to wait between update attempts</help>
+ <valueHelp>
+ <format>u32:60-86400</format>
+ <description>Time in seconds</description>
+ </valueHelp>
+ <constraint>
+ <validator name="numeric" argument="--range 60-86400"/>
+ </constraint>
+ <constraintErrorMessage>Wait time must be between 60 and 86400 seconds</constraintErrorMessage>
+ </properties>
+ </leafNode>
+ <leafNode name="expiry-time">
+ <properties>
+ <help>Time in seconds for the hostname to be marked expired in cache</help>
+ <valueHelp>
+ <format>u32:300-2160000</format>
+ <description>Time in seconds</description>
+ </valueHelp>
+ <constraint>
+ <validator name="numeric" argument="--range 300-2160000"/>
+ </constraint>
+ <constraintErrorMessage>Expiry time must be between 300 and 2160000 seconds</constraintErrorMessage>
+ </properties>
+ </leafNode>
</children>
</tagNode>
<leafNode name="interval">
diff --git a/interface-definitions/include/dns/dynamic-service-host-name-server.xml.i b/interface-definitions/include/dns/dynamic-service-host-name-server.xml.i
deleted file mode 100644
index 9dd14f97c..000000000
--- a/interface-definitions/include/dns/dynamic-service-host-name-server.xml.i
+++ /dev/null
@@ -1,35 +0,0 @@
-<!-- include start from dns/dynamic-service-host-name-server.xml.i -->
-<leafNode name="host-name">
- <properties>
- <help>Hostname to register with Dynamic DNS service</help>
- <constraint>
- #include <include/constraint/host-name.xml.i>
- <regex>(\@|\*)[-.A-Za-z0-9]*</regex>
- </constraint>
- <constraintErrorMessage>Host-name must be alphanumeric, can contain hyphens and can be prefixed with '@' or '*'</constraintErrorMessage>
- <multi/>
- </properties>
-</leafNode>
-<leafNode name="server">
- <properties>
- <help>Remote Dynamic DNS server to send updates to</help>
- <valueHelp>
- <format>ipv4</format>
- <description>IPv4 address of the remote server</description>
- </valueHelp>
- <valueHelp>
- <format>ipv6</format>
- <description>IPv6 address of the remote server</description>
- </valueHelp>
- <valueHelp>
- <format>hostname</format>
- <description>Fully qualified domain name of the remote server</description>
- </valueHelp>
- <constraint>
- <validator name="ip-address"/>
- <validator name="fqdn"/>
- </constraint>
- <constraintErrorMessage>Remote server must be IP address or fully qualified domain name</constraintErrorMessage>
- </properties>
-</leafNode>
-<!-- include end -->
diff --git a/interface-definitions/include/dns/dynamic-service-wait-expiry-time.xml.i b/interface-definitions/include/dns/dynamic-service-wait-expiry-time.xml.i
deleted file mode 100644
index 866690cbe..000000000
--- a/interface-definitions/include/dns/dynamic-service-wait-expiry-time.xml.i
+++ /dev/null
@@ -1,28 +0,0 @@
-<!-- include start from dns/dynamic-service-wait-expiry-time.xml.i -->
-<leafNode name="wait-time">
- <properties>
- <help>Time in seconds to wait between update attempts</help>
- <valueHelp>
- <format>u32:60-86400</format>
- <description>Time in seconds</description>
- </valueHelp>
- <constraint>
- <validator name="numeric" argument="--range 60-86400"/>
- </constraint>
- <constraintErrorMessage>Wait time must be between 60 and 86400 seconds</constraintErrorMessage>
- </properties>
-</leafNode>
-<leafNode name="expiry-time">
- <properties>
- <help>Time in seconds for the hostname to be marked expired in cache</help>
- <valueHelp>
- <format>u32:300-2160000</format>
- <description>Time in seconds</description>
- </valueHelp>
- <constraint>
- <validator name="numeric" argument="--range 300-2160000"/>
- </constraint>
- <constraintErrorMessage>Expiry time must be between 300 and 2160000 seconds</constraintErrorMessage>
- </properties>
-</leafNode>
-<!-- include end -->
diff --git a/interface-definitions/include/dns/dynamic-service-zone.xml.i b/interface-definitions/include/dns/dynamic-service-zone.xml.i
deleted file mode 100644
index 0cc00468f..000000000
--- a/interface-definitions/include/dns/dynamic-service-zone.xml.i
+++ /dev/null
@@ -1,14 +0,0 @@
-<!-- include start from dns/dynamic-service-zone.xml.i -->
-<leafNode name="zone">
- <properties>
- <help>DNS zone to be updated</help>
- <valueHelp>
- <format>txt</format>
- <description>Name of DNS zone</description>
- </valueHelp>
- <constraint>
- <validator name="fqdn"/>
- </constraint>
- </properties>
-</leafNode>
-<!-- include end -->
diff --git a/interface-definitions/include/version/dns-dynamic-version.xml.i b/interface-definitions/include/version/dns-dynamic-version.xml.i
index 7bdb90a35..773a6ab51 100644
--- a/interface-definitions/include/version/dns-dynamic-version.xml.i
+++ b/interface-definitions/include/version/dns-dynamic-version.xml.i
@@ -1,3 +1,3 @@
<!-- include start from include/version/dns-dynamic-version.xml.i -->
-<syntaxVersion component='dns-dynamic' version='2'></syntaxVersion>
+<syntaxVersion component='dns-dynamic' version='3'></syntaxVersion>
<!-- include end -->
diff --git a/smoketest/scripts/cli/test_service_dns_dynamic.py b/smoketest/scripts/cli/test_service_dns_dynamic.py
index fe213a8ae..cb3d90593 100755
--- a/smoketest/scripts/cli/test_service_dns_dynamic.py
+++ b/smoketest/scripts/cli/test_service_dns_dynamic.py
@@ -32,6 +32,7 @@ DDCLIENT_PID = '/run/ddclient/ddclient.pid'
DDCLIENT_PNAME = 'ddclient'
base_path = ['service', 'dns', 'dynamic']
+name_path = base_path + ['name']
server = 'ddns.vyos.io'
hostname = 'test.ddns.vyos.io'
zone = 'vyos.io'
@@ -58,38 +59,38 @@ class TestServiceDDNS(VyOSUnitTestSHIM.TestCase):
# IPv4 standard DDNS service configuration
def test_01_dyndns_service_standard(self):
- svc_path = ['address', interface, 'service']
services = {'cloudflare': {'protocol': 'cloudflare'},
'freedns': {'protocol': 'freedns', 'username': username},
'zoneedit': {'protocol': 'zoneedit1', 'username': username}}
for svc, details in services.items():
- self.cli_set(base_path + svc_path + [svc, 'host-name', hostname])
- self.cli_set(base_path + svc_path + [svc, 'password', password])
- self.cli_set(base_path + svc_path + [svc, 'zone', zone])
- self.cli_set(base_path + svc_path + [svc, 'ttl', ttl])
+ self.cli_set(name_path + [svc, 'address', interface])
+ self.cli_set(name_path + [svc, 'host-name', hostname])
+ self.cli_set(name_path + [svc, 'password', password])
+ self.cli_set(name_path + [svc, 'zone', zone])
+ self.cli_set(name_path + [svc, 'ttl', ttl])
for opt, value in details.items():
- self.cli_set(base_path + svc_path + [svc, opt, value])
+ self.cli_set(name_path + [svc, opt, value])
# 'zone' option is supported and required by 'cloudfare', but not 'freedns' and 'zoneedit'
- self.cli_set(base_path + svc_path + [svc, 'zone', zone])
+ self.cli_set(name_path + [svc, 'zone', zone])
if details['protocol'] == 'cloudflare':
pass
else:
# exception is raised for unsupported ones
with self.assertRaises(ConfigSessionError):
self.cli_commit()
- self.cli_delete(base_path + svc_path + [svc, 'zone'])
+ self.cli_delete(name_path + [svc, 'zone'])
# 'ttl' option is supported by 'cloudfare', but not 'freedns' and 'zoneedit'
- self.cli_set(base_path + svc_path + [svc, 'ttl', ttl])
+ self.cli_set(name_path + [svc, 'ttl', ttl])
if details['protocol'] == 'cloudflare':
pass
else:
# exception is raised for unsupported ones
with self.assertRaises(ConfigSessionError):
self.cli_commit()
- self.cli_delete(base_path + svc_path + [svc, 'ttl'])
+ self.cli_delete(name_path + [svc, 'ttl'])
# commit changes
self.cli_commit()
@@ -113,7 +114,7 @@ class TestServiceDDNS(VyOSUnitTestSHIM.TestCase):
# IPv6 only DDNS service configuration
def test_02_dyndns_service_ipv6(self):
interval = '60'
- svc_path = ['address', interface, 'service', 'dynv6']
+ svc_path = name_path + ['dynv6']
proto = 'dyndns2'
ip_version = 'ipv6'
wait_time = '600'
@@ -121,19 +122,20 @@ class TestServiceDDNS(VyOSUnitTestSHIM.TestCase):
expiry_time_bad = '360'
self.cli_set(base_path + ['interval', interval])
- self.cli_set(base_path + svc_path + ['ip-version', ip_version])
- self.cli_set(base_path + svc_path + ['protocol', proto])
- self.cli_set(base_path + svc_path + ['server', server])
- self.cli_set(base_path + svc_path + ['username', username])
- self.cli_set(base_path + svc_path + ['password', password])
- self.cli_set(base_path + svc_path + ['host-name', hostname])
- self.cli_set(base_path + svc_path + ['wait-time', wait_time])
+ self.cli_set(svc_path + ['address', interface])
+ self.cli_set(svc_path + ['ip-version', ip_version])
+ self.cli_set(svc_path + ['protocol', proto])
+ self.cli_set(svc_path + ['server', server])
+ self.cli_set(svc_path + ['username', username])
+ self.cli_set(svc_path + ['password', password])
+ self.cli_set(svc_path + ['host-name', hostname])
+ self.cli_set(svc_path + ['wait-time', wait_time])
# expiry-time must be greater than wait-time, exception is raised otherwise
- self.cli_set(base_path + svc_path + ['expiry-time', expiry_time_bad])
with self.assertRaises(ConfigSessionError):
+ self.cli_set(svc_path + ['expiry-time', expiry_time_bad])
self.cli_commit()
- self.cli_set(base_path + svc_path + ['expiry-time', expiry_time_good])
+ self.cli_set(svc_path + ['expiry-time', expiry_time_good])
# commit changes
self.cli_commit()
@@ -152,25 +154,25 @@ class TestServiceDDNS(VyOSUnitTestSHIM.TestCase):
# IPv4+IPv6 dual DDNS service configuration
def test_03_dyndns_service_dual_stack(self):
- svc_path = ['address', interface, 'service']
services = {'cloudflare': {'protocol': 'cloudflare', 'zone': zone},
'freedns': {'protocol': 'freedns', 'username': username},
'google': {'protocol': 'googledomains', 'username': username}}
ip_version = 'both'
for name, details in services.items():
- self.cli_set(base_path + svc_path + [name, 'host-name', hostname])
- self.cli_set(base_path + svc_path + [name, 'password', password])
+ self.cli_set(name_path + [name, 'address', interface])
+ self.cli_set(name_path + [name, 'host-name', hostname])
+ self.cli_set(name_path + [name, 'password', password])
for opt, value in details.items():
- self.cli_set(base_path + svc_path + [name, opt, value])
+ self.cli_set(name_path + [name, opt, value])
# Dual stack is supported by 'cloudfare' and 'freedns' but not 'googledomains'
# exception is raised for unsupported ones
- self.cli_set(base_path + svc_path + [name, 'ip-version', ip_version])
+ self.cli_set(name_path + [name, 'ip-version', ip_version])
if details['protocol'] not in ['cloudflare', 'freedns']:
with self.assertRaises(ConfigSessionError):
self.cli_commit()
- self.cli_delete(base_path + svc_path + [name, 'ip-version'])
+ self.cli_delete(name_path + [name, 'ip-version'])
# commit changes
self.cli_commit()
@@ -197,16 +199,19 @@ class TestServiceDDNS(VyOSUnitTestSHIM.TestCase):
def test_04_dyndns_rfc2136(self):
# Check if DDNS service can be configured and runs
- svc_path = ['address', interface, 'rfc2136', 'vyos']
+ svc_path = name_path + ['vyos']
+ proto = 'nsupdate'
with tempfile.NamedTemporaryFile(prefix='/config/auth/') as key_file:
key_file.write(b'S3cretKey')
- self.cli_set(base_path + svc_path + ['server', server])
- self.cli_set(base_path + svc_path + ['zone', zone])
- self.cli_set(base_path + svc_path + ['key', key_file.name])
- self.cli_set(base_path + svc_path + ['ttl', ttl])
- self.cli_set(base_path + svc_path + ['host-name', hostname])
+ self.cli_set(svc_path + ['address', interface])
+ self.cli_set(svc_path + ['protocol', proto])
+ self.cli_set(svc_path + ['server', server])
+ self.cli_set(svc_path + ['zone', zone])
+ self.cli_set(svc_path + ['key', key_file.name])
+ self.cli_set(svc_path + ['ttl', ttl])
+ self.cli_set(svc_path + ['host-name', hostname])
# commit changes
self.cli_commit()
@@ -215,7 +220,7 @@ class TestServiceDDNS(VyOSUnitTestSHIM.TestCase):
ddclient_conf = cmd(f'sudo cat {DDCLIENT_CONF}')
self.assertIn(f'use=if', ddclient_conf)
self.assertIn(f'if={interface}', ddclient_conf)
- self.assertIn(f'protocol=nsupdate', ddclient_conf)
+ self.assertIn(f'protocol={proto}', ddclient_conf)
self.assertIn(f'server={server}', ddclient_conf)
self.assertIn(f'zone={zone}', ddclient_conf)
self.assertIn(f'password=\'{key_file.name}\'', ddclient_conf)
@@ -223,16 +228,17 @@ class TestServiceDDNS(VyOSUnitTestSHIM.TestCase):
def test_05_dyndns_hostname(self):
# Check if DDNS service can be configured and runs
- svc_path = ['address', interface, 'service', 'namecheap']
+ svc_path = name_path + ['namecheap']
proto = 'namecheap'
hostnames = ['@', 'www', hostname, f'@.{hostname}']
for name in hostnames:
- self.cli_set(base_path + svc_path + ['protocol', proto])
- self.cli_set(base_path + svc_path + ['server', server])
- self.cli_set(base_path + svc_path + ['username', username])
- self.cli_set(base_path + svc_path + ['password', password])
- self.cli_set(base_path + svc_path + ['host-name', name])
+ self.cli_set(svc_path + ['address', interface])
+ self.cli_set(svc_path + ['protocol', proto])
+ self.cli_set(svc_path + ['server', server])
+ self.cli_set(svc_path + ['username', username])
+ self.cli_set(svc_path + ['password', password])
+ self.cli_set(svc_path + ['host-name', name])
# commit changes
self.cli_commit()
@@ -247,42 +253,32 @@ class TestServiceDDNS(VyOSUnitTestSHIM.TestCase):
def test_06_dyndns_web_options(self):
# Check if DDNS service can be configured and runs
- base_path_iface = base_path + ['address', interface]
- base_path_web = base_path + ['address', 'web']
- svc_path_iface = base_path_iface + ['service', 'cloudflare']
- svc_path_web = base_path_web + ['service', 'cloudflare']
+ svc_path = name_path + ['cloudflare']
proto = 'cloudflare'
web_url_good = 'https://ifconfig.me/ip'
web_url_bad = 'http:/ifconfig.me/ip'
- self.cli_set(svc_path_iface + ['protocol', proto])
- self.cli_set(svc_path_iface + ['zone', zone])
- self.cli_set(svc_path_iface + ['password', password])
- self.cli_set(svc_path_iface + ['host-name', hostname])
- self.cli_set(base_path_iface + ['web-options', 'url', web_url_good])
+ self.cli_set(svc_path + ['protocol', proto])
+ self.cli_set(svc_path + ['zone', zone])
+ self.cli_set(svc_path + ['password', password])
+ self.cli_set(svc_path + ['host-name', hostname])
+ self.cli_set(svc_path + ['web-options', 'url', web_url_good])
# web-options is supported only with web service based address lookup
# exception is raised for interface based address lookup
with self.assertRaises(ConfigSessionError):
+ self.cli_set(svc_path + ['address', interface])
self.cli_commit()
- self.cli_delete(base_path_iface + ['web-options'])
+ self.cli_set(svc_path + ['address', 'web'])
# commit changes
self.cli_commit()
- # web-options is supported with web service based address lookup
- # this should work, but clear interface based config first
- self.cli_delete(base_path_iface)
- self.cli_set(svc_path_web + ['protocol', proto])
- self.cli_set(svc_path_web + ['zone', zone])
- self.cli_set(svc_path_web + ['password', password])
- self.cli_set(svc_path_web + ['host-name', hostname])
-
# web-options must be a valid URL
- with self.assertRaises(ConfigSessionError) as cm:
- self.cli_set(base_path_web + ['web-options', 'url', web_url_bad])
- self.assertIn(f'"{web_url_bad.removeprefix("http:")}" is not a valid URI', str(cm.exception))
- self.cli_set(base_path_web + ['web-options', 'url', web_url_good])
+ with self.assertRaises(ConfigSessionError):
+ self.cli_set(svc_path + ['web-options', 'url', web_url_bad])
+ self.cli_commit()
+ self.cli_set(svc_path + ['web-options', 'url', web_url_good])
# commit changes
self.cli_commit()
@@ -300,15 +296,17 @@ class TestServiceDDNS(VyOSUnitTestSHIM.TestCase):
# Table number randomized, but should be within range 100-65535
vrf_table = "".join(random.choices(string.digits, k=4))
vrf_name = f'vyos-test-{vrf_table}'
- svc_path = ['address', interface, 'service', 'cloudflare']
+ svc_path = name_path + ['cloudflare']
+ proto = 'cloudflare'
self.cli_set(['vrf', 'name', vrf_name, 'table', vrf_table])
self.cli_set(base_path + ['vrf', vrf_name])
- self.cli_set(base_path + svc_path + ['protocol', 'cloudflare'])
- self.cli_set(base_path + svc_path + ['host-name', hostname])
- self.cli_set(base_path + svc_path + ['zone', zone])
- self.cli_set(base_path + svc_path + ['password', password])
+ self.cli_set(svc_path + ['address', interface])
+ self.cli_set(svc_path + ['protocol', proto])
+ self.cli_set(svc_path + ['host-name', hostname])
+ self.cli_set(svc_path + ['zone', zone])
+ self.cli_set(svc_path + ['password', password])
# commit changes
self.cli_commit()
diff --git a/src/completion/list_ddclient_protocols.sh b/src/completion/list_ddclient_protocols.sh
index c8855b5d1..634981660 100755
--- a/src/completion/list_ddclient_protocols.sh
+++ b/src/completion/list_ddclient_protocols.sh
@@ -14,4 +14,4 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
-echo -n $(ddclient -list-protocols | grep -vE 'nsupdate|cloudns|porkbun')
+echo -n $(ddclient -list-protocols | grep -vE 'cloudns|porkbun')
diff --git a/src/conf_mode/dns_dynamic.py b/src/conf_mode/dns_dynamic.py
index 2bccaee0f..3ddc8e7fd 100755
--- a/src/conf_mode/dns_dynamic.py
+++ b/src/conf_mode/dns_dynamic.py
@@ -30,16 +30,18 @@ config_file = r'/run/ddclient/ddclient.conf'
systemd_override = r'/run/systemd/system/ddclient.service.d/override.conf'
# Protocols that require zone
-zone_necessary = ['cloudflare', 'digitalocean', 'godaddy', 'hetzner', 'gandi', 'nfsn']
+zone_necessary = ['cloudflare', 'digitalocean', 'godaddy', 'hetzner', 'gandi',
+ 'nfsn', 'nsupdate']
zone_supported = zone_necessary + ['dnsexit2', 'zoneedit1']
# Protocols that do not require username
username_unnecessary = ['1984', 'cloudflare', 'cloudns', 'digitalocean', 'dnsexit2',
'duckdns', 'freemyip', 'hetzner', 'keysystems', 'njalla',
- 'regfishde']
+ 'nsupdate', 'regfishde']
# Protocols that support TTL
-ttl_supported = ['cloudflare', 'dnsexit2', 'gandi', 'hetzner', 'godaddy', 'nfsn']
+ttl_supported = ['cloudflare', 'dnsexit2', 'gandi', 'hetzner', 'godaddy', 'nfsn',
+ 'nsupdate']
# Protocols that support both IPv4 and IPv6
dualstack_supported = ['cloudflare', 'digitalocean', 'dnsexit2', 'duckdns',
@@ -70,63 +72,65 @@ def get_config(config=None):
def verify(dyndns):
# bail out early - looks like removal from running config
- if not dyndns or 'address' not in dyndns:
+ if not dyndns or 'name' not in dyndns:
return None
- for address in dyndns['address']:
- # If dyndns address is an interface, ensure it exists
- if address != 'web':
- verify_interface_exists(address)
+ # Dynamic DNS service provider - configuration validation
+ for service, config in dyndns['name'].items():
- # RFC2136 - configuration validation
- if 'rfc2136' in dyndns['address'][address]:
- for config in dyndns['address'][address]['rfc2136'].values():
- for field in ['host_name', 'zone', 'server', 'key']:
- if field not in config:
- raise ConfigError(f'"{field.replace("_", "-")}" is required for RFC2136 '
- f'based Dynamic DNS service on "{address}"')
+ error_msg_req = f'is required for Dynamic DNS service "{service}"'
+ error_msg_uns = f'is not supported for Dynamic DNS service "{service}"'
- # Dynamic DNS service provider - configuration validation
- if 'web_options' in dyndns['address'][address] and address != 'web':
- raise ConfigError(f'"web-options" is applicable only when using HTTP(S) web request to obtain the IP address')
+ for field in ['protocol', 'address', 'host_name']:
+ if field not in config:
+ raise ConfigError(f'"{field.replace("_", "-")}" {error_msg_req}')
- # Dynamic DNS service provider - configuration validation
- if 'service' in dyndns['address'][address]:
- for service, config in dyndns['address'][address]['service'].items():
- error_msg_req = f'is required for Dynamic DNS service "{service}" on "{address}"'
- error_msg_uns = f'is not supported for Dynamic DNS service "{service}" on "{address}" with protocol "{config["protocol"]}"'
+ # If dyndns address is an interface, ensure that it exists
+ # and that web-options are not set
+ if config['address'] != 'web':
+ verify_interface_exists(config['address'])
+ if 'web_options' in config:
+ raise ConfigError(f'"web-options" is applicable only when using HTTP(S) web request to obtain the IP address')
- for field in ['host_name', 'password', 'protocol']:
- if field not in config:
- raise ConfigError(f'"{field.replace("_", "-")}" {error_msg_req}')
+ # RFC2136 uses 'key' instead of 'password'
+ if config['protocol'] != 'nsupdate' and 'password' not in config:
+ raise ConfigError(f'"password" {error_msg_req}')
- if config['protocol'] in zone_necessary and 'zone' not in config:
- raise ConfigError(f'"zone" {error_msg_req} with protocol "{config["protocol"]}"')
+ # Other RFC2136 specific configuration validation
+ if config['protocol'] == 'nsupdate':
+ if 'password' in config:
+ raise ConfigError(f'"password" {error_msg_uns} with protocol "{config["protocol"]}"')
+ for field in ['server', 'key']:
+ if field not in config:
+ raise ConfigError(f'"{field}" {error_msg_req} with protocol "{config["protocol"]}"')
- if config['protocol'] not in zone_supported and 'zone' in config:
- raise ConfigError(f'"zone" {error_msg_uns}')
+ if config['protocol'] in zone_necessary and 'zone' not in config:
+ raise ConfigError(f'"zone" {error_msg_req} with protocol "{config["protocol"]}"')
- if config['protocol'] not in username_unnecessary and 'username' not in config:
- raise ConfigError(f'"username" {error_msg_req} with protocol "{config["protocol"]}"')
+ if config['protocol'] not in zone_supported and 'zone' in config:
+ raise ConfigError(f'"zone" {error_msg_uns} with protocol "{config["protocol"]}"')
- if config['protocol'] not in ttl_supported and 'ttl' in config:
- raise ConfigError(f'"ttl" {error_msg_uns}')
+ if config['protocol'] not in username_unnecessary and 'username' not in config:
+ raise ConfigError(f'"username" {error_msg_req} with protocol "{config["protocol"]}"')
- if config['ip_version'] == 'both':
- if config['protocol'] not in dualstack_supported:
- raise ConfigError(f'Both IPv4 and IPv6 at the same time {error_msg_uns}')
- # dyndns2 protocol in ddclient honors dual stack only for dyn.com (dyndns.org)
- if config['protocol'] == 'dyndns2' and 'server' in config and config['server'] not in dyndns_dualstack_servers:
- raise ConfigError(f'Both IPv4 and IPv6 at the same time {error_msg_uns} for "{config["server"]}"')
+ if config['protocol'] not in ttl_supported and 'ttl' in config:
+ raise ConfigError(f'"ttl" {error_msg_uns} with protocol "{config["protocol"]}"')
- if {'wait_time', 'expiry_time'} <= config.keys() and int(config['expiry_time']) < int(config['wait_time']):
- raise ConfigError(f'"expiry-time" must be greater than "wait-time"')
+ if config['ip_version'] == 'both':
+ if config['protocol'] not in dualstack_supported:
+ raise ConfigError(f'Both IPv4 and IPv6 at the same time {error_msg_uns} with protocol "{config["protocol"]}"')
+ # dyndns2 protocol in ddclient honors dual stack only for dyn.com (dyndns.org)
+ if config['protocol'] == 'dyndns2' and 'server' in config and config['server'] not in dyndns_dualstack_servers:
+ raise ConfigError(f'Both IPv4 and IPv6 at the same time {error_msg_uns} for "{config["server"]}" with protocol "{config["protocol"]}"')
+
+ if {'wait_time', 'expiry_time'} <= config.keys() and int(config['expiry_time']) < int(config['wait_time']):
+ raise ConfigError(f'"expiry-time" must be greater than "wait-time" for Dynamic DNS service "{service}"')
return None
def generate(dyndns):
# bail out early - looks like removal from running config
- if not dyndns or 'address' not in dyndns:
+ if not dyndns or 'name' not in dyndns:
return None
render(config_file, 'dns-dynamic/ddclient.conf.j2', dyndns, permission=0o600)
@@ -139,7 +143,7 @@ def apply(dyndns):
call('systemctl daemon-reload')
# bail out early - looks like removal from running config
- if not dyndns or 'address' not in dyndns:
+ if not dyndns or 'name' not in dyndns:
call(f'systemctl stop {systemd_service}')
if os.path.exists(config_file):
os.unlink(config_file)
diff --git a/src/migration-scripts/dns-dynamic/2-to-3 b/src/migration-scripts/dns-dynamic/2-to-3
new file mode 100644
index 000000000..02bc9324a
--- /dev/null
+++ b/src/migration-scripts/dns-dynamic/2-to-3
@@ -0,0 +1,85 @@
+#!/usr/bin/env python3
+
+# Copyright (C) 2023 VyOS maintainers and contributors
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 or later as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+# T5791:
+# - migrate "service dns dynamic address web web-options ..."
+# to "service dns dynamic name <service> address web ..." (per service)
+# - migrate "service dns dynamic address <address> rfc2136 <service> ..."
+# to "service dns dynamic name <service> address <interface> protocol 'nsupdate'"
+# - migrate "service dns dynamic address <interface> service <service> ..."
+# to "service dns dynamic name <service> address <interface> ..."
+
+import sys
+from vyos.configtree import ConfigTree
+
+if len(sys.argv) < 2:
+ print("Must specify file name!")
+ sys.exit(1)
+
+file_name = sys.argv[1]
+
+with open(file_name, 'r') as f:
+ config_file = f.read()
+
+config = ConfigTree(config_file)
+
+base_path = ['service', 'dns', 'dynamic']
+address_path = base_path + ['address']
+name_path = base_path + ['name']
+
+if not config.exists(address_path):
+ # Nothing to do
+ sys.exit(0)
+
+# config.copy does not recursively create a path, so initialize the name path
+if not config.exists(name_path):
+ config.set(name_path)
+
+for address in config.list_nodes(address_path):
+
+ # Move web-option as a configuration in each service instead of top level web-option
+ if config.exists(address_path + [address, 'web-options']) and address == 'web':
+ for svc_type in ['service', 'rfc2136']:
+ if config.exists(address_path + [address, svc_type]):
+ for svc_cfg in config.list_nodes(address_path + [address, svc_type]):
+ config.copy(address_path + [address, 'web-options'],
+ address_path + [address, svc_type, svc_cfg, 'web-options'])
+ config.delete(address_path + [address, 'web-options'])
+
+ for svc_type in ['service', 'rfc2136']:
+ if config.exists(address_path + [address, svc_type]):
+ # Move RFC2136 as service configuration, rename to avoid name conflict and set protocol to 'nsupdate'
+ if svc_type == 'rfc2136':
+ for rfc_cfg_old in config.list_nodes(address_path + [address, 'rfc2136']):
+ rfc_cfg_new = f'{rfc_cfg_old}-rfc2136'
+ config.rename(address_path + [address, 'rfc2136', rfc_cfg_old], rfc_cfg_new)
+ config.set(address_path + [address, 'rfc2136', rfc_cfg_new, 'protocol'], 'nsupdate')
+
+ # Add address as config value in each service before moving the service path
+ # And then copy the services from 'address <interface> service <service>' to 'name <service>'
+ for svc_cfg in config.list_nodes(address_path + [address, svc_type]):
+ config.set(address_path + [address, svc_type, svc_cfg, 'address'], address)
+ config.copy(address_path + [address, svc_type, svc_cfg], name_path + [svc_cfg])
+
+# Finally cleanup the old address path
+config.delete(address_path)
+
+try:
+ with open(file_name, 'w') as f:
+ f.write(config.to_string())
+except OSError as e:
+ print("Failed to save the modified config: {}".format(e))
+ sys.exit(1)
diff --git a/src/validators/ddclient-protocol b/src/validators/ddclient-protocol
index 8f455e12e..ce5efbd52 100755
--- a/src/validators/ddclient-protocol
+++ b/src/validators/ddclient-protocol
@@ -14,7 +14,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
-ddclient -list-protocols | grep -vE 'nsupdate|cloudns|porkbun' | grep -qw $1
+ddclient -list-protocols | grep -vE 'cloudns|porkbun' | grep -qw $1
if [ $? -gt 0 ]; then
echo "Error: $1 is not a valid protocol, please choose from the supported list of protocols"