diff --git a/ChangeLog.md b/ChangeLog.md index 13d1d10..9e8ed3a 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -90,6 +90,8 @@ repository history](https://github.com/ddclient/ddclient/commits/master). [#719](https://github.com/ddclient/ddclient/pull/719) * `duckdns`: Multiple hosts with the same IP address are now updated together. [#719](https://github.com/ddclient/ddclient/pull/719) + * `directnic`: Added support for updatng Directnic records. + [#726](https://github.com/ddclient/ddclient/pull/726) ### Bug fixes diff --git a/Makefile.am b/Makefile.am index 1d2e37e..a9d782b 100644 --- a/Makefile.am +++ b/Makefile.am @@ -75,6 +75,7 @@ handwritten_tests = \ t/is-and-extract-ipv6-global.pl \ t/logmsg.pl \ t/parse_assignments.pl \ + t/protocol_directnic.pl \ t/protocol_dnsexit2.pl \ t/protocol_dyndns2.pl \ t/skip.pl \ diff --git a/README.md b/README.md index 73b6723..22db499 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Dynamic DNS services currently supported include: * [DDNS.fm](https://www.ddns.fm/) * [DigitalOcean](https://www.digitalocean.com/) * [dinahosting](https://dinahosting.com) + * [Directnic](https://directnic.com) * [DonDominio](https://www.dondominio.com) * [DNS Made Easy](https://dnsmadeeasy.com) * [DNSExit](https://dnsexit.com/dns/dns-api) diff --git a/ddclient.conf.in b/ddclient.conf.in index 6704396..dd4189e 100644 --- a/ddclient.conf.in +++ b/ddclient.conf.in @@ -365,6 +365,14 @@ pid=@runstatedir@/ddclient.pid # record PID in file. # password=api-token \ # example.com,sub.example.com +## +## Directnic (directnic.com) +## +# protocol=directnic, +# urlv4=https://directnic.com/dns/gateway/ipv4_token/ +# urlv6=https://directnic.com/dns/gateway/ipv6_token/ +# my-domain.com + ## ## Infomaniak (www.infomaniak.com) ## diff --git a/ddclient.in b/ddclient.in index 9e3bd5a..4b4e1b7 100755 --- a/ddclient.in +++ b/ddclient.in @@ -782,6 +782,17 @@ our %protocols = ( 'server' => setv(T_FQDNP, 0, 0, 'dinahosting.com', undef), }, }, + 'directnic' => { + 'update' => \&nic_directnic_update, + 'examples' => \&nic_directnic_examples, + 'variables' => { + %{$variables{'protocol-common-defaults'}}, + 'login' => undef, + 'password' => undef, + 'urlv4' => setv(T_URL, 0, 0, undef, undef), + 'urlv6' => setv(T_URL, 0, 0, undef, undef), + }, + }, 'dnsmadeeasy' => { 'update' => \&nic_dnsmadeeasy_update, 'examples' => \&nic_dnsmadeeasy_examples, @@ -1996,7 +2007,7 @@ sub init_config { 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_json_support($proto) if (grep($_ eq $proto, ("1984", "cloudflare", "digitalocean", "directnic", "gandi", "godaddy", "hetzner", "yandex", "nfsn", "njalla", "porkbun", "dnsexit2"))); if (!exists($protocols{$proto})) { warning("skipping host: %s: unrecognized protocol '%s'", $h, $proto); @@ -6877,6 +6888,72 @@ sub nic_dinahosting_update { } } +###################################################################### +## nic_directnic_examples +###################################################################### +sub nic_directnic_examples { + return <<"EoEXAMPLE"; +o 'directnic' + +The 'directnic' protocol is used by directnic (https://directnic.com). +Details about the API can be found at https://directnic.com/knowledge#/knowledge/article/3726. + +You must specify at least one of the following variables: + * urlv4=https://directnic.com/dns/gateway/ad133743f001e318e455fdc05/ the URL to use to update the A record + * urlv6=https://directnic.com/dns/gateway/ad133743f001e318e455fdc06/ the URL to use to update the AAAA record + +urlv4 is required when updating an IPv4 record, and urlv6 is required when updating an IPv6 record. + +Example ${program}.conf file entry: + protocol=directnic, \\ + urlv4=https://directnic.com/dns/gateway/ad133743f001e318e455fdc05/ \\ + urlv6=https://directnic.com/dns/gateway/ad133743f001e318e455fdc06/ \\ + myhost.mydomain.com +EoEXAMPLE +} + +###################################################################### +## nic_directnic_update +###################################################################### +sub nic_directnic_update { + for my $h (@_) { + local $_l = pushlogctx($h); + for my $ipv ('4', '6') { + my $ip = delete $config{$h}{"wantipv$ipv"} or next; + info("setting IPv$ipv address to $ip"); + + my $url = $config{$h}{"urlv$ipv"}; + if (!defined($url)) { + failed("missing urlv$ipv option"); + next; + } + + $url .= "?data=$ip"; + my $reply = geturl(proxy => opt('proxy'), url => $url); + next if !header_ok($reply); + + (my $body = $reply) =~ s/^.*?\n\n//s; + my $response = eval {decode_json($body)}; + if (ref($response) ne 'HASH') { + $config{$h}{"status-ipv$ipv"} = 'bad'; + failed("response is not a JSON object:\n$body"); + next; + } + + if ($response->{'result'} ne 'success') { + $config{$h}{"status-ipv$ipv"} = 'failed'; + failed("server said:\n$body"); + next; + } + + $config{$h}{"ipv$ipv"} = $ip; + $config{$h}{"status-ipv$ipv"} = 'good'; + $config{$h}{'mtime'} = $now; + success("IPv$ipv address set to $ip"); + } + } +} + ###################################################################### ## nic_gandi_examples ## by Jimmy Thrasibule diff --git a/t/protocol_directnic.pl b/t/protocol_directnic.pl new file mode 100644 index 0000000..d6c8e54 --- /dev/null +++ b/t/protocol_directnic.pl @@ -0,0 +1,192 @@ +use Test::More; +eval { require JSON::PP; } or plan(skip_all => $@); +JSON::PP->import(qw(encode_json)); +eval { require ddclient::Test::Fake::HTTPD; } or plan(skip_all => $@); +SKIP: { eval { require Test::Warnings; } or skip($@, 1); } +eval { require 'ddclient'; } or BAIL_OUT($@); + +ddclient::load_json_support('directnic'); + +my $httpd = ddclient::Test::Fake::HTTPD->new(); +$httpd->run(sub { + my ($req) = @_; + diag('=============================================================================='); + diag("Test server received request:\n" . $req->as_string()); + my $headers = ['content-type' => 'text/plain; charset=utf-8']; + if ($req->uri->as_string =~ m/\/dns\/gateway\/(abc|def)\/\?data=([^&]*)/) { + return [200, ['Content-Type' => 'application/json'], [encode_json({ + result => 'success', + message => "Your record was updated to $2", + })]]; + } elsif ($req->uri->as_string =~ m/\/dns\/gateway\/bad_token\/\?data=([^&]*)/) { + return [200, ['Content-Type' => 'application/json'], [encode_json({ + result => 'error', + message => "There was an error updating your record.", + })]]; + } elsif ($req->uri->as_string =~ m/\/bad\/path\/\?data=([^&]*)/) { + return [200, ['Content-Type' => 'application/json'], ['unexpected response body']]; + } + return [400, $headers, ['unexpected request: ' . $req->uri()]] +}); +diag("started IPv4 HTTP server running at " . $httpd->endpoint()); + +{ + package Logger; + BEGIN { push(our @ISA, qw(ddclient::Logger)); } + sub new { + my ($class, $parent) = @_; + my $self = $class->SUPER::new(undef, $parent); + $self->{logs} = []; + return $self; + } + sub _log { + my ($self, $args) = @_; + push(@{$self->{logs}}, $args) + if ($args->{label} // '') =~ qr/^(?:WARNING|FATAL|SUCCESS|FAILED)$/; + return $self->SUPER::_log($args); + } +} + +my $hostname = $httpd->endpoint(); +my @test_cases = ( + { + desc => 'IPv4, good', + cfg => {h1 => {urlv4 => "$hostname/dns/gateway/abc/", wantipv4 => '192.0.2.1'}}, + wantstatus => { + h1 => {'status-ipv4' => 'good', 'ipv4' => '192.0.2.1', 'mtime' => $ddclient::now}, + }, + wantlogs => [ + {label => 'SUCCESS', ctx => ['h1'], msg => qr/IPv4/}, + ], + }, + { + desc => 'IPv4, failed', + cfg => {h1 => {urlv4 => "$hostname/dns/gateway/bad_token/", wantipv4 => '192.0.2.1'}}, + wantstatus => { + h1 => {'status-ipv4' => 'failed'}, + }, + wantlogs => [ + {label => 'FAILED', ctx => ['h1'], msg => qr/There was an error updating your record/}, + ], + }, + { + desc => 'IPv4, bad', + cfg => {h1 => {urlv4 => "$hostname/bad/path/", wantipv4 => '192.0.2.1'}}, + wantstatus => { + h1 => {'status-ipv4' => 'bad'}, + }, + wantlogs => [ + {label => 'FAILED', ctx => ['h1'], msg => qr/response is not a JSON object:\nunexpected response body/}, + ], + }, + { + desc => 'IPv4, unexpected response', + cfg => {h1 => {urlv4 => "$hostname/unexpected/path/", wantipv4 => '192.0.2.1'}}, + wantstatus => {h1 => {}}, + wantlogs => [ + {label => 'FAILED', ctx => ['h1'], msg => qr/400 Bad Request/}, + ], + }, + { + desc => 'IPv4, no urlv4', + cfg => {h1 => {wantipv4 => '192.0.2.1'}}, + wantstatus => {h1 => {}}, + wantlogs => [ + {label => 'FAILED', ctx => ['h1'], msg => qr/missing urlv4 option/}, + ], + }, + { + desc => 'IPv6, good', + cfg => {h1 => {urlv6 => "$hostname/dns/gateway/abc/", wantipv6 => '2001:db8::1'}}, + wantstatus => { + h1 => {'status-ipv6' => 'good', 'ipv6' => '2001:db8::1', 'mtime' => $ddclient::now}, + }, + wantlogs => [ + {label => 'SUCCESS', ctx => ['h1'], msg => qr/IPv6/}, + ], + }, + { + desc => 'IPv4 and IPv6, good', + cfg => {h1 => { + urlv4 => "$hostname/dns/gateway/abc/", + urlv6 => "$hostname/dns/gateway/def/", + wantipv4 => '192.0.2.1', + wantipv6 => '2001:db8::1', + }}, + wantstatus => { + h1 => {'status-ipv4' => 'good', 'ipv4' => '192.0.2.1', + 'status-ipv6' => 'good', 'ipv6' => '2001:db8::1', + 'mtime' => $ddclient::now}, + }, + wantlogs => [ + {label => 'SUCCESS', ctx => ['h1'], msg => qr/IPv4/}, + {label => 'SUCCESS', ctx => ['h1'], msg => qr/IPv6/}, + ], + }, + { + desc => 'IPv4 and IPv6, mixed success', + cfg => {h1 => { + urlv4 => "$hostname/dns/gateway/bad_token/", + urlv6 => "$hostname/dns/gateway/def/", + wantipv4 => '192.0.2.1', + wantipv6 => '2001:db8::1', + }}, + wantips => {h1 => {wantipv4 => '192.0.2.1', wantipv6 => '2001:db8::1'}}, + wantstatus => { + h1 => {'status-ipv4' => 'failed', + 'status-ipv6' => 'good', 'ipv6' => '2001:db8::1', + 'mtime' => $ddclient::now}, + }, + wantlogs => [ + {label => 'FAILED', ctx => ['h1'], msg => qr/There was an error updating your record/}, + {label => 'SUCCESS', ctx => ['h1'], msg => qr/IPv6/}, + ], + }, +); + +for my $tc (@test_cases) { + diag('=============================================================================='); + diag("Starting test: $tc->{desc}"); + diag('=============================================================================='); + local $ddclient::globals{debug} = 1; + local $ddclient::globals{verbose} = 1; + my $l = Logger->new($ddclient::_l); + local %ddclient::config = %{$tc->{cfg}}; + { + local $ddclient::_l = $l; + ddclient::nic_directnic_update(sort(keys(%{$tc->{cfg}}))); + } + # These are the properties in %ddclient::config to check against $tc->{wantstatus}. + my %statuskeys = map(($_ => undef), qw(atime ip ipv4 ipv6 mtime status status-ipv4 status-ipv6 + wantip wantipv4 wantipv6 wtime)); + my %gotstatus; + for my $h (keys(%ddclient::config)) { + $gotstatus{$h} = {map(($_ => $ddclient::config{$h}{$_}), + grep(exists($statuskeys{$_}), keys(%{$ddclient::config{$h}})))}; + } + is_deeply(\%gotstatus, $tc->{wantstatus}, "$tc->{desc}: status") + or diag(ddclient::repr(\%ddclient::config, Names => ['*ddclient::config'])); + $tc->{wantlogs} //= []; + subtest("$tc->{desc}: logs" => sub { + my @got = @{$l->{logs}}; + my @want = @{$tc->{wantlogs}}; + for my $i (0..$#want) { + last if $i >= @got; + my $got = $got[$i]; + my $want = $want[$i]; + subtest("log $i" => sub { + is($got->{label}, $want->{label}, "label matches"); + is_deeply($got->{ctx}, $want->{ctx}, "context matches"); + like($got->{msg}, $want->{msg}, "message matches"); + }) or diag(ddclient::repr(Values => [$got, $want], Names => ['*got', '*want'])); + } + my @unexpected = @got[@want..$#got]; + ok(@unexpected == 0, "no unexpected logs") + or diag(ddclient::repr(\@unexpected, Names => ['*unexpected'])); + my @missing = @want[@got..$#want]; + ok(@missing == 0, "no missing logs") + or diag(ddclient::repr(\@missing, Names => ['*missing'])); + }); +} + +done_testing();