From f24763c416a3726e1a20c76947c3cd6801a9d0f2 Mon Sep 17 00:00:00 2001
From: Indrajit Raychaudhuri <irc@indrajit.com>
Date: Tue, 12 Sep 2023 23:31:43 -0500
Subject: ddclient: T5612:  Fix VRF support for ddclient service

Fix VRF support interface definition and configuration mode for ddclient
to actually capture the VRF name and pass it to the template.
---
 src/conf_mode/dns_dynamic.py | 5 +++++
 1 file changed, 5 insertions(+)

(limited to 'src/conf_mode')

diff --git a/src/conf_mode/dns_dynamic.py b/src/conf_mode/dns_dynamic.py
index 4b1aed742..712889f68 100755
--- a/src/conf_mode/dns_dynamic.py
+++ b/src/conf_mode/dns_dynamic.py
@@ -19,6 +19,7 @@ import os
 from sys import exit
 
 from vyos.config import Config
+from vyos.configverify import verify_interface_exists
 from vyos.template import render
 from vyos.utils.process import call
 from vyos import ConfigError
@@ -61,6 +62,10 @@ def verify(dyndns):
         return None
 
     for address in dyndns['address']:
+        # If dyndns address is an interface, ensure it exists
+        if address != 'web':
+            verify_interface_exists(address)
+
         # RFC2136 - configuration validation
         if 'rfc2136' in dyndns['address'][address]:
             for config in dyndns['address'][address]['rfc2136'].values():
-- 
cgit v1.2.3


From c545758552ababa069fc090ac50b79a69ad72457 Mon Sep 17 00:00:00 2001
From: Indrajit Raychaudhuri <irc@indrajit.com>
Date: Thu, 21 Sep 2023 21:35:18 -0500
Subject: ddclient: T5612: Enable TTL support for web-service based protocols

Enable TTL support for web-service based protocols in addition to
RFC2136 based (nsupdate) protocol.

Since TTL is not supported by all protocols, and thus cannot have a
configuration default, the existing XML snippet `include/dns/time-to-live.xml.i`
does not have common `<defaultValue>300</defaultValue>` anymore and is
instead added explicitly whenever necessary.
---
 data/templates/dns-dynamic/ddclient.conf.j2        |  2 +-
 interface-definitions/dns-dynamic.xml.in           |  1 +
 interface-definitions/dns-forwarding.xml.in        | 30 ++++++++++++++++++++++
 .../include/dns/time-to-live.xml.i                 |  1 -
 smoketest/scripts/cli/test_service_dns_dynamic.py  | 19 +++++++++++---
 src/conf_mode/dns_dynamic.py                       |  6 +++++
 6 files changed, 53 insertions(+), 6 deletions(-)

(limited to 'src/conf_mode')

diff --git a/data/templates/dns-dynamic/ddclient.conf.j2 b/data/templates/dns-dynamic/ddclient.conf.j2
index 421daf1df..efc7f0fe4 100644
--- a/data/templates/dns-dynamic/ddclient.conf.j2
+++ b/data/templates/dns-dynamic/ddclient.conf.j2
@@ -63,7 +63,7 @@ use=no            {# ddclient default ('ip') results in confusing warning messag
 # Web service dynamic DNS configuration for {{ name }}: [{{ config.protocol }}, {{ host }}]
 {{ render_config(host, address, service_cfg.web_options, ip_suffixes,
                  protocol=config.protocol, server=config.server, zone=config.zone,
-                 login=config.username, password=config.password) }}
+                 login=config.username, password=config.password, ttl=config.ttl) }}
 
 {%                 endfor %}
 {%             endfor %}
diff --git a/interface-definitions/dns-dynamic.xml.in b/interface-definitions/dns-dynamic.xml.in
index 94e0645d4..93b1dbc23 100644
--- a/interface-definitions/dns-dynamic.xml.in
+++ b/interface-definitions/dns-dynamic.xml.in
@@ -101,6 +101,7 @@
                       #include <include/dns/dynamic-service-host-name-server.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>
diff --git a/interface-definitions/dns-forwarding.xml.in b/interface-definitions/dns-forwarding.xml.in
index c00051c47..5ca02acef 100644
--- a/interface-definitions/dns-forwarding.xml.in
+++ b/interface-definitions/dns-forwarding.xml.in
@@ -158,6 +158,9 @@
                             </properties>
                           </leafNode>
                           #include <include/dns/time-to-live.xml.i>
+                          <leafNode name="ttl">
+                              <defaultValue>300</defaultValue>
+                          </leafNode>
                           #include <include/generic-disable-node.xml.i>
                         </children>
                       </tagNode>
@@ -195,6 +198,9 @@
                             </properties>
                           </leafNode>
                           #include <include/dns/time-to-live.xml.i>
+                          <leafNode name="ttl">
+                              <defaultValue>300</defaultValue>
+                          </leafNode>
                           #include <include/generic-disable-node.xml.i>
                         </children>
                       </tagNode>
@@ -227,6 +233,9 @@
                             </properties>
                           </leafNode>
                           #include <include/dns/time-to-live.xml.i>
+                          <leafNode name="ttl">
+                              <defaultValue>300</defaultValue>
+                          </leafNode>
                           #include <include/generic-disable-node.xml.i>
                         </children>
                       </tagNode>
@@ -274,6 +283,9 @@
                             </children>
                           </tagNode>
                           #include <include/dns/time-to-live.xml.i>
+                          <leafNode name="ttl">
+                              <defaultValue>300</defaultValue>
+                          </leafNode>
                           #include <include/generic-disable-node.xml.i>
                         </children>
                       </tagNode>
@@ -302,6 +314,9 @@
                             </properties>
                           </leafNode>
                           #include <include/dns/time-to-live.xml.i>
+                          <leafNode name="ttl">
+                              <defaultValue>300</defaultValue>
+                          </leafNode>
                           #include <include/generic-disable-node.xml.i>
                         </children>
                       </tagNode>
