diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ConnectedPlayer.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ConnectedPlayer.java index 8d39fcf9f6..fb840b0d3d 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ConnectedPlayer.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ConnectedPlayer.java @@ -99,6 +99,7 @@ import com.velocitypowered.proxy.tablist.KeyedVelocityTabList; import com.velocitypowered.proxy.tablist.VelocityTabList; import com.velocitypowered.proxy.tablist.VelocityTabListLegacy; +import com.velocitypowered.proxy.util.AddressUtil; import com.velocitypowered.proxy.util.ClosestLocaleMatcher; import com.velocitypowered.proxy.util.DurationUtils; import com.velocitypowered.proxy.util.TranslatableMapper; @@ -890,8 +891,7 @@ private Optional getNextServerToTry(@Nullable RegisteredServer String virtualHostStr = getVirtualHost().map(InetSocketAddress::getHostString) .orElse("") .toLowerCase(Locale.ROOT); - serversToTry = server.getConfiguration().getForcedHosts().getOrDefault(virtualHostStr, - Collections.emptyList()); + serversToTry = AddressUtil.resolveForcedHostServers(server, virtualHostStr).orElseGet(Collections::emptyList); } if (serversToTry.isEmpty()) { diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/util/ServerListPingHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/util/ServerListPingHandler.java index 5ccb2b1105..7d5a3eaf1d 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/util/ServerListPingHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/util/ServerListPingHandler.java @@ -28,6 +28,7 @@ import com.velocitypowered.proxy.config.PingPassthroughMode; import com.velocitypowered.proxy.config.VelocityConfiguration; import com.velocitypowered.proxy.server.VelocityRegisteredServer; +import com.velocitypowered.proxy.util.AddressUtil; import java.net.InetSocketAddress; import java.util.ArrayList; import java.util.Collections; @@ -174,8 +175,8 @@ public CompletableFuture getInitialPing(VelocityInboundConnection co String virtualHostStr = connection.getVirtualHost().map(InetSocketAddress::getHostString) .map(str -> str.toLowerCase(Locale.ROOT)) .orElse(""); - List serversToTry = server.getConfiguration().getForcedHosts().getOrDefault( - virtualHostStr, server.getConfiguration().getAttemptConnectionOrder()); + List serversToTry = AddressUtil.resolveForcedHostServers(server, virtualHostStr) + .orElseGet(server.getConfiguration()::getAttemptConnectionOrder); return attemptPingPassthrough(connection, passthroughMode, serversToTry, shownVersion, virtualHostStr); } } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/util/AddressUtil.java b/proxy/src/main/java/com/velocitypowered/proxy/util/AddressUtil.java index 42c5228e89..02744fbcbb 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/util/AddressUtil.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/util/AddressUtil.java @@ -19,9 +19,14 @@ import com.google.common.base.Preconditions; import com.google.common.net.InetAddresses; +import com.velocitypowered.proxy.VelocityServer; import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import javax.annotation.Nullable; /** * Utilities to parse addresses. @@ -74,4 +79,69 @@ public static InetSocketAddress parseAndResolveAddress(String ip) { int port = uri.getPort() == -1 ? DEFAULT_MINECRAFT_PORT : uri.getPort(); return new InetSocketAddress(uri.getHost(), port); } + + /** + * Tests whether a host matches a pattern whose labels may be the + * wildcard {@code "*"}. Each {@code "*"} matches exactly one label; all other + * labels match case-insensitively. + * + * @param pattern the pattern to match against, for example, {@code *.example.com} + * @param host the virtual host to test, for example, {@code play.example.com} + * @return true if the host matches the pattern, false otherwise + */ + public static boolean isHostMatchingPattern(@Nullable String pattern, @Nullable String host) { + if (host == null || pattern == null) { + return false; + } + + String[] patternDomains = pattern.split("\\."); + String[] strDomains = host.split("\\."); + + if (patternDomains.length != strDomains.length) { + return false; + } + + for (int i = 0; i < patternDomains.length; i++) { + String patternDomain = patternDomains[i]; + String strDomain = strDomains[i]; + + if (!patternDomain.equals("*") && !strDomain.equalsIgnoreCase(patternDomain)) { + return false; + } + } + + return true; + } + + /** + * Resolves the list of servers configured for a given virtual host via forced hosts. An exact + * match on the virtual host is preferred. Then, if none exist, the configured forced host patterns are + * checked in turn and the first matching pattern's servers are returned. + * + * @param server the proxy server providing the forced host configuration + * @param virtualHostStr the virtual host the client connected with + * @return the servers for the matching forced host, or {@link Optional#empty()} if none match + */ + public static Optional> resolveForcedHostServers(VelocityServer server, String virtualHostStr) { + Map> forcedHosts = server.getConfiguration().getForcedHosts(); + + // Check for exact match + List exactMatch = forcedHosts.get(virtualHostStr); + + if (exactMatch != null) { + return Optional.of(exactMatch); + } + + // Check for pattern match + for (Map.Entry> entry : forcedHosts.entrySet()) { + String virtualHostPattern = entry.getKey(); + + if (AddressUtil.isHostMatchingPattern(virtualHostPattern, virtualHostStr)) { + return Optional.of(entry.getValue()); + } + } + + // No match + return Optional.empty(); + } } diff --git a/proxy/src/test/java/com/velocitypowered/proxy/protocol/util/AddressUtilTest.java b/proxy/src/test/java/com/velocitypowered/proxy/protocol/util/AddressUtilTest.java new file mode 100644 index 0000000000..0ddee64fbd --- /dev/null +++ b/proxy/src/test/java/com/velocitypowered/proxy/protocol/util/AddressUtilTest.java @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2018-2026 Velocity Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.velocitypowered.proxy.protocol.util; + +import static com.velocitypowered.proxy.util.AddressUtil.isHostMatchingPattern; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class AddressUtilTest { + @Test + void testOneWildcardMatches() { + assertTrue(isHostMatchingPattern("*.example.com", "play.example.com")); + assertTrue(isHostMatchingPattern("*.example.com", "a.example.com")); + assertTrue(isHostMatchingPattern("b.*.example.com", "b.a.example.com")); + } + + @Test + void testMultipleWildcardsMatch() { + assertTrue(isHostMatchingPattern("*.*.example.com", "a.b.example.com")); + } + + @Test + void testDifferentNumberOfLabelsDoNotMatch() { + assertFalse(isHostMatchingPattern("*.example.com", "a.b.example.com")); + } + + @Test + void testWildcardsDoNotMatchApexDomain() { + assertFalse(isHostMatchingPattern("*.example.com", "example.com")); + } + + @Test + void testExactMatchesMatch() { + assertTrue(isHostMatchingPattern("example.com", "example.com")); + assertTrue(isHostMatchingPattern("play.example.com", "play.example.com")); + } + + @Test + void testDifferentDomainsDoNotMatch() { + assertFalse(isHostMatchingPattern("otherdomain.com", "example.com")); + assertFalse(isHostMatchingPattern("a.otherdomain.com", "a.example.com")); + } + + @Test + void testMalformedArgumentsDoNotMatch() { + assertFalse(isHostMatchingPattern(null, "example.com")); + assertFalse(isHostMatchingPattern("example.com", null)); + assertFalse(isHostMatchingPattern(null, null)); + } + + @Test + void testCaseInsensitivityMatches() { + assertTrue(isHostMatchingPattern("Example.COM", "example.com")); + assertTrue(isHostMatchingPattern("*.Example.com", "play.EXAMPLE.com")); + } +}