Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"mcp__acp__Bash"
]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@
import com.electronwill.nightconfig.core.CommentedConfig;
import com.electronwill.nightconfig.core.UnmodifiableConfig;
import com.electronwill.nightconfig.core.file.CommentedFileConfig;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.MoreObjects;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.gson.annotations.Expose;
import com.velocitypowered.api.proxy.config.ProxyConfig;
import com.velocitypowered.api.util.Favicon;
import com.velocitypowered.proxy.config.migration.ConfigurationMigration;
import com.velocitypowered.proxy.config.migration.ForcedHostSubdomainMigration;
import com.velocitypowered.proxy.config.migration.ForwardingMigration;
import com.velocitypowered.proxy.config.migration.KeyAuthenticationMigration;
import com.velocitypowered.proxy.config.migration.MiniMessageTranslationsMigration;
Expand Down Expand Up @@ -323,6 +325,45 @@ public Map<String, List<String>> getForcedHosts() {
return forcedHosts.getForcedHosts();
}

/**
* Resolves the forced host key for a given virtual hostname.
* If subdomain matching is enabled, falls back to suffix matching on domain
* boundaries when an exact match is not found.
*
* @param virtualHost the lowercased virtual hostname to look up
* @return the matching forced host key, or null if no match
*/
public @Nullable String resolveMatchedForcedHost(String virtualHost) {
return resolveMatchedForcedHost(forcedHosts.getForcedHosts(), virtualHost,
forcedHosts.isSubdomainMatching());
}

@VisibleForTesting
static @Nullable String resolveMatchedForcedHost(Map<String, List<String>> forcedHosts,
String virtualHost, boolean subdomainMatching) {
// Try exact match first (always)
if (forcedHosts.containsKey(virtualHost)) {
return virtualHost;
}

// If subdomain matching is enabled, try suffix matching on domain boundaries
if (subdomainMatching && !virtualHost.isEmpty()) {
for (String configuredHost : forcedHosts.keySet()) {
if (virtualHost.endsWith("." + configuredHost)) {
logger.debug("Forced host subdomain match: '{}' matched configured host '{}'",
virtualHost, configuredHost);
return configuredHost;
}
}
}

if (!virtualHost.isEmpty()) {
logger.debug("No forced host match for virtual host '{}' (subdomain-matching: {})",
virtualHost, subdomainMatching);
}
return null;
}