@@ -334,6 +349,9 @@
                             </properties>
                           </leafNode>
                           #include <include/dns/time-to-live.xml.i>
+                          <leafNode name="ttl">
+                              <defaultValue>300</defaultValue>
+                          </leafNode>
                           #include <include/generic-disable-node.xml.i>
                         </children>
                       </tagNode>
@@ -364,6 +382,9 @@
                             </properties>
                           </leafNode>
                           #include <include/dns/time-to-live.xml.i>
+                          <leafNode name="ttl">
+                              <defaultValue>300</defaultValue>
+                          </leafNode>
                           #include <include/generic-disable-node.xml.i>
                         </children>
                       </tagNode>
@@ -393,6 +414,9 @@
                             </properties>
                           </leafNode>
                           #include <include/dns/time-to-live.xml.i>
+                          <leafNode name="ttl">
+                              <defaultValue>300</defaultValue>
+                          </leafNode>
                           #include <include/generic-disable-node.xml.i>
                         </children>
                       </tagNode>
@@ -477,6 +501,9 @@
                             </children>
                           </tagNode>
                           #include <include/dns/time-to-live.xml.i>
+                          <leafNode name="ttl">
+                              <defaultValue>300</defaultValue>
+                          </leafNode>
                           #include <include/generic-disable-node.xml.i>
                         </children>
                       </tagNode>
@@ -585,6 +612,9 @@
                             </children>
                           </tagNode>
                           #include <include/dns/time-to-live.xml.i>
+                          <leafNode name="ttl">
+                              <defaultValue>300</defaultValue>
+                          </leafNode>
                           #include <include/generic-disable-node.xml.i>
                         </children>
                       </tagNode>
diff --git a/interface-definitions/include/dns/time-to-live.xml.i b/interface-definitions/include/dns/time-to-live.xml.i
index 5c1a1472d..000eea108 100644
--- a/interface-definitions/include/dns/time-to-live.xml.i
+++ b/interface-definitions/include/dns/time-to-live.xml.i
@@ -10,6 +10,5 @@
       <validator name="numeric" argument="--range 0-2147483647"/>
     </constraint>
   </properties>
-  <defaultValue>300</defaultValue>
 </leafNode>
 <!-- include end -->
diff --git a/smoketest/scripts/cli/test_service_dns_dynamic.py b/smoketest/scripts/cli/test_service_dns_dynamic.py
index 366b063c7..aa4891829 100755
--- a/smoketest/scripts/cli/test_service_dns_dynamic.py
+++ b/smoketest/scripts/cli/test_service_dns_dynamic.py
@@ -63,18 +63,29 @@ class TestServiceDDNS(VyOSUnitTestSHIM.TestCase):
             self.cli_set(base_path + ddns + [svc, 'host-name', hostname])
             self.cli_set(base_path + ddns + [svc, 'password', password])
             self.cli_set(base_path + ddns + [svc, 'zone', zone])
+            self.cli_set(base_path + ddns + [svc, 'ttl', ttl])
             for opt, value in details.items():
                 self.cli_set(base_path + ddns + [svc, opt, value])
 
