diff --git a/ChangeLog.md b/ChangeLog.md index b8cf192..69c2f76 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -48,6 +48,8 @@ repository history](https://github.com/ddclient/ddclient/commits/master). removed. [#716](https://github.com/ddclient/ddclient/pull/716) * `googledomains`: Support was removed because the service shut down. [#716](https://github.com/ddclient/ddclient/pull/716) + * The `--retry` option was removed. + [#732](https://github.com/ddclient/ddclient/pull/732) ### New features @@ -131,6 +133,7 @@ repository history](https://github.com/ddclient/ddclient/commits/master). [#734](https://github.com/ddclient/ddclient/pull/734) * Fixed unnecessary repeated updates for some services. [#670](https://github.com/ddclient/ddclient/pull/670) + [#732](https://github.com/ddclient/ddclient/pull/732) * Fixed DNSExit provider when configured with a zone and non-identical hostname. [#674](https://github.com/ddclient/ddclient/pull/674) * `infomaniak`: Fixed frequent forced updates after 25 days (`max-interval`). diff --git a/Makefile.am b/Makefile.am index a9d782b..4f0f916 100644 --- a/Makefile.am +++ b/Makefile.am @@ -80,6 +80,7 @@ handwritten_tests = \ t/protocol_dyndns2.pl \ t/skip.pl \ t/ssl-validate.pl \ + t/update_nics.pl \ t/use_web.pl \ t/variable_defaults.pl \ t/write_recap.pl diff --git a/configure.ac b/configure.ac index 5ca6da7..75edda5 100644 --- a/configure.ac +++ b/configure.ac @@ -77,6 +77,8 @@ m4_foreach_w([_m], [ B File::Spec::Functions File::Temp + List::Util + re ], [AX_PROG_PERL_MODULES([_m], [], [AC_MSG_WARN([some tests will fail due to missing module _m])])]) diff --git a/ddclient.in b/ddclient.in index 88e4c5d..c586a4a 100755 --- a/ddclient.in +++ b/ddclient.in @@ -158,7 +158,7 @@ my $saved_recap; my %saved_opt; my $daemon; # Control how many times warning message logged for invalid IP addresses -my (%warned_ip, %warned_ipv4, %warned_ipv6); +my (%warned_ipv4, %warned_ipv6); sub repr { my $vals = @_ % 2 ? [shift] : []; @@ -570,36 +570,7 @@ our %variables = ( 'proxy' => setv(T_FQDNP, 0, 0, undef, undef), 'protocol' => setv(T_PROTO, 0, 0, 'dyndns2', undef), - 'use' => setv(T_USE, 0, 0, 'ip', undef), - 'usev4' => setv(T_USEV4, 0, 0, 'disabled', undef), - 'usev6' => setv(T_USEV6, 0, 0, 'disabled', undef), - 'ip' => setv(T_IP, 0, 0, undef, undef), - 'ipv4' => setv(T_IPV4, 0, 0, undef, undef), - 'ipv6' => setv(T_IPV6, 0, 0, undef, undef), - 'if' => setv(T_IF, 0, 0, 'ppp0', undef), - 'ifv4' => setv(T_IF, 0, 0, 'default', undef), - 'ifv6' => setv(T_IF, 0, 0, 'default', undef), - 'web' => setv(T_STRING,0, 0, 'dyndns', undef), - 'web-skip' => setv(T_STRING,0, 0, undef, undef), - 'webv4' => setv(T_STRING,0, 0, 'ipify-ipv4', undef), - '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, undef), - 'fw-skip' => setv(T_STRING,0, 0, undef, 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, undef), - 'fwv6-skip' => setv(T_STRING,0, 0, undef, 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, 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, 1, undef), 'syslog' => setv(T_BOOL, 0, 0, 0, undef), @@ -626,7 +597,13 @@ our %variables = ( 'password' => setv(T_PASSWD,1, 0, undef, undef), 'host' => setv(T_STRING,1, 1, undef, undef), - 'use' => setv(T_USE, 0, 0, 'ip', undef), + 'use' => setv(T_USE, 0, 0, sub { + my ($h) = @_; + return "'disabled' if '--usev4' or '--usev6' is enabled, otherwise 'ip'" + if ($h // '') eq ''; + return 'disabled' if opt('usev4', $h) ne 'disabled' || opt('usev6', $h) ne 'disabled'; + return 'ip'; + }, undef), 'usev4' => setv(T_USEV4, 0, 0, 'disabled', undef), 'usev6' => setv(T_USEV6, 0, 0, 'disabled', undef), 'if' => setv(T_IF, 0, 0, 'ppp0', undef), @@ -656,16 +633,20 @@ our %variables = ( 'max-interval' => setv(T_DELAY, 0, 0, interval('25d'), 0), 'min-error-interval' => setv(T_DELAY, 0, 0, interval('5m'), 0), - # 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. + # The desired IP address (IPv4 or IPv6, but almost always IPv4) that should be saved at the + # DDNS service. + # TODO: Legacy protocol implementations write the IP address most recently saved at the + # DDNS service to this variable so that it can be saved in the recap (as `ipv4` or `ipv6`). + # Update the legacy implementations to use `ipv4` or `ipv6` instead, though see the TODO + # for those variables. + 'ip' => setv(T_IP, 0, 0, undef, undef), + # As a recap value, this is the IPv4 address most recently saved at the DDNS service. As a + # setting, this is the desired IPv4 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. 'ipv4' => setv(T_IPV4, 0, 1, undef, undef), - # As `ip`, but only IPv6 addresses. + # As `ipv4`, but for an IPv6 address. 'ipv6' => setv(T_IPV6, 0, 1, undef, undef), # Timestamp (seconds since epoch) indicating the earliest time the next update is # permitted. @@ -681,25 +662,23 @@ our %variables = ( # 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`. + # service with the IP address in `wantipv4` (or `wantip`, if an IPv4 address). Anything + # other than `good`, including undef, is treated as a failure. 'status-ipv4' => setv(T_ANY, 0, 1, undef, undef), - # As `status`, but with `wantipv6`. + # As `status-ipv4`, but with `wantipv6` (or `wantip`, if an IPv6 address). '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), + 'warned-min-interval' => setv(T_ANY, 0, 1, undef, 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), + 'warned-min-error-interval' => setv(T_ANY, 0, 1, undef, undef), }, 'dyndns-common-defaults' => { 'backupmx' => setv(T_BOOL, 0, 1, 0, undef), @@ -1121,7 +1100,7 @@ $variables{'merged'} = { }; # This will hold the processed args. -my %opt = (); +our %opt; my $deprecated_handler = sub { warning("'-$_[0]' is deprecated and does nothing"); }; $opt{'fw-banlocal'} = $deprecated_handler; $opt{'if-skip'} = $deprecated_handler; @@ -1166,7 +1145,7 @@ my @opt = ( ["cache", "=s", "--cache= : record address used in "], ["pid", "=s", "--pid= : record process id in if daemonized"], "", - ["use", "=s", "--use= : deprecated, see '--usev4' and '--usev6' (forced to 'disabled' if either '--usev4' or '--usev6' is enabled)"], + ["use", "=s", "--use= : deprecated, see '--usev4' and '--usev6'"], &ip_strategies_usage(), ["usev4", "=s", "--usev4= : how the IPv4 address should be obtained"], &ipv4_strategies_usage(), @@ -1221,7 +1200,6 @@ my @opt = ( ["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"], ["web-ssl-validate", "!", "--{no}web-ssl-validate : Validate SSL certificate when retrieving IP address from web"], - ["retry", "!", "--{no}retry : Initiate a one-time update attempt for hosts that have not been successfully updated (according to the cache). Incompatible with '--daemon'"], ["force", "!", "--{no}force : force an update even if the update may be unnecessary"], ["timeout", "=i", "--timeout= : when fetching a URL, wait at most seconds for a response"], ["syslog", "!", "--{no}syslog : log messages to syslog"], @@ -1460,16 +1438,55 @@ sub update_nics { $0 = sprintf("%s - updating %s", $program, join(',', @hosts)); local $_l = pushlogctx($p); &$update(@hosts); - - # Backwards compatibility: - # The legacy '--use' parameter sets 'wantip' and the legacy providers process this and - # set 'ip', 'status' accordingly. - # 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. for my $h (@hosts) { - $config{$h}{'status'} //= $config{$h}{'status-ipv4'} // $config{$h}{'status-ipv6'}; - $config{$h}{'ip'} //= $config{$h}{'ipv4'} // $config{$h}{'ipv6'}; + delete($config{$h}{$_}) for qw(wantip wantipv4 wantipv6); + } + + # Backwards compatibility: Legacy protocol implementations read `wantip` and set `ip` + # and `status`. Modern protocol implementations read `wantipv4` and `wantipv6` and set + # `ipv4`, `ipv6`, `status-ipv4`, and `status-ipv6`. Make legacy implementations look + # like modern implementations by moving `ip` and `status` to the modern + # version-specific equivalents. + for my $h (@hosts) { + local $_l = pushlogctx($h); + my $status = delete($config{$h}{'status'}) || next; + my $ip = $config{$h}{'ip'}; + my $ipv = is_ipv4($ip) ? '4' : is_ipv6($ip) ? '6' : undef; + if (!defined($ipv)) { + warning("ddclient bug: legacy protocol set 'status' but did not set 'ip' " . + "to an IPv4 or IPv6 address: " . ($ip // '')); + next; + } + # TODO: Currently $config{$h}{'ip'} is used for two distinct purposes: it holds the + # value of the --ip option, and it is updated by legacy protocols to hold the new + # IP address after an update. Fortunately, the --ip option is not used very often, + # and if it is, the values for the two use cases are usually (but not always) the + # same. This boolean is an imperfect attempt to identify whether 'ip' is being + # used for the --ip option, to avoid breaking some user's configuration. Protocols + # should be updated to not set $config{$h}{'ip'} because %config is for + # configuration, not update status (same goes for 'status', 'mtime', etc.). + my $ip_option = opt('use', $h) eq 'ip' || opt('usev6', $h) eq 'ip'; + delete($config{$h}{'ip'}) if !$ip_option; + debug("legacy protocol; moving status to status-ipv$ipv and ip to ipv$ipv"); + if (defined(my $vstatus = $config{$h}{"status-ipv$ipv"})) { + warning("ddclient bug: legacy protocol set both 'status' (to '$status') " . + "and 'status-ipv$ipv' (to '$vstatus')"); + } else { + $config{$h}{"status-ipv$ipv"} = $status; + } + # TODO: See above comment for $ip_option. This is the same situation, but for + # 'ipv4' and 'ipv6'. + my $vip_option = opt("usev$ipv", $h) eq "ipv$ipv"; + if (defined(my $vip = $config{$h}{"ipv$ipv"}) && $vip_option) { + debug("unable to update 'ipv$ipv' to '$ip' " . + "because it is already set to '$vip'") if $vip_option; + } else { + # A previous update will have set "ipv$ipv" if !$vip_option, so it's safe to + # overwrite it here because $vip_option was checked above. + debug("updating 'ipv$ipv' from '$vip' to '$ip'") + if defined($vip) && $vip ne $ip; + $config{$h}{"ipv$ipv"} = $ip; + } } runpostscript(join ' ', keys %ipsv4, keys %ipsv6); @@ -1531,7 +1548,7 @@ sub write_recap { $recap{$h}{$v} = opt($v, $h); } } else { - for my $v (qw(atime wtime status status-ipv4 status-ipv6)) { + for my $v (qw(atime wtime status-ipv4 status-ipv6)) { $recap{$h}{$v} = opt($v, $h); } } @@ -1593,7 +1610,7 @@ sub read_recap { next if !exists($config->{$h}); # TODO: Why is this limited to this set of variables? Why not copy every recap var # defined for the host's protocol? - for (qw(atime mtime wtime ip ipv4 ipv6 status status-ipv4 status-ipv6)) { + for (qw(atime mtime wtime ip ipv4 ipv6 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 @@ -1925,9 +1942,7 @@ sub init_config { ## override global options with those on the command-line. for my $o (keys %opt) { - # TODO: Why is this limited to $variables{'global-defaults'}? Why not - # $variables{'merged'}? - if (defined $opt{$o} && exists $variables{'global-defaults'}{$o}) { + if (defined $opt{$o} && exists $variables{'merged'}{$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}; @@ -1937,23 +1952,11 @@ sub init_config { # if they are not associated with a host via `--host=` or `--options=host=`)? } - ## sanity check - if (defined(opt('host')) && opt('retry')) { - fatal("options --retry and --host (or --option host=..) are mutually exclusive"); - } - 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 in recap) my @hosts = keys %config; if (opt('host')) { @hosts = split_by_comma($opt{'host'}); } - # TODO: The first two times init_config() is called the cache file has not been read yet, so - # this will not filter out any hosts and thus updates will not be limited to non-good hosts as - # intended. - if (opt('retry')) { - @hosts = grep(($recap{$_}{'status'} // '') ne 'good', keys(%recap)); - } ## remove any other hosts my %hosts; @@ -1963,10 +1966,6 @@ sub init_config { # TODO: Why aren't the hosts specified by --host added to %config except when --options is also # given? - for my $h (keys %config) { - $config{$h}{use} = 'disabled' - if opt('usev4', $h) ne 'disabled' || opt('usev6', $h) ne 'disabled'; - } my @protos = map(opt('protocol', $_), keys(%config)); my @needs_sha1 = grep({ my $p = $_; grep($_ eq $p, @protos); } qw(freedns nfsn)); load_sha1_support(join(', ', @needs_sha1)) if @needs_sha1; @@ -1981,7 +1980,7 @@ sub usage { for (@_) { if (ref $_) { my ($key, $specifier, $arg_usage) = @$_; - my $value = default($key); + my $value = default($key, ''); next unless $arg_usage; $usage .= " $arg_usage"; if (defined($value) && $value ne '') { @@ -2264,15 +2263,17 @@ sub split_by_comma { } sub default { my ($v, $h) = @_; + my $var; if (defined($h) && $config{$h}) { my $proto = $protocols{opt('protocol', $v eq 'protocol' ? undef : $h)}; - my $var = $proto->{variables}{$v} if $proto; - return $var->{default} if $var; + $var = $proto->{variables}{$v} if $proto; } - return undef if !defined($variables{'merged'}{$v}); # TODO: This might grab an arbitrary protocol-specific variable definition, which could cause # surprising behavior. - return $variables{'merged'}{$v}{'default'}; + $var //= $variables{'merged'}{$v}; + return undef if !defined($var); + return $var->{'default'}($h) if ref($var->{default}) eq 'CODE'; + return $var->{'default'}; } sub opt { my $v = shift; @@ -3444,12 +3445,8 @@ sub nic_updateable { my ($host) = @_; my $force_update = $protocols{opt('protocol', $host)}{force_update}; my $update = 0; - my $ip = $config{$host}{'wantip'}; my $ipv4 = $config{$host}{'wantipv4'}; my $ipv6 = $config{$host}{'wantipv6'}; - my $use = opt('use', $host); - my $usev4 = opt('usev4', $host); - my $usev6 = opt('usev6', $host); my $inv_ip_warn_count = opt('max-warn'); my $previp = $recap{$host}{'ip'} || ''; my $previpv4 = $recap{$host}{'ipv4'} || ''; @@ -3459,9 +3456,8 @@ sub nic_updateable { my %prettyi = map({ ($_ => prettyinterval(opt($_, $host))); } qw(max-interval min-error-interval min-interval)); - $warned_ip{$host} = 0 if $use ne 'disabled' && $ip; - $warned_ipv4{$host} = 0 if $usev4 ne 'disabled' && $ipv4; - $warned_ipv6{$host} = 0 if $usev6 ne 'disabled' && $ipv6; + $warned_ipv4{$host} = 0 if defined($ipv4); + $warned_ipv6{$host} = 0 if defined($ipv6); if (opt('force')) { info("update forced via 'force' option"); @@ -3472,42 +3468,13 @@ sub nic_updateable { $update = 1; } elsif ($recap{$host}{'wtime'} && $recap{$host}{'wtime'} > $now) { - warning("cannot update IP from $previp to $ip until after $prettyt{'wtime'}"); + warning("cannot update IP until after $prettyt{'wtime'}"); } elsif ($recap{$host}{'mtime'} && interval_expired($host, 'mtime', 'max-interval')) { info("update forced because it has been $prettyi{'max-interval'} since the previous update (on $prettyt{'mtime'})"); $update = 1; - } elsif ($use ne 'disabled' && $previp ne $ip) { - ## Check whether to update IP address for the "--use" method" - if (($recap{$host}{'status'} // '') eq 'good' && - !interval_expired($host, 'mtime', 'min-interval')) { - warning("skipped update from $previp to $ip because it has been less than $prettyi{'min-interval'} since the previous update (on $prettyt{'mtime'})") - if opt('verbose') || !($recap{$host}{'warned-min-interval'} // 0); - - $recap{$host}{'warned-min-interval'} = $now; - - } elsif (($recap{$host}{'status'} // '') ne 'good' && - !interval_expired($host, 'atime', 'min-error-interval')) { - - if (opt('verbose') || (!$recap{$host}{'warned-min-error-interval'} && - ($warned_ip{$host} // 0) < $inv_ip_warn_count)) { - warning("skipped update from $previp to $ip because it has been less than $prettyi{'min-error-interval'} since the previous update attempt (on $prettyt{'atime'}), which failed"); - if (!$ip && !opt('verbose')) { - $warned_ip{$host} = ($warned_ip{$host} // 0) + 1; - warning("IP address undefined. Warned $inv_ip_warn_count times, suppressing further warnings") - if ($warned_ip{$host} >= $inv_ip_warn_count); - } - } - - $recap{$host}{'warned-min-error-interval'} = $now; - - } else { - $update = 1; - } - - } elsif ($usev4 ne 'disabled' && $previpv4 ne $ipv4) { - ## Check whether to update IPv4 address for the "--usev4" method" + } elsif (defined($ipv4) && $previpv4 ne $ipv4) { if (($recap{$host}{'status-ipv4'} // '') eq 'good' && !interval_expired($host, 'mtime', 'min-interval')) { warning("skipped update from $previpv4 to $ipv4 because it has been less than $prettyi{'min-interval'} since the previous update (on $prettyt{'mtime'})") @@ -3534,8 +3501,7 @@ sub nic_updateable { $update = 1; } - } elsif ($usev6 ne 'disabled' && $previpv6 ne $ipv6) { - ## Check whether to update IPv6 address for the "--usev6" method" + } elsif (defined($ipv6) && $previpv6 ne $ipv6) { if (($recap{$host}{'status-ipv6'} // '') eq 'good' && !interval_expired($host, 'mtime', 'min-interval')) { warning("skipped update from $previpv6 to $ipv6 because it has been less than $prettyi{'min-interval'} since the previous update (on $prettyt{'mtime'})") @@ -3572,30 +3538,27 @@ sub nic_updateable { } else { if (opt('verbose')) { - success("skipped update because IP address is already set to $ip") - if $use ne 'disabled'; success("skipped update because IPv4 address is already set to $ipv4") - if $usev4 ne 'disabled'; + if defined($ipv4); success("skipped update because IPv6 address is already set to $ipv6") - if $usev6 ne 'disabled'; + if defined($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; + # TODO: `status` is set by legacy protocol implementations. Remove it from this list once all + # legacy protocol implementations have been upgraded. + delete($config{$host}{$_}) for qw(status status-ipv4 status-ipv6 update); if ($update) { - $config{$host}{'status'} = undef; - $config{$host}{'status-ipv4'} = undef; - $config{$host}{'status-ipv6'} = undef; - $config{$host}{'atime'} = $now; - $config{$host}{'wtime'} = 0; - $config{$host}{'warned-min-interval'} = 0; - $config{$host}{'warned-min-error-interval'} = 0; - + $config{$host}{'update'} = 1; + $config{$host}{'atime'} = $now; + delete($config{$host}{$_}) for qw(wtime warned-min-interval warned-min-error-interval); delete $recap{$host}{'warned-min-interval'}; delete $recap{$host}{'warned-min-error-interval'}; + } else { + for (qw(status-ipv4 status-ipv6)) { + $config{$host}{$_} = $recap{$host}{$_} if defined($recap{$host}{$_}); + } + delete($config{$host}{$_}) for qw(wantip wantipv4 wantipv6); } return $update; diff --git a/sample-etc_cron.d_ddclient b/sample-etc_cron.d_ddclient index b081832..6019805 100644 --- a/sample-etc_cron.d_ddclient +++ b/sample-etc_cron.d_ddclient @@ -10,7 +10,3 @@ ## force an update twice a month (only if you are not using daemon-mode) ## ## 30 23 1,15 * * root /usr/bin/ddclient -daemon=0 -syslog -quiet -force -###################################################################### -## retry failed updates every hour (only if you are not using daemon-mode) -## -## 0 * * * * root /usr/bin/ddclient -daemon=0 -syslog -quiet retry diff --git a/t/update_nics.pl b/t/update_nics.pl new file mode 100644 index 0000000..9807703 --- /dev/null +++ b/t/update_nics.pl @@ -0,0 +1,352 @@ +use Test::More; +use File::Temp; +use List::Util qw(max); +eval { require ddclient::Test::Fake::HTTPD; } or plan(skip_all => $@); +SKIP: { eval { require Test::Warnings; } or skip($@, 1); } +eval { require 'ddclient'; } or BAIL_OUT($@); +my $ipv6_supported = eval { + require IO::Socket::IP; + my $ipv6_socket = IO::Socket::IP->new( + Domain => 'PF_INET6', + LocalHost => '::1', + Listen => 1, + ); + defined($ipv6_socket); +}; +my $http_daemon_supports_ipv6 = eval { + require HTTP::Daemon; + HTTP::Daemon->VERSION(6.12); +}; + +sub run_httpd { + my ($ipv) = @_; + return undef if $ipv eq '6' && (!$ipv6_supported || !$http_daemon_supports_ipv6); + my $httpd = ddclient::Test::Fake::HTTPD->new( + host => $ipv eq '4' ? '127.0.0.1' : '::1', + daemon_args => {V6Only => 1}, + ); + my $ip = $ipv eq '4' ? '192.0.2.1' : '2001:db8::1'; + $httpd->run(sub { return [200, ['content-type' => 'text/plain; charset=utf-8'], [$ip]]; }); + diag("started IPv$ipv HTTP server running at " . $httpd->endpoint()); + return $httpd; +} +my %httpd = ( + '4' => run_httpd('4'), + '6' => run_httpd('6'), +); +local %ddclient::builtinweb = ( + v4 => {url => "" . $httpd{'4'}->endpoint()}, + defined($httpd{'6'}) ? (v6 => {url => "" . $httpd{'6'}->endpoint()}) : (), +); + +local $ddclient::globals{debug} = 1; +local $ddclient::globals{verbose} = 1; +local $ddclient::now = 1000; +our @updates; +local %ddclient::protocols = ( + # The `legacy` protocol reads the legacy `wantip` property and sets the legacy `ip` and `status` + # properties. (Modern protocol implementations read `wantipv4` and `wantipv6` and set `ipv4`, + # `ipv6`, `status-ipv4`, and `status-ipv6`.) It always succeeds. + legacy => { + update => sub { + for my $h (@_) { + push(@updates, [@_]); + $ddclient::config{$h}{status} = 'good'; + $ddclient::config{$h}{ip} = delete($ddclient::config{$h}{wantip}); + $ddclient::config{$h}{mtime} = $ddclient::now; + } + }, + variables => { + %{$ddclient::variables{'protocol-common-defaults'}}, + }, + }, +); + +my @test_cases = ( + map({ + my %cfg = %{delete($_->{cfg})}; + my $desc = join(' ', map("$_=$cfg{$_}", keys(%cfg))); + { + desc => "legacy, fresh, $desc", + cfg => { + 'protocol' => 'legacy', + %cfg, + }, + want_update => 1, + want_recap_changes => { + 'atime' => $ddclient::now, + 'ipv4' => '192.0.2.1', + 'mtime' => $ddclient::now, + 'status-ipv4' => 'good', + }, + want_cfg_changes => { + 'atime' => $ddclient::now, + 'ipv4' => '192.0.2.1', + 'mtime' => $ddclient::now, + 'status-ipv4' => 'good', + }, + %$_, + }; + } {cfg => {use => 'web'}}, {cfg => {usev4 => 'webv4'}}), + { + desc => 'legacy, fresh, use=web (IPv6)', + ipv6 => 1, + cfg => { + 'protocol' => 'legacy', + 'use' => 'web', + 'web' => 'v6', + }, + want_update => 1, + want_recap_changes => { + 'atime' => $ddclient::now, + 'ipv6' => '2001:db8::1', + 'mtime' => $ddclient::now, + 'status-ipv6' => 'good', + }, + want_cfg_changes => { + 'atime' => $ddclient::now, + 'ipv6' => '2001:db8::1', + 'mtime' => $ddclient::now, + 'status-ipv6' => 'good', + }, + }, + { + desc => 'legacy, fresh, usev6=webv6', + ipv6 => 1, + cfg => { + 'protocol' => 'legacy', + 'usev6' => 'webv6', + }, + want_update => 1, + want_recap_changes => { + 'atime' => $ddclient::now, + 'ipv6' => '2001:db8::1', + 'mtime' => $ddclient::now, + 'status-ipv6' => 'good', + }, + want_cfg_changes => { + 'atime' => $ddclient::now, + 'ipv6' => '2001:db8::1', + 'mtime' => $ddclient::now, + 'status-ipv6' => 'good', + }, + }, + { + desc => 'legacy, fresh, usev4=webv4 usev6=webv6', + ipv6 => 1, + cfg => { + 'protocol' => 'legacy', + 'usev4' => 'webv4', + 'usev6' => 'webv6', + }, + want_update => 1, + want_recap_changes => { + 'atime' => $ddclient::now, + 'ipv4' => '192.0.2.1', + 'mtime' => $ddclient::now, + 'status-ipv4' => 'good', + }, + want_cfg_changes => { + 'atime' => $ddclient::now, + 'ipv4' => '192.0.2.1', + 'mtime' => $ddclient::now, + 'status-ipv4' => 'good', + }, + }, + map({ + my %cfg = %{delete($_->{cfg})}; + my $desc = join(' ', map("$_=$cfg{$_}", keys(%cfg))); + { + desc => "legacy, no change, not yet time, $desc", + recap => { + 'atime' => $ddclient::now - ddclient::opt('min-interval'), + 'ipv4' => '192.0.2.1', + 'mtime' => $ddclient::now - ddclient::opt('min-interval'), + 'status-ipv4' => 'good', + }, + cfg => { + 'protocol' => 'legacy', + %cfg, + }, + %$_, + }; + } {cfg => {use => 'web'}}, {cfg => {usev4 => 'webv4'}}), + map({ + my %cfg = %{delete($_->{cfg})}; + my $desc = join(' ', map("$_=$cfg{$_}", keys(%cfg))); + { + desc => "legacy, min-interval elapsed but no change, $desc", + recap => { + 'atime' => $ddclient::now - ddclient::opt('min-interval') - 1, + 'ipv4' => '192.0.2.1', + 'mtime' => $ddclient::now - ddclient::opt('min-interval') - 1, + 'status-ipv4' => 'good', + }, + cfg => { + 'protocol' => 'legacy', + %cfg, + }, + %$_, + }; + } {cfg => {use => 'web'}}, {cfg => {usev4 => 'webv4'}}), + map({ + my %cfg = %{delete($_->{cfg})}; + my $desc = join(' ', map("$_=$cfg{$_}", keys(%cfg))); + { + desc => "legacy, needs update, not yet time, $desc", + recap => { + 'atime' => $ddclient::now - ddclient::opt('min-interval'), + 'ipv4' => '192.0.2.2', + 'mtime' => $ddclient::now - ddclient::opt('min-interval'), + 'status-ipv4' => 'good', + }, + cfg => { + 'protocol' => 'legacy', + %cfg, + }, + want_recap_changes => { + 'warned-min-interval' => $ddclient::now, + }, + %$_, + }; + } {cfg => {use => 'web'}}, {cfg => {usev4 => 'webv4'}}), + map({ + my %cfg = %{delete($_->{cfg})}; + my $desc = join(' ', map("$_=$cfg{$_}", keys(%cfg))); + { + desc => "legacy, min-interval elapsed, needs update, $desc", + recap => { + 'atime' => $ddclient::now - ddclient::opt('min-interval') - 1, + 'ipv4' => '192.0.2.2', + 'mtime' => $ddclient::now - ddclient::opt('min-interval') - 1, + 'status-ipv4' => 'good', + }, + cfg => { + 'protocol' => 'legacy', + %cfg, + }, + want_update => 1, + want_recap_changes => { + 'atime' => $ddclient::now, + 'ipv4' => '192.0.2.1', + 'mtime' => $ddclient::now, + }, + want_cfg_changes => { + 'atime' => $ddclient::now, + 'ipv4' => '192.0.2.1', + 'mtime' => $ddclient::now, + }, + %$_, + }; + } {cfg => {use => 'web'}}, {cfg => {usev4 => 'webv4'}}), + map({ + my %cfg = %{delete($_->{cfg})}; + my $desc = join(' ', map("$_=$cfg{$_}", keys(%cfg))); + { + desc => "legacy, previous failed update, not yet time to retry, $desc", + recap => { + 'atime' => $ddclient::now - ddclient::opt('min-error-interval'), + 'ipv4' => '192.0.2.2', + 'mtime' => $ddclient::now - max(ddclient::opt('min-error-interval'), + ddclient::opt('min-interval')) - 1, + 'status-ipv4' => 'failed', + }, + cfg => { + 'protocol' => 'legacy', + %cfg, + }, + want_recap_changes => { + 'warned-min-error-interval' => $ddclient::now, + }, + %$_, + }; + } {cfg => {use => 'web'}}, {cfg => {usev4 => 'webv4'}}), + map({ + my %cfg = %{delete($_->{cfg})}; + my $desc = join(' ', map("$_=$cfg{$_}", keys(%cfg))); + { + desc => "legacy, previous failed update, time to retry, $desc", + recap => { + 'atime' => $ddclient::now - ddclient::opt('min-error-interval') - 1, + 'ipv4' => '192.0.2.2', + 'mtime' => $ddclient::now - ddclient::opt('min-error-interval') - 2, + 'status-ipv4' => 'failed', + }, + cfg => { + 'protocol' => 'legacy', + %cfg, + }, + want_update => 1, + want_recap_changes => { + 'atime' => $ddclient::now, + 'ipv4' => '192.0.2.1', + 'mtime' => $ddclient::now, + 'status-ipv4' => 'good', + }, + want_cfg_changes => { + 'atime' => $ddclient::now, + 'ipv4' => '192.0.2.1', + 'mtime' => $ddclient::now, + 'status-ipv4' => 'good', + }, + %$_, + }; + } {cfg => {use => 'web'}}, {cfg => {usev4 => 'webv4'}}), +); + +for my $tc (@test_cases) { + SKIP: { + skip("IPv6 not supported on this system", 1) if $tc->{ipv6} && !$ipv6_supported; + skip("HTTP::Daemon too old for IPv6 support", 1) + if $tc->{ipv6} && !$http_daemon_supports_ipv6; + subtest($tc->{desc} => sub { + local $ddclient::_l = ddclient::pushlogctx($tc->{desc}); + # Copy %{$tc->{recap}} so that updates to $recap{$h} don't update %{$tc->{recap}}. + local %ddclient::recap = (host => {%{$tc->{recap} // {}}}); + my $cachef = File::Temp->new(); + # $cachef is an object that stringifies to a filename. + local $ddclient::globals{cache} = "$cachef"; + my %cfg = ( + %{$tc->{recap} // {}}, # Simulate a previous update. + web => 'v4', + webv4 => 'v4', + webv6 => 'v6', + %{$tc->{cfg} // {}}, + ); + # Copy %cfg so that updates to $config{$h} don't update %cfg. + local %ddclient::config = (host => {%cfg}); + local @updates; + + ddclient::update_nics(); + + TODO: { + local $TODO = $tc->{want_update_TODO}; + is_deeply(\@updates, [(['host']) x ($tc->{want_update} ? 1 : 0)], + 'got expected update'); + } + my %want_recap = (host => { + %{$tc->{recap} // {}}, + %{$tc->{want_recap_changes} // {}}, + }); + TODO: { + local $TODO = $tc->{want_recap_changes_TODO}; + is_deeply(\%ddclient::recap, \%want_recap, 'recap matches') + or diag(ddclient::repr(Values => [\%ddclient::recap, \%want_recap], + Names => ['*got', '*want'])); + } + my %want_cfg = (host => { + $tc->{want_update} ? (update => 1) : (), + %cfg, + %{$tc->{want_cfg_changes} // {}}, + }); + TODO: { + local $TODO = $tc->{want_cfg_changes_TODO}; + is_deeply(\%ddclient::config, \%want_cfg, 'config matches') + or diag(ddclient::repr(Values => [\%ddclient::config, \%want_cfg], + Names => ['*got', '*want'])); + } + }); + } +} + +done_testing(); diff --git a/t/variable_defaults.pl b/t/variable_defaults.pl index 09dc92c..8d82347 100644 --- a/t/variable_defaults.pl +++ b/t/variable_defaults.pl @@ -1,4 +1,5 @@ use Test::More; +use re qw(is_regexp); SKIP: { eval { require Test::Warnings; } or skip($@, 1); } eval { require 'ddclient'; } or BAIL_OUT($@); @@ -23,10 +24,78 @@ for my $tc (@test_cases) { if ($tc->{def}{required}) { is($tc->{def}{default}, undef, "'$tc->{desc}' (required) has no default"); } else { + # Preserve all existing variables in $variables{merged} so that variables with dynamic + # defaults can reference them. + local %ddclient::variables = (merged => { + %{$ddclient::variables{merged}}, + 'var for test' => $tc->{def}, + }); + # Variables with dynamic defaults will need their own unit tests, but we can still check the + # clean-slate hostless default. + local %ddclient::config; + local %ddclient::opt; + local %ddclient::globals; my $norm; - my $valid = eval { $norm = ddclient::check_value($tc->{def}{default}, $tc->{def}); 1; }; + my $default = ddclient::default('var for test'); + diag("'$tc->{desc}' default: " . ($default // '')); + is($default, $tc->{def}{default}, "'$tc->{desc}' default() return value matches default") + if ref($tc->{def}{default}) ne 'CODE'; + my $valid = eval { $norm = ddclient::check_value($default, $tc->{def}); 1; } or diag($@); ok($valid, "'$tc->{desc}' (optional) has a valid default"); - is($norm, $tc->{def}{default}, "'$tc->{desc}' default normalizes to itself") if $valid; + is($norm, $default, "'$tc->{desc}' default normalizes to itself") if $valid; } } + +my @use_test_cases = ( + { + desc => 'clean slate hostless default', + want => 'ip', + }, + { + desc => 'usage string', + host => '', + want => qr/disabled.*ip|ip.*disabled/, + }, + { + desc => 'usev4 disables use by default', + host => 'host', + cfg => {usev4 => 'webv4'}, + want => 'disabled', + }, + { + desc => 'usev6 disables use by default', + host => 'host', + cfg => {usev4 => 'webv4'}, + want => 'disabled', + }, + { + desc => 'explicitly setting use re-enables it', + host => 'host', + cfg => {use => 'web', usev4 => 'webv4'}, + want => 'web', + }, +); +for my $tc (@use_test_cases) { + my $desc = "'use' dynamic default: $tc->{desc}"; + local %ddclient::protocols = + (protocol => {variables => $ddclient::variables{'protocol-common-defaults'}}); + local %ddclient::variables = (merged => { + 'protocol' => $ddclient::variables{'merged'}{'protocol'}, + 'use' => $ddclient::variables{'protocol-common-defaults'}{'use'}, + 'usev4' => $ddclient::variables{'merged'}{'usev4'}, + 'usev6' => $ddclient::variables{'merged'}{'usev6'}, + }); + local %ddclient::config = (host => {protocol => 'protocol', %{$tc->{cfg} // {}}}); + local %ddclient::opt; + local %ddclient::globals; + + my $got = ddclient::opt('use', $tc->{host}); + + if (is_regexp($tc->{want})) { + like($got, $tc->{want}, $desc); + } else { + is($got, $tc->{want}, $desc); + } +} + done_testing();