Merge pull request #674 from jortkoopmans/bugfix/673_Fix_DnsExit_subdomain

Fix DNSExit provider when provided with a zone and a non-identical hostname
This commit is contained in:
Richard Hansen 2024-06-02 16:58:43 -04:00 committed by GitHub
commit 09ce262c82
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 316 additions and 90 deletions

View file

@ -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

View file

@ -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

View file

@ -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 \

View file

@ -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

View file

@ -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,99 +4175,113 @@ 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;
# 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 => $h,
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);
last;
};
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'}));
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'}));
}
}

203
t/dnsexit2.pl Normal file
View file

@ -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();