feat(config): map VelocityConfiguration to YAML via Configurate ObjectMapper

Wire up the Configurate ObjectMapper path for the new velocity.yml format:

- Make VelocityConfiguration ObjectMapper-friendly: no-arg constructor,
  non-final nested fields, transient on non-config fields (forwardingSecret,
  motdAsComponent, favicon), and drop the dead gson @Expose annotations.
- Annotate Advanced/Query/Metrics @ConfigSerializable and add @Setting for the
  keys the lower-case-dashed naming scheme can't derive (kick-existing-players,
  packet-limiter, haproxy-protocol, accepts-transfers, query enabled/port/map).
- Add ConfigurationLoader with a LOWER_CASE_DASHED ObjectMapper factory, a YAML
  loader builder, load/save helpers, and custom TypeSerializers for the dynamic
  sections that don't fit object mapping: Servers (entries + try), ForcedHosts,
  and PacketLimiterConfig (renamed keys).
- Add ConfigurationLoaderTest: loads the bundled default, and round-trips a
  config with non-default values for every renamed/custom-mapped key so a wrong
  mapping can't silently fall back to an identical default.

Part of the velocity.toml -> velocity.yml Configurate migration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Shane Freeder
2026-06-17 21:15:33 +01:00
parent b157b30ac5
commit d0e654d1ca
3 changed files with 378 additions and 68 deletions

View File