@Override
public int getCompressionThreshold() {
return advanced.getCompressionThreshold();
Expand Down Expand Up @@ -503,7 +544,8 @@ public static VelocityConfiguration read(Path path) throws IOException {
new KeyAuthenticationMigration(),
new MotdMigration(),
new MiniMessageTranslationsMigration(),
new TransferIntegrationMigration()
new TransferIntegrationMigration(),
new ForcedHostSubdomainMigration()
};

for (final ConfigurationMigration migration : migrations) {
Expand Down Expand Up @@ -628,6 +670,9 @@ private Servers(CommentedConfig config) {
if (config != null) {
Map<String, String> servers = new HashMap<>();
for (UnmodifiableConfig.Entry entry : config.entrySet()) {
if ("subdomain-matching".equals(entry.getKey())) {
continue;
}
if (entry.getValue() instanceof String) {
servers.put(cleanServerName(entry.getKey()), entry.getValue());
} else {
Expand Down Expand Up @@ -691,6 +736,7 @@ private static class ForcedHosts {
"factions.example.com", ImmutableList.of("factions"),
"minigames.example.com", ImmutableList.of("minigames")
);
private boolean subdomainMatching = false;

private ForcedHosts() {
}
Expand All @@ -699,18 +745,22 @@ private ForcedHosts(CommentedConfig config) {
if (config != null) {
Map<String, List<String>> forcedHosts = new HashMap<>();
for (UnmodifiableConfig.Entry entry : config.entrySet()) {
if ("subdomain-matching".equals(entry.getKey())) {
continue;
}
if (entry.getValue() instanceof String) {
forcedHosts.put(entry.getKey().toLowerCase(Locale.ROOT),
ImmutableList.of(entry.getValue()));
} else if (entry.getValue() instanceof List) {
forcedHosts.put(entry.getKey().toLowerCase(Locale.ROOT),
ImmutableList.copyOf((List<String>) entry.getValue()));
} else {
} else if (!(entry.getValue() instanceof Boolean)) {
throw new IllegalStateException(
"Invalid value of type " + entry.getValue().getClass() + " in forced hosts!");
}
}
this.forcedHosts = ImmutableMap.copyOf(forcedHosts);
this.subdomainMatching = config.getOrElse("subdomain-matching", false);
}
}

Expand All @@ -722,6 +772,10 @@ private Map<String, List<String>> getForcedHosts() {
return forcedHosts;
}

private boolean isSubdomainMatching() {
return subdomainMatching;
}

private void setForcedHosts(Map<String, List<String>> forcedHosts) {
this.forcedHosts = forcedHosts;
}
Expand All @@ -730,6 +784,7 @@ private void setForcedHosts(Map<String, List<String>> forcedHosts) {
public String toString() {
return "ForcedHosts{"
+ "forcedHosts=" + forcedHosts
+ ", subdomainMatching=" + subdomainMatching
+ '}';
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ public sealed interface ConfigurationMigration
KeyAuthenticationMigration,
MotdMigration,
MiniMessageTranslationsMigration,
TransferIntegrationMigration {
TransferIntegrationMigration,
ForcedHostSubdomainMigration {
boolean shouldMigrate(CommentedFileConfig config);

void migrate(CommentedFileConfig config, Logger logger) throws IOException;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* Copyright (C) 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 <https://www.gnu.org/licenses/>.
*/

package com.velocitypowered.proxy.config.migration;

import com.electronwill.nightconfig.core.file.CommentedFileConfig;
import org.apache.logging.log4j.Logger;

/**
* Creation of the configuration option "forced-hosts.subdomain-matching".
*/
public final class ForcedHostSubdomainMigration implements ConfigurationMigration {
@Override
public boolean shouldMigrate(final CommentedFileConfig config) {
return configVersion(config) < 2.8;
}

@Override
public void migrate(final CommentedFileConfig config, final Logger logger) {
config.set("forced-hosts.subdomain-matching", false);
config.setComment("forced-hosts.subdomain-matching", """
When enabled, if no exact forced host match is found, Velocity will check if
the hostname is a subdomain of any configured forced host (on a dot boundary).
Useful when DNS services prepend prefixes to hostnames.""");
config.set("config-version", "2.8");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -888,8 +888,10 @@ private Optional<RegisteredServer> getNextServerToTry(@Nullable RegisteredServer
String virtualHostStr = getVirtualHost().map(InetSocketAddress::getHostString)
.orElse("")
.toLowerCase(Locale.ROOT);
serversToTry = server.getConfiguration().getForcedHosts().getOrDefault(virtualHostStr,
Collections.emptyList());
String matchedHost = server.getConfiguration().resolveMatchedForcedHost(virtualHostStr);
serversToTry = matchedHost != null
? server.getConfiguration().getForcedHosts().get(matchedHost)
: Collections.emptyList();
}

if (serversToTry.isEmpty()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
import io.netty.buffer.ByteBuf;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.util.Locale;
import java.util.Optional;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
Expand Down Expand Up @@ -93,8 +94,14 @@ public boolean handle(final HandshakePacket handshake) {
LOGGER.error("{} provided invalid protocol {}", this, handshake.getNextStatus());
connection.close(true);
} else {
String cleaned = cleanVhost(handshake.getServerAddress());
final String matched = server.getConfiguration()
.resolveMatchedForcedHost(cleaned.toLowerCase(Locale.ROOT));
if (matched != null && !matched.equalsIgnoreCase(cleaned)) {
cleaned = matched;
}
final InitialInboundConnection ic = new InitialInboundConnection(connection,
cleanVhost(handshake.getServerAddress()), handshake);
cleaned, handshake);
if (handshake.getIntent() == HandshakeIntent.TRANSFER
&& !server.getConfiguration().isAcceptTransfers()) {
ic.disconnect(Component.translatable("multiplayer.disconnect.transfers_disabled"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,8 +174,10 @@ public CompletableFuture<ServerPing> getInitialPing(VelocityInboundConnection co
String virtualHostStr = connection.getVirtualHost().map(InetSocketAddress::getHostString)
.map(str -> str.toLowerCase(Locale.ROOT))
.orElse("");
List<String> serversToTry = server.getConfiguration().getForcedHosts().getOrDefault(
virtualHostStr, server.getConfiguration().getAttemptConnectionOrder());
String matchedHost = server.getConfiguration().resolveMatchedForcedHost(virtualHostStr);
List<String> serversToTry = matchedHost != null
? server.getConfiguration().getForcedHosts().get(matchedHost)
: server.getConfiguration().getAttemptConnectionOrder();
return attemptPingPassthrough(connection, passthroughMode, serversToTry, shownVersion, virtualHostStr);
}
}
Expand Down
10 changes: 9 additions & 1 deletion proxy/src/main/resources/default-velocity.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Config version. Do not change this
config-version = "2.7"
config-version = "2.8"

# What port should the proxy be bound to? By default, we'll bind to all addresses on port 25565.
bind = "0.0.0.0:25565"
Expand Down Expand Up @@ -98,6 +98,14 @@ try = [
"minigames"
]

# When enabled, if no exact forced host match is found, Velocity will check if the hostname
# is a subdomain of any configured forced host (on a dot boundary). This is useful when DNS
# services (e.g., Cloudflare proxied SRV records) prepend prefixes to your hostname.
# For example, if "play.example.com" is a forced host and a player connects via
# "_dc-srv.abc123.play.example.com", it will match "play.example.com".
# Default: false
subdomain-matching = false

[advanced]
# How large a Minecraft packet has to be before we compress it. Setting this to zero will
# compress all packets, and setting it to -1 will disable compression entirely.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/

package com.velocitypowered.proxy.config;

import static com.velocitypowered.proxy.config.VelocityConfiguration.resolveMatchedForcedHost;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;

import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;

class ForcedHostResolutionTest {

private static final Map<String, List<String>> HOSTS = Map.of(
"play.example.com", List.of("lobby"),
"factions.example.com", List.of("factions"),
"hub.example.net", List.of("hub")
);

// --- Exact matching (subdomain matching OFF) ---

@Test
void exactMatchReturnsKey() {
assertEquals("play.example.com",
resolveMatchedForcedHost(HOSTS, "play.example.com", false));
}

@Test
void noMatchReturnsNull() {
assertNull(resolveMatchedForcedHost(HOSTS, "unknown.example.com", false));
}

@Test
void subdomainDoesNotMatchWhenDisabled() {
assertNull(resolveMatchedForcedHost(HOSTS, "_dc-srv.abc.play.example.com", false));
}

// --- Subdomain matching (enabled) ---

@Test
void subdomainMatchesWhenEnabled() {
assertEquals("play.example.com",
resolveMatchedForcedHost(HOSTS, "_dc-srv.abc.play.example.com", true));
}

@Test
void exactMatchTakesPriorityOverSubdomain() {
assertEquals("play.example.com",
resolveMatchedForcedHost(HOSTS, "play.example.com", true));
}

@Test
void subdomainMatchRequiresDotBoundary() {
assertNull(resolveMatchedForcedHost(HOSTS, "notplay.example.com", true));
}

@Test
void deepSubdomainMatches() {
assertEquals("play.example.com",
resolveMatchedForcedHost(HOSTS, "a.b.c.play.example.com", true));
}

@Test
void emptyVirtualHostReturnsNull() {
assertNull(resolveMatchedForcedHost(HOSTS, "", true));
}

@Test
void noMatchSubdomainEnabledReturnsNull() {
assertNull(resolveMatchedForcedHost(HOSTS, "totally.different.org", true));
}
}
Loading