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:
commit
09ce262c82
6 changed files with 316 additions and 90 deletions
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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 \
|
||||
|
|
|
@ -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
|
||||
|
|
196
ddclient.in
196
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,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
203
t/dnsexit2.pl
Normal 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();
|
Loading…
Reference in a new issue