@@ -17,30 +17,209 @@
package com.velocitypowered.proxy.config; package com.velocitypowered.proxy.config;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import java.io.IOException;
import java.lang.reflect.Type;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.spongepowered.configurate.CommentedConfigurationNode;
import org.spongepowered.configurate.ConfigurationNode;
import org.spongepowered.configurate.objectmapping.ObjectMapper;
import org.spongepowered.configurate.serialize.SerializationException;
import org.spongepowered.configurate.serialize.TypeSerializer;
import org.spongepowered.configurate.util.NamingSchemes;
import org.spongepowered.configurate.yaml.NodeStyle;
import org.spongepowered.configurate.yaml.YamlConfigurationLoader;
/** /**
* Velocity Configurate Loader entry utils. * Velocity Configurate (YAML) loader entry utils.
*/ */
public class ConfigurationLoader { public final class ConfigurationLoader {
/**
* ObjectMapper factory configured to map {@code camelCase} fields onto {@code lower-case-dashed}
* configuration keys, matching the historical TOML key style.
*/
private static final ObjectMapper.Factory OBJECT_MAPPER_FACTORY = ObjectMapper.factoryBuilder()
.defaultNamingScheme(NamingSchemes.LOWER_CASE_DASHED)
.build();
private ConfigurationLoader() { private ConfigurationLoader() {
} }
/** /**
* performs legacy configuration migration if needed. * Builds a YAML loader for the given {@code path}, wired with the serializers needed to map a
* {@link VelocityConfiguration}.
* *
* @return {@code true} if a migration was performed, {@code false} otherwise * @param path the configuration file path
* @return the configured loader builder
*/ */
public static boolean migrateIfNeeded() { static YamlConfigurationLoader.Builder yamlLoader(final Path path) {
return false; return YamlConfigurationLoader.builder()
.path(path)
.nodeStyle(NodeStyle.BLOCK)
.indent(2)
.defaultOptions(opts -> opts.serializers(builder -> builder
.register(VelocityConfiguration.Servers.class, new ServersSerializer())
.register(VelocityConfiguration.ForcedHosts.class, new ForcedHostsSerializer())
.register(VelocityConfiguration.PacketLimiterConfig.class,
new PacketLimiterConfigSerializer())
.registerAnnotatedObjects(OBJECT_MAPPER_FACTORY)));
} }
/** /**
* loads the velocity configuration. * Reads a {@link VelocityConfiguration} from the YAML file at {@code path}.
* *
* @return the loaded configuration * @param path the configuration file path
* @return the deserialized configuration
* @throws IOException if the file could not be read or deserialized
*/ */
public static VelocityConfiguration loadConfiguration() { static VelocityConfiguration load(final Path path) throws IOException {
return null; final CommentedConfigurationNode node = yamlLoader(path).build().load();
final VelocityConfiguration config = node.get(VelocityConfiguration.class);
if (config == null) {
throw new IOException("Unable to deserialize configuration from " + path);
}
return config;
}
/**
* Writes a {@link VelocityConfiguration} to the YAML file at {@code path}.
*
* @param config the configuration to write
* @param path the configuration file path
* @throws IOException if the file could not be written
*/
static void save(final VelocityConfiguration config, final Path path) throws IOException {
final YamlConfigurationLoader loader = yamlLoader(path).build();
final CommentedConfigurationNode node = loader.createNode();
node.set(VelocityConfiguration.class, config);
loader.save(node);
}
/**
* Serializes {@code config} onto the provided {@code node}. Exposed for migration tooling.
*
* @param config the configuration to serialize
* @param node the node to write to
* @throws SerializationException if serialization fails
*/
static void write(final VelocityConfiguration config, final ConfigurationNode node)
throws SerializationException {
node.set(VelocityConfiguration.class, config);
}
/**
* Serializes the dynamic {@code [servers]} section, where named server entries live alongside the
* {@code try} fallback order in a single node.
*/
static final class ServersSerializer implements TypeSerializer<VelocityConfiguration.Servers> {
@Override
public VelocityConfiguration.Servers deserialize(final Type type, final ConfigurationNode node)
throws SerializationException {
final Map<String, String> servers = new LinkedHashMap<>();
List<String> attemptConnectionOrder = ImmutableList.of("lobby");
for (final Map.Entry<Object, ? extends ConfigurationNode> entry
: node.childrenMap().entrySet()) {
final String key = entry.getKey().toString();
final ConfigurationNode value = entry.getValue();
if (key.equalsIgnoreCase("try")) {
attemptConnectionOrder = value.getList(String.class, ImmutableList.of());
} else {
final String address = value.getString();
if (address == null) {
throw new SerializationException("Server entry " + key + " is not a string!");
}
servers.put(VelocityConfiguration.Servers.cleanServerName(key), address);
}
}
return new VelocityConfiguration.Servers(ImmutableMap.copyOf(servers),
ImmutableList.copyOf(attemptConnectionOrder));
}
@Override
public void serialize(final Type type, final VelocityConfiguration.@Nullable Servers obj,
final ConfigurationNode node) throws SerializationException {
if (obj == null) {
node.raw(null);
return;
}
for (final Map.Entry<String, String> entry : obj.getServers().entrySet()) {
node.node(entry.getKey()).set(entry.getValue());
}
node.node("try").setList(String.class, obj.getAttemptConnectionOrder());
}
}
/**
* Serializes the dynamic {@code [forced-hosts]} section (host pattern to server list map).
*/
static final class ForcedHostsSerializer
implements TypeSerializer<VelocityConfiguration.ForcedHosts> {
@Override
public VelocityConfiguration.ForcedHosts deserialize(final Type type,
final ConfigurationNode node) throws SerializationException {
final Map<String, List<String>> forcedHosts = new LinkedHashMap<>();
for (final Map.Entry<Object, ? extends ConfigurationNode> entry
: node.childrenMap().entrySet()) {
final String key = entry.getKey().toString().toLowerCase(Locale.ROOT);
forcedHosts.put(key,
ImmutableList.copyOf(entry.getValue().getList(String.class, ImmutableList.of())));
}
return new VelocityConfiguration.ForcedHosts(ImmutableMap.copyOf(forcedHosts));
}
@Override
public void serialize(final Type type, final VelocityConfiguration.@Nullable ForcedHosts obj,
final ConfigurationNode node) throws SerializationException {
if (obj == null) {
node.raw(null);
return;
}
for (final Map.Entry<String, List<String>> entry : obj.getForcedHosts().entrySet()) {
node.node(entry.getKey()).setList(String.class, entry.getValue());
}
}
}
/**
* Serializes the {@code [packet-limiter]} section, whose keys do not follow the field naming
* scheme (for example {@code packets-per-second} maps to {@code pps}).
*/
static final class PacketLimiterConfigSerializer
implements TypeSerializer<VelocityConfiguration.PacketLimiterConfig> {
@Override
public VelocityConfiguration.PacketLimiterConfig deserialize(final Type type,
final ConfigurationNode node) {
final VelocityConfiguration.PacketLimiterConfig def =
VelocityConfiguration.PacketLimiterConfig.DEFAULT;
return new VelocityConfiguration.PacketLimiterConfig(
node.node("interval").getInt(def.interval()),
node.node("packets-per-second").getInt(def.pps()),
node.node("bytes-per-second").getInt(def.bytes()),
node.node("decompressed-bytes-per-second").getInt(def.bytesAfterDecompression()));
}
@Override
public void serialize(final Type type,
final VelocityConfiguration.@Nullable PacketLimiterConfig obj, final ConfigurationNode node)
throws SerializationException {
if (obj == null) {
node.raw(null);
return;
}
node.node("interval").set(obj.interval());
node.node("packets-per-second").set(obj.pps());
node.node("bytes-per-second").set(obj.bytes());
node.node("decompressed-bytes-per-second").set(obj.bytesAfterDecompression());
}
} }
} }

