From ec2d5f7f6951f797ae5ea54a1b6ada84dcad202f Mon Sep 17 00:00:00 2001 From: jortkoopmans Date: Mon, 27 May 2024 00:42:40 +0200 Subject: [PATCH 1/4] dnsexit2: Add tests Needs LWP::UserAgent. --- .github/workflows/ci.yml | 2 + Makefile.am | 1 + configure.ac | 2 + t/dnsexit2.pl | 212 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 217 insertions(+) create mode 100644 t/dnsexit2.pl diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de4d443..d4cd166 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 \ ; @@ -102,6 +103,7 @@ jobs: perl-Test-TCP \ perl-Test-Warnings \ perl-core \ + perl-libwww-perl \ net-tools \ ; - name: autogen diff --git a/Makefile.am b/Makefile.am index 5b61f08..fc78825 100644 --- a/Makefile.am +++ b/Makefile.am @@ -63,6 +63,7 @@ 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/group_hosts_by.pl \ diff --git a/configure.ac b/configure.ac index d1fc02c..649b7e3 100644 --- a/configure.ac +++ b/configure.ac @@ -80,6 +80,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/t/dnsexit2.pl b/t/dnsexit2.pl new file mode 100644 index 0000000..ca25e31 --- /dev/null +++ b/t/dnsexit2.pl @@ -0,0 +1,212 @@ +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, + } + ] + }); + TODO: { + local $TODO = "https://github.com/ddclient/ddclient/issues/673"; + 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, + } + ] + }); + TODO: { + local $TODO = "https://github.com/ddclient/ddclient/issues/673"; + 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}); + TODO: { + local $TODO = "https://github.com/ddclient/ddclient/issues/673"; + 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(); From 216741c9ceb32f1c80b04378f6387db9c33af8f9 Mon Sep 17 00:00:00 2001 From: jortkoopmans Date: Mon, 27 May 2024 00:42:40 +0200 Subject: [PATCH 2/4] dnsexit2: Fix when provided with a zone and a non-identical hostname Trim the zone from the hostname in the request to fix issue. --- ChangeLog.md | 2 ++ ddclient.in | 17 ++++++++++++++--- t/dnsexit2.pl | 17 ++++------------- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/ChangeLog.md b/ChangeLog.md index 5a9bfae..f376ad9 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -75,6 +75,8 @@ 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) ## 2023-11-23 v3.11.2 diff --git a/ddclient.in b/ddclient.in index f686da5..bcd1197 100755 --- a/ddclient.in +++ b/ddclient.in @@ -4140,6 +4140,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 ## @@ -4173,10 +4175,19 @@ EoEXAMPLE ###################################################################### sub nic_dnsexit2_update { debug("\nnic_dnsexit2_update -------------------"); - - # The DNSExit API does not support updating multiple hosts at a time. + # 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; + 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; @@ -4186,7 +4197,7 @@ sub nic_dnsexit2_update { info("Going to update IPv%s address to %s for %s.", $ipv, $ip, $h); $config{$h}{"status-ipv$ipv"} = 'failed'; push(@updates, { - name => $h, + name => $name, type => ($ipv eq '6') ? 'AAAA' : 'A', content => $ip, ttl => $config{$h}{'ttl'}, diff --git a/t/dnsexit2.pl b/t/dnsexit2.pl index ca25e31..b32c688 100644 --- a/t/dnsexit2.pl +++ b/t/dnsexit2.pl @@ -104,10 +104,7 @@ subtest 'Testing nic_dnsexit2_update' => sub { } ] }); - TODO: { - local $TODO = "https://github.com/ddclient/ddclient/issues/673"; - is_deeply($data, $expected_data, 'Data is correct'); - } + is_deeply($data, $expected_data, 'Data is correct'); reset_test_data(); }; @@ -139,10 +136,7 @@ subtest 'Testing nic_dnsexit2_update without a zone set' => sub { } ] }); - TODO: { - local $TODO = "https://github.com/ddclient/ddclient/issues/673"; - is_deeply($data, $expected_data, 'Data is correct'); - } + is_deeply($data, $expected_data, 'Data is correct'); reset_test_data($ua); }; @@ -200,11 +194,8 @@ subtest 'Testing nic_dnsexit2_update with two hostnames, one with a zone and one @requests = get_requests(); for my $i (0..1) { my $data = decode_and_sort_array($requests[$i]->{content}); - TODO: { - local $TODO = "https://github.com/ddclient/ddclient/issues/673"; - 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; - } + 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(); }; From 11d0c84639be90178310cd11cca3dcf0cf34b838 Mon Sep 17 00:00:00 2001 From: Richard Hansen Date: Fri, 31 May 2024 19:03:04 -0400 Subject: [PATCH 3/4] dnsexit2: Don't skip remaining hosts on connect error or non-2xx A non-2xx status code might be host-specific, so ddclient should continue with the next host. We could skip the remaining hosts if there is a connection failure, but it doesn't hurt to retry. --- ddclient.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ddclient.in b/ddclient.in index bcd1197..46ed478 100755 --- a/ddclient.in +++ b/ddclient.in @@ -4217,7 +4217,7 @@ sub nic_dnsexit2_update { ); unless ($reply && header_ok($h, $reply)) { failed("updating %s: Could not connect to %s", $h, $url); - last; + next; }; debug("%s", $reply); (my $http_status) = ($reply =~ m%^s*HTTP/.*\s+(\d+)%i); From 73a67b728d1dc0875666e75c21a31aba96fec7c3 Mon Sep 17 00:00:00 2001 From: jortkoopmans Date: Mon, 27 May 2024 00:42:40 +0200 Subject: [PATCH 4/4] dnsexit2: Move body of `for` loop to a separate function For improved readability. --- ddclient.in | 197 +++++++++++++++++++++++++++------------------------- 1 file changed, 101 insertions(+), 96 deletions(-) diff --git a/ddclient.in b/ddclient.in index 46ed478..53115d0 100755 --- a/ddclient.in +++ b/ddclient.in @@ -4179,104 +4179,109 @@ sub nic_dnsexit2_update { # handling update per host. for my $h (@_) { $config{$h}{'zone'} //= $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. + 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 { - 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); - next; - }; - 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'); - next; - } - my $body = ($reply =~ s/^.*?\r?\n\r?\n//sr); - my $response = eval { decode_json($body); }; - if (!$response) { - failed("failed to parse response: $@"); - next; - } - if (!defined($response->{'code'}) || !defined($response->{'message'})) { - failed("Did not receive expected 'code' and 'message' keys in server response:\n%s", - $body); - next; - } - 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'}); - next; - } - 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); - } - next; - } - 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'})); + 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'})); } }