diff options
author | Christian Breunig <christian@breunig.cc> | 2023-06-05 22:04:21 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-06-05 22:04:21 +0200 |
commit | 47cce6805562c11b75ef9d5d761881e275d1a27d (patch) | |
tree | e75bac5474b8f47cf0a37c47fa6acf78e2116e92 | |
parent | 3cfeddebb73e12de15d46d13c44003ede2d72c19 (diff) | |
parent | c14825f55d286d54ca3c04703ecbded1cb4c2cca (diff) | |
download | vyos-1x-47cce6805562c11b75ef9d5d761881e275d1a27d.tar.gz vyos-1x-47cce6805562c11b75ef9d5d761881e275d1a27d.zip |
Merge pull request #2005 from indrajitr/ddclient-improvement-round-2
dns: T5144: Modernize dynamic dns operation (round 2)
-rw-r--r-- | data/templates/dns-dynamic/ddclient.conf.j2 | 72 | ||||
-rw-r--r-- | data/templates/dns-dynamic/override.conf.j2 | 11 | ||||
-rw-r--r-- | data/templates/dynamic-dns/ddclient.conf.j2 | 53 | ||||
-rw-r--r-- | interface-definitions/dns-dynamic.xml.in | 192 | ||||
-rw-r--r-- | interface-definitions/include/dns/dynamic-service-host-name-server.xml.i | 34 | ||||
-rw-r--r-- | interface-definitions/include/version/dns-dynamic-version.xml.i | 3 | ||||
-rw-r--r-- | interface-definitions/xml-component-version.xml.in | 1 | ||||
-rw-r--r-- | op-mode-definitions/dns-dynamic.xml.in | 6 | ||||
-rwxr-xr-x | smoketest/scripts/cli/test_service_dns_dynamic.py | 163 | ||||
-rwxr-xr-x | src/conf_mode/dns_dynamic.py | 134 | ||||
-rwxr-xr-x | src/conf_mode/dynamic_dns.py | 156 | ||||
-rw-r--r-- | src/etc/systemd/system/ddclient.service.d/override.conf | 11 | ||||
-rwxr-xr-x | src/migration-scripts/dns-dynamic/0-to-1 | 104 | ||||
-rwxr-xr-x | src/op_mode/dns_dynamic.py (renamed from src/op_mode/dynamic_dns.py) | 0 |
14 files changed, 520 insertions, 420 deletions
diff --git a/data/templates/dns-dynamic/ddclient.conf.j2 b/data/templates/dns-dynamic/ddclient.conf.j2 new file mode 100644 index 000000000..a19b79c00 --- /dev/null +++ b/data/templates/dns-dynamic/ddclient.conf.j2 @@ -0,0 +1,72 @@ +{% macro render_config(host, address, web_options, ip_suffixes=['']) %} +{# Address: use=if, if=ethX, usev6=ifv6, ifv6=ethX, usev6=webv6, webv6=https://v6.example.com #} +{% for ipv in ip_suffixes %} +use{{ ipv }}={{ address if address == 'web' else 'if' }}{{ ipv }}, \ +{% if address == 'web' %} +{% if web_options.url is vyos_defined %} +web{{ ipv }}={{ web_options.url }}, \ +{% endif %} +{% if web_options.skip is vyos_defined %} +web-skip{{ ipv }}='{{ web_options.skip }}', \ +{% endif %} +{% else %} +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 %} +{% endfor %} +{# Actual hostname for the service #} +{{ host }} +{% endmacro %} +### Autogenerated by dns_dynamic.py ### +daemon=1m +syslog=yes +ssl=yes +pid={{ config_file | replace('.conf', '.pid') }} +cache={{ config_file | replace('.conf', '.cache') }} + +{% 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 %} +# {{ 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 (['v6'] if config.ip_version == 'ipv6' else ['']) %} +# Web service dynamic DNS configuration for {{ name }}: [{{ config.protocol }}, {{ host }}] +{# For ipv4 only setup, don't append 'new-style' compliant suffix ('usev4', 'ifv4', 'webv4' etc.) + to the properties and instead live through the deprecation warnings for better compatibility + with most ddclient protocols. #} +{{ 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) }} + +{% endfor %} +{% endfor %} +{% endif %} +{% endfor %} +{% endif %} diff --git a/data/templates/dns-dynamic/override.conf.j2 b/data/templates/dns-dynamic/override.conf.j2 new file mode 100644 index 000000000..8a9dfcd70 --- /dev/null +++ b/data/templates/dns-dynamic/override.conf.j2 @@ -0,0 +1,11 @@ +{% set vrf_command = 'ip vrf exec ' ~ vrf ~ ' ' if vrf is vyos_defined else '' %} +[Unit] +ConditionPathExists={{ config_file }} +After=vyos-router.service + +[Service] +PIDFile= +PIDFile={{ config_file | replace('.conf', '.pid') }} +EnvironmentFile= +ExecStart= +ExecStart=/usr/bin/ddclient -file {{ config_file }} diff --git a/data/templates/dynamic-dns/ddclient.conf.j2 b/data/templates/dynamic-dns/ddclient.conf.j2 deleted file mode 100644 index e8ef5ac90..000000000 --- a/data/templates/dynamic-dns/ddclient.conf.j2 +++ /dev/null @@ -1,53 +0,0 @@ -### Autogenerated by dynamic_dns.py ### -daemon=1m -syslog=yes -ssl=yes - -{% if interface is vyos_defined %} -{% for iface, iface_config in interface.items() %} -# ddclient configuration for interface "{{ iface }}" -{% if iface_config.use_web is vyos_defined %} -{% set web_skip = ", web-skip='" ~ iface_config.use_web.skip ~ "'" if iface_config.use_web.skip is vyos_defined else '' %} -use=web, web='{{ iface_config.use_web.url }}'{{ web_skip }} -{% else %} -{{ 'usev6=ifv6' if iface_config.ipv6_enable is vyos_defined else 'use=if' }}, if={{ iface }} -{% endif %} - -{% if iface_config.rfc2136 is vyos_defined %} -{% for rfc2136, config in iface_config.rfc2136.items() %} -{% for dns_record in config.record if config.record is vyos_defined %} -# RFC2136 dynamic DNS configuration for {{ rfc2136 }}, {{ config.zone }}, {{ dns_record }} -server={{ config.server }} -protocol=nsupdate -password={{ config.key }} -ttl={{ config.ttl }} -zone={{ config.zone }} -{{ dns_record }} - -{% endfor %} -{% endfor %} -{% endif %} - -{% if iface_config.service is vyos_defined %} -{% for service, config in iface_config.service.items() %} -{% for dns_record in config.host_name %} -# DynDNS provider configuration for {{ service }}, {{ dns_record }} -protocol={{ config.protocol }}, -max-interval=28d, -{% if config.login is vyos_defined %} -login={{ config.login }}, -{% endif %} -password='{{ config.password }}', -{% if config.server is vyos_defined %} -server={{ config.server }}, -{% endif %} -{% if config.zone is vyos_defined %} -zone={{ config.zone }}, -{% endif %} -{{ dns_record }} - -{% endfor %} -{% endfor %} -{% endif %} -{% endfor %} -{% endif %} diff --git a/interface-definitions/dns-dynamic.xml.in b/interface-definitions/dns-dynamic.xml.in index 48c101d73..292c50603 100644 --- a/interface-definitions/dns-dynamic.xml.in +++ b/interface-definitions/dns-dynamic.xml.in @@ -7,146 +7,99 @@ <help>Domain Name System related services</help> </properties> <children> - <node name="dynamic" owner="${vyos_conf_scripts_dir}/dynamic_dns.py"> + <node name="dynamic" owner="${vyos_conf_scripts_dir}/dns_dynamic.py"> <properties> <help>Dynamic DNS</help> </properties> <children> - <tagNode name="interface"> + <tagNode name="address"> <properties> - <help>Interface to send Dynamic DNS updates for</help> - <completionHelp> - <script>${vyos_completion_dir}/list_interfaces</script> - </completionHelp> + <help>Obtain IP address to send Dynamic DNS update for</help> <valueHelp> <format>txt</format> - <description>Interface name</description> + <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> <children> - <tagNode name="rfc2136"> + <node name="web-options"> <properties> - <help>RFC2136 Update name</help> + <help>Options when using HTTP(S) web request to obtain the IP address</help> </properties> <children> - <leafNode name="key"> + #include <include/url.xml.i> + <leafNode name="skip"> <properties> - <help>File containing the secret key shared with remote DNS server</help> + <help>Pattern to skip from the HTTP(S) respose</help> <valueHelp> - <format>filename</format> - <description>File in /config/auth directory</description> + <format>txt</format> + <description>Pattern to skip from the HTTP(S) respose to extract the external IP address</description> </valueHelp> </properties> </leafNode> - <leafNode name="record"> - <properties> - <help>Record to be updated</help> - <multi/> - </properties> - </leafNode> - <leafNode name="server"> - <properties> - <help>Server to be updated</help> - </properties> - </leafNode> - <leafNode name="ttl"> + </children> + </node> + <tagNode name="rfc2136"> + <properties> + <help>RFC2136 nsupdate configuration</help> + <valueHelp> + <format>txt</format> + <description>RFC2136 nsupdate service name</description> + </valueHelp> + </properties> + <children> + #include <include/generic-description.xml.i> + #include <include/dns/dynamic-service-host-name-server.xml.i> + <leafNode name="key"> <properties> - <help>Time To Live (default: 600)</help> + <help>File containing the TSIG secret key shared with remote DNS server</help> <valueHelp> - <format>u32:1-86400</format> - <description>DNS forwarding cache size</description> + <format>filename</format> + <description>File in /config/auth directory</description> </valueHelp> <constraint> - <validator name="numeric" argument="--range 1-86400"/> + <validator name="file-path" argument="--strict --parent-dir /config/auth"/> </constraint> </properties> - <defaultValue>600</defaultValue> </leafNode> + #include <include/dns/time-to-live.xml.i> <leafNode name="zone"> <properties> - <help>Zone to be updated</help> + <help>Forwarding zone to be updated</help> + <valueHelp> + <format>txt</format> + <description>RFC2136 Zone to be updated</description> + </valueHelp> + <constraint> + <validator name="fqdn"/> + </constraint> </properties> </leafNode> </children> </tagNode> <tagNode name="service"> <properties> - <help>Service being used for Dynamic DNS</help> - <completionHelp> - <list>afraid changeip cloudflare dnspark dslreports dyndns easydns namecheap noip sitelutions zoneedit</list> - </completionHelp> + <help>Dynamic DNS configuration</help> <valueHelp> <format>txt</format> - <description>Dynanmic DNS service with a custom name</description> - </valueHelp> - <valueHelp> - <format>afraid</format> - <description>afraid.org Services</description> - </valueHelp> - <valueHelp> - <format>changeip</format> - <description>changeip.com Services</description> - </valueHelp> - <valueHelp> - <format>cloudflare</format> - <description>cloudflare.com Services</description> + <description>Dynamic DNS service name</description> </valueHelp> - <valueHelp> - <format>dnspark</format> - <description>dnspark.com Services</description> - </valueHelp> - <valueHelp> - <format>dslreports</format> - <description>dslreports.com Services</description> - </valueHelp> - <valueHelp> - <format>dyndns</format> - <description>dyndns.com Services</description> - </valueHelp> - <valueHelp> - <format>easydns</format> - <description>easydns.com Services</description> - </valueHelp> - <valueHelp> - <format>namecheap</format> - <description>namecheap.com Services</description> - </valueHelp> - <valueHelp> - <format>noip</format> - <description>noip.com Services</description> - </valueHelp> - <valueHelp> - <format>sitelutions</format> - <description>sitelutions.com Services</description> - </valueHelp> - <valueHelp> - <format>zoneedit</format> - <description>zoneedit.com Services</description> - </valueHelp> - <constraint> - <regex>(custom|afraid|changeip|cloudflare|dnspark|dslreports|dyndns|easydns|namecheap|noip|sitelutions|zoneedit|\w+)</regex> - </constraint> - <constraintErrorMessage>You can use only predefined list of services or word characters (_, a-z, A-Z, 0-9) as service name</constraintErrorMessage> </properties> <children> - <leafNode name="host-name"> - <properties> - <help>Hostname to register with Dynamic DNS service</help> - <constraint> - #include <include/constraint/host-name.xml.i> - </constraint> - <constraintErrorMessage>Host-name must be alphanumeric and can contain hyphens</constraintErrorMessage> - <multi/> - </properties> - </leafNode> - <leafNode name="login"> - <properties> - <help>Login/Username for Dynamic DNS service</help> - </properties> - </leafNode> + #include <include/generic-description.xml.i> + #include <include/dns/dynamic-service-host-name-server.xml.i> + #include <include/generic-username.xml.i> #include <include/generic-password.xml.i> <leafNode name="protocol"> <properties> @@ -159,7 +112,6 @@ </constraint> </properties> </leafNode> - #include <include/server-ipv4-fqdn.xml.i> <leafNode name="zone"> <properties> <help>DNS zone to update (not used by all protocols)</help> @@ -169,31 +121,33 @@ </valueHelp> </properties> </leafNode> - </children> - </tagNode> - <node name="use-web"> - <properties> - <help>Use HTTP(S) web request to obtain external IP address instead of the IP address associated with the interface</help> - </properties> - <children> - <leafNode name="skip"> + <leafNode name="ip-version"> <properties> - <help>Pattern to skip from the respose</help> + <help>IP address version to use</help> <valueHelp> - <format>txt</format> - <description>Pattern to skip from the respose of the given URL to extract the external IP address</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> + <defaultValue>ipv4</defaultValue> </leafNode> - #include <include/url.xml.i> </children> - </node> - <leafNode name="ipv6-enable"> - <properties> - <help>Explicitly use IPv6 address instead of IPv4 address to update the Dynamic DNS IP address</help> - <valueless/> - </properties> - </leafNode> + </tagNode> </children> </tagNode> </children> 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 new file mode 100644 index 000000000..ee1af2a36 --- /dev/null +++ b/interface-definitions/include/dns/dynamic-service-host-name-server.xml.i @@ -0,0 +1,34 @@ +<!-- 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> + </constraint> + <constraintErrorMessage>Host-name must be alphanumeric and can contain hyphens</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/version/dns-dynamic-version.xml.i b/interface-definitions/include/version/dns-dynamic-version.xml.i new file mode 100644 index 000000000..b25fc6e76 --- /dev/null +++ b/interface-definitions/include/version/dns-dynamic-version.xml.i @@ -0,0 +1,3 @@ +<!-- include start from include/version/dns-dynamic-version.xml.i --> +<syntaxVersion component='dns-dynamic' version='1'></syntaxVersion> +<!-- include end --> diff --git a/interface-definitions/xml-component-version.xml.in b/interface-definitions/xml-component-version.xml.in index e05f64643..8c9e816d1 100644 --- a/interface-definitions/xml-component-version.xml.in +++ b/interface-definitions/xml-component-version.xml.in @@ -10,6 +10,7 @@ #include <include/version/dhcp-relay-version.xml.i> #include <include/version/dhcp-server-version.xml.i> #include <include/version/dhcpv6-server-version.xml.i> + #include <include/version/dns-dynamic-version.xml.i> #include <include/version/dns-forwarding-version.xml.i> #include <include/version/firewall-version.xml.i> #include <include/version/flow-accounting-version.xml.i> diff --git a/op-mode-definitions/dns-dynamic.xml.in b/op-mode-definitions/dns-dynamic.xml.in index 9c37874fb..8047d55cd 100644 --- a/op-mode-definitions/dns-dynamic.xml.in +++ b/op-mode-definitions/dns-dynamic.xml.in @@ -30,7 +30,7 @@ <properties> <help>Show Dynamic DNS status</help> </properties> - <command>sudo ${vyos_op_scripts_dir}/dynamic_dns.py --status</command> + <command>sudo ${vyos_op_scripts_dir}/dns_dynamic.py --status</command> </leafNode> </children> </node> @@ -46,7 +46,7 @@ <properties> <help>Restart Dynamic DNS service</help> </properties> - <command>sudo ${vyos_op_scripts_dir}/dynamic_dns.py --update</command> + <command>sudo ${vyos_op_scripts_dir}/dns_dynamic.py --update</command> </node> </children> </node> @@ -66,7 +66,7 @@ <properties> <help>Update Dynamic DNS information</help> </properties> - <command>sudo ${vyos_op_scripts_dir}/dynamic_dns.py --update</command> + <command>sudo ${vyos_op_scripts_dir}/dns_dynamic.py --update</command> </node> </children> </node> diff --git a/smoketest/scripts/cli/test_service_dns_dynamic.py b/smoketest/scripts/cli/test_service_dns_dynamic.py index 4a3c05a36..044d053b4 100755 --- a/smoketest/scripts/cli/test_service_dns_dynamic.py +++ b/smoketest/scripts/cli/test_service_dns_dynamic.py @@ -17,13 +17,13 @@ import re import os import unittest +import tempfile from base_vyostest_shim import VyOSUnitTestSHIM from vyos.configsession import ConfigSessionError from vyos.util import cmd from vyos.util import process_running -from vyos.util import read_file DDCLIENT_CONF = '/run/ddclient/ddclient.conf' DDCLIENT_PID = '/run/ddclient/ddclient.pid' @@ -35,7 +35,7 @@ interface = 'eth0' def get_config_value(key): tmp = cmd(f'sudo cat {DDCLIENT_CONF}') tmp = re.findall(r'\n?{}=+(.*)'.format(key), tmp) - tmp = tmp[0].rstrip(',') + tmp = tmp[0].rstrip(', \\') return tmp class TestServiceDDNS(VyOSUnitTestSHIM.TestCase): @@ -51,118 +51,125 @@ class TestServiceDDNS(VyOSUnitTestSHIM.TestCase): self.assertFalse(os.path.exists(DDCLIENT_PID)) def test_dyndns_service(self): - from itertools import product - ddns = ['interface', interface, 'service'] - users = [None, 'vyos_user'] - services = ['cloudflare', 'afraid', 'dyndns', 'zoneedit'] - - for user, service in product(users, services): - password = 'vyos_pass' - zone = 'vyos.io' + ddns = ['address', interface, 'service'] + services = {'cloudflare': {'protocol': 'cloudflare', 'zone': 'vyos.io'}, + 'freedns': {'protocol': 'freedns', 'username': 'vyos_user'}, + 'zoneedit': {'protocol': 'zoneedit1', 'username': 'vyos_user'}} + password = 'vyos_pass' + zone = 'vyos.io' + + for svc, details in services.items(): self.cli_delete(base_path) - self.cli_set(base_path + ddns + [service, 'host-name', hostname]) - if user is not None: - self.cli_set(base_path + ddns + [service, 'login', user]) - self.cli_set(base_path + ddns + [service, 'password', password]) - self.cli_set(base_path + ddns + [service, 'zone', zone]) + self.cli_set(base_path + ddns + [svc, 'host-name', hostname]) + for opt, value in details.items(): + self.cli_set(base_path + ddns + [svc, opt, value]) + self.cli_set(base_path + ddns + [svc, 'password', password]) + self.cli_set(base_path + ddns + [svc, 'zone', zone]) # commit changes - if service == 'cloudflare': + if details['protocol'] == 'cloudflare': self.cli_commit() - elif user is None: - # not set user is only allowed for cloudflare - with self.assertRaises(ConfigSessionError): - # remove zone to test not set user - self.cli_delete(base_path + ddns + [service, 'zone', 'vyos.io']) - self.cli_commit() - # this case is fininshed, user not set is not allowed when service isn't cloudflare - continue else: - # zone option only works on cloudflare, an exception is raised - # for all others + # zone option does not work on all protocols, an exception is + # raised for all others with self.assertRaises(ConfigSessionError): self.cli_commit() - self.cli_delete(base_path + ddns + [service, 'zone', 'vyos.io']) + self.cli_delete(base_path + ddns + [svc, 'zone', zone]) # commit changes again - now it should work self.cli_commit() - # we can only read the configuration file when we operate as 'root' - protocol = get_config_value('protocol') - login = None if user is None else get_config_value('login') - pwd = get_config_value('password') - - # some services need special treatment - protoname = service - if service == 'cloudflare': - tmp = get_config_value('zone') - self.assertTrue(tmp == zone) - elif service == 'afraid': - protoname = 'freedns' - elif service == 'dyndns': - protoname = 'dyndns2' - elif service == 'zoneedit': - protoname = 'zoneedit1' - - self.assertTrue(protocol == protoname) - self.assertTrue(login == user) - self.assertTrue(pwd == "'" + password + "'") + for opt in details.keys(): + if opt == 'username': + self.assertTrue(get_config_value('login') == details[opt]) + else: + self.assertTrue(get_config_value(opt) == details[opt]) + + self.assertTrue(get_config_value('use') == 'if') + self.assertTrue(get_config_value('if') == interface) def test_dyndns_rfc2136(self): # Check if DDNS service can be configured and runs - ddns = ['interface', interface, 'rfc2136', 'vyos'] - ddns_key_file = '/config/auth/my.key' + ddns = ['address', interface, 'rfc2136', 'vyos'] + srv = 'ns1.vyos.io' + zone = 'vyos.io' + ttl = '300' - self.cli_set(base_path + ddns + ['key', ddns_key_file]) - self.cli_set(base_path + ddns + ['record', 'test.ddns.vyos.io']) - self.cli_set(base_path + ddns + ['server', 'ns1.vyos.io']) - self.cli_set(base_path + ddns + ['ttl', '300']) - self.cli_set(base_path + ddns + ['zone', 'vyos.io']) + with tempfile.NamedTemporaryFile(prefix='/config/auth/') as key_file: + key_file.write(b'S3cretKey') - # ensure an exception will be raised as no key is present - if os.path.exists(ddns_key_file): - os.unlink(ddns_key_file) + self.cli_set(base_path + ddns + ['key', key_file.name]) + self.cli_set(base_path + ddns + ['host-name', hostname]) + self.cli_set(base_path + ddns + ['server', srv]) + self.cli_set(base_path + ddns + ['ttl', ttl]) + self.cli_set(base_path + ddns + ['zone', zone]) - # check validate() - the key file does not exist yet - with self.assertRaises(ConfigSessionError): + # commit changes self.cli_commit() - with open(ddns_key_file, 'w') as f: - f.write('S3cretKey') + # Check some generating config parameters + self.assertEqual(get_config_value('protocol'), 'nsupdate') + self.assertTrue(get_config_value('password') == key_file.name) + self.assertTrue(get_config_value('server') == srv) + self.assertTrue(get_config_value('zone') == zone) + self.assertTrue(get_config_value('ttl') == ttl) + self.assertEqual(get_config_value('use'), 'if') + self.assertEqual(get_config_value('if'), interface) + + def test_dyndns_dual(self): + ddns = ['address', interface, 'service'] + services = {'cloudflare': {'protocol': 'cloudflare', 'zone': 'vyos.io'}, + 'freedns': {'protocol': 'freedns', 'username': 'vyos_user'}} + password = 'vyos_pass' + ip_version = 'both' + + for svc, details in services.items(): + self.cli_delete(base_path) + self.cli_set(base_path + ddns + [svc, 'host-name', hostname]) + for opt, value in details.items(): + self.cli_set(base_path + ddns + [svc, opt, value]) + self.cli_set(base_path + ddns + [svc, 'password', password]) + self.cli_set(base_path + ddns + [svc, 'ip-version', ip_version]) - # commit changes - self.cli_commit() + # commit changes + self.cli_commit() - # TODO: inspect generated configuration file + # Check some generating config parameters + for opt in details.keys(): + if opt == 'username': + self.assertTrue(get_config_value('login') == details[opt]) + else: + self.assertTrue(get_config_value(opt) == details[opt]) + + self.assertTrue(get_config_value('usev4') == 'ifv4') + self.assertTrue(get_config_value('usev6') == 'ifv6') + self.assertTrue(get_config_value('ifv4') == interface) + self.assertTrue(get_config_value('ifv6') == interface) def test_dyndns_ipv6(self): - ddns = ['interface', interface, 'service', 'dynv6'] + ddns = ['address', interface, 'service', 'dynv6'] proto = 'dyndns2' user = 'none' password = 'paSS_4ord' srv = 'ddns.vyos.io' + ip_version = 'ipv6' - self.cli_set(base_path + ['interface', interface, 'ipv6-enable']) self.cli_set(base_path + ddns + ['host-name', hostname]) - self.cli_set(base_path + ddns + ['login', user]) + self.cli_set(base_path + ddns + ['username', user]) self.cli_set(base_path + ddns + ['password', password]) self.cli_set(base_path + ddns + ['protocol', proto]) self.cli_set(base_path + ddns + ['server', srv]) + self.cli_set(base_path + ddns + ['ip-version', ip_version]) # commit changes self.cli_commit() - protocol = get_config_value('protocol') - login = get_config_value('login') - pwd = get_config_value('password') - server = get_config_value('server') - usev6 = get_config_value('usev6') - # Check some generating config parameters - self.assertEqual(protocol, proto) - self.assertEqual(login, user) - self.assertEqual(pwd, f"'{password}'") - self.assertEqual(server, srv) - self.assertEqual(usev6, f"ifv6, if={interface}") + self.assertEqual(get_config_value('protocol'), proto) + self.assertEqual(get_config_value('login'), user) + self.assertEqual(get_config_value('password'), password) + self.assertEqual(get_config_value('server'), srv) + self.assertEqual(get_config_value('usev6'), 'ifv6') + self.assertEqual(get_config_value('ifv6'), interface) if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/src/conf_mode/dns_dynamic.py b/src/conf_mode/dns_dynamic.py new file mode 100755 index 000000000..f97225370 --- /dev/null +++ b/src/conf_mode/dns_dynamic.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2020 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/>. + +import os + +from sys import exit + +from vyos.config import Config +from vyos.configdict import dict_merge +from vyos.template import render +from vyos.util import call +from vyos.xml import defaults +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +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'] + +# Protocols that do not require username +username_unnecessary = ['1984', 'cloudflare', 'cloudns', 'duckdns', 'freemyip', 'hetzner', 'keysystems', 'njalla'] + +# Protocols that support both IPv4 and IPv6 +dualstack_supported = ['cloudflare', 'dyndns2', 'freedns', 'njalla'] + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + + base_level = ['service', 'dns', 'dynamic'] + if not conf.exists(base_level): + return None + + dyndns = conf.get_config_dict(base_level, key_mangling=('-', '_'), get_first_key=True) + + for address in dyndns['address']: + # Apply service specific defaults (stype = ['rfc2136', 'service']) + for svc_type in dyndns['address'][address]: + default_values = defaults(base_level + ['address', svc_type]) + for svc_cfg in dyndns['address'][address][svc_type]: + dyndns['address'][address][svc_type][svc_cfg] = dict_merge( + default_values, dyndns['address'][address][svc_type][svc_cfg]) + + dyndns['config_file'] = config_file + return dyndns + +def verify(dyndns): + # bail out early - looks like removal from running config + if not dyndns: + return None + + for address in dyndns['address']: + # 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}"') + + # 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}"' + + for field in ['host_name', 'password', 'protocol']: + if field not in config: + raise ConfigError(f'"{field.replace("_", "-")}" {error_msg}') + + if config['protocol'] in zone_allowed and 'zone' not in config: + raise ConfigError(f'"zone" {error_msg}') + + if config['protocol'] not in zone_allowed 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['ip_version'] == 'both': + if config['protocol'] not in dualstack_supported: + raise ConfigError(f'"{config["protocol"]}" does not support 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': + raise ConfigError(f'"{config["protocol"]}" for "{config["server"]}" does not support IPv4 and IPv6 at the same time') + + return None + +def generate(dyndns): + # bail out early - looks like removal from running config + if not dyndns: + return None + + render(config_file, 'dns-dynamic/ddclient.conf.j2', dyndns) + render(systemd_override, 'dns-dynamic/override.conf.j2', dyndns) + return None + +def apply(dyndns): + if not dyndns: + call('systemctl stop ddclient.service') + if os.path.exists(config_file): + os.unlink(config_file) + else: + call('systemctl restart ddclient.service') + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/dynamic_dns.py b/src/conf_mode/dynamic_dns.py deleted file mode 100755 index 426e3d693..000000000 --- a/src/conf_mode/dynamic_dns.py +++ /dev/null @@ -1,156 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2018-2020 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/>. - -import os - -from sys import exit - -from vyos.config import Config -from vyos.configdict import dict_merge -from vyos.template import render -from vyos.util import call -from vyos.xml import defaults -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -config_file = r'/run/ddclient/ddclient.conf' - -# Mapping of service name to service protocol -default_service_protocol = { - 'afraid': 'freedns', - 'changeip': 'changeip', - 'cloudflare': 'cloudflare', - 'dnspark': 'dnspark', - 'dslreports': 'dslreports1', - 'dyndns': 'dyndns2', - 'easydns': 'easydns', - 'namecheap': 'namecheap', - 'noip': 'noip', - 'sitelutions': 'sitelutions', - 'zoneedit': 'zoneedit1' -} - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - - base_level = ['service', 'dns', 'dynamic'] - if not conf.exists(base_level): - return None - - dyndns = conf.get_config_dict(base_level, key_mangling=('-', '_'), get_first_key=True) - - # We have gathered the dict representation of the CLI, but there are default - # options which we need to update into the dictionary retrived. - for interface in dyndns['interface']: - if 'service' in dyndns['interface'][interface]: - # 'Autodetect' protocol used by DynDNS service - for service in dyndns['interface'][interface]['service']: - if service in default_service_protocol: - dyndns['interface'][interface]['service'][service].update( - {'protocol' : default_service_protocol.get(service)}) - else: - dyndns['interface'][interface]['service'][service].update( - {'custom': ''}) - - if 'rfc2136' in dyndns['interface'][interface]: - default_values = defaults(base_level + ['interface', 'rfc2136']) - for rfc2136 in dyndns['interface'][interface]['rfc2136']: - dyndns['interface'][interface]['rfc2136'][rfc2136] = dict_merge( - default_values, dyndns['interface'][interface]['rfc2136'][rfc2136]) - - return dyndns - -def verify(dyndns): - # bail out early - looks like removal from running config - if not dyndns: - return None - - # A 'node' corresponds to an interface - if 'interface' not in dyndns: - return None - - for interface in dyndns['interface']: - # RFC2136 - configuration validation - if 'rfc2136' in dyndns['interface'][interface]: - for rfc2136, config in dyndns['interface'][interface]['rfc2136'].items(): - - for tmp in ['record', 'zone', 'server', 'key']: - if tmp not in config: - raise ConfigError(f'"{tmp}" required for rfc2136 based ' - f'DynDNS service on "{interface}"') - - if not os.path.isfile(config['key']): - raise ConfigError(f'"key"-file not found for rfc2136 based ' - f'DynDNS service on "{interface}"') - - # DynDNS service provider - configuration validation - if 'service' in dyndns['interface'][interface]: - for service, config in dyndns['interface'][interface]['service'].items(): - error_msg = f'required for DynDNS service "{service}" on "{interface}"' - if 'host_name' not in config: - raise ConfigError(f'"host-name" {error_msg}') - - if 'login' not in config: - if service != 'cloudflare' and ('protocol' not in config or config['protocol'] != 'cloudflare'): - raise ConfigError(f'"login" (username) {error_msg}, unless using CloudFlare') - - if 'password' not in config: - raise ConfigError(f'"password" {error_msg}') - - if 'zone' in config: - if service != 'cloudflare' and ('protocol' not in config or config['protocol'] != 'cloudflare'): - raise ConfigError(f'"zone" option only supported with CloudFlare') - - if 'custom' in config: - if 'protocol' not in config: - raise ConfigError(f'"protocol" {error_msg}') - - if 'server' not in config: - raise ConfigError(f'"server" {error_msg}') - - return None - -def generate(dyndns): - # bail out early - looks like removal from running config - if not dyndns: - return None - - render(config_file, 'dynamic-dns/ddclient.conf.j2', dyndns) - return None - -def apply(dyndns): - if not dyndns: - call('systemctl stop ddclient.service') - if os.path.exists(config_file): - os.unlink(config_file) - else: - call('systemctl restart ddclient.service') - - return None - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - exit(1) diff --git a/src/etc/systemd/system/ddclient.service.d/override.conf b/src/etc/systemd/system/ddclient.service.d/override.conf deleted file mode 100644 index 09d929d39..000000000 --- a/src/etc/systemd/system/ddclient.service.d/override.conf +++ /dev/null @@ -1,11 +0,0 @@ -[Unit] -After= -After=vyos-router.service - -[Service] -WorkingDirectory= -WorkingDirectory=/run/ddclient -PIDFile= -PIDFile=/run/ddclient/ddclient.pid -ExecStart= -ExecStart=/usr/bin/ddclient -cache /run/ddclient/ddclient.cache -pid /run/ddclient/ddclient.pid -file /run/ddclient/ddclient.conf diff --git a/src/migration-scripts/dns-dynamic/0-to-1 b/src/migration-scripts/dns-dynamic/0-to-1 new file mode 100755 index 000000000..cf0983b01 --- /dev/null +++ b/src/migration-scripts/dns-dynamic/0-to-1 @@ -0,0 +1,104 @@ +#!/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/>. + +# T5144: +# - migrate "service dns dynamic interface ..." +# to "service dns dynamic address ..." +# - migrate "service dns dynamic interface <interface> use-web ..." +# to "service dns dynamic address <address> web-options ..." +# - migrate "service dns dynamic interface <interface> rfc2136 <config> record ..." +# to "service dns dynamic address <address> rfc2136 <config> host-name ..." +# - migrate "service dns dynamic interface <interface> service <config> login ..." +# to "service dns dynamic address <address> service <config> username ..." +# - apply global 'ipv6-enable' to per <config> 'ip-version: ipv6' +# - apply service protocol mapping upfront, they are not 'auto-detected' anymore + +import sys +from vyos.configtree import ConfigTree + +service_protocol_mapping = { + 'afraid': 'freedns', + 'changeip': 'changeip', + 'cloudflare': 'cloudflare', + 'dnspark': 'dnspark', + 'dslreports': 'dslreports1', + 'dyndns': 'dyndns2', + 'easydns': 'easydns', + 'namecheap': 'namecheap', + 'noip': 'noip', + 'sitelutions': 'sitelutions', + 'zoneedit': 'zoneedit1' +} + +if (len(sys.argv) < 1): + 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) + +old_base_path = ['service', 'dns', 'dynamic', 'interface'] +new_base_path = ['service', 'dns', 'dynamic', 'address'] + +if not config.exists(old_base_path): + # Nothing to do + sys.exit(0) + +# Migrate "service dns dynamic interface" +# to "service dns dynamic address" +config.rename(old_base_path, new_base_path[-1]) + +for address in config.list_nodes(new_base_path): + # Migrate "service dns dynamic interface <interface> rfc2136 <config> record" + # to "service dns dynamic address <address> rfc2136 <config> host-name" + if config.exists(new_base_path + [address, 'rfc2136']): + for rfc_cfg in config.list_nodes(new_base_path + [address, 'rfc2136']): + if config.exists(new_base_path + [address, 'rfc2136', rfc_cfg, 'record']): + config.rename(new_base_path + [address, 'rfc2136', rfc_cfg, 'record'], 'host-name') + + # Migrate "service dns dynamic interface <interface> service <config> login" + # to "service dns dynamic address <address> service <config> username" + if config.exists(new_base_path + [address, 'service']): + for svc_cfg in config.list_nodes(new_base_path + [address, 'service']): + if config.exists(new_base_path + [address, 'service', svc_cfg, 'login']): + config.rename(new_base_path + [address, 'service', svc_cfg, 'login'], 'username') + # Apply global 'ipv6-enable' to per <config> 'ip-version: ipv6' + if config.exists(new_base_path + [address, 'ipv6-enable']): + config.set(new_base_path + [address, 'service', svc_cfg, 'ip-version'], + value='ipv6', replace=False) + config.delete(new_base_path + [address, 'ipv6-enable']) + # Apply service protocol mapping upfront, they are not 'auto-detected' anymore + if svc_cfg in service_protocol_mapping: + config.set(new_base_path + [address, 'service', svc_cfg, 'protocol'], + value=service_protocol_mapping.get(svc_cfg), replace=False) + + # Migrate "service dns dynamic interface <interface> use-web" + # to "service dns dynamic address <address> web-options" + # Also, rename <address> to 'web' literal for backward compatibility + if config.exists(new_base_path + [address, 'use-web']): + config.rename(new_base_path + [address], 'web') + config.rename(new_base_path + ['web', 'use-web'], 'web-options') + +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/op_mode/dynamic_dns.py b/src/op_mode/dns_dynamic.py index d41a74db3..d41a74db3 100755 --- a/src/op_mode/dynamic_dns.py +++ b/src/op_mode/dns_dynamic.py |