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()); + } +}