View File

@@ -21,7 +21,6 @@ import com.electronwill.nightconfig.core.CommentedConfig;
import com.google.common.base.MoreObjects; import com.google.common.base.MoreObjects;
import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableMap;
import com.google.gson.annotations.Expose;
import com.velocitypowered.api.proxy.config.ProxyConfig; import com.velocitypowered.api.proxy.config.ProxyConfig;
import com.velocitypowered.api.util.Favicon; import com.velocitypowered.api.util.Favicon;
import com.velocitypowered.proxy.util.AddressUtil; import com.velocitypowered.proxy.util.AddressUtil;
@@ -40,6 +39,7 @@ import org.apache.logging.log4j.Logger;
import org.checkerframework.checker.nullness.qual.MonotonicNonNull; import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
import org.checkerframework.checker.nullness.qual.Nullable; import org.checkerframework.checker.nullness.qual.Nullable;
import org.spongepowered.configurate.objectmapping.ConfigSerializable; import org.spongepowered.configurate.objectmapping.ConfigSerializable;
import org.spongepowered.configurate.objectmapping.meta.Setting;
/** /**
* Velocity's configuration. * Velocity's configuration.
@@ -49,50 +49,32 @@ public class VelocityConfiguration implements ProxyConfig {
private static final Logger logger = LogManager.getLogger(VelocityConfiguration.class); private static final Logger logger = LogManager.getLogger(VelocityConfiguration.class);
@Expose
private String bind = "0.0.0.0:25565"; private String bind = "0.0.0.0:25565";
@Expose
private String motd = "<aqua>A Velocity Server"; private String motd = "<aqua>A Velocity Server";
@Expose
private int showMaxPlayers = 500; private int showMaxPlayers = 500;
@Expose
private boolean onlineMode = true; private boolean onlineMode = true;
@Expose
private boolean preventClientProxyConnections = false; private boolean preventClientProxyConnections = false;
@Expose
private PlayerInfoForwarding playerInfoForwardingMode = PlayerInfoForwarding.NONE; private PlayerInfoForwarding playerInfoForwardingMode = PlayerInfoForwarding.NONE;
private byte[] forwardingSecret = generateRandomString(12).getBytes(StandardCharsets.UTF_8); private transient byte[] forwardingSecret =
@Expose generateRandomString(12).getBytes(StandardCharsets.UTF_8);
private boolean announceForge = false; private boolean announceForge = false;
@Expose @Setting("kick-existing-players")
private boolean onlineModeKickExistingPlayers = false; private boolean onlineModeKickExistingPlayers = false;
@Expose
private PingPassthroughMode pingPassthrough = PingPassthroughMode.DISABLED; private PingPassthroughMode pingPassthrough = PingPassthroughMode.DISABLED;
@Expose
private boolean samplePlayersInPing = false; private boolean samplePlayersInPing = false;
private final Servers servers; private Servers servers = new Servers();
private final ForcedHosts forcedHosts; private ForcedHosts forcedHosts = new ForcedHosts();
@Expose private Advanced advanced = new Advanced();
private final Advanced advanced; private Query query = new Query();
@Expose private Metrics metrics = new Metrics();
private final Query query;
private final Metrics metrics;
@Expose
private boolean enablePlayerAddressLogging = true; private boolean enablePlayerAddressLogging = true;
private net.kyori.adventure.text.@MonotonicNonNull Component motdAsComponent; private transient net.kyori.adventure.text.@MonotonicNonNull Component motdAsComponent;
private @Nullable Favicon favicon; private transient @Nullable Favicon favicon;
@Expose
private boolean forceKeyAuthentication = true; // Added in 1.19 private boolean forceKeyAuthentication = true; // Added in 1.19
@Expose @Setting("packet-limiter")
private PacketLimiterConfig packetLimiterConfig = PacketLimiterConfig.DEFAULT; private PacketLimiterConfig packetLimiterConfig = PacketLimiterConfig.DEFAULT;
private VelocityConfiguration(Servers servers, ForcedHosts forcedHosts, Advanced advanced, VelocityConfiguration() {
Query query, Metrics metrics) {
this.servers = servers;
this.forcedHosts = forcedHosts;
this.advanced = advanced;
this.query = query;
this.metrics = metrics;
} }
VelocityConfiguration(String bind, String motd, int showMaxPlayers, boolean onlineMode, VelocityConfiguration(String bind, String motd, int showMaxPlayers, boolean onlineMode,
@@ -501,7 +483,7 @@ public class VelocityConfiguration implements ProxyConfig {
this.attemptConnectionOrder = attemptConnectionOrder; this.attemptConnectionOrder = attemptConnectionOrder;
} }
private Map<String, String> getServers() { Map<String, String> getServers() {
return servers; return servers;
} }
@@ -553,7 +535,7 @@ public class VelocityConfiguration implements ProxyConfig {
this.forcedHosts = forcedHosts; this.forcedHosts = forcedHosts;
} }
private Map<String, List<String>> getForcedHosts() { Map<String, List<String>> getForcedHosts() {
return forcedHosts; return forcedHosts;
} }
@@ -569,47 +551,30 @@ public class VelocityConfiguration implements ProxyConfig {
} }
} }
@ConfigSerializable
static class Advanced { static class Advanced {
@Expose
private int compressionThreshold = 256; private int compressionThreshold = 256;
@Expose
private int compressionLevel = -1; private int compressionLevel = -1;
@Expose
private int loginRatelimit = 3000; private int loginRatelimit = 3000;
@Expose
private int connectionTimeout = 5000; private int connectionTimeout = 5000;
@Expose
private int readTimeout = 30000; private int readTimeout = 30000;
@Expose @Setting("haproxy-protocol")
private boolean proxyProtocol = false; private boolean proxyProtocol = false;
@Expose
private boolean tcpFastOpen = false; private boolean tcpFastOpen = false;
@Expose
private boolean bungeePluginMessageChannel = true; private boolean bungeePluginMessageChannel = true;
@Expose
private boolean showPingRequests = false; private boolean showPingRequests = false;
@Expose
private boolean failoverOnUnexpectedServerDisconnect = true; private boolean failoverOnUnexpectedServerDisconnect = true;
@Expose
private boolean announceProxyCommands = true; private boolean announceProxyCommands = true;
@Expose
private boolean logCommandExecutions = false; private boolean logCommandExecutions = false;
@Expose
private boolean logPlayerConnections = true; private boolean logPlayerConnections = true;
@Expose @Setting("accepts-transfers")
private boolean acceptTransfers = false; private boolean acceptTransfers = false;
@Expose
private boolean enableReusePort = false; private boolean enableReusePort = false;
@Expose
private int commandRateLimit = 50; private int commandRateLimit = 50;
@Expose
private boolean forwardCommandsIfRateLimited = true; private boolean forwardCommandsIfRateLimited = true;
@Expose
private int kickAfterRateLimitedCommands = 5; private int kickAfterRateLimitedCommands = 5;
@Expose
private int tabCompleteRateLimit = 50; private int tabCompleteRateLimit = 50;
@Expose
private int kickAfterRateLimitedTabCompletes = 10; private int kickAfterRateLimitedTabCompletes = 10;
Advanced() { Advanced() {
@@ -752,15 +717,15 @@ public class VelocityConfiguration implements ProxyConfig {
} }
} }
@ConfigSerializable
static class Query { static class Query {
@Expose @Setting("enabled")
private boolean queryEnabled = false; private boolean queryEnabled = false;
@Expose @Setting("port")
private int queryPort = 25565; private int queryPort = 25565;
@Expose @Setting("map")
private String queryMap = "Velocity"; private String queryMap = "Velocity";
@Expose
private boolean showPlugins = false; private boolean showPlugins = false;
Query() { Query() {
@@ -803,6 +768,7 @@ public class VelocityConfiguration implements ProxyConfig {
/** /**
* Configuration for metrics. * Configuration for metrics.
*/ */
@ConfigSerializable
public static class Metrics { public static class Metrics {
private boolean enabled = true; private boolean enabled = true;

View File

@@ -0,0 +1,165 @@
/*
* Copyright (C) 2024 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 org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.velocitypowered.proxy.config.VelocityConfiguration.PacketLimiterConfig;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ConfigurationLoaderTest {
/**
* The bundled default config must deserialize cleanly via the ObjectMapper and custom
* serializers, preserving the dynamic {@code servers}/{@code try} and {@code forced-hosts}
* sections.
*/
@Test
void bundledDefaultLoads(@TempDir final Path dir) throws IOException {
final Path path = dir.resolve("velocity.yml");
try (InputStream in = ConfigurationLoaderTest.class.getClassLoader()
.getResourceAsStream("default-velocity.yml")) {
assertNotNull(in, "default-velocity.yml is missing from resources");
Files.copy(in, path);
}
final VelocityConfiguration config = ConfigurationLoader.load(path);
assertEquals(500, config.getShowMaxPlayers());
assertEquals(ImmutableMap.of(
"lobby", "127.0.0.1:30066",
"factions", "127.0.0.1:30067",
"minigames", "127.0.0.1:30068"), config.getServers());
assertEquals(ImmutableList.of("lobby"), config.getAttemptConnectionOrder());
assertEquals(ImmutableMap.of(
"lobby.example.com", ImmutableList.of("lobby"),
"factions.example.com", ImmutableList.of("factions"),
"minigames.example.com", ImmutableList.of("minigames")), config.getForcedHosts());
assertEquals(7, config.getPacketLimiterConfig().interval());
assertEquals(5242880, config.getPacketLimiterConfig().bytesAfterDecompression());
assertTrue(config.getMetrics().isEnabled());
}
/**
* Verifies the non-trivial key mappings (renamed via {@code @Setting} and the custom
* serializers) using values that differ from the Java field defaults, so a wrong mapping cannot
* silently fall back to an identical default. Also exercises a save/reload round trip.
*/
@Test
void renamedKeysRoundTrip(@TempDir final Path dir) throws IOException {
final String yaml = """
config-version: 1
bind: "0.0.0.0:25577"
show-max-players: 123
online-mode: false
kick-existing-players: true
player-info-forwarding-mode: "MODERN"
ping-passthrough: "ALL"
sample-players-in-ping: true
enable-player-address-logging: false
force-key-authentication: false
announce-forge: true
packet-limiter:
interval: 9
packets-per-second: 100
bytes-per-second: 200
decompressed-bytes-per-second: 300
servers:
alpha: "1.2.3.4:25565"
beta: "5.6.7.8:25565"
try:
- beta
- alpha
forced-hosts:
"host.example.com":
- alpha
- beta
advanced:
haproxy-protocol: true
accepts-transfers: true
compression-threshold: 128
command-rate-limit: 99
enable-reuse-port: true
query:
enabled: true
port: 12345
map: "CustomMap"
show-plugins: true
metrics:
enabled: false
""";
final Path path = dir.resolve("velocity.yml");
Files.writeString(path, yaml, StandardCharsets.UTF_8);
assertConfig(ConfigurationLoader.load(path));
// Round trip: save the loaded config out and read it back; everything must still match.
final Path roundTripped = dir.resolve("velocity-roundtrip.yml");
ConfigurationLoader.save(ConfigurationLoader.load(path), roundTripped);
assertConfig(ConfigurationLoader.load(roundTripped));
}
private static void assertConfig(final VelocityConfiguration config) {
assertEquals(123, config.getShowMaxPlayers());
assertFalse(config.isOnlineMode());
assertTrue(config.isOnlineModeKickExistingPlayers());
assertEquals(PlayerInfoForwarding.MODERN, config.getPlayerInfoForwardingMode());
assertEquals(PingPassthroughMode.ALL, config.getPingPassthrough());
assertTrue(config.getSamplePlayersInPing());
assertFalse(config.isPlayerAddressLoggingEnabled());
assertFalse(config.isForceKeyAuthentication());
assertTrue(config.isAnnounceForge());
final PacketLimiterConfig limiter = config.getPacketLimiterConfig();
assertEquals(9, limiter.interval());
assertEquals(100, limiter.pps());
assertEquals(200, limiter.bytes());
assertEquals(300, limiter.bytesAfterDecompression());
assertEquals(ImmutableMap.of(
"alpha", "1.2.3.4:25565",
"beta", "5.6.7.8:25565"), config.getServers());
assertEquals(ImmutableList.of("beta", "alpha"), config.getAttemptConnectionOrder());
assertEquals(ImmutableMap.of(
"host.example.com", ImmutableList.of("alpha", "beta")), config.getForcedHosts());
assertTrue(config.isProxyProtocol());
assertTrue(config.isAcceptTransfers());
assertEquals(128, config.getCompressionThreshold());
assertEquals(99, config.getCommandRatelimit());
assertTrue(config.isEnableReusePort());
assertTrue(config.isQueryEnabled());
assertEquals(12345, config.getQueryPort());
assertEquals("CustomMap", config.getQueryMap());
assertTrue(config.shouldQueryShowPlugins());
assertFalse(config.getMetrics().isEnabled());
}
}