-            # commit changes
+            # 'zone' option is supported and required by 'cloudfare', but not 'freedns' and 'zoneedit'
+            self.cli_set(base_path + ddns + [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 + ddns + [svc, 'zone'])
+
+            # 'ttl' option is supported by 'cloudfare', but not 'freedns' and 'zoneedit'
+            self.cli_set(base_path + ddns + [svc, 'ttl', ttl])
             if details['protocol'] == 'cloudflare':
                 pass
             else:
-                # zone option does not work on all protocols, an exception is
-                # raised for all others
+                # exception is raised for unsupported ones
                 with self.assertRaises(ConfigSessionError):
                     self.cli_commit()
-                self.cli_delete(base_path + ddns + [svc, 'zone', zone])
+                self.cli_delete(base_path + ddns + [svc, 'ttl'])
 
             # commit changes
             self.cli_commit()
diff --git a/src/conf_mode/dns_dynamic.py b/src/conf_mode/dns_dynamic.py
index 712889f68..2885f3e37 100755
--- a/src/conf_mode/dns_dynamic.py
+++ b/src/conf_mode/dns_dynamic.py
@@ -35,6 +35,9 @@ zone_allowed = ['cloudflare', 'godaddy', 'hetzner', 'gandi', 'nfsn']
 # Protocols that do not require username
 username_unnecessary = ['1984', 'cloudflare', 'cloudns', 'duckdns', 'freemyip', 'hetzner', 'keysystems', 'njalla']
 
+# Protocols that support TTL
+ttl_supported = ['cloudflare', 'gandi', 'hetzner', 'dnsexit', 'godaddy', 'nfsn']
+
 # Protocols that support both IPv4 and IPv6
 dualstack_supported = ['cloudflare', 'dyndns2', 'freedns', 'njalla']
 
@@ -93,6 +96,9 @@ def verify(dyndns):
                     if 'username' not in config:
                         raise ConfigError(f'"username" {error_msg}')
 
+                if config['protocol'] not in ttl_supported and 'ttl' in config:
+                    raise ConfigError(f'"{config["protocol"]}" does not support "ttl"')
+
                 if config['ip_version'] == 'both':
                     if config['protocol'] not in dualstack_supported:
                         raise ConfigError(f'"{config["protocol"]}" does not support '
-- 
cgit v1.2.3


From 6023328a637ebb84e9d90145a87b63262bc5af63 Mon Sep 17 00:00:00 2001
From: Indrajit Raychaudhuri <irc@indrajit.com>
Date: Sat, 16 Sep 2023 13:33:02 -0500
Subject: ddclient: T5612: Improve dual stack support for dyndns2 protocol

dyndns2 protocol in ddclient honors dual stack for selective servers
because of the way it is implemented in ddclient.

We formalize the well known servers that support dual stack in a list
and check against it when validating the configuration.
---
 src/conf_mode/dns_dynamic.py | 12 ++++++++----
 1 file changed, 8 insertions(+), 4 deletions(-)

(limited to 'src/conf_mode')

diff --git a/src/conf_mode/dns_dynamic.py b/src/conf_mode/dns_dynamic.py
index 2885f3e37..5150574a8 100755
--- a/src/conf_mode/dns_dynamic.py
+++ b/src/conf_mode/dns_dynamic.py
@@ -30,7 +30,7 @@ config_file = r'/run/ddclient/ddclient.conf'
 systemd_override = r'/run/systemd/system/ddclient.service.d/override.conf'
 
 # Protocols that require zone
-zone_allowed = ['cloudflare', 'godaddy', 'hetzner', 'gandi', 'nfsn']
+zone_required = ['cloudflare', 'godaddy', 'hetzner', 'gandi', 'nfsn']
 
 # Protocols that do not require username
 username_unnecessary = ['1984', 'cloudflare', 'cloudns', 'duckdns', 'freemyip', 'hetzner', 'keysystems', 'njalla']
@@ -41,6 +41,10 @@ ttl_supported = ['cloudflare', 'gandi', 'hetzner', 'dnsexit', 'godaddy', 'nfsn']
 # Protocols that support both IPv4 and IPv6
 dualstack_supported = ['cloudflare', 'dyndns2', 'freedns', 'njalla']
 
+# dyndns2 protocol in ddclient honors dual stack for selective servers
+# because of the way it is implemented in ddclient
+dyndns_dualstack_servers = ['members.dyndns.org', 'dynv6.com']
+
 def get_config(config=None):
     if config:
         conf = config
@@ -86,10 +90,10 @@ def verify(dyndns):
                     if field not in config:
                         raise ConfigError(f'"{field.replace("_", "-")}" {error_msg}')
 
-                if config['protocol'] in zone_allowed and 'zone' not in config:
+                if config['protocol'] in zone_required and 'zone' not in config:
                         raise ConfigError(f'"zone" {error_msg}')
 
-                if config['protocol'] not in zone_allowed and 'zone' in config:
+                if config['protocol'] not in zone_required and 'zone' in config:
                         raise ConfigError(f'"{config["protocol"]}" does not support "zone"')
 
                 if config['protocol'] not in username_unnecessary:
@@ -104,7 +108,7 @@ def verify(dyndns):
                         raise ConfigError(f'"{config["protocol"]}" does not support '
                                           f'both IPv4 and IPv6 at the same time')
                     # dyndns2 protocol in ddclient honors dual stack only for dyn.com (dyndns.org)
-                    if config['protocol'] == 'dyndns2' and 'server' in config and config['server'] != 'members.dyndns.org':
+                    if config['protocol'] == 'dyndns2' and 'server' in config and config['server'] not in dyndns_dualstack_servers:
                         raise ConfigError(f'"{config["protocol"]}" does not support '
                                           f'both IPv4 and IPv6 at the same time for "{config["server"]}"')
 
-- 
cgit v1.2.3


From 6b30a92eaff48ae5dd4968e30f3464e04c69d4fd Mon Sep 17 00:00:00 2001
From: Indrajit Raychaudhuri <irc@indrajit.com>
Date: Thu, 21 Sep 2023 21:11:40 -0500
Subject: ddclient: T5612: Additional refactoring for scripts and smoketests

Additional cleanup and refactoring for ddclient scripts including the
smotektests.
---
 smoketest/scripts/cli/test_service_dns_dynamic.py | 115 +++++++++++-----------
 src/conf_mode/dns_dynamic.py                      |  21 ++--
 2 files changed, 69 insertions(+), 67 deletions(-)

(limited to 'src/conf_mode')

diff --git a/smoketest/scripts/cli/test_service_dns_dynamic.py b/smoketest/scripts/cli/test_service_dns_dynamic.py
index f1870320e..66dcde434 100755
--- a/smoketest/scripts/cli/test_service_dns_dynamic.py
+++ b/smoketest/scripts/cli/test_service_dns_dynamic.py
@@ -32,12 +32,19 @@ DDCLIENT_PID = '/run/ddclient/ddclient.pid'
 DDCLIENT_PNAME = 'ddclient'
 
 base_path = ['service', 'dns', 'dynamic']
+server = 'ddns.vyos.io'
 hostname = 'test.ddns.vyos.io'
 zone = 'vyos.io'
+username = 'vyos_user'
 password = 'paSS_@4ord'
+ttl = '300'
 interface = 'eth0'
 
 class TestServiceDDNS(VyOSUnitTestSHIM.TestCase):
+    def setUp(self):
+        # Always start with a clean CLI instance
+        self.cli_delete(base_path)
+
     def tearDown(self):
         # Check for running process
         self.assertTrue(process_running(DDCLIENT_PID))
@@ -51,41 +58,38 @@ class TestServiceDDNS(VyOSUnitTestSHIM.TestCase):
 
     # IPv4 standard DDNS service configuration
     def test_01_dyndns_service_standard(self):
-        ddns = ['address', interface, 'service']
+        svc_path = ['address', interface, 'service']
         services = {'cloudflare': {'protocol': 'cloudflare'},
-                    'freedns': {'protocol': 'freedns', 'username': 'vyos_user'},
-                    'zoneedit': {'protocol': 'zoneedit1', 'username': 'vyos_user'}}
+                    'freedns': {'protocol': 'freedns', 'username': username},
+                    'zoneedit': {'protocol': 'zoneedit1', 'username': username}}
 
         for svc, details in services.items():
-            # Always start with a clean CLI instance
-            self.cli_delete(base_path)
-
-            self.cli_set(base_path + ddns + [svc, 'host-name', hostname])
-            self.cli_set(base_path + ddns + [svc, 'password', password])
-            self.cli_set(base_path + ddns + [svc, 'zone', zone])
-            self.cli_set(base_path + ddns + [svc, 'ttl', ttl])
+            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])
             for opt, value in details.items():
-                self.cli_set(base_path + ddns + [svc, opt, value])
+                self.cli_set(base_path + svc_path + [svc, opt, value])
 
             # 'zone' option is supported and required by 'cloudfare', but not 'freedns' and 'zoneedit'
