addresses = Set.of();
+ private volatile Instant expiresAt = Instant.MIN;
+
+ private DynamicHostnameMatcher(Duration refreshTimeout, String hostname)
+ {
+ this(refreshTimeout, hostname, Clock.systemUTC(), InetAddress::getAllByName);
+ }
+
+ // package private for testing
+ DynamicHostnameMatcher(Duration refreshTimeout, String hostname, Clock clock, DnsResolver dnsResolver)
+ {
+ Objects.requireNonNull(refreshTimeout, "refreshTimeout");
+ Objects.requireNonNull(hostname, "hostname");
+
+ if (refreshTimeout == null || refreshTimeout.isZero() || refreshTimeout.isNegative())
+ throw new IllegalArgumentException("refreshTimeout must be greater than zero");
+ if (hostname.isBlank())
+ throw new IllegalArgumentException("hostname must not be blank");
+
+ this.refreshTimeout = refreshTimeout;
+
+ this.hostname = hostname;
+
+ this.clock = clock;
+ this.dnsResolver = dnsResolver;
+
+ refresh();
+ }
+
+ @Override
+ public boolean matches(InetSocketAddress address)
+ {
+ if (address == null || address.isUnresolved())
+ return false;
+
+ refreshIfExpired();
+
+ return addresses.contains(address.getAddress());
+ }
+
+ private void refreshIfExpired()
+ {
+ if (clock.instant().isBefore(expiresAt))
+ return;
+
+ refresh();
+ }
+
+ private void refresh()
+ {
+ refreshLock.lock();
+ try
+ {
+ if (clock.instant().isBefore(expiresAt))
+ return;
+
+ try
+ {
+ addresses = Set.copyOf(List.of(dnsResolver.resolve(hostname)));
+ }
+ catch (UnknownHostException | RuntimeException e)
+ {
+ logger.warn("Unable to resolve domain '{}': {} - {}", hostname, e.getClass().getName(), e.getMessage());
+
+ // DNS failure means no address is authorized
+ addresses = Set.of();
+ }
+ expiresAt = clock.instant().plus(refreshTimeout);
+ }
+ finally
+ {
+ refreshLock.unlock();
+ }
+ }
+
+ @Override
+ public String toString()
+ {
+ return hostname;
+ }
+}
\ No newline at end of file
diff --git a/dsf-common/dsf-common-config/src/main/java/dev/dsf/common/config/network/HostSpecParser.java b/dsf-common/dsf-common-config/src/main/java/dev/dsf/common/config/network/HostSpecParser.java
new file mode 100644
index 000000000..dd0314b31
--- /dev/null
+++ b/dsf-common/dsf-common-config/src/main/java/dev/dsf/common/config/network/HostSpecParser.java
@@ -0,0 +1,594 @@
+/*
+ * Copyright 2018-2025 Heilbronn University of Applied Sciences
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package dev.dsf.common.config.network;
+
+import java.net.IDN;
+import java.net.Inet6Address;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.Objects;
+import java.util.function.Predicate;
+
+/**
+ *
+ * Parses host specifications into immutable {@link HostSpec} instances.
+ *
+ *
+ * A host specification may represent a domain name, a wildcard domain, an IPv4 address, an IPv4 CIDR network, an IPv6
+ * address, or an IPv6 CIDR network. All host types may specify an optional port, except that IPv6 addresses must always
+ * be enclosed in square brackets when a port is present (and are required to use brackets in all cases).
+ *
+ *
+ * Examples of supported specifications:
+ *
+ *
+ * Supported host specification formats
+ *
+ * | Kind |
+ * Example |
+ * Example with port |
+ *
+ *
+ * | {@link Kind#DOMAIN DOMAIN} |
+ * {@code example.com} |
+ * {@code example.com:443} |
+ *
+ *
+ * | {@link Kind#DOMAIN_WILDCARD_SINGLE_LEVEL WILDCARD_DOMAIN_ONE_LEVEL} |
+ * {@code *.example.com} |
+ * {@code *.example.com:443} |
+ *
+ *
+ * | {@link Kind#DOMAIN_WILDCARD_MULTI_LEVEL WILDCARD_DOMAIN_MULTI_LEVEL} |
+ * {@code **.example.com} |
+ * {@code **.example.com:443} |
+ *
+ *
+ * | {@link Kind#IPV4 IPV4} |
+ * {@code 192.0.2.1} |
+ * {@code 192.0.2.1:443} |
+ *
+ *
+ * | {@link Kind#IPV4_CIDR IPV4_CIDR} |
+ * {@code 192.0.2.0/24} |
+ * {@code 192.0.2.0/24:443} |
+ *
+ *
+ * | {@link Kind#IPV6 IPV6} |
+ * {@code [2001:db8::1]} |
+ * {@code [2001:db8::1]:443} |
+ *
+ *
+ * | {@link Kind#IPV6_CIDR IPV6_CIDR} |
+ * {@code [2001:db8::/32]} |
+ * {@code [2001:db8::/32]:443} |
+ *
+ *
+ *
+ *
+ * Ports are optional and must be decimal values in the range {@code 0..65535}.
+ *
+ *
+ * IPv6 specifications are always enclosed in square brackets, including IPv6 specifications without a port. This
+ * removes ambiguity between the IPv6 address's colons and the optional port separator.
+ *
+ *
+ * Domain names support Unicode/IDN labels according to the behavior of {@link java.net.IDN}. The original Unicode
+ * representation supplied by the caller is retained in {@link HostSpec#host()}; IDN conversion is used for validation
+ * and does not replace the original host value.
+ *
+ *
+ * For example:
+ *
+ *
{@code
+ *
+ * HostSpecParser.parse("example.com:443");
+ * HostSpecParser.parse("münchen.example:443");
+ * HostSpecParser.parse("*.example.com");
+ * HostSpecParser.parse("**.example.com:8443");
+ * HostSpecParser.parse("192.0.2.1");
+ * HostSpecParser.parse("192.0.2.0/24:8080");
+ * HostSpecParser.parse("[2001:db8::1]");
+ * HostSpecParser.parse("[2001:db8::1]:443");
+ * HostSpecParser.parse("[2001:db8::/32]:8443");
+ * }
+ *
+ *
+ * The parser trims leading and trailing whitespace from the supplied specification. A {@link NullPointerException} is
+ * thrown for a {@code null} input, and an {@link IllegalArgumentException} is thrown for an empty or syntactically
+ * invalid specification.
+ *
+ * @see HostSpec
+ * @see Kind
+ * @see java.net.IDN
+ */
+public final class HostSpecParser
+{
+ private static final int MAX_PORT = 65_535;
+ private static final int MAX_IPV4_PREFIX = 32;
+ private static final int MAX_IPV6_PREFIX = 128;
+
+ private HostSpecParser()
+ {
+ }
+
+ /**
+ * @param inputs
+ * may be null, null or blank elements are filtered
+ * @return
+ * @throws IllegalArgumentException
+ */
+ public static List parse(String... inputs)
+ {
+ return inputs == null ? List.of() : parse(Arrays.asList(inputs));
+ }
+
+ /**
+ * @param inputs
+ * may be {@link NullPointerException}, null or blank elements are filtered
+ * @return
+ * @throws IllegalArgumentException
+ */
+ public static List parse(Collection inputs)
+ {
+ return inputs == null ? List.of()
+ : inputs.stream().filter(Objects::nonNull).filter(Predicate.not(String::isBlank))
+ .map(HostSpecParser::parse).toList();
+ }
+
+ /**
+ * @param input
+ * not null, not blank
+ * @return
+ * @throws IllegalArgumentException
+ */
+ public static HostSpec parse(String input)
+ {
+ Objects.requireNonNull(input, "input");
+ if (input.isBlank())
+ throw new IllegalArgumentException("input is blank");
+
+ String value = input.trim();
+
+ if (value.isEmpty())
+ throw invalid(input, "Host specification is empty");
+
+ if (value.startsWith("["))
+ return parseIpv6(value);
+
+ if (value.startsWith("**."))
+ return parseWildcard(value, true);
+
+ if (value.startsWith("*."))
+ return parseWildcard(value, false);
+
+ if (looksLikeIpv4(value))
+ return parseIpv4(value);
+
+ return parseDomain(value);
+ }
+
+ private static HostSpec parseIpv6(String input)
+ {
+ int closingBracket = input.indexOf(']');
+
+ if (closingBracket < 0)
+ throw invalid(input, "Missing closing ']' for IPv6 address");
+
+ String inside = input.substring(1, closingBracket);
+ String after = input.substring(closingBracket + 1);
+
+ if (inside.isEmpty())
+ throw invalid(input, "IPv6 address is empty");
+
+ Integer port = null;
+
+ if (!after.isEmpty())
+ {
+ if (!after.startsWith(":"))
+ throw invalid(input, "Unexpected characters after IPv6 address");
+
+ port = parsePort(after.substring(1), input);
+ }
+
+ int slash = inside.indexOf('/');
+
+ if (slash >= 0)
+ {
+ if (inside.indexOf('/', slash + 1) >= 0)
+ throw invalid(input, "Invalid IPv6 CIDR specification");
+
+ String address = inside.substring(0, slash);
+ String prefixText = inside.substring(slash + 1);
+
+ if (address.isEmpty())
+ throw invalid(input, "IPv6 address is empty");
+
+ int prefix = parseInteger(prefixText, input, "IPv6 prefix length");
+
+ if (prefix > MAX_IPV6_PREFIX)
+ throw invalid(input, "IPv6 prefix length must be 0..128");
+
+ if (!isValidIpv6(address))
+ throw invalid(input, "Invalid IPv6 address");
+
+ return new HostSpec(Kind.IPV6_CIDR, address, prefix, port);
+ }
+
+ if (!isValidIpv6(inside))
+ throw invalid(input, "Invalid IPv6 address");
+
+ return new HostSpec(Kind.IPV6, inside, MAX_IPV6_PREFIX, port);
+ }
+
+ private static HostSpec parseWildcard(String input, boolean multiLevel)
+ {
+ String prefix = multiLevel ? "**." : "*.";
+ String remainder = input.substring(prefix.length());
+
+ HostAndPort hostAndPort = splitHostAndPort(remainder);
+
+ if (!isValidDomain(hostAndPort.host()))
+ throw invalid(input, "Invalid wildcard domain");
+
+ Kind kind = multiLevel ? Kind.DOMAIN_WILDCARD_MULTI_LEVEL : Kind.DOMAIN_WILDCARD_SINGLE_LEVEL;
+
+ return new HostSpec(kind, hostAndPort.host(), null, hostAndPort.port());
+ }
+
+ private static boolean looksLikeIpv4(String input)
+ {
+ String host = input;
+
+ int colon = host.lastIndexOf(':');
+
+ if (colon >= 0)
+ host = host.substring(0, colon);
+
+ int slash = host.indexOf('/');
+
+ if (slash >= 0)
+ host = host.substring(0, slash);
+
+ return isAsciiIpv4Candidate(host);
+ }
+
+ private static boolean isAsciiIpv4Candidate(String value)
+ {
+ if (value.isEmpty())
+ return false;
+
+ int dots = 0;
+
+ for (int i = 0; i < value.length(); i++)
+ {
+ char c = value.charAt(i);
+
+ if (c == '.')
+ dots++;
+ else if (c < '0' || c > '9')
+ return false;
+ }
+
+ return dots == 3;
+ }
+
+ private static HostSpec parseIpv4(String input)
+ {
+ HostAndPort hostAndPort = splitHostAndPort(input);
+ String host = hostAndPort.host();
+
+ int slash = host.indexOf('/');
+
+ if (slash >= 0)
+ {
+ if (host.indexOf('/', slash + 1) >= 0)
+ throw invalid(input, "Invalid IPv4 CIDR specification");
+
+ String address = host.substring(0, slash);
+ String prefixText = host.substring(slash + 1);
+
+ int prefix = parseInteger(prefixText, input, "IPv4 prefix length");
+
+ if (prefix > MAX_IPV4_PREFIX)
+ throw invalid(input, "IPv4 prefix length must be 0..32");
+
+ if (!isValidIpv4(address))
+ throw invalid(input, "Invalid IPv4 address");
+
+ return new HostSpec(Kind.IPV4_CIDR, address, prefix, hostAndPort.port());
+ }
+
+ if (!isValidIpv4(host))
+ throw invalid(input, "Invalid IPv4 address");
+
+ return new HostSpec(Kind.IPV4, host, MAX_IPV4_PREFIX, hostAndPort.port());
+ }
+
+ private static HostSpec parseDomain(String input)
+ {
+ HostAndPort hostAndPort = splitHostAndPort(input);
+
+ if (!isValidDomain(hostAndPort.host()))
+ throw invalid(input, "Invalid domain");
+
+ return new HostSpec(Kind.DOMAIN, hostAndPort.host(), null, hostAndPort.port());
+ }
+
+ /**
+ * Splits a hostname from its optional ":port".
+ *
+ * IPv6 is handled separately because it must be bracketed.
+ */
+ private static HostAndPort splitHostAndPort(String input)
+ {
+ int colon = input.lastIndexOf(':');
+
+ if (colon < 0)
+ return new HostAndPort(input, null);
+
+ String host = input.substring(0, colon);
+ String portText = input.substring(colon + 1);
+
+ if (host.isEmpty())
+ throw invalid(input, "Missing host");
+
+ /*
+ * An unbracketed host containing another colon cannot be a domain or IPv4 address. IPv6 must have been handled
+ * by parseIpv6() already.
+ */
+ if (host.indexOf(':') >= 0)
+ throw invalid(input, "IPv6 addresses must be bracketed");
+
+ return new HostAndPort(host, parsePort(portText, input));
+ }
+
+ private static int parsePort(String text, String input)
+ {
+ int port = parseInteger(text, input, "port");
+
+ if (port < 0 || port > MAX_PORT)
+ throw invalid(input, "Port must be 0..65535");
+
+ return port;
+ }
+
+ /**
+ * Parses an ASCII decimal integer.
+ *
+ * Unicode decimal digits are intentionally rejected for ports and CIDR prefix lengths.
+ */
+ private static int parseInteger(String text, String input, String name)
+ {
+ if (text.isEmpty())
+ throw invalid(input, "Invalid " + name);
+
+ for (int i = 0; i < text.length(); i++)
+ {
+ char c = text.charAt(i);
+
+ if (c < '0' || c > '9')
+ throw invalid(input, "Invalid " + name);
+ }
+
+ try
+ {
+ return Integer.parseInt(text);
+ }
+ catch (NumberFormatException e)
+ {
+ throw invalid(input, "Invalid " + name);
+ }
+ }
+
+ private static boolean isValidIpv4(String value)
+ {
+ if (!isAsciiIpv4Candidate(value))
+ return false;
+
+ String[] parts = value.split("\\.", -1);
+
+ if (parts.length != 4)
+ return false;
+
+ for (String part : parts)
+ {
+ if (part.isEmpty())
+ return false;
+
+ if (part.length() > 1 && part.charAt(0) == '0')
+ return false;
+
+ int number = 0;
+
+ for (int i = 0; i < part.length(); i++)
+ {
+ number = number * 10 + (part.charAt(i) - '0');
+
+ if (number > 255)
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static boolean isValidIpv6(String value)
+ {
+ if (value.isEmpty())
+ return false;
+
+ /*
+ * Explicitly reject IPv6 scope identifiers. HostSpec currently represents an IP address, not an
+ * interface-scoped address.
+ */
+ if (value.indexOf('%') >= 0)
+ return false;
+
+ try
+ {
+ return Inet6Address.ofLiteral(value) instanceof Inet6Address;
+ }
+ catch (IllegalArgumentException e)
+ {
+ return false;
+ }
+ }
+
+ /**
+ * Validates the domain using Java's IDN implementation while preserving the original Unicode representation.
+ *
+ * The returned ASCII/Punycode value is deliberately discarded.
+ *
+ * USE_STD3_ASCII_RULES additionally applies the RFC 1122/1123 restrictions to ASCII labels.
+ */
+ private static boolean isValidDomain(String value)
+ {
+ if (value.isEmpty())
+ return false;
+
+ /*
+ * A trailing root dot is valid. Java's IDN implementation handles the Unicode DNS separator characters
+ * according to its own IDN processing rules.
+ */
+ try
+ {
+ String ascii = IDN.toASCII(value, IDN.USE_STD3_ASCII_RULES);
+
+ if (ascii.isEmpty())
+ return false;
+
+ /*
+ * IDN.toASCII() performs IDNA processing, but keep these explicit DNS length checks here so the parser's
+ * accepted representation has a clear DNS-size boundary.
+ */
+ if (ascii.length() > 255)
+ return false;
+
+ String withoutRootDot = ascii.endsWith(".") ? ascii.substring(0, ascii.length() - 1) : ascii;
+
+ if (withoutRootDot.isEmpty())
+ return false;
+
+ String[] labels = withoutRootDot.split("\\.", -1);
+
+ for (String label : labels)
+ {
+ if (label.isEmpty() || label.length() > 63)
+ return false;
+ }
+
+ return true;
+ }
+ catch (IllegalArgumentException e)
+ {
+ return false;
+ }
+ }
+
+ private static IllegalArgumentException invalid(String input, String reason)
+ {
+ return new IllegalArgumentException(reason + ": \"" + input + "\"");
+ }
+
+ public static record HostSpec(Kind kind, String host, Integer prefixLength, Integer port)
+ {
+ public HostSpec
+ {
+ Objects.requireNonNull(kind, "kind");
+ Objects.requireNonNull(host, "host");
+
+ if (kind == Kind.IPV4 && (prefixLength == null || prefixLength != MAX_IPV4_PREFIX))
+ throw new IllegalArgumentException("Invalid prefix length: " + prefixLength);
+ else if (kind == Kind.IPV6 && (prefixLength == null || prefixLength != MAX_IPV6_PREFIX))
+ throw new IllegalArgumentException("Invalid prefix length: " + prefixLength);
+ else if (prefixLength != null)
+ {
+ int max = switch (kind)
+ {
+ case IPV4, IPV4_CIDR -> MAX_IPV4_PREFIX;
+ case IPV6, IPV6_CIDR -> MAX_IPV6_PREFIX;
+ default -> throw new IllegalArgumentException("Prefix length is only valid for IP kinds");
+ };
+
+ if (prefixLength < 0 || prefixLength > max)
+ throw new IllegalArgumentException("Invalid prefix length: " + prefixLength);
+ }
+
+ if (port != null && (port < 0 || port > MAX_PORT))
+ throw new IllegalArgumentException("Invalid port: " + port);
+ }
+
+ public boolean hasPort()
+ {
+ return port != null;
+ }
+
+ public boolean isIp()
+ {
+ return kind == Kind.IPV4 || kind == Kind.IPV6 || kind == Kind.IPV4_CIDR || kind == Kind.IPV6_CIDR;
+ }
+
+ public boolean isDomain()
+ {
+ return kind == Kind.DOMAIN;
+ }
+
+ public boolean isDomainOrWildcard()
+ {
+ return isDomain() || kind == Kind.DOMAIN_WILDCARD_SINGLE_LEVEL || kind == Kind.DOMAIN_WILDCARD_MULTI_LEVEL;
+ }
+
+ public String asciiHost()
+ {
+ return isDomainOrWildcard() ? IDN.toASCII(host, IDN.USE_STD3_ASCII_RULES) : host;
+ }
+
+ @Override
+ public final String toString()
+ {
+ return switch (kind)
+ {
+ case Kind.DOMAIN, Kind.IPV4 -> host + (hasPort() ? ":" + port : "");
+ case Kind.IPV6 -> "[" + host + "]" + (hasPort() ? ":" + port : "");
+
+ case Kind.DOMAIN_WILDCARD_SINGLE_LEVEL -> "*." + host + (hasPort() ? ":" + port : "");
+ case Kind.DOMAIN_WILDCARD_MULTI_LEVEL -> "**." + host + (hasPort() ? ":" + port : "");
+
+ case Kind.IPV4_CIDR -> host + "/" + prefixLength + (hasPort() ? ":" + port : "");
+ case Kind.IPV6_CIDR -> "[" + host + "/" + prefixLength + "]" + (hasPort() ? ":" + port : "");
+
+ default -> throw new IllegalArgumentException("Kind not supported: " + kind);
+ };
+ }
+ }
+
+ public static enum Kind
+ {
+ DOMAIN,
+
+ DOMAIN_WILDCARD_SINGLE_LEVEL, DOMAIN_WILDCARD_MULTI_LEVEL,
+
+ IPV4, IPV4_CIDR,
+
+ IPV6, IPV6_CIDR
+ }
+
+ private static record HostAndPort(String host, Integer port)
+ {
+ }
+}
\ No newline at end of file
diff --git a/dsf-common/dsf-common-config/src/main/java/dev/dsf/common/config/network/InetSocketAddressMatcher.java b/dsf-common/dsf-common-config/src/main/java/dev/dsf/common/config/network/InetSocketAddressMatcher.java
new file mode 100644
index 000000000..2c1623c4f
--- /dev/null
+++ b/dsf-common/dsf-common-config/src/main/java/dev/dsf/common/config/network/InetSocketAddressMatcher.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2018-2025 Heilbronn University of Applied Sciences
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package dev.dsf.common.config.network;
+
+import java.net.InetSocketAddress;
+
+public interface InetSocketAddressMatcher
+{
+ /**
+ * Matches all of the incoming addresses
+ */
+ InetSocketAddressMatcher ALL = _ -> true;
+
+ /**
+ * Matches none of the incoming addresses
+ */
+ InetSocketAddressMatcher NONE = _ -> false;
+
+ /**
+ * @param address
+ * not null
+ * @return true if given address matches one of the configured networks
+ */
+ boolean matches(InetSocketAddress address);
+}
\ No newline at end of file
diff --git a/dsf-common/dsf-common-config/src/main/java/dev/dsf/common/config/network/InetSocketAddressMatcherList.java b/dsf-common/dsf-common-config/src/main/java/dev/dsf/common/config/network/InetSocketAddressMatcherList.java
new file mode 100644
index 000000000..7a2b517ca
--- /dev/null
+++ b/dsf-common/dsf-common-config/src/main/java/dev/dsf/common/config/network/InetSocketAddressMatcherList.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2018-2025 Heilbronn University of Applied Sciences
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package dev.dsf.common.config.network;
+
+import java.net.InetSocketAddress;
+import java.util.Collection;
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/**
+ * Matches addresses against network definitions.
+ */
+public class InetSocketAddressMatcherList implements InetSocketAddressMatcher
+{
+ private final List networks;
+
+ /**
+ * @param networks
+ * null values are ignored
+ */
+ public InetSocketAddressMatcherList(InetSocketAddressMatcher... networks)
+ {
+ this(Stream.of(networks));
+ }
+
+ /**
+ * @param networks
+ * may be null, null values are filtered
+ */
+ public InetSocketAddressMatcherList(Collection extends InetSocketAddressMatcher> networks)
+ {
+ this(networks == null ? null : networks.stream());
+ }
+
+ /**
+ * @param networks
+ * may be null, null values are filtered
+ */
+ public InetSocketAddressMatcherList(Stream extends InetSocketAddressMatcher> networks)
+ {
+ this.networks = networks == null ? List.of()
+ : networks.filter(Objects::nonNull).map(InetSocketAddressMatcher.class::cast).toList();
+ }
+
+ @Override
+ public boolean matches(InetSocketAddress address)
+ {
+ if (address == null)
+ return false;
+
+ return networks.stream().anyMatch(n -> n.matches(address));
+ }
+
+ public List getNetworks()
+ {
+ return networks;
+ }
+
+ @Override
+ public String toString()
+ {
+ return networks.stream().map(InetSocketAddressMatcher::toString).collect(Collectors.joining(", ", "[", "]"));
+ }
+}
diff --git a/dsf-common/dsf-common-config/src/main/java/dev/dsf/common/config/network/StaticCidrMatcher.java b/dsf-common/dsf-common-config/src/main/java/dev/dsf/common/config/network/StaticCidrMatcher.java
new file mode 100644
index 000000000..45a9af8f7
--- /dev/null
+++ b/dsf-common/dsf-common-config/src/main/java/dev/dsf/common/config/network/StaticCidrMatcher.java
@@ -0,0 +1,145 @@
+/*
+ * Copyright 2018-2025 Heilbronn University of Applied Sciences
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package dev.dsf.common.config.network;
+
+import java.net.Inet4Address;
+import java.net.Inet6Address;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.UnknownHostException;
+import java.util.Collection;
+import java.util.List;
+import java.util.Objects;
+
+import dev.dsf.common.config.network.HostSpecParser.HostSpec;
+
+/**
+ * Matches resolved socket addresses against statically configured IPv4 or IPv6 addresses and CIDR network ranges, with
+ * optional port matching.
+ *
+ * Supported host specifications include individual IPv4 and IPv6 addresses, which are treated as full-length network
+ * prefixes, and IPv4 and IPv6 CIDR ranges, which match addresses sharing the specified network prefix.
+ *
+ * Matching compares the binary address representations and requires the candidate address and configured network to use
+ * the same address family. IPv4 and IPv6 addresses therefore never match each other. CIDR matching compares all
+ * complete prefix bytes and, when necessary, the remaining prefix bits.
+ *
+ * A {@link HostSpec} may specify a port to restrict matches to that port. If no port is specified, any port is
+ * accepted.
+ *
+ * A {@code null} or unresolved socket address never matches. Matching uses the resolved binary address and does not
+ * perform hostname-based matching.
+ */
+public class StaticCidrMatcher implements InetSocketAddressMatcher
+{
+ public static InetSocketAddressMatcherList of(HostSpec... hostSpecs)
+ {
+ return of(hostSpecs == null ? List.of() : List.of(hostSpecs));
+ }
+
+ public static InetSocketAddressMatcherList of(Collection hostSpecs)
+ {
+ return new InetSocketAddressMatcherList(hostSpecs == null ? List.of()
+ : hostSpecs.stream().filter(HostSpec::isIp).map(StaticCidrMatcher::of).toList());
+ }
+
+ public static StaticCidrMatcher of(HostSpec hostSpec)
+ {
+ Objects.requireNonNull(hostSpec, "hostSpec");
+
+ return switch (hostSpec.kind())
+ {
+ case IPV4 -> {
+ byte[] address = Inet4Address.ofLiteral(hostSpec.host()).getAddress();
+ yield new StaticCidrMatcher(address, address.length * 8, hostSpec.port());
+ }
+ case IPV6 -> {
+ byte[] address = Inet6Address.ofLiteral(hostSpec.host()).getAddress();
+ yield new StaticCidrMatcher(address, address.length * 8, hostSpec.port());
+ }
+ case IPV4_CIDR, IPV6_CIDR -> new StaticCidrMatcher(InetAddress.ofLiteral(hostSpec.host()).getAddress(),
+ hostSpec.prefixLength(), hostSpec.port());
+
+ default -> throw new IllegalArgumentException("hostSpec.kind '" + hostSpec.kind() + "' not supported");
+ };
+ }
+
+ private final byte[] network;
+ private final int prefixLength;
+ private final Integer port;
+
+ private StaticCidrMatcher(byte[] network, int prefixLength, Integer port)
+ {
+ this.network = network;
+ this.prefixLength = prefixLength;
+ this.port = port;
+ }
+
+ @Override
+ public boolean matches(InetSocketAddress address)
+ {
+ if (address == null)
+ return false;
+
+ if (address.isUnresolved())
+ return false;
+
+ if (!portMatches(port, address.getPort()))
+ return false;
+
+ byte[] candidate = address.getAddress().getAddress();
+
+ // IPv4 and IPv6 addresses have different lengths and can never match each other
+ if (candidate.length != network.length)
+ return false;
+
+ int fullBytes = prefixLength / 8;
+ int remainingBits = prefixLength % 8;
+
+ for (int i = 0; i < fullBytes; i++)
+ if (candidate[i] != network[i])
+ return false;
+
+ if (remainingBits == 0)
+ return true;
+
+ int mask = 0xFF << (8 - remainingBits);
+
+ return (candidate[fullBytes] & mask) == (network[fullBytes] & mask);
+ }
+
+ private boolean portMatches(Integer expectedPort, int candidatePort)
+ {
+ return expectedPort == null || expectedPort == candidatePort;
+ }
+
+ @Override
+ public String toString()
+ {
+ try
+ {
+ InetAddress address = InetAddress.getByAddress(network);
+ boolean isIpv6 = address.getAddress().length == 16;
+
+ return (isIpv6 ? "[" : "") + address.getHostAddress() + "/" + prefixLength + (isIpv6 ? "]" : "")
+ + (port == null ? "" : ":" + port);
+ }
+ catch (UnknownHostException e)
+ {
+ throw new IllegalStateException("Invalid CIDR network address", e);
+ }
+ }
+}
\ No newline at end of file
diff --git a/dsf-common/dsf-common-config/src/main/java/dev/dsf/common/config/network/StaticHostnameMatcher.java b/dsf-common/dsf-common-config/src/main/java/dev/dsf/common/config/network/StaticHostnameMatcher.java
new file mode 100644
index 000000000..b3954b150
--- /dev/null
+++ b/dsf-common/dsf-common-config/src/main/java/dev/dsf/common/config/network/StaticHostnameMatcher.java
@@ -0,0 +1,201 @@
+/*
+ * Copyright 2018-2025 Heilbronn University of Applied Sciences
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package dev.dsf.common.config.network;
+
+import java.net.InetSocketAddress;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.Objects;
+
+import dev.dsf.common.config.network.HostSpecParser.HostSpec;
+
+/**
+ * Matches socket addresses against a statically configured hostname pattern with optional port matching.
+ *
+ * Supported hostname patterns are:
+ *
+ * - Exact hostnames, which match the specified hostname only.
+ * - Single-level wildcards ({@code *.example.invalid}), which match exactly one subdomain level.
+ * - Multi-level wildcards ({@code .example.invalid}), which match one or more subdomain levels.
+ *
+ * Hostname comparisons are case-insensitive. Wildcard patterns require the candidate hostname to end with the specified
+ * suffix, preceded by a dot. The suffix itself does not match a wildcard pattern.
+ *
+ * A {@link HostSpec} may specify a port to restrict matches to that port. If no port is specified, any port is
+ * accepted.
+ *
+ * A {@code null} socket address never matches. The original hostname representation is used for matching without
+ * requiring a reverse DNS lookup.
+ */
+public final class StaticHostnameMatcher implements InetSocketAddressMatcher
+{
+ public static InetSocketAddressMatcherList of(HostSpec... hostSpecs)
+ {
+ return of(hostSpecs == null ? List.of() : Arrays.asList(hostSpecs));
+ }
+
+ public static InetSocketAddressMatcherList of(Collection hostSpecs)
+ {
+ return new InetSocketAddressMatcherList(hostSpecs == null ? List.of()
+ : hostSpecs.stream().filter(HostSpec::isDomainOrWildcard).map(StaticHostnameMatcher::of).toList());
+ }
+
+ public static StaticHostnameMatcher of(HostSpec hostSpec)
+ {
+ Objects.requireNonNull(hostSpec, "hostSpec");
+
+ return new StaticHostnameMatcher(compile(hostSpec));
+ }
+
+ private final MatchPattern pattern;
+
+ private StaticHostnameMatcher(MatchPattern pattern)
+ {
+ this.pattern = Objects.requireNonNull(pattern, "pattern");
+ }
+
+ private static MatchPattern compile(HostSpec hostSpec)
+ {
+ return switch (hostSpec.kind())
+ {
+ case DOMAIN -> new ExactPattern(hostSpec.host(), hostSpec.port());
+ case DOMAIN_WILDCARD_SINGLE_LEVEL -> new SingleLevelWildcardPattern(hostSpec.host(), hostSpec.port());
+ case DOMAIN_WILDCARD_MULTI_LEVEL -> new MultiLevelWildcardPattern(hostSpec.host(), hostSpec.port());
+
+ default -> throw new IllegalArgumentException("hostSpec.kind '" + hostSpec.kind() + "' not supported");
+ };
+ }
+
+ private static boolean portMatches(Integer expectedPort, int candidatePort)
+ {
+ return expectedPort == null || expectedPort == candidatePort;
+ }
+
+ private static String toOptionalPortString(String hostname, Integer port)
+ {
+ return hostname + (port == null ? "" : ":" + port);
+ }
+
+ private interface MatchPattern
+ {
+ boolean matches(String hostname, int port);
+ }
+
+ /**
+ * Example: test.invalid, matches test.invalid, does not match foo.test.invalid
+ */
+ private record ExactPattern(String hostname, Integer port) implements MatchPattern
+ {
+ @Override
+ public boolean matches(String candidateHostname, int candidatePort)
+ {
+ return hostname.equalsIgnoreCase(candidateHostname) && portMatches(port, candidatePort);
+ }
+
+ @Override
+ public final String toString()
+ {
+ return toOptionalPortString(hostname, port);
+ }
+ }
+
+ /**
+ * Example: *.test.invalid, matches foo.test.invalid, bar.test.invalid, does not match foo.bar.test.invalid,
+ * test.invalid
+ */
+ private record SingleLevelWildcardPattern(String suffix, Integer port) implements MatchPattern
+ {
+ @Override
+ public boolean matches(String candidateHostname, int candidatePort)
+ {
+ if (!portMatches(port, candidatePort))
+ return false;
+
+ String prefix = subdomainPrefix(candidateHostname);
+
+ return prefix != null && prefix.indexOf('.') < 0;
+ }
+
+ private String subdomainPrefix(String candidateHostname)
+ {
+ if (candidateHostname.length() <= suffix.length() + 1)
+ return null;
+
+ int suffixStart = candidateHostname.length() - suffix.length();
+
+ if (!candidateHostname.regionMatches(true, suffixStart, suffix, 0, suffix.length()))
+ return null;
+
+ if (candidateHostname.charAt(suffixStart - 1) != '.')
+ return null;
+
+ return candidateHostname.substring(0, suffixStart - 1);
+ }
+
+ @Override
+ public final String toString()
+ {
+ return "*." + toOptionalPortString(suffix, port);
+ }
+ }
+
+ /**
+ * Example: **.test.invalid, matches foo.test.invalid, bar.test.invalid, foo.bar.test.invalid, does not match
+ * test.invalid
+ */
+ private record MultiLevelWildcardPattern(String suffix, Integer port) implements MatchPattern
+ {
+ @Override
+ public boolean matches(String candidateHostname, int candidatePort)
+ {
+ if (!portMatches(port, candidatePort))
+ return false;
+
+ if (candidateHostname.length() <= suffix.length() + 1)
+ return false;
+
+ int suffixStart = candidateHostname.length() - suffix.length();
+
+ return candidateHostname.regionMatches(true, suffixStart, suffix, 0, suffix.length())
+ && candidateHostname.charAt(suffixStart - 1) == '.';
+ }
+
+ @Override
+ public final String toString()
+ {
+ return "**." + toOptionalPortString(suffix, port);
+ }
+ }
+
+ @Override
+ public boolean matches(InetSocketAddress address)
+ {
+ if (address == null)
+ return false;
+
+ String hostname = address.getHostString();
+ int port = address.getPort();
+
+ return pattern.matches(hostname, port);
+ }
+
+ @Override
+ public String toString()
+ {
+ return pattern.toString();
+ }
+}
\ No newline at end of file
diff --git a/dsf-common/dsf-common-config/src/test/java/dev/dsf/common/config/ProxyConfigTest.java b/dsf-common/dsf-common-config/src/test/java/dev/dsf/common/config/ProxyConfigTest.java
index bed2ce56b..55c8ad55f 100644
--- a/dsf-common/dsf-common-config/src/test/java/dev/dsf/common/config/ProxyConfigTest.java
+++ b/dsf-common/dsf-common-config/src/test/java/dev/dsf/common/config/ProxyConfigTest.java
@@ -15,6 +15,7 @@
*/
package dev.dsf.common.config;
+import static dev.dsf.common.config.network.HostSpecParser.parse;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
@@ -26,6 +27,8 @@
import org.junit.Test;
+import dev.dsf.common.config.network.HostSpecParser.HostSpec;
+
public class ProxyConfigTest
{
@Test
@@ -55,7 +58,7 @@ public void testGetMethods() throws Exception
String url = "http://proxy", username = "username";
char[] password = "password".toCharArray();
// Arrays.asList as we need a null element, not allowed in List.of
- List noProxy = Arrays.asList(null, " ", "no-proxy");
+ List noProxy = parse(Arrays.asList(null, " ", "no-proxy"));
ProxyConfigImpl c = new ProxyConfigImpl(url, username, password, noProxy);
assertEquals(url, c.getUrl());
@@ -70,16 +73,15 @@ public void testGetMethods() throws Exception
public void testIsEnabled() throws Exception
{
assertTrue(new ProxyConfigImpl("http://proxy", null, null, null).isEnabled());
- assertTrue(new ProxyConfigImpl("http://proxy", null, null, List.of("foo")).isEnabled());
+ assertTrue(new ProxyConfigImpl("http://proxy", null, null, List.of(parse("foo"))).isEnabled());
assertFalse(new ProxyConfigImpl(null, null, null, null).isEnabled());
- assertFalse(new ProxyConfigImpl("http://proxy", null, null, List.of("*")).isEnabled());
}
@Test
public void testIsEndabled() throws Exception
{
ProxyConfig proxyConfig = new ProxyConfigImpl("http://proxy", null, null,
- List.of("foo.bar", "foo.bar.baz:8080", "test:1234"));
+ parse("foo.bar", "*.foo.bar", "foo.bar.baz:8080", "test:1234"));
assertFalse(proxyConfig.isEnabled("http://foo.bar"));
assertFalse(proxyConfig.isEnabled("http://foo.bar:8080"));
@@ -87,7 +89,7 @@ public void testIsEndabled() throws Exception
assertFalse(proxyConfig.isEnabled("https://foo.bar:8443"));
assertFalse(proxyConfig.isEnabled("http://test.foo.bar"));
assertFalse(proxyConfig.isEnabled("https://test.foo.bar:443"));
- assertFalse(proxyConfig.isEnabled("https://test.test:1234"));
+ assertFalse(proxyConfig.isEnabled("https://test:1234"));
assertFalse(proxyConfig.isEnabled("http://foo.bar.baz:8080"));
assertFalse(proxyConfig.isEnabled("https://foo.bar.baz:8080"));
@@ -96,41 +98,18 @@ public void testIsEndabled() throws Exception
assertTrue(proxyConfig.isEnabled("http://bar.baz"));
assertTrue(proxyConfig.isEnabled("https://bar.baz"));
assertTrue(proxyConfig.isEnabled("https://test.test"));
- }
-
- @Test
- public void testIsEnabledAllNoProxy() throws Exception
- {
- ProxyConfig proxyConfig = new ProxyConfigImpl(null, null, null, List.of("*"));
-
- assertFalse(proxyConfig.isEnabled("http://foo.bar"));
- assertFalse(proxyConfig.isEnabled("http://foo.bar:8080"));
- assertFalse(proxyConfig.isEnabled("https://foo.bar"));
- assertFalse(proxyConfig.isEnabled("https://foo.bar:8443"));
- assertFalse(proxyConfig.isEnabled("http://test.foo.bar"));
- assertFalse(proxyConfig.isEnabled("https://test.foo.bar:443"));
-
- assertFalse(proxyConfig.isEnabled("http://foo.bar.baz:8080"));
- assertFalse(proxyConfig.isEnabled("https://foo.bar.baz:8080"));
- assertFalse(proxyConfig.isEnabled("http://foo.bar.baz"));
- assertFalse(proxyConfig.isEnabled("https://foo.bar.baz"));
- assertFalse(proxyConfig.isEnabled("http://bar.baz"));
- assertFalse(proxyConfig.isEnabled("https://bar.baz"));
+ assertTrue(proxyConfig.isEnabled("https://test.test:1234"));
}
@Test
public void testIsEnabledNull() throws Exception
{
assertFalse(new ProxyConfigImpl("http://proxy", null, null, null).isEnabled(null));
- assertFalse(new ProxyConfigImpl("http://proxy", null, null, List.of("*")).isEnabled(null));
- assertFalse(new ProxyConfigImpl("http://proxy", null, null, null).isEnabled(null));
}
@Test
public void testIsEnabledBlank() throws Exception
{
- assertFalse(new ProxyConfigImpl("http://proxy", null, null, List.of("*")).isEnabled(""));
- assertFalse(new ProxyConfigImpl("http://proxy", null, null, List.of("*")).isEnabled(" "));
assertFalse(new ProxyConfigImpl("http://proxy", null, null, null).isEnabled(""));
assertFalse(new ProxyConfigImpl("http://proxy", null, null, null).isEnabled(" "));
}
@@ -138,14 +117,13 @@ public void testIsEnabledBlank() throws Exception
@Test
public void testIsEnabledMalformedUrl() throws Exception
{
- assertFalse(new ProxyConfigImpl("http://proxy", null, null, List.of("*")).isEnabled("malformed"));
assertTrue(new ProxyConfigImpl("http://proxy", null, null, null).isEnabled(":malformed"));
assertTrue(new ProxyConfigImpl("http://proxy", null, null, null).isEnabled("malformed"));
}
- @Test(expected = IllegalArgumentException.class)
+ @Test
public void testIsEnabledSchemaNotSupported() throws Exception
{
- new ProxyConfigImpl("http://proxy", null, null, null).isEnabled("foo://bar");
+ assertTrue(new ProxyConfigImpl("http://proxy", null, null, null).isEnabled("foo://bar"));
}
}
diff --git a/dsf-common/dsf-common-config/src/test/java/dev/dsf/common/config/network/DynamicHostnameMatcherTest.java b/dsf-common/dsf-common-config/src/test/java/dev/dsf/common/config/network/DynamicHostnameMatcherTest.java
new file mode 100644
index 000000000..d8cbe851e
--- /dev/null
+++ b/dsf-common/dsf-common-config/src/test/java/dev/dsf/common/config/network/DynamicHostnameMatcherTest.java
@@ -0,0 +1,184 @@
+/*
+ * Copyright 2018-2025 Heilbronn University of Applied Sciences
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package dev.dsf.common.config.network;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.UnknownHostException;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.junit.Test;
+
+public class DynamicHostnameMatcherTest
+{
+ private static final Duration REFRESH_TIMEOUT = Duration.ofMinutes(5);
+
+ @Test
+ public void matchesResolvedAddress() throws Exception
+ {
+ DynamicHostnameMatcher matcher = matcher(clockAt("2026-01-01T00:00:00Z"),
+ _ -> new InetAddress[] { InetAddress.ofLiteral("192.0.2.10") });
+
+ assertTrue(matcher.matches(new InetSocketAddress("192.0.2.10", 0)));
+ }
+
+ @Test
+ public void doesNotMatchDifferentAddress() throws Exception
+ {
+ DynamicHostnameMatcher matcher = matcher(clockAt("2026-01-01T00:00:00Z"),
+ _ -> new InetAddress[] { InetAddress.ofLiteral("192.0.2.10") });
+
+ assertFalse(matcher.matches(new InetSocketAddress("192.0.2.11", 0)));
+ }
+
+ @Test
+ public void doesNotMatchUnresolvedAddress()
+ {
+ DynamicHostnameMatcher matcher = matcher(clockAt("2026-01-01T00:00:00Z"),
+ _ -> new InetAddress[] { InetAddress.getLoopbackAddress() });
+
+ assertFalse(matcher.matches(InetSocketAddress.createUnresolved("example.com", 0)));
+ }
+
+ @Test
+ public void refreshesAfterTimeout() throws Exception
+ {
+ Instant initialTime = Instant.parse("2026-01-01T00:00:00Z");
+ AtomicReference currentTime = new AtomicReference<>(initialTime);
+
+ InetAddress firstAddress = InetAddress.ofLiteral("192.0.2.10");
+ AtomicReference resolved = new AtomicReference<>(new InetAddress[] { firstAddress });
+
+ DynamicHostnameMatcher matcher = matcher(new MutableClock(currentTime), _ -> resolved.get());
+
+ assertTrue(matcher.matches(new InetSocketAddress("192.0.2.10", 443)));
+
+ // Change DNS result, but cache has not expired
+ InetAddress secondAddress = InetAddress.ofLiteral("192.0.2.20");
+ resolved.set(new InetAddress[] { secondAddress });
+ currentTime.set(initialTime.plus(Duration.ofMinutes(4)));
+
+ assertTrue(matcher.matches(new InetSocketAddress("192.0.2.10", 443)));
+
+ // Cache expired, so DNS is resolved again
+ currentTime.set(initialTime.plus(Duration.ofMinutes(6)));
+
+ assertFalse(matcher.matches(new InetSocketAddress("192.0.2.10", 443)));
+ assertTrue(matcher.matches(new InetSocketAddress("192.0.2.20", 443)));
+ }
+
+ @Test
+ public void failsClosedWhenRefreshFails() throws Exception
+ {
+ Instant initialTime = Instant.parse("2026-01-01T00:00:00Z");
+ AtomicReference currentTime = new AtomicReference<>(initialTime);
+
+ InetAddress allowed = InetAddress.ofLiteral("192.0.2.10");
+
+ AtomicReference fail = new AtomicReference<>(false);
+ DynamicHostnameMatcher matcher = matcher(new MutableClock(currentTime), _ ->
+ {
+ if (fail.get())
+ throw new UnknownHostException("DNS temporarily unavailable");
+
+ return new InetAddress[] { allowed };
+ });
+
+ // Initially the address is allowed
+ assertTrue(matcher.matches(new InetSocketAddress("192.0.2.10", 443)));
+
+ // Cause the next DNS refresh to fail
+ fail.set(true);
+
+ currentTime.set(initialTime.plus(Duration.ofMinutes(6)));
+
+ // The old address must no longer be authorized
+ assertFalse(matcher.matches(new InetSocketAddress("192.0.2.10", 443)));
+ }
+
+ @Test
+ public void recoversAfterFailedRefresh() throws Exception
+ {
+ Instant initialTime = Instant.parse("2026-01-01T00:00:00Z");
+ AtomicReference currentTime = new AtomicReference<>(initialTime);
+
+ InetAddress allowed = InetAddress.ofLiteral("192.0.2.10");
+
+ AtomicReference fail = new AtomicReference<>(true);
+ DynamicHostnameMatcher matcher = matcher(new MutableClock(currentTime), _ ->
+ {
+ if (fail.get())
+ throw new UnknownHostException("DNS failure");
+
+ return new InetAddress[] { allowed };
+ });
+
+ // Constructor refresh failed, so access is denied
+ assertFalse(matcher.matches(new InetSocketAddress("192.0.2.10", 443)));
+
+ // Allow DNS to recover
+ fail.set(false);
+
+ currentTime.set(initialTime.plus(Duration.ofMinutes(6)));
+
+ assertTrue(matcher.matches(new InetSocketAddress("192.0.2.10", 443)));
+ }
+
+ private static DynamicHostnameMatcher matcher(Clock clock, DynamicHostnameMatcher.DnsResolver resolver)
+ {
+ return new DynamicHostnameMatcher(REFRESH_TIMEOUT, "example.com", clock, resolver);
+ }
+
+ private static Clock clockAt(String instant)
+ {
+ return Clock.fixed(Instant.parse(instant), ZoneOffset.UTC);
+ }
+
+ private static final class MutableClock extends Clock
+ {
+ private final AtomicReference currentTime;
+
+ private MutableClock(AtomicReference currentTime)
+ {
+ this.currentTime = currentTime;
+ }
+
+ @Override
+ public ZoneOffset getZone()
+ {
+ return ZoneOffset.UTC;
+ }
+
+ @Override
+ public Clock withZone(java.time.ZoneId zone)
+ {
+ return this;
+ }
+
+ @Override
+ public Instant instant()
+ {
+ return currentTime.get();
+ }
+ }
+}
diff --git a/dsf-common/dsf-common-config/src/test/java/dev/dsf/common/config/network/HostSpecParserTest.java b/dsf-common/dsf-common-config/src/test/java/dev/dsf/common/config/network/HostSpecParserTest.java
new file mode 100644
index 000000000..afd9119e5
--- /dev/null
+++ b/dsf-common/dsf-common-config/src/test/java/dev/dsf/common/config/network/HostSpecParserTest.java
@@ -0,0 +1,329 @@
+/*
+ * Copyright 2018-2025 Heilbronn University of Applied Sciences
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package dev.dsf.common.config.network;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.fail;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+
+import dev.dsf.common.config.network.HostSpecParser.HostSpec;
+import dev.dsf.common.config.network.HostSpecParser.Kind;
+
+@RunWith(Parameterized.class)
+public class HostSpecParserTest
+{
+ private final String input;
+ private final boolean valid;
+
+ private final HostSpecParser.Kind expectedKind;
+ private final String expectedHost;
+ private final Integer expectedPrefixLength;
+ private final Integer expectedPort;
+
+ public HostSpecParserTest(String input, boolean valid, HostSpecParser.Kind expectedKind, String expectedHost,
+ Integer expectedPrefixLength, Integer expectedPort)
+ {
+ this.input = input;
+ this.valid = valid;
+ this.expectedKind = expectedKind;
+ this.expectedHost = expectedHost;
+ this.expectedPrefixLength = expectedPrefixLength;
+ this.expectedPort = expectedPort;
+ }
+
+ @Parameterized.Parameters(name = "{index}: {0}")
+ public static Collection