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 <noreply@anthropic.com>
This commit is contained in:
Shane Freeder
2026-06-17 21:28:40 +01:00
parent d0e654d1ca
commit 64e603c7e0
4 changed files with 208 additions and 0 deletions

View File

@@ -20,12 +20,18 @@ package com.velocitypowered.proxy.config;
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 java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Type; import java.lang.reflect.Type;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; 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.checkerframework.checker.nullness.qual.Nullable;
import org.spongepowered.configurate.CommentedConfigurationNode; import org.spongepowered.configurate.CommentedConfigurationNode;
import org.spongepowered.configurate.ConfigurationNode; import org.spongepowered.configurate.ConfigurationNode;
@@ -41,6 +47,24 @@ import org.spongepowered.configurate.yaml.YamlConfigurationLoader;
*/ */
public final class ConfigurationLoader { 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} * ObjectMapper factory configured to map {@code camelCase} fields onto {@code lower-case-dashed}
* configuration keys, matching the historical TOML key style. * configuration keys, matching the historical TOML key style.
@@ -52,6 +76,117 @@ public final class ConfigurationLoader {
private 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 * Builds a YAML loader for the given {@code path}, wired with the serializers needed to map a
* {@link VelocityConfiguration}. * {@link VelocityConfiguration}.

View File

@@ -44,6 +44,7 @@ import java.util.Locale;
import java.util.Map; import java.util.Map;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import org.checkerframework.checker.nullness.qual.Nullable;
/** /**
* Legacy configuration loader for Velocity. * Legacy configuration loader for Velocity.
@@ -51,6 +52,20 @@ import org.apache.logging.log4j.Logger;
public class LegacyConfigurationLoader { public class LegacyConfigurationLoader {
private static final Logger logger = LogManager.getLogger(LegacyConfigurationLoader.class); 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}. * Reads the Velocity configuration from {@code path}.
* *

View File

@@ -282,6 +282,10 @@ public class VelocityConfiguration implements ProxyConfig {
return forwardingSecret.clone(); return forwardingSecret.clone();
} }
void setForwardingSecret(final byte[] forwardingSecret) {
this.forwardingSecret = forwardingSecret;
}
@Override @Override
public Map<String, String> getServers() { public Map<String, String> getServers() {
return servers.getServers(); return servers.getServers();

View File

@@ -17,6 +17,7 @@
package com.velocitypowered.proxy.config; 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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -32,6 +33,7 @@ import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.io.TempDir;
import org.spongepowered.configurate.CommentedConfigurationNode;
class ConfigurationLoaderTest { class ConfigurationLoaderTest {
@@ -125,6 +127,58 @@ class ConfigurationLoaderTest {
assertConfig(ConfigurationLoader.load(roundTripped)); 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) { private static void assertConfig(final VelocityConfiguration config) {
assertEquals(123, config.getShowMaxPlayers()); assertEquals(123, config.getShowMaxPlayers());
assertFalse(config.isOnlineMode()); assertFalse(config.isOnlineMode());