From f2392055a33e537ce88c6a3124106bfd3b8b8e57 Mon Sep 17 00:00:00 2001 From: Indrajit Raychaudhuri Date: Sun, 23 Apr 2023 18:25:21 -0500 Subject: dns: T5144: Modernize dynamic dns operation Apply next round of configuration tree updates to 'service dns dynamic' with the following changes: - Migrate `service dns dynamic interface [use-web]` to `service dns dynamic address ` or `service dns dynamic address web [web-options]` This communicates the intent that dynamic dns IP address is detected in only one way - using the `` or using an external web request, not both. - When using external web request, (`service dns dynamic address web`), external url is optional (`web-options url`). Ddclient defaults are used when unspecified, - Rename all config `login` to `username` for consistency and also to align better with alternative ddclient backends in consideration. - Apply global 'ipv6-enable' to per service 'ip-version: ipv6'. Selecting usage of IPv4 or IPv6 (or both simultaneously) is now at per service (protocol) level instead of global level. This allows more control on the ability to select IPv4 in some cases and IPv6 in some other cases wherever supported by the underlying ddclient protocol. - While the IP address (and by extension, the detection mechanism) is global, the way it is applied to a particular ddclient protocol depends on whether it supports IPv4 or IPv6 or both. - Related to the above, this also prevents generating incorrect config file (`ddclient.conf`) with multiple global sections leading to an unpredictable behavior of ddclient. - Implement provider (protocol) specific custom tweaks whenever possible (e.g., `zone`, `username`, `server` are not necessary in all cases). - Move service name from a combination of 'protocol' (with protocol config autodetected) and custom (with protocol config specified) to a single 'service' key. This allows for consisent setup of multiple config for the same ddclient protocol (with different options and credentials). This also avoid ambiguity with usual networking term 'protocol' and ddclient specific term 'protocol' (and can change with a move to a different backend). - Apply upfront XML constraints and validations consistently wherever applicable. - RFC2136 specific change: Rename rfc2136 config `record` to `host-name` for consistency. - Cloudflare specific change: While ddclient still supports authenticating with email and global auth key, skipping `username` in config will indicate the intent to use API token authentication (with special 'token' literal as `username`). --- data/templates/dynamic-dns/ddclient.conf.j2 | 89 ++++++---- interface-definitions/dns-dynamic.xml.in | 190 ++++++++------------- .../dns/dynamic-service-host-name-server.xml.i | 34 ++++ src/conf_mode/dynamic_dns.py | 118 +++++-------- 4 files changed, 204 insertions(+), 227 deletions(-) create mode 100644 interface-definitions/include/dns/dynamic-service-host-name-server.xml.i diff --git a/data/templates/dynamic-dns/ddclient.conf.j2 b/data/templates/dynamic-dns/ddclient.conf.j2 index e8ef5ac90..6d1eb03a7 100644 --- a/data/templates/dynamic-dns/ddclient.conf.j2 +++ b/data/templates/dynamic-dns/ddclient.conf.j2 @@ -1,50 +1,67 @@ +{% 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 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 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 }} -{% 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 }} +{% 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 }} -{% 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 }} +{% 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 %} diff --git a/interface-definitions/dns-dynamic.xml.in b/interface-definitions/dns-dynamic.xml.in index 48c101d73..308dc5de0 100644 --- a/interface-definitions/dns-dynamic.xml.in +++ b/interface-definitions/dns-dynamic.xml.in @@ -12,141 +12,94 @@ Dynamic DNS - + - Interface to send Dynamic DNS updates for - - - + Obtain IP address to send Dynamic DNS update for txt - Interface name + Use interface to obtain the IP address + + web + Use HTTP(S) web request to obtain the IP address + + + + web + #include + web - + - RFC2136 Update name + Options when using HTTP(S) web request to obtain the IP address - + #include + - File containing the secret key shared with remote DNS server + Pattern to skip from the HTTP(S) respose - filename - File in /config/auth directory + txt + Pattern to skip from the HTTP(S) respose to extract the external IP address - - - Record to be updated - - - - - - Server to be updated - - - + + + + + RFC2136 nsupdate configuration + + txt + RFC2136 nsupdate service name + + + + #include + #include + - Time To Live (default: 600) + File containing the TSIG secret key shared with remote DNS server - u32:1-86400 - DNS forwarding cache size + filename + File in /config/auth directory - + - 600 + #include - Zone to be updated + Forwarding zone to be updated + + txt + RFC2136 Zone to be updated + + + + - Service being used for Dynamic DNS - - afraid changeip cloudflare dnspark dslreports dyndns easydns namecheap noip sitelutions zoneedit - + Dynamic DNS configuration txt - Dynanmic DNS service with a custom name - - - afraid - afraid.org Services - - - changeip - changeip.com Services - - - cloudflare - cloudflare.com Services + Dynamic DNS service name - - dnspark - dnspark.com Services - - - dslreports - dslreports.com Services - - - dyndns - dyndns.com Services - - - easydns - easydns.com Services - - - namecheap - namecheap.com Services - - - noip - noip.com Services - - - sitelutions - sitelutions.com Services - - - zoneedit - zoneedit.com Services - - - (custom|afraid|changeip|cloudflare|dnspark|dslreports|dyndns|easydns|namecheap|noip|sitelutions|zoneedit|\w+) - - You can use only predefined list of services or word characters (_, a-z, A-Z, 0-9) as service name - - - Hostname to register with Dynamic DNS service - - #include - - Host-name must be alphanumeric and can contain hyphens - - - - - - Login/Username for Dynamic DNS service - - + #include + #include + #include #include @@ -159,7 +112,6 @@ - #include DNS zone to update (not used by all protocols) @@ -169,31 +121,33 @@ - - - - - Use HTTP(S) web request to obtain external IP address instead of the IP address associated with the interface - - - + - Pattern to skip from the respose + IP address version to use - txt - Pattern to skip from the respose of the given URL to extract the external IP address + _ipv4 + Use only IPv4 address + + + _ipv6 + Use only IPv6 address + + both + Use both IPv4 and IPv6 address + + + ipv4 ipv6 both + + + (ipv[46]|both) + + IP Version must be literal 'ipv4', 'ipv6' or 'both' + ipv4 - #include - - - - Explicitly use IPv6 address instead of IPv4 address to update the Dynamic DNS IP address - - - + 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 @@ + + + + Hostname to register with Dynamic DNS service + + #include + + Host-name must be alphanumeric and can contain hyphens + + + + + + Remote Dynamic DNS server to send updates to + + ipv4 + IPv4 address of the remote server + + + ipv6 + IPv6 address of the remote server + + + hostname + Fully qualified domain name of the remote server + + + + + + Remote server must be IP address or fully qualified domain name + + + diff --git a/src/conf_mode/dynamic_dns.py b/src/conf_mode/dynamic_dns.py index 426e3d693..879086cc4 100755 --- a/src/conf_mode/dynamic_dns.py +++ b/src/conf_mode/dynamic_dns.py @@ -29,20 +29,14 @@ 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' -} +# 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: @@ -56,24 +50,13 @@ def get_config(config=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]) + 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]) return dyndns @@ -82,48 +65,37 @@ def verify(dyndns): if not dyndns: return None - # A 'node' corresponds to an interface - if 'interface' not in dyndns: - return None - - for interface in dyndns['interface']: + for address in dyndns['address']: # 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}') + 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['protocol'] not in dualstack_supported + and config['ip_version'] == 'both'): + raise ConfigError(f'"{config["protocol"]}" does not support IPv4 and IPv6 at the same time') return None -- cgit v1.2.3 From 604a12d65ddc5ca81bc1a8a7bbdcb7cf40a7897d Mon Sep 17 00:00:00 2001 From: Indrajit Raychaudhuri Date: Sun, 4 Jun 2023 00:17:59 -0500 Subject: dns: T5144: Restrict dualstack for dyndns2 protocol to dyn.com ddclient implementation of dualstack for dyndns2 protocol is targeted for dyn.com (dyndns.org) only. Dualstack won't work for other servers supporting dyndns2 protocol (for example, dyn.dns.he.net). --- src/conf_mode/dynamic_dns.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/conf_mode/dynamic_dns.py b/src/conf_mode/dynamic_dns.py index 879086cc4..fe72a3180 100755 --- a/src/conf_mode/dynamic_dns.py +++ b/src/conf_mode/dynamic_dns.py @@ -93,9 +93,12 @@ def verify(dyndns): if 'username' not in config: raise ConfigError(f'"username" {error_msg}') - if (config['protocol'] not in dualstack_supported - and config['ip_version'] == 'both'): - raise ConfigError(f'"{config["protocol"]}" does not support IPv4 and IPv6 at the same time') + 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 -- cgit v1.2.3 From 79040279b0400cd819c165e5ed38c24bf3c387e3 Mon Sep 17 00:00:00 2001 From: Indrajit Raychaudhuri Date: Thu, 11 May 2023 19:56:34 -0500 Subject: dns: T5144: Apply migration for dynamic dns path update Create migration and bump package version from 0 -> 1 for dynamic dns --- .../include/version/dns-dynamic-version.xml.i | 3 + interface-definitions/xml-component-version.xml.in | 1 + src/migration-scripts/dns-dynamic/0-to-1 | 104 +++++++++++++++++++++ 3 files changed, 108 insertions(+) create mode 100644 interface-definitions/include/version/dns-dynamic-version.xml.i create mode 100755 src/migration-scripts/dns-dynamic/0-to-1 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 @@ + + + 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 #include + #include #include #include #include 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 . + +# T5144: +# - migrate "service dns dynamic interface ..." +# to "service dns dynamic address ..." +# - migrate "service dns dynamic interface use-web ..." +# to "service dns dynamic address
web-options ..." +# - migrate "service dns dynamic interface rfc2136 record ..." +# to "service dns dynamic address
rfc2136 host-name ..." +# - migrate "service dns dynamic interface service login ..." +# to "service dns dynamic address
service username ..." +# - apply global 'ipv6-enable' to per '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 rfc2136 record" + # to "service dns dynamic address
rfc2136 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 service login" + # to "service dns dynamic address
service 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 '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 use-web" + # to "service dns dynamic address
web-options" + # Also, rename
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) -- cgit v1.2.3 From f4ea61dd5bf0ef2baa7d545b12e168b652412509 Mon Sep 17 00:00:00 2001 From: Indrajit Raychaudhuri Date: Sat, 13 May 2023 00:55:33 -0500 Subject: dns: T5144: Relocate ddclient template path for consistency with config path --- data/templates/dns-dynamic/ddclient.conf.j2 | 70 +++++++++++++++ data/templates/dynamic-dns/ddclient.conf.j2 | 70 --------------- interface-definitions/dns-dynamic.xml.in | 2 +- src/conf_mode/dns_dynamic.py | 131 ++++++++++++++++++++++++++++ src/conf_mode/dynamic_dns.py | 131 ---------------------------- 5 files changed, 202 insertions(+), 202 deletions(-) create mode 100644 data/templates/dns-dynamic/ddclient.conf.j2 delete mode 100644 data/templates/dynamic-dns/ddclient.conf.j2 create mode 100755 src/conf_mode/dns_dynamic.py delete mode 100755 src/conf_mode/dynamic_dns.py diff --git a/data/templates/dns-dynamic/ddclient.conf.j2 b/data/templates/dns-dynamic/ddclient.conf.j2 new file mode 100644 index 000000000..945191bb7 --- /dev/null +++ b/data/templates/dns-dynamic/ddclient.conf.j2 @@ -0,0 +1,70 @@ +{% 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 + +{% 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/dynamic-dns/ddclient.conf.j2 b/data/templates/dynamic-dns/ddclient.conf.j2 deleted file mode 100644 index 6d1eb03a7..000000000 --- a/data/templates/dynamic-dns/ddclient.conf.j2 +++ /dev/null @@ -1,70 +0,0 @@ -{% 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 dynamic_dns.py ### -daemon=1m -syslog=yes -ssl=yes - -{% 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/interface-definitions/dns-dynamic.xml.in b/interface-definitions/dns-dynamic.xml.in index 308dc5de0..292c50603 100644 --- a/interface-definitions/dns-dynamic.xml.in +++ b/interface-definitions/dns-dynamic.xml.in @@ -7,7 +7,7 @@ Domain Name System related services - + Dynamic DNS diff --git a/src/conf_mode/dns_dynamic.py b/src/conf_mode/dns_dynamic.py new file mode 100755 index 000000000..e4e2cf30e --- /dev/null +++ b/src/conf_mode/dns_dynamic.py @@ -0,0 +1,131 @@ +#!/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 . + +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' + +# 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]) + + 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) + 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 fe72a3180..000000000 --- a/src/conf_mode/dynamic_dns.py +++ /dev/null @@ -1,131 +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 . - -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' - -# 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]) - - 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, '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) -- cgit v1.2.3 From db495ed36be215deebbf34e294eae755dde64fa6 Mon Sep 17 00:00:00 2001 From: Indrajit Raychaudhuri Date: Mon, 15 May 2023 02:09:06 -0500 Subject: dns: T5144: Update smoketest for dynamic dns operation --- smoketest/scripts/cli/test_service_dns_dynamic.py | 132 +++++++++++----------- 1 file changed, 67 insertions(+), 65 deletions(-) diff --git a/smoketest/scripts/cli/test_service_dns_dynamic.py b/smoketest/scripts/cli/test_service_dns_dynamic.py index 4a3c05a36..b2b425d80 100755 --- a/smoketest/scripts/cli/test_service_dns_dynamic.py +++ b/smoketest/scripts/cli/test_service_dns_dynamic.py @@ -23,7 +23,6 @@ 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 +34,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,102 +50,104 @@ 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 = ['address', interface, 'rfc2136', 'vyos'] ddns_key_file = '/config/auth/my.key' + with open(ddns_key_file, 'w') as f: + f.write('S3cretKey') + 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 + ['host-name', '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']) - # ensure an exception will be raised as no key is present - if os.path.exists(ddns_key_file): - os.unlink(ddns_key_file) - - # check validate() - the key file does not exist yet - with self.assertRaises(ConfigSessionError): - self.cli_commit() - - with open(ddns_key_file, 'w') as f: - f.write('S3cretKey') - # commit changes self.cli_commit() # TODO: inspect generated configuration file + 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() + + 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() @@ -160,9 +161,10 @@ class TestServiceDDNS(VyOSUnitTestSHIM.TestCase): # Check some generating config parameters self.assertEqual(protocol, proto) self.assertEqual(login, user) - self.assertEqual(pwd, f"'{password}'") + self.assertEqual(pwd, password) self.assertEqual(server, srv) - self.assertEqual(usev6, f"ifv6, if={interface}") + self.assertEqual(usev6, 'ifv6') + self.assertEqual(get_config_value('ifv6'), interface) if __name__ == '__main__': unittest.main(verbosity=2) -- cgit v1.2.3 From 5fdf30550dd2f76f7bb85445b58acd7c6c0c3ae1 Mon Sep 17 00:00:00 2001 From: Indrajit Raychaudhuri Date: Sun, 4 Jun 2023 02:03:40 -0500 Subject: dns: T5144: Improve old smoketests for dynamic dns operation --- smoketest/scripts/cli/test_service_dns_dynamic.py | 49 +++++++++++++---------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/smoketest/scripts/cli/test_service_dns_dynamic.py b/smoketest/scripts/cli/test_service_dns_dynamic.py index b2b425d80..044d053b4 100755 --- a/smoketest/scripts/cli/test_service_dns_dynamic.py +++ b/smoketest/scripts/cli/test_service_dns_dynamic.py @@ -17,6 +17,7 @@ import re import os import unittest +import tempfile from base_vyostest_shim import VyOSUnitTestSHIM @@ -89,21 +90,30 @@ class TestServiceDDNS(VyOSUnitTestSHIM.TestCase): def test_dyndns_rfc2136(self): # Check if DDNS service can be configured and runs ddns = ['address', interface, 'rfc2136', 'vyos'] - ddns_key_file = '/config/auth/my.key' + srv = 'ns1.vyos.io' + zone = 'vyos.io' + ttl = '300' - with open(ddns_key_file, 'w') as f: - f.write('S3cretKey') + with tempfile.NamedTemporaryFile(prefix='/config/auth/') as key_file: + key_file.write(b'S3cretKey') - self.cli_set(base_path + ddns + ['key', ddns_key_file]) - self.cli_set(base_path + ddns + ['host-name', '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']) + 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]) - # commit changes - self.cli_commit() + # commit changes + self.cli_commit() - # TODO: inspect generated configuration file + # 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'] @@ -123,6 +133,7 @@ class TestServiceDDNS(VyOSUnitTestSHIM.TestCase): # commit changes self.cli_commit() + # Check some generating config parameters for opt in details.keys(): if opt == 'username': self.assertTrue(get_config_value('login') == details[opt]) @@ -152,18 +163,12 @@ class TestServiceDDNS(VyOSUnitTestSHIM.TestCase): # 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, password) - self.assertEqual(server, srv) - self.assertEqual(usev6, 'ifv6') + 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__': -- cgit v1.2.3 From a79a61b8e3afd39b7faeaca0750e68f3d693c5a9 Mon Sep 17 00:00:00 2001 From: Indrajit Raychaudhuri Date: Sun, 4 Jun 2023 00:28:40 -0500 Subject: dns: T5144: Relocate ddclient op-mode config files for consistency with config path --- op-mode-definitions/dns-dynamic.xml.in | 6 +- src/op_mode/dns_dynamic.py | 113 +++++++++++++++++++++++++++++++++ src/op_mode/dynamic_dns.py | 113 --------------------------------- 3 files changed, 116 insertions(+), 116 deletions(-) create mode 100755 src/op_mode/dns_dynamic.py delete mode 100755 src/op_mode/dynamic_dns.py 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 @@ Show Dynamic DNS status - sudo ${vyos_op_scripts_dir}/dynamic_dns.py --status + sudo ${vyos_op_scripts_dir}/dns_dynamic.py --status @@ -46,7 +46,7 @@ Restart Dynamic DNS service - sudo ${vyos_op_scripts_dir}/dynamic_dns.py --update + sudo ${vyos_op_scripts_dir}/dns_dynamic.py --update @@ -66,7 +66,7 @@ Update Dynamic DNS information - sudo ${vyos_op_scripts_dir}/dynamic_dns.py --update + sudo ${vyos_op_scripts_dir}/dns_dynamic.py --update diff --git a/src/op_mode/dns_dynamic.py b/src/op_mode/dns_dynamic.py new file mode 100755 index 000000000..d41a74db3 --- /dev/null +++ b/src/op_mode/dns_dynamic.py @@ -0,0 +1,113 @@ +#!/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 . + +import os +import argparse +import sys +import time +from tabulate import tabulate + +from vyos.config import Config +from vyos.template import is_ipv4, is_ipv6 +from vyos.util import call + +cache_file = r'/run/ddclient/ddclient.cache' + +columns = { + 'host': 'Hostname', + 'ipv4': 'IPv4 address', + 'status-ipv4': 'IPv4 status', + 'ipv6': 'IPv6 address', + 'status-ipv6': 'IPv6 status', + 'mtime': 'Last update', +} + + +def _get_formatted_host_records(host_data): + data_entries = [] + for entry in host_data: + data_entries.append([entry.get(key) for key in columns.keys()]) + + header = columns.values() + output = tabulate(data_entries, header, numalign='left') + return output + + +def show_status(): + # A ddclient status file might not always exist + if not os.path.exists(cache_file): + sys.exit(0) + + data = [] + + with open(cache_file, 'r') as f: + for line in f: + if line.startswith('#'): + continue + + props = {} + # ddclient cache rows have properties in 'key=value' format separated by comma + # we pick up the ones we are interested in + for kvraw in line.split(' ')[0].split(','): + k, v = kvraw.split('=') + if k in list(columns.keys()) + ['ip', 'status']: # ip and status are legacy keys + props[k] = v + + # Extract IPv4 and IPv6 address and status from legacy keys + # Dual-stack isn't supported in legacy format, 'ip' and 'status' are for one of IPv4 or IPv6 + if 'ip' in props: + if is_ipv4(props['ip']): + props['ipv4'] = props['ip'] + props['status-ipv4'] = props['status'] + elif is_ipv6(props['ip']): + props['ipv6'] = props['ip'] + props['status-ipv6'] = props['status'] + del props['ip'] + + # Convert mtime to human readable format + if 'mtime' in props: + props['mtime'] = time.strftime( + "%Y-%m-%d %H:%M:%S", time.localtime(int(props['mtime'], base=10))) + + data.append(props) + + print(_get_formatted_host_records(data)) + + +def update_ddns(): + call('systemctl stop ddclient.service') + if os.path.exists(cache_file): + os.remove(cache_file) + call('systemctl start ddclient.service') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + group = parser.add_mutually_exclusive_group() + group.add_argument("--status", help="Show DDNS status", action="store_true") + group.add_argument("--update", help="Update DDNS on a given interface", action="store_true") + args = parser.parse_args() + + # Do nothing if service is not configured + c = Config() + if not c.exists_effective('service dns dynamic'): + print("Dynamic DNS not configured") + sys.exit(1) + + if args.status: + show_status() + elif args.update: + update_ddns() diff --git a/src/op_mode/dynamic_dns.py b/src/op_mode/dynamic_dns.py deleted file mode 100755 index d41a74db3..000000000 --- a/src/op_mode/dynamic_dns.py +++ /dev/null @@ -1,113 +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 . - -import os -import argparse -import sys -import time -from tabulate import tabulate - -from vyos.config import Config -from vyos.template import is_ipv4, is_ipv6 -from vyos.util import call - -cache_file = r'/run/ddclient/ddclient.cache' - -columns = { - 'host': 'Hostname', - 'ipv4': 'IPv4 address', - 'status-ipv4': 'IPv4 status', - 'ipv6': 'IPv6 address', - 'status-ipv6': 'IPv6 status', - 'mtime': 'Last update', -} - - -def _get_formatted_host_records(host_data): - data_entries = [] - for entry in host_data: - data_entries.append([entry.get(key) for key in columns.keys()]) - - header = columns.values() - output = tabulate(data_entries, header, numalign='left') - return output - - -def show_status(): - # A ddclient status file might not always exist - if not os.path.exists(cache_file): - sys.exit(0) - - data = [] - - with open(cache_file, 'r') as f: - for line in f: - if line.startswith('#'): - continue - - props = {} - # ddclient cache rows have properties in 'key=value' format separated by comma - # we pick up the ones we are interested in - for kvraw in line.split(' ')[0].split(','): - k, v = kvraw.split('=') - if k in list(columns.keys()) + ['ip', 'status']: # ip and status are legacy keys - props[k] = v - - # Extract IPv4 and IPv6 address and status from legacy keys - # Dual-stack isn't supported in legacy format, 'ip' and 'status' are for one of IPv4 or IPv6 - if 'ip' in props: - if is_ipv4(props['ip']): - props['ipv4'] = props['ip'] - props['status-ipv4'] = props['status'] - elif is_ipv6(props['ip']): - props['ipv6'] = props['ip'] - props['status-ipv6'] = props['status'] - del props['ip'] - - # Convert mtime to human readable format - if 'mtime' in props: - props['mtime'] = time.strftime( - "%Y-%m-%d %H:%M:%S", time.localtime(int(props['mtime'], base=10))) - - data.append(props) - - print(_get_formatted_host_records(data)) - - -def update_ddns(): - call('systemctl stop ddclient.service') - if os.path.exists(cache_file): - os.remove(cache_file) - call('systemctl start ddclient.service') - - -if __name__ == '__main__': - parser = argparse.ArgumentParser() - group = parser.add_mutually_exclusive_group() - group.add_argument("--status", help="Show DDNS status", action="store_true") - group.add_argument("--update", help="Update DDNS on a given interface", action="store_true") - args = parser.parse_args() - - # Do nothing if service is not configured - c = Config() - if not c.exists_effective('service dns dynamic'): - print("Dynamic DNS not configured") - sys.exit(1) - - if args.status: - show_status() - elif args.update: - update_ddns() -- cgit v1.2.3 From b98f38a604954b87c1505bdb6c500a7d6fab983f Mon Sep 17 00:00:00 2001 From: Indrajit Raychaudhuri Date: Sun, 4 Jun 2023 02:56:20 -0500 Subject: dns: T5144: Add pid and cache config as ddclient global --- data/templates/dns-dynamic/ddclient.conf.j2 | 2 ++ src/conf_mode/dns_dynamic.py | 1 + 2 files changed, 3 insertions(+) diff --git a/data/templates/dns-dynamic/ddclient.conf.j2 b/data/templates/dns-dynamic/ddclient.conf.j2 index 945191bb7..a19b79c00 100644 --- a/data/templates/dns-dynamic/ddclient.conf.j2 +++ b/data/templates/dns-dynamic/ddclient.conf.j2 @@ -26,6 +26,8 @@ if{{ ipv }}={{ address }}, \ 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() %} diff --git a/src/conf_mode/dns_dynamic.py b/src/conf_mode/dns_dynamic.py index e4e2cf30e..d1ac2a08f 100755 --- a/src/conf_mode/dns_dynamic.py +++ b/src/conf_mode/dns_dynamic.py @@ -58,6 +58,7 @@ def get_config(config=None): 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): -- cgit v1.2.3 From c14825f55d286d54ca3c04703ecbded1cb4c2cca Mon Sep 17 00:00:00 2001 From: Indrajit Raychaudhuri Date: Sun, 4 Jun 2023 02:27:00 -0500 Subject: dns: T5144: Streamline ddclient systemd service override Templatize systemd override for ddclient service and move the generated override files in /run. This ensures that the override files are always generated afresh after boot. Additionally, simplify the systemd override file by removing the redundant/superfluous overrides. --- data/templates/dns-dynamic/override.conf.j2 | 11 +++++++++++ src/conf_mode/dns_dynamic.py | 2 ++ src/etc/systemd/system/ddclient.service.d/override.conf | 11 ----------- 3 files changed, 13 insertions(+), 11 deletions(-) create mode 100644 data/templates/dns-dynamic/override.conf.j2 delete mode 100644 src/etc/systemd/system/ddclient.service.d/override.conf 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/src/conf_mode/dns_dynamic.py b/src/conf_mode/dns_dynamic.py index d1ac2a08f..f97225370 100755 --- a/src/conf_mode/dns_dynamic.py +++ b/src/conf_mode/dns_dynamic.py @@ -28,6 +28,7 @@ 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'] @@ -109,6 +110,7 @@ def generate(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): 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 -- cgit v1.2.3