-            self.cli_set(base_path + ddns + [svc, 'zone', zone])
+            self.cli_set(base_path + svc_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 + ddns + [svc, 'zone'])
+                self.cli_delete(base_path + svc_path + [svc, 'zone'])
 
             # 'ttl' option is supported by 'cloudfare', but not 'freedns' and 'zoneedit'
-            self.cli_set(base_path + ddns + [svc, 'ttl', ttl])
+            self.cli_set(base_path + svc_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 + ddns + [svc, 'ttl'])
+                self.cli_delete(base_path + svc_path + [svc, 'ttl'])
 
             # commit changes
             self.cli_commit()
@@ -109,20 +113,17 @@ class TestServiceDDNS(VyOSUnitTestSHIM.TestCase):
     # IPv6 only DDNS service configuration
     def test_02_dyndns_service_ipv6(self):
         timeout = '60'
-        ddns = ['address', interface, 'service', 'dynv6']
+        svc_path = ['address', interface, 'service', 'dynv6']
         proto = 'dyndns2'
-        user = 'none'
-        password = 'paSS_4ord'
-        srv = 'ddns.vyos.io'
         ip_version = 'ipv6'
 
         self.cli_set(base_path + ['timeout', timeout])
-        self.cli_set(base_path + ddns + ['ip-version', ip_version])
-        self.cli_set(base_path + ddns + ['protocol', proto])
-        self.cli_set(base_path + ddns + ['server', srv])
-        self.cli_set(base_path + ddns + ['username', user])
-        self.cli_set(base_path + ddns + ['password', password])
-        self.cli_set(base_path + ddns + ['host-name', hostname])
+        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])
 
         # commit changes
         self.cli_commit()
@@ -133,37 +134,45 @@ class TestServiceDDNS(VyOSUnitTestSHIM.TestCase):
         self.assertIn(f'usev6=ifv6', ddclient_conf)
         self.assertIn(f'ifv6={interface}', ddclient_conf)
         self.assertIn(f'protocol={proto}', ddclient_conf)
-        self.assertIn(f'server={srv}', ddclient_conf)
-        self.assertIn(f'login={user}', ddclient_conf)
+        self.assertIn(f'server={server}', ddclient_conf)
+        self.assertIn(f'login={username}', ddclient_conf)
         self.assertIn(f'password={password}', ddclient_conf)
 
     # IPv4+IPv6 dual DDNS service configuration
     def test_03_dyndns_service_dual_stack(self):
-        ddns = ['address', interface, 'service']
-        services = {'cloudflare': {'protocol': 'cloudflare', 'zone': 'vyos.io'},
-                    'freedns': {'protocol': 'freedns', 'username': 'vyos_user'}}
-        password = 'vyos_pass'
+        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 svc, details in services.items():
-            # Always start with a clean CLI instance
-            self.cli_delete(base_path)
-
-            self.cli_set(base_path + ddns + [svc, 'host-name', hostname])
-            self.cli_set(base_path + ddns + [svc, 'password', password])
-            self.cli_set(base_path + ddns + [svc, 'ip-version', ip_version])
+        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])
             for opt, value in details.items():
-                self.cli_set(base_path + ddns + [svc, opt, value])
+                self.cli_set(base_path + svc_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])
+            if details['protocol'] not in ['cloudflare', 'freedns']:
+                with self.assertRaises(ConfigSessionError):
+                    self.cli_commit()
+                self.cli_delete(base_path + svc_path + [name, 'ip-version'])
 
             # commit changes
             self.cli_commit()
 
             # Check the generating config parameters
             ddclient_conf = cmd(f'sudo cat {DDCLIENT_CONF}')
-            self.assertIn(f'usev4=ifv4', ddclient_conf)
-            self.assertIn(f'usev6=ifv6', ddclient_conf)
-            self.assertIn(f'ifv4={interface}', ddclient_conf)
-            self.assertIn(f'ifv6={interface}', ddclient_conf)
+            if details['protocol'] not in ['cloudflare', 'freedns']:
+                self.assertIn(f'usev4=ifv4', ddclient_conf)
+                self.assertIn(f'ifv4={interface}', ddclient_conf)
+            else:
+                self.assertIn(f'usev4=ifv4', ddclient_conf)
+                self.assertIn(f'usev6=ifv6', ddclient_conf)
+                self.assertIn(f'ifv4={interface}', ddclient_conf)
+                self.assertIn(f'ifv6={interface}', ddclient_conf)
             self.assertIn(f'password={password}', ddclient_conf)
 
             for opt in details.keys():
@@ -176,19 +185,16 @@ class TestServiceDDNS(VyOSUnitTestSHIM.TestCase):
 
     def test_04_dyndns_rfc2136(self):
         # Check if DDNS service can be configured and runs
-        ddns = ['address', interface, 'rfc2136', 'vyos']
-        srv = 'ns1.vyos.io'
-        zone = 'vyos.io'
-        ttl = '300'
+        svc_path = ['address', interface, 'rfc2136', 'vyos']
 
         with tempfile.NamedTemporaryFile(prefix='/config/auth/') as key_file:
             key_file.write(b'S3cretKey')
 
-            self.cli_set(base_path + ddns + ['server', srv])
-            self.cli_set(base_path + ddns + ['zone', zone])
-            self.cli_set(base_path + ddns + ['key', key_file.name])
-            self.cli_set(base_path + ddns + ['ttl', ttl])
-            self.cli_set(base_path + ddns + ['host-name', hostname])
+            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])
 
             # commit changes
             self.cli_commit()
@@ -198,7 +204,7 @@ class TestServiceDDNS(VyOSUnitTestSHIM.TestCase):
             self.assertIn(f'use=if', ddclient_conf)
             self.assertIn(f'if={interface}', ddclient_conf)
             self.assertIn(f'protocol=nsupdate', ddclient_conf)
-            self.assertIn(f'server={srv}', 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)
             self.assertIn(f'ttl={ttl}', ddclient_conf)
