diff --git a/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java b/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java
index 83e443e9c7..01ffb85fae 100644
--- a/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java
+++ b/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java
@@ -46,6 +46,7 @@
import com.velocitypowered.proxy.command.builtin.ServerCommand;
import com.velocitypowered.proxy.command.builtin.ShutdownCommand;
import com.velocitypowered.proxy.command.builtin.VelocityCommand;
+import com.velocitypowered.proxy.config.ConfigurationLoader;
import com.velocitypowered.proxy.config.VelocityConfiguration;
import com.velocitypowered.proxy.connection.client.ConnectedPlayer;
import com.velocitypowered.proxy.connection.player.resourcepack.VelocityResourcePackInfo;
@@ -402,8 +403,7 @@ private void registerTranslations() {
@SuppressFBWarnings("DM_EXIT")
private void doStartupConfigLoad() {
try {
- Path configPath = Path.of("velocity.toml");
- configuration = VelocityConfiguration.read(configPath);
+ configuration = ConfigurationLoader.loadConfiguration();
if (!configuration.validate()) {
logger.error("Your configuration is invalid. Velocity will not start up until the errors "
@@ -414,7 +414,7 @@ private void doStartupConfigLoad() {
commandManager.setAnnounceProxyCommands(configuration.isAnnounceProxyCommands());
} catch (Exception e) {
- logger.error("Unable to read/load/save your velocity.toml. The server will shut down.", e);
+ logger.error("Unable to read/load/save your velocity.yaml. The server will shut down.", e);
LogManager.shutdown();
System.exit(1);
}
@@ -477,11 +477,10 @@ public boolean isShutdown() {
* Reloads the proxy's configuration.
*
* @return {@code true} if successful, {@code false} if we can't read the configuration
- * @throws IOException if we can't read {@code velocity.toml}
+ * @throws IOException if we can't read {@code velocity.yaml}
*/
public boolean reloadConfiguration() throws IOException {
- Path configPath = Path.of("velocity.toml");
- VelocityConfiguration newConfiguration = VelocityConfiguration.read(configPath);
+ VelocityConfiguration newConfiguration = ConfigurationLoader.loadConfiguration();
if (!newConfiguration.validate()) {
return false;
diff --git a/proxy/src/main/java/com/velocitypowered/proxy/config/ConfigurationLoader.java b/proxy/src/main/java/com/velocitypowered/proxy/config/ConfigurationLoader.java
new file mode 100644
index 0000000000..e58d072aff
--- /dev/null
+++ b/proxy/src/main/java/com/velocitypowered/proxy/config/ConfigurationLoader.java
@@ -0,0 +1,360 @@
+/*
+ * 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 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;
+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 (YAML) loader entry utils.
+ */
+public final class ConfigurationLoader {
+
+ private static final Logger logger = LogManager.getLogger(ConfigurationLoader.class);
+
+ private static final String DEFAULT_CONFIG_RESOURCE = "default-velocity.yaml";
+ private static final Path DEFAULT_CONFIG_PATH = Path.of("velocity.yaml");
+ 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.yaml} 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.
+ */
+ private static final ObjectMapper.Factory OBJECT_MAPPER_FACTORY = ObjectMapper.factoryBuilder()
+ .defaultNamingScheme(NamingSchemes.LOWER_CASE_DASHED)
+ .build();
+
+ private ConfigurationLoader() {
+ }
+
+ /**
+ * Loads the Velocity configuration from {@code velocity.yaml}, 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.yaml}. 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.yaml}.
+ */
+ 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}.
+ *
+ * @param path the configuration file path
+ * @return the configured loader builder
+ */
+ 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)));
+ }
+
+ /**
+ * Reads a {@link VelocityConfiguration} from the YAML file at {@code path}.
+ *
+ * @param path the configuration file path
+ * @return the deserialized configuration
+ * @throws IOException if the file could not be read or deserialized
+ */
+ 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();
+ 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/LegacyConfigurationLoader.java b/proxy/src/main/java/com/velocitypowered/proxy/config/LegacyConfigurationLoader.java
new file mode 100644
index 0000000000..0bdacb4272
--- /dev/null
+++ b/proxy/src/main/java/com/velocitypowered/proxy/config/LegacyConfigurationLoader.java
@@ -0,0 +1,297 @@
+/*
+ * 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 com.velocitypowered.proxy.config.VelocityConfiguration.Servers.cleanServerName;
+import static com.velocitypowered.proxy.config.VelocityConfiguration.generateRandomString;
+
+import com.electronwill.nightconfig.core.CommentedConfig;
+import com.electronwill.nightconfig.core.UnmodifiableConfig;
+import com.electronwill.nightconfig.core.file.CommentedFileConfig;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.velocitypowered.proxy.config.migration.ConfigurationMigration;
+import com.velocitypowered.proxy.config.migration.ForwardingMigration;
+import com.velocitypowered.proxy.config.migration.KeyAuthenticationMigration;
+import com.velocitypowered.proxy.config.migration.MiniMessageTranslationsMigration;
+import com.velocitypowered.proxy.config.migration.MotdMigration;
+import com.velocitypowered.proxy.config.migration.PacketLimiterMigration;
+import com.velocitypowered.proxy.config.migration.TransferIntegrationMigration;
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
+import java.io.IOException;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashMap;
+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;
+
+/**
+ * Legacy configuration loader for Velocity.
+ */
+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.yaml}.
+ *
+ * @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}.
+ *
+ * @param path the path to read from
+ * @return the deserialized Velocity configuration
+ * @throws IOException if we could not read from the {@code path}.
+ */
+ @SuppressFBWarnings(value = "RCN_REDUNDANT_NULLCHECK_WOULD_HAVE_BEEN_A_NPE",
+ justification = "I looked carefully and there's no way SpotBugs is right.")
+ public static VelocityConfiguration read(Path path) throws IOException {
+ URL defaultConfigLocation = VelocityConfiguration.class.getClassLoader()
+ .getResource("default-velocity.toml");
+ if (defaultConfigLocation == null) {
+ throw new RuntimeException("Default configuration file does not exist.");
+ }
+
+ // Create the forwarding-secret file on first-time startup if it doesn't exist
+ final Path defaultForwardingSecretPath = Path.of("forwarding.secret");
+ if (Files.notExists(path) && Files.notExists(defaultForwardingSecretPath)) {
+ Files.writeString(defaultForwardingSecretPath, generateRandomString(12));
+ }
+
+ try (final CommentedFileConfig config = CommentedFileConfig.builder(path)
+ .defaultData(defaultConfigLocation)
+ .autosave()
+ .preserveInsertionOrder()
+ .sync()
+ .build()
+ ) {
+ config.load();
+
+ final ConfigurationMigration[] migrations = {
+ new ForwardingMigration(),
+ new KeyAuthenticationMigration(),
+ new MotdMigration(),
+ new MiniMessageTranslationsMigration(),
+ new TransferIntegrationMigration(),
+ new PacketLimiterMigration()
+ };
+
+ for (final ConfigurationMigration migration : migrations) {
+ if (migration.shouldMigrate(config)) {
+ migration.migrate(config, logger);
+ }
+ }
+
+ String forwardingSecretString = System.getenv().getOrDefault(
+ "VELOCITY_FORWARDING_SECRET", "");
+ if (forwardingSecretString.isBlank()) {
+ final String forwardSecretFile = config.get("forwarding-secret-file");
+ final Path secretPath = forwardSecretFile == null
+ ? defaultForwardingSecretPath
+ : Path.of(forwardSecretFile);
+ if (Files.exists(secretPath)) {
+ if (Files.isRegularFile(secretPath)) {
+ forwardingSecretString = String.join("", Files.readAllLines(secretPath));
+ } else {
+ throw new RuntimeException(
+ "The file " + forwardSecretFile + " is not a valid file or it is a directory.");
+ }
+ } else {
+ Files.createFile(secretPath);
+ Files.writeString(secretPath, forwardingSecretString = generateRandomString(12),
+ StandardCharsets.UTF_8);
+ logger.info("The forwarding-secret-file does not exist. A new file has been created at {}",
+ forwardSecretFile);
+ }
+ }
+ final byte[] forwardingSecret = forwardingSecretString.getBytes(StandardCharsets.UTF_8);
+ final String motd = config.getOrElse("motd", "<#09add3>A Velocity Server");
+
+ // Read the rest of the config
+ final CommentedConfig serversConfig = config.get("servers");
+ final CommentedConfig forcedHostsConfig = config.get("forced-hosts");
+ final CommentedConfig advancedConfig = config.get("advanced");
+ final CommentedConfig queryConfig = config.get("query");
+ final CommentedConfig metricsConfig = config.get("metrics");
+ final PlayerInfoForwarding forwardingMode = config.getEnumOrElse(
+ "player-info-forwarding-mode", PlayerInfoForwarding.NONE);
+ final PingPassthroughMode pingPassthroughMode = config.getEnumOrElse("ping-passthrough",
+ PingPassthroughMode.DISABLED);
+
+ final boolean samplePlayersInPing = config.getOrElse("sample-players-in-ping", false);
+
+ final String bind = config.getOrElse("bind", "0.0.0.0:25565");
+ final int maxPlayers = config.getIntOrElse("show-max-players", 500);
+ final boolean onlineMode = config.getOrElse("online-mode", true);
+ final boolean forceKeyAuthentication = config.getOrElse("force-key-authentication", true);
+ final boolean announceForge = config.getOrElse("announce-forge", true);
+ final boolean preventClientProxyConnections = config.getOrElse(
+ "prevent-client-proxy-connections", false);
+ final boolean kickExisting = config.getOrElse("kick-existing-players", false);
+ final boolean enablePlayerAddressLogging = config.getOrElse(
+ "enable-player-address-logging", true);
+ final VelocityConfiguration.PacketLimiterConfig packetLimiterConfig =
+ VelocityConfiguration.PacketLimiterConfig.fromConfig(config.get("packet-limiter"));
+
+ // Throw an exception if the forwarding-secret file is empty and the proxy is using a
+ // forwarding mode that requires it.
+ if (forwardingSecret.length == 0
+ && (forwardingMode == PlayerInfoForwarding.MODERN
+ || forwardingMode == PlayerInfoForwarding.BUNGEEGUARD)) {
+ throw new RuntimeException("The forwarding-secret file must not be empty.");
+ }
+
+ return new VelocityConfiguration(
+ bind,
+ motd,
+ maxPlayers,
+ onlineMode,
+ preventClientProxyConnections,
+ announceForge,
+ forwardingMode,
+ forwardingSecret,
+ kickExisting,
+ pingPassthroughMode,
+ samplePlayersInPing,
+ enablePlayerAddressLogging,
+ readServers(serversConfig),
+ readForcedHosts(forcedHostsConfig),
+ readAdvanced(advancedConfig),
+ readQuery(queryConfig),
+ readMetrics(metricsConfig),
+ forceKeyAuthentication,
+ packetLimiterConfig
+ );
+ }
+ }
+
+
+ private static VelocityConfiguration.Servers readServers(CommentedConfig config) {
+ if (config != null) {
+ Map servers = new HashMap<>();
+ for (UnmodifiableConfig.Entry entry : config.entrySet()) {
+ if (entry.getValue() instanceof String) {
+ servers.put(cleanServerName(entry.getKey()), entry.getValue());
+ } else {
+ if (!entry.getKey().equalsIgnoreCase("try")) {
+ throw new IllegalArgumentException(
+ "Server entry " + entry.getKey() + " is not a string!");
+ }
+ }
+ }
+ return new VelocityConfiguration.Servers(ImmutableMap.copyOf(servers),
+ config.getOrElse("try", ImmutableList.of("lobby")));
+ }
+ return new VelocityConfiguration.Servers();
+ }
+
+ private static VelocityConfiguration.ForcedHosts readForcedHosts(CommentedConfig config) {
+ if (config != null) {
+ Map> forcedHosts = new HashMap<>();
+ for (UnmodifiableConfig.Entry entry : config.entrySet()) {
+ 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) entry.getValue()));
+ } else {
+ throw new IllegalStateException(
+ "Invalid value of type " + entry.getValue().getClass() + " in forced hosts!");
+ }
+ }
+ return new VelocityConfiguration.ForcedHosts(forcedHosts);
+ }
+ return new VelocityConfiguration.ForcedHosts();
+ }
+
+ private static VelocityConfiguration.Advanced readAdvanced(CommentedConfig config) {
+ if (config != null) {
+ int compressionThreshold = config.getIntOrElse("compression-threshold", 256);
+ int compressionLevel = config.getIntOrElse("compression-level", -1);
+ int loginRatelimit = config.getIntOrElse("login-ratelimit", 3000);
+ int connectionTimeout = config.getIntOrElse("connection-timeout", 5000);
+ int readTimeout = config.getIntOrElse("read-timeout", 30000);
+ boolean proxyProtocol = false;
+ if (config.contains("haproxy-protocol")) {
+ proxyProtocol = config.getOrElse("haproxy-protocol", false);
+ } else {
+ proxyProtocol = config.getOrElse("proxy-protocol", false);
+ }
+ boolean tcpFastOpen = config.getOrElse("tcp-fast-open", false);
+ boolean bungeePluginMessageChannel = config.getOrElse("bungee-plugin-message-channel", true);
+ boolean showPingRequests = config.getOrElse("show-ping-requests", false);
+ boolean failoverOnUnexpectedServerDisconnect = config
+ .getOrElse("failover-on-unexpected-server-disconnect", true);
+ boolean announceProxyCommands = config.getOrElse("announce-proxy-commands", true);
+ boolean logCommandExecutions = config.getOrElse("log-command-executions", false);
+ boolean logPlayerConnections = config.getOrElse("log-player-connections", true);
+ boolean acceptTransfers = config.getOrElse("accepts-transfers", false);
+ boolean enableReusePort = config.getOrElse("enable-reuse-port", false);
+ int commandRateLimit = config.getIntOrElse("command-rate-limit", 25);
+ boolean forwardCommandsIfRateLimited =
+ config.getOrElse("forward-commands-if-rate-limited", true);
+ int kickAfterRateLimitedCommands = config.getIntOrElse("kick-after-rate-limited-commands", 0);
+ int tabCompleteRateLimit = config.getIntOrElse("tab-complete-rate-limit", 10);
+ int kickAfterRateLimitedTabCompletes =
+ config.getIntOrElse("kick-after-rate-limited-tab-completes", 0);
+ return new VelocityConfiguration.Advanced(compressionThreshold, compressionLevel,
+ loginRatelimit, connectionTimeout, readTimeout, proxyProtocol, tcpFastOpen,
+ bungeePluginMessageChannel, showPingRequests, failoverOnUnexpectedServerDisconnect,
+ announceProxyCommands, logCommandExecutions, logPlayerConnections, acceptTransfers,
+ enableReusePort, commandRateLimit, forwardCommandsIfRateLimited,
+ kickAfterRateLimitedCommands, tabCompleteRateLimit, kickAfterRateLimitedTabCompletes);
+ }
+ return new VelocityConfiguration.Advanced();
+ }
+
+ private static VelocityConfiguration.Query readQuery(CommentedConfig config) {
+ if (config != null) {
+ boolean queryEnabled = config.getOrElse("enabled", false);
+ int queryPort = config.getIntOrElse("port", 25565);
+ String queryMap = config.getOrElse("map", "Velocity");
+ boolean showPlugins = config.getOrElse("show-plugins", false);
+ return new VelocityConfiguration.Query(queryEnabled, queryPort, queryMap, showPlugins);
+ }
+ return new VelocityConfiguration.Query();
+ }
+
+ private static VelocityConfiguration.Metrics readMetrics(CommentedConfig config) {
+ if (config != null) {
+ boolean enabled = config.getOrElse("enabled", true);
+ return new VelocityConfiguration.Metrics(enabled);
+ }
+ return new VelocityConfiguration.Metrics();
+ }
+
+}
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 2c7826e019..73d502c82d 100644
--- a/proxy/src/main/java/com/velocitypowered/proxy/config/VelocityConfiguration.java
+++ b/proxy/src/main/java/com/velocitypowered/proxy/config/VelocityConfiguration.java
@@ -18,33 +18,18 @@
package com.velocitypowered.proxy.config;
import com.electronwill.nightconfig.core.CommentedConfig;
-import com.electronwill.nightconfig.core.UnmodifiableConfig;
-import com.electronwill.nightconfig.core.file.CommentedFileConfig;
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.ForwardingMigration;
-import com.velocitypowered.proxy.config.migration.KeyAuthenticationMigration;
-import com.velocitypowered.proxy.config.migration.MiniMessageTranslationsMigration;
-import com.velocitypowered.proxy.config.migration.MotdMigration;
-import com.velocitypowered.proxy.config.migration.PacketLimiterMigration;
-import com.velocitypowered.proxy.config.migration.TransferIntegrationMigration;
import com.velocitypowered.proxy.util.AddressUtil;
-import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
-import java.io.IOException;
import java.net.InetSocketAddress;
-import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.SecureRandom;
-import java.util.HashMap;
import java.util.List;
-import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Random;
@@ -53,61 +38,46 @@
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.
*/
+@ConfigSerializable
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() {
}
- private VelocityConfiguration(String bind, String motd, int showMaxPlayers, boolean onlineMode,
+ VelocityConfiguration(String bind, String motd, int showMaxPlayers, boolean onlineMode,
boolean preventClientProxyConnections, boolean announceForge,
PlayerInfoForwarding playerInfoForwardingMode, byte[] forwardingSecret,
boolean onlineModeKickExistingPlayers, PingPassthroughMode pingPassthrough,
@@ -312,6 +282,10 @@ public byte[] getForwardingSecret() {
return forwardingSecret.clone();
}
+ void setForwardingSecret(final byte[] forwardingSecret) {
+ this.forwardingSecret = forwardingSecret;
+ }
+
@Override
public Map getServers() {
return servers.getServers();
@@ -476,134 +450,6 @@ public String toString() {
.toString();
}
- /**
- * Reads the Velocity configuration from {@code path}.
- *
- * @param path the path to read from
- * @return the deserialized Velocity configuration
- * @throws IOException if we could not read from the {@code path}.
- */
- @SuppressFBWarnings(value = "RCN_REDUNDANT_NULLCHECK_WOULD_HAVE_BEEN_A_NPE",
- justification = "I looked carefully and there's no way SpotBugs is right.")
- public static VelocityConfiguration read(Path path) throws IOException {
- URL defaultConfigLocation = VelocityConfiguration.class.getClassLoader()
- .getResource("default-velocity.toml");
- if (defaultConfigLocation == null) {
- throw new RuntimeException("Default configuration file does not exist.");
- }
-
- // Create the forwarding-secret file on first-time startup if it doesn't exist
- final Path defaultForwardingSecretPath = Path.of("forwarding.secret");
- if (Files.notExists(path) && Files.notExists(defaultForwardingSecretPath)) {
- Files.writeString(defaultForwardingSecretPath, generateRandomString(12));
- }
-
- try (final CommentedFileConfig config = CommentedFileConfig.builder(path)
- .defaultData(defaultConfigLocation)
- .autosave()
- .preserveInsertionOrder()
- .sync()
- .build()
- ) {
- config.load();
-
- final ConfigurationMigration[] migrations = {
- new ForwardingMigration(),
- new KeyAuthenticationMigration(),
- new MotdMigration(),
- new MiniMessageTranslationsMigration(),
- new TransferIntegrationMigration(),
- new PacketLimiterMigration()
- };
-
- for (final ConfigurationMigration migration : migrations) {
- if (migration.shouldMigrate(config)) {
- migration.migrate(config, logger);
- }
- }
-
- String forwardingSecretString = System.getenv().getOrDefault(
- "VELOCITY_FORWARDING_SECRET", "");
- if (forwardingSecretString.isBlank()) {
- final String forwardSecretFile = config.get("forwarding-secret-file");
- final Path secretPath = forwardSecretFile == null
- ? defaultForwardingSecretPath
- : Path.of(forwardSecretFile);
- if (Files.exists(secretPath)) {
- if (Files.isRegularFile(secretPath)) {
- forwardingSecretString = String.join("", Files.readAllLines(secretPath));
- } else {
- throw new RuntimeException(
- "The file " + forwardSecretFile + " is not a valid file or it is a directory.");
- }
- } else {
- Files.createFile(secretPath);
- Files.writeString(secretPath, forwardingSecretString = generateRandomString(12),
- StandardCharsets.UTF_8);
- logger.info("The forwarding-secret-file does not exist. A new file has been created at {}",
- forwardSecretFile);
- }
- }
- final byte[] forwardingSecret = forwardingSecretString.getBytes(StandardCharsets.UTF_8);
- final String motd = config.getOrElse("motd", "<#09add3>A Velocity Server");
-
- // Read the rest of the config
- final CommentedConfig serversConfig = config.get("servers");
- final CommentedConfig forcedHostsConfig = config.get("forced-hosts");
- final CommentedConfig advancedConfig = config.get("advanced");
- final CommentedConfig queryConfig = config.get("query");
- final CommentedConfig metricsConfig = config.get("metrics");
- final PlayerInfoForwarding forwardingMode = config.getEnumOrElse(
- "player-info-forwarding-mode", PlayerInfoForwarding.NONE);
- final PingPassthroughMode pingPassthroughMode = config.getEnumOrElse("ping-passthrough",
- PingPassthroughMode.DISABLED);
-
- final boolean samplePlayersInPing = config.getOrElse("sample-players-in-ping", false);
-
- final String bind = config.getOrElse("bind", "0.0.0.0:25565");
- final int maxPlayers = config.getIntOrElse("show-max-players", 500);
- final boolean onlineMode = config.getOrElse("online-mode", true);
- final boolean forceKeyAuthentication = config.getOrElse("force-key-authentication", true);
- final boolean announceForge = config.getOrElse("announce-forge", true);
- final boolean preventClientProxyConnections = config.getOrElse(
- "prevent-client-proxy-connections", false);
- final boolean kickExisting = config.getOrElse("kick-existing-players", false);
- final boolean enablePlayerAddressLogging = config.getOrElse(
- "enable-player-address-logging", true);
- final PacketLimiterConfig packetLimiterConfig = PacketLimiterConfig.fromConfig(config.get("packet-limiter"));
-
- // Throw an exception if the forwarding-secret file is empty and the proxy is using a
- // forwarding mode that requires it.
- if (forwardingSecret.length == 0
- && (forwardingMode == PlayerInfoForwarding.MODERN
- || forwardingMode == PlayerInfoForwarding.BUNGEEGUARD)) {
- throw new RuntimeException("The forwarding-secret file must not be empty.");
- }
-
- return new VelocityConfiguration(
- bind,
- motd,
- maxPlayers,
- onlineMode,
- preventClientProxyConnections,
- announceForge,
- forwardingMode,
- forwardingSecret,
- kickExisting,
- pingPassthroughMode,
- samplePlayersInPing,
- enablePlayerAddressLogging,
- new Servers(serversConfig),
- new ForcedHosts(forcedHostsConfig),
- new Advanced(advancedConfig),
- new Query(queryConfig),
- new Metrics(metricsConfig),
- forceKeyAuthentication,
- packetLimiterConfig
- );
- }
- }
-
/**
* Generates a Random String.
*
@@ -624,42 +470,23 @@ public boolean isOnlineModeKickExistingPlayers() {
return onlineModeKickExistingPlayers;
}
- private static class Servers {
-
- private Map servers = ImmutableMap.of(
- "lobby", "127.0.0.1:30066",
- "factions", "127.0.0.1:30067",
- "minigames", "127.0.0.1:30068"
- );
- private List attemptConnectionOrder = ImmutableList.of("lobby");
+ static class Servers {
- private Servers() {
- }
+ // Example entries live in default-velocity.yaml, not here: the code default must stay empty so
+ // that a user who removes the section gets an empty map rather than resurrected examples that
+ // reference servers they no longer have.
+ private Map servers = ImmutableMap.of();
+ private List attemptConnectionOrder = ImmutableList.of();
- private Servers(CommentedConfig config) {
- if (config != null) {
- Map servers = new HashMap<>();
- for (UnmodifiableConfig.Entry entry : config.entrySet()) {
- if (entry.getValue() instanceof String) {
- servers.put(cleanServerName(entry.getKey()), entry.getValue());
- } else {
- if (!entry.getKey().equalsIgnoreCase("try")) {
- throw new IllegalArgumentException(
- "Server entry " + entry.getKey() + " is not a string!");
- }
- }
- }
- this.servers = ImmutableMap.copyOf(servers);
- this.attemptConnectionOrder = config.getOrElse("try", attemptConnectionOrder);
- }
+ Servers() {
}
- private Servers(Map servers, List attemptConnectionOrder) {
+ Servers(Map servers, List attemptConnectionOrder) {
this.servers = servers;
this.attemptConnectionOrder = attemptConnectionOrder;
}
- private Map getServers() {
+ Map getServers() {
return servers;
}
@@ -683,7 +510,7 @@ public void setAttemptConnectionOrder(List attemptConnectionOrder) {
* @param name the server name to clean
* @return the cleaned server name
*/
- private String cleanServerName(String name) {
+ static String cleanServerName(String name) {
return name.replace("\"", "");
}
@@ -696,41 +523,21 @@ public String toString() {
}
}
- private static class ForcedHosts {
+ static class ForcedHosts {
- private Map> forcedHosts = ImmutableMap.of(
- "lobby.example.com", ImmutableList.of("lobby"),
- "factions.example.com", ImmutableList.of("factions"),
- "minigames.example.com", ImmutableList.of("minigames")
- );
+ // Example entries live in default-velocity.yaml, not here: the code default must stay empty so
+ // that removing the section yields no forced hosts rather than examples pointing at servers the
+ // user does not have (which would fail validation).
+ private Map> forcedHosts = ImmutableMap.of();
- private ForcedHosts() {
+ ForcedHosts() {
}
- private ForcedHosts(CommentedConfig config) {
- if (config != null) {
- Map> forcedHosts = new HashMap<>();
- for (UnmodifiableConfig.Entry entry : config.entrySet()) {
- 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) entry.getValue()));
- } else {
- throw new IllegalStateException(
- "Invalid value of type " + entry.getValue().getClass() + " in forced hosts!");
- }
- }
- this.forcedHosts = ImmutableMap.copyOf(forcedHosts);
- }
- }
-
- private ForcedHosts(Map> forcedHosts) {
+ ForcedHosts(Map> forcedHosts) {
this.forcedHosts = forcedHosts;
}
- private Map> getForcedHosts() {
+ Map> getForcedHosts() {
return forcedHosts;
}
@@ -746,81 +553,65 @@ public String toString() {
}
}
- private static class Advanced {
+ @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;
- private Advanced() {
+ Advanced() {
+ }
+
+ Advanced(int compressionThreshold, int compressionLevel, int loginRatelimit,
+ int connectionTimeout, int readTimeout, boolean proxyProtocol, boolean tcpFastOpen,
+ boolean bungeePluginMessageChannel, boolean showPingRequests,
+ boolean failoverOnUnexpectedServerDisconnect, boolean announceProxyCommands,
+ boolean logCommandExecutions, boolean logPlayerConnections, boolean acceptTransfers,
+ boolean enableReusePort, int commandRateLimit, boolean forwardCommandsIfRateLimited,
+ int kickAfterRateLimitedCommands, int tabCompleteRateLimit,
+ int kickAfterRateLimitedTabCompletes) {
+ this.compressionThreshold = compressionThreshold;
+ this.compressionLevel = compressionLevel;
+ this.loginRatelimit = loginRatelimit;
+ this.connectionTimeout = connectionTimeout;
+ this.readTimeout = readTimeout;
+ this.proxyProtocol = proxyProtocol;
+ this.tcpFastOpen = tcpFastOpen;
+ this.bungeePluginMessageChannel = bungeePluginMessageChannel;
+ this.showPingRequests = showPingRequests;
+ this.failoverOnUnexpectedServerDisconnect = failoverOnUnexpectedServerDisconnect;
+ this.announceProxyCommands = announceProxyCommands;
+ this.logCommandExecutions = logCommandExecutions;
+ this.logPlayerConnections = logPlayerConnections;
+ this.acceptTransfers = acceptTransfers;
+ this.enableReusePort = enableReusePort;
+ this.commandRateLimit = commandRateLimit;
+ this.forwardCommandsIfRateLimited = forwardCommandsIfRateLimited;
+ this.kickAfterRateLimitedCommands = kickAfterRateLimitedCommands;
+ this.tabCompleteRateLimit = tabCompleteRateLimit;
+ this.kickAfterRateLimitedTabCompletes = kickAfterRateLimitedTabCompletes;
}
- private Advanced(CommentedConfig config) {
- if (config != null) {
- this.compressionThreshold = config.getIntOrElse("compression-threshold", 256);
- this.compressionLevel = config.getIntOrElse("compression-level", -1);
- this.loginRatelimit = config.getIntOrElse("login-ratelimit", 3000);
- this.connectionTimeout = config.getIntOrElse("connection-timeout", 5000);
- this.readTimeout = config.getIntOrElse("read-timeout", 30000);
- if (config.contains("haproxy-protocol")) {
- this.proxyProtocol = config.getOrElse("haproxy-protocol", false);
- } else {
- this.proxyProtocol = config.getOrElse("proxy-protocol", false);
- }
- this.tcpFastOpen = config.getOrElse("tcp-fast-open", false);
- this.bungeePluginMessageChannel = config.getOrElse("bungee-plugin-message-channel", true);
- this.showPingRequests = config.getOrElse("show-ping-requests", false);
- this.failoverOnUnexpectedServerDisconnect = config
- .getOrElse("failover-on-unexpected-server-disconnect", true);
- this.announceProxyCommands = config.getOrElse("announce-proxy-commands", true);
- this.logCommandExecutions = config.getOrElse("log-command-executions", false);
- this.logPlayerConnections = config.getOrElse("log-player-connections", true);
- this.acceptTransfers = config.getOrElse("accepts-transfers", false);
- this.enableReusePort = config.getOrElse("enable-reuse-port", false);
- this.commandRateLimit = config.getIntOrElse("command-rate-limit", 25);
- this.forwardCommandsIfRateLimited = config.getOrElse("forward-commands-if-rate-limited", true);
- this.kickAfterRateLimitedCommands = config.getIntOrElse("kick-after-rate-limited-commands", 0);
- this.tabCompleteRateLimit = config.getIntOrElse("tab-complete-rate-limit", 10); // very lenient
- this.kickAfterRateLimitedTabCompletes = config.getIntOrElse("kick-after-rate-limited-tab-completes", 0);
- }
- }
public int getCompressionThreshold() {
return compressionThreshold;
@@ -928,36 +719,27 @@ public String toString() {
}
}
- private static class Query {
+ @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;
- private Query() {
+ Query() {
}
- private Query(boolean queryEnabled, int queryPort, String queryMap, boolean showPlugins) {
+ Query(boolean queryEnabled, int queryPort, String queryMap, boolean showPlugins) {
this.queryEnabled = queryEnabled;
this.queryPort = queryPort;
this.queryMap = queryMap;
this.showPlugins = showPlugins;
}
- private Query(CommentedConfig config) {
- if (config != null) {
- this.queryEnabled = config.getOrElse("enabled", false);
- this.queryPort = config.getIntOrElse("port", 25565);
- this.queryMap = config.getOrElse("map", "Velocity");
- this.showPlugins = config.getOrElse("show-plugins", false);
- }
- }
-
public boolean isQueryEnabled() {
return queryEnabled;
}
@@ -988,14 +770,16 @@ public String toString() {
/**
* Configuration for metrics.
*/
+ @ConfigSerializable
public static class Metrics {
private boolean enabled = true;
- private Metrics(CommentedConfig toml) {
- if (toml != null) {
- this.enabled = toml.getOrElse("enabled", true);
- }
+ Metrics() {
+ }
+
+ Metrics(boolean enabled) {
+ this.enabled = enabled;
}
public boolean isEnabled() {
diff --git a/proxy/src/main/resources/default-velocity.yaml b/proxy/src/main/resources/default-velocity.yaml
new file mode 100644
index 0000000000..bbcb104551
--- /dev/null
+++ b/proxy/src/main/resources/default-velocity.yaml
@@ -0,0 +1,200 @@
+# Config version. Do not change this
+config-version: 1
+
+# 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"
+
+# What should be the MOTD? This gets displayed when the player adds your server to
+# their server list. Only MiniMessage format is accepted.
+motd: "<#09add3>A Velocity Server"
+
+# What should we display for the maximum number of players? (Velocity does not support a cap
+# on the number of players online.)
+show-max-players: 500
+
+# Should we authenticate players with Mojang? By default, this is on.
+online-mode: true
+
+# Should the proxy enforce the new public key security standard? By default, this is on.
+force-key-authentication: true
+
+# If client's ISP/AS sent from this proxy is different from the one from Mojang's
+# authentication server, the player is kicked. This disallows some VPN and proxy
+# connections but is a weak form of protection.
+prevent-client-proxy-connections: false
+
+# Should we forward IP addresses and other data to backend servers?
+# Available options:
+# - "none": No forwarding will be done. All players will appear to be connecting
+# from the proxy and will have offline-mode UUIDs.
+# - "legacy": Forward player IPs and UUIDs in a BungeeCord-compatible format. Use this
+# if you run servers using Minecraft 1.12 or lower.
+# - "bungeeguard": Forward player IPs and UUIDs in a format supported by the BungeeGuard
+# plugin. Use this if you run servers using Minecraft 1.12 or lower, and are
+# unable to implement network level firewalling (on a shared host).
+# - "modern": Forward player IPs and UUIDs as part of the login process using
+# Velocity's native forwarding. Only applicable for Minecraft 1.13 or higher.
+player-info-forwarding-mode: "NONE"
+
+# If you are using modern or BungeeGuard IP forwarding, configure a file that contains a unique secret here.
+# The file is expected to be UTF-8 encoded and not empty.
+forwarding-secret-file: "forwarding.secret"
+
+# Announce whether or not your server supports Forge. If you run a modded server, we
+# suggest turning this on.
+#
+# If your network runs one modpack consistently, consider using ping-passthrough = "mods"
+# instead for a nicer display in the server list.
+announce-forge: false
+
+# If enabled (default is false) and the proxy is in online mode, Velocity will kick
+# any existing player who is online if a duplicate connection attempt is made.
+kick-existing-players: false
+
+# Should Velocity pass server list ping requests to a backend server?
+# Available options:
+# - "disabled": No pass-through will be done. The velocity.yaml and server-icon.png
+# will determine the initial server list ping response.
+# - "mods": Passes only the mod list from your backend server into the response.
+# The first server in your try list (or forced host) with a mod list will be
+# used. If no backend servers can be contacted, Velocity won't display any
+# mod information.
+# - "description": Uses the description and mod list from the backend server. The first
+# server in the try (or forced host) list that responds is used for the
+# description and mod list.
+# - "all": Uses the backend server's response as the proxy response. The Velocity
+# configuration is used if no servers could be contacted.
+ping-passthrough: "DISABLED"
+
+# If enabled (default is false), then a sample of the online players on the proxy will be visible
+# when hovering over the player count in the server list.
+# This doesn't have any effect when ping passthrough is set to either "description" or "all".
+sample-players-in-ping: false
+
+# If not enabled (default is true) player IP addresses will be replaced by in logs
+enable-player-address-logging: true
+
+packet-limiter:
+ # Size of the moving time window in seconds used to calculate average rates.
+ # A larger window tolerates short bursts while still enforcing the configured limits over time.
+ interval: 7
+ # Maximum average number of packets per second a client may send. -1 disables this check.
+ packets-per-second: -1
+ # Maximum average number of compressed (on-wire) bytes per second a client may send. -1 disables this check.
+ bytes-per-second: -1
+ # Maximum average number of decompressed bytes per second a client may send.
+ # Protects against compression bomb attacks where small packets expand to excessive sizes after decompression.
+ # -1 disables this check.
+ decompressed-bytes-per-second: 5242880
+
+servers:
+ # Configure your servers here. Each key represents the server's name, and the value
+ # represents the IP address of the server to connect to.
+ lobby: "127.0.0.1:30066"
+ factions: "127.0.0.1:30067"
+ minigames: "127.0.0.1:30068"
+
+ # In what order we should try servers when a player logs in or is kicked from a server.
+ try:
+ - lobby
+
+forced-hosts:
+ # Configure your forced hosts here.
+ "lobby.example.com":
+ - lobby
+ "factions.example.com":
+ - factions
+ "minigames.example.com":
+ - minigames
+
+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.
+ compression-threshold: 256
+
+ # How much compression should be done (from 0-9). The default is -1, which uses the
+ # default level of 6.
+ compression-level: -1
+
+ # How fast (in milliseconds) are clients allowed to connect after the last connection? By
+ # default, this is three seconds. Disable this by setting this to 0.
+ login-ratelimit: 3000
+
+ # Specify a custom timeout for connection timeouts here. The default is five seconds.
+ connection-timeout: 5000
+
+ # Specify a read timeout for connections here. The default is 30 seconds.
+ read-timeout: 30000
+
+ # Enables compatibility with HAProxy's PROXY protocol. If you don't know what this is for, then
+ # don't enable it.
+ haproxy-protocol: false
+
+ # Enables TCP fast open support on the proxy. Requires the proxy to run on Linux.
+ tcp-fast-open: false
+
+ # Enables BungeeCord plugin messaging channel support on Velocity.
+ bungee-plugin-message-channel: true
+
+ # Shows ping requests to the proxy from clients.
+ show-ping-requests: false
+
+ # By default, Velocity will attempt to gracefully handle situations where the user unexpectedly
+ # loses connection to the server without an explicit disconnect message by attempting to fall the
+ # user back, except in the case of read timeouts. BungeeCord will disconnect the user instead. You
+ # can disable this setting to use the BungeeCord behavior.
+ failover-on-unexpected-server-disconnect: true
+
+ # Declares the proxy commands to 1.13+ clients.
+ announce-proxy-commands: true
+
+ # Enables the logging of commands
+ log-command-executions: false
+
+ # Enables logging of player connections when connecting to the proxy, switching servers
+ # and disconnecting from the proxy.
+ log-player-connections: true
+
+ # Allows players transferred from other hosts via the
+ # Transfer packet (Minecraft 1.20.5) to be received.
+ accepts-transfers: false
+
+ # Enables support for SO_REUSEPORT. This may help the proxy scale better on multicore systems
+ # with a lot of incoming connections, and provide better CPU utilization than the existing
+ # strategy of having a single thread accepting connections and distributing them to worker
+ # threads. Disabled by default. Requires Linux or macOS.
+ enable-reuse-port: false
+
+ # How fast (in milliseconds) are clients allowed to send commands after the last command
+ # By default this is 50ms (20 commands per second)
+ command-rate-limit: 50
+
+ # Should we forward commands to the backend upon being rate limited?
+ # This will forward the command to the server instead of processing it on the proxy.
+ # Since most server implementations have a rate limit, this will prevent the player
+ # from being able to send excessive commands to the server.
+ forward-commands-if-rate-limited: true
+
+ # How many commands are allowed to be sent after the rate limit is hit before the player is kicked?
+ # Setting this to 0 or lower will disable this feature.
+ kick-after-rate-limited-commands: 0
+
+ # How fast (in milliseconds) are clients allowed to send tab completions after the last tab completion
+ tab-complete-rate-limit: 10
+
+ # How many tab completions are allowed to be sent after the rate limit is hit before the player is kicked?
+ # Setting this to 0 or lower will disable this feature.
+ kick-after-rate-limited-tab-completes: 0
+
+query:
+ # Whether to enable responding to GameSpy 4 query responses or not.
+ enabled: false
+
+ # If query is enabled, on what port should the query protocol listen on?
+ port: 25565
+
+ # This is the map name that is reported to the query services.
+ map: "Velocity"
+
+ # Whether plugins should be shown in query response by default or not
+ show-plugins: false
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 0000000000..80a7663242
--- /dev/null
+++ b/proxy/src/test/java/com/velocitypowered/proxy/config/ConfigurationLoaderTest.java
@@ -0,0 +1,239 @@
+/*
+ * 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.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;
+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;
+import org.spongepowered.configurate.CommentedConfigurationNode;
+
+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.yaml");
+ try (InputStream in = ConfigurationLoaderTest.class.getClassLoader()
+ .getResourceAsStream("default-velocity.yaml")) {
+ assertNotNull(in, "default-velocity.yaml 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.yaml");
+ 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.yaml");
+ ConfigurationLoader.save(ConfigurationLoader.load(path), roundTripped);
+ assertConfig(ConfigurationLoader.load(roundTripped));
+ }
+
+ /**
+ * A legacy {@code velocity.toml} must be converted to {@code velocity.yaml}: 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.yaml");
+ 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());
+ }
+
+ /**
+ * Removing the {@code forced-hosts}/{@code try} sections must leave them empty rather than
+ * resurrecting the bundled example entries, which would reference servers the user does not have
+ * and fail validation.
+ */
+ @Test
+ void removedSectionsDoNotResurrectDefaults(@TempDir final Path dir) throws IOException {
+ final Path path = dir.resolve("velocity.yaml");
+ Files.writeString(path, """
+ servers:
+ only: "1.2.3.4:25565"
+ """, StandardCharsets.UTF_8);
+
+ final VelocityConfiguration config = ConfigurationLoader.load(path);
+
+ assertEquals(ImmutableMap.of("only", "1.2.3.4:25565"), config.getServers());
+ assertTrue(config.getForcedHosts().isEmpty());
+ assertTrue(config.getAttemptConnectionOrder().isEmpty());
+ }
+
+ 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());
+ }
+}