From d0e654d1ca0634b0eb53cac37549989a1270f346 Mon Sep 17 00:00:00 2001 From: Shane Freeder Date: Wed, 17 Jun 2026 21:15:33 +0100 Subject: [PATCH] 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 --- .../proxy/config/ConfigurationLoader.java | 201 +++++++++++++++++- .../proxy/config/VelocityConfiguration.java | 80 ++----- .../proxy/config/ConfigurationLoaderTest.java | 165 ++++++++++++++ 3 files changed, 378 insertions(+), 68 deletions(-) create mode 100644 proxy/src/test/java/com/velocitypowered/proxy/config/ConfigurationLoaderTest.java diff --git a/proxy/src/main/java/com/velocitypowered/proxy/config/ConfigurationLoader.java b/proxy/src/main/java/com/velocitypowered/proxy/config/ConfigurationLoader.java index 419f0a562..cf98416ae 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/config/ConfigurationLoader.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/config/ConfigurationLoader.java @@ -17,30 +17,209 @@ 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() { } - /** - * 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() { - return false; + static YamlConfigurationLoader.Builder yamlLoader(final Path path) { + 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() { - return null; + static VelocityConfiguration load(final Path path) throws IOException { + 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 { + + @Override + public VelocityConfiguration.Servers deserialize(final Type type, final ConfigurationNode node) + throws SerializationException { + final Map servers = new LinkedHashMap<>(); + List attemptConnectionOrder = ImmutableList.of("lobby"); + for (final Map.Entry 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 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 { + + @Override + public VelocityConfiguration.ForcedHosts deserialize(final Type type, + final ConfigurationNode node) throws SerializationException { + final Map> forcedHosts = new LinkedHashMap<>(); + for (final Map.Entry 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> 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 { + + @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()); + } } } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/config/VelocityConfiguration.java b/proxy/src/main/java/com/velocitypowered/proxy/config/VelocityConfiguration.java index 530df3443..04b022bbb 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/config/VelocityConfiguration.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/config/VelocityConfiguration.java @@ -21,7 +21,6 @@ import com.electronwill.nightconfig.core.CommentedConfig; 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.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.Nullable; import org.spongepowered.configurate.objectmapping.ConfigSerializable; +import org.spongepowered.configurate.objectmapping.meta.Setting; /** * Velocity's configuration. @@ -49,50 +49,32 @@ public class VelocityConfiguration implements ProxyConfig { private static final Logger logger = LogManager.getLogger(VelocityConfiguration.class); - @Expose private String bind = "0.0.0.0:25565"; - @Expose private String motd = "A Velocity Server"; - @Expose private int showMaxPlayers = 500; - @Expose private boolean onlineMode = true; - @Expose private boolean preventClientProxyConnections = false; - @Expose private PlayerInfoForwarding playerInfoForwardingMode = PlayerInfoForwarding.NONE; - private byte[] forwardingSecret = generateRandomString(12).getBytes(StandardCharsets.UTF_8); - @Expose + private transient byte[] forwardingSecret = + generateRandomString(12).getBytes(StandardCharsets.UTF_8); private boolean announceForge = false; - @Expose + @Setting("kick-existing-players") private boolean onlineModeKickExistingPlayers = false; - @Expose private PingPassthroughMode pingPassthrough = PingPassthroughMode.DISABLED; - @Expose private boolean samplePlayersInPing = false; - private final Servers servers; - private final ForcedHosts forcedHosts; - @Expose - private final Advanced advanced; - @Expose - private final Query query; - private final Metrics metrics; - @Expose + private Servers servers = new Servers(); + private ForcedHosts forcedHosts = new ForcedHosts(); + private Advanced advanced = new Advanced(); + private Query query = new Query(); + private Metrics metrics = new Metrics(); private boolean enablePlayerAddressLogging = true; - private net.kyori.adventure.text.@MonotonicNonNull Component motdAsComponent; - private @Nullable Favicon favicon; - @Expose + private transient net.kyori.adventure.text.@MonotonicNonNull Component motdAsComponent; + private transient @Nullable Favicon favicon; private boolean forceKeyAuthentication = true; // Added in 1.19 - @Expose + @Setting("packet-limiter") private PacketLimiterConfig packetLimiterConfig = PacketLimiterConfig.DEFAULT; - private VelocityConfiguration(Servers servers, ForcedHosts forcedHosts, Advanced advanced, - Query query, Metrics metrics) { - this.servers = servers; - this.forcedHosts = forcedHosts; - this.advanced = advanced; - this.query = query; - this.metrics = metrics; + VelocityConfiguration() { } VelocityConfiguration(String bind, String motd, int showMaxPlayers, boolean onlineMode, @@ -501,7 +483,7 @@ public class VelocityConfiguration implements ProxyConfig { this.attemptConnectionOrder = attemptConnectionOrder; } - private Map getServers() { + Map getServers() { return servers; } @@ -553,7 +535,7 @@ public class VelocityConfiguration implements ProxyConfig { this.forcedHosts = forcedHosts; } - private Map> getForcedHosts() { + Map> getForcedHosts() { return forcedHosts; } @@ -569,47 +551,30 @@ public class VelocityConfiguration implements ProxyConfig { } } + @ConfigSerializable static class Advanced { - @Expose private int compressionThreshold = 256; - @Expose private int compressionLevel = -1; - @Expose private int loginRatelimit = 3000; - @Expose private int connectionTimeout = 5000; - @Expose private int readTimeout = 30000; - @Expose + @Setting("haproxy-protocol") private boolean proxyProtocol = false; - @Expose private boolean tcpFastOpen = false; - @Expose private boolean bungeePluginMessageChannel = true; - @Expose private boolean showPingRequests = false; - @Expose private boolean failoverOnUnexpectedServerDisconnect = true; - @Expose private boolean announceProxyCommands = true; - @Expose private boolean logCommandExecutions = false; - @Expose private boolean logPlayerConnections = true; - @Expose + @Setting("accepts-transfers") private boolean acceptTransfers = false; - @Expose private boolean enableReusePort = false; - @Expose private int commandRateLimit = 50; - @Expose private boolean forwardCommandsIfRateLimited = true; - @Expose private int kickAfterRateLimitedCommands = 5; - @Expose private int tabCompleteRateLimit = 50; - @Expose private int kickAfterRateLimitedTabCompletes = 10; Advanced() { @@ -752,15 +717,15 @@ public class VelocityConfiguration implements ProxyConfig { } } + @ConfigSerializable static class Query { - @Expose + @Setting("enabled") private boolean queryEnabled = false; - @Expose + @Setting("port") private int queryPort = 25565; - @Expose + @Setting("map") private String queryMap = "Velocity"; - @Expose private boolean showPlugins = false; Query() { @@ -803,6 +768,7 @@ public class VelocityConfiguration implements ProxyConfig { /** * Configuration for metrics. */ + @ConfigSerializable public static class Metrics { private boolean enabled = true; diff --git a/proxy/src/test/java/com/velocitypowered/proxy/config/ConfigurationLoaderTest.java b/proxy/src/test/java/com/velocitypowered/proxy/config/ConfigurationLoaderTest.java new file mode 100644 index 000000000..e23c80424 --- /dev/null +++ b/proxy/src/test/java/com/velocitypowered/proxy/config/ConfigurationLoaderTest.java @@ -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 . + */ + +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()); + } +}