From 64e603c7e0f95216a58a1ffae10e2db6c1e96350 Mon Sep 17 00:00:00 2001 From: Shane Freeder Date: Wed, 17 Jun 2026 21:28:40 +0100 Subject: [PATCH] feat(config): add velocity.yml loading and velocity.toml migration Implement the runtime entry point for the YAML config: - ConfigurationLoader.loadConfiguration() resolves velocity.yml, migrating a legacy velocity.toml or writing the documented default on first start. Absent keys fall back to the model's field defaults (matching the old getOrElse behaviour), so existing files are never rewritten and their comments survive. - Migration runs the legacy night-config migrations to normalise the TOML, then writes it as YAML stamped config-version=1, preserving a custom forwarding-secret-file location, and archives the old file as velocity.toml.migrated. - Forwarding secret resolution mirrors the legacy path (env var, then the forwarding-secret-file, creating it if absent) and is injected via a new package-private setter; the secret stays out of velocity.yml. Not yet wired into VelocityServer; that follows in the next change. Co-Authored-By: Claude Opus 4.8 --- .../proxy/config/ConfigurationLoader.java | 135 ++++++++++++++++++ .../config/LegacyConfigurationLoader.java | 15 ++ .../proxy/config/VelocityConfiguration.java | 4 + .../proxy/config/ConfigurationLoaderTest.java | 54 +++++++ 4 files changed, 208 insertions(+) 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 cf98416ae..1b265e0d0 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/config/ConfigurationLoader.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/config/ConfigurationLoader.java @@ -20,12 +20,18 @@ package com.velocitypowered.proxy.config; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import java.io.IOException; +import java.io.InputStream; import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.checkerframework.checker.nullness.qual.Nullable; import org.spongepowered.configurate.CommentedConfigurationNode; import org.spongepowered.configurate.ConfigurationNode; @@ -41,6 +47,24 @@ import org.spongepowered.configurate.yaml.YamlConfigurationLoader; */ public final class ConfigurationLoader { + private static final Logger logger = LogManager.getLogger(ConfigurationLoader.class); + + private static final String DEFAULT_CONFIG_RESOURCE = "default-velocity.yml"; + private static final Path DEFAULT_CONFIG_PATH = Path.of("velocity.yml"); + private static final Path LEGACY_CONFIG_PATH = Path.of("velocity.toml"); + private static final String FORWARDING_SECRET_FILE_KEY = "forwarding-secret-file"; + private static final String DEFAULT_FORWARDING_SECRET_FILE = "forwarding.secret"; + static final String CONFIG_VERSION_KEY = "config-version"; + + /** + * Current {@code velocity.yml} schema version. Files are born at this version (either freshly + * written from the bundled default or stamped during a {@code velocity.toml} migration). Once a + * YAML-schema migration is needed, drive upgrades from here via + * {@link org.spongepowered.configurate.transformation.ConfigurationTransformation#versionedBuilder()} + * keyed on {@link #CONFIG_VERSION_KEY}. + */ + static final int CURRENT_CONFIG_VERSION = 1; + /** * ObjectMapper factory configured to map {@code camelCase} fields onto {@code lower-case-dashed} * configuration keys, matching the historical TOML key style. @@ -52,6 +76,117 @@ public final class ConfigurationLoader { private ConfigurationLoader() { } + /** + * Loads the Velocity configuration from {@code velocity.yml}, migrating a legacy + * {@code velocity.toml} or writing the documented default on first start as needed. + * + * @return the loaded configuration + * @throws IOException if the configuration could not be read or written + */ + public static VelocityConfiguration loadConfiguration() throws IOException { + return loadConfiguration(DEFAULT_CONFIG_PATH, LEGACY_CONFIG_PATH); + } + + /** + * Loads the Velocity configuration from {@code path}, migrating {@code legacyPath} or writing the + * documented default if {@code path} does not yet exist. + * + * @param path the YAML configuration path + * @param legacyPath the legacy TOML configuration path to migrate from, if present + * @return the loaded configuration + * @throws IOException if the configuration could not be read or written + */ + static VelocityConfiguration loadConfiguration(final Path path, final Path legacyPath) + throws IOException { + if (Files.notExists(path) && Files.exists(legacyPath)) { + migrateFromLegacy(legacyPath, path); + } + + if (Files.notExists(path)) { + // Fresh install: copy the documented default verbatim so its comments are preserved. + writeDefaultConfig(path); + } + + // Absent keys fall back to the model's field defaults (matching the old getOrElse behaviour), + // so there is no need to merge the bundled default back into the user's file here. + 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); + } + config.setForwardingSecret(readForwardingSecret(node)); + return config; + } + + /** + * Converts a legacy {@code velocity.toml} to {@code velocity.yml}. The legacy night-config + * migrations are run first to normalise the file, then it is written out as YAML stamped with the + * current schema version and the old file is preserved as {@code velocity.toml.migrated}. + */ + private static void migrateFromLegacy(final Path legacyPath, final Path path) throws IOException { + logger.info("Found a legacy {}; migrating it to {}.", + legacyPath.getFileName(), path.getFileName()); + final VelocityConfiguration legacy = LegacyConfigurationLoader.read(legacyPath); + + final YamlConfigurationLoader loader = yamlLoader(path).build(); + final CommentedConfigurationNode node = loader.createNode(); + node.set(VelocityConfiguration.class, legacy); + // forwarding-secret-file is a bootstrap pointer, not a mapped field; carry it across so a + // custom secret location keeps working after migration. + final String secretFile = LegacyConfigurationLoader.readForwardingSecretFile(legacyPath); + if (secretFile != null) { + node.node(FORWARDING_SECRET_FILE_KEY).set(secretFile); + } + node.node(CONFIG_VERSION_KEY).set(CURRENT_CONFIG_VERSION); + loader.save(node); + + final Path archived = legacyPath.resolveSibling(legacyPath.getFileName().toString() + + ".migrated"); + Files.move(legacyPath, archived, StandardCopyOption.REPLACE_EXISTING); + logger.info("Migration complete. Your old configuration has been preserved as {}.", + archived.getFileName()); + } + + private static void writeDefaultConfig(final Path path) throws IOException { + try (InputStream in = ConfigurationLoader.class.getClassLoader() + .getResourceAsStream(DEFAULT_CONFIG_RESOURCE)) { + if (in == null) { + throw new IOException(DEFAULT_CONFIG_RESOURCE + " is missing from the classpath"); + } + Files.copy(in, path); + } + } + + /** + * Resolves the player-info forwarding secret. Mirrors the legacy behaviour: prefer the + * {@code VELOCITY_FORWARDING_SECRET} environment variable, otherwise read (creating if absent) the + * file pointed at by {@code forwarding-secret-file}. The secret is deliberately kept out of + * {@code velocity.yml}. + */ + private static byte[] readForwardingSecret(final ConfigurationNode node) throws IOException { + String secret = System.getenv().getOrDefault("VELOCITY_FORWARDING_SECRET", ""); + if (secret.isBlank()) { + final String secretFile = node.node(FORWARDING_SECRET_FILE_KEY) + .getString(DEFAULT_FORWARDING_SECRET_FILE); + final Path secretPath = Path.of(secretFile); + if (Files.exists(secretPath)) { + if (Files.isRegularFile(secretPath)) { + secret = String.join("", Files.readAllLines(secretPath)); + } else { + throw new IOException( + "The file " + secretFile + " is not a valid file or it is a directory."); + } + } else { + Files.createFile(secretPath); + Files.writeString(secretPath, secret = VelocityConfiguration.generateRandomString(12), + StandardCharsets.UTF_8); + logger.info("The forwarding-secret-file does not exist. A new file has been created at {}", + secretFile); + } + } + return secret.getBytes(StandardCharsets.UTF_8); + } + /** * Builds a YAML loader for the given {@code path}, wired with the serializers needed to map a * {@link VelocityConfiguration}. diff --git a/proxy/src/main/java/com/velocitypowered/proxy/config/LegacyConfigurationLoader.java b/proxy/src/main/java/com/velocitypowered/proxy/config/LegacyConfigurationLoader.java index e30768791..f455d1732 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/config/LegacyConfigurationLoader.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/config/LegacyConfigurationLoader.java @@ -44,6 +44,7 @@ import java.util.Locale; import java.util.Map; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.checkerframework.checker.nullness.qual.Nullable; /** * Legacy configuration loader for Velocity. @@ -51,6 +52,20 @@ import org.apache.logging.log4j.Logger; public class LegacyConfigurationLoader { private static final Logger logger = LogManager.getLogger(LegacyConfigurationLoader.class); + /** + * Reads the raw {@code forwarding-secret-file} value from a legacy TOML configuration, so a + * custom secret location can be preserved when migrating to {@code velocity.yml}. + * + * @param path the legacy configuration path + * @return the configured forwarding-secret-file, or {@code null} if unset + */ + static @Nullable String readForwardingSecretFile(final Path path) { + try (CommentedFileConfig config = CommentedFileConfig.builder(path).build()) { + config.load(); + return config.get("forwarding-secret-file"); + } + } + /** * Reads the Velocity configuration from {@code path}. * 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 04b022bbb..981efd02f 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/config/VelocityConfiguration.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/config/VelocityConfiguration.java @@ -282,6 +282,10 @@ public class VelocityConfiguration implements ProxyConfig { return forwardingSecret.clone(); } + void setForwardingSecret(final byte[] forwardingSecret) { + this.forwardingSecret = forwardingSecret; + } + @Override public Map getServers() { return servers.getServers(); diff --git a/proxy/src/test/java/com/velocitypowered/proxy/config/ConfigurationLoaderTest.java b/proxy/src/test/java/com/velocitypowered/proxy/config/ConfigurationLoaderTest.java index e23c80424..c5a0143d1 100644 --- a/proxy/src/test/java/com/velocitypowered/proxy/config/ConfigurationLoaderTest.java +++ b/proxy/src/test/java/com/velocitypowered/proxy/config/ConfigurationLoaderTest.java @@ -17,6 +17,7 @@ package com.velocitypowered.proxy.config; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -32,6 +33,7 @@ import java.nio.file.Files; import java.nio.file.Path; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.spongepowered.configurate.CommentedConfigurationNode; class ConfigurationLoaderTest { @@ -125,6 +127,58 @@ class ConfigurationLoaderTest { assertConfig(ConfigurationLoader.load(roundTripped)); } + /** + * A legacy {@code velocity.toml} must be converted to {@code velocity.yml}: values carried over, + * the schema version stamped, a custom forwarding-secret-file preserved, and the old file + * archived as {@code velocity.toml.migrated}. + */ + @Test + void migratesLegacyTomlToYaml(@TempDir final Path dir) throws IOException { + final Path secret = dir.resolve("forwarding.secret"); + Files.writeString(secret, "supersecretvalue"); + + final Path toml = dir.resolve("velocity.toml"); + final String secretLiteral = secret.toString().replace("\\", "\\\\"); + Files.writeString(toml, """ + config-version = "2.8" + bind = "0.0.0.0:25599" + show-max-players = 321 + online-mode = false + player-info-forwarding-mode = "MODERN" + forwarding-secret-file = "%s" + + [servers] + hub = "10.0.0.1:25565" + try = ["hub"] + + [advanced] + haproxy-protocol = true + accepts-transfers = true + """.formatted(secretLiteral)); + + final Path yaml = dir.resolve("velocity.yml"); + final VelocityConfiguration config = ConfigurationLoader.loadConfiguration(yaml, toml); + + assertTrue(Files.exists(yaml)); + assertFalse(Files.exists(toml)); + assertTrue(Files.exists(dir.resolve("velocity.toml.migrated"))); + + assertEquals(321, config.getShowMaxPlayers()); + assertFalse(config.isOnlineMode()); + assertEquals(PlayerInfoForwarding.MODERN, config.getPlayerInfoForwardingMode()); + assertEquals(ImmutableMap.of("hub", "10.0.0.1:25565"), config.getServers()); + assertEquals(ImmutableList.of("hub"), config.getAttemptConnectionOrder()); + assertTrue(config.isProxyProtocol()); + assertTrue(config.isAcceptTransfers()); + assertArrayEquals("supersecretvalue".getBytes(StandardCharsets.UTF_8), + config.getForwardingSecret()); + + final CommentedConfigurationNode node = ConfigurationLoader.yamlLoader(yaml).build().load(); + assertEquals(ConfigurationLoader.CURRENT_CONFIG_VERSION, + node.node(ConfigurationLoader.CONFIG_VERSION_KEY).getInt()); + assertEquals(secret.toString(), node.node("forwarding-secret-file").getString()); + } + private static void assertConfig(final VelocityConfiguration config) { assertEquals(123, config.getShowMaxPlayers()); assertFalse(config.isOnlineMode());