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
This application can be use to update dynamic hostnames. Currently it implements only the ddnss.de provider, but it
is extensible and more providers can be added easily.
This application can be use to update dynamic hostnames. Currently it implements the following DynDNS providers:
- ddnss.de
- duckdns.org
This project uses Quarkus.

View file

@ -45,7 +45,7 @@ public class Updater {
}
void run() {
log.trace("Updater running.");
log.info("Updater running.");
if (config.isEmpty()) {
throw new IllegalStateException("Missing configuration");
@ -58,7 +58,7 @@ public class Updater {
if (updateMap.containsKey(cfg.hostname())
&& 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;
}
@ -73,7 +73,7 @@ public class Updater {
dynDns.update(cfg.hostname(), publicIps, new DyndnsAuth(null, null, cfg.token()));
updateMap.put(cfg.hostname(), LocalDateTime.now());
} 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 java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.text.MessageFormat;
@ -29,26 +27,29 @@ public class Ddnss implements DynDns {
}
public void update(String hostname, IPs currentIps, DyndnsAuth auth) {
String updateUrl = MessageFormat.format("https://ddnss.de/upd.php?key={0}&host={1}&ip={2}&ip6={3}",
auth.token(), hostname, currentIps.v4(), currentIps.v6());
log.tracef("Updating ddnss hostname %s", hostname);
try (HttpClient http = httpClientFactory.create()) {
HttpRequest request = HttpRequest.newBuilder()
.header("User-Agent", "ddns-client")
.GET()
.uri(URI.create(updateUrl))
.build();
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
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);
}
log.info("Hostname updated.");
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://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.IPs;
import java.net.URI;
import java.net.http.HttpRequest;
public interface DynDns {
String name();
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 jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import org.xbill.DNS.Record;
import org.xbill.DNS.*;
@ -11,6 +12,8 @@ import java.util.Optional;
@ApplicationScoped
public class DnsJava implements DnsResolver {
private static final Logger log = Logger.getLogger(DnsJava.class);
private final 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
public IPs resolve(String hostname) {
String v4 = getIp(hostname, Type.A).orElseThrow(() -> new RuntimeException("Missing A Record"));
String v6 = getIp(hostname, Type.AAAA).orElseThrow(() -> new RuntimeException("Missing AAAA Record"));
Optional<String> v4 = getIp(hostname, RecordType.A);
Optional<String> v6 = getIp(hostname, RecordType.AAAA);
return new IPs(v4, v6);
}
private Optional<String> getIp(String hostname, int type) {
private Optional<String> getIp(String hostname, RecordType type) {
try {
SimpleResolver resolver = new SimpleResolver(resolverAddress);
Lookup lookup = new Lookup(hostname, type);
Lookup lookup = new Lookup(hostname, type.value);
lookup.setResolver(resolver);
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) {
if (record instanceof ARecord) {
return Optional.of(((ARecord) record).getAddress().getHostAddress());
@ -45,4 +53,15 @@ public class DnsJava implements DnsResolver {
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 Optional<String> configuredProvider;
PublicIpLookup(
CurlProcessFactory curlProcessFactory,
@ConfigProperty(name = "ddnsclient.ip-provider") Optional<String> configuredProvider
) {
PublicIpLookup(CurlProcessFactory curlProcessFactory, @ConfigProperty(name = "ddnsclient.ip-provider") Optional<String> configuredProvider) {
this.curlProcessFactory = curlProcessFactory;
this.configuredProvider = configuredProvider;
}
@ -33,10 +30,9 @@ public class PublicIpLookup {
void onStart(@Observes StartupEvent event) {
try {
configuredProvider.ifPresent(it -> {
log.tracef("Setting ip lookup provider %s", it);
provider = Optional.of(IpLookupProviders.get(it));
}
);
log.tracef("Setting ip lookup provider %s", it);
provider = Optional.of(IpLookupProviders.get(it));
});
} catch (Exception e) {
throw new IllegalStateException("Unknow provider configured!", e);
}
@ -48,8 +44,8 @@ public class PublicIpLookup {
try {
String provider = this.provider.map(it -> it.url).orElseGet(() -> IpLookupProviders.random().url);
String v4 = getIp(provider, CurlProcessFactory.IpClass.V4);
String v6 = getIp(provider, CurlProcessFactory.IpClass.V6);
Optional<String> v4 = getIp(provider, CurlProcessFactory.IpClass.V4);
Optional<String> v6 = getIp(provider, CurlProcessFactory.IpClass.V6);
return new IPs(v4, v6);
} 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 {
Process curlProcess = curlProcessFactory.create(provider, ipClass).start();
curlProcess.waitFor();
@ -65,9 +61,10 @@ public class PublicIpLookup {
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
String ip = reader.readLine();
log.tracef("Retrieved ip: %s", ip);
return ip;
return Optional.of(ip);
} 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;
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

@ -47,7 +47,7 @@ class DdnssTest {
);
ArgumentCaptor<HttpRequest> requestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
verify(httpClient).send(requestCaptor.capture(),any());
verify(httpClient).send(requestCaptor.capture(), any());
HttpRequest capturedRequest = requestCaptor.getValue();
@ -58,4 +58,45 @@ class DdnssTest {
.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
void test() {
IPs result = new DnsJava("9.9.9.9").resolve("example.com");
assertThat(result.v4()).isNotBlank();
assertThat(result.v6()).isNotBlank();
assertThat(result.v4()).isPresent();
assertThat(result.v6()).isPresent();
}
}