Compare commits

...

2 commits

Author SHA1 Message Date
d8f547d1e7 Update tests
All checks were successful
ci/woodpecker/push/build Pipeline was successful
2024-11-29 14:32:33 +01:00
115eeec47d Implement updating of DuckDNS 2024-11-29 13:54:55 +01:00
11 changed files with 276 additions and 40 deletions

View file

@ -1,7 +1,9 @@
# ddnsclient # ddnsclient
This application can be use to update dynamic hostnames. Currently it implements only the ddnss.de provider, but it This application can be use to update dynamic hostnames. Currently it implements the following DynDNS providers:
is extensible and more providers can be added easily.
- ddnss.de
- duckdns.org
This project uses Quarkus. This project uses Quarkus.

View file

@ -45,7 +45,7 @@ public class Updater {
} }
void run() { void run() {
log.trace("Updater running."); log.info("Updater running.");
if (config.isEmpty()) { if (config.isEmpty()) {
throw new IllegalStateException("Missing configuration"); throw new IllegalStateException("Missing configuration");
@ -58,7 +58,7 @@ public class Updater {
if (updateMap.containsKey(cfg.hostname()) if (updateMap.containsKey(cfg.hostname())
&& Duration.between(updateMap.get(cfg.hostname()), LocalDateTime.now()).toSeconds() < backoff.toSeconds()) { && Duration.between(updateMap.get(cfg.hostname()), LocalDateTime.now()).toSeconds() < backoff.toSeconds()) {
log.debug("Back-off period, skipping update."); log.info("Back-off period, skipping update.");
return; return;
} }
@ -73,7 +73,7 @@ public class Updater {
dynDns.update(cfg.hostname(), publicIps, new DyndnsAuth(null, null, cfg.token())); dynDns.update(cfg.hostname(), publicIps, new DyndnsAuth(null, null, cfg.token()));
updateMap.put(cfg.hostname(), LocalDateTime.now()); updateMap.put(cfg.hostname(), LocalDateTime.now());
} else { } else {
log.debugf("Hostname is up-to-date.", cfg.hostname()); log.infof("Hostname %s is up-to-date.", cfg.hostname());
} }
}); });
} }

View file

@ -6,9 +6,7 @@ import jakarta.enterprise.context.ApplicationScoped;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import java.io.IOException; import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient; import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.text.MessageFormat; import java.text.MessageFormat;
@ -29,26 +27,29 @@ public class Ddnss implements DynDns {
} }
public void update(String hostname, IPs currentIps, DyndnsAuth auth) { public void update(String hostname, IPs currentIps, DyndnsAuth auth) {
log.tracef("Updating ddnss hostname %s", hostname);
String updateUrl = MessageFormat.format("https://ddnss.de/upd.php?key={0}&host={1}&ip={2}&ip6={3}",
auth.token(), hostname, currentIps.v4(), currentIps.v6());
try (HttpClient http = httpClientFactory.create()) { try (HttpClient http = httpClientFactory.create()) {
String updateUrl = getUpdateUrl(hostname, currentIps, auth.token());
HttpRequest request = HttpRequest.newBuilder() HttpResponse<String> response = http.send(getRequest(updateUrl), HttpResponse.BodyHandlers.ofString());
.header("User-Agent", "ddns-client")
.GET()
.uri(URI.create(updateUrl))
.build();
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) { if (response.statusCode() != 200) {
log.errorf("Couldn't update hostname %s." + hostname); log.errorf("Couldn't update hostname %s." + hostname);
} }
log.info("Hostname updated."); log.infof("Hostname %s updated.", hostname);
} catch (IOException | InterruptedException e) { } catch (IOException | InterruptedException e) {
throw new RuntimeException(e); throw new RuntimeException(e);
} }
} }
private String getUpdateUrl(String hostname, IPs currentIps, String token) {
if (currentIps.v4().isPresent() && currentIps.v6().isPresent()) {
return MessageFormat.format("https://ddnss.de/upd.php?key={0}&host={1}&ip={2}&ip6={3}", token, hostname, currentIps.v4().get(), currentIps.v6().get());
} else if (currentIps.v4().isPresent()) {
return MessageFormat.format("https://ddnss.de/upd.php?key={0}&host={1}&ip={2}", token, hostname, currentIps.v4().get());
} else if (currentIps.v6().isPresent()) {
return MessageFormat.format("https://ddnss.de/upd.php?key={0}&host={1}&ip6={2}", token, hostname, currentIps.v6().get());
}
throw new RuntimeException("No ips to update!");
}
} }

View file

@ -0,0 +1,58 @@
package de.rpr.ddnsclient.dyndns;
import de.rpr.ddnsclient.model.DyndnsAuth;
import de.rpr.ddnsclient.model.IPs;
import jakarta.enterprise.context.ApplicationScoped;
import org.jboss.logging.Logger;
import java.io.IOException;
import java.net.http.HttpClient;
import java.net.http.HttpResponse;
import java.text.MessageFormat;
@ApplicationScoped
public class DuckDNS implements DynDns {
private static final Logger log = Logger.getLogger(DuckDNS.class);
private final HttpClientFactory httpClientFactory;
public DuckDNS(HttpClientFactory httpClientFactory) {
this.httpClientFactory = httpClientFactory;
}
@Override
public String name() {
return "duckdns";
}
@Override
public void update(String hostname, IPs currentIps, DyndnsAuth auth) {
log.tracef("Updating duckdns hostname %s", hostname);
try (HttpClient http = httpClientFactory.create()) {
String updateUrl = getUpdateUrl(hostname, currentIps, auth.token());
HttpResponse<String> response = http.send(getRequest(updateUrl), HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
log.errorf("Couldn't update hostname %s." + hostname);
} else if (!response.body().equals("OK")) {
throw new RuntimeException("Couldn't update hostname!");
}
log.infof("Hostname %s updated.", hostname);
} catch (IOException | InterruptedException e) {
throw new RuntimeException(e);
}
}
private String getUpdateUrl(String hostname, IPs currentIps, String token) {
if (currentIps.v4().isPresent() && currentIps.v6().isPresent()) {
return MessageFormat.format("https://www.duckdns.org/update?domains={0}&token={1}&ip={2}&ipv6={3}", hostname, token, currentIps.v4().get(), currentIps.v6().get());
} else if (currentIps.v4().isPresent()) {
return MessageFormat.format("https://www.duckdns.org/update?domains={0}&token={1}&ip={2}", hostname, token, currentIps.v4().get());
} else if (currentIps.v6().isPresent()) {
return MessageFormat.format("https://www.duckdns.org/update?domains={0}&token={1}&ipv6={2}", hostname, token, currentIps.v6().get());
}
throw new RuntimeException("No ips to update!");
}
}

View file

@ -3,9 +3,20 @@ package de.rpr.ddnsclient.dyndns;
import de.rpr.ddnsclient.model.DyndnsAuth; import de.rpr.ddnsclient.model.DyndnsAuth;
import de.rpr.ddnsclient.model.IPs; import de.rpr.ddnsclient.model.IPs;
import java.net.URI;
import java.net.http.HttpRequest;
public interface DynDns { public interface DynDns {
String name(); String name();
void update(String hostname, IPs currentIps, DyndnsAuth auth); void update(String hostname, IPs currentIps, DyndnsAuth auth);
default HttpRequest getRequest(String updateUrl) {
return HttpRequest.newBuilder()
.header("User-Agent", "ddns-client")
.GET()
.uri(URI.create(updateUrl))
.build();
}
} }

View file

@ -3,6 +3,7 @@ package de.rpr.ddnsclient.lookup;
import de.rpr.ddnsclient.model.IPs; import de.rpr.ddnsclient.model.IPs;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import org.xbill.DNS.Record; import org.xbill.DNS.Record;
import org.xbill.DNS.*; import org.xbill.DNS.*;
@ -11,6 +12,8 @@ import java.util.Optional;
@ApplicationScoped @ApplicationScoped
public class DnsJava implements DnsResolver { public class DnsJava implements DnsResolver {
private static final Logger log = Logger.getLogger(DnsJava.class);
private final String resolverAddress; private final String resolverAddress;
public DnsJava(@ConfigProperty(name = "ddnsclient.dns.resolver", defaultValue = "9.9.9.9") String resolverAddress) { public DnsJava(@ConfigProperty(name = "ddnsclient.dns.resolver", defaultValue = "9.9.9.9") String resolverAddress) {
@ -19,19 +22,24 @@ public class DnsJava implements DnsResolver {
@Override @Override
public IPs resolve(String hostname) { public IPs resolve(String hostname) {
String v4 = getIp(hostname, Type.A).orElseThrow(() -> new RuntimeException("Missing A Record")); Optional<String> v4 = getIp(hostname, RecordType.A);
String v6 = getIp(hostname, Type.AAAA).orElseThrow(() -> new RuntimeException("Missing AAAA Record")); Optional<String> v6 = getIp(hostname, RecordType.AAAA);
return new IPs(v4, v6); return new IPs(v4, v6);
} }
private Optional<String> getIp(String hostname, int type) { private Optional<String> getIp(String hostname, RecordType type) {
try { try {
SimpleResolver resolver = new SimpleResolver(resolverAddress); SimpleResolver resolver = new SimpleResolver(resolverAddress);
Lookup lookup = new Lookup(hostname, type); Lookup lookup = new Lookup(hostname, type.value);
lookup.setResolver(resolver); lookup.setResolver(resolver);
Record[] records = lookup.run(); Record[] records = lookup.run();
if (records == null || records.length == 0) {
log.infof("Hostname %s has no record for type %s found", hostname, type.name());
return Optional.empty();
}
for (Record record : records) { for (Record record : records) {
if (record instanceof ARecord) { if (record instanceof ARecord) {
return Optional.of(((ARecord) record).getAddress().getHostAddress()); return Optional.of(((ARecord) record).getAddress().getHostAddress());
@ -45,4 +53,15 @@ public class DnsJava implements DnsResolver {
throw new RuntimeException(e); throw new RuntimeException(e);
} }
} }
private enum RecordType {
A(1),
AAAA(28);
private final int value; // org.bill.DNS.Type.AAAA
RecordType(int value) {
this.value = value;
}
}
} }

View file

@ -20,10 +20,7 @@ public class PublicIpLookup {
private final CurlProcessFactory curlProcessFactory; private final CurlProcessFactory curlProcessFactory;
private final Optional<String> configuredProvider; private final Optional<String> configuredProvider;
PublicIpLookup( PublicIpLookup(CurlProcessFactory curlProcessFactory, @ConfigProperty(name = "ddnsclient.ip-provider") Optional<String> configuredProvider) {
CurlProcessFactory curlProcessFactory,
@ConfigProperty(name = "ddnsclient.ip-provider") Optional<String> configuredProvider
) {
this.curlProcessFactory = curlProcessFactory; this.curlProcessFactory = curlProcessFactory;
this.configuredProvider = configuredProvider; this.configuredProvider = configuredProvider;
} }
@ -35,8 +32,7 @@ public class PublicIpLookup {
configuredProvider.ifPresent(it -> { configuredProvider.ifPresent(it -> {
log.tracef("Setting ip lookup provider %s", it); log.tracef("Setting ip lookup provider %s", it);
provider = Optional.of(IpLookupProviders.get(it)); provider = Optional.of(IpLookupProviders.get(it));
} });
);
} catch (Exception e) { } catch (Exception e) {
throw new IllegalStateException("Unknow provider configured!", e); throw new IllegalStateException("Unknow provider configured!", e);
} }
@ -48,8 +44,8 @@ public class PublicIpLookup {
try { try {
String provider = this.provider.map(it -> it.url).orElseGet(() -> IpLookupProviders.random().url); String provider = this.provider.map(it -> it.url).orElseGet(() -> IpLookupProviders.random().url);
String v4 = getIp(provider, CurlProcessFactory.IpClass.V4); Optional<String> v4 = getIp(provider, CurlProcessFactory.IpClass.V4);
String v6 = getIp(provider, CurlProcessFactory.IpClass.V6); Optional<String> v6 = getIp(provider, CurlProcessFactory.IpClass.V6);
return new IPs(v4, v6); return new IPs(v4, v6);
} catch (Exception e) { } catch (Exception e) {
@ -57,7 +53,7 @@ public class PublicIpLookup {
} }
} }
private String getIp(String provider, CurlProcessFactory.IpClass ipClass) { private Optional<String> getIp(String provider, CurlProcessFactory.IpClass ipClass) {
try { try {
Process curlProcess = curlProcessFactory.create(provider, ipClass).start(); Process curlProcess = curlProcessFactory.create(provider, ipClass).start();
curlProcess.waitFor(); curlProcess.waitFor();
@ -65,9 +61,10 @@ public class PublicIpLookup {
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream)); BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
String ip = reader.readLine(); String ip = reader.readLine();
log.tracef("Retrieved ip: %s", ip); log.tracef("Retrieved ip: %s", ip);
return ip; return Optional.of(ip);
} catch (Exception e) { } catch (Exception e) {
throw new RuntimeException(e); log.errorf("Couldn't lookup ip. Provider: %s, ipClass: %s", provider, ipClass.name());
return Optional.empty();
} }
} }

View file

@ -1,6 +1,11 @@
package de.rpr.ddnsclient.model; package de.rpr.ddnsclient.model;
public record IPs(String v4, String v6) { import java.util.Optional;
public record IPs(Optional<String> v4, Optional<String> v6) {
public IPs(String v4, String v6) {
this(Optional.ofNullable(v4), Optional.ofNullable(v6));
}
} }

View file

@ -58,4 +58,45 @@ class DdnssTest {
.allValues("User-Agent")).containsExactly("ddns-client"); .allValues("User-Agent")).containsExactly("ddns-client");
} }
@Test
void should_send_correct_request_if_only_ipv4_is_present() throws IOException, InterruptedException {
when(httpClientFactory.create()).thenReturn(httpClient);
when(httpClient.send(any(), ArgumentMatchers.<HttpResponse.BodyHandler<String>>any())).thenReturn(httpResponse);
Ddnss dynDns = new Ddnss(httpClientFactory);
dynDns.update(
"example.com",
new IPs("ipv4", null),
new DyndnsAuth(null, null, "token")
);
ArgumentCaptor<HttpRequest> requestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
verify(httpClient).send(requestCaptor.capture(), any());
HttpRequest capturedRequest = requestCaptor.getValue();
assertThat(capturedRequest.uri().toString())
.isEqualTo("https://ddnss.de/upd.php?key=token&host=example.com&ip=ipv4");
}
@Test
void should_send_correct_request_if_only_ipv6_is_present() throws IOException, InterruptedException {
when(httpClientFactory.create()).thenReturn(httpClient);
when(httpClient.send(any(), ArgumentMatchers.<HttpResponse.BodyHandler<String>>any())).thenReturn(httpResponse);
Ddnss dynDns = new Ddnss(httpClientFactory);
dynDns.update(
"example.com",
new IPs(null, "ipv6"),
new DyndnsAuth(null, null, "token")
);
ArgumentCaptor<HttpRequest> requestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
verify(httpClient).send(requestCaptor.capture(), any());
HttpRequest capturedRequest = requestCaptor.getValue();
assertThat(capturedRequest.uri().toString())
.isEqualTo("https://ddnss.de/upd.php?key=token&host=example.com&ip6=ipv6");
}
} }

View file

@ -0,0 +1,102 @@
package de.rpr.ddnsclient.dyndns;
import de.rpr.ddnsclient.model.DyndnsAuth;
import de.rpr.ddnsclient.model.IPs;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.ArgumentMatchers;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.io.IOException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DuckDNSTest {
@Mock
HttpClientFactory httpClientFactory;
@Mock
HttpClient httpClient;
@Mock
HttpResponse<String> httpResponse;
@Test
void should_have_correct_name() {
assertThat(new DuckDNS(httpClientFactory).name()).isEqualTo("duckdns");
}
@Test
void should_send_correct_request_if_ipv4_and_ipv6_are_present() throws IOException, InterruptedException {
when(httpClientFactory.create()).thenReturn(httpClient);
when(httpClient.send(any(), ArgumentMatchers.<HttpResponse.BodyHandler<String>>any())).thenReturn(httpResponse);
DuckDNS dynDns = new DuckDNS(httpClientFactory);
dynDns.update(
"example.com",
new IPs("ipv4", "ipv6"),
new DyndnsAuth(null, null, "token")
);
ArgumentCaptor<HttpRequest> requestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
verify(httpClient).send(requestCaptor.capture(), any());
HttpRequest capturedRequest = requestCaptor.getValue();
assertThat(capturedRequest.uri().toString())
.isEqualTo("https://www.duckdns.org/update?domains=example.com&token=token&ip=ipv4&ipv6=ipv6");
assertThat(capturedRequest.headers()
.allValues("User-Agent")).containsExactly("ddns-client");
}
@Test
void should_send_correct_request_if_only_ipv4_is_present() throws IOException, InterruptedException {
when(httpClientFactory.create()).thenReturn(httpClient);
when(httpClient.send(any(), ArgumentMatchers.<HttpResponse.BodyHandler<String>>any())).thenReturn(httpResponse);
DuckDNS dynDns = new DuckDNS(httpClientFactory);
dynDns.update(
"example.com",
new IPs("ipv4", null),
new DyndnsAuth(null, null, "token")
);
ArgumentCaptor<HttpRequest> requestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
verify(httpClient).send(requestCaptor.capture(), any());
HttpRequest capturedRequest = requestCaptor.getValue();
assertThat(capturedRequest.uri().toString())
.isEqualTo("https://www.duckdns.org/update?domains=example.com&token=token&ip=ipv4");
}
@Test
void should_send_correct_request_if_only_ipv6_is_present() throws IOException, InterruptedException {
when(httpClientFactory.create()).thenReturn(httpClient);
when(httpClient.send(any(), ArgumentMatchers.<HttpResponse.BodyHandler<String>>any())).thenReturn(httpResponse);
DuckDNS dynDns = new DuckDNS(httpClientFactory);
dynDns.update(
"example.com",
new IPs(null, "ipv6"),
new DyndnsAuth(null, null, "token")
);
ArgumentCaptor<HttpRequest> requestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
verify(httpClient).send(requestCaptor.capture(), any());
HttpRequest capturedRequest = requestCaptor.getValue();
assertThat(capturedRequest.uri().toString())
.isEqualTo("https://www.duckdns.org/update?domains=example.com&token=token&ipv6=ipv6");
}
}

View file

@ -10,7 +10,7 @@ class DnsJavaTest {
@Test @Test
void test() { void test() {
IPs result = new DnsJava("9.9.9.9").resolve("example.com"); IPs result = new DnsJava("9.9.9.9").resolve("example.com");
assertThat(result.v4()).isNotBlank(); assertThat(result.v4()).isPresent();
assertThat(result.v6()).isNotBlank(); assertThat(result.v6()).isPresent();
} }
} }