@@ -231,9 +237,6 @@ class TestServiceDDNS(VyOSUnitTestSHIM.TestCase):
         vrf_name = f'vyos-test-{"".join(random.choices(string.ascii_letters + string.digits, k=5))}'
         svc_path = ['address', interface, 'service', 'cloudflare']
 
-        # Always start with a clean CLI instance
-        self.cli_delete(base_path)
-
         self.cli_set(['vrf', 'name', vrf_name, 'table', '12345'])
         self.cli_set(base_path + ['vrf', vrf_name])
 
diff --git a/src/conf_mode/dns_dynamic.py b/src/conf_mode/dns_dynamic.py
index 5150574a8..8a438cf6f 100755
--- a/src/conf_mode/dns_dynamic.py
+++ b/src/conf_mode/dns_dynamic.py
@@ -30,7 +30,7 @@ config_file = r'/run/ddclient/ddclient.conf'
 systemd_override = r'/run/systemd/system/ddclient.service.d/override.conf'
 
 # Protocols that require zone
-zone_required = ['cloudflare', 'godaddy', 'hetzner', 'gandi', 'nfsn']
+zone_necessary = ['cloudflare', 'godaddy', 'hetzner', 'gandi', 'nfsn']
 
 # Protocols that do not require username
 username_unnecessary = ['1984', 'cloudflare', 'cloudns', 'duckdns', 'freemyip', 'hetzner', 'keysystems', 'njalla']
@@ -51,11 +51,11 @@ def get_config(config=None):
     else:
         conf = Config()
 
-    base_level = ['service', 'dns', 'dynamic']
-    if not conf.exists(base_level):
+    base = ['service', 'dns', 'dynamic']
+    if not conf.exists(base):
         return None
 
-    dyndns = conf.get_config_dict(base_level, key_mangling=('-', '_'),
+    dyndns = conf.get_config_dict(base, key_mangling=('-', '_'),
                                   no_tag_node_value_mangle=True,
                                   get_first_key=True,
                                   with_recursive_defaults=True)
@@ -90,15 +90,14 @@ def verify(dyndns):
                     if field not in config:
                         raise ConfigError(f'"{field.replace("_", "-")}" {error_msg}')
 
-                if config['protocol'] in zone_required and 'zone' not in config:
-                        raise ConfigError(f'"zone" {error_msg}')
+                if config['protocol'] in zone_necessary and 'zone' not in config:
+                    raise ConfigError(f'"zone" {error_msg}')
 
-                if config['protocol'] not in zone_required and 'zone' in config:
-                        raise ConfigError(f'"{config["protocol"]}" does not support "zone"')
+                if config['protocol'] not in zone_necessary and 'zone' in config:
+                    raise ConfigError(f'"{config["protocol"]}" does not support "zone"')
 
-                if config['protocol'] not in username_unnecessary:
-                    if 'username' not in config:
-                        raise ConfigError(f'"username" {error_msg}')
+                if config['protocol'] not in username_unnecessary and 'username' not in config:
+                    raise ConfigError(f'"username" {error_msg}')
 
                 if config['protocol'] not in ttl_supported and 'ttl' in config:
                     raise ConfigError(f'"{config["protocol"]}" does not support "ttl"')
-- 
cgit v1.2.3


From c3ba4527824c9f4d2e53e7fbd0bff4b84c3012f4 Mon Sep 17 00:00:00 2001
From: Indrajit Raychaudhuri <irc@indrajit.com>
Date: Wed, 13 Sep 2023 01:02:12 -0500
Subject: ddclient: T5574: Support per-service cache management for services

Add support for per-service cache management for ddclient providers
via `wait-time` and `expiry-time` options. This allows for finer-grained
control over how often a service is updated and how long the hostname
will be cached before being marked expired in ddclient's cache.

More specifically, `wait-time` controls how often ddclient will attempt
to check for a change in the hostname's IP address, and `expiry-time`
controls how often ddclient to a forced update of the hostname's IP
address.

These options intentionally don't have any default values because they
are provider-specific. They get treated similar to the other provider-
specific options in that they are only used if defined.
---
 data/templates/dns-dynamic/ddclient.conf.j2        | 11 ++++-----
 interface-definitions/dns-dynamic.xml.in           |  2 ++
 .../dns/dynamic-service-wait-expiry-time.xml.i     | 28 ++++++++++++++++++++++
 smoketest/scripts/cli/test_service_dns_dynamic.py  | 12 ++++++++++
 src/conf_mode/dns_dynamic.py                       |  3 +++
 5 files changed, 49 insertions(+), 7 deletions(-)
 create mode 100644 interface-definitions/include/dns/dynamic-service-wait-expiry-time.xml.i

(limited to 'src/conf_mode')

diff --git a/data/templates/dns-dynamic/ddclient.conf.j2 b/data/templates/dns-dynamic/ddclient.conf.j2
index 5905b19ea..6e77abdb5 100644
--- a/data/templates/dns-dynamic/ddclient.conf.j2
+++ b/data/templates/dns-dynamic/ddclient.conf.j2
@@ -14,10 +14,8 @@ if{{ ipv }}={{ address }}, \
 {%     endif %}
 {% endfor %}
 {# Other service options #}
-{% for k,v in kwargs.items() %}
-{%     if v is vyos_defined %}
-{{ k }}={{ v }}{{ ',' if not loop.last }} \
-{%     endif %}
+{% for k,v in kwargs.items() if v is vyos_defined %}
+{{ k | replace('_', '-') }}={{ v }}{{ ',' if not loop.last }} \
 {% endfor %}
 {# Actual hostname for the service #}
 {{ host }}
@@ -49,7 +47,6 @@ use=no
 {{ 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 %}
@@ -66,8 +63,8 @@ use=no
 # Web service dynamic DNS configuration for {{ name }}: [{{ config.protocol }}, {{ host }}]
 {{ render_config(host, address, service_cfg.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=config.password, ttl=config.ttl,
+                 min_interval=config.wait_time, max_interval=config.expiry_time) }}
 {%                 endfor %}
 {%             endfor %}
 {%         endif %}
diff --git a/interface-definitions/dns-dynamic.xml.in b/interface-definitions/dns-dynamic.xml.in
index ba7f426c1..723223f1c 100644
--- a/interface-definitions/dns-dynamic.xml.in
+++ b/interface-definitions/dns-dynamic.xml.in
@@ -61,6 +61,7 @@
                     <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>
@@ -88,6 +89,7 @@
                     <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>
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
new file mode 100644
index 000000000..866690cbe
--- /dev/null
+++ b/interface-definitions/include/dns/dynamic-service-wait-expiry-time.xml.i
@@ -0,0 +1,28 @@
+<!-- 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/smoketest/scripts/cli/test_service_dns_dynamic.py b/smoketest/scripts/cli/test_service_dns_dynamic.py
index 66dcde434..acabc0070 100755
--- a/smoketest/scripts/cli/test_service_dns_dynamic.py
+++ b/smoketest/scripts/cli/test_service_dns_dynamic.py
@@ -116,6 +116,9 @@ class TestServiceDDNS(VyOSUnitTestSHIM.TestCase):
         svc_path = ['address', interface, 'service', 'dynv6']
         proto = 'dyndns2'
         ip_version = 'ipv6'
+        wait_time = '600'
+        expiry_time_good = '3600'
+        expiry_time_bad = '360'
 
         self.cli_set(base_path + ['timeout', timeout])
         self.cli_set(base_path + svc_path + ['ip-version', ip_version])
@@ -124,6 +127,13 @@ class TestServiceDDNS(VyOSUnitTestSHIM.TestCase):
         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])
+
+        # 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_commit()
+        self.cli_set(base_path + svc_path + ['expiry-time', expiry_time_good])
 
         # commit changes
         self.cli_commit()
@@ -137,6 +147,8 @@ class TestServiceDDNS(VyOSUnitTestSHIM.TestCase):
         self.assertIn(f'server={server}', ddclient_conf)
         self.assertIn(f'login={username}', ddclient_conf)
         self.assertIn(f'password={password}', ddclient_conf)
+        self.assertIn(f'min-interval={wait_time}', ddclient_conf)
+        self.assertIn(f'max-interval={expiry_time_good}', ddclient_conf)
 
     # IPv4+IPv6 dual DDNS service configuration
     def test_03_dyndns_service_dual_stack(self):
diff --git a/src/conf_mode/dns_dynamic.py b/src/conf_mode/dns_dynamic.py
index 8a438cf6f..874c4b689 100755
--- a/src/conf_mode/dns_dynamic.py
+++ b/src/conf_mode/dns_dynamic.py
@@ -111,6 +111,9 @@ def verify(dyndns):
                         raise ConfigError(f'"{config["protocol"]}" does not support '
                                           f'both IPv4 and IPv6 at the same time for "{config["server"]}"')
 
+                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"')
+
     return None
 
 def generate(dyndns):
-- 
cgit v1.2.3


From e058ee4909728541f3cd63110908c86214bf76c0 Mon Sep 17 00:00:00 2001
From: Indrajit Raychaudhuri <irc@indrajit.com>
Date: Sat, 4 Nov 2023 16:01:02 -0500
Subject: ddclient: T5708: Validate proper use of `web-options`

`web-options` is only applicable when using HTTP(S) web request to
obtain the IP address. Apply guard for that.
---
 src/conf_mode/dns_dynamic.py             | 4 ++++
 src/migration-scripts/dns-dynamic/1-to-2 | 7 +++++++
 2 files changed, 11 insertions(+)

(limited to 'src/conf_mode')

diff --git a/src/conf_mode/dns_dynamic.py b/src/conf_mode/dns_dynamic.py
index 874c4b689..d71dc22fd 100755
--- a/src/conf_mode/dns_dynamic.py
+++ b/src/conf_mode/dns_dynamic.py
@@ -81,6 +81,10 @@ def verify(dyndns):
                         raise ConfigError(f'"{field.replace("_", "-")}" is required for RFC2136 '
                                           f'based Dynamic DNS service on "{address}"')
 
+        # 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')
+
         # Dynamic DNS service provider - configuration validation
         if 'service' in dyndns['address'][address]:
             for service, config in dyndns['address'][address]['service'].items():
diff --git a/src/migration-scripts/dns-dynamic/1-to-2 b/src/migration-scripts/dns-dynamic/1-to-2
index b4679769c..8aaedf210 100644
--- a/src/migration-scripts/dns-dynamic/1-to-2
+++ b/src/migration-scripts/dns-dynamic/1-to-2
@@ -17,6 +17,7 @@
 # T5708:
 # - migrate "service dns dynamic timeout ..."
 #        to "service dns dynamic interval ..."
+# - remove "service dns dynamic address <interface> web-options ..." when <interface> != "web"
 
 import sys
 from vyos.configtree import ConfigTree
@@ -34,6 +35,7 @@ config = ConfigTree(config_file)
 
 base_path = ['service', 'dns', 'dynamic']
 timeout_path = base_path + ['timeout']
+address_path = base_path + ['address']
 
 if not config.exists(base_path):
     # Nothing to do
@@ -44,6 +46,11 @@ if not config.exists(base_path):
 if config.exists(timeout_path):
     config.rename(timeout_path, 'interval')
 
+# Remove "service dns dynamic address <interface> web-options ..." when <interface> != "web"
+for address in config.list_nodes(address_path):
+    if config.exists(address_path + [address, 'web-options']) and address != 'web':
+        config.delete(address_path + [address, 'web-options'])
+
 try:
     with open(file_name, 'w') as f:
         f.write(config.to_string())
-- 
cgit v1.2.3


From 4419244972ad1183ae42665dd453abb19e162ed5 Mon Sep 17 00:00:00 2001
From: Indrajit Raychaudhuri <irc@indrajit.com>
Date: Thu, 2 Nov 2023 20:15:49 -0500
Subject: ddclient: T5708: Migration to 3.11.1 and related improvements

- Migrate to ddclient 3.11.1 and enforce debian/control dependency
- Add dual stack support for additional protocols
- Restrict usage of `porkbun` protocol, VyOS configuration structure
  isn't compatible with porkbun yet
- Improve and cleanup error messages
---
 debian/control                                    |  2 +-
 smoketest/scripts/cli/test_service_dns_dynamic.py |  5 ++--
 src/completion/list_ddclient_protocols.sh         |  2 +-
 src/conf_mode/dns_dynamic.py                      | 34 +++++++++++++----------
 src/migration-scripts/dns-dynamic/1-to-2          | 11 ++++++++
 src/validators/ddclient-protocol                  |  2 +-
 6 files changed, 36 insertions(+), 20 deletions(-)

(limited to 'src/conf_mode')

diff --git a/debian/control b/debian/control
index 42c0b580b..ae54d6ed6 100644
--- a/debian/control
+++ b/debian/control
@@ -48,7 +48,7 @@ Depends:
   cron,
   curl,
   dbus,
-  ddclient (>= 3.9.1),
+  ddclient (>= 3.11.1),
   dropbear,
   easy-rsa,
   etherwake,
diff --git a/smoketest/scripts/cli/test_service_dns_dynamic.py b/smoketest/scripts/cli/test_service_dns_dynamic.py
index c6940f01d..69ea5c1b3 100755
--- a/smoketest/scripts/cli/test_service_dns_dynamic.py
+++ b/smoketest/scripts/cli/test_service_dns_dynamic.py
@@ -246,10 +246,11 @@ class TestServiceDDNS(VyOSUnitTestSHIM.TestCase):
             self.assertIn(f'{name}', ddclient_conf)
 
     def test_06_dyndns_vrf(self):
-        vrf_name = f'vyos-test-{"".join(random.choices(string.ascii_letters + string.digits, k=5))}'
+        vrf_table = "".join(random.choices(string.digits, k=5))
+        vrf_name = f'vyos-test-{vrf_table}'
         svc_path = ['address', interface, 'service', 'cloudflare']
 
-        self.cli_set(['vrf', 'name', vrf_name, 'table', '12345'])
+        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'])
diff --git a/src/completion/list_ddclient_protocols.sh b/src/completion/list_ddclient_protocols.sh
index 3b4eff4d6..c8855b5d1 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')
+echo -n $(ddclient -list-protocols | grep  -vE 'nsupdate|cloudns|porkbun')
diff --git a/src/conf_mode/dns_dynamic.py b/src/conf_mode/dns_dynamic.py
index d71dc22fd..d6ef620fe 100755
--- a/src/conf_mode/dns_dynamic.py
+++ b/src/conf_mode/dns_dynamic.py
@@ -30,16 +30,21 @@ 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', 'godaddy', 'hetzner', 'gandi', 'nfsn']
+zone_necessary = ['cloudflare', 'digitalocean', 'godaddy', 'hetzner', 'gandi', 'nfsn']
+zone_supported = zone_necessary + ['dnsexit2', 'zoneedit1']
 
 # Protocols that do not require username
-username_unnecessary = ['1984', 'cloudflare', 'cloudns', 'duckdns', 'freemyip', 'hetzner', 'keysystems', 'njalla']
+username_unnecessary = ['1984', 'cloudflare', 'cloudns', 'digitalocean', 'dnsexit2',
+                        'duckdns', 'freemyip', 'hetzner', 'keysystems', 'njalla',
+                        'regfishde']
 
 # Protocols that support TTL
-ttl_supported = ['cloudflare', 'gandi', 'hetzner', 'dnsexit', 'godaddy', 'nfsn']
+ttl_supported = ['cloudflare', 'dnsexit2', 'gandi', 'hetzner', 'godaddy', 'nfsn']
 
 # Protocols that support both IPv4 and IPv6
-dualstack_supported = ['cloudflare', 'dyndns2', 'freedns', 'njalla']
+dualstack_supported = ['cloudflare', 'digitalocean', 'dnsexit2', 'duckdns',
+                       'dyndns2', 'easydns', 'freedns', 'hetzner', 'infomaniak',
+                       'njalla']
 
 # dyndns2 protocol in ddclient honors dual stack for selective servers
 # because of the way it is implemented in ddclient
@@ -88,32 +93,31 @@ def verify(dyndns):
         # Dynamic DNS service provider - configuration validation
         if 'service' in dyndns['address'][address]:
             for service, config in dyndns['address'][address]['service'].items():
-                error_msg = f'is required for Dynamic DNS service "{service}" on "{address}"'
+                error_msg_req = f'is required for Dynamic DNS service "{service}" on "{address}" with protocol "{config["protocol"]}"'
+                error_msg_uns = f'is not supported for Dynamic DNS service "{service}" on "{address}" with protocol "{config["protocol"]}"'
 
                 for field in ['host_name', 'password', 'protocol']:
                     if field not in config:
-                        raise ConfigError(f'"{field.replace("_", "-")}" {error_msg}')
+                        raise ConfigError(f'"{field.replace("_", "-")}" {error_msg_req}')
 
                 if config['protocol'] in zone_necessary and 'zone' not in config:
-                    raise ConfigError(f'"zone" {error_msg}')
+                    raise ConfigError(f'"zone" {error_msg_req}')
 
-                if config['protocol'] not in zone_necessary and 'zone' in config:
-                    raise ConfigError(f'"{config["protocol"]}" does not support "zone"')
+                if config['protocol'] not in zone_supported and 'zone' in config:
+                    raise ConfigError(f'"zone" {error_msg_uns}')
 
                 if config['protocol'] not in username_unnecessary and 'username' not in config:
-                    raise ConfigError(f'"username" {error_msg}')
+                    raise ConfigError(f'"username" {error_msg_req}')
 
                 if config['protocol'] not in ttl_supported and 'ttl' in config:
-                    raise ConfigError(f'"{config["protocol"]}" does not support "ttl"')
+                    raise ConfigError(f'"ttl" {error_msg_uns}')
 
                 if config['ip_version'] == 'both':
                     if config['protocol'] not in dualstack_supported:
-                        raise ConfigError(f'"{config["protocol"]}" does not support '
-                                          f'both IPv4 and IPv6 at the same time')
+                        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'"{config["protocol"]}" does not support '
-                                          f'both IPv4 and IPv6 at the same time for "{config["server"]}"')
+                        raise ConfigError(f'Both IPv4 and IPv6 at the same time {error_msg_uns} for "{config["server"]}"')
 
                 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"')
diff --git a/src/migration-scripts/dns-dynamic/1-to-2 b/src/migration-scripts/dns-dynamic/1-to-2
index 8aaedf210..8b599b57a 100644
--- a/src/migration-scripts/dns-dynamic/1-to-2
+++ b/src/migration-scripts/dns-dynamic/1-to-2
@@ -18,6 +18,8 @@
 # - migrate "service dns dynamic timeout ..."
 #        to "service dns dynamic interval ..."
 # - remove "service dns dynamic address <interface> web-options ..." when <interface> != "web"
+# - migrate "service dns dynamic address <interface> service <service> protocol dnsexit"
+#        to "service dns dynamic address <interface> service <service> protocol dnsexit2"
 
 import sys
 from vyos.configtree import ConfigTree
@@ -51,6 +53,15 @@ for address in config.list_nodes(address_path):
     if config.exists(address_path + [address, 'web-options']) and address != 'web':
         config.delete(address_path + [address, 'web-options'])
 
+# Migrate "service dns dynamic address <interface> service <service> protocol dnsexit"
+#      to "service dns dynamic address <interface> service <service> protocol dnsexit2"
+for address in config.list_nodes(address_path):
+    for svc_cfg in config.list_nodes(address_path + [address, 'service']):
+        if config.exists(address_path + [address, 'service', svc_cfg, 'protocol']):
+            protocol = config.return_value(address_path + [address, 'service', svc_cfg, 'protocol'])
+            if protocol == 'dnsexit':
+                config.set(address_path + [address, 'service', svc_cfg, 'protocol'], 'dnsexit2')
+
 try:
     with open(file_name, 'w') as f:
         f.write(config.to_string())
diff --git a/src/validators/ddclient-protocol b/src/validators/ddclient-protocol
index bc6826120..8f455e12e 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' | grep -qw $1
+ddclient -list-protocols | grep -vE 'nsupdate|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"
-- 
cgit v1.2.3


From 7092d85ea7d949e65655debe531e17a2220889ad Mon Sep 17 00:00:00 2001
From: Indrajit Raychaudhuri <irc@indrajit.com>
Date: Thu, 9 Nov 2023 16:12:58 -0600
Subject: ddclient: T5708: Additional smoketests for web-options

Add additional smoketests for web-options validation.
Also, format error messages to optionally include protocol name.
---
 smoketest/scripts/cli/test_service_dns_dynamic.py | 53 ++++++++++++++++++++++-
 src/conf_mode/dns_dynamic.py                      |  6 +--
 2 files changed, 55 insertions(+), 4 deletions(-)

(limited to 'src/conf_mode')

diff --git a/smoketest/scripts/cli/test_service_dns_dynamic.py b/smoketest/scripts/cli/test_service_dns_dynamic.py
index 69ea5c1b3..a3b220f69 100755
--- a/smoketest/scripts/cli/test_service_dns_dynamic.py
+++ b/smoketest/scripts/cli/test_service_dns_dynamic.py
@@ -245,7 +245,58 @@ class TestServiceDDNS(VyOSUnitTestSHIM.TestCase):
             self.assertIn(f'password=\'{password}\'', ddclient_conf)
             self.assertIn(f'{name}', ddclient_conf)
 
-    def test_06_dyndns_vrf(self):
+    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']
+        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])
+
+        # 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_commit()
+        self.cli_delete(base_path_iface + ['web-options'])
+
+        # 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])
+
+        # commit changes
+        self.cli_commit()
+
+        # Check the generating config parameters
+        ddclient_conf = cmd(f'sudo cat {DDCLIENT_CONF}')
+        self.assertIn(f'usev4=webv4', ddclient_conf)
+        self.assertIn(f'webv4={web_url_good}', ddclient_conf)
+        self.assertIn(f'protocol={proto}', ddclient_conf)
+        self.assertIn(f'zone={zone}', ddclient_conf)
+        self.assertIn(f'password=\'{password}\'', ddclient_conf)
+        self.assertIn(f'{hostname}', ddclient_conf)
+
+    def test_07_dyndns_vrf(self):
         vrf_table = "".join(random.choices(string.digits, k=5))
         vrf_name = f'vyos-test-{vrf_table}'
         svc_path = ['address', interface, 'service', 'cloudflare']
diff --git a/src/conf_mode/dns_dynamic.py b/src/conf_mode/dns_dynamic.py
index d6ef620fe..2bccaee0f 100755
--- a/src/conf_mode/dns_dynamic.py
+++ b/src/conf_mode/dns_dynamic.py
@@ -93,7 +93,7 @@ def verify(dyndns):
         # 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}" with protocol "{config["protocol"]}"'
+                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"]}"'
 
                 for field in ['host_name', 'password', 'protocol']:
@@ -101,13 +101,13 @@ def verify(dyndns):
                         raise ConfigError(f'"{field.replace("_", "-")}" {error_msg_req}')
 
                 if config['protocol'] in zone_necessary and 'zone' not in config:
-                    raise ConfigError(f'"zone" {error_msg_req}')
+                    raise ConfigError(f'"zone" {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'] not in username_unnecessary and 'username' not in config:
-                    raise ConfigError(f'"username" {error_msg_req}')
+                    raise ConfigError(f'"username" {error_msg_req} with protocol "{config["protocol"]}"')
 
                 if config['protocol'] not in ttl_supported and 'ttl' in config:
                     raise ConfigError(f'"ttl" {error_msg_uns}')
-- 
cgit v1.2.3