diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de4d443..0b5139c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,7 @@ jobs: libtest-tcp-perl \ libtest-warnings-perl \ liburi-perl \ + libwww-perl \ net-tools \ make \ ; @@ -61,33 +62,28 @@ jobs: - fedora:39 - fedora:latest - fedora:rawhide - # RedHat UBI is mostly garbage due to a profound lack of basic - # packages. It is tested anyway because it's the closest available - # approximation of RHEL. Some of the packages needed for some tests - # aren't available, so those tests will be skipped. I guess it's - # still better than nothing. - - registry.access.redhat.com/ubi7/ubi:latest - - registry.access.redhat.com/ubi8/ubi:latest - - registry.access.redhat.com/ubi9/ubi:latest + - almalinux:8 + - almalinux:latest runs-on: ubuntu-latest container: image: ${{ matrix.image }} steps: - - if: ${{ matrix.image != 'registry.access.redhat.com/ubi7/ubi:latest' }} - uses: actions/checkout@v4 - # ubi7 is too old for checkout@v4. - - if: ${{ matrix.image == 'registry.access.redhat.com/ubi7/ubi:latest' }} - uses: actions/checkout@v3 - - name: install dependencies - # The --skip-broken argument works around RedHat UBI's missing packages. - # (They're only used for testing, so it's OK to not install them.) + - uses: actions/checkout@v4 + - name: enable repositories (AlmaLinux 8) + if: ${{ matrix.image == 'almalinux:8' }} run: | - inst="dnf --refresh --skip-broken install -y" - case '${{ matrix.image }}' in - # RedHat UBI 7 (RHEL 7) doesn't have dnf. - *ubi7*) inst="yum --skip-broken install -y";; - esac - ${inst} \ + dnf --refresh install -y 'dnf-command(config-manager)' epel-release && + dnf config-manager --set-enabled powertools + - name: enable repositories (AlmaLinux latest) + if: ${{ matrix.image == 'almalinux:latest' }} + run: | + dnf --refresh install -y 'dnf-command(config-manager)' epel-release && + dnf config-manager --set-enabled crb + - name: install dependencies + # The --skip-broken argument works around missing packages. (They're + # only used for testing, so it's OK to not install them.) + run: | + dnf --refresh install --skip-broken -y \ automake \ findutils \ iproute \ @@ -102,6 +98,7 @@ jobs: perl-Test-TCP \ perl-Test-Warnings \ perl-core \ + perl-libwww-perl \ net-tools \ ; - name: autogen diff --git a/ChangeLog.md b/ChangeLog.md index d0ca95c..19633f3 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -3,18 +3,37 @@ This document describes notable changes. For details, see the [source code repository history](https://github.com/ddclient/ddclient/commits/master). -## v3.11.3~alpha (unreleased work-in-progress) +## v4.0.0~alpha (unreleased work-in-progress) ### Breaking changes + * The `--ssl` option is now enabled by default. + [#705](https://github.com/ddclient/ddclient/pull/705) * Unencrypted (plain) HTTP is now used instead of encrypted (TLS) HTTP if the URL uses `http://` instead of `https://`, even if the `--ssl` option is enabled. [#608](https://github.com/ddclient/ddclient/pull/608) + * The `googledomains` built-in web IP discovery service + (`--webv4=googledomains`, `--webv6=googledomains`, and + `--web=googledomains`) is deprecated due to the service shutting down. It + will be removed in a future version of ddclient. + [5b104ad1](https://github.com/ddclient/ddclient/commit/5b104ad116c023c3760129cab6e141f04f72b406) * The default web service for `--webv4` and `--webv6` has changed from Google Domains (which is shutting down) to ipify. [5b104ad1](https://github.com/ddclient/ddclient/commit/5b104ad116c023c3760129cab6e141f04f72b406) * All log messages are now written to STDERR, not a mix of STDOUT and STDERR. [#676](https://github.com/ddclient/ddclient/pull/676) + * For `--protocol=freedns` and `--protocol=nfsn`, the core module + `Digest::SHA` is now required. Previously, `Digest::SHA1` was used (if + available) as an alternative to `Digest::SHA`. + [#685](https://github.com/ddclient/ddclient/pull/685) + * The `he` built-in web IP discovery service (`--webv4=he`, `--webv6=he`, and + `--web=he`) was renamed to `he.net` for consistency with the new `he.net` + protocol. The old name is still accepted but is deprecated and will be + removed in a future version of ddclient. + [#682](https://github.com/ddclient/ddclient/pull/682) + * Deprecated built-in web IP discovery services are not listed in the output + of `--list-web-services`. + [#682](https://github.com/ddclient/ddclient/pull/682) ### New features @@ -41,6 +60,16 @@ repository history](https://github.com/ddclient/ddclient/commits/master). * The second and subsequent lines in a multi-line log message are now prefixed with a `|` character. [#676](https://github.com/ddclient/ddclient/pull/676) + * `emailonly`: New `protocol` option that simply emails you when your IP + address changes. [#654](https://github.com/ddclient/ddclient/pull/654) + * `he.net`: Added support for updating Hurricane Electric records. + [#682](https://github.com/ddclient/ddclient/pull/682) + * `dyndns2`, `domeneshop`, `dnsmadeeasy`, `keysystems`, `woima`: The `server` + option can now include `http://` or `https://` to control the use of TLS. + If omitted, the value of the `ssl` option is used to determine the scheme. + [#703](https://github.com/ddclient/ddclient/pull/703) + * `ddns.fm`: New `protocol` option for updating [DDNS.FM](https://ddns.fm/) + records. [#695](https://github.com/ddclient/ddclient/pull/695) ### Bug fixes @@ -73,6 +102,14 @@ repository history](https://github.com/ddclient/ddclient/commits/master). [#667](https://github.com/ddclient/ddclient/pull/667) * Fixed unnecessary repeated updates for some services. [#670](https://github.com/ddclient/ddclient/pull/670) + * Fixed DNSExit provider when configured with a zone and non-identical + hostname. [#673](https://github.com/ddclient/ddclient/issues/673) + * `infomaniak`: Fixed frequent forced updates after 25 days (`max-interval`). + [#691](https://github.com/ddclient/ddclient/issues/691) + * `infomaniak`: Fixed incorrect parsing of server response. + [#692](https://github.com/ddclient/ddclient/issues/692) + * `regfishde`: Fixed IPv6 support. + [#691](https://github.com/ddclient/ddclient/issues/691) ## 2023-11-23 v3.11.2 @@ -136,7 +173,7 @@ Refer to [v3.11 release plan discussions](https://github.com/ddclient/ddclient/i * Added support for domaindiscount24.com * Added support for njal.la - + ## 2022-05-15 v3.10.0_2 ### Bug fixes diff --git a/Makefile.am b/Makefile.am index 9503c04..90d3597 100644 --- a/Makefile.am +++ b/Makefile.am @@ -63,8 +63,13 @@ AM_PL_LOG_FLAGS = -Mstrict -w \ -MDevel::Autoflush handwritten_tests = \ t/builtinfw_query.pl \ + t/dnsexit2.pl \ t/get_ip_from_if.pl \ t/geturl_connectivity.pl \ + t/geturl_response.pl \ + t/group_hosts_by.pl \ + t/header_ok.pl \ + t/interval_expired.pl \ t/is-and-extract-ipv4.pl \ t/is-and-extract-ipv6.pl \ t/is-and-extract-ipv6-global.pl \ @@ -72,7 +77,8 @@ handwritten_tests = \ t/parse_assignments.pl \ t/skip.pl \ t/ssl-validate.pl \ - t/write_cache.pl + t/variable_defaults.pl \ + t/write_recap.pl generated_tests = \ t/version.pl TESTS = $(handwritten_tests) $(generated_tests) diff --git a/README.md b/README.md index e6fd0cb..5b3275a 100644 --- a/README.md +++ b/README.md @@ -17,11 +17,13 @@ Dynamic DNS services currently supported include: * [ChangeIP](https://www.changeip.com) * [CloudFlare](https://www.cloudflare.com) * [ClouDNS](https://www.cloudns.net) +* [DDNS.fm](https://www.ddns.fm/) * [DigitalOcean](https://www.digitalocean.com/) * [dinahosting](https://dinahosting.com) * [DonDominio](https://www.dondominio.com) * [DNS Made Easy](https://dnsmadeeasy.com) * [DNSExit](https://dnsexit.com/dns/dns-api) +* [dnsHome.de](https://www.dnshome.de) * [Domeneshop](https://api.domeneshop.no/docs/#tag/ddns/paths/~1dyndns~1update/get) * [DslReports](https://www.dslreports.com) * [Duck DNS](https://duckdns.org) @@ -33,6 +35,7 @@ Dynamic DNS services currently supported include: * [Gandi](https://gandi.net) * [GoDaddy](https://www.godaddy.com) * [Google](https://domains.google) +* [Hurricane Electric](https://dns.he.net) * [Infomaniak](https://faq.infomaniak.com/2376) * [Loopia](https://www.loopia.se) * [Mythic Beasts](https://www.mythic-beasts.com/support/api/dnsv2/dynamic-dns) @@ -130,9 +133,16 @@ Note that any issues prior to version v3.9.1 will not be listed here. If a fix is committed but not yet part of any tagged release, the notes here will reference the not-yet-released version number. ### v3.11.2 - v3.9.1: SSL parameter breaks HTTP-only IP acquisition -The `ssl` parameter forces all connections to use HTTPS. While technically working as expected, this behavior keeps coming up as a pain point when using HTTP-only IP querying sites such as http://checkip.dyndns.org. For the future (v3.11.3), the behavior is changed to respect `http://` in a URL. A separate parameter to disallow all HTTP connections or warn about them may be added later. -**Fix**: v3.11.3 will use HTTP to connect to URLs starting with `http://`. See [here](https://github.com/ddclient/ddclient/pull/608) for more info. +The `ssl` parameter forces all connections to use HTTPS. While technically +working as expected, this behavior keeps coming up as a pain point when using +HTTP-only IP querying sites such as http://checkip.dyndns.org. Starting with +v4.0.0, the behavior is changed to respect `http://` in a URL. A separate +parameter to disallow all HTTP connections or warn about them may be added +later. + +**Fix**: v4.0.0 uses HTTP to connect to URLs starting with `http://`. See +[here](https://github.com/ddclient/ddclient/pull/608) for more info. **Workaround**: Disable the SSL parameter diff --git a/configure.ac b/configure.ac index f65b8b3..5ca6da7 100644 --- a/configure.ac +++ b/configure.ac @@ -36,7 +36,18 @@ AC_PROG_MKDIR_P AC_PATH_PROG([FIND], [find]) AS_IF([test -z "${FIND}"], [AC_MSG_ERROR(['find' utility not found])]) -AC_PATH_PROG([CURL], [curl]) +AC_ARG_WITH([curl], + [AS_HELP_STRING([[--with-curl[=CURL]]], [use CURL as absolute path to curl executable])], + [], + [with_curl=yes]) +AS_CASE([${with_curl}], + [[yes]], [AC_PATH_PROG([CURL], [curl])], + [[no]], [CURL=], + [ + AC_MSG_CHECKING([for curl]) + CURL=${with_curl} + AC_MSG_RESULT([${CURL}]) + ]); AS_IF([test -z "${CURL}"], [AC_MSG_ERROR([curl not found])]) AX_WITH_PROG([PERL], perl) @@ -49,6 +60,7 @@ AC_SUBST([PERL]) # package doesn't depend on all of them, so their availability can't # be assumed. m4_foreach_w([_m], [ + Data::Dumper File::Basename File::Path File::Temp @@ -63,7 +75,6 @@ m4_foreach_w([_m], [ # then some tests will fail. Only prints a warning if not installed. m4_foreach_w([_m], [ B - Data::Dumper File::Spec::Functions File::Temp ], [AX_PROG_PERL_MODULES([_m], [], @@ -80,6 +91,8 @@ m4_foreach_w([_m], [ HTTP::Message::PSGI HTTP::Request HTTP::Response + JSON::PP + LWP::UserAgent Scalar::Util Test::MockModule Test::TCP diff --git a/ddclient.conf.in b/ddclient.conf.in index 8652635..8a336ac 100644 --- a/ddclient.conf.in +++ b/ddclient.conf.in @@ -16,13 +16,16 @@ ## are mentioned here. ## ###################################################################### + +## Use encryption (TLS) when the scheme (either "http://" or "https://") is +## missing from a URL. Defaults to "yes". +#ssl=yes + daemon=300 # check every 300 seconds syslog=yes # log update msgs to syslog mail=root # mail all msgs to root mail-failure=root # mail failed update msgs to root pid=@runstatedir@/ddclient.pid # record PID in file. -ssl=yes # use ssl-support. Works with - # ssl-library # postscript=script # run script after updating. The # new IP is added as argument. # @@ -222,6 +225,13 @@ ssl=yes # use ssl-support. Works with # password=my-auto-generated-password # my.domain.tld, otherhost.domain.tld +## +## Hurricane Electric (dns.he.net) +## +# protocol=he.net, \ +# password=my-genereated-password \ +# myhost.example.com + ## ## Duckdns (http://www.duckdns.org/) ## @@ -238,6 +248,14 @@ ssl=yes # use ssl-support. Works with # password=my-token # myhost +## +## DDNS.FM (https://ddns.fm/) +## +# +# protocol=ddns.fm, +# password=my-token +# myhost.example.com + ## ## MyOnlinePortal (http://myonlineportal.net) ## @@ -391,3 +409,18 @@ ssl=yes # use ssl-support. Works with # password=ddns_password # redirect=2 # example.com + +## +## Email Only +## +# protocol=emailonly +# host.example.com + +## +## dnsHome.de +## +# protocol=dyndns2 \ +# server=www.dnshome.de \ +# login=subdomain.domain.tld \ +# password=your_password \ +# subdomain.domain.tld diff --git a/ddclient.in b/ddclient.in index 957cb65..58bbe43 100755 --- a/ddclient.in +++ b/ddclient.in @@ -15,6 +15,7 @@ package ddclient; require v5.10.1; use strict; use warnings; +use Data::Dumper; use File::Basename; use File::Path qw(make_path); use File::Temp; @@ -62,7 +63,7 @@ use Sys::Hostname; # # For consistency and to match user expectations, the release part of the version is always three # components: MAJOR.MINOR.PATCH. -use version 0.77; our $VERSION = version->declare('v3.11.3.0_0'); +use version 0.77; our $VERSION = version->declare('v4.0.0.0_0'); sub parse_version { my ($v) = @_; @@ -104,7 +105,7 @@ my $programd = $0; $programd =~ s%^.*/%%; my $program = $programd; $program =~ s/d$//; -my $now = time; +our $now = time; my $hostname = hostname(); # subst_var(subst, default) returns subst unless it looks like @foo@ in which case it returns @@ -124,13 +125,14 @@ if ($program =~ /test/i) { $cachedir = '.'; $savedir = 'URL'; } +our @curl = (subst_var('@CURL@', 'curl')); our $emailbody = ''; my $last_emailbody = ''; ## If run as *d (e.g., ddclientd) then daemonize by default (but allow ## flags and options to override). -my $daemon_default = ($programd =~ /d$/) ? interval('5m') : 0; +my $daemon_default = ($programd =~ /d$/) ? interval('5m') : undef; use vars qw($file $lineno); local $file = ''; @@ -140,8 +142,24 @@ $ENV{'PATH'} = (exists($ENV{PATH}) ? "$ENV{PATH}:" : "") . "/sbin:/usr/sbin:/bin our %globals; our %config; -my ($result, %cache); -my $saved_cache; + +# %recap holds details about recent updates (and attempts) that are needed to implement various +# service-specific and protocol-independent mechanisms such as `min-interval`. This data is +# persisted in the cache file (`--cache`) so that it survives ddclient restarts. This hash maps a +# hostname to a hashref containing those protocol variables that have their `recap` property set to +# true. +# +# A note about terminology: This was previously named `%cache`, but "cache" implies that the +# purpose is to reduce the cost or latency of data retrieval or computation, and that deletion only +# affects performance. That is not the case here, so the variable was renamed. "Recap" is meant +# to evoke the "previously on" clips that play before TV episodes, which are designed to give you +# just enough context to recall the state. The recap is written to the cache file, so-named for +# historical reasons. (Renaming "cache file" to "recap file" is more difficult due to +# compatibility concerns with the public `--cache` option.) +our %recap; + +my $result; +my $saved_recap; my %saved_opt; my $daemon; # Control how many times warning message logged for invalid IP addresses @@ -175,8 +193,15 @@ sub T_POSTS { 'postscript' } our %builtinweb = ( 'dyndns' => {'url' => 'http://checkip.dyndns.org/', 'skip' => 'Current IP Address:'}, 'freedns' => {'url' => 'https://freedns.afraid.org/dynamic/check.php'}, - 'googledomains' => {'url' => 'https://domains.google.com/checkip'}, # Deprecated! See https://github.com/ddclient/ddclient/issues/622 for more details - 'he' => {'url' => 'https://checkip.dns.he.net/'}, + 'googledomains' => { + url => 'https://domains.google.com/checkip', + deprecated => 'See https://github.com/ddclient/ddclient/issues/622 for more info.', + }, + 'he' => { + url => 'https://checkip.dns.he.net/', + deprecated => "Use 'he.net' instead.", + }, + 'he.net' => {'url' => 'https://checkip.dns.he.net/'}, 'ip4only.me' => {'url' => 'https://ip4only.me/api/'}, 'ip6only.me' => {'url' => 'https://ip6only.me/api/'}, 'ipify-ipv4' => {'url' => 'https://api.ipify.org/'}, @@ -192,9 +217,9 @@ our %builtinweb = ( sub query_cisco { my ($h, $asa, $v4) = @_; - warning("'--if' is deprecated for '--usev4=ifv4; use '--ifv4' instead") + warning("'--if' is deprecated for '--usev4=cisco%s; use '--ifv4' instead", $asa ? '-asa' : '') if ($v4 && !defined(opt('ifv4')) && defined(opt('if', $h))); - warning("'--fw' is deprecated for '--usev4=fwv4; use '--fwv4' instead") + warning("'--fw' is deprecated for '--usev4=cisco%s; use '--fwv4' instead", $asa ? '-asa' : '') if ($v4 && !defined(opt('fwv4')) && defined(opt('fw', $h))); my $if = ($v4 ? opt('ifv4', $h) : undef) // opt('if', $h); my $fw = ($v4 ? opt('fwv4', $h) : undef) // opt('fw', $h); @@ -533,18 +558,18 @@ sub setv { return { 'type' => shift, 'required' => shift, - 'cache' => shift, + 'recap' => shift, 'default' => shift, 'minimum' => shift, }; } -my %variables = ( +our %variables = ( 'global-defaults' => { 'daemon' => setv(T_DELAY, 0, 0, $daemon_default, interval('60s')), 'foreground' => setv(T_BOOL, 0, 0, 0, undef), 'file' => setv(T_FILE, 0, 0, "$etc/$program.conf", undef), 'cache' => setv(T_FILE, 0, 0, "$cachedir/$program.cache", undef), - 'pid' => setv(T_FILE, 0, 0, "", undef), + 'pid' => setv(T_FILE, 0, 0, undef, undef), 'proxy' => setv(T_FQDNP, 0, 0, undef, undef), 'protocol' => setv(T_PROTO, 0, 0, 'dyndns2', undef), @@ -563,28 +588,28 @@ my %variables = ( 'webv4-skip' => setv(T_STRING,0, 0, undef, undef), 'webv6' => setv(T_STRING,0, 0, 'ipify-ipv6', undef), 'webv6-skip' => setv(T_STRING,0, 0, undef, undef), - 'fw' => setv(T_ANY, 0, 0, '', undef), + 'fw' => setv(T_ANY, 0, 0, undef, undef), 'fw-skip' => setv(T_STRING,0, 0, undef, undef), - 'fwv4' => setv(T_ANY, 0, 0, '', undef), + 'fwv4' => setv(T_ANY, 0, 0, undef, undef), 'fwv4-skip' => setv(T_STRING,0, 0, undef, undef), - 'fwv6' => setv(T_ANY, 0, 0, '', undef), + 'fwv6' => setv(T_ANY, 0, 0, undef, undef), 'fwv6-skip' => setv(T_STRING,0, 0, undef, undef), - 'fw-login' => setv(T_LOGIN, 1, 0, '', undef), - 'fw-password' => setv(T_PASSWD,1, 0, '', undef), - 'cmd' => setv(T_PROG, 0, 0, '', undef), + 'fw-login' => setv(T_LOGIN, 0, 0, undef, undef), + 'fw-password' => setv(T_PASSWD,0, 0, undef, undef), + 'cmd' => setv(T_PROG, 0, 0, undef, undef), 'cmd-skip' => setv(T_STRING,0, 0, undef, undef), - 'cmdv4' => setv(T_PROG, 0, 0, '', undef), - 'cmdv6' => setv(T_PROG, 0, 0, '', undef), + 'cmdv4' => setv(T_PROG, 0, 0, undef, undef), + 'cmdv6' => setv(T_PROG, 0, 0, undef, undef), 'timeout' => setv(T_DELAY, 0, 0, interval('120s'), interval('120s')), 'retry' => setv(T_BOOL, 0, 0, 0, undef), 'force' => setv(T_BOOL, 0, 0, 0, undef), - 'ssl' => setv(T_BOOL, 0, 0, 0, undef), + 'ssl' => setv(T_BOOL, 0, 0, 1, undef), 'syslog' => setv(T_BOOL, 0, 0, 0, undef), 'facility' => setv(T_STRING,0, 0, 'daemon', undef), 'priority' => setv(T_STRING,0, 0, 'notice', undef), - 'mail' => setv(T_EMAIL, 0, 0, '', undef), - 'mail-failure' => setv(T_EMAIL, 0, 0, '', undef), + 'mail' => setv(T_EMAIL, 0, 0, undef, undef), + 'mail-failure' => setv(T_EMAIL, 0, 0, undef, undef), 'max-warn' => setv(T_NUMBER,0, 0, 1, undef), 'exec' => setv(T_BOOL, 0, 0, 1, undef), @@ -593,18 +618,18 @@ my %variables = ( 'quiet' => setv(T_BOOL, 0, 0, 0, undef), 'help' => setv(T_BOOL, 0, 0, 0, undef), 'test' => setv(T_BOOL, 0, 0, 0, undef), - 'geturl' => setv(T_STRING,0, 0, '', undef), + 'geturl' => setv(T_STRING,0, 0, undef, undef), - 'postscript' => setv(T_POSTS, 0, 0, '', undef), + 'postscript' => setv(T_POSTS, 0, 0, undef, undef), 'ssl_ca_dir' => setv(T_FILE, 0, 0, undef, undef), 'ssl_ca_file' => setv(T_FILE, 0, 0, undef, undef), 'redirect' => setv(T_NUMBER,0, 0, 0, undef) }, - 'service-common-defaults' => { - 'server' => setv(T_FQDNP, 1, 0, 'members.dyndns.org', undef), - 'login' => setv(T_LOGIN, 1, 0, '', undef), - 'password' => setv(T_PASSWD,1, 0, '', undef), - 'host' => setv(T_STRING,1, 1, '', undef), + 'protocol-common-defaults' => { + 'server' => setv(T_FQDNP, 0, 0, 'members.dyndns.org', undef), + 'login' => setv(T_LOGIN, 1, 0, undef, undef), + 'password' => setv(T_PASSWD,1, 0, undef, undef), + 'host' => setv(T_STRING,1, 1, undef, undef), 'use' => setv(T_USE, 0, 0, 'ip', undef), 'usev4' => setv(T_USEV4, 0, 0, 'disabled', undef), @@ -619,463 +644,532 @@ my %variables = ( 'webv4-skip' => setv(T_STRING,0, 0, undef, undef), 'webv6' => setv(T_STRING,0, 0, 'ipify-ipv6', undef), 'webv6-skip' => setv(T_STRING,0, 0, undef, undef), - 'fw' => setv(T_ANY, 0, 0, '', undef), + 'fw' => setv(T_ANY, 0, 0, undef, undef), 'fw-skip' => setv(T_STRING,0, 0, undef, undef), - 'fw-login' => setv(T_LOGIN, 0, 0, '', undef), - 'fw-password' => setv(T_PASSWD,0, 0, '', undef), + 'fw-login' => setv(T_LOGIN, 0, 0, undef, undef), + 'fw-password' => setv(T_PASSWD,0, 0, undef, undef), 'fw-ssl-validate' => setv(T_BOOL, 0, 0, 1, undef), - 'fwv4' => setv(T_ANY, 0, 0, '', undef), + 'fwv4' => setv(T_ANY, 0, 0, undef, undef), 'fwv4-skip' => setv(T_STRING,0, 0, undef, undef), - 'fwv6' => setv(T_ANY, 0, 0, '', undef), + 'fwv6' => setv(T_ANY, 0, 0, undef, undef), 'fwv6-skip' => setv(T_STRING,0, 0, undef, undef), - 'cmd' => setv(T_PROG, 0, 0, '', undef), + 'cmd' => setv(T_PROG, 0, 0, undef, undef), 'cmd-skip' => setv(T_STRING,0, 0, undef, undef), - 'cmdv4' => setv(T_PROG, 0, 0, '', undef), - 'cmdv6' => setv(T_PROG, 0, 0, '', undef), - - 'ip' => setv(T_IP, 0, 1, undef, undef), #TODO remove from cache? - 'ipv4' => setv(T_IPV4, 0, 1, undef, undef), - 'ipv6' => setv(T_IPV6, 0, 1, undef, undef), - 'wtime' => setv(T_DELAY, 0, 1, 0, interval('30s')), - 'mtime' => setv(T_NUMBER,0, 1, 0, undef), - 'atime' => setv(T_NUMBER,0, 1, 0, undef), - 'status' => setv(T_ANY, 0, 1, undef, undef), #TODO remove from cache? - 'status-ipv4' => setv(T_ANY, 0, 1, undef, undef), - 'status-ipv6' => setv(T_ANY, 0, 1, undef, undef), + 'cmdv4' => setv(T_PROG, 0, 0, undef, undef), + 'cmdv6' => setv(T_PROG, 0, 0, undef, undef), 'min-interval' => setv(T_DELAY, 0, 0, interval('30s'), 0), 'max-interval' => setv(T_DELAY, 0, 0, interval('25d'), 0), 'min-error-interval' => setv(T_DELAY, 0, 0, interval('5m'), 0), - 'warned-min-interval' => setv(T_ANY, 0, 1, 0, undef), + # As a recap value, this is the IP address (IPv4 or IPv6, but almost always IPv4) most + # recently saved at the DDNS service. As a setting, this is the desired IP address that + # should be saved at the DDNS service. Unfortunately, these two meanings are conflated, + # causing the bug "skipped: IP address was already set to a.b.c.d" when the IP was never + # set to a.b.c.d. + # TODO: Move the recap value elsewhere to fix the bug. + 'ip' => setv(T_IP, 0, 1, undef, undef), + # As `ip`, but only IPv4 addresses. + 'ipv4' => setv(T_IPV4, 0, 1, undef, undef), + # As `ip`, but only IPv6 addresses. + 'ipv6' => setv(T_IPV6, 0, 1, undef, undef), + # Timestamp (seconds since epoch) indicating the earliest time the next update is + # permitted. + # TODO: Create a timestamp type and change this to that type. + 'wtime' => setv(T_NUMBER,0, 1, undef, undef), + # Timestamp (seconds since epoch) indicating when an IP address was last sent to the DDNS + # service, even if the IP address was not different from what was already stored. + # TODO: Create a timestamp type and change this to that type. + 'mtime' => setv(T_NUMBER,0, 1, 0, undef), + # Timestamp (seconds since epoch) of the most recent attempt to update the DDNS service + # (including attempts to update with the same IP address). This equals mtime if the most + # recent attempt was successful, otherwise it will be more recent than mtime. + # TODO: Create a timestamp type and change this to that type. + 'atime' => setv(T_NUMBER,0, 1, 0, undef), + # Disposition of the most recent (or currently in progress) attempt to update the DDNS + # service with the IP address in `wantip`. Anything other than `good`, including undef, is + # treated as a failure. + 'status' => setv(T_ANY, 0, 1, undef, undef), + # As `status`, but with `wantipv4`. + 'status-ipv4' => setv(T_ANY, 0, 1, undef, undef), + # As `status`, but with `wantipv6`. + 'status-ipv6' => setv(T_ANY, 0, 1, undef, undef), + # Timestamp (seconds since epoch) of the most recent attempt that would have been made had + # `min-interval` not inhibited the attempt. This is reset to 0 once an attempt is actually + # made. This is used as a boolean to suppress repeated warnings to the user that indicate + # that `min-interval` has inhibited an update attempt. + # TODO: Change to a boolean and rename to improve readability. + 'warned-min-interval' => setv(T_ANY, 0, 1, 0, undef), + # Timestamp (seconds since epoch) of the most recent attempt that would have been made had + # `min-error-interval` not inhibited the attempt. This is reset to 0 once an attempt is + # actually made. This is used as a boolean to suppress repeated warnings to the user that + # indicate that `min-error-interval` has inhibited an update attempt. + # TODO: Change to a boolean and rename to improve readability. 'warned-min-error-interval' => setv(T_ANY, 0, 1, 0, undef), }, 'dyndns-common-defaults' => { 'backupmx' => setv(T_BOOL, 0, 1, 0, undef), - 'mx' => setv(T_OFQDN, 0, 1, '', undef), + 'mx' => setv(T_OFQDN, 0, 1, undef, undef), 'static' => setv(T_BOOL, 0, 1, 0, undef), 'wildcard' => setv(T_BOOL, 0, 1, 0, undef), }, ); -my %services = ( +our %protocols = ( '1984' => { - 'updateable' => undef, + 'force_update' => undef, 'update' => \&nic_1984_update, 'examples' => \&nic_1984_examples, 'variables' => { - %{$variables{'service-common-defaults'}}, + %{$variables{'protocol-common-defaults'}}, 'login' => undef, - 'server' => setv(T_FQDNP, 1, 0, 'api.1984.is', undef), + 'server' => setv(T_FQDNP, 0, 0, 'api.1984.is', undef), }, }, 'changeip' => { - 'updateable' => undef, + 'force_update' => undef, 'update' => \&nic_changeip_update, 'examples' => \&nic_changeip_examples, 'variables' => { - %{$variables{'service-common-defaults'}}, - 'min-interval' => setv(T_DELAY, 0, 0, 0, interval('5m')), - 'server' => setv(T_FQDNP, 1, 0, 'nic.changeip.com', undef), + %{$variables{'protocol-common-defaults'}}, + 'min-interval' => setv(T_DELAY, 0, 0, interval('5m'), interval('5m')), + 'server' => setv(T_FQDNP, 0, 0, 'nic.changeip.com', undef), }, }, 'cloudflare' => { - 'updateable' => undef, + 'force_update' => undef, 'update' => \&nic_cloudflare_update, 'examples' => \&nic_cloudflare_examples, 'variables' => { - %{$variables{'service-common-defaults'}}, + %{$variables{'protocol-common-defaults'}}, 'backupmx' => setv(T_BOOL, 0, 1, 0, undef), 'login' => setv(T_LOGIN, 0, 0, 'token', undef), 'min-interval' => setv(T_DELAY, 0, 0, interval('5m'), 0), - 'mx' => setv(T_OFQDN, 0, 1, '', undef), - 'server' => setv(T_FQDNP, 1, 0, 'api.cloudflare.com/client/v4', undef), + 'mx' => setv(T_OFQDN, 0, 1, undef, undef), + 'server' => setv(T_FQDNP, 0, 0, 'api.cloudflare.com/client/v4', undef), 'static' => setv(T_BOOL, 0, 1, 0, undef), - 'ttl' => setv(T_NUMBER, 1, 0, 1, undef), + 'ttl' => setv(T_NUMBER, 0, 0, 1, undef), 'wildcard' => setv(T_BOOL, 0, 1, 0, undef), - 'zone' => setv(T_FQDN, 1, 0, '', undef), + 'zone' => setv(T_FQDN, 1, 0, undef, undef), }, }, 'cloudns' => { - 'updateable' => undef, + 'force_update' => undef, 'update' => \&nic_cloudns_update, 'examples' => \&nic_cloudns_examples, 'variables' => { - %{$variables{'service-common-defaults'}}, + %{$variables{'protocol-common-defaults'}}, 'login' => undef, 'password' => undef, 'dynurl' => setv(T_STRING, 1, 0, undef, undef), }, }, + 'ddns.fm' => { + 'force_update' => undef, + 'update' => \&nic_ddnsfm_update, + 'examples' => \&nic_ddnsfm_examples, + 'variables' => { + %{$variables{'protocol-common-defaults'}}, + 'login' => undef, + 'server' => setv(T_FQDNP, 0, 0, 'https://api.ddns.fm', undef), + }, + }, 'digitalocean' => { - 'updateable' => undef, + 'force_update' => undef, 'update' => \&nic_digitalocean_update, 'examples' => \&nic_digitalocean_examples, 'variables' => { - %{$variables{'service-common-defaults'}}, + %{$variables{'protocol-common-defaults'}}, 'login' => undef, - 'server' => setv(T_FQDNP, 1, 0, 'api.digitalocean.com', undef), - 'zone' => setv(T_FQDN, 1, 0, '', undef), + 'server' => setv(T_FQDNP, 0, 0, 'api.digitalocean.com', undef), + 'zone' => setv(T_FQDN, 1, 0, undef, undef), }, }, 'dinahosting' => { - 'updateable' => undef, + 'force_update' => undef, 'update' => \&nic_dinahosting_update, 'examples' => \&nic_dinahosting_examples, 'variables' => { - %{$variables{'service-common-defaults'}}, + %{$variables{'protocol-common-defaults'}}, 'min-error-interval' => setv(T_DELAY, 0, 0, interval('8m'), 0), 'script' => setv(T_STRING, 0, 1, '/special/api.php', undef), - 'server' => setv(T_FQDNP, 1, 0, 'dinahosting.com', undef), + 'server' => setv(T_FQDNP, 0, 0, 'dinahosting.com', undef), }, }, 'dnsmadeeasy' => { - 'updateable' => undef, + 'force_update' => undef, 'update' => \&nic_dnsmadeeasy_update, 'examples' => \&nic_dnsmadeeasy_examples, 'variables' => { - %{$variables{'service-common-defaults'}}, - 'script' => setv(T_STRING, 1, 1, '/servlet/updateip', undef), - 'server' => setv(T_FQDNP, 1, 0, 'cp.dnsmadeeasy.com', undef), + %{$variables{'protocol-common-defaults'}}, + 'script' => setv(T_STRING, 0, 1, '/servlet/updateip', undef), + 'server' => setv(T_FQDNP, 0, 0, 'cp.dnsmadeeasy.com', undef), }, }, 'dondominio' => { - 'updateable' => undef, + 'force_update' => undef, 'update' => \&nic_dondominio_update, 'examples' => \&nic_dondominio_examples, 'variables' => { - %{$variables{'service-common-defaults'}}, - 'server' => setv(T_FQDNP, 1, 0, 'dondns.dondominio.com', undef), + %{$variables{'protocol-common-defaults'}}, + 'server' => setv(T_FQDNP, 0, 0, 'dondns.dondominio.com', undef), }, }, 'dslreports1' => { - 'updateable' => undef, + 'force_update' => undef, 'update' => \&nic_dslreports1_update, 'examples' => \&nic_dslreports1_examples, 'variables' => { - %{$variables{'service-common-defaults'}}, - 'server' => setv(T_FQDNP, 1, 0, 'www.dslreports.com', undef), + %{$variables{'protocol-common-defaults'}}, + 'server' => setv(T_FQDNP, 0, 0, 'www.dslreports.com', undef), }, }, 'domeneshop' => { - 'updateable' => undef, + 'force_update' => undef, 'update' => \&nic_domeneshop_update, 'examples' => \&nic_domeneshop_examples, 'variables' => { - %{$variables{'service-common-defaults'}}, - 'server' => setv(T_FQDNP, 1, 0, 'api.domeneshop.no', undef), + %{$variables{'protocol-common-defaults'}}, + 'server' => setv(T_FQDNP, 0, 0, 'api.domeneshop.no', undef), }, }, 'duckdns' => { - 'updateable' => undef, + 'force_update' => undef, 'update' => \&nic_duckdns_update, 'examples' => \&nic_duckdns_examples, 'variables' => { - %{$variables{'service-common-defaults'}}, + %{$variables{'protocol-common-defaults'}}, 'login' => undef, - 'server' => setv(T_FQDNP, 1, 0, 'www.duckdns.org', undef), + 'server' => setv(T_FQDNP, 0, 0, 'www.duckdns.org', undef), }, }, 'dyndns1' => { - 'updateable' => \&nic_dyndns2_updateable, + 'force_update' => \&nic_dyndns2_force_update, 'update' => \&nic_dyndns1_update, 'examples' => \&nic_dyndns1_examples, 'variables' => { - %{$variables{'service-common-defaults'}}, + %{$variables{'protocol-common-defaults'}}, %{$variables{'dyndns-common-defaults'}}, }, }, 'dyndns2' => { - 'updateable' => \&nic_dyndns2_updateable, + 'force_update' => \&nic_dyndns2_force_update, 'update' => \&nic_dyndns2_update, 'examples' => \&nic_dyndns2_examples, 'variables' => { - %{$variables{'service-common-defaults'}}, + %{$variables{'protocol-common-defaults'}}, %{$variables{'dyndns-common-defaults'}}, 'custom' => setv(T_BOOL, 0, 1, 0, undef), - 'script' => setv(T_STRING, 1, 1, '/nic/update', undef), + 'script' => setv(T_STRING, 0, 1, '/nic/update', undef), }, }, 'easydns' => { - 'updateable' => undef, + 'force_update' => undef, 'update' => \&nic_easydns_update, 'examples' => \&nic_easydns_examples, 'variables' => { - %{$variables{'service-common-defaults'}}, + %{$variables{'protocol-common-defaults'}}, 'backupmx' => setv(T_BOOL, 0, 1, 0, undef), 'min-interval' => setv(T_DELAY, 0, 0, interval('5m'), 0), - 'mx' => setv(T_OFQDN, 0, 1, '', undef), - 'server' => setv(T_FQDNP, 1, 0, 'api.cp.easydns.com', undef), - 'script' => setv(T_STRING, 1, 1, '/dyn/generic.php', undef), + 'mx' => setv(T_OFQDN, 0, 1, undef, undef), + 'server' => setv(T_FQDNP, 0, 0, 'api.cp.easydns.com', undef), + 'script' => setv(T_STRING, 0, 1, '/dyn/generic.php', undef), 'wildcard' => setv(T_BOOL, 0, 1, 0, undef), }, }, 'freedns' => { - 'updateable' => undef, + 'force_update' => undef, 'update' => \&nic_freedns_update, 'examples' => \&nic_freedns_examples, 'variables' => { - %{$variables{'service-common-defaults'}}, - 'min-interval' => setv(T_DELAY, 0, 0, 0, interval('5m')), - 'server' => setv(T_FQDNP, 1, 0, 'freedns.afraid.org', undef), + %{$variables{'protocol-common-defaults'}}, + 'min-interval' => setv(T_DELAY, 0, 0, interval('5m'), interval('5m')), + 'server' => setv(T_FQDNP, 0, 0, 'freedns.afraid.org', undef), }, }, 'freemyip' => { - 'updateable' => undef, + 'force_update' => undef, 'update' => \&nic_freemyip_update, 'examples' => \&nic_freemyip_examples, 'variables' => { - %{$variables{'service-common-defaults'}}, + %{$variables{'protocol-common-defaults'}}, 'login' => undef, - 'server' => setv(T_FQDNP, 1, 0, 'freemyip.com', undef), + 'server' => setv(T_FQDNP, 0, 0, 'freemyip.com', undef), }, }, 'gandi' => { - 'updateable' => undef, + 'force_update' => undef, 'update' => \&nic_gandi_update, 'examples' => \&nic_gandi_examples, 'variables' => { - %{$variables{'service-common-defaults'}}, + %{$variables{'protocol-common-defaults'}}, 'login' => undef, - 'min-interval' => setv(T_DELAY, 0, 0, 0, interval('5m')), - 'server' => setv(T_FQDNP, 1, 0, 'api.gandi.net', undef), - 'script' => setv(T_STRING, 1, 1, '/v5', undef), + 'min-interval' => setv(T_DELAY, 0, 0, interval('5m'), interval('5m')), + 'server' => setv(T_FQDNP, 0, 0, 'api.gandi.net', undef), + 'script' => setv(T_STRING, 0, 1, '/v5', undef), 'use-personal-access-token' => setv(T_BOOL, 0, 0, 0, undef), 'ttl' => setv(T_DELAY, 0, 0, undef, interval('5m')), 'zone' => setv(T_FQDN, 1, 0, undef, undef), } }, 'godaddy' => { - 'updateable' => undef, + 'force_update' => undef, 'update' => \&nic_godaddy_update, 'examples' => \&nic_godaddy_examples, 'variables' => { - %{$variables{'service-common-defaults'}}, + %{$variables{'protocol-common-defaults'}}, 'min-interval' => setv(T_DELAY, 0, 0, interval('5m'), 0), - 'server' => setv(T_FQDNP, 1, 0, 'api.godaddy.com/v1/domains', undef), - 'ttl' => setv(T_NUMBER, 1, 0, 600, undef), - 'zone' => setv(T_FQDN, 1, 0, '', undef), + 'server' => setv(T_FQDNP, 0, 0, 'api.godaddy.com/v1/domains', undef), + 'ttl' => setv(T_NUMBER, 0, 0, 600, undef), + 'zone' => setv(T_FQDN, 1, 0, undef, undef), }, }, 'googledomains' => { - 'updateable' => undef, + 'force_update' => undef, 'update' => \&nic_googledomains_update, 'examples' => \&nic_googledomains_examples, 'variables' => { - %{$variables{'service-common-defaults'}}, + %{$variables{'protocol-common-defaults'}}, 'min-interval' => setv(T_DELAY, 0, 0, interval('5m'), 0), - 'server' => setv(T_FQDNP, 1, 0, 'domains.google.com', undef), + 'server' => setv(T_FQDNP, 0, 0, 'domains.google.com', undef), + }, + }, + 'he.net' => { + 'updateable' => undef, + 'update' => \&nic_henet_update, + 'examples' => \&nic_henet_examples, + 'variables' => { + %{$variables{'protocol-common-defaults'}}, + 'login' => undef, + 'min-interval' => setv(T_DELAY, 0, 0, interval('5m'), 0), + 'server' => setv(T_FQDNP, 0, 0, 'dyn.dns.he.net', undef), }, }, 'hetzner' => { - 'updateable' => undef, + 'force_update' => undef, 'update' => \&nic_hetzner_update, 'examples' => \&nic_hetzner_examples, 'variables' => { - %{$variables{'service-common-defaults'}}, - 'login' => setv(T_LOGIN, 0, 0, 'token', undef), + %{$variables{'protocol-common-defaults'}}, + 'login' => undef, 'min-interval' => setv(T_DELAY, 0, 0, interval('1m'), 0), - 'server' => setv(T_FQDNP, 1, 0, 'dns.hetzner.com/api/v1', undef), + 'server' => setv(T_FQDNP, 0, 0, 'dns.hetzner.com/api/v1', undef), 'ttl' => setv(T_NUMBER, 0, 0, 60, 60), - 'zone' => setv(T_FQDN, 1, 0, '', undef), + 'zone' => setv(T_FQDN, 1, 0, undef, undef), }, }, 'mythicdyn' => { - 'updateable' => undef, + 'force_update' => undef, 'update' => \&nic_mythicdyn_update, 'examples' => \&nic_mythicdyn_examples, 'variables' => { - %{$variables{'service-common-defaults'}}, + %{$variables{'protocol-common-defaults'}}, 'min-interval' => setv(T_DELAY, 0, 0, interval('5m'), 0), - 'server' => setv(T_FQDNP, 1, 0, 'api.mythic-beasts.com', undef), + 'server' => setv(T_FQDNP, 0, 0, 'api.mythic-beasts.com', undef), }, }, 'namecheap' => { - 'updateable' => undef, + 'force_update' => undef, 'update' => \&nic_namecheap_update, 'examples' => \&nic_namecheap_examples, 'variables' => { - %{$variables{'service-common-defaults'}}, - 'min-interval' => setv(T_DELAY, 0, 0, 0, interval('5m')), - 'server' => setv(T_FQDNP, 1, 0, 'dynamicdns.park-your-domain.com', undef), + %{$variables{'protocol-common-defaults'}}, + 'min-interval' => setv(T_DELAY, 0, 0, interval('5m'), interval('5m')), + 'server' => setv(T_FQDNP, 0, 0, 'dynamicdns.park-your-domain.com', undef), }, }, 'nfsn' => { - 'updateable' => undef, + 'force_update' => undef, 'update' => \&nic_nfsn_update, 'examples' => \&nic_nfsn_examples, 'variables' => { - %{$variables{'service-common-defaults'}}, - 'min_interval' => setv(T_FQDNP, 0, 0, 0, interval('5m')), - 'server' => setv(T_FQDNP, 1, 0, 'api.nearlyfreespeech.net', undef), - 'ttl' => setv(T_NUMBER, 1, 0, 300, undef), + %{$variables{'protocol-common-defaults'}}, + 'min-interval' => setv(T_DELAY, 0, 0, interval('5m'), interval('5m')), + 'server' => setv(T_FQDNP, 0, 0, 'api.nearlyfreespeech.net', undef), + 'ttl' => setv(T_NUMBER, 0, 0, 300, undef), 'zone' => setv(T_FQDN, 1, 0, undef, undef), }, }, 'njalla' => { - 'updateable' => undef, + 'force_update' => undef, 'update' => \&nic_njalla_update, 'examples' => \&nic_njalla_examples, 'variables' => { - %{$variables{'service-common-defaults'}}, + %{$variables{'protocol-common-defaults'}}, 'login' => undef, - 'server' => setv(T_FQDNP, 1, 0, 'njal.la', undef), + 'server' => setv(T_FQDNP, 0, 0, 'njal.la', undef), 'quietreply' => setv(T_BOOL, 0, 1, 0, undef), }, }, 'noip' => { - 'updateable' => undef, + 'force_update' => undef, 'update' => \&nic_noip_update, 'examples' => \&nic_noip_examples, 'variables' => { - %{$variables{'service-common-defaults'}}, - 'custom' => setv(T_BOOL, 0, 1, 0, undef), - 'server' => setv(T_FQDNP, 1, 0, 'dynupdate.no-ip.com', undef), - 'static' => setv(T_BOOL, 0, 1, 0, undef), + %{$variables{'protocol-common-defaults'}}, + 'server' => setv(T_FQDNP, 0, 0, 'dynupdate.no-ip.com', undef), }, }, 'nsupdate' => { - 'updateable' => undef, + 'force_update' => undef, 'update' => \&nic_nsupdate_update, 'examples' => \&nic_nsupdate_examples, 'variables' => { - %{$variables{'service-common-defaults'}}, - 'login' => setv(T_LOGIN, 1, 0, '/usr/bin/nsupdate', undef), + %{$variables{'protocol-common-defaults'}}, + 'login' => setv(T_LOGIN, 0, 0, '/usr/bin/nsupdate', undef), 'tcp' => setv(T_BOOL, 0, 1, 0, undef), 'ttl' => setv(T_NUMBER, 0, 1, 600, undef), - 'zone' => setv(T_STRING, 1, 1, '', undef), + 'zone' => setv(T_STRING, 1, 1, undef, undef), }, }, 'ovh' => { - 'updateable' => undef, + 'force_update' => undef, 'update' => \&nic_ovh_update, 'examples' => \&nic_ovh_examples, 'variables' => { - %{$variables{'service-common-defaults'}}, - 'script' => setv(T_STRING, 1, 1, '/nic/update', undef), - 'server' => setv(T_FQDNP, 1, 0, 'www.ovh.com', undef), + %{$variables{'protocol-common-defaults'}}, + 'script' => setv(T_STRING, 0, 1, '/nic/update', undef), + 'server' => setv(T_FQDNP, 0, 0, 'www.ovh.com', undef), }, }, 'porkbun' => { - 'updateable' => undef, + 'force_update' => undef, 'update' => \&nic_porkbun_update, 'examples' => \&nic_porkbun_examples, 'variables' => { - %{$variables{'service-common-defaults'}}, + %{$variables{'protocol-common-defaults'}}, 'login' => undef, 'password' => undef, - 'apikey' => setv(T_PASSWD, 1, 0, '', undef), - 'secretapikey' => setv(T_PASSWD, 1, 0, '', undef), - 'root-domain' => setv(T_OFQDN, 0, 0, '', undef), + 'apikey' => setv(T_PASSWD, 1, 0, undef, undef), + 'secretapikey' => setv(T_PASSWD, 1, 0, undef, undef), + 'root-domain' => setv(T_OFQDN, 0, 0, undef, undef), 'on-root-domain' => setv(T_BOOL, 0, 0, 0, undef), }, }, 'sitelutions' => { - 'updateable' => undef, + 'force_update' => undef, 'update' => \&nic_sitelutions_update, 'examples' => \&nic_sitelutions_examples, 'variables' => { - %{$variables{'service-common-defaults'}}, - 'server' => setv(T_FQDNP, 1, 0, 'www.sitelutions.com', undef), - 'min-interval' => setv(T_DELAY, 0, 0, 0, interval('5m')), + %{$variables{'protocol-common-defaults'}}, + 'server' => setv(T_FQDNP, 0, 0, 'www.sitelutions.com', undef), + 'min-interval' => setv(T_DELAY, 0, 0, interval('5m'), interval('5m')), }, }, 'woima' => { - 'updateable' => undef, + 'force_update' => undef, 'update' => \&nic_woima_update, 'examples' => \&nic_woima_examples, 'variables' => { - %{$variables{'service-common-defaults'}}, + %{$variables{'protocol-common-defaults'}}, 'backupmx' => setv(T_BOOL, 0, 1, 0, undef), 'custom' => setv(T_BOOL, 0, 1, 0, undef), - 'mx' => setv(T_OFQDN, 0, 1, '', undef), - 'script' => setv(T_STRING, 1, 1, '/nic/update', undef), - 'server' => setv(T_FQDNP, 1, 0, 'dyn.woima.fi', undef), + 'mx' => setv(T_OFQDN, 0, 1, undef, undef), + 'script' => setv(T_STRING, 0, 1, '/nic/update', undef), + # As of 2024-07-13, dyn.woima.fi does not have a valid TLS certificate so the `http:` + # scheme is explicitly specified here. Once a proper certificate is deployed, or if + # the user overrides certificate validation, the user can manually set the `server` + # option and either change http: to https: or omit the scheme (in which case ddclient + # will use the value of the `ssl` option to determine the scheme). + 'server' => setv(T_FQDNP, 0, 0, 'http://dyn.woima.fi', undef), 'static' => setv(T_BOOL, 0, 1, 0, undef), 'wildcard' => setv(T_BOOL, 0, 1, 0, undef), }, }, 'yandex' => { - 'updateable' => undef, + 'force_update' => undef, 'update' => \&nic_yandex_update, 'examples' => \&nic_yandex_examples, 'variables' => { - %{$variables{'service-common-defaults'}}, + %{$variables{'protocol-common-defaults'}}, 'min-interval' => setv(T_DELAY, 0, 0, interval('5m'), 0), - 'server' => setv(T_FQDNP, 1, 0, 'pddimp.yandex.ru', undef), + 'server' => setv(T_FQDNP, 0, 0, 'pddimp.yandex.ru', undef), }, }, 'zoneedit1' => { - 'updateable' => undef, + 'force_update' => undef, 'update' => \&nic_zoneedit1_update, 'examples' => \&nic_zoneedit1_examples, 'variables' => { - %{$variables{'service-common-defaults'}}, + %{$variables{'protocol-common-defaults'}}, 'min-interval' => setv(T_DELAY, 0, 0, interval('10m'), 0), - 'server' => setv(T_FQDNP, 1, 0, 'dynamic.zoneedit.com', undef), + 'server' => setv(T_FQDNP, 0, 0, 'dynamic.zoneedit.com', undef), 'zone' => setv(T_OFQDN, 0, 0, undef, undef), }, }, 'keysystems' => { - 'updateable' => undef, + 'force_update' => undef, 'update' => \&nic_keysystems_update, 'examples' => \&nic_keysystems_examples, 'variables' => { - %{$variables{'service-common-defaults'}}, + %{$variables{'protocol-common-defaults'}}, 'login' => undef, - 'server' => setv(T_FQDNP, 1, 0, 'dynamicdns.key-systems.net', undef), + 'server' => setv(T_FQDNP, 0, 0, 'dynamicdns.key-systems.net', undef), }, }, 'dnsexit2' => { - 'updateable' => undef, + 'force_update' => undef, 'update' => \&nic_dnsexit2_update, 'examples' => \&nic_dnsexit2_examples, 'variables' => { - %{$variables{'service-common-defaults'}}, + %{$variables{'protocol-common-defaults'}}, 'login' => undef, 'ssl' => setv(T_BOOL, 0, 0, 1, undef), - 'server' => setv(T_FQDNP, 1, 0, 'api.dnsexit.com', undef), + 'server' => setv(T_FQDNP, 0, 0, 'api.dnsexit.com', undef), 'path' => setv(T_STRING, 0, 0, '/dns/', undef), - 'ttl' => setv(T_NUMBER, 1, 0, 5, 0), + 'ttl' => setv(T_NUMBER, 0, 0, 5, 0), 'zone' => setv(T_STRING, 0, 0, undef, undef), }, }, 'regfishde' => { - 'updateable' => undef, + 'force_update' => undef, 'update' => \&nic_regfishde_update, 'examples' => \&nic_regfishde_examples, 'variables' => { - %{$variables{'service-common-defaults'}}, + %{$variables{'protocol-common-defaults'}}, 'login' => undef, - 'server' => setv(T_FQDNP, 1, 0, 'dyndns.regfish.de', undef), + 'server' => setv(T_FQDNP, 0, 0, 'dyndns.regfish.de', undef), }, }, 'enom' => { - 'updateable' => undef, + 'force_update' => undef, 'update' => \&nic_enom_update, 'examples' => \&nic_enom_examples, 'variables' => { - %{$variables{'service-common-defaults'}}, - 'server' => setv(T_FQDNP, 1, 0, 'dynamic.name-services.com', undef), - 'min-interval' => setv(T_DELAY, 0, 0, 0, interval('5m')), + %{$variables{'protocol-common-defaults'}}, + 'server' => setv(T_FQDNP, 0, 0, 'dynamic.name-services.com', undef), + 'min-interval' => setv(T_DELAY, 0, 0, interval('5m'), interval('5m')), }, }, 'infomaniak' => { - 'updateable' => undef, + 'force_update' => undef, 'update' => \&nic_infomaniak_update, 'examples' => \&nic_infomaniak_examples, 'variables' => { - %{$variables{'service-common-defaults'}}, + %{$variables{'protocol-common-defaults'}}, + 'server' => undef, + }, + }, + 'emailonly' => { + 'force_update' => undef, + 'update' => \&nic_emailonly_update, + 'examples' => \&nic_emailonly_examples, + 'variables' => { + %{$variables{'protocol-common-defaults'}}, + 'login' => undef, + 'password' => undef, + # Change default to never re-notify if IP address has not changed. + 'max-interval' => setv(T_DELAY, 0, 0, 'inf', 0), }, }, ); # Delete undefined variables to make it easier to cancel previously defined variables. -for my $svc (values(%services)) { - my $vars = $svc->{variables}; +for my $proto (values(%protocols)) { + my $vars = $proto->{variables}; delete(@$vars{grep(!defined($vars->{$_}), keys(%$vars))}); } $variables{'merged'} = { - map({ %{$services{$_}{'variables'}} } keys(%services)), + map({ %{$protocols{$_}{'variables'}} } keys(%protocols)), %{$variables{'dyndns-common-defaults'}}, - %{$variables{'service-common-defaults'}}, + %{$variables{'protocol-common-defaults'}}, %{$variables{'global-defaults'}}, }; @@ -1089,11 +1183,15 @@ $opt{'list-devices'} = sub { exit(0); }; $opt{'list-protocols'} = sub { - printf("%s\n", $_) for sort(keys(%services)); + printf("%s\n", $_) for sort(keys(%protocols)); exit(0); }; $opt{'list-web-services'} = sub { - printf("%s %s\n", $_, $builtinweb{$_}{url}) for sort(keys(%builtinweb)); + # This intentionally does not list deprecated services, although they are still accepted. + # Excluding deprecated services from the output discourages their selection by configuration + # wizards (e.g., Debian's debconf) that present this list to users. + printf("%s %s\n", $_, $builtinweb{$_}{url}) + for sort(grep(!$builtinweb{$_}{deprecated}, keys(%builtinweb))); exit(0); }; $opt{'version'} = sub { @@ -1149,6 +1247,7 @@ my @opt = ( "", " Options related to '--use=fw', '--usev4=fwv4', '--usev6=fwv6', and '--usev6=fw'", " as well as '--use=', '--usev4=', and '--usev6=':", + ["fw", "=s", "--fw= : deprecated, use '--fwv4' or '--fwv6'"], ["fw-skip", "=s", "--fw-skip= : deprecated, use '--fwv4-skip' or '--fwv6-skip'"], ["fwv4", "=s", "--fwv4= : obtain IPv4 address from device with IP address
or URL "], ["fwv4-skip", "=s", "--fwv4-skip= : skip any IP addresses before in the text returned from the device"], @@ -1170,7 +1269,7 @@ my @opt = ( "", ["options", "=s", "--options==[,=,...]\n : optional per-service arguments (see below)"], "", - ["ssl", "!", "--{no}ssl : do updates over encrypted SSL connection"], + ["ssl", "!", '--{no}ssl : use encryption (TLS) when the scheme (either "http://" or "https://") is missing from a URL'], ["ssl_ca_dir", "=s", "--ssl_ca_dir= : look in for certificates of trusted certificate authorities (default: auto-detect)"], ["ssl_ca_file", "=s", "--ssl_ca_file= : look at for certificates of trusted certificate authorities (default: auto-detect)"], ["fw-ssl-validate", "!", "--{no}fw-ssl-validate : Validate SSL certificate when retrieving IP address from firewall"], @@ -1204,7 +1303,7 @@ my @opt = ( sub main { ## process args my $opt_usage = process_args(@opt); - $saved_cache = ''; + $saved_recap = ''; %saved_opt = %opt; $result = 'OK'; @@ -1256,13 +1355,15 @@ sub main { read_config($opt{'file'} // default('file'), \%config, \%globals); init_config(); - read_cache(opt('cache'), \%cache); + read_recap(opt('cache'), \%recap); print_info() if opt('debug') && opt('verbose'); - fatal("invalid argument '--use=%s'; possible values are:\n%s", $opt{'use'}, join("\n", ip_strategies_usage())) - unless exists $ip_strategies{lc opt('use')}; + fatal("invalid argument '--use=%s'; possible values are:\n%s", + $opt{'use'}, join("\n", ip_strategies_usage())) + if defined(opt('use')) && !$ip_strategies{lc(opt('use'))}; if (defined($opt{'usev6'})) { - usage("invalid argument '--usev6=%s'; possible values are:\n%s", $opt{'usev6'}, join("\n",ipv6_strategies_usage())) + fatal("invalid argument '--usev6=%s'; possible values are:\n%s", + $opt{'usev6'}, join("\n", ipv6_strategies_usage())) unless exists $ipv6_strategies{lc opt('usev6')}; } @@ -1330,21 +1431,21 @@ sub update_nics { my %ipv4list = (); my %ipv6list = (); - foreach my $s (sort keys %services) { + for my $p (sort keys %protocols) { my (@hosts, %ipsv4, %ipsv6) = (); - my $updateable = $services{$s}{'updateable'}; - my $update = $services{$s}{'update'}; + my $force_update = $protocols{$p}{'force_update'}; + my $update = $protocols{$p}{'update'}; - foreach my $h (sort keys %config) { - next if $config{$h}{'protocol'} ne lc($s); + for my $h (sort keys %config) { + next if $config{$h}{'protocol'} ne lc($p); $examined{$h} = 1; # we only do this once per 'use' and argument combination - my $use = opt('use', $h) // 'disabled'; - my $usev4 = opt('usev4', $h) // 'disabled'; - my $usev6 = opt('usev6', $h) // 'disabled'; - $use = 'disabled' if ($use eq 'no'); # backward compatibility - $usev6 = 'disabled' if ($usev6 eq 'no'); # backward compatibility - $use = 'disabled' if ($usev4 ne 'disabled') || ($usev6 ne 'disabled'); + my $use = opt('use', $h) // 'disabled'; + my $usev4 = opt('usev4', $h) // 'disabled'; + my $usev6 = opt('usev6', $h) // 'disabled'; + $use = 'disabled' if ($use eq 'no'); # backward compatibility + $usev6 = 'disabled' if ($usev6 eq 'no'); # backward compatibility + $use = 'disabled' if ($usev4 ne 'disabled') || ($usev6 ne 'disabled'); my $arg_ip = opt('ip', $h) // ''; my $arg_ipv4 = opt('ipv4', $h) // ''; my $arg_ipv6 = opt('ipv6', $h) // ''; @@ -1379,8 +1480,6 @@ sub update_nics { if !$daemon || opt('verbose'); } } - # And remember it as the IP address we want to send to the DNS service. - $config{$h}{'wantip'} = $ip; } if ($usev4 ne 'disabled') { @@ -1398,8 +1497,6 @@ sub update_nics { if !$daemon || opt('verbose'); } } - # And remember it as the IPv4 address we want to send to the DNS service. - $config{$h}{'wantipv4'} = $ipv4; } if ($usev6 ne 'disabled') { @@ -1417,25 +1514,21 @@ sub update_nics { if !$daemon || opt('verbose'); } } - # And remember it as the IP address we want to send to the DNS service. - $config{$h}{'wantipv6'} = $ipv6; } - # DNS service update functions should only have to handle 'wantipv4' and 'wantipv6' - $config{$h}{'wantipv4'} = $ipv4 = $ip if (!$ipv4 && is_ipv4($ip)); - $config{$h}{'wantipv6'} = $ipv6 = $ip if (!$ipv6 && is_ipv6($ip)); - # If we don't have 'wantip', we fill it from 'wantipv4' or 'wantipv6' - # so old provider implementations continue to work until we update them all. - $config{$h}{'wantip'} = $ipv4 if (!$ip && $ipv4); - $config{$h}{'wantip'} = $ipv6 if (!$ip && !$ipv4 && $ipv6); + $ip //= $ipv4 // $ipv6; + $ipv4 //= $ip if is_ipv4($ip); + $ipv6 //= $ip if is_ipv6($ip); + $config{$h}{'wantip'} = $ip; + $config{$h}{'wantipv4'} = $ipv4; + $config{$h}{'wantipv6'} = $ipv6; - if (!$ip && !$ipv4 && !$ipv6) - { + if (!$ip && !$ipv4 && !$ipv6) { warning("Could not determine an IP for %s", $h); next; } - next if !nic_updateable($h, $updateable); + next if !nic_updateable($h, $force_update); push @hosts, $h; $ipsv4{$ipv4} = $h if ($ipv4); @@ -1451,7 +1544,7 @@ sub update_nics { # The new '--usev*' parameters set 'wantipv*' and the new providers set 'ipv*' and 'status-ipv*'. # To allow gradual transition, we make sure both the old 'status' and 'ip' are being set # accordingly to what new providers returned in the new 'status-ipv*' and 'ipv*' fields respectively. - foreach my $h (@hosts) { + for my $h (@hosts) { $config{$h}{'status'} //= $config{$h}{'status-ipv4'} // $config{$h}{'status-ipv6'}; $config{$h}{'ip'} //= $config{$h}{'ipv4'} // $config{$h}{'ipv6'}; } @@ -1459,13 +1552,13 @@ sub update_nics { runpostscript(join ' ', keys %ipsv4, keys %ipsv6); } } - foreach my $h (sort keys %config) { + for my $h (sort keys %config) { if (!exists $examined{$h}) { failed("%s was not updated because protocol %s is not supported.", $h, $config{$h}{'protocol'} // ''); } } - write_cache(opt('cache')); + write_recap(opt('cache')); } ###################################################################### @@ -1496,30 +1589,33 @@ sub write_pid { } ###################################################################### -## write_cache($file) +## write_recap($file) ###################################################################### -sub write_cache { +sub write_recap { my ($file) = @_; - ## merge the updated host entries into the cache. - foreach my $h (keys %config) { - if (!exists $cache{$h} || $config{$h}{'update'}) { - map { defined($config{$h}{$_}) ? ($cache{$h}{$_} = $config{$h}{$_}) : () } @{$config{$h}{'cacheable'}}; + for my $h (keys %config) { + if (!exists $recap{$h} || $config{$h}{'update'}) { + my $vars = $protocols{$config{$h}{protocol}}{variables}; + for my $v (keys(%$vars)) { + next if !$vars->{$v}{recap} || !defined($config{$h}{$v}); + $recap{$h}{$v} = $config{$h}{$v}; + } } else { - map { $cache{$h}{$_} = $config{$h}{$_} } qw(atime wtime status); + for my $v (qw(atime wtime status status-ipv4 status-ipv6)) { + $recap{$h}{$v} = $config{$h}{$v}; + } } } - ## construct the cache file. - my $cache = ""; - foreach my $h (sort keys %cache) { - my $opt = join(',', map { "$_=" . ($cache{$h}{$_} // '') } sort keys %{$cache{$h}}); + my $recap = ""; + for my $h (sort keys %recap) { + my $opt = join(',', map { "$_=" . ($recap{$h}{$_} // '') } sort keys %{$recap{$h}}); - $cache .= sprintf "%s%s%s\n", $opt, ($opt ? ' ' : ''), $h; + $recap .= sprintf "%s%s%s\n", $opt, ($opt ? ' ' : ''), $h; } - $file = '' if defined($saved_cache) && $cache eq $saved_cache; + $file = '' if defined($saved_recap) && $recap eq $saved_recap; - ## write the updates and other entries to the cache file. if ($file) { (undef, my $dir) = fileparse($file); make_path($dir, { error => \my $err }) if !-d $dir; @@ -1531,7 +1627,7 @@ sub write_cache { return; } - $saved_cache = undef; + $saved_recap = undef; local *FD; if (!open(FD, ">", $file)) { warning("Failed to create cache file %s: %s", $file, $!); @@ -1539,32 +1635,35 @@ sub write_cache { } printf FD "## %s-%s\n", $program, $version; printf FD "## last updated at %s (%d)\n", prettytime($now), $now; - printf FD "%s", $cache; + printf FD "%s", $recap; close(FD); } } ###################################################################### -## read_cache($file) - called before reading the .conf +## read_recap($file) - called before reading the .conf ###################################################################### -sub read_cache { +sub read_recap { my $file = shift; my $config = shift; my $globals = {}; %{$config} = (); - ## read the cache file ignoring anything on the command-line. if (-e $file) { my %saved = %opt; %opt = (); - $saved_cache = _read_config($config, $globals, "##\\s*$program-$version\\s*", $file); + $saved_recap = _read_config($config, $globals, "##\\s*$program-$version\\s*", $file); %opt = %saved; - foreach my $h (keys %cache) { - if (exists $config->{$h}) { - foreach (qw(atime mtime wtime ip status)) { - $config->{$h}{$_} = $cache{$h}{$_} if exists $cache{$h}{$_}; - } + for my $h (keys(%recap)) { + next if !exists($config->{$h}); + for (qw(atime mtime wtime ip ipv4 ipv6 status status-ipv4 status-ipv6)) { + # TODO: Isn't $config equal to \%recap here? If so, this is a no-op. What was the + # original intention behind this? To copy %recap values into %config? If so, is + # it better to just delete this and live with the current behavior (which doesn't + # seem to be causing users any problems) or to "fix" it to match the original + # intention, which might introduce a bug? + $config->{$h}{$_} = $recap{$h}{$_} if exists $recap{$h}{$_}; } } } @@ -1744,71 +1843,67 @@ sub _read_config { my @args = split; ## verify that keywords are valid...and check the value - foreach my $k (keys %locals) { + for my $k (keys %locals) { # Handle '_env' keyword suffix - if ($k =~ /(.*)_env$/) - { + if ($k =~ /(.*)_env$/) { debug("Loading value for $1 from environment variable $locals{$k}."); - if (exists($ENV{$locals{$k}})) - { - # Set the value to the value of the environment variable - $locals{$1} = $ENV{$locals{$k}}; - # Remove the '_env' suffix from the key - $k = $1; - } - else - { + if (!exists($ENV{$locals{$k}})) { warning("Environment variable '$locals{$k}' not set for keyword '$k' (ignored)"); delete $locals{$k}; next; } + # Set the value to the value of the environment variable + $locals{$1} = $ENV{$locals{$k}}; + # Remove the '_env' suffix from the key + $k = $1; } $locals{$k} = $passwords{$k} if defined $passwords{$k}; if (!exists $variables{'merged'}{$k}) { warning("unrecognized keyword '%s' (ignored)", $k); delete $locals{$k}; - } else { - my $def = $variables{'merged'}{$k}; - my $value = check_value($locals{$k}, $def); - if (!defined($value)) { - warning("Invalid Value for keyword '%s' = '%s'", $k, $locals{$k}); - delete $locals{$k}; - } else { $locals{$k} = $value; } + next; } + my $def = $variables{'merged'}{$k}; + my $value = check_value($locals{$k}, $def); + if (!defined($value)) { + warning("Invalid Value for keyword '%s' = '%s'", $k, $locals{$k}); + delete $locals{$k}; + next; + } + $locals{$k} = $value; } + %passwords = (); if (exists($locals{'host'})) { - $args[0] = @args ? "$args[0],$locals{host}" : "$locals{host}"; + $args[0] = (@args ? "$args[0]," : '') . $locals{host}; } ## accumulate globals - if ($#args < 0) { - map { $globals{$_} = $locals{$_} } keys %locals; + if (!@args) { + %globals = (%globals, %locals); + next; } ## process this host definition - if (@args) { - my ($host, $login, $password) = @args; + my ($host, $login, $password) = @args; - ## add in any globals.. - %locals = (%globals, %locals); + ## add in any globals.. + %locals = (%globals, %locals); - ## override login and password if specified the old way. - $locals{'login'} = $login if defined $login; - $locals{'password'} = $password if defined $password; + ## override login and password if specified the old way. + $locals{'login'} = $login if defined $login; + $locals{'password'} = $password if defined $password; - ## allow {host} to be a comma separated list of hosts - foreach my $h (split_by_comma($host)) { - if ($config{$h}) { - ## host already defined, merging configs - $config{$h} = {%locals, %{$config{$h}}}; - } else { - ## save a copy of the current globals - $config{$h} = { %locals }; - $config{$h}{'host'} = $h; - } - } + ## allow {host} to be a comma separated list of hosts + for my $h (split_by_comma($host)) { + # TODO: Shouldn't %locals go after $config{h}? Later lines should override earlier + # lines, no? Otherwise, later assignments will have a mixed effect: assignments to new + # variables will take effect but assignments to variables that already have a value + # will not. One problem with swapping the order: due to the `%locals = (%globals, + # %locals)` line above, any values in %globals would override any locals in the + # previous host line. + $config{$h} = {%locals, %{$config{$h} // {}}}; + $config{$h}{'host'} = $h; } - %passwords = (); } close(FD); @@ -1859,13 +1954,13 @@ sub init_config { ## parse an interval expression (such as '5m') into number of seconds $opt{'daemon'} = interval(opt('daemon')) if defined($opt{'daemon'}); ## make sure the interval isn't too short - $opt{'daemon'} = minimum('daemon') if opt('daemon') > 0 && opt('daemon') < minimum('daemon'); + $opt{'daemon'} = minimum('daemon') if opt('daemon') && opt('daemon') < minimum('daemon'); ## define or modify host options specified on the command-line - if (exists $opt{'options'} && defined $opt{'options'}) { + if (defined($opt{'options'})) { ## collect cmdline configuration options. my %options = (); - foreach my $opt (split_by_comma($opt{'options'})) { + for my $opt (split_by_comma($opt{'options'})) { my ($name, $var) = split /\s*=\s*/, $opt; if ($name eq 'fw-banlocal' || $name eq 'if-skip') { warning("'$name' is deprecated and does nothing"); @@ -1876,21 +1971,21 @@ sub init_config { ## determine hosts specified with --host my @hosts = (); if (exists $opt{'host'}) { - foreach my $h (split_by_comma($opt{'host'})) { + for my $h (split_by_comma($opt{'host'})) { push @hosts, $h; } } ## and those in --options=... if (exists $options{'host'}) { - foreach my $h (split_by_comma($options{'host'})) { + for my $h (split_by_comma($options{'host'})) { push @hosts, $h; } delete $options{'host'}; } ## merge options into host definitions or globals if (@hosts) { - foreach my $h (@hosts) { - $config{$h} = {%{$config{$h}}, %options}; + for my $h (@hosts) { + $config{$h} = {%{$config{$h} // {}}, %options, 'host' => $h}; } $opt{'host'} = join(',', @hosts); } else { @@ -1899,8 +1994,14 @@ sub init_config { } ## override global options with those on the command-line. - foreach my $o (keys %opt) { + for my $o (keys %opt) { + # TODO: Isn't $opt{$o} guaranteed to be defined? Otherwise $o wouldn't appear in the keys + # of %opt, right? + # TODO: Why is this limited to $variables{'global-defaults'}? Why not + # $variables{'merged'}? if (defined $opt{$o} && exists $variables{'global-defaults'}{$o}) { + # TODO: What's the point of this? The opt() function will fall back to %globals if + # %opt doesn't have a value, so this shouldn't be necessary. $globals{$o} = $opt{$o}; } } @@ -1911,13 +2012,14 @@ sub init_config { } fatal("options --retry and --daemon cannot be used together") if (opt('retry') && opt('daemon')); - ## determine hosts to update (those on the cmd-line, config-file, or failed cached) + ## determine hosts to update (those on the cmd-line, config-file, or failed in recap) my @hosts = keys %config; if (opt('host')) { @hosts = split_by_comma($opt{'host'}); } + # TODO: This function is called before the recap file is read. How is this supposed to work? if (opt('retry')) { - @hosts = map { $_ if ($cache{$_}{'status'} // '') ne 'good' } keys %cache; + @hosts = grep(($recap{$_}{'status'} // '') ne 'good', keys(%recap)); } ## remove any other hosts @@ -1925,26 +2027,30 @@ sub init_config { map { $hosts{$_} = undef } @hosts; map { delete $config{$_} unless exists $hosts{$_} } keys %config; - ## collect the cacheable variables. - foreach my $proto (keys %services) { - my @cacheable = (); - foreach my $k (keys %{$services{$proto}{'variables'}}) { - push @cacheable, $k if $services{$proto}{'variables'}{$k}{'cache'}; - } - $services{$proto}{'cacheable'} = [ @cacheable ]; - } - ## sanity check.. ## make sure config entries have all defaults and they meet minimums ## first the globals... - foreach my $k (keys %globals) { + for my $k (keys %globals) { # Make sure any _env suffixed variables look at their original entry $k = $1 if $k =~ /^(.*)_env$/; - my $def = $variables{'merged'}{$k}; + # TODO: This might grab an arbitrary protocol-specific variable, which could cause + # surprising behavior. + my $def = $variables{'merged'}{$k}; + if (!$def) { + warning("ignoring unknown setting '$k=$globals{$k}'"); + delete($globals{$k}); + next; + } + # TODO: Isn't $globals{$k} guaranteed to be defined here? Otherwise $k wouldn't appear in + # %globals. my $ovalue = $globals{$k} // $def->{'default'}; - my $value = check_value($ovalue, $def); + # TODO: Didn't _read_config already check the value? Or is the purpose of this to check + # the value of command-line options ($opt{$k}) which were merged into %globals above? + my $value = check_value($ovalue, $def); if ($def->{'required'} && !defined $value) { + # TODO: What's the point of this? The opt() function will fall back to the default + # value if $globals{$k} is undefined. $value = default($k); warning("'%s=%s' is an invalid %s. (using default of %s)", $k, $ovalue, $def->{'type'}, $value); } @@ -1953,40 +2059,36 @@ sub init_config { ## now the host definitions... HOST: - foreach my $h (keys %config) { - my $proto; - $proto = $config{$h}{'protocol'}; - $proto = opt('protocol') if !defined($proto); + for my $h (keys %config) { + my $proto = opt('protocol', $h); + load_sha1_support($proto) if (grep($_ eq $proto, ("freedns", "nfsn"))); + load_json_support($proto) if (grep($_ eq $proto, ("1984", "cloudflare", "digitalocean", "gandi", "godaddy", "hetzner", "yandex", "nfsn", "njalla", "porkbun", "dnsexit2"))); - load_sha1_support($proto) if (grep (/^$proto$/, ("freedns", "nfsn"))); - load_json_support($proto) if (grep (/^$proto$/, ("1984", "cloudflare", "digitalocean", "gandi", "godaddy", "hetzner", "yandex", "nfsn", "njalla", "porkbun", "dnsexit2"))); - - if (!exists($services{$proto})) { + if (!exists($protocols{$proto})) { warning("skipping host: %s: unrecognized protocol '%s'", $h, $proto); delete $config{$h}; - - } else { - my $svars = $services{$proto}{'variables'}; - my $conf = { 'protocol' => $proto }; - - foreach my $k (keys %$svars) { - # Make sure any _env suffixed variables look at their original entry - $k = $1 if $k =~ /^(.*)_env$/; - - my $def = $svars->{$k}; - my $ovalue = $config{$h}{$k} // $def->{'default'}; - my $value = check_value($ovalue, $def); - if ($def->{'required'} && !defined $value) { - warning("skipping host: %s: '%s=%s' is an invalid %s.", $h, $k, $ovalue, $def->{'type'}); - delete $config{$h}; - next HOST; - } - $conf->{$k} = $value; - - } - $config{$h} = $conf; - $config{$h}{'cacheable'} = [ @{$services{$proto}{'cacheable'}} ]; + next; } + + my $svars = $protocols{$proto}{'variables'}; + my $conf = {'host' => $h, 'protocol' => $proto}; + + for my $k (keys %$svars) { + # Make sure any _env suffixed variables look at their original entry + $k = $1 if $k =~ /^(.*)_env$/; + + my $def = $svars->{$k}; + my $ovalue = $config{$h}{$k} // $def->{'default'}; + my $value = check_value($ovalue, $def); + if ($def->{'required'} && !defined $value) { + $ovalue //= '(not set)'; + warning("skipping host $h: invalid $def->{type} variable value '$k=$ovalue'"); + delete $config{$h}; + next HOST; + } + $conf->{$k} = $value; + } + $config{$h} = $conf; } } @@ -1997,7 +2099,7 @@ sub process_args { my @spec = (); my $usage = ""; - foreach (@_) { + for (@_) { if (ref $_) { my ($key, $specifier, $arg_usage) = @$_; my $value = default($key); @@ -2006,7 +2108,7 @@ sub process_args { push @spec, $key . $specifier; ## define the default value which can be overwritten later - $opt{$key} = undef unless exists($opt{$key}); + $opt{$key} //= undef; next unless $arg_usage; @@ -2042,9 +2144,10 @@ sub test_possible_ip { local $opt{'debug'} = 0; printf "----- Test_possible_ip with 'get_ip' -----\n"; - printf "use=ip, ip=%s address is %s\n", opt('ip'), get_ip('ip') // 'NOT FOUND' - if defined opt('ip'); - + if (defined(opt('ip'))) { + local $opt{'use'} = 'ip'; + printf "use=ip, ip=%s address is %s\n", opt('ip'), get_ip('ip') // 'NOT FOUND'; + } { local $opt{'use'} = 'if'; # Note: The `ip` command adds a `@eth0` suffix to the names of VLAN @@ -2055,26 +2158,26 @@ sub test_possible_ip { `command -v ifconfig >/dev/null && ifconfig -a`) if $? || !@ifs; @ifs = () if $?; warning("failed to get list of interfaces") if !@ifs; - foreach my $if (@ifs) { + for my $if (@ifs) { local $opt{'if'} = $if; printf "use=if, if=%s address is %s\n", opt('if'), get_ip('if') // 'NOT FOUND'; } } if (opt('fw')) { if (opt('fw') !~ m%/%) { - foreach my $fw (sort keys %builtinfw) { + for my $fw (sort keys %builtinfw) { local $opt{'use'} = $fw; printf "use=%s address is %s\n", $fw, get_ip($fw) // 'NOT FOUND'; } } local $opt{'use'} = 'fw'; - printf "use=fw, fw=%s address is %s\n", opt('fw'), get_ip(opt('fw')) // 'NOT FOUND' + printf "use=fw, fw=%s address is %s\n", opt('fw'), get_ip('fw') // 'NOT FOUND' if !exists $builtinfw{opt('fw')}; } { local $opt{'use'} = 'web'; - foreach my $web (sort keys %builtinweb) { + for my $web (sort keys %builtinweb) { local $opt{'web'} = $web; printf "use=web, web=%s address is %s\n", $web, get_ip('web') // 'NOT FOUND'; } @@ -2088,10 +2191,12 @@ sub test_possible_ip { # Now force IPv4 printf "----- Test_possible_ip with 'get_ipv4' ------\n"; - printf "use=ipv4, ipv4=%s address is %s\n", opt('ipv4'), get_ipv4('ipv4') // 'NOT FOUND' - if defined opt('ipv4'); - + if (defined(opt('ipv4'))) { + local $opt{'usev4'} = 'ipv4'; + printf "usev4=ipv4, ipv4=%s address is %s\n", opt('ipv4'), get_ipv4('ipv4') // 'NOT FOUND'; + } { + local $opt{'usev4'} = 'ifv4'; # Note: The `ip` command adds a `@eth0` suffix to the names of VLAN # interfaces. That `@eth0` suffix is NOT part of the interface name. my @ifs = map({ /^[^\s:]*:\s*([^\s:@]+)/ ? $1 : () } @@ -2100,32 +2205,34 @@ sub test_possible_ip { `command -v ifconfig >/dev/null && ifconfig -a`) if $? || !@ifs; @ifs = () if $?; warning("failed to get list of interfaces") if !@ifs; - foreach my $if (@ifs) { + for my $if (@ifs) { local $opt{'ifv4'} = $if; - printf "use=ifv4, ifv4=%s address is %s\n", opt('ifv4'), get_ipv4('ifv4') // 'NOT FOUND'; + printf "usev4=ifv4, ifv4=%s address is %s\n", opt('ifv4'), get_ipv4('ifv4') // 'NOT FOUND'; } } { local $opt{'usev4'} = 'webv4'; - foreach my $web (sort keys %builtinweb) { + for my $web (sort keys %builtinweb) { local $opt{'webv4'} = $web; - printf "use=webv4, webv4=$web address is %s\n", get_ipv4('webv4') // 'NOT FOUND' + printf "usev4=webv4, webv4=$web address is %s\n", get_ipv4('webv4') // 'NOT FOUND' if ($web !~ "6") ## Don't bother if web site only supports IPv6; } - printf "use=webv4, webv4=%s address is %s\n", opt('webv4'), get_ipv4('webv4') // 'NOT FOUND' + printf "usev4=webv4, webv4=%s address is %s\n", opt('webv4'), get_ipv4('webv4') // 'NOT FOUND' if ! exists $builtinweb{opt('webv4')}; } if (opt('cmdv4')) { local $opt{'usev4'} = 'cmdv4'; - printf "use=cmdv4, cmdv4=%s address is %s\n", opt('cmdv4'), get_ipv4('cmdv4') // 'NOT FOUND'; + printf "usev4=cmdv4, cmdv4=%s address is %s\n", opt('cmdv4'), get_ipv4('cmdv4') // 'NOT FOUND'; } # Now force IPv6 printf "----- Test_possible_ip with 'get_ipv6' -----\n"; - printf "use=ipv6, ipv6=%s address is %s\n", opt('ipv6'), get_ipv6('ipv6') // 'NOT FOUND' - if defined opt('ipv6'); - + if (defined(opt('ipv6'))) { + local $opt{'usev6'} = 'ipv6'; + printf "usev6=ipv6, ipv6=%s address is %s\n", opt('ipv6'), get_ipv6('ipv6') // 'NOT FOUND'; + } { + local $opt{'usev6'} = 'ifv6'; # Note: The `ip` command adds a `@eth0` suffix to the names of VLAN # interfaces. That `@eth0` suffix is NOT part of the interface name. my @ifs = map({ /^[^\s:]*:\s*([^\s:@]+)/ ? $1 : () } @@ -2134,24 +2241,24 @@ sub test_possible_ip { `command -v ifconfig >/dev/null && ifconfig -a`) if $? || !@ifs; @ifs = () if $?; warning("failed to get list of interfaces") if !@ifs; - foreach my $if (@ifs) { + for my $if (@ifs) { local $opt{'ifv6'} = $if; - printf "use=ifv6, ifv6=%s address is %s\n", opt('ifv6'), get_ipv6('ifv6') // 'NOT FOUND'; + printf "usev6=ifv6, ifv6=%s address is %s\n", opt('ifv6'), get_ipv6('ifv6') // 'NOT FOUND'; } } { local $opt{'usev6'} = 'webv6'; - foreach my $web (sort keys %builtinweb) { + for my $web (sort keys %builtinweb) { local $opt{'webv6'} = $web; - printf "use=webv6, webv6=$web address is %s\n", get_ipv6('webv6') // 'NOT FOUND' + printf "usev6=webv6, webv6=$web address is %s\n", get_ipv6('webv6') // 'NOT FOUND' if ($web !~ "4"); ## Don't bother if web site only supports IPv4 } - printf "use=webv6, webv6=%s address is %s\n", opt('webv6'), get_ipv6('webv6') // 'NOT FOUND' + printf "usev6=webv6, webv6=%s address is %s\n", opt('webv6'), get_ipv6('webv6') // 'NOT FOUND' if ! exists $builtinweb{opt('webv6')}; } if (opt('cmdv6')) { local $opt{'usev6'} = 'cmdv6'; - printf "use=cmdv6, cmdv6=%s address is %s\n", opt('cmdv6'), get_ipv6('cmdv6') // 'NOT FOUND'; + printf "usev6=cmdv6, cmdv6=%s address is %s\n", opt('cmdv6'), get_ipv6('cmdv6') // 'NOT FOUND'; } exit 0 unless opt('debug'); @@ -2224,7 +2331,7 @@ sub save_file { ## print_opt ## print_globals ## print_config -## print_cache +## print_recap ## print_info ###################################################################### sub _print_hash { @@ -2234,7 +2341,7 @@ sub _print_hash { if (!defined($ptr)) { $value = ""; } elsif (ref $ptr eq 'HASH') { - foreach my $key (sort keys %$ptr) { + for my $key (sort keys %$ptr) { if (($key eq "login") || ($key eq "password")) { $value = ""; } else { @@ -2254,12 +2361,12 @@ sub print_hash { sub print_opt { print_hash("opt", \%opt); } sub print_globals { print_hash("globals", \%globals); } sub print_config { print_hash("config", \%config); } -sub print_cache { print_hash("cache", \%cache); } +sub print_recap { print_hash("recap", \%recap); } sub print_info { print_opt(); print_globals(); print_config(); - print_cache(); + print_recap(); } ###################################################################### ## pipecmd - run an external command @@ -2339,10 +2446,12 @@ sub split_by_comma { } sub default { my $v = shift; + return undef if !defined($variables{'merged'}{$v}); return $variables{'merged'}{$v}{'default'}; } sub minimum { my $v = shift; + return undef if !defined($variables{'merged'}{$v}); return $variables{'merged'}{$v}{'minimum'}; } sub opt { @@ -2353,14 +2462,14 @@ sub opt { } sub min { my $min = shift; - foreach my $arg (@_) { + for my $arg (@_) { $min = $arg if $arg < $min; } return $min; } sub max { my $max = shift; - foreach my $arg (@_) { + for my $arg (@_) { $max = $arg if $arg > $max; } return $max; @@ -2373,10 +2482,10 @@ sub ynu { return $no if !($value // ''); return $yes if $value eq '1'; - foreach (qw(yes true)) { + for (qw(yes true)) { return $yes if $_ =~ /^$value/i; } - foreach (qw(no false)) { + for (qw(no false)) { return $no if $_ =~ /^$value/i; } return $undef; @@ -2465,6 +2574,8 @@ sub interval { $value = $1 * 60*60; } elsif ($value =~ /^(\d+)(days|d)/i) { $value = $1 * 60*60*24; + } elsif ($value =~ qr/^(?:inf(?:init[ye])?|indefinite(?:ly)?|never|forever|always)$/i) { + $value = 'inf'; } elsif ($value !~ /^\d+$/) { $value = undef; } @@ -2473,11 +2584,12 @@ sub interval { sub interval_expired { my ($host, $time, $interval) = @_; - return 1 if !exists $cache{$host}; - return 1 if !exists $cache{$host}{$time} || !$cache{$host}{$time}; + return 0 if ($config{$host}{$interval} // 0) == 'inf'; + return 1 if !exists $recap{$host}; + return 1 if !exists $recap{$host}{$time} || !$recap{$host}{$time}; return 1 if !exists $config{$host}{$interval} || !$config{$host}{$interval}; - return $now > ($cache{$host}{$time} + $config{$host}{$interval}); + return $now > ($recap{$host}{$time} + $config{$host}{$interval}); } @@ -2493,6 +2605,10 @@ sub check_value { if (!defined $value && !$required) { ; + } elsif (!defined($value) && $required) { + # None of the types have 'undef' as a valid value, so check definedness once here for + # convenience. + return undef; } elsif ($type eq T_DELAY) { $value = interval($value); @@ -2520,7 +2636,7 @@ sub check_value { } elsif ($type eq T_PROTO) { $value = lc $value; - return undef if !exists $services{$value}; + return undef if !exists $protocols{$value}; } elsif ($type eq T_USE) { $value = lc $value; @@ -2528,11 +2644,11 @@ sub check_value { } elsif ($type eq T_USEV4) { $value = lc $value; - return undef if ! exists $ipv4_strategies{$value}; + return undef if !exists $ipv4_strategies{$value}; } elsif ($type eq T_USEV6) { $value = lc $value; - return undef if ! exists $ipv6_strategies{$value}; + return undef if !exists $ipv6_strategies{$value}; } elsif ($type eq T_FILE) { return undef if $value eq ""; @@ -2582,33 +2698,22 @@ sub encode_base64 ($;$) { ## load_sha1_support ###################################################################### sub load_sha1_support { - my $why = shift; - my $sha1_loaded = eval { require Digest::SHA1 }; - my $sha_loaded = eval { require Digest::SHA }; - unless ($sha1_loaded || $sha_loaded) { - fatal("%s", <<"EOM"); -Error loading the Perl module Digest::SHA1 or Digest::SHA needed for $why update. -On Debian, the package libdigest-sha1-perl or libdigest-sha-perl must be installed. + my ($protocol) = @_; + eval { require Digest::SHA; } or fatal(<<"EOM"); +Error loading the Perl module Digest::SHA needed for $protocol update. +On Debian, the package libdigest-sha-perl must be installed. EOM - } - if ($sha1_loaded) { - import Digest::SHA1 (qw/sha1_hex/); - } elsif ($sha_loaded) { - import Digest::SHA (qw/sha1_hex/); - } + Digest::SHA->import(qw/sha1_hex/); } + ###################################################################### ## load_json_support ###################################################################### sub load_json_support { - my $why = shift; - my $json_loaded = eval { require JSON::PP }; - unless ($json_loaded) { - fatal("%s", <<"EOM"); -Error loading the Perl module JSON::PP needed for $why update. -EOM - } - import JSON::PP (qw/decode_json encode_json/); + my ($protocol) = @_; + eval { require JSON::PP; } + or fatal("Error loading the Perl module JSON::PP needed for $protocol update."); + JSON::PP->import(qw/decode_json encode_json/); } ###################################################################### @@ -2618,7 +2723,7 @@ sub curl_cmd { my @params = @_; my $tmpfile; my $tfh; - my $system_curl = quotemeta(subst_var('@CURL@', 'curl')); + my $curl = join(' ', @curl); my %curl_codes = ( ## Subset of error codes from https://curl.haxx.se/docs/manpage.html 2 => "Failed to initialize. (Most likely a bug in ddclient, please open issue at https://github.com/ddclient/ddclient)", 3 => "URL malformed. The syntax was not correct", @@ -2636,11 +2741,11 @@ sub curl_cmd { 67 => "The user name, password, or similar was not accepted and curl failed to log in.", 77 => "Problem with reading the SSL CA cert (path? access rights?).", 78 => "The resource referenced in the URL does not exist.", - 127 => "$system_curl was not found", + 127 => "$curl was not found", ); - debug("CURL: %s", $system_curl); - fatal("curl not found") if ($system_curl eq ''); + debug("CURL: %s", $curl); + fatal("curl not found") if ($curl[0] eq ''); return '' if (scalar(@params) == 0); ## no parameters provided # Hard code to /tmp rather than use system TMPDIR to protect from malicious @@ -2656,9 +2761,20 @@ sub curl_cmd { print($tfh @params); } close($tfh); - my $reply = qx{ $system_curl --config $tmpfile 2>/dev/null; }; + # Use open's list form (as opposed to qx, backticks, or the scalar form of open) to avoid the + # shell and reduce the risk of a shell injection vulnerability. ':raw' mode is used because + # HTTP is defined in terms of octets (bytes), not characters. In raw mode, each byte from curl + # is mapped to a same-valued codepoint (byte value 0x78 becomes character U+0078, 0xff becomes + # U+00ff). The caller is responsible for decoding the byte sequence if necessary. + open(my $cfh, '-|:raw', @curl, '--config', $tmpfile) + or fatal("failed to run curl ($curl): $!"); + # According to , adding ':raw' to the open + # mode is buggy with Perl < v5.14. Call binmode on the filehandle just in case. + binmode($cfh) or fatal("binmode failed: $!"); + my $reply = do { local $/; <$cfh>; }; + close($cfh); # Closing $cfh waits for the process to exit and sets $?. if ((my $rc = $?>>8) != 0) { - warning("CURL error (%d) %s", $rc, $curl_codes{$rc} // "Unknown return code. Check $system_curl is installed and its manpage."); + warning("CURL error (%d) %s", $rc, $curl_codes{$rc} // "Unknown return code. Check $curl is installed and its manpage."); } return $reply; } @@ -2749,7 +2865,7 @@ sub geturl { # Each header line is added individually @header_lines = split('\n', $headers); - $_ = "header=\"".escape_curl_param($_).'"' foreach (@header_lines); + $_ = "header=\"".escape_curl_param($_).'"' for (@header_lines); push(@curlopt, @header_lines); # Add in the data if any was provided (for POST/PATCH) @@ -2764,7 +2880,7 @@ sub geturl { # don't include ${url} as that might expose login credentials $0 = sprintf("%s - Curl system cmd sending to %s", $program, "${protocol}://${server}"); verbose("SENDING:", "Curl system cmd to %s", "${protocol}://${server}"); - verbose("SENDING:", "%s", $_) foreach (@curlopt); + verbose("SENDING:", "%s", $_) for (@curlopt); $reply = curl_cmd(@curlopt); verbose("RECEIVE:", "%s", $reply // ""); @@ -2798,41 +2914,35 @@ sub geturl { ###################################################################### sub get_ip { my $use = lc shift; - $use = 'disabled' if ($use eq 'no'); # backward compatibility - my $h = shift; + $use = 'disabled' if ($use eq 'no'); # backward compatibility + my $h = shift; my ($ip, $arg, $reply, $url, $skip) = (undef, opt($use, $h), ''); $arg = '' unless $arg; if ($use eq 'ip') { - $ip = opt('ip', $h); + $ip = opt('ip', $h); if (!is_ipv4($ip) && !is_ipv6($ip)) { warning("'%s' is not a valid IPv4 or IPv6 address", $ip // ''); $ip = undef; } $arg = 'ip'; - } elsif ($use eq 'if') { $ip = get_ip_from_interface($arg); - } elsif ($use eq 'cmd') { if ($arg) { $skip = opt('cmd-skip', $h); $reply = `$arg`; $reply = '' if $?; } - } elsif ($use eq 'web') { $url = opt('web', $h) // ''; $skip = opt('web-skip', $h); - - if (exists $builtinweb{$url}) { - warning("googledomains is deprecated! See https://github.com/ddclient/ddclient/issues/622 for more info.") if ($url eq 'googledomains'); - - $skip //= $builtinweb{$url}->{'skip'}; - $url = $builtinweb{$url}->{'url'}; + if (my $biw = $builtinweb{$url}) { + warning("'--web=$url' is deprecated! $biw->{deprecated}") if $biw->{deprecated}; + $skip //= $biw->{skip}; + $url = $biw->{url}; } $arg = $url; - if ($url) { $reply = geturl( proxy => opt('proxy', $h), @@ -2840,11 +2950,9 @@ sub get_ip { ssl_validate => opt('web-ssl-validate', $h), ) // ''; } - } elsif ($use eq 'disabled') { ## This is a no-op... Do not get an IP address for this host/service $reply = ''; - } elsif ($use eq 'fw' || defined(my $fw = $builtinfw{$use})) { # Note that --use=firewallname uses --fw=arg, not --firewallname=arg. $arg = opt('fw', $h) // ''; @@ -2859,7 +2967,6 @@ sub get_ip { $url = "http://$url$fw->{'url'}" unless $url =~ /\//; } } - if ($url) { $reply = geturl( url => $url, @@ -2869,7 +2976,6 @@ sub get_ip { ssl_validate => opt('fw-ssl-validate', $h), ) // ''; } - } else { warning("ignoring unsupported '--use=$use'"); } @@ -3063,7 +3169,7 @@ sub get_default_interface { debug("Default routes found for IPv%s :\n%s", $ipver, join("\n",@list)); # now check each interface to make sure it is global (not loopback). - foreach my $line (@list) { + for my $line (@list) { ## Interface will be after "dev" or the last word in the line. Must accept blank spaces ## at the end. Interface name may not have any whitespace or forward slash. $line =~ /\bdev\b\s*\K[^\s\/]+|\b[^\s\/]+(?=[\s\/]*$)/; @@ -3197,7 +3303,6 @@ sub get_ip_from_interface { sub get_ipv4 { my $usev4 = lc(shift); ## Method to obtain IP address my $h = shift; ## Host/service making the request - my $ipv4 = undef; ## Found IPv4 address my $reply = ''; ## Text returned from various methods my $url = ''; ## URL of website or firewall @@ -3206,17 +3311,15 @@ sub get_ipv4 { if ($usev4 eq 'ipv4') { ## Static IPv4 address is provided in "ipv4=
" - $ipv4 = $arg; + $ipv4 = $arg; if (!is_ipv4($ipv4)) { - warning("'%s' is not a valid IPv4",$ipv4 // ''); + warning("'%s' is not a valid IPv4", $ipv4 // ''); $ipv4 = undef; } $arg = 'ipv4'; # For debug message at end of function - } elsif ($usev4 eq 'ifv4') { ## Obtain IPv4 address from interface mamed in "ifv4=" - $ipv4 = get_ip_from_interface($arg,4); - + $ipv4 = get_ip_from_interface($arg, 4); } elsif ($usev4 eq 'cmdv4') { ## Obtain IPv4 address by executing the command in "cmdv4=" warning("'--cmd-skip' ignored for '--usev4=$usev4'") if (opt('verbose') && opt('cmd-skip', $h)); @@ -3225,30 +3328,27 @@ sub get_ipv4 { $reply = qx{$sys_cmd}; $reply = '' if $?; } - } elsif ($usev4 eq 'webv4') { ## Obtain IPv4 address by accessing website at url in "webv4=" $url = $arg; $skip = opt('webv4-skip', $h); - if (exists $builtinweb{$url}) { - warning("googledomains is deprecated! See https://github.com/ddclient/ddclient/issues/622 for more info.") if ($url eq 'googledomains'); - - $skip //= $builtinweb{$url}->{'skip'}; - $url = $builtinweb{$url}->{'url'}; + if (my $biw = $builtinweb{$url}) { + warning("'--webv4=$url' is deprecated! $biw->{deprecated}") if $biw->{deprecated}; + $skip //= $biw->{skip}; + $url = $biw->{url}; $arg = $url; } if ($url) { - $reply = geturl( proxy => opt('proxy', $h), + $reply = geturl( + proxy => opt('proxy', $h), url => $url, ipversion => 4, # when using a URL to find IPv4 address we should force use of IPv4 ssl_validate => opt('web-ssl-validate', $h), ) // ''; } - } elsif ($usev4 eq 'disabled') { ## This is a no-op... Do not get an IPv4 address for this host/service $reply = ''; - } elsif ($usev4 eq 'fwv4' || defined(my $fw = $builtinfw{$usev4})) { warning("'--fw' is deprecated for '--usev4=$usev4'; use '--fwv4' instead") if (!defined(opt('fwv4', $h)) && defined(opt('fw', $h))); @@ -3277,7 +3377,6 @@ sub get_ipv4 { ssl_validate => opt('fw-ssl-validate', $h), ) // ''; } - } else { warning("ignoring unsupported '--usev4=$usev4'"); } @@ -3303,7 +3402,6 @@ sub get_ipv6 { my $usev6 = lc(shift); ## Method to obtain IP address $usev6 = 'disabled' if ($usev6 eq 'no'); # backward compatibility my $h = shift; ## Host/service making the request - my $ipv6 = undef; ## Found IPv6 address my $reply = ''; ## Text returned from various methods my $url = ''; ## URL of website or firewall @@ -3318,19 +3416,17 @@ sub get_ipv6 { } $ipv6 = $arg; if (!is_ipv6($ipv6)) { - warning("'%s' is not a valid IPv6",$ipv6 // ''); + warning("'%s' is not a valid IPv6", $ipv6 // ''); $ipv6 = undef; } $arg = 'ipv6'; # For debug message at end of function - - } elsif ($usev6 eq 'ifv6' || $usev6 eq 'if' ) { + } elsif ($usev6 eq 'ifv6' || $usev6 eq 'if') { ## Obtain IPv6 address from interface mamed in "ifv6=" if ($usev6 eq 'if') { warning("'--usev6=if' is deprecated. Use '--usev6=ifv6'"); $arg = opt('ifv6', $h) // $arg; } - $ipv6 = get_ip_from_interface($arg,6); - + $ipv6 = get_ip_from_interface($arg, 6); } elsif ($usev6 eq 'cmdv6' || $usev6 eq 'cmd') { ## Obtain IPv6 address by executing the command in "cmdv6=" if ($usev6 eq 'cmd') { @@ -3343,7 +3439,6 @@ sub get_ipv6 { $reply = qx{$sys_cmd}; $reply = '' if $?; } - } elsif ($usev6 eq 'webv6' || $usev6 eq 'web') { ## Obtain IPv6 address by accessing website at url in "webv6=" if ($usev6 eq 'web') { @@ -3354,11 +3449,10 @@ sub get_ipv6 { if (!defined(opt('webv6-skip', $h)) && defined(opt('web-skip', $h))); $url = $arg; $skip = opt('webv6-skip', $h); - if (exists $builtinweb{$url}) { - warning("googledomains is deprecated! See https://github.com/ddclient/ddclient/issues/622 for more info.") if ($url eq 'googledomains'); - - $skip //= $builtinweb{$url}->{'skip'}; - $url = $builtinweb{$url}->{'url'}; + if (my $biw = $builtinweb{$url}) { + warning("'--webv6=$url' is deprecated! $biw->{deprecated}") if $biw->{deprecated}; + $skip //= $biw->{skip}; + $url = $biw->{url}; $arg = $url; } if ($url) { @@ -3369,10 +3463,8 @@ sub get_ipv6 { ssl_validate => opt('web-ssl-validate', $h), ) // ''; } - } elsif ($usev6 eq 'disabled') { $reply = ''; - } elsif ($usev6 eq 'fwv6' || defined(my $fw = $builtinfw{$usev6})) { $skip = opt('fwv6-skip', $h) // $fw->{'skip'}; if ($fw && defined(my $query = $fw->{'queryv6'})) { @@ -3381,10 +3473,8 @@ sub get_ipv6 { } else { warning("'--usev6=%s' is not implemented and does nothing", $usev6); } - } else { warning("ignoring unsupported '--usev6=$usev6'"); - } ## Set to loopback address if no text set yet @@ -3405,16 +3495,19 @@ sub get_ipv6 { ## group_hosts_by ###################################################################### sub group_hosts_by { -##TODO - Update for wantipv4 and wantipv6 - my ($hosts, $attributes) = @_; - my %attrs = (map({ ($_ => 1) } @$attributes), 'wantip' => 1); - my @attrs = sort(keys(%attrs)); - my %groups = (); - foreach my $h (@$hosts) { - my $sig = join(',', map({ sprintf("%s=%s", $_, $config{$h}{$_} // '') } @attrs)); - push @{$groups{$sig}}, $h; + my ($hosts, @attrs) = @_; + my %attrs = map({ ($_ => undef); } @attrs); + @attrs = sort(keys(%attrs)); + my %groups; + my %cfgs; + my $d = Data::Dumper->new([])->Indent(0)->Sortkeys(1)->Terse(1)->Useqq(1); + for my $h (@$hosts) { + my %cfg = map({ ($_ => $config{$h}{$_}); } grep(exists($config{$h}{$_}), @attrs)); + my $sig = $d->Reset()->Values([\%cfg])->Dump(); + push(@{$groups{$sig}}, $h); + $cfgs{$sig} = \%cfg; } - return %groups; + return map({ {cfg => $cfgs{$_}, hosts => $groups{$_}}; } keys(%groups)); } ###################################################################### @@ -3426,7 +3519,7 @@ sub encode_www_form_urlencoded { my $must_encode = qr'[<>"#%{}|\\^~\[\]`;/?:=&+]'; my $encoded; my $i = 0; - foreach my $k (keys %$formdata) { + for my $k (keys %$formdata) { my $kenc = $k; my $venc = $formdata->{$k}; @@ -3452,8 +3545,8 @@ sub encode_www_form_urlencoded { sub nic_examples { my $examples = ""; my $separator = ""; - foreach my $s (sort keys %services) { - my $subr = $services{$s}{'examples'}; + for my $p (sort keys %protocols) { + my $subr = $protocols{$p}{'examples'}; my $example; if (defined($subr) && ($example = &$subr())) { @@ -3548,56 +3641,54 @@ sub nic_updateable { info("forcing update of %s.", $host); $update = 1; - } elsif (!exists($cache{$host})) { - info("forcing updating %s because no cached entry exists.", $host); + } elsif (!exists($recap{$host})) { + info("forcing updating %s because no recap entry exists in cache file.", $host); $update = 1; - } elsif ($cache{$host}{'wtime'} && $cache{$host}{'wtime'} > $now) { + } elsif ($recap{$host}{'wtime'} && $recap{$host}{'wtime'} > $now) { warning("cannot update %s from %s to %s until after %s.", $host, - ($cache{$host}{'ip'} ? $cache{$host}{'ip'} : ''), $ip, - prettytime($cache{$host}{'wtime'}) + ($recap{$host}{'ip'} ? $recap{$host}{'ip'} : ''), $ip, + prettytime($recap{$host}{'wtime'}) ); - } elsif ($cache{$host}{'mtime'} && interval_expired($host, 'mtime', 'max-interval')) { + } elsif ($recap{$host}{'mtime'} && interval_expired($host, 'mtime', 'max-interval')) { warning("forcing update of %s from %s to %s; %s since last update on %s.", $host, - ($cache{$host}{'ip'} ? $cache{$host}{'ip'} : ''), $ip, + ($recap{$host}{'ip'} ? $recap{$host}{'ip'} : ''), $ip, prettyinterval($config{$host}{'max-interval'}), - prettytime($cache{$host}{'mtime'}) + prettytime($recap{$host}{'mtime'}) ); $update = 1; - } elsif ( ($use ne 'disabled') - && ((!exists($cache{$host}{'ip'})) || ("$cache{$host}{'ip'}" ne "$ip"))) { + } elsif ($use ne 'disabled' && ($recap{$host}{'ip'} // '') ne $ip) { ## Check whether to update IP address for the "--use" method" - if ((($cache{$host}{'status'} // '') eq 'good') && + if (($recap{$host}{'status'} // '') eq 'good' && !interval_expired($host, 'mtime', 'min-interval')) { warning("skipping update of %s from %s to %s.\nlast updated %s.\nWait at least %s between update attempts.", $host, - ($cache{$host}{'ip'} ? $cache{$host}{'ip'} : ''), + ($recap{$host}{'ip'} ? $recap{$host}{'ip'} : ''), $ip, - ($cache{$host}{'mtime'} ? prettytime($cache{$host}{'mtime'}) : ''), + ($recap{$host}{'mtime'} ? prettytime($recap{$host}{'mtime'}) : ''), prettyinterval($config{$host}{'min-interval'}) ) - if opt('verbose') || !($cache{$host}{'warned-min-interval'} // 0); + if opt('verbose') || !($recap{$host}{'warned-min-interval'} // 0); - $cache{$host}{'warned-min-interval'} = $now; + $recap{$host}{'warned-min-interval'} = $now; - } elsif ((($cache{$host}{'status'} // '') ne 'good') && - !interval_expired($host, 'atime', 'min-error-interval')) { + } elsif (($recap{$host}{'status'} // '') ne 'good' && + !interval_expired($host, 'atime', 'min-error-interval')) { - if ( opt('verbose') - || ( ! $cache{$host}{'warned-min-error-interval'} - && (($warned_ip{$host} // 0) < $inv_ip_warn_count)) ) { + if (opt('verbose') || (!$recap{$host}{'warned-min-error-interval'} && + ($warned_ip{$host} // 0) < $inv_ip_warn_count)) { warning("skipping update of %s from %s to %s.\nlast updated %s but last attempt on %s failed.\nWait at least %s between update attempts.", $host, - ($cache{$host}{'ip'} ? $cache{$host}{'ip'} : ''), + ($recap{$host}{'ip'} ? $recap{$host}{'ip'} : ''), $ip, - ($cache{$host}{'mtime'} ? prettytime($cache{$host}{'mtime'}) : ''), - ($cache{$host}{'atime'} ? prettytime($cache{$host}{'atime'}) : ''), + ($recap{$host}{'mtime'} ? prettytime($recap{$host}{'mtime'}) : ''), + ($recap{$host}{'atime'} ? prettytime($recap{$host}{'atime'}) : ''), prettyinterval($config{$host}{'min-error-interval'}) ); if (!$ip && !opt('verbose')) { @@ -3607,42 +3698,40 @@ sub nic_updateable { } } - $cache{$host}{'warned-min-error-interval'} = $now; + $recap{$host}{'warned-min-error-interval'} = $now; } else { $update = 1; } - } elsif ( ($usev4 ne 'disabled') - && ((!exists($cache{$host}{'ipv4'})) || ("$cache{$host}{'ipv4'}" ne "$ipv4"))) { + } elsif ($usev4 ne 'disabled' && ($recap{$host}{'ipv4'} // '') ne $ipv4) { ## Check whether to update IPv4 address for the "--usev4" method" - if ((($cache{$host}{'status-ipv4'} // '') eq 'good') && + if (($recap{$host}{'status-ipv4'} // '') eq 'good' && !interval_expired($host, 'mtime', 'min-interval')) { warning("skipping update of %s from %s to %s.\nlast updated %s.\nWait at least %s between update attempts.", $host, - ($cache{$host}{'ipv4'} ? $cache{$host}{'ipv4'} : ''), + ($recap{$host}{'ipv4'} ? $recap{$host}{'ipv4'} : ''), $ipv4, - ($cache{$host}{'mtime'} ? prettytime($cache{$host}{'mtime'}) : ''), + ($recap{$host}{'mtime'} ? prettytime($recap{$host}{'mtime'}) : ''), prettyinterval($config{$host}{'min-interval'}) ) - if opt('verbose') || !($cache{$host}{'warned-min-interval'} // 0); + if opt('verbose') || !($recap{$host}{'warned-min-interval'} // 0); - $cache{$host}{'warned-min-interval'} = $now; + $recap{$host}{'warned-min-interval'} = $now; - } elsif ((($cache{$host}{'status-ipv4'} // '') ne 'good') && - !interval_expired($host, 'atime', 'min-error-interval')) { + } elsif (($recap{$host}{'status-ipv4'} // '') ne 'good' && + !interval_expired($host, 'atime', 'min-error-interval')) { - if ( opt('verbose') - || ( ! $cache{$host}{'warned-min-error-interval'} - && (($warned_ipv4{$host} // 0) < $inv_ip_warn_count)) ) { + if (opt('verbose') || (!$recap{$host}{'warned-min-error-interval'} && + ($warned_ipv4{$host} // 0) < $inv_ip_warn_count)) { warning("skipping update of %s from %s to %s.\nlast updated %s but last attempt on %s failed.\nWait at least %s between update attempts.", $host, - ($cache{$host}{'ipv4'} ? $cache{$host}{'ipv4'} : ''), + ($recap{$host}{'ipv4'} ? $recap{$host}{'ipv4'} : ''), $ipv4, - ($cache{$host}{'mtime'} ? prettytime($cache{$host}{'mtime'}) : ''), - ($cache{$host}{'atime'} ? prettytime($cache{$host}{'atime'}) : ''), + ($recap{$host}{'mtime'} ? prettytime($recap{$host}{'mtime'}) : ''), + ($recap{$host}{'atime'} ? prettytime($recap{$host}{'atime'}) : ''), prettyinterval($config{$host}{'min-error-interval'}) ); if (!$ipv4 && !opt('verbose')) { @@ -3652,42 +3741,40 @@ sub nic_updateable { } } - $cache{$host}{'warned-min-error-interval'} = $now; + $recap{$host}{'warned-min-error-interval'} = $now; } else { $update = 1; } - } elsif ( ($usev6 ne 'disabled') - && ((!exists($cache{$host}{'ipv6'})) || ("$cache{$host}{'ipv6'}" ne "$ipv6"))) { + } elsif ($usev6 ne 'disabled' && ($recap{$host}{'ipv6'} // '') ne $ipv6) { ## Check whether to update IPv6 address for the "--usev6" method" - if ((($cache{$host}{'status-ipv6'} // '') eq 'good') && + if (($recap{$host}{'status-ipv6'} // '') eq 'good' && !interval_expired($host, 'mtime', 'min-interval')) { warning("skipping update of %s from %s to %s.\nlast updated %s.\nWait at least %s between update attempts.", $host, - ($cache{$host}{'ipv6'} ? $cache{$host}{'ipv6'} : ''), + ($recap{$host}{'ipv6'} ? $recap{$host}{'ipv6'} : ''), $ipv6, - ($cache{$host}{'mtime'} ? prettytime($cache{$host}{'mtime'}) : ''), + ($recap{$host}{'mtime'} ? prettytime($recap{$host}{'mtime'}) : ''), prettyinterval($config{$host}{'min-interval'}) ) - if opt('verbose') || !($cache{$host}{'warned-min-interval'} // 0); + if opt('verbose') || !($recap{$host}{'warned-min-interval'} // 0); - $cache{$host}{'warned-min-interval'} = $now; + $recap{$host}{'warned-min-interval'} = $now; - } elsif ((($cache{$host}{'status-ipv6'} // '') ne 'good') && - !interval_expired($host, 'atime', 'min-error-interval')) { + } elsif (($recap{$host}{'status-ipv6'} // '') ne 'good' && + !interval_expired($host, 'atime', 'min-error-interval')) { - if ( opt('verbose') - || ( ! $cache{$host}{'warned-min-error-interval'} - && (($warned_ipv6{$host} // 0) < $inv_ip_warn_count)) ) { + if (opt('verbose') || (!$recap{$host}{'warned-min-error-interval'} && + ($warned_ipv6{$host} // 0) < $inv_ip_warn_count)) { warning("skipping update of %s from %s to %s.\nlast updated %s but last attempt on %s failed.\nWait at least %s between update attempts.", $host, - ($cache{$host}{'ipv6'} ? $cache{$host}{'ipv6'} : ''), + ($recap{$host}{'ipv6'} ? $recap{$host}{'ipv6'} : ''), $ipv6, - ($cache{$host}{'mtime'} ? prettytime($cache{$host}{'mtime'}) : ''), - ($cache{$host}{'atime'} ? prettytime($cache{$host}{'atime'}) : ''), + ($recap{$host}{'mtime'} ? prettytime($recap{$host}{'mtime'}) : ''), + ($recap{$host}{'atime'} ? prettytime($recap{$host}{'atime'}) : ''), prettyinterval($config{$host}{'min-error-interval'}) ); if (!$ipv6 && !opt('verbose')) { @@ -3697,7 +3784,7 @@ sub nic_updateable { } } - $cache{$host}{'warned-min-error-interval'} = $now; + $recap{$host}{'warned-min-error-interval'} = $now; } else { $update = 1; @@ -3705,14 +3792,14 @@ sub nic_updateable { } elsif (defined($sub) && &$sub($host)) { $update = 1; - } elsif ((defined($cache{$host}{'static'}) && defined($config{$host}{'static'}) && - ($cache{$host}{'static'} ne $config{$host}{'static'})) || - (defined($cache{$host}{'wildcard'}) && defined($config{$host}{'wildcard'}) && - ($cache{$host}{'wildcard'} ne $config{$host}{'wildcard'})) || - (defined($cache{$host}{'mx'}) && defined($config{$host}{'mx'}) && - ($cache{$host}{'mx'} ne $config{$host}{'mx'})) || - (defined($cache{$host}{'backupmx'}) && defined($config{$host}{'backupmx'}) && - ($cache{$host}{'backupmx'} ne $config{$host}{'backupmx'}))) { + } elsif ((defined($recap{$host}{'static'}) && defined($config{$host}{'static'}) && + ($recap{$host}{'static'} ne $config{$host}{'static'})) || + (defined($recap{$host}{'wildcard'}) && defined($config{$host}{'wildcard'}) && + ($recap{$host}{'wildcard'} ne $config{$host}{'wildcard'})) || + (defined($recap{$host}{'mx'}) && defined($config{$host}{'mx'}) && + ($recap{$host}{'mx'} ne $config{$host}{'mx'})) || + (defined($recap{$host}{'backupmx'}) && defined($config{$host}{'backupmx'}) && + ($recap{$host}{'backupmx'} ne $config{$host}{'backupmx'}))) { info("updating %s because host settings have been changed.", $host); $update = 1; @@ -3730,9 +3817,9 @@ sub nic_updateable { } } - $config{$host}{'status'} = $cache{$host}{'status'}; - $config{$host}{'status-ipv4'} = $cache{$host}{'status-ipv4'}; - $config{$host}{'status-ipv6'} = $cache{$host}{'status-ipv6'}; + $config{$host}{'status'} = $recap{$host}{'status'}; + $config{$host}{'status-ipv4'} = $recap{$host}{'status-ipv4'}; + $config{$host}{'status-ipv6'} = $recap{$host}{'status-ipv6'}; $config{$host}{'update'} = $update; if ($update) { $config{$host}{'status'} = undef; @@ -3743,8 +3830,8 @@ sub nic_updateable { $config{$host}{'warned-min-interval'} = 0; $config{$host}{'warned-min-error-interval'} = 0; - delete $cache{$host}{'warned-min-interval'}; - delete $cache{$host}{'warned-min-error-interval'}; + delete $recap{$host}{'warned-min-interval'}; + delete $recap{$host}{'warned-min-error-interval'}; } return $update; @@ -3755,30 +3842,30 @@ sub nic_updateable { ###################################################################### sub header_ok { my ($host, $line) = @_; - my $ok = 0; - - if ($line =~ m%^s*HTTP/.*\s+(\d+)%i) { - my $result = $1; - - if ($result =~ m/^2\d\d$/) { - $ok = 1; - - } elsif ($result eq '401') { - failed("updating %s: authentication failed (%s)", $host, $line); - } elsif ($result eq '403') { - failed("updating %s: not authorized (%s)", $host, $line); - } - - } else { - failed("updating %s: unexpected line (%s)", $host, $line); + if (!$line) { + failed("updating %s: no response from server", $host); + return 0; } - return $ok; + $line =~ s/\r?\n.*//s; + my ($code, $msg) = ($line =~ qr%^\s*HTTP/.*\s+(\d+)\s*(?:\s+([^\s].*))?$%i); + if (!defined($code)) { + failed('updating %s: unexpected HTTP response: %s', $host, $line); + return 0; + } elsif ($code !~ qr/^2\d\d$/) { + my %msgs = ( + '401' => 'authentication failed', + '403' => 'not authorized', + ); + failed('updating %s: %s %s', $host, $code, $msg // $msgs{$code} // ''); + return 0; + } + return 1; } ###################################################################### ## DDNS providers # A DDNS provider consists of an example function, the update -# function, and an optional updateable function. +# function, and an optional force_update function. # # The example function simply returns a string for the help message, # explaining how to configure the provider @@ -3786,7 +3873,7 @@ sub header_ok { # The update function performs the actual record update. # It receives an array of hosts as its argument. # -# The updateable function allows a provider implementation to force +# The force_update function allows a provider implementation to force # an update even if ddclient has itself determined no update is # necessary. The function shall return 1 if an update should be # performed, else 0. @@ -3836,7 +3923,7 @@ EoEXAMPLE sub nic_dyndns1_update { debug("\nnic_dyndns1_update -------------------"); ## update each configured host - foreach my $h (@_) { + for my $h (@_) { my $ip = delete $config{$h}{'wantip'}; info("setting IP address to %s for %s", $ip, $h); verbose("UPDATE:", "updating %s", $h); @@ -3867,7 +3954,7 @@ sub nic_dyndns1_update { my @reply = split /\n/, $reply; my ($title, $return_code, $error_code) = ('', '', ''); - foreach my $line (@reply) { + for my $line (@reply) { $title = $1 if $line =~ m%\s*(.*)\s*%i; $return_code = $1 if $line =~ m%^return\s+code\s*:\s*(.*)\s*$%i; $error_code = $1 if $line =~ m%^error\s+code\s*:\s*(.*)\s*$%i; @@ -3881,29 +3968,29 @@ sub nic_dyndns1_update { failed("updating %s: %s", $h, $title); } else { - $config{$h}{'ip'} = $ip; - $config{$h}{'mtime'} = $now; + $config{$h}{'ip'} = $ip; + $config{$h}{'mtime'} = $now; $config{$h}{'status'} = 'good'; success("updating %s: %s: IP address set to %s (%s)", $h, $return_code, $ip, $title); } } } ###################################################################### -## nic_dyndns2_updateable +## nic_dyndns2_force_update ###################################################################### -sub nic_dyndns2_updateable { +sub nic_dyndns2_force_update { my $host = shift; my $update = 0; - if ($config{$host}{'mx'} ne $cache{$host}{'mx'}) { + if ($config{$host}{'mx'} ne $recap{$host}{'mx'}) { info("forcing updating %s because 'mx' has changed to %s.", $host, $config{$host}{'mx'}); $update = 1; - } elsif ($config{$host}{'mx'} && (ynu($config{$host}{'backupmx'}, 1, 2, 3) ne ynu($config{$host}{'backupmx'}, 1, 2, 3))) { + } elsif ($config{$host}{'mx'} && (ynu($config{$host}{'backupmx'}, 1, 2, 3) ne ynu($recap{$host}{'backupmx'}, 1, 2, 3))) { info("forcing updating %s because 'backupmx' has changed to %s.", $host, ynu($config{$host}{'backupmx'}, "YES", "NO", "NO")); $update = 1; - } elsif ($config{$host}{'static'} ne $cache{$host}{'static'}) { + } elsif ($config{$host}{'static'} ne $recap{$host}{'static'}) { info("forcing updating %s because 'static' has changed to %s.", $host, ynu($config{$host}{'static'}, "YES", "NO", "NO")); $update = 1; @@ -3957,62 +4044,59 @@ Example ${program}.conf file entries: my-toplevel-domain.com,my-other-domain.com EoEXAMPLE } + ###################################################################### ## nic_dyndns2_update ###################################################################### sub nic_dyndns2_update { debug("\nnic_dyndns2_update -------------------"); - - ## group hosts with identical attributes together - my %groups = group_hosts_by([ @_ ], [ qw(login password server static custom wildcard mx backupmx) ]); - my %errors = ( - 'badauth' => 'Bad authorization (username or password)', - 'badsys' => 'The system parameter given was not valid', - - 'notfqdn' => 'A Fully-Qualified Domain Name was not provided', - 'nohost' => 'The hostname specified does not exist in the database', - '!yours' => 'The hostname specified exists, but not under the username currently being used', + 'badauth' => 'Bad authorization (username or password)', + 'badsys' => 'The system parameter given was not valid', + 'notfqdn' => 'A Fully-Qualified Domain Name was not provided', + 'nohost' => 'The hostname specified does not exist in the database', + '!yours' => 'The hostname specified exists, but not under the username currently being used', '!donator' => 'The offline setting was set, when the user is not a donator', - '!active' => 'The hostname specified is in a Custom DNS domain which has not yet been activated.', - 'abuse', => 'The hostname specified is blocked for abuse; you should receive an email notification ' . - 'which provides an unblock request link. More info can be found on ' . - 'https://www.dyndns.com/support/abuse.html', - - 'numhost' => 'System error: Too many or too few hosts found. Contact support@dyndns.org', - 'dnserr' => 'System error: DNS error encountered. Contact support@dyndns.org', - - 'nochg' => 'No update required; unnecessary attempts to change to the current address are considered abusive', + '!active' => 'The hostname specified is in a Custom DNS domain which has not yet been activated.', + 'abuse', => 'The hostname specified is blocked for abuse; you should receive an email notification which provides an unblock request link. More info can be found on https://www.dyndns.com/support/abuse.html', + 'numhost' => 'System error: Too many or too few hosts found. Contact support@dyndns.org', + 'dnserr' => 'System error: DNS error encountered. Contact support@dyndns.org', + 'nochg' => 'No update required; unnecessary attempts to change to the current address are considered abusive', ); - - ## update each set of hosts that had similar configurations - foreach my $sig (keys %groups) { - my @hosts = @{$groups{$sig}}; + my @group_by_attrs = qw( + backupmx + custom + login + mx + password + script + server + static + wantipv4 + wantipv6 + wildcard + ); + for my $group (group_hosts_by(\@_, @group_by_attrs)) { + my @hosts = @{$group->{hosts}}; + my %groupcfg = %{$group->{cfg}}; my $hosts = join(',', @hosts); - my $h = $hosts[0]; - my $ipv4 = $config{$h}{'wantipv4'}; - my $ipv6 = $config{$h}{'wantipv6'}; - delete $config{$_}{'wantipv4'} foreach @hosts; - delete $config{$_}{'wantipv6'} foreach @hosts; - + my $ipv4 = $groupcfg{'wantipv4'}; + my $ipv6 = $groupcfg{'wantipv6'}; + delete $config{$_}{'wantipv4'} for @hosts; + delete $config{$_}{'wantipv6'} for @hosts; info("setting IPv4 address to %s for %s", $ipv4, $hosts) if $ipv4; info("setting IPv6 address to %s for %s", $ipv6, $hosts) if $ipv6; verbose("UPDATE:", "updating %s", $hosts); - - ## Select the DynDNS system to update - my $url = "http://$config{$h}{'server'}$config{$h}{'script'}?system="; - if ($config{$h}{'custom'}) { + my $url = "$groupcfg{'server'}$groupcfg{'script'}?system="; + if ($groupcfg{'custom'}) { warning("updating %s: 'custom' and 'static' may not be used together. ('static' ignored)", $hosts) - if $config{$h}{'static'}; + if $groupcfg{'static'}; $url .= 'custom'; - - } elsif ($config{$h}{'static'}) { + } elsif ($groupcfg{'static'}) { $url .= 'statdns'; - } else { $url .= 'dyndns'; } - $url .= "&hostname=$hosts"; $url .= "&myip="; $url .= $ipv4 if $ipv4; @@ -4020,96 +4104,68 @@ sub nic_dyndns2_update { $url .= "," if $ipv4; $url .= $ipv6; } - ## some args are not valid for a custom domain. - $url .= "&wildcard=ON" if ynu($config{$h}{'wildcard'}, 1, 0, 0); - if ($config{$h}{'mx'}) { - $url .= "&mx=$config{$h}{'mx'}"; - $url .= "&backmx=" . ynu($config{$h}{'backupmx'}, 'YES', 'NO'); + $url .= "&wildcard=ON" if ynu($groupcfg{'wildcard'}, 1, 0, 0); + if ($groupcfg{'mx'}) { + $url .= "&mx=$groupcfg{'mx'}"; + $url .= "&backmx=" . ynu($groupcfg{'backupmx'}, 'YES', 'NO'); } - my $reply = geturl( proxy => opt('proxy'), url => $url, - login => $config{$h}{'login'}, - password => $config{$h}{'password'}, + login => $groupcfg{'login'}, + password => $groupcfg{'password'}, ) // ''; if ($reply eq '') { - failed("updating %s: Could not connect to %s.", $hosts, $config{$h}{'server'}); + failed("updating %s: Could not connect to %s.", $hosts, $groupcfg{'server'}); next; } next if !header_ok($hosts, $reply); - - my @reply = split /\n/, $reply; - my $state = 'header'; - - foreach my $line (@reply) { - if ($state eq 'header') { - $state = 'body'; - - } elsif ($state eq 'body') { - $state = 'results' if $line eq ''; - - } elsif ($state =~ /^results/) { - $state = 'results2'; - - # bug #10: some dyndns providers does not return the IP so - # we can't use the returned IP - my ($status, $returnedips) = split / /, lc $line; - - foreach my $h (@hosts) { - $config{$h}{'status-ipv4'} = $status if $ipv4; - $config{$h}{'status-ipv6'} = $status if $ipv6; - } - - if ($status eq 'good') { - foreach my $h (@hosts) { - $config{$h}{'ipv4'} = $ipv4 if $ipv4; - $config{$h}{'ipv6'} = $ipv6 if $ipv6; - $config{$h}{'mtime'} = $now; - } - - success("updating %s: %s: IPv4 address set to %s", $hosts, $status, $ipv4) if $ipv4; - success("updating %s: %s: IPv6 address set to %s", $hosts, $status, $ipv6) if $ipv6; - - } elsif (exists $errors{$status}) { - if ($status eq 'nochg') { - warning("updating %s: %s: %s", $hosts, $status, $errors{$status}); - - foreach my $h (@hosts) { - $config{$h}{'ipv4'} = $ipv4 if $ipv4; - $config{$h}{'ipv6'} = $ipv6 if $ipv6; - $config{$h}{'mtime'} = $now; - $config{$h}{'status-ipv4'} = 'good' if $ipv4; - $config{$h}{'status-ipv6'} = 'good' if $ipv6; - } - - } else { - failed("updating %s: %s: %s", $hosts, $status, $errors{$status}); - } - - } elsif ($status =~ /w(\d+)(.)/) { - my ($wait, $units) = ($1, lc $2); - my ($sec, $scale) = ($wait, 1); - - ($scale, $units) = (1, 'seconds') if $units eq 's'; - ($scale, $units) = (60, 'minutes') if $units eq 'm'; - ($scale, $units) = (60*60, 'hours') if $units eq 'h'; - - $sec = $wait * $scale; - foreach my $h (@hosts) { - $config{$h}{'wtime'} = $now + $sec; - } - - warning("updating %s: %s: wait %s %s before further updates", $hosts, $status, $wait, $units); - - } else { - failed("updating %s: unexpected status (%s)", $hosts, $line); - } - } + # Some services can return 200 OK even if there is an error (e.g., bad authentication, + # updates too frequent) so the body of the response must also be checked. + (my $body = $reply) =~ s/^.*?\n\n//s; + my ($line) = grep(qr/^results/, split(qr/\n/, $body)); + if (!$line) { + failed("updating %s: Could not connect to %s.", $hosts, $groupcfg{'server'}); + next; } - failed("updating %s: Could not connect to %s.", $hosts, $config{$h}{'server'}) - if $state ne 'results2'; + # The IP address normally comes after the status, but we ignore it. (Some services do not + # return the IP so we can't rely on it anyway.) + (my $status = $line) =~ s/ .*$//; + if ($status eq 'nochg') { + warning("updating %s: %s: %s", $hosts, $status, $errors{$status}); + $status = 'good'; + } + for my $h (@hosts) { + $config{$h}{'status-ipv4'} = $status if $ipv4; + $config{$h}{'status-ipv6'} = $status if $ipv6; + } + if ($status ne 'good') { + if (exists($errors{$status})) { + failed("updating %s: %s: %s", $hosts, $status, $errors{$status}); + } elsif ($status =~ qr/w(\d+)(.)/) { + my ($wait, $units) = ($1, lc $2); + my ($sec, $scale) = ($wait, 1); + ($scale, $units) = (1, 'seconds') if $units eq 's'; + ($scale, $units) = (60, 'minutes') if $units eq 'm'; + ($scale, $units) = (60*60, 'hours') if $units eq 'h'; + $sec = $wait * $scale; + for my $h (@hosts) { + $config{$h}{'wtime'} = $now + $sec; + } + warning("updating %s: %s: wait %s %s before further updates", $hosts, $status, $wait, $units); + } else { + failed("updating %s: unexpected status (%s)", $hosts, $line); + } + next; + } + for my $h (@hosts) { + $config{$h}{'ipv4'} = $ipv4 if $ipv4; + $config{$h}{'ipv6'} = $ipv6 if $ipv6; + $config{$h}{'mtime'} = $now; + } + success("updating %s: %s: IPv4 address set to %s", $hosts, $status, $ipv4) if $ipv4; + success("updating %s: %s: IPv6 address set to %s", $hosts, $status, $ipv6) if $ipv6; } } @@ -4122,6 +4178,8 @@ o 'dnsexit2' The 'dnsexit2' protocol is the updated protocol for the (free) dynamic hostname services of 'DNSExit' (www.dnsexit.com). Their API is accepting JSON payload. +Note that we only update the record, it must already exist in the DNSExit system +(A and/or AAAA records where applicable). Configuration variables applicable to the 'dnsexit2' protocol are: protocol=dnsexit2 ## @@ -4155,189 +4213,146 @@ EoEXAMPLE ###################################################################### sub nic_dnsexit2_update { debug("\nnic_dnsexit2_update -------------------"); - - ## Update each configured host (hosts cannot be grouped on this API) - foreach my $h (@_) { - # All the known status - my %status = ( - '0' => [ 'good', 'Success! Actions got executed successfully.' ], - '1' => [ 'warning', 'Some execution problems. May not indicate actions failures. Some action may got executed fine and some may have problems.' ], - '2' => [ 'badauth', 'API Key Authentication Error. The API Key is missing or wrong.' ], - '3' => [ 'error', 'Missing Required Definitions. Your JSON file may missing some required definitions.' ], - '4' => [ 'error', 'JSON Data Syntax Error. Your JSON file has syntax error.' ], - '5' => [ 'error', 'JSON Defined Record Type not Supported. Your JSON may try to update some record type not supported by our system.' ], - '6' => [ 'error', 'System Error. Our system problem. May not be your problem. Contact our support if you got such error.' ], - '7' => [ 'error', 'Error getting post data. Our server has problem to receive your JSON posting.' ], - ); - my $ipv4 = delete $config{$h}{'wantipv4'}; - my $ipv6 = delete $config{$h}{'wantipv6'}; - - # Updates for ipv4 and ipv6 need to be combined in a single API call, create Hash of Arrays for tracking - my %total_payload; - - foreach my $ip ($ipv4, $ipv6){ - next if (!$ip); - my $ipv = ($ip eq ($ipv6 // '')) ? '6' : '4'; - my $type = ($ip eq ($ipv6 // '')) ? 'AAAA' : 'A'; - - info("Going to update IPv$ipv address to %s for %s.", $ip, $h); - $config{$h}{'status-ipv$ipv'} = 'failed'; - $total_payload{$ipv} = { - name => $h, - type => $type, - content => $ip, - ttl => $config{$h}{'ttl'}, - }; - }; - # Set the URL of the API endpoint - my $url = "https://$config{$h}{'server'}$config{$h}{'path'}"; - - # Set additional headers - my $header = "Content-Type: application/json\nAccept: application/json"; - - # Set the zone if empty - if ( not defined $config{$h}{'zone'}){ - debug("Zone not defined, setting to default hostname: %s", $h); - $config{$h}{'zone'} = $h - } else { - debug("Zone is: %s", $config{$h}{'zone'}); - } - - # Build total JSON payload - my @payload_values = values %total_payload; - my $data = encode_json({ - apikey => $config{$h}{'password'}, - domain => $config{$h}{'zone'}, - update => \@payload_values - }); - - # Make the call - my $reply = geturl( - proxy => opt('proxy'), - url => $url, - headers => $header, - method => 'POST', - data => $data - ); - - # No reply, declare as failed - unless ($reply && header_ok($h, $reply)){ - failed("updating %s: Could not connect to %s%s.", $h, $config{$h}{'server'}, $config{$h}{'path'}); - last; - }; - - # Reply found - debug("%s", $reply); - # Extract the HTTP response code - (my $http_status) = ($reply =~ m%^s*HTTP/.*\s+(\d+)%i); - debug("HTTP response code: %s", $http_status); - - # If not 200, bail - if ( $http_status ne '200' ){ - failed("Failed to update Host\n%s", $h); - failed("HTTP response code\n%s", $http_status); - failed("Full reply\n%s", $reply) unless opt('verbose'); - next; - } - - # Strip HTTP response headers - (my $strip_status) = ($reply =~ s/^[\s\S]*?(?=\{"code":)//); - debug("strip_status"); - debug("%s", $strip_status); - if ($strip_status) { - debug("HTTP headers are stripped."); - } - else { - warning("Unexpected: no HTTP headers stripped!"); - } - - # Decode the remaining reply, it should be JSON. - my $response = decode_json($reply); - - # It should at least have a 'code' and 'message'. - if (defined($response->{'code'}) and defined($response->{'message'})) { - if (exists $status{$response->{'code'}}) { - # Add the server response data to the applicable array - push( @{ $status {$response->{'code'} } }, $response->{'message'}); - if (defined($response->{'details'})) { - push ( @{ $status {$response->{'code'} } }, $response->{'details'}[0]); - } else { - # Keep it symmetrical for simplicity - push ( @{ $status {$response->{'code'} } }, "no details received"); - } - - # Set data from array - my ($status, $message, $srv_message, $srv_details) = @{ $status {$response->{'code'} } }; - info("Status: %s -- Message: %s", $status, $message); - info("Server Message: %s -- Server Details: %s", $srv_message, $srv_details); - $config{$h}{'status-ipv4'} = $status if $ipv4; - $config{$h}{'status-ipv6'} = $status if $ipv6; - - # Handle statuses - if ($status eq 'good') { - $config{$h}{'mtime'} = $now; - my $tracked_ipv; - foreach $tracked_ipv ( keys %total_payload ){ - $config{$h}{"ipv$tracked_ipv"} = $total_payload{$tracked_ipv}{content}; - $config{$h}{"status-ipv$tracked_ipv"} = 'good'; - success("%s", $message); - success("Updated %s successfully to IPv$tracked_ipv address %s at time %s", $h, $total_payload{$tracked_ipv}{content}, prettytime($config{$h}{'mtime'})); - } - } elsif ($status eq 'warning') { - warning("%s", $message); - warning("Server response: %s", $srv_message); - } elsif ($status =~ m'^(badauth|error)$') { - failed("%s", $message); - failed("Server response: %s", $srv_message); - } else { - failed("This should not be possible"); - } - } else { - failed("Status code %s is unknown!", $response->{'code'}); - } - } else { - failed("Did not receive expected \"code\" and \"message\" keys in server response."); - failed("Response:"); - failed("%s", $response); - } + # The DNSExit API does not support updating hosts with different zones at the same time, + # handling update per host. + for my $h (@_) { + $config{$h}{'zone'} //= $h; + dnsexit2_update_host($h); } } + +sub dnsexit2_update_host { + my ($h) = @_; + my $name = $h; + # Remove the zone suffix from $name. If the zone eq $name, $name can be left alone or + # set to the empty string; both have identical semantics. For consistency, always + # remove the zone even if it means $name becomes the empty string. + if ($name =~ s/(?:^|\.)\Q$config{$h}{'zone'}\E$//) { + # The zone was successfully trimmed from $name. + } else { + fatal("Hostname %s does not end with the zone %s", $h, $config{$h}{'zone'}); + } + # The IPv4 and IPv6 addresses must be updated together in a single API call. + my %ips; + my @updates; + for my $ipv ('4', '6') { + my $ip = delete($config{$h}{"wantipv$ipv"}) or next; + $ips{$ipv} = $ip; + info("Going to update IPv%s address to %s for %s.", $ipv, $ip, $h); + $config{$h}{"status-ipv$ipv"} = 'failed'; + push(@updates, { + name => $name, + type => ($ipv eq '6') ? 'AAAA' : 'A', + content => $ip, + ttl => $config{$h}{'ttl'}, + }); + }; + my $url = $config{$h}{'server'} . $config{$h}{'path'}; + my $reply = geturl( + proxy => opt('proxy'), + url => $url, + headers => "Content-Type: application/json\nAccept: application/json", + method => 'POST', + data => encode_json({ + apikey => $config{$h}{'password'}, + domain => $config{$h}{'zone'}, + update => \@updates, + }), + ); + unless ($reply && header_ok($h, $reply)) { + failed("updating %s: Could not connect to %s", $h, $url); + return; + }; + debug("%s", $reply); + (my $http_status) = ($reply =~ m%^s*HTTP/.*\s+(\d+)%i); + debug("HTTP response code: %s", $http_status); + if ($http_status ne '200') { + failed("Failed to update Host\n%s", $h); + failed("HTTP response code\n%s", $http_status); + failed("Full reply\n%s", $reply) unless opt('verbose'); + return; + } + my $body = ($reply =~ s/^.*?\r?\n\r?\n//sr); + my $response = eval { decode_json($body); }; + if (!$response) { + failed("failed to parse response: $@"); + return; + } + if (!defined($response->{'code'}) || !defined($response->{'message'})) { + failed("Did not receive expected 'code' and 'message' keys in server response:\n%s", + $body); + return; + } + my %codemeaning = ( + '0' => ['good', 'Success! Actions got executed successfully.'], + '1' => ['warning', 'Some execution problems. May not indicate actions failures. Some action may got executed fine and some may have problems.'], + '2' => ['badauth', 'API Key Authentication Error. The API Key is missing or wrong.'], + '3' => ['error', 'Missing Required Definitions. Your JSON file may missing some required definitions.'], + '4' => ['error', 'JSON Data Syntax Error. Your JSON file has syntax error.'], + '5' => ['error', 'JSON Defined Record Type not Supported. Your JSON may try to update some record type not supported by our system.'], + '6' => ['error', 'System Error. Our system problem. May not be your problem. Contact our support if you got such error.'], + '7' => ['error', 'Error getting post data. Our server has problem to receive your JSON posting.'], + ); + if (!exists($codemeaning{$response->{'code'}})) { + failed("Status code %s is unknown!", $response->{'code'}); + return; + } + my ($status, $message) = @{$codemeaning{$response->{'code'}}}; + info("Status: %s -- Message: %s", $status, $message); + info("Server Message: %s -- Server Details: %s", $response->{'message'}, + defined($response->{'details'}) ? $response->{'details'}[0] : "no details received"); + if ($status ne 'good') { + if ($status eq 'warning') { + warning("%s", $message); + warning("Server response: %s", $response->{'message'}); + } elsif ($status =~ m'^(badauth|error)$') { + failed("%s", $message); + failed("Server response: %s", $response->{'message'}); + } else { + failed("Unexpected status: %s", $status); + } + return; + } + success("%s", $message); + $config{$h}{'mtime'} = $now; + keys(%ips); # Reset internal iterator. + while (my ($ipv, $ip) = each(%ips)) { + $config{$h}{"ipv$ipv"} = $ip; + $config{$h}{"status-ipv$ipv"} = 'good'; + success("Updated %s successfully to IPv%s address %s at time %s", $h, $ipv, $ip, prettytime($config{$h}{'mtime'})); + } +} + ###################################################################### ## nic_noip_update ## Note: uses same features as nic_dyndns2_update, less return codes ###################################################################### sub nic_noip_update { debug("\nnic_noip_update -------------------"); - - ## group hosts with identical attributes together - my %groups = group_hosts_by([ @_ ], [ qw(login password server static custom wildcard mx backupmx) ]); - my %errors = ( - 'badauth' => 'Invalid username or password', + 'badauth' => 'Invalid username or password', 'badagent' => 'Invalid user agent', - 'nohost' => 'The hostname specified does not exist in the database', + 'nohost' => 'The hostname specified does not exist in the database', '!donator' => 'The offline setting was set, when the user is not a donator', - 'abuse', => 'The hostname specified is blocked for abuse; open a trouble ticket at https://www.no-ip.com', - 'numhost' => 'System error: Too many or too few hosts found. open a trouble ticket at https://www.no-ip.com', - 'dnserr' => 'System error: DNS error encountered. Contact support@dyndns.org', - 'nochg' => 'No update required; unnecessary attempts to change to the current address are considered abusive', + 'abuse', => 'The hostname specified is blocked for abuse; open a trouble ticket at https://www.no-ip.com', + 'numhost' => 'System error: Too many or too few hosts found. open a trouble ticket at https://www.no-ip.com', + 'dnserr' => 'System error: DNS error encountered. Contact support@dyndns.org', + 'nochg' => 'No update required; unnecessary attempts to change to the current address are considered abusive', ); - - ## update each set of hosts that had similar configurations - foreach my $sig (keys %groups) { - my @hosts = @{$groups{$sig}}; + for my $group (group_hosts_by(\@_, qw(login password server wantipv4 wantipv6))) { + my @hosts = @{$group->{hosts}}; + my %groupcfg = %{$group->{cfg}}; my $hosts = join(',', @hosts); - my $h = $hosts[0]; - my $ipv4 = $config{$h}{'wantipv4'}; - my $ipv6 = $config{$h}{'wantipv6'}; - delete $config{$_}{'wantipv4'} foreach @hosts; - delete $config{$_}{'wantipv6'} foreach @hosts; + my $ipv4 = $groupcfg{'wantipv4'}; + my $ipv6 = $groupcfg{'wantipv6'}; + delete $config{$_}{'wantipv4'} for @hosts; + delete $config{$_}{'wantipv6'} for @hosts; info("setting IPv4 address to %s for %s", $ipv4, $hosts) if $ipv4; info("setting IPv6 address to %s for %s", $ipv6, $hosts) if $ipv6; verbose("UPDATE:", "updating %s", $hosts); - my $url = "https://$config{$h}{'server'}/nic/update?system=noip&hostname=$hosts&myip="; + my $url = "https://$groupcfg{'server'}/nic/update?system=noip&hostname=$hosts&myip="; $url .= $ipv4 if $ipv4; if ($ipv6) { $url .= "," if $ipv4; @@ -4347,18 +4362,18 @@ sub nic_noip_update { my $reply = geturl( proxy => opt('proxy'), url => $url, - login => $config{$h}{'login'}, - password => $config{$h}{'password'}, + login => $groupcfg{'login'}, + password => $groupcfg{'password'}, ) // ''; if ($reply eq '') { - failed("updating %s: Could not connect to %s.", $hosts, $config{$h}{'server'}); + failed("updating %s: Could not connect to %s.", $hosts, $groupcfg{'server'}); next; } next if !header_ok($hosts, $reply); my @reply = split /\n/, $reply; my $state = 'header'; - foreach my $line (@reply) { + for my $line (@reply) { if ($state eq 'header') { $state = 'body'; @@ -4371,7 +4386,7 @@ sub nic_noip_update { my ($status, $returnedips) = split / /, lc $line; my $h = shift @hosts; - foreach my $ip (split_by_comma($returnedips)) { + for my $ip (split_by_comma($returnedips)) { next if (!$ip); my $ipv = ($ip eq ($ipv6 // '')) ? '6' : '4'; $config{$h}{"status-ipv$ipv"} = $status; @@ -4379,7 +4394,7 @@ sub nic_noip_update { if ($status eq 'good') { $config{$h}{'mtime'} = $now; - foreach my $ip (split_by_comma($returnedips)) { + for my $ip (split_by_comma($returnedips)) { next if (!$ip); my $ipv = ($ip eq ($ipv6 // '')) ? '6' : '4'; $config{$h}{"ipv$ipv"} = $ip; @@ -4390,7 +4405,7 @@ sub nic_noip_update { if ($status eq 'nochg') { warning("updating %s: %s: %s", $h, $status, $errors{$status}); $config{$h}{'mtime'} = $now; - foreach my $ip (split_by_comma($returnedips)) { + for my $ip (split_by_comma($returnedips)) { next if (!$ip); my $ipv = ($ip eq ($ipv6 // '')) ? '6' : '4'; $config{$h}{"ipv$ipv"} = $ip; @@ -4402,10 +4417,10 @@ sub nic_noip_update { } elsif ($status =~ /w(\d+)(.)/) { my ($wait, $units) = ($1, lc $2); - my ($sec, $scale) = ($wait, 1); + my ($sec, $scale) = ($wait, 1); - ($scale, $units) = (1, 'seconds') if $units eq 's'; - ($scale, $units) = (60, 'minutes') if $units eq 'm'; + ($scale, $units) = (1, 'seconds') if $units eq 's'; + ($scale, $units) = (60, 'minutes') if $units eq 'm'; ($scale, $units) = (60*60, 'hours') if $units eq 'h'; $sec = $wait * $scale; @@ -4417,10 +4432,11 @@ sub nic_noip_update { } } } - failed("updating %s: Could not connect to %s.", $hosts, $config{$h}{'server'}) + failed("updating %s: Could not connect to %s.", $hosts, $groupcfg{'server'}) if $state ne 'results2'; } } + ###################################################################### ## nic_noip_examples ###################################################################### @@ -4483,7 +4499,7 @@ EoEXAMPLE sub nic_dslreports1_update { debug("\nnic_dslreports1_update -------------------"); ## update each configured host - foreach my $h (@_) { + for my $h (@_) { my $ip = delete $config{$h}{'wantip'}; info("setting IP address to %s for %s", $ip, $h); verbose("UPDATE:", "updating %s", $h); @@ -4508,7 +4524,7 @@ sub nic_dslreports1_update { my @reply = split /\n/, $reply; my $return_code = ''; - foreach my $line (@reply) { + for my $line (@reply) { $return_code = $1 if $line =~ m%^return\s+code\s*:\s*(.*)\s*$%i; } @@ -4519,8 +4535,8 @@ sub nic_dslreports1_update { failed("updating %s", $h); } else { - $config{$h}{'ip'} = $ip; - $config{$h}{'mtime'} = $now; + $config{$h}{'ip'} = $ip; + $config{$h}{'mtime'} = $now; $config{$h}{'status'} = 'good'; success("updating %s: %s: IP address set to %s", $h, $return_code, $ip); } @@ -4565,20 +4581,13 @@ sub nic_domeneshop_update { ## update each configured host ## should improve to update in one pass - foreach my $h (@_) { + for my $h (@_) { my $ip = delete $config{$h}{'wantip'}; info("Setting IP address to %s for %s", $ip, $h); verbose("UPDATE:", "Updating %s", $h); - - # Set the URL that we're going to to update - my $url; - $url = $globals{'ssl'} ? "https://" : "http://"; - $url .= "$config{$h}{'server'}$endpointPath?hostname=$h&myip=$ip"; - - # Try to get URL my $reply = geturl( proxy => opt('proxy'), - url => $url, + url => "$config{$h}{'server'}$endpointPath?hostname=$h&myip=$ip", login => $config{$h}{'login'}, password => $config{$h}{'password'}, ); @@ -4595,8 +4604,8 @@ sub nic_domeneshop_update { my $status = shift(@reply); my $message = pop(@reply); if ($status =~ /204/) { - $config{$h}{'ip'} = $ip; - $config{$h}{'mtime'} = $now; + $config{$h}{'ip'} = $ip; + $config{$h}{'mtime'} = $now; $config{$h}{'status'} = 'good'; success("updating %s: good: IP address set to %s", $h, $ip); } else { @@ -4641,9 +4650,9 @@ EoEXAMPLE } ###################################################################### -## nic_zoneedit1_updateable +## nic_zoneedit1_force_update ###################################################################### -sub nic_zoneedit1_updateable { +sub nic_zoneedit1_force_update { return 0; } @@ -4655,41 +4664,39 @@ sub nic_zoneedit1_updateable { ###################################################################### sub nic_zoneedit1_update { debug("\nnic_zoneedit1_update -------------------"); - - ## group hosts with identical attributes together - my %groups = group_hosts_by([ @_ ], [ qw(login password server zone) ]); - - ## update each set of hosts that had similar configurations - foreach my $sig (keys %groups) { - my @hosts = @{$groups{$sig}}; + for my $group (group_hosts_by(\@_, qw(login password server zone wantip))) { + my @hosts = @{$group->{hosts}}; + my %groupcfg = %{$group->{cfg}}; my $hosts = join(',', @hosts); - my $h = $hosts[0]; - my $ip = $config{$h}{'wantip'}; - delete $config{$_}{'wantip'} foreach @hosts; + my $ip = $groupcfg{'wantip'}; + delete $config{$_}{'wantip'} for @hosts; info("setting IP address to %s for %s", $ip, $hosts); verbose("UPDATE:", "updating %s", $hosts); my $url = ''; - $url .= "https://$config{$h}{'server'}/auth/dynamic.html"; + $url .= "https://$groupcfg{'server'}/auth/dynamic.html"; $url .= "?host=$hosts"; - $url .= "&dnsto=$ip" if $ip; - $url .= "&zone=$config{$h}{'zone'}" if defined $config{$h}{'zone'}; + $url .= "&dnsto=$ip" if $ip; + $url .= "&zone=$groupcfg{'zone'}" if defined $groupcfg{'zone'}; my $reply = geturl( proxy => opt('proxy'), url => $url, - login => $config{$h}{'login'}, - password => $config{$h}{'password'}, + login => $groupcfg{'login'}, + password => $groupcfg{'password'}, ) // ''; if ($reply eq '') { - failed("updating %s: Could not connect to %s.", $hosts, $config{$h}{'server'}); + failed("updating %s: Could not connect to %s.", $hosts, $groupcfg{'server'}); next; } next if !header_ok($hosts, $reply); my @reply = split /\n/, $reply; - foreach my $line (@reply) { + # TODO: This is awkward and fragile -- it assumes that each line in the response body + # corresponds with each host in @hosts (and in the same order). + my $h = $hosts[0]; + for my $line (@reply) { if ($h && $line =~ /^[^<]*<(SUCCESS|ERROR)\s+([^>]+)>(.*)/) { my ($status, $assignments, $rest) = ($1, $2, $3); my ($left, %var) = parse_assignments($assignments); @@ -4698,11 +4705,11 @@ sub nic_zoneedit1_update { my ($status_code, $status_text, $status_ip) = ('999', '', $ip); $status_code = $var{'CODE'} if exists $var{'CODE'}; $status_text = $var{'TEXT'} if exists $var{'TEXT'}; - $status_ip = $var{'IP'} if exists $var{'IP'}; + $status_ip = $var{'IP'} if exists $var{'IP'}; if ($status eq 'SUCCESS' || ($status eq 'ERROR' && $var{'CODE'} eq '707')) { - $config{$h}{'ip'} = $status_ip; - $config{$h}{'mtime'} = $now; + $config{$h}{'ip'} = $status_ip; + $config{$h}{'mtime'} = $now; $config{$h}{'status'} = 'good'; success("updating %s: IP address set to %s (%s: %s)", $h, $ip, $status_code, $status_text); @@ -4712,25 +4719,27 @@ sub nic_zoneedit1_update { failed("updating %s: %s: %s", $h, $status_code, $status_text); } shift @hosts; - $h = $hosts[0]; + $h = $hosts[0]; $hosts = join(',', @hosts); } $line = $rest; redo if $line; } } - failed("updating %s: no response from %s", $hosts, $config{$h}{'server'}) + # TODO: Shouldn't this log join(',' @hosts) instead of $hosts? + failed("updating %s: no response from %s", $hosts, $groupcfg{'server'}) if @hosts; } } + ###################################################################### -## nic_easydns_updateable +## nic_easydns_force_update ###################################################################### -sub nic_easydns_updateable { +sub nic_easydns_force_update { my $host = shift; my $update = 0; - if ($config{$host}{'mx'} ne $cache{$host}{'mx'}) { + if ($config{$host}{'mx'} ne $recap{$host}{'mx'}) { info("forcing updating %s because 'mx' has changed to %s.", $host, $config{$host}{'mx'}); $update = 1; @@ -4738,7 +4747,7 @@ sub nic_easydns_updateable { info("forcing updating %s because 'backupmx' has changed to %s.", $host, ynu($config{$host}{'backupmx'}, "YES", "NO", "NO")); $update = 1; - } elsif ($config{$host}{'static'} ne $cache{$host}{'static'}) { + } elsif ($config{$host}{'static'} ne $recap{$host}{'static'}) { info("forcing updating %s because 'static' has changed to %s.", $host, ynu($config{$host}{'static'}, "YES", "NO", "NO")); $update = 1; @@ -4746,6 +4755,7 @@ sub nic_easydns_updateable { } return $update; } + ###################################################################### ## nic_easydns_examples ###################################################################### @@ -4790,43 +4800,33 @@ Example ${program}.conf file entries: my-toplevel-domain.com,my-other-domain.com EoEXAMPLE } + ###################################################################### ## nic_easydns_update ###################################################################### sub nic_easydns_update { debug("\nnic_easydns_update -------------------"); - - ## each host is in a group by itself - my %groups = map { $_ => [ $_ ] } @_; - my %errors = ( - 'NOACCESS' => 'Authentication failed. This happens if the username/password OR host or domain are wrong.', + 'NOACCESS' => 'Authentication failed. This happens if the username/password OR host or domain are wrong.', 'NOSERVICE' => 'Dynamic DNS is not turned on for this domain.', - 'ILLEGAL' => 'Client sent data that is not allowed in a dynamic DNS update.', - 'TOOSOON' => 'Update frequency is too short.', + 'ILLEGAL' => 'Client sent data that is not allowed in a dynamic DNS update.', + 'TOOSOON' => 'Update frequency is too short.', ); + for my $h (@_) { + my $ipv4 = delete $config{$h}{'wantipv4'}; + my $ipv6 = delete $config{$h}{'wantipv6'}; - ## update each set of hosts that had similar configurations - foreach my $sig (keys %groups) { - my @hosts = @{$groups{$sig}}; - my $hosts = join(',', @hosts); - my $h = $hosts[0]; - my $ipv4 = $config{$h}{'wantipv4'}; - my $ipv6 = $config{$h}{'wantipv6'}; - delete $config{$_}{'wantipv4'} foreach @hosts; - delete $config{$_}{'wantipv6'} foreach @hosts; - - info("setting IP address to %s %s for %s", $ipv4, $ipv6, $hosts); - verbose("UPDATE:", "updating %s", $hosts); + info("setting IP address to %s %s for %s", $ipv4, $ipv6, $h); + verbose("UPDATE:", "updating %s", $h); #'https://api.cp.easydns.com/dyn/generic.php?hostname=test.burry.ca&myip=10.20.30.40&wildcard=ON' my $url; - $url = "https://$config{$h}{'server'}$config{$h}{'script'}?"; - $url .= "hostname=$hosts"; + $url = "https://$config{$h}{'server'}$config{$h}{'script'}?"; + $url .= "hostname=$h"; $url .= "&myip="; $url .= $ipv4 if $ipv4; - foreach my $ipv6a ($ipv6) { + for my $ipv6a ($ipv6) { $url .= "&myip="; $url .= $ipv6a } @@ -4844,14 +4844,14 @@ sub nic_easydns_update { password => $config{$h}{'password'}, ) // ''; if ($reply eq '') { - failed("updating %s: Could not connect to %s.", $hosts, $config{$h}{'server'}); + failed("updating %s: Could not connect to %s.", $h, $config{$h}{'server'}); next; } - next if !header_ok($hosts, $reply); + next if !header_ok($h, $reply); my @reply = split /\n/, $reply; my $state = 'header'; - foreach my $line (@reply) { + for my $line (@reply) { if ($state eq 'header') { $state = 'body'; @@ -4862,23 +4862,22 @@ sub nic_easydns_update { $state = 'results2'; my ($status) = $line =~ /^(\S*)\b.*/; - my $h = shift @hosts; $config{$h}{'status-ipv4'} = $status if $ipv4; $config{$h}{'status-ipv6'} = $status if $ipv6; if ($status eq 'NOERROR') { - $config{$h}{'ipv4'} = $ipv4; - $config{$h}{'ipv6'} = $ipv6; + $config{$h}{'ipv4'} = $ipv4; + $config{$h}{'ipv6'} = $ipv6; $config{$h}{'mtime'} = $now; success("updating %s: %s: IP address set to %s %s", $h, $status, $ipv4, $ipv6); } elsif ($status =~ /TOOSOON/) { ## make sure we wait at least a little my ($wait, $units) = (5, 'm'); - my ($sec, $scale) = ($wait, 1); + my ($sec, $scale) = ($wait, 1); - ($scale, $units) = (1, 'seconds') if $units eq 's'; - ($scale, $units) = (60, 'minutes') if $units eq 'm'; + ($scale, $units) = (1, 'seconds') if $units eq 's'; + ($scale, $units) = (60, 'minutes') if $units eq 'm'; ($scale, $units) = (60*60, 'hours') if $units eq 'h'; $config{$h}{'wtime'} = $now + $sec; warning("updating %s: %s: wait %d %s before further updates", $h, $status, $wait, $units); @@ -4892,11 +4891,10 @@ sub nic_easydns_update { last; } } - failed("updating %s: Could not connect to %s.", $hosts, $config{$h}{'server'}) + failed("updating %s: Could not connect to %s.", $h, $config{$h}{'server'}) if $state ne 'results2'; } } -###################################################################### ###################################################################### ## nic_namecheap_examples @@ -4937,12 +4935,10 @@ EoEXAMPLE ## ###################################################################### sub nic_namecheap_update { - - debug("\nnic_namecheap1_update -------------------"); ## update each configured host - foreach my $h (@_) { + for my $h (@_) { my $ip = delete $config{$h}{'wantip'}; info("setting IP address to %s for %s", $ip, $h); verbose("UPDATE:", "updating %s", $h); @@ -4967,8 +4963,8 @@ sub nic_namecheap_update { my @reply = split /\n/, $reply; if (grep /0/i, @reply) { - $config{$h}{'ip'} = $ip; - $config{$h}{'mtime'} = $now; + $config{$h}{'ip'} = $ip; + $config{$h}{'mtime'} = $now; $config{$h}{'status'} = 'good'; success("updating %s: good: IP address set to %s", $h, $ip); } else { @@ -5134,7 +5130,7 @@ sub nic_nfsn_update { debug("\nnic_nfsn_update -------------------"); ## update each configured host - foreach my $h (@_) { + for my $h (@_) { my $zone = $config{$h}{'zone'}; my $name; @@ -5197,8 +5193,8 @@ sub nic_nfsn_update { my $add_resp = nic_nfsn_make_request($h, $add_path, 'POST', $add_body); if (header_ok($h, $add_resp)) { - $config{$h}{'ip'} = $ip; - $config{$h}{'mtime'} = $now; + $config{$h}{'ip'} = $ip; + $config{$h}{'mtime'} = $now; $config{$h}{'status'} = 'good'; success("updating %s: good: IP address set to %s", $h, $ip); } else { @@ -5248,17 +5244,17 @@ EoEXAMPLE sub nic_njalla_update { debug("\nnic_njalla_update -------------------"); - foreach my $h (@_) { + for my $h (@_) { # Read input params my $ipv4 = delete $config{$h}{'wantipv4'}; my $ipv6 = delete $config{$h}{'wantipv6'}; - my $quietreply = delete $config{$h}{'quietreply'}; + my $quietreply = $config{$h}{'quietreply'}; my $ip_output = ''; # Build url my $url = "https://$config{$h}{'server'}/update/?h=$h&k=$config{$h}{'password'}"; my $auto = 1; - foreach my $ip ($ipv4, $ipv6) { + for my $ip ($ipv4, $ipv6) { next if (!$ip); $auto = 0; my $ipv = ($ip eq ($ipv6 // '')) ? '6' : '4'; @@ -5354,12 +5350,10 @@ EoEXAMPLE ## ###################################################################### sub nic_sitelutions_update { - - debug("\nnic_sitelutions_update -------------------"); ## update each configured host - foreach my $h (@_) { + for my $h (@_) { my $ip = delete $config{$h}{'wantip'}; info("setting IP address to %s for %s", $ip, $h); verbose("UPDATE:", "updating %s", $h); @@ -5381,8 +5375,8 @@ sub nic_sitelutions_update { my @reply = split /\n/, $reply; if (grep /success/i, @reply) { - $config{$h}{'ip'} = $ip; - $config{$h}{'mtime'} = $now; + $config{$h}{'ip'} = $ip; + $config{$h}{'mtime'} = $now; $config{$h}{'status'} = 'good'; success("updating %s: good: IP address set to %s", $h, $ip); } else { @@ -5482,7 +5476,7 @@ sub nic_freedns_update { $record_list_error = "failed to get record list from $url_tmpl"; } - foreach my $h (@_) { + for my $h (@_) { next if (!$h); my $ipv4 = delete $config{$h}{'wantipv4'}; my $ipv6 = delete $config{$h}{'wantipv6'}; @@ -5495,7 +5489,7 @@ sub nic_freedns_update { } # IPv4 and IPv6 handling are similar enough to do in a loop... - foreach my $ip ($ipv4, $ipv6) { + for my $ip ($ipv4, $ipv6) { next if (!$ip); my $ipv = ($ip eq ($ipv6 // '')) ? '6' : '4'; my $type = ($ip eq ($ipv6 // '')) ? 'AAAA' : 'A'; @@ -5578,7 +5572,7 @@ EoEXAMPLE ###################################################################### sub nic_1984_update { debug("\nnic_1984_update -------------------"); - foreach my $host (@_) { + for my $host (@_) { my $ip = delete $config{$host}{'wantip'}; info("setting IP address to %s for %s", $ip, $host); verbose("UPDATE:", "updating %s", $host); @@ -5657,12 +5651,10 @@ EoEXAMPLE ## ###################################################################### sub nic_changeip_update { - - debug("\nnic_changeip_update -------------------"); ## update each configured host - foreach my $h (@_) { + for my $h (@_) { my $ip = delete $config{$h}{'wantip'}; info("setting IP address to %s for %s", $ip, $h); verbose("UPDATE:", "updating %s", $h); @@ -5687,8 +5679,8 @@ sub nic_changeip_update { my @reply = split /\n/, $reply; if (grep /success/i, @reply) { - $config{$h}{'ip'} = $ip; - $config{$h}{'mtime'} = $now; + $config{$h}{'ip'} = $ip; + $config{$h}{'mtime'} = $now; $config{$h}{'status'} = 'good'; success("updating %s: good: IP address set to %s", $h, $ip); } else { @@ -5737,107 +5729,98 @@ Example ${program}.conf file entries: host1.example.com,host2.example.com EoEXAMPLE } + ###################################################################### ## nic_godaddy_update ###################################################################### sub nic_godaddy_update { debug("\nnic_godaddy_update --------------------"); + for my $host (@_) { + my $ipv4 = delete $config{$host}{'wantipv4'}; + my $ipv6 = delete $config{$host}{'wantipv6'}; - ## group hosts with identical attributes together - my %groups = group_hosts_by([ @_ ], [ qw(server login password zone) ]); + my $zone = $config{$host}{'zone'}; + (my $hostname = $host) =~ s/\.\Q$zone\E$//; - ## update each set of hosts that had similar configurations - foreach my $sig (keys %groups) { - my @hosts = @{$groups{$sig}}; + for my $ip ($ipv4, $ipv6) { + next if (!$ip); - # Update each set configured host. - for my $host (@hosts) { - my $ipv4 = delete $config{$host}{'wantipv4'}; - my $ipv6 = delete $config{$host}{'wantipv6'}; + info("%s.%s -- Setting IP address to %s.", $hostname, $zone, $ip); + verbose("UPDATE:", "updating %s.%s", $hostname, $zone); - my $zone = $config{$host}{'zone'}; - (my $hostname = $host) =~ s/\.\Q$zone\E$//; + my $ipversion = ($ip eq ($ipv6 // '')) ? '6' : '4'; + my $status = \$config{$host}{"status-ipv$ipversion"}; + my $rrset_type = ($ipversion eq '6') ? 'AAAA' : 'A'; + my $data = encode_json([ { + data => $ip, + defined($config{$host}{'ttl'}) ? (ttl => $config{$host}{'ttl'}) : (), + name => $hostname, + type => $rrset_type, + } ]); - foreach my $ip ($ipv4, $ipv6) { - next if (!$ip); + my $url = "https://$config{$host}{'server'}"; + $url .= "/${zone}/records/${rrset_type}/${hostname}"; - info("%s.%s -- Setting IP address to %s.", $hostname, $zone, $ip); - verbose("UPDATE:", "updating %s.%s", $hostname, $zone); - - my $ipversion = ($ip eq ($ipv6 // '')) ? '6' : '4'; - my $status = \$config{$host}{"status-ipv$ipversion"}; - my $rrset_type = ($ipversion eq '6') ? 'AAAA' : 'A'; - my $data = encode_json([ { - data => $ip, - defined($config{$host}{'ttl'}) ? (ttl => $config{$host}{'ttl'}) : (), - name => $hostname, - type => $rrset_type, - } ]); - - my $url = "https://$config{$host}{'server'}"; - $url .= "/${zone}/records/${rrset_type}/${hostname}"; - - my $header = "Content-Type: application/json\n"; - $header .= "Accept: application/json\n"; - $header .= "Authorization: sso-key $config{$host}{'login'}:$config{$host}{'password'}\n"; - my $reply = geturl( - proxy => opt('proxy'), - url => $url, - headers => $header, - method => 'PUT', - data => $data, - ); - unless ($reply) { - failed("%s.%s -- Could not connect to %s.", $hostname, $zone, $config{$host}{'server'}); - next; - } - - (my $code) = ($reply =~ m%^s*HTTP/.*\s+(\d+)%i); - my $ok = header_ok($host, $reply); - my $msg; - $reply =~ s/^.*?\n\n//s; # extract payload - my $response = eval {decode_json($reply)}; - if (!defined($response) && $code != "200") { - $$status = "bad"; - - failed("%s.%s -- Unexpected or empty service response, cannot parse data.", $hostname, $zone); - } elsif (defined($response->{code})) { - info("%s.%s -- %s - %s.", $hostname, $zone, $response->{code}, $response->{message}); - } - if ($ok) { - # read data - $config{$host}{"ipv$ipversion"} = $ip; - $config{$host}{'mtime'} = $now; - $$status = 'good'; - - success("%s.%s -- Updated successfully to %s (status: %s).", $hostname, $zone, $ip, $code); - next; - } elsif ($code == "400") { - $msg = 'GoDaddy API URL ($url) was malformed.'; - } elsif ($code == "401") { # authentication error - if ($config{$host}{'login'} && $config{$host}{'login'}) { - $msg = 'login or password option incorrect.'; - } else { - $msg = 'login or password option missing.'; - } - $msg .= ' Correct values can be obtained from from https://developer.godaddy.com/keys/.'; - } elsif ($code == "403") { - $msg = 'Customer identified by login and password options denied permission.'; - } elsif ($code == "404") { - $msg = "\"${hostname}.${zone}\" not found at GoDaddy, please check zone option and login/password."; - } elsif ($code == "422") { - $msg = "\"${hostname}.${zone}\" has invalid domain or lacks A/AAAA record."; - } elsif ($code == "429") { - $msg = 'Too many requests to GoDaddy within brief period.'; - } elsif ($code == "503") { - $msg = "\"${hostname}.${zone}\" is unavailable."; - } else { - $msg = 'Unexpected service response.'; - } - - $$status = 'bad'; - failed("%s.%s -- %s", $hostname, $zone, $msg); + my $header = "Content-Type: application/json\n"; + $header .= "Accept: application/json\n"; + $header .= "Authorization: sso-key $config{$host}{'login'}:$config{$host}{'password'}\n"; + my $reply = geturl( + proxy => opt('proxy'), + url => $url, + headers => $header, + method => 'PUT', + data => $data, + ); + unless ($reply) { + failed("%s.%s -- Could not connect to %s.", $hostname, $zone, $config{$host}{'server'}); + next; } + + (my $code) = ($reply =~ m%^s*HTTP/.*\s+(\d+)%i); + my $ok = header_ok($host, $reply); + my $msg; + $reply =~ s/^.*?\n\n//s; # extract payload + my $response = eval {decode_json($reply)}; + if (!defined($response) && $code != "200") { + $$status = "bad"; + + failed("%s.%s -- Unexpected or empty service response, cannot parse data.", $hostname, $zone); + } elsif (defined($response->{code})) { + info("%s.%s -- %s - %s.", $hostname, $zone, $response->{code}, $response->{message}); + } + if ($ok) { + # read data + $config{$host}{"ipv$ipversion"} = $ip; + $config{$host}{'mtime'} = $now; + $$status = 'good'; + + success("%s.%s -- Updated successfully to %s (status: %s).", $hostname, $zone, $ip, $code); + next; + } elsif ($code == "400") { + $msg = 'GoDaddy API URL ($url) was malformed.'; + } elsif ($code == "401") { # authentication error + if ($config{$host}{'login'} && $config{$host}{'login'}) { + $msg = 'login or password option incorrect.'; + } else { + $msg = 'login or password option missing.'; + } + $msg .= ' Correct values can be obtained from from https://developer.godaddy.com/keys/.'; + } elsif ($code == "403") { + $msg = 'Customer identified by login and password options denied permission.'; + } elsif ($code == "404") { + $msg = "\"${hostname}.${zone}\" not found at GoDaddy, please check zone option and login/password."; + } elsif ($code == "422") { + $msg = "\"${hostname}.${zone}\" has invalid domain or lacks A/AAAA record."; + } elsif ($code == "429") { + $msg = 'Too many requests to GoDaddy within brief period.'; + } elsif ($code == "503") { + $msg = "\"${hostname}.${zone}\" is unavailable."; + } else { + $msg = 'Unexpected service response.'; + } + + $$status = 'bad'; + failed("%s.%s -- %s", $hostname, $zone, $msg); } } } @@ -5874,50 +5857,114 @@ Example ${program}.conf file entries: my-toplevel-domain.com,my-other-domain.com EoEXAMPLE } + ###################################################################### ## nic_googledomains_update ###################################################################### sub nic_googledomains_update { debug("\nnic_googledomains_update -------------------"); + for my $host (@_) { + my $ip = delete $config{$host}{'wantip'}; + info("setting IP address to %s for %s", $ip, $host); + verbose("UPDATE:", "updating %s", $host); - ## group hosts with identical attributes together - my %groups = group_hosts_by([ @_ ], [ qw(server login password) ]); + my $url = "https://$config{$host}{'server'}/nic/update"; + $url .= "?hostname=$host"; + $url .= "&myip="; + $url .= $ip if $ip; - ## update each set of hosts that had similar configurations - foreach my $sig (keys %groups) { - my @hosts = @{$groups{$sig}}; - my $key = $hosts[0]; - my $ip = $config{$key}{'wantip'}; + my $reply = geturl( + proxy => opt('proxy'), + url => $url, + login => $config{$host}{'login'}, + password => $config{$host}{'password'}, + ); + unless ($reply) { + failed("updating %s: Could not connect to %s.", $host, $config{$host}{'server'}); + next; + } + next if !header_ok($host, $reply); - # FQDNs - for my $host (@hosts) { - delete $config{$host}{'wantip'}; + $config{$host}{'ip'} = $ip; + $config{$host}{'mtime'} = $now; + $config{$host}{'status'} = 'good'; + } +} - info("setting IP address to %s for %s", $ip, $host); - verbose("UPDATE:", "updating %s", $host); +###################################################################### +## nic_henet_examples +## +## written by Indrajit Raychaudhuri +## +###################################################################### +sub nic_henet_examples { + return <<"EoEXAMPLE"; +o 'he.net' - # Update the DNS record - my $url = "https://$config{$host}{'server'}/nic/update"; - $url .= "?hostname=$host"; - $url .= "&myip="; - $url .= $ip if $ip; +The 'he.net' protocol is used by DNS service offered by dns.he.net. +Configuration variables applicable to the 'he.net' protocol are: + protocol=he.net ## + password=service-password ## the password provided by the admin interface + fully.qualified.host ## the host registered with the service. + +Example ${program}.conf file entries: + ## single host update + protocol=he.net, \\ + password=my-genereated-password \\ + myhost.example.com +EoEXAMPLE +} + +###################################################################### +## nic_henet_update +###################################################################### +sub nic_henet_update { + debug("\nnic_henet_update -------------------"); + + my %errors = ( + 'badauth' => 'Bad authorization (username or password)', + 'badsys' => 'The system parameter given was not valid', + 'nohost' => 'The hostname specified does not exist in the database', + 'abuse' => 'The hostname specified is blocked for abuse', + 'nochg' => 'No update required; unnecessary attempts to change to the current address are considered abusive', + ); + + for my $h (@_) { + # The IPv4 and IPv6 addresses must be updated in separate API call. + for my $ipv ('4', '6') { + my $ip = delete($config{$h}{"wantipv$ipv"}) or next; + info("Setting IPv%s address to %s for %s", $ipv, $ip, $h); + verbose("UPDATE:", "updating %s", $h); my $reply = geturl( proxy => opt('proxy'), - url => $url, - login => $config{$host}{'login'}, - password => $config{$host}{'password'}, - ); - unless ($reply) { - failed("updating %s: Could not connect to %s.", $host, $config{$host}{'server'}); + url => "https://$config{$h}{'server'}/nic/update?hostname=$h&myip=$ip", + login => $h, + password => $config{$h}{'password'}, + ) // ''; + if ($reply eq '') { + failed("updating %s: Could not connect to %s.", $h, $config{$h}{'server'}); next; } - next if !header_ok($host, $reply); - - # Cache - $config{$host}{'ip'} = $ip; - $config{$host}{'mtime'} = $now; - $config{$host}{'status'} = 'good'; + next if !header_ok($h, $reply); + # dyn.dns.he.net can return 200 OK even if there is an error (e.g., bad authentication, + # updates too frequent) so the body of the response must also be checked. + (my $body = $reply) =~ s/^.*?\n\n//s; + my ($line) = split(/\n/, $body, 2); + my ($status, $returnedip) = split(/ /, lc($line)); + $status = 'good' if $status eq 'nochg'; + $config{$h}{"status-ipv$ipv"} = $status; + if ($status ne 'good') { + if (exists($errors{$status})) { + failed("updating %s: %s: %s", $h, $status, $errors{$status}); + } else { + failed("updating %s: unexpected status: %s", $h, $line); + } + next; + } + success("updating %s: %s: IPv%s address set to %s", $h, $status, $ipv, $returnedip); + $config{$h}{"ipv$ipv"} = $returnedip; + $config{$h}{'mtime'} = $now; } } } @@ -5969,10 +6016,10 @@ sub nic_mythicdyn_update { debug("\nnic_mythicdyn_update --------------------"); # Update each configured host. - foreach my $h (@_) { + for my $h (@_) { info("%s -- Setting IP address.", $h); - foreach my $mythver ('4','6') { + for my $mythver ('4','6') { my $ip = $config{$h}{"wantipv$mythver"}; if (defined($ip)) { @@ -5992,7 +6039,7 @@ sub nic_mythicdyn_update { my $ok = header_ok($h, $reply); if ($ok) { - $config{$h}{'mtime'} = $now; + $config{$h}{'mtime'} = $now; $config{$h}{"status-ipv$mythver"} = "good"; success("%s -- IPV%s Updated successfully.", $h, $mythver); @@ -6059,25 +6106,20 @@ EoEXAMPLE ###################################################################### sub nic_nsupdate_update { debug("\nnic_nsupdate_update -------------------"); - - ## group hosts with identical attributes together - my %groups = group_hosts_by([ @_ ], [ qw(login password server zone) ]); - - ## update each set of hosts that had similar configurations - foreach my $sig (keys %groups) { - my @hosts = @{$groups{$sig}}; - my $hosts = join(',', @hosts); - my $h = $hosts[0]; - my $binary = $config{$h}{'login'}; - my $keyfile = $config{$h}{'password'}; - my $server = $config{$h}{'server'}; + for my $group (group_hosts_by(\@_, qw(login password server tcp zone wantipv4 wantipv6))) { + my @hosts = @{$group->{hosts}}; + my %groupcfg = %{$group->{cfg}}; + my $hosts = join(',', @hosts); + my $binary = $groupcfg{'login'}; + my $keyfile = $groupcfg{'password'}; + my $server = $groupcfg{'server'}; ## nsupdate requires a port number to be separated by whitepace, not colon $server =~ s/:/ /; - my $zone = $config{$h}{'zone'}; - my $ipv4 = $config{$h}{'wantipv4'}; - my $ipv6 = $config{$h}{'wantipv6'}; - delete $config{$_}{'wantipv4'} foreach @hosts; - delete $config{$_}{'wantipv6'} foreach @hosts; + my $zone = $groupcfg{'zone'}; + my $ipv4 = $groupcfg{'wantipv4'}; + my $ipv6 = $groupcfg{'wantipv6'}; + delete $config{$_}{'wantipv4'} for @hosts; + delete $config{$_}{'wantipv6'} for @hosts; info("setting IPv4 address to %s for %s", $ipv4, $hosts) if ($ipv4); info("setting IPv6 address to %s for %s", $ipv6, $hosts) if ($ipv6); @@ -6088,10 +6130,10 @@ sub nic_nsupdate_update { server $server zone $zone. EoINSTR1 - foreach (@hosts) { - foreach my $ip ($ipv4, $ipv6) { + for (@hosts) { + for my $ip ($ipv4, $ipv6) { next if (!$ip); - my $type = ($ip eq ($ipv6 // '')) ? 'AAAA' : 'A'; + my $type = ($ip eq ($ipv6 // '')) ? 'AAAA' : 'A'; $instructions .= <<"EoINSTR2"; update delete $_. $type update add $_. $config{$_}{'ttl'} $type $ip @@ -6102,25 +6144,25 @@ EoINSTR2 send EoINSTR4 my $command = "$binary -k $keyfile"; - $command .= " -v" if ynu($config{$h}{'tcp'}, 1, 0, 0); + $command .= " -v" if ynu($groupcfg{'tcp'}, 1, 0, 0); $command .= " -d" if (opt('debug')); verbose("UPDATE:", "nsupdate command is: %s", $command); verbose("UPDATE:", "nsupdate instructions are:\n%s", $instructions); my $status = pipecmd($command, $instructions); if ($status eq 1) { - foreach (@hosts) { + for (@hosts) { $config{$_}{'mtime'} = $now; - foreach my $ip ($ipv4, $ipv6) { + for my $ip ($ipv4, $ipv6) { next if (!$ip); - my $ipv = ($ip eq ($ipv6 // '')) ? '6' : '4'; - $config{$_}{"ipv$ipv"} = $ip; + my $ipv = ($ip eq ($ipv6 // '')) ? '6' : '4'; + $config{$_}{"ipv$ipv"} = $ip; $config{$_}{"status-ipv$ipv"} = 'good'; success("updating %s: good: IPv%s address set to %s", $_, $ipv, $ip); } } } else { - foreach (@hosts) { + for (@hosts) { failed("updating %s", $_); } } @@ -6171,46 +6213,40 @@ Example ${program}.conf file entries: my-toplevel-domain.com,my-other-domain.com EoEXAMPLE } + ###################################################################### ## nic_cloudflare_update ###################################################################### sub nic_cloudflare_update { debug("\nnic_cloudflare_update -------------------"); - - ## group hosts with identical attributes together - my %groups = group_hosts_by([ @_ ], [ qw(ssh login password server wildcard mx backupmx zone) ]); - - ## update each set of hosts that had similar configurations - foreach my $sig (keys %groups) { - my @hosts = @{$groups{$sig}}; + for my $group (group_hosts_by(\@_, qw(login password))) { + my @hosts = @{$group->{hosts}}; + my %groupcfg = %{$group->{cfg}}; my $hosts = join(',', @hosts); - my $key = $hosts[0]; - my $headers = "Content-Type: application/json\n"; - if ($config{$key}{'login'} eq 'token') { - $headers .= "Authorization: Bearer $config{$key}{'password'}\n"; + if ($groupcfg{'login'} eq 'token') { + $headers .= "Authorization: Bearer $groupcfg{'password'}\n"; } else { - $headers .= "X-Auth-Email: $config{$key}{'login'}\n"; - $headers .= "X-Auth-Key: $config{$key}{'password'}\n"; + $headers .= "X-Auth-Email: $groupcfg{'login'}\n"; + $headers .= "X-Auth-Key: $groupcfg{'password'}\n"; } - # FQDNs for my $domain (@hosts) { - my $ipv4 = delete $config{$domain}{'wantipv4'}; - my $ipv6 = delete $config{$domain}{'wantipv6'}; + my $ipv4 = delete $config{$domain}{'wantipv4'}; + my $ipv6 = delete $config{$domain}{'wantipv6'}; info("getting Cloudflare Zone ID for %s", $domain); # Get zone ID - my $url = "https://$config{$key}{'server'}/zones/?"; - $url .= "name=" . $config{$key}{'zone'}; + my $url = "https://$config{$domain}{'server'}/zones/?"; + $url .= "name=" . $config{$domain}{'zone'}; my $reply = geturl(proxy => opt('proxy'), url => $url, headers => $headers ); unless ($reply && header_ok($domain, $reply)) { - failed("updating %s: Could not connect to %s.", $domain, $config{$key}{'server'}); + failed("updating %s: Could not connect to %s.", $domain, $config{$domain}{'server'}); next; } @@ -6223,32 +6259,32 @@ sub nic_cloudflare_update { } # Pull the ID out of the json, messy - my ($zone_id) = map {$_->{name} eq $config{$key}{'zone'} ? $_->{id} : ()} @{$response->{result}}; + my ($zone_id) = map {$_->{name} eq $config{$domain}{'zone'} ? $_->{id} : ()} @{$response->{result}}; unless ($zone_id) { - failed("updating %s: No zone ID found.", $config{$key}{'zone'}); + failed("updating %s: No zone ID found.", $config{$domain}{'zone'}); next; } info("Zone ID is %s", $zone_id); # IPv4 and IPv6 handling are similar enough to do in a loop... - foreach my $ip ($ipv4, $ipv6) { + for my $ip ($ipv4, $ipv6) { next if (!$ip); - my $ipv = ($ip eq ($ipv6 // '')) ? '6' : '4'; + my $ipv = ($ip eq ($ipv6 // '')) ? '6' : '4'; my $type = ($ip eq ($ipv6 // '')) ? 'AAAA' : 'A'; info("updating %s: setting IPv$ipv address to %s", $domain, $ip); $config{$domain}{"status-ipv$ipv"} = 'failed'; # Get DNS 'A' or 'AAAA' record ID - $url = "https://$config{$key}{'server'}/zones/$zone_id/dns_records?"; + $url = "https://$config{$domain}{'server'}/zones/$zone_id/dns_records?"; $url .= "type=$type&name=$domain"; $reply = geturl(proxy => opt('proxy'), url => $url, headers => $headers ); unless ($reply && header_ok($domain, $reply)) { - failed("updating %s: Could not connect to %s.", $domain, $config{$key}{'server'}); + failed("updating %s: Could not connect to %s.", $domain, $config{$domain}{'server'}); next; } # Strip header @@ -6266,7 +6302,7 @@ sub nic_cloudflare_update { } debug("updating %s: DNS '$type' record ID: $dns_rec_id", $domain); # Set domain - $url = "https://$config{$key}{'server'}/zones/$zone_id/dns_records/$dns_rec_id"; + $url = "https://$config{$domain}{'server'}/zones/$zone_id/dns_records/$dns_rec_id"; my $data = "{\"content\":\"$ip\"}"; $reply = geturl(proxy => opt('proxy'), url => $url, @@ -6319,78 +6355,67 @@ Example ${program}.conf file entries: my-toplevel-domain.com,my-other-domain.com EoEXAMPLE } + ###################################################################### ## nic_hetzner_update ###################################################################### sub nic_hetzner_update { debug("\nnic_hetzner_update -------------------"); - - ## group hosts with identical attributes together - my %groups = group_hosts_by([ @_ ], [ qw(ssh login password server wildcard mx backupmx zone) ]); - - ## update each set of hosts that had similar configurations - foreach my $sig (keys %groups) { - my @hosts = @{$groups{$sig}}; - my $hosts = join(',', @hosts); - my $key = $hosts[0]; - - my $headers = "Auth-API-Token: $config{$key}{'password'}\n"; + for my $domain (@_) { + my $headers = "Auth-API-Token: $config{$domain}{'password'}\n"; $headers .= "Content-Type: application/json"; - # FQDNs - for my $domain (@hosts) { - (my $hostname = $domain) =~ s/\.$config{$key}{zone}$//; - my $ipv4 = delete $config{$domain}{'wantipv4'}; - my $ipv6 = delete $config{$domain}{'wantipv6'}; + (my $hostname = $domain) =~ s/\.$config{$domain}{zone}$//; + my $ipv4 = delete $config{$domain}{'wantipv4'}; + my $ipv6 = delete $config{$domain}{'wantipv6'}; - info("getting Hetzner Zone ID for %s", $domain); + info("getting Hetzner Zone ID for %s", $domain); - # Get zone ID - my $url = "https://$config{$key}{'server'}/zones?name=" . $config{$key}{'zone'}; + # Get zone ID + my $url = "https://$config{$domain}{'server'}/zones?name=" . $config{$domain}{'zone'}; - my $reply = geturl(proxy => opt('proxy'), - url => $url, - headers => $headers - ); - unless ($reply && header_ok($domain, $reply)) { - failed("updating %s: Could not connect to %s.", $domain, $config{$key}{'server'}); - next; - } + my $reply = geturl(proxy => opt('proxy'), + url => $url, + headers => $headers + ); + unless ($reply && header_ok($domain, $reply)) { + failed("updating %s: Could not connect to %s.", $domain, $config{$domain}{'server'}); + next; + } - # Strip header - $reply =~ qr/{(?:[^{}]*|(?R))*}/mp; - my $response = eval {decode_json(${^MATCH})}; - unless ($response && $response->{zones}) { - failed("updating %s: invalid json or result.", $domain); - next; - } + # Strip header + $reply =~ qr/{(?:[^{}]*|(?R))*}/mp; + my $response = eval {decode_json(${^MATCH})}; + unless ($response && $response->{zones}) { + failed("updating %s: invalid json or result.", $domain); + next; + } - # Pull the ID out of the json, messy - my ($zone_id) = map {$_->{name} eq $config{$key}{'zone'} ? $_->{id} : ()} @{$response->{zones}}; - unless ($zone_id) { - failed("updating %s: No zone ID found.", $config{$key}{'zone'}); - next; - } - info("Zone ID is %s", $zone_id); + # Pull the ID out of the json, messy + my ($zone_id) = map {$_->{name} eq $config{$domain}{'zone'} ? $_->{id} : ()} @{$response->{zones}}; + unless ($zone_id) { + failed("updating %s: No zone ID found.", $config{$domain}{'zone'}); + next; + } + info("Zone ID is %s", $zone_id); + # IPv4 and IPv6 handling are similar enough to do in a loop... + for my $ip ($ipv4, $ipv6) { + next if (!$ip); + my $ipv = ($ip eq ($ipv6 // '')) ? '6' : '4'; + my $type = ($ip eq ($ipv6 // '')) ? 'AAAA' : 'A'; - # IPv4 and IPv6 handling are similar enough to do in a loop... - foreach my $ip ($ipv4, $ipv6) { - next if (!$ip); - my $ipv = ($ip eq ($ipv6 // '')) ? '6' : '4'; - my $type = ($ip eq ($ipv6 // '')) ? 'AAAA' : 'A'; - - info("updating %s: setting IPv$ipv address to %s", $domain, $ip); - $config{$domain}{"status-ipv$ipv"} = 'failed'; + info("updating %s: setting IPv$ipv address to %s", $domain, $ip); + $config{$domain}{"status-ipv$ipv"} = 'failed'; # Get DNS 'A' or 'AAAA' record ID - $url = "https://$config{$key}{'server'}/records?zone_id=$zone_id"; + $url = "https://$config{$domain}{'server'}/records?zone_id=$zone_id"; $reply = geturl(proxy => opt('proxy'), url => $url, headers => $headers ); unless ($reply && header_ok($domain, $reply)) { - failed("updating %s: Could not connect to %s.", $domain, $config{$key}{'server'}); + failed("updating %s: Could not connect to %s.", $domain, $config{$domain}{'server'}); next; } # Strip header @@ -6401,7 +6426,7 @@ sub nic_hetzner_update { next; } # Pull the ID out of the json, messy - my ($dns_rec_id, $current_ip) = map { ($_->{name} eq $hostname && $_->{type} eq $type) ? ($_->{id}, $_->{value}) : ()} @{$response->{records}}; + my ($dns_rec_id, $current_ip) = map { ($_->{name} eq $hostname && $_->{type} eq $type) ? ($_->{id}, $_ ->{value}) : ()} @{$response->{records}}; my $http_method=""; if ($current_ip && $current_ip eq $ip) { @@ -6414,11 +6439,11 @@ sub nic_hetzner_update { if ($dns_rec_id) { debug("updating %s: DNS '$type' record ID: $dns_rec_id", $domain); - $url = "https://$config{$key}{'server'}/records/$dns_rec_id"; + $url = "https://$config{$domain}{'server'}/records/$dns_rec_id"; $http_method = "PUT"; } else { debug("creating %s: DNS '$type'", $domain); - $url = "https://$config{$key}{'server'}/records"; + $url = "https://$config{$domain}{'server'}/records"; $http_method = "POST"; } my $data = "{\"zone_id\":\"$zone_id\", \"name\": \"$hostname\", \"value\": \"$ip\", \"type\": \"$type\", \"ttl\": $config{$domain}{'ttl'}}"; @@ -6480,6 +6505,7 @@ Example ${program}.conf file entries: record.myhost.com,other.myhost.com EoEXAMPLE } + ###################################################################### ## nic_yandex_update ## @@ -6488,86 +6514,73 @@ EoEXAMPLE ###################################################################### sub nic_yandex_update { debug("\nnic_yandex_update -------------------"); + for my $host (@_) { + my $ip = delete $config{$host}{'wantip'}; + my $headers = "PddToken: $config{$host}{'password'}\n"; - ## group hosts with identical attributes together - my %groups = group_hosts_by([ @_ ], [ qw(server login pasword) ]); + info("setting IP address to %s for %s", $ip, $host); + verbose("UPDATE:", "updating %s", $host); - ## update each set of hosts that had similar configurations - foreach my $sig (keys %groups) { - my @hosts = @{$groups{$sig}}; - my $key = $hosts[0]; - my $ip = $config{$key}{'wantip'}; - my $headers = "PddToken: $config{$key}{'password'}\n"; - - # FQDNs - for my $host (@hosts) { - delete $config{$host}{'wantip'}; - - info("setting IP address to %s for %s", $ip, $host); - verbose("UPDATE:", "updating %s", $host); - - # Get record ID for host - my $url = "https://$config{$host}{'server'}/api2/admin/dns/list?"; - $url .= "domain="; - $url .= $config{$key}{'login'}; - my $reply = geturl(proxy => opt('proxy'), url => $url, headers => $headers); - unless ($reply) { - failed("updating %s: Could not connect to %s.", $host, $config{$key}{'server'}); - next; - } - next if !header_ok($host, $reply); - - # Strip header - $reply =~ s/^.*?\n\n//s; - my $response = eval { decode_json($reply) }; - if ($response->{success} eq 'error') { - failed("%s", $response->{error}); - next; - } - - # Pull the ID out of the json - my ($id) = map { $_->{fqdn} eq $host ? $_->{record_id} : () } @{$response->{records}}; - unless ($id) { - failed("updating %s: DNS record ID not found.", $host); - next; - } - - # Update the DNS record - $url = "https://$config{$host}{'server'}/api2/admin/dns/edit"; - my $data = "domain="; - $data .= $config{$key}{'login'}; - $data .= "&record_id="; - $data .= $id; - $data .= "&content="; - $data .= $ip if $ip; - - $reply = geturl( - proxy => opt('proxy'), - url => $url, - headers => $headers, - method => 'POST', - data => $data, - ); - unless ($reply) { - failed("updating %s: Could not connect to %s.", $host, $config{$host}{'server'}); - next; - } - next if !header_ok($host, $reply); - - # Strip header - $reply =~ s/^.*?\n\n//s; - $response = eval { decode_json($reply) }; - if ($response->{success} eq 'error') { - failed("%s", $response->{error}); - } else { - success("%s -- Updated Successfully to %s", $host, $ip); - } - - # Cache - $config{$host}{'ip'} = $ip; - $config{$host}{'mtime'} = $now; - $config{$host}{'status'} = 'good'; + # Get record ID for host + my $url = "https://$config{$host}{'server'}/api2/admin/dns/list?"; + $url .= "domain="; + $url .= $config{$host}{'login'}; + my $reply = geturl(proxy => opt('proxy'), url => $url, headers => $headers); + unless ($reply) { + failed("updating %s: Could not connect to %s.", $host, $config{$host}{'server'}); + next; } + next if !header_ok($host, $reply); + + # Strip header + $reply =~ s/^.*?\n\n//s; + my $response = eval { decode_json($reply) }; + if ($response->{success} eq 'error') { + failed("%s", $response->{error}); + next; + } + + # Pull the ID out of the json + my ($id) = map { $_->{fqdn} eq $host ? $_->{record_id} : () } @{$response->{records}}; + unless ($id) { + failed("updating %s: DNS record ID not found.", $host); + next; + } + + # Update the DNS record + $url = "https://$config{$host}{'server'}/api2/admin/dns/edit"; + my $data = "domain="; + $data .= $config{$host}{'login'}; + $data .= "&record_id="; + $data .= $id; + $data .= "&content="; + $data .= $ip if $ip; + + $reply = geturl( + proxy => opt('proxy'), + url => $url, + headers => $headers, + method => 'POST', + data => $data, + ); + unless ($reply) { + failed("updating %s: Could not connect to %s.", $host, $config{$host}{'server'}); + next; + } + next if !header_ok($host, $reply); + + # Strip header + $reply =~ s/^.*?\n\n//s; + $response = eval { decode_json($reply) }; + if ($response->{success} eq 'error') { + failed("%s", $response->{error}); + } else { + success("%s -- Updated Successfully to %s", $host, $ip); + } + + $config{$host}{'ip'} = $ip; + $config{$host}{'mtime'} = $now; + $config{$host}{'status'} = 'good'; } } @@ -6608,7 +6621,7 @@ sub nic_duckdns_update { ## update each configured host ## should improve to update in one pass - foreach my $h (@_) { + for my $h (@_) { my $ipv4 = delete $config{$h}{'wantipv4'}; my $ipv6 = delete $config{$h}{'wantipv6'}; info("setting IPv4 address to %s for %s", $ipv4, $h) if $ipv4; @@ -6639,11 +6652,11 @@ sub nic_duckdns_update { my $state = 'noresult'; my $line = ''; - foreach $line (@reply) { + for $line (@reply) { if ($line eq 'OK') { - $config{$h}{'ipv4'} = $ipv4 if $ipv4; - $config{$h}{'ipv6'} = $ipv6 if $ipv6; - $config{$h}{'mtime'} = $now; + $config{$h}{'ipv4'} = $ipv4 if $ipv4; + $config{$h}{'ipv6'} = $ipv6 if $ipv6; + $config{$h}{'mtime'} = $now; $config{$h}{'status-ipv4'} = 'good' if $ipv4; $config{$h}{'status-ipv6'} = 'good' if $ipv6; $state = 'result'; @@ -6698,7 +6711,7 @@ EoEXAMPLE sub nic_freemyip_update { debug("\nnic_freemyip_update -------------------"); - foreach my $h (@_) { + for my $h (@_) { my $ip = delete $config{$h}{'wantip'}; info("setting IP address to %s for %s", $ip, $h); verbose("UPDATE:", "updating %s", $h); @@ -6724,8 +6737,8 @@ sub nic_freemyip_update { my @reply = split /\n/, $reply; my $returned = pop(@reply); if ($returned =~ /OK/) { - $config{$h}{'ip'} = $ip; - $config{$h}{'mtime'} = $now; + $config{$h}{'ip'} = $ip; + $config{$h}{'mtime'} = $now; $config{$h}{'status'} = 'good'; success("updating %s: good: IP address set to %s", $h, $ip); } else { @@ -6735,6 +6748,62 @@ sub nic_freemyip_update { } } +###################################################################### +## nic_ddnsfm_examples +###################################################################### +sub nic_ddnsfm_examples { + return <<"EoEXAMPLE"; +o 'ddns.fm' + +The 'ddns.fm' protocol is used by the free +dynamic DNS service available at ddns.fm. +API is documented here: https://ddns.fm/docs + +Configuration variables applicable to the 'ddns.fm' protocol are: + protocol=ddns.fm ## + password=service-key ## key for your domain + non-fully.qualified.host ## the host registered with the service. + +Example ${program}.conf file entries: + ## single host update + protocol=ddns.fm, \\ + password=your_ddns_key, \\ + myhost.example.com + +EoEXAMPLE +} + +###################################################################### +## nic_ddnsfm_update +###################################################################### +sub nic_ddnsfm_update { + debug("\nnic_ddnsfm_update -------------------"); + for my $h (@_) { + # ddns.fm behavior as of 2024-07-14: + # - IPv4 and IPv6 addresses cannot be updated simultaneously. + # - IPv4 updates do not affect the IPv6 AAAA record (if present). + # - IPv6 updates do not affect the IPv4 A record (if present). + for my $ipv ('4', '6') { + my $ip = delete $config{$h}{"wantipv$ipv"} or next; + info("setting IPv$ipv address to $ip for $h"); + verbose("UPDATE:", "updating %s", $h); + my $reply = geturl( + proxy => opt('proxy'), + url => "$config{$h}{server}/update?key=$config{$h}{password}&domain=$h&myip=$ip", + ); + if (!$reply) { + failed("updating %s: Could not connect to %s.", $h, $config{$h}{'server'}); + next; + } + next if !header_ok($h, $reply); + $config{$h}{"ipv$ipv"} = $ip; + $config{$h}{'mtime'} = $now; + $config{$h}{"status-ipv$ipv"} = 'good'; + success("updating $h: good: IPv$ipv address set to $ip"); + } + } +} + ###################################################################### ## nic_woima_examples ###################################################################### @@ -6812,10 +6881,7 @@ sub nic_woima_update { info("setting IP address to %s for %s", $ip, $h); verbose("UPDATE:", "updating %s", $h); - - ## Select the DynDNS system to update - ## TODO: endpoint does not support https with functioning certificate. Remove? - my $url = "http://$config{$h}{'server'}$config{$h}{'script'}?system="; + my $url = "$config{$h}{'server'}$config{$h}{'script'}?system="; if ($config{$h}{'custom'}) { warning("updating %s: 'custom' and 'static' may not be used together. ('static' ignored)", $h) if $config{$h}{'static'}; @@ -6855,7 +6921,7 @@ sub nic_woima_update { my $state = 'header'; my $returnedip = $ip; - foreach my $line (@reply) { + for my $line (@reply) { if ($state eq 'header') { $state = 'body'; @@ -6872,15 +6938,15 @@ sub nic_woima_update { $config{$h}{'status'} = $status; if ($status eq 'good') { - $config{$h}{'ip'} = $ip; + $config{$h}{'ip'} = $ip; $config{$h}{'mtime'} = $now; success("updating %s: %s: IP address set to %s", $h, $status, $ip); } elsif (exists $errors{$status}) { if ($status eq 'nochg') { warning("updating %s: %s: %s", $h, $status, $errors{$status}); - $config{$h}{'ip'} = $ip; - $config{$h}{'mtime'} = $now; + $config{$h}{'ip'} = $ip; + $config{$h}{'mtime'} = $now; $config{$h}{'status'} = 'good'; } else { @@ -6941,7 +7007,7 @@ sub nic_dondominio_update { ## update each configured host ## should improve to update in one pass - foreach my $h (@_) { + for my $h (@_) { my $ip = delete $config{$h}{'wantip'}; info("setting IP address to %s for %s", $ip, $h); verbose("UPDATE:", "updating %s", $h); @@ -6972,8 +7038,8 @@ sub nic_dondominio_update { my @reply = split /\n/, $reply; my $returned = pop(@reply); if ($returned =~ /OK/ || $returned =~ /IP:$ip/) { - $config{$h}{'ip'} = $ip; - $config{$h}{'mtime'} = $now; + $config{$h}{'ip'} = $ip; + $config{$h}{'mtime'} = $now; $config{$h}{'status'} = 'good'; success("updating %s: good: IP address set to %s", $h, $ip); } else { @@ -7031,15 +7097,11 @@ sub nic_dnsmadeeasy_update { ## update each configured host ## should improve to update in one pass - foreach my $h (@_) { + for my $h (@_) { my $ip = delete $config{$h}{'wantip'}; info("Setting IP address to %s for %s", $ip, $h); verbose("UPDATE:", "Updating %s", $h); - - # Set the URL that we're going to to update - my $url; - $url = $globals{'ssl'} ? "https://" : "http://"; - $url .= $config{$h}{'server'} . $config{$h}{'script'}; + my $url = $config{$h}{'server'} . $config{$h}{'script'}; $url .= "?username=$config{$h}{'login'}"; $url .= "&password=$config{$h}{'password'}"; $url .= "&ip=$ip"; @@ -7058,8 +7120,8 @@ sub nic_dnsmadeeasy_update { my @reply = split /\n/, $reply; my $returned = pop(@reply); if ($returned =~ 'success') { - $config{$h}{'ip'} = $ip; - $config{$h}{'mtime'} = $now; + $config{$h}{'ip'} = $ip; + $config{$h}{'mtime'} = $now; $config{$h}{'status'} = 'good'; success("Updating %s: good: IP address set to %s", $h, $ip); } else { @@ -7105,7 +7167,7 @@ sub nic_ovh_update { ## update each configured host ## should improve to update in one pass - foreach my $h (@_) { + for my $h (@_) { my $ip = delete $config{$h}{'wantip'}; info("setting IP address to %s for %s", $ip, $h); verbose("UPDATE:","updating %s", $h); @@ -7132,8 +7194,8 @@ sub nic_ovh_update { my @reply = split /\n/, $reply; my $returned = List::Util::first { $_ =~ /good/ || $_ =~ /nochg/ } @reply; if ($returned) { - $config{$h}{'ip'} = $ip; - $config{$h}{'mtime'} = $now; + $config{$h}{'ip'} = $ip; + $config{$h}{'mtime'} = $now; $config{$h}{'status'} = 'good'; if ($returned =~ /good/) { success("updating %s: good: IP address set to %s", $h, $ip); @@ -7226,9 +7288,9 @@ EoEXAMPLE sub nic_porkbun_update { debug("\nnic_porkbun_update -------------------"); - foreach my $host (@_) { + for my $host (@_) { my ($sub_domain, $domain); - if ($config{$host}{'root-domain'} ne '') { + if ($config{$host}{'root-domain'}) { # Process 'root-domain' option $domain = $config{$host}{'root-domain'}; $sub_domain = $host; @@ -7253,7 +7315,7 @@ sub nic_porkbun_update { } info("subdomain %s, root domain %s", $sub_domain, $domain) if $sub_domain ne ''; - foreach my $ipv ('ipv4', 'ipv6') { + for my $ipv ('ipv4', 'ipv6') { my $ip = delete $config{$host}{"want$ipv"}; if (!$ip) { next; @@ -7381,12 +7443,12 @@ EoEXAMPLE } sub nic_cloudns_update { - my %groups = group_hosts_by([ @_ ], [ qw(dynurl) ]); - for my $hr (values(%groups)) { - my @hosts = @$hr; + for my $group (group_hosts_by(\@_, qw(dynurl wantip))) { + my @hosts = @{$group->{hosts}}; + my %groupcfg = %{$group->{cfg}}; my $hosts = join(',', @hosts); - my $ip = $config{$hosts[0]}{'wantip'}; - my $dynurl = $config{$hosts[0]}{'dynurl'}; + my $ip = $groupcfg{'wantip'}; + my $dynurl = $groupcfg{'dynurl'}; delete $config{$_}{'wantip'} for @hosts; # https://www.cloudns.net/wiki/article/36/ says, "If you are behind a proxy and your real # IP is set in the header X-Forwarded-For you need to add &proxy=1 at the end of the @@ -7477,8 +7539,8 @@ sub nic_dinahosting_update { failed("updating %s: error %d: %s", $code, $message); next; } - $config{$h}{'ip'} = $ip; - $config{$h}{'mtime'} = $now; + $config{$h}{'ip'} = $ip; + $config{$h}{'mtime'} = $now; $config{$h}{'status'} = 'good'; success("updating %s: IP address set to %s", $h, $ip); } @@ -7538,8 +7600,8 @@ EoEXAMPLE sub nic_gandi_update { debug("\nnic_gandi_update -------------------"); # Update each set configured host. - foreach my $h (@_) { - foreach my $ipv ('ipv4', 'ipv6') { + for my $h (@_) { + for my $ipv ('ipv4', 'ipv6') { my $ip = delete $config{$h}{"want$ipv"}; if(!$ip) { next; @@ -7587,8 +7649,8 @@ sub nic_gandi_update { } if($response->{'rrset_values'}->[0] eq $ip && (!defined($config{$h}{'ttl'}) || $response->{'rrset_ttl'} eq $config{$h}{'ttl'})) { - $config{$h}{'ip'} = $ip; - $config{$h}{'mtime'} = $now; + $config{$h}{'ip'} = $ip; + $config{$h}{'mtime'} = $now; $config{$h}{"status-$ipv"} = "good"; success("updating %s: skipped: address was already set to %s.", $h, $ip); next; @@ -7611,14 +7673,12 @@ sub nic_gandi_update { } $ok = header_ok($h, $reply); if ($ok) { - $config{$h}{'ip'} = $ip; - $config{$h}{'mtime'} = $now; + $config{$h}{'ip'} = $ip; + $config{$h}{'mtime'} = $now; $config{$h}{"status-$ipv"} = "good"; - success("%s -- Updated successfully to %s.", $h, $ip); } else { $config{$h}{"status-$ipv"} = "bad"; - if (defined($response->{status}) && $response->{status} eq "error") { my @errors; for my $err (@{$response->{errors}}) { @@ -7663,39 +7723,33 @@ EoEXAMPLE ## response contains "code 200" on succesfull completion ###################################################################### sub nic_keysystems_update { - debug("\nnic_keysystems_update -------------------"); + debug("\nnic_keysystems_update -------------------"); - ## update each configured host - ## should improve to update in one pass - foreach my $h (@_) { - my $ip = delete $config{$h}{'wantip'}; - info("KEYSYSTEMS setting IP address to %s for %s", $ip, $h); + ## update each configured host + ## should improve to update in one pass + for my $h (@_) { + my $ip = delete $config{$h}{'wantip'}; + info("KEYSYSTEMS setting IP address to %s for %s", $ip, $h); + my $url = "$config{$h}{'server'}/update.php?hostname=$h&password=$config{$h}{'password'}&ip=$ip"; + my $reply = geturl(proxy => opt('proxy'), url => $url) // ''; - my $url = "http://$config{$h}{'server'}/update.php?hostname=$h&password=$config{$h}{'password'}&ip=$ip"; + # No response, declare as failed + if (!defined($reply) || !$reply) { + failed("KEYSYSTEMS updating %s: Could not connect to %s.", $h, $config{$h}{'server'}); + last; + } + last if !header_ok($h, $reply); - # Try to get URL - my $reply = geturl(proxy => opt('proxy'), url => $url) // ''; - - # No response, declare as failed - if (!defined($reply) || !$reply) { - failed("KEYSYSTEMS updating %s: Could not connect to %s.", $h, $config{$h}{'server'}); - last; - } - last if !header_ok($h, $reply); - - if ($reply =~ /code = 200/) - { - $config{$h}{'ip'} = $ip; - $config{$h}{'mtime'} = $now; - $config{$h}{'status'} = 'good'; - success("updating %s: good: IP address set to %s", $h, $ip); + if ($reply =~ /code = 200/) { + $config{$h}{'ip'} = $ip; + $config{$h}{'mtime'} = $now; + $config{$h}{'status'} = 'good'; + success("updating %s: good: IP address set to %s", $h, $ip); + } else { + $config{$h}{'status'} = 'failed'; + failed("updating %s: Server said: '$reply'", $h); + } } - else - { - $config{$h}{'status'} = 'failed'; - failed("updating %s: Server said: '$reply'", $h); - } - } } ###################################################################### @@ -7723,41 +7777,39 @@ EoEXAMPLE ## response contains "success" on succesfull completion ###################################################################### sub nic_regfishde_update { - debug("\nnic_regfishde_update -------------------"); + debug("\nnic_regfishde_update -------------------"); - ## update configured host - for my $h (@_) { - my $ip = delete $config{$h}{'wantip'}; - my $ipv6 = delete $config{$h}{'wantip'}; + ## update configured host + for my $h (@_) { + my $ipv4 = delete $config{$h}{'wantipv4'}; + my $ipv6 = delete $config{$h}{'wantipv6'}; + info("regfish.de setting IPv4 address to %s for %s", $ipv4, $h) if $ipv4; + info("regfish.de setting IPv6 address to %s for %s", $ipv6, $h) if $ipv6; + my $url = "https://$config{$h}{'server'}/?fqdn=$h&forcehost=1&token=$config{$h}{'password'}"; + $url .= "&ipv4=$ipv4" if $ipv4; + $url .= "&ipv6=$ipv6" if $ipv6; - info("regfish.de setting IP address to %s for %s", $ip, $h); + # Try to get URL + my $reply = geturl(proxy => opt('proxy'), url => $url); - my $ipv = ($ip eq ($ipv6 // '')) ? '6' : '4'; - my $url = "https://$config{$h}{'server'}/?fqdn=$h&ipv$ipv=$ip&forcehost=1&token=$config{$h}{'password'}"; - - # Try to get URL - my $reply = geturl(proxy => opt('proxy'), url => $url); - - # No response, give error - if (!defined($reply) || !$reply) { - failed("regfish.de updating %s: failed: %s.", $h, $config{$h}{'server'}); - last; - } - last if !header_ok($h, $reply); - - if ($reply =~ /success/) - { - $config{$h}{'ip'} = $ip; - $config{$h}{'mtime'} = $now; - $config{$h}{'status'} = 'good'; - success("updating %s: good: IP address set to %s", $h, $ip); + # No response, give error + if (!defined($reply) || !$reply) { + failed("regfish.de updating %s: failed: %s.", $h, $config{$h}{'server'}); + last; + } + last if !header_ok($h, $reply); + if ($reply !~ /success/) { + failed("updating %s: Server said: '%s'", $h, $reply); + next; + } + $config{$h}{'ipv4'} = $ipv4 if $ipv4; + $config{$h}{'ipv6'} = $ipv6 if $ipv6; + $config{$h}{'status-ipv4'} = 'good' if $ipv4; + $config{$h}{'status-ipv6'} = 'good' if $ipv6; + $config{$h}{'mtime'} = $now; + success("updating %s: good: IPv4 address set to %s", $h, $ipv4) if $ipv4; + success("updating %s: good: IPv6 address set to %s", $h, $ipv6) if $ipv6; } - else - { - $config{$h}{'status'} = 'failed'; - failed("updating %s: Server said: '$reply'", $h); - } - } } ###################################################################### @@ -7803,7 +7855,7 @@ EoEXAMPLE sub nic_enom_update { debug("\nenom_update -------------------"); ## update each configured host - foreach my $h (@_) { + for my $h (@_) { my $ip = delete $config{$h}{'wantip'}; info("setting IP address to %s for %s", $ip, $h); verbose("UPDATE:","updating %s", $h); @@ -7831,8 +7883,8 @@ sub nic_enom_update { my @reply = split /\n/, $reply; if (grep /Done=true/i, @reply) { - $config{$h}{'ip'} = $ip; - $config{$h}{'mtime'} = $now; + $config{$h}{'ip'} = $ip; + $config{$h}{'mtime'} = $now; $config{$h}{'status'} = 'good'; success("updating %s: good: IP address set to %s", $h, $ip); } else { @@ -7940,14 +7992,14 @@ sub nic_digitalocean_update_one { } $config{$h}{"status-$ipv"} = 'good'; - $config{$h}{"ip-$ipv"} = $ip; - $config{$h}{"mtime"} = $now; + $config{$h}{"ip-$ipv"} = $ip; + $config{$h}{"mtime"} = $now; } sub nic_digitalocean_update { debug("\nnic_digitalocean_update -------------------"); - foreach my $h (@_) { + for my $h (@_) { my $ipv4 = delete $config{$h}{'wantipv4'}; my $ipv6 = delete $config{$h}{'wantipv6'}; @@ -8014,19 +8066,14 @@ EoEXAMPLE ###################################################################### sub nic_infomaniak_update { debug("\nnic_infomaniak_update -------------------"); - - foreach my $h (@_) { - INFOMANIAK_IP_LOOP: - foreach my $v (4, 6) { + for my $h (@_) { + for my $v (4, 6) { my $ip = delete $config{$h}{"wantipv$v"}; - if (!defined $ip) { debug("ipv%d not wanted, skipping", $v); next; } - info("setting IP address to %s for %s", $ip, $h); - # No change in IP => nochg # Bad auth => badauth # Bad domain name => nohost @@ -8039,50 +8086,78 @@ sub nic_infomaniak_update { 'nohost' => (0, sprintf("Bad domain name %s or bad IP %s", $h, $ip)), 'badauth' => (0, sprintf("Bad authentication for %s", $h)), ); - - my $url1 = "https://$config{$h}{'login'}:$config{$h}{'password'}"; - $url1 .= "\@infomaniak.com/nic/update"; - $url1 .= "?hostname=$h"; - $url1 .= "&myip=$ip"; - - my $url2 = "https://infomaniak.com/nic/update"; - $url2 .= "?hostname=$h"; - $url2 .= "&myip=$ip"; - $url2 .= "&username=$config{$h}{'login'}"; - $url2 .= "&password=$config{$h}{'password'}"; - - my $reply; - - foreach my $url ($url1, $url2) { - info("trying update with %s", $url); - $reply = geturl(proxy => opt('proxy'), url => $url); - if (!defined($reply) || !$reply) { - info("could not update %s using url %s, trying next one", $h, $url); - next; - } - - my ($status) = split / /, $reply, 1; - my ($updated, $msg) = - $statuses{$status} // (0, sprintf("Unknown reply from Infomaniak: %s", $reply)); - - if (defined $updated && $updated) { - info($msg); - $config{$h}{"ipv$v"} = $ip; - $config{$h}{'mtime'} = $config{$h}{'mtime'} // $now; - $config{$h}{"status-ipv$v"} = 'good'; - next INFOMANIAK_IP_LOOP; - } - else { - warning($msg); - } + my $reply = geturl( + proxy => opt('proxy'), + url => "https://infomaniak.com/nic/update?hostname=$h&myip=$ip", + login => $config{$h}{'login'}, + password => $config{$h}{'password'}, + ); + next if !header_ok($h, $reply); + (my $body = $reply) =~ s/^.*?\n\n//s; + my ($status) = split(/ /, $body, 2); + my ($ok, $msg) = + $statuses{$status} // (0, sprintf("Unknown reply from Infomaniak: %s", $body)); + if (!$ok) { + failed($msg); + next; } - - $config{$h}{"status-ipv$v"} = 'failed'; - failed("updating %s: could not update IP on Infomaniak", $h); + success($msg); + $config{$h}{"ipv$v"} = $ip; + $config{$h}{'mtime'} = $now; + $config{$h}{"status-ipv$v"} = 'good'; } } } +###################################################################### +## nic_emailonly_update +## +## Written by Joel Croteau +## +## Do not update Dynamic DNS, only send status emails. Use if you do +## not have a DDNS host, but still want to get emails when your IP +## address changes. Note that you must set the "mail" config option +## and configure sendmail for this to have an effect. At least one +## host must be specified; the host names are mentioned in the email. +###################################################################### +sub nic_emailonly_update { + debug("\nnic_emailonly_update -------------------"); + # Note: This is logged after $config{$_}{'max-interval'] even if the IP address hasn't changed, + # so it is best to avoid phrasing like, "IP address has changed." + logmsg(email => 1, join("\n", 'Host IP addresses:', map({ + my $ipv4 = delete($config{$_}{'wantipv4'}); + my $ipv6 = delete($config{$_}{'wantipv6'}); + $config{$_}{'status-ipv4'} = 'good' if $ipv4; + $config{$_}{'status-ipv6'} = 'good' if $ipv6; + $config{$_}{'ipv4'} = $ipv4 if $ipv4; + $config{$_}{'ipv6'} = $ipv6 if $ipv6; + $config{$_}{'mtime'} = $now; + sprintf('%30s %s', $_, join(' ', grep(defined($_), $ipv4, $ipv6))); + } @_))); +} + +###################################################################### +## nic_emailonly_examples +###################################################################### +sub nic_emailonly_examples { + return <<"EoEXAMPLE"; +o 'emailonly' + +The 'emailonly' protocol is a dummy protocol that will send status emails but +not actually issue any dynamic DNS updates. You can use this if you don\'t +have a DDNS host, but still want to get emails when your IP address changes. +For this to have an effect, you must set the 'mail' config option, have +sendmail properly configured on your machine, and specify at least one dummy +hostname. + +Example ${program}.conf file entries: + ## single host update + mail=me\@example.com + protocol=emailonly + host.example.com +EoEXAMPLE +} + # Execute main() if this file is run as a script or run via PAR (https://metacpan.org/pod/PAR), # otherwise do nothing. This "modulino" pattern makes it possible to import this file as a module # and test its functions directly; there's no need for test-only command-line arguments or stdout diff --git a/t/dnsexit2.pl b/t/dnsexit2.pl new file mode 100644 index 0000000..b32c688 --- /dev/null +++ b/t/dnsexit2.pl @@ -0,0 +1,203 @@ +use Test::More; +eval { require JSON::PP; } or plan(skip_all => $@); +JSON::PP->import(qw(encode_json decode_json)); +eval { require 'ddclient'; } or BAIL_OUT($@); +eval { require ddclient::Test::Fake::HTTPD; } or plan(skip_all => $@); +eval { require LWP::UserAgent; } or plan(skip_all => $@); + +ddclient::load_json_support('dnsexit2'); + +my @requests; # Declare global variable to store requests, used for tests. +my @httpd_requests; # Declare variable specificly used for the httpd process (which cannot be shared with tests). +my $httpd = ddclient::Test::Fake::HTTPD->new(); + +$httpd->run(sub { + my ($req) = @_; + if ($req->uri->as_string eq '/get_requests') { + return [200, ['Content-Type' => 'application/json'], [encode_json(\@httpd_requests)]]; + } elsif ($req->uri->as_string eq '/reset_requests') { + @httpd_requests = (); + return [200, ['Content-Type' => 'application/json'], [encode_json({ message => 'OK' })]]; + } + my $request_info = { + method => $req->method, + uri => $req->uri->as_string, + content => $req->content, + headers => $req->headers->as_string + }; + push @httpd_requests, $request_info; + return [200, ['Content-Type' => 'application/json'], [encode_json({ + code => 0, + message => 'Success' + })]]; +}); + +diag(sprintf("started IPv4 server running at %s", $httpd->endpoint())); + +my $ua = LWP::UserAgent->new; + +sub test_nic_dnsexit2_update { + my ($config, @hostnames) = @_; + %ddclient::config = %$config; + ddclient::nic_dnsexit2_update(@hostnames); +} + +sub decode_and_sort_array { + my ($data) = @_; + if (!ref $data) { + $data = decode_json($data); + } + @{$data->{update}} = sort { $a->{type} cmp $b->{type} } @{$data->{update}}; + return $data; +} + +sub reset_test_data { + my $response = $ua->get($httpd->endpoint . '/reset_requests'); + die "Failed to reset requests" unless $response->is_success; + @requests = (); +} + +sub get_requests { + my $res = $ua->get($httpd->endpoint . '/get_requests'); + die "Failed to get requests: " . $res->status_line unless $res->is_success; + return @{decode_json($res->decoded_content)}; +} + +subtest 'Testing nic_dnsexit2_update' => sub { + my %config = ( + 'host.my.zone.com' => { + 'ssl' => 'no', + 'verbose' => 'yes', + 'usev4' => 'ipv4', + 'wantipv4' => '8.8.4.4', + 'usev6' => 'ipv6', + 'wantipv6' => '2001:4860:4860::8888', + 'protocol' => 'dnsexit2', + 'password' => 'mytestingpassword', + 'zone' => 'my.zone.com', + 'server' => $httpd->host_port(), + 'path' => '/update', + 'ttl' => 5 + }); + test_nic_dnsexit2_update(\%config, 'host.my.zone.com'); + @requests = get_requests(); + is($requests[0]->{method}, 'POST', 'Method is correct'); + is($requests[0]->{uri}, '/update', 'URI contains correct path'); + like($requests[0]->{headers}, qr/Content-Type: application\/json/, 'Content-Type header is correct'); + like($requests[0]->{headers}, qr/Accept: application\/json/, 'Accept header is correct'); + my $data = decode_and_sort_array($requests[0]->{content}); + my $expected_data = decode_and_sort_array({ + 'domain' => 'my.zone.com', + 'apikey' => 'mytestingpassword', + 'update' => [ + { + 'type' => 'A', + 'name' => 'host', + 'content' => '8.8.4.4', + 'ttl' => 5, + }, + { + 'type' => 'AAAA', + 'name' => 'host', + 'content' => '2001:4860:4860::8888', + 'ttl' => 5, + } + ] + }); + is_deeply($data, $expected_data, 'Data is correct'); + reset_test_data(); +}; + +subtest 'Testing nic_dnsexit2_update without a zone set' => sub { + my %config = ( + 'myhost.zone.com' => { + 'ssl' => 'yes', + 'verbose' => 'yes', + 'usev4' => 'ipv4', + 'wantipv4' => '8.8.4.4', + 'protocol' => 'dnsexit2', + 'password' => 'anotherpassword', + 'server' => $httpd->host_port(), + 'path' => '/update-alt', + 'ttl' => 10 + }); + test_nic_dnsexit2_update(\%config, 'myhost.zone.com'); + @requests = get_requests(); + my $data = decode_and_sort_array($requests[0]->{content}); + my $expected_data = decode_and_sort_array({ + 'domain' => 'myhost.zone.com', + 'apikey' => 'anotherpassword', + 'update' => [ + { + 'type' => 'A', + 'name' => '', + 'content' => '8.8.4.4', + 'ttl' => 10, + } + ] + }); + is_deeply($data, $expected_data, 'Data is correct'); + reset_test_data($ua); +}; + +subtest 'Testing nic_dnsexit2_update with two hostnames, one with a zone and one without' => sub { + my %config = ( + 'host1.zone.com' => { + 'ssl' => 'yes', + 'verbose' => 'yes', + 'usev4' => 'ipv4', + 'wantipv4' => '8.8.4.4', + 'protocol' => 'dnsexit2', + 'password' => 'testingpassword', + 'server' => $httpd->host_port(), + 'path' => '/update', + 'ttl' => 5 + }, + 'host2.zone.com' => { + 'ssl' => 'yes', + 'verbose' => 'yes', + 'usev6' => 'ipv6', + 'wantipv6' => '2001:4860:4860::8888', + 'protocol' => 'dnsexit2', + 'password' => 'testingpassword', + 'server' => $httpd->host_port(), + 'path' => '/update', + 'ttl' => 10, + 'zone' => 'zone.com' + } + ); + test_nic_dnsexit2_update(\%config, 'host1.zone.com', 'host2.zone.com'); + my $expected_data1 = decode_and_sort_array({ + 'domain' => 'host1.zone.com', + 'apikey' => 'testingpassword', + 'update' => [ + { + 'type' => 'A', + 'name' => '', + 'content' => '8.8.4.4', + 'ttl' => 5, + } + ] + }); + my $expected_data2 = decode_and_sort_array({ + 'domain' => 'zone.com', + 'apikey' => 'testingpassword', + 'update' => [ + { + 'type' => 'AAAA', + 'name' => 'host2', + 'content' => '2001:4860:4860::8888', + 'ttl' => 10, + } + ] + }); + @requests = get_requests(); + for my $i (0..1) { + my $data = decode_and_sort_array($requests[$i]->{content}); + is_deeply($data, $expected_data1, 'Data is correct for call host1') if $i == 0; + is_deeply($data, $expected_data2, 'Data is correct for call host2') if $i == 1; + } + reset_test_data(); +}; + +done_testing(); diff --git a/t/get_ip_from_if.pl b/t/get_ip_from_if.pl index 6f08e5d..15c66a1 100644 --- a/t/get_ip_from_if.pl +++ b/t/get_ip_from_if.pl @@ -39,23 +39,30 @@ subtest "get_ip_from_interface tests" => sub { } }; -subtest "Get default interface and IP for test system" => sub { +subtest "Get default interface and IP for test system (IPv4)" => sub { my $interface = ddclient::get_default_interface(4); - if ($interface) { - isnt($interface, "lo", "Check for loopback 'lo'"); - isnt($interface, "lo0", "Check for loopback 'lo0'"); - my $ip1 = ddclient::get_ip_from_interface("default", 4); - my $ip2 = ddclient::get_ip_from_interface($interface, 4); - is($ip1, $ip2, "Check IPv4 from default interface"); + plan(skip_all => 'no IPv4 interface') if !$interface; + isnt($interface, "lo", "Check for loopback 'lo'"); + isnt($interface, "lo0", "Check for loopback 'lo0'"); + my $ip1 = ddclient::get_ip_from_interface("default", 4); + my $ip2 = ddclient::get_ip_from_interface($interface, 4); + is($ip1, $ip2, "Check IPv4 from default interface"); + SKIP: { + skip('default interface does not have an appropriate IPv4 addresses') if !$ip1; ok(ddclient::is_ipv4($ip1), "Valid IPv4 from get_ip_from_interface($interface)"); } - $interface = ddclient::get_default_interface(6); - if ($interface) { - isnt($interface, "lo", "Check for loopback 'lo'"); - isnt($interface, "lo0", "Check for loopback 'lo0'"); - my $ip1 = ddclient::get_ip_from_interface("default", 6); - my $ip2 = ddclient::get_ip_from_interface($interface, 6); - is($ip1, $ip2, "Check IPv6 from default interface"); +}; + +subtest "Get default interface and IP for test system (IPv6)" => sub { + my $interface = ddclient::get_default_interface(6); + plan(skip_all => 'no IPv6 interface') if !$interface; + isnt($interface, "lo", "Check for loopback 'lo'"); + isnt($interface, "lo0", "Check for loopback 'lo0'"); + my $ip1 = ddclient::get_ip_from_interface("default", 6); + my $ip2 = ddclient::get_ip_from_interface($interface, 6); + is($ip1, $ip2, "Check IPv6 from default interface"); + SKIP: { + skip('default interface does not have an appropriate IPv6 addresses') if !$ip1; ok(ddclient::is_ipv6($ip1), "Valid IPv6 from get_ip_from_interface($interface)"); } }; diff --git a/t/geturl_response.pl b/t/geturl_response.pl new file mode 100644 index 0000000..beb1a92 --- /dev/null +++ b/t/geturl_response.pl @@ -0,0 +1,27 @@ +use Test::More; +SKIP: { eval { require Test::Warnings; } or skip($@, 1); } +eval { require 'ddclient'; } or BAIL_OUT($@); + +# Fake curl. Use the printf utility, which can process escapes. This allows Perl to drive the fake +# curl with plain ASCII and get arbitrary bytes back, avoiding problems caused by any encoding that +# might be done by Perl (e.g., "use open ':encoding(UTF-8)';"). +my @fakecurl = ('sh', '-c', 'printf %b "$1"', '--'); + +my @test_cases = ( + { + desc => 'binary body', + # Body is UTF-8 encoded ✨ (U+2728 Sparkles) followed by a 0xff byte (invalid UTF-8). + printf => join('\r\n', ('HTTP/1.1 200 OK', '', '\0342\0234\0250\0377')), + # The raw bytes should come through as equally valued codepoints. They must not be decoded. + want => "HTTP/1.1 200 OK\n\n\xe2\x9c\xa8\xff", + }, +); + +for my $tc (@test_cases) { + @ddclient::curl = (@fakecurl, $tc->{printf}); + $ddclient::curl if 0; # suppress spurious warning "Name used only once: possible typo" + my $got = ddclient::geturl(url => 'http://ignored'); + is($got, $tc->{want}, $tc->{desc}); +} + +done_testing(); diff --git a/t/group_hosts_by.pl b/t/group_hosts_by.pl new file mode 100644 index 0000000..4e2c29f --- /dev/null +++ b/t/group_hosts_by.pl @@ -0,0 +1,110 @@ +use Test::More; +SKIP: { eval { require Test::Warnings; } or skip($@, 1); } +eval { require 'ddclient'; } or BAIL_OUT($@); +eval { require Data::Dumper; } or skip($@, 1); +Data::Dumper->import(); + +my $h1 = 'h1'; +my $h2 = 'h2'; +my $h3 = 'h3'; + +$ddclient::config{$h1} = { + common => 'common', + h1h2 => 'h1 and h2', + unique => 'h1', + falsy => 0, + maybeunset => 'unique', +}; +$ddclient::config{$h2} = { + common => 'common', + h1h2 => 'h1 and h2', + unique => 'h2', + falsy => '', + maybeunset => undef, # should not be grouped with unset +}; +$ddclient::config{$h3} = { + common => 'common', + h1h2 => 'unique', + unique => 'h3', + falsy => undef, + # maybeunset is intentionally not set +}; + +my @test_cases = ( + { + desc => 'empty attribute set yields single group with all hosts', + groupby => [qw()], + want => [{cfg => {}, hosts => [$h1, $h2, $h3]}], + }, + { + desc => 'common attribute yields single group with all hosts', + groupby => [qw(common)], + want => [{cfg => {common => 'common'}, hosts => [$h1, $h2, $h3]}], + }, + { + desc => 'subset share a value', + groupby => [qw(h1h2)], + want => [ + {cfg => {h1h2 => 'h1 and h2'}, hosts => [$h1, $h2]}, + {cfg => {h1h2 => 'unique'}, hosts => [$h3]}, + ], + }, + { + desc => 'all unique', + groupby => [qw(unique)], + want => [ + {cfg => {unique => 'h1'}, hosts => [$h1]}, + {cfg => {unique => 'h2'}, hosts => [$h2]}, + {cfg => {unique => 'h3'}, hosts => [$h3]}, + ], + }, + { + desc => 'combination', + groupby => [qw(common h1h2)], + want => [ + {cfg => {common => 'common', h1h2 => 'h1 and h2'}, hosts => [$h1, $h2]}, + {cfg => {common => 'common', h1h2 => 'unique'}, hosts => [$h3]}, + ], + }, + { + desc => 'falsy values', + groupby => [qw(falsy)], + want => [ + {cfg => {falsy => 0}, hosts => [$h1]}, + {cfg => {falsy => ''}, hosts => [$h2]}, + {cfg => {falsy => undef}, hosts => [$h3]}, + ], + }, + { + desc => 'set, unset, undef', + groupby => [qw(maybeunset)], + want => [ + {cfg => {maybeunset => 'unique'}, hosts => [$h1]}, + {cfg => {maybeunset => undef}, hosts => [$h2]}, + {cfg => {}, hosts => [$h3]}, + ], + }, + { + desc => 'missing attribute', + groupby => [qw(thisdoesnotexist)], + want => [{cfg => {}, hosts => [$h1, $h2, $h3]}], + }, +); + +for my $tc (@test_cases) { + my @got = ddclient::group_hosts_by([$h1, $h2, $h3], @{$tc->{groupby}}); + # @got is used as a set of sets. Sort everything to make comparison easier. + $_->{hosts} = [sort(@{$_->{hosts}})] for @got; + @got = sort({ + for (my $i = 0; $i < @{$a->{hosts}} && $i < @{$b->{hosts}}; ++$i) { + my $x = $a->{hosts}[$i] cmp $b->{hosts}[$i]; + return $x if $x != 0; + } + return @{$a->{hosts}} <=> @{$b->{hosts}}; + } @got); + is_deeply(\@got, $tc->{want}, $tc->{desc}) + or diag(Data::Dumper->new([\@got, $tc->{want}], + [qw(got want)])->Sortkeys(1)->Useqq(1)->Dump()); +} + +done_testing(); diff --git a/t/header_ok.pl b/t/header_ok.pl new file mode 100644 index 0000000..197cc8d --- /dev/null +++ b/t/header_ok.pl @@ -0,0 +1,74 @@ +use Test::More; +SKIP: { eval { require Test::Warnings; } or skip($@, 1); } +eval { require 'ddclient'; } or BAIL_OUT($@); +my $have_mock = eval { require Test::MockModule; }; + +my $failmsg; +my $module; +if ($have_mock) { + $module = Test::MockModule->new('ddclient'); + # Note: 'mock' is used instead of 'redefine' because 'redefine' is not available in the versions + # of Test::MockModule distributed with old Debian and Ubuntu releases. + $module->mock('failed', sub { $failmsg //= ''; $failmsg .= sprintf(shift, @_) . "\n"; }); +} + +my @test_cases = ( + { + desc => 'malformed not OK', + input => 'malformed', + want => 0, + wantmsg => qr/unexpected/, + }, + { + desc => 'HTTP/1.1 200 OK', + input => 'HTTP/1.1 200 OK', + want => 1, + }, + { + desc => 'HTTP/2 200 OK', + input => 'HTTP/2 200 OK', + want => 1, + }, + { + desc => 'HTTP/3 200 OK', + input => 'HTTP/3 200 OK', + want => 1, + }, + { + desc => '401 not OK, fallback message', + input => 'HTTP/1.1 401 ', + want => 0, + wantmsg => qr/authentication failed/, + }, + { + desc => '403 not OK, fallback message', + input => 'HTTP/1.1 403 ', + want => 0, + wantmsg => qr/not authorized/, + }, + { + desc => 'other 4xx not OK', + input => 'HTTP/1.1 456 bad', + want => 0, + wantmsg => qr/bad/, + }, + { + desc => 'only first line is logged on error', + input => "HTTP/1.1 404 not found\n\nbody", + want => 0, + wantmsg => qr/(?!body)/, + }, +); + +for my $tc (@test_cases) { + subtest $tc->{desc} => sub { + $failmsg = ''; + is(ddclient::header_ok('host', $tc->{input}), $tc->{want}, 'return value matches'); + SKIP: { + skip('Test::MockModule not available') if !$have_mock; + like($failmsg, $tc->{wantmsg} // qr/^$/, 'fail message matches'); + } + }; +} + +done_testing(); diff --git a/t/interval_expired.pl b/t/interval_expired.pl new file mode 100644 index 0000000..1043dea --- /dev/null +++ b/t/interval_expired.pl @@ -0,0 +1,51 @@ +use Test::More; +SKIP: { eval { require Test::Warnings; } or skip($@, 1); } +eval { require 'ddclient'; } or BAIL_OUT($@); + +my $h = 't/interval_expired.pl'; + +my $default_now = 1000000000; + +my @test_cases = ( + { + interval => 'inf', + want => 0, + }, + { + now => 'inf', + interval => 'inf', + want => 0, + }, + { + cache => '-inf', + interval => 'inf', + want => 0, + }, + { + cache => undef, # Falsy cache value. + interval => 'inf', + want => 0, + }, + { + now => 0, + cache => 0, # Different kind of falsy cache value. + interval => 'inf', + want => 0, + }, +); + +for my $tc (@test_cases) { + $tc->{now} //= $default_now; + # For convenience, $tc->{cache} is an offset from $tc->{now}, not an absolute time.. + my $cachetime = $tc->{now} + $tc->{cache} if defined($tc->{cache}); + $ddclient::config{$h} = {'interval' => $tc->{interval}}; + %ddclient::config if 0; # suppress spurious warning "Name used only once: possible typo" + $ddclient::cache{$h} = {'cached-time' => $cachetime} if defined($cachetime); + %ddclient::cache if 0; # suppress spurious warning "Name used only once: possible typo" + $ddclient::now = $tc->{now}; + $ddclient::now if 0; # suppress spurious warning "Name used only once: possible typo" + my $desc = "now=$tc->{now}, cache=${\($cachetime // 'undef')}, interval=$tc->{interval}"; + is(ddclient::interval_expired($h, 'cached-time', 'interval'), $tc->{want}, $desc); +} + +done_testing(); diff --git a/t/variable_defaults.pl b/t/variable_defaults.pl new file mode 100644 index 0000000..09dc92c --- /dev/null +++ b/t/variable_defaults.pl @@ -0,0 +1,32 @@ +use Test::More; +SKIP: { eval { require Test::Warnings; } or skip($@, 1); } +eval { require 'ddclient'; } or BAIL_OUT($@); + +my %variable_collections = ( + map({ ($_ => $ddclient::variables{$_}) } grep($_ ne 'merged', keys(%ddclient::variables))), + map({ ("protocol=$_" => $ddclient::protocols{$_}{variables}); } keys(%ddclient::protocols)), +); +my %seen; +my @test_cases = ( + map({ + my $vcn = $_; + my $vc = $variable_collections{$_}; + map({ + my $def = $vc->{$_}; + my $seen = exists($seen{$def}); + $seen{$def} = undef; + ({desc => "$vcn $_", def => $vc->{$_}}) x !$seen; + } sort(keys(%$vc))); + } sort(keys(%variable_collections))), +); +for my $tc (@test_cases) { + if ($tc->{def}{required}) { + is($tc->{def}{default}, undef, "'$tc->{desc}' (required) has no default"); + } else { + my $norm; + my $valid = eval { $norm = ddclient::check_value($tc->{def}{default}, $tc->{def}); 1; }; + ok($valid, "'$tc->{desc}' (optional) has a valid default"); + is($norm, $tc->{def}{default}, "'$tc->{desc}' default normalizes to itself") if $valid; + } +} +done_testing(); diff --git a/t/write_cache.pl b/t/write_recap.pl similarity index 97% rename from t/write_cache.pl rename to t/write_recap.pl index 94e959b..9f9c661 100644 --- a/t/write_cache.pl +++ b/t/write_recap.pl @@ -35,7 +35,7 @@ my @test_cases = ( for my $tc (@test_cases) { $warning = undef; - ddclient::write_cache($tc->{f}); + ddclient::write_recap($tc->{f}); subtest $tc->{name} => sub { if (defined($tc->{warning_regex})) { like($warning, $tc->{warning_regex}, "expected warning message");