diff --git a/server-plugin/build.gradle b/server-plugin/build.gradle new file mode 100644 index 0000000..6131945 --- /dev/null +++ b/server-plugin/build.gradle @@ -0,0 +1,52 @@ +plugins { + id 'java' +} + +group = 'dev.loki.lovisual' +version = '1.0.0' + +java { + // velocity-api 4.2.0 class files require Java 25; settings.gradle enables + // foojay auto-provisioning so a plain JDK 21 machine still builds. + toolchain { + languageVersion = JavaLanguageVersion.of(25) + } +} + +repositories { + mavenCentral() + maven { + name = 'papermc' + url = 'https://repo.papermc.io/repository/maven-public/' + } +} + +dependencies { + compileOnly 'com.velocitypowered:velocity-api:4.2.0' + annotationProcessor 'com.velocitypowered:velocity-api:4.2.0' + + implementation 'com.google.code.gson:gson:2.14.0' + implementation 'org.yaml:snakeyaml:2.5' + + testImplementation 'org.junit.jupiter:junit-jupiter:5.11.4' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testImplementation 'com.google.code.gson:gson:2.14.0' + testImplementation 'org.yaml:snakeyaml:2.5' +} + +tasks.named('test') { + useJUnitPlatform() +} + +// Single deployable jar: plugin classes + shaded gson/snakeyaml + the +// annotation-processor generated velocity-plugin.json from processResources. +tasks.named('jar', Jar) { + archiveBaseName = 'lovisual-policy' + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + exclude 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA', 'META-INF/MANIFEST.MF' + exclude 'module-info.class', 'META-INF/versions/**/module-info.class' + exclude 'META-INF/maven/**', 'META-INF/proguard/**', 'META-INF/LICENSE*', 'META-INF/NOTICE*' + from { + configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) } + } +} diff --git a/server-plugin/settings.gradle b/server-plugin/settings.gradle new file mode 100644 index 0000000..422e2c7 --- /dev/null +++ b/server-plugin/settings.gradle @@ -0,0 +1,5 @@ +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0' +} + +rootProject.name = 'lovisual-policy' diff --git a/server-plugin/src/main/java/dev/loki/lovisual/policy/PolicyConfig.java b/server-plugin/src/main/java/dev/loki/lovisual/policy/PolicyConfig.java new file mode 100644 index 0000000..c5bbf6b --- /dev/null +++ b/server-plugin/src/main/java/dev/loki/lovisual/policy/PolicyConfig.java @@ -0,0 +1,94 @@ +package dev.loki.lovisual.policy; + +import org.yaml.snakeyaml.Yaml; + +import java.io.IOException; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * The proxy-side policy: which mod features are denied and how strictly. + * Lives in {@code plugins/lovisual-policy/config.yml}; written with defaults + * on first run. Invalid content (e.g. an unknown mode) throws so the plugin + * can log the error instead of silently pushing a broken policy. + */ +public record PolicyConfig(boolean enabled, PolicyMode mode, String reason, List deny) { + + private static final String DEFAULT_REASON = "Правила сервера"; + private static final List EXAMPLE_DENY = + List.of("esp", "freecam", "hitboxes", "fullbright"); + + static final String DEFAULT_YAML = """ + # LoVisual server policy: features denied to clients on this proxy. + # mode: lock — denied modules are switched off and cannot be enabled; + # mode: suggest — the mod only warns the player. + enabled: true + mode: lock + reason: "Правила сервера" + deny: + - esp + - freecam + - hitboxes + - fullbright + """; + + public PolicyConfig { + reason = reason == null ? "" : reason; + deny = deny == null ? List.of() : List.copyOf(deny); + } + + public static PolicyConfig defaults() { + return new PolicyConfig(true, PolicyMode.LOCK, DEFAULT_REASON, EXAMPLE_DENY); + } + + /** + * Loads {@code config.yml}, writing {@link #DEFAULT_YAML} first when it is + * absent. An empty file falls back to {@link #defaults()}. + */ + public static PolicyConfig load(Path file) throws IOException { + if (!Files.exists(file)) { + Files.createDirectories(file.getParent()); + Files.writeString(file, DEFAULT_YAML, StandardCharsets.UTF_8); + return defaults(); + } + try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { + Map map = new Yaml().load(reader); + if (map == null || map.isEmpty()) { + return defaults(); + } + return fromMap(map); + } + } + + /** Builds a config from parsed YAML/map values with per-key fallbacks. */ + public static PolicyConfig fromMap(Map map) { + if (map == null || map.isEmpty()) { + return defaults(); + } + Object enabledRaw = map.get("enabled"); + boolean enabled = enabledRaw == null + || (enabledRaw instanceof Boolean b ? b : Boolean.parseBoolean(String.valueOf(enabledRaw))); + PolicyMode mode = PolicyMode.parse(map.get("mode") == null ? null : String.valueOf(map.get("mode"))); + Object reasonRaw = map.get("reason"); + String reason = reasonRaw == null ? DEFAULT_REASON : String.valueOf(reasonRaw); + return new PolicyConfig(enabled, mode, reason, denyList(map.get("deny"))); + } + + private static List denyList(Object raw) { + if (!(raw instanceof List list)) { + return List.of(); + } + List out = new ArrayList<>(list.size()); + for (Object item : list) { + if (item != null) { + out.add(String.valueOf(item)); + } + } + return out; + } +} diff --git a/server-plugin/src/main/java/dev/loki/lovisual/policy/PolicyMessage.java b/server-plugin/src/main/java/dev/loki/lovisual/policy/PolicyMessage.java new file mode 100644 index 0000000..b3e919d --- /dev/null +++ b/server-plugin/src/main/java/dev/loki/lovisual/policy/PolicyMessage.java @@ -0,0 +1,49 @@ +package dev.loki.lovisual.policy; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonSyntaxException; + +/** + * The wire format of the {@code lovisual:policy} channel: UTF-8 JSON, one + * message per plugin-message packet. The mod only ever parses requests + * ({@link #isRequest}); the plugin only ever encodes policies + * ({@link #encodePolicy}). + */ +public final class PolicyMessage { + + /** The registry name of the plugin channel shared with the mod. */ + public static final String CHANNEL = "lovisual:policy"; + + private PolicyMessage() { + } + + /** Encodes a S→C policy payload. A null server name is sent as "". */ + public static String encodePolicy(PolicyConfig config, String server) { + JsonObject json = new JsonObject(); + json.addProperty("type", "policy"); + json.addProperty("mode", config.mode() == PolicyMode.SUGGEST ? "suggest" : "lock"); + json.addProperty("reason", config.reason()); + json.addProperty("server", server == null ? "" : server); + JsonArray deny = new JsonArray(); + for (String id : config.deny()) { + deny.add(id); + } + json.add("deny", deny); + return json.toString(); + } + + /** True when the given C→S payload is a policy request from the mod. */ + public static boolean isRequest(String payload) { + if (payload == null || payload.isBlank()) { + return false; + } + try { + JsonObject json = JsonParser.parseString(payload).getAsJsonObject(); + return json.has("type") && "request".equals(json.get("type").getAsString()); + } catch (JsonSyntaxException | IllegalStateException | ClassCastException e) { + return false; + } + } +} diff --git a/server-plugin/src/main/java/dev/loki/lovisual/policy/PolicyMode.java b/server-plugin/src/main/java/dev/loki/lovisual/policy/PolicyMode.java new file mode 100644 index 0000000..24da65c --- /dev/null +++ b/server-plugin/src/main/java/dev/loki/lovisual/policy/PolicyMode.java @@ -0,0 +1,29 @@ +package dev.loki.lovisual.policy; + +import java.util.Locale; + +/** + * How the mod must treat a denied feature: {@link #LOCK} refuses to enable it + * and shows a lock in the ClickGui, {@link #SUGGEST} only warns the player. + */ +public enum PolicyMode { + LOCK, + SUGGEST; + + /** + * Parses a config value. A missing value falls back to the conservative + * {@link #LOCK}; an unknown value is an admin error and throws. + * + * @throws IllegalArgumentException when {@code raw} is present but not {@code lock}/{@code suggest} + */ + public static PolicyMode parse(String raw) { + if (raw == null || raw.isBlank()) { + return LOCK; + } + return switch (raw.trim().toLowerCase(Locale.ROOT)) { + case "lock" -> LOCK; + case "suggest" -> SUGGEST; + default -> throw new IllegalArgumentException("unknown mode: " + raw + " (expected lock|suggest)"); + }; + } +} diff --git a/server-plugin/src/main/java/dev/loki/lovisual/policy/plugin/LoVisualPolicyPlugin.java b/server-plugin/src/main/java/dev/loki/lovisual/policy/plugin/LoVisualPolicyPlugin.java new file mode 100644 index 0000000..a3d18a1 --- /dev/null +++ b/server-plugin/src/main/java/dev/loki/lovisual/policy/plugin/LoVisualPolicyPlugin.java @@ -0,0 +1,127 @@ +package dev.loki.lovisual.policy.plugin; + +import com.velocitypowered.api.command.CommandManager; +import com.velocitypowered.api.event.Subscribe; +import com.velocitypowered.api.event.connection.DisconnectEvent; +import com.velocitypowered.api.event.connection.PluginMessageEvent; +import com.velocitypowered.api.event.connection.PostLoginEvent; +import com.velocitypowered.api.event.player.ServerConnectedEvent; +import com.velocitypowered.api.event.proxy.ProxyInitializeEvent; +import com.velocitypowered.api.plugin.Plugin; +import com.velocitypowered.api.plugin.annotation.DataDirectory; +import com.velocitypowered.api.proxy.ProxyServer; +import com.velocitypowered.api.proxy.Player; +import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier; +import com.google.inject.Inject; +import dev.loki.lovisual.policy.PolicyConfig; +import dev.loki.lovisual.policy.PolicyMessage; +import org.slf4j.Logger; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Velocity-side source of the {@code lovisual:policy} channel: loads + * {@code config.yml} once, pushes the policy on join/backend switch and in + * answer to the mod's {@code {"type":"request"}} message. + */ +@Plugin(id = "lovisual-policy", name = "LoVisual Policy", version = "1.0.0", + authors = {"loki"}) +public final class LoVisualPolicyPlugin { + + private static final MinecraftChannelIdentifier CHANNEL = + MinecraftChannelIdentifier.from(PolicyMessage.CHANNEL); + + private final ProxyServer proxy; + private final Logger logger; + private final Path dataDirectory; + private final Map currentServer = new ConcurrentHashMap<>(); + + /** Null when {@code config.yml} is invalid — then nothing is sent. */ + private volatile PolicyConfig config; + + @Inject + public LoVisualPolicyPlugin(ProxyServer proxy, Logger logger, @DataDirectory Path dataDirectory) { + this.proxy = proxy; + this.logger = logger; + this.dataDirectory = dataDirectory; + } + + @Subscribe + public void onProxyInitialize(ProxyInitializeEvent event) { + proxy.getChannelRegistrar().register(CHANNEL); + CommandManager commands = proxy.getCommandManager(); + commands.register(commands.metaBuilder("lvpolicy").plugin(this).build(), + new PolicyCommand(this)); + reload(); + logger.info("LoVisual policy loaded: {}", status()); + } + + @Subscribe + public void onPostLogin(PostLoginEvent event) { + sendPolicy(event.getPlayer()); + } + + @Subscribe + public void onServerConnected(ServerConnectedEvent event) { + currentServer.put(event.getPlayer().getUniqueId(), + event.getServer().getServerInfo().getName()); + sendPolicy(event.getPlayer()); + } + + @Subscribe + public void onDisconnect(DisconnectEvent event) { + currentServer.remove(event.getPlayer().getUniqueId()); + } + + @Subscribe + public void onPluginMessage(PluginMessageEvent event) { + if (!CHANNEL.equals(event.getIdentifier()) + || !(event.getSource() instanceof Player player)) { + return; + } + event.setResult(PluginMessageEvent.ForwardResult.handled()); + if (PolicyMessage.isRequest(new String(event.getData(), StandardCharsets.UTF_8))) { + sendPolicy(player); + } + } + + /** Re-reads {@code config.yml} and pushes the fresh policy to everyone online. */ + public void reload() { + try { + config = PolicyConfig.load(dataDirectory.resolve("config.yml")); + } catch (IOException | IllegalArgumentException e) { + config = null; + logger.error("Invalid config.yml, no policy will be sent until it is fixed", e); + } + for (Player player : proxy.getAllPlayers()) { + sendPolicy(player); + } + } + + /** A one-line answer for {@code /lvpolicy status}. */ + public String status() { + PolicyConfig cfg = config; + if (cfg == null) { + return "config invalid (see log), nothing is sent"; + } + if (!cfg.enabled()) { + return "disabled"; + } + return cfg.mode() + ", deny=" + cfg.deny().size() + " modules, reason=" + cfg.reason(); + } + + private void sendPolicy(Player player) { + PolicyConfig cfg = config; + if (cfg == null || !cfg.enabled()) { + return; + } + String server = currentServer.getOrDefault(player.getUniqueId(), ""); + byte[] payload = PolicyMessage.encodePolicy(cfg, server).getBytes(StandardCharsets.UTF_8); + player.sendPluginMessage(CHANNEL, payload); + } +} diff --git a/server-plugin/src/main/java/dev/loki/lovisual/policy/plugin/PolicyCommand.java b/server-plugin/src/main/java/dev/loki/lovisual/policy/plugin/PolicyCommand.java new file mode 100644 index 0000000..810c619 --- /dev/null +++ b/server-plugin/src/main/java/dev/loki/lovisual/policy/plugin/PolicyCommand.java @@ -0,0 +1,34 @@ +package dev.loki.lovisual.policy.plugin; + +import com.velocitypowered.api.command.CommandSource; +import com.velocitypowered.api.command.SimpleCommand; + +/** + * {@code /lvpolicy [reload|status]} — shows the active policy and re-reads + * {@code config.yml} without a proxy restart. + */ +final class PolicyCommand implements SimpleCommand { + + private final LoVisualPolicyPlugin plugin; + + PolicyCommand(LoVisualPolicyPlugin plugin) { + this.plugin = plugin; + } + + @Override + public void execute(Invocation invocation) { + CommandSource source = invocation.source(); + String[] args = invocation.arguments(); + String sub = args.length == 0 ? "status" : args[0]; + if ("reload".equalsIgnoreCase(sub)) { + plugin.reload(); + source.sendPlainMessage("LoVisual policy: " + plugin.status()); + return; + } + if ("status".equalsIgnoreCase(sub)) { + source.sendPlainMessage("LoVisual policy: " + plugin.status()); + return; + } + source.sendPlainMessage("Использование: /lvpolicy [reload|status]"); + } +} diff --git a/server-plugin/src/test/java/dev/loki/lovisual/policy/PolicyConfigTest.java b/server-plugin/src/test/java/dev/loki/lovisual/policy/PolicyConfigTest.java new file mode 100644 index 0000000..c1985a0 --- /dev/null +++ b/server-plugin/src/test/java/dev/loki/lovisual/policy/PolicyConfigTest.java @@ -0,0 +1,105 @@ +package dev.loki.lovisual.policy; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PolicyConfigTest { + + @Test + void defaultsAreEnabledLockWithExampleDeny() { + PolicyConfig cfg = PolicyConfig.defaults(); + assertTrue(cfg.enabled()); + assertEquals(PolicyMode.LOCK, cfg.mode()); + assertFalse(cfg.reason().isBlank()); + assertFalse(cfg.deny().isEmpty()); + } + + @Test + void fromMapParsesAllFields() { + PolicyConfig cfg = PolicyConfig.fromMap(Map.of( + "enabled", true, + "mode", "suggest", + "reason", "custom rules", + "deny", List.of("esp", "freecam") + )); + assertTrue(cfg.enabled()); + assertEquals(PolicyMode.SUGGEST, cfg.mode()); + assertEquals("custom rules", cfg.reason()); + assertEquals(List.of("esp", "freecam"), cfg.deny()); + } + + @Test + void fromMapMissingModeDefaultsToLock() { + PolicyConfig cfg = PolicyConfig.fromMap(Map.of("deny", List.of("esp"))); + assertEquals(PolicyMode.LOCK, cfg.mode()); + assertEquals(List.of("esp"), cfg.deny()); + } + + @Test + void fromMapUnknownModeThrows() { + assertThrows(IllegalArgumentException.class, + () -> PolicyConfig.fromMap(Map.of("mode", "banana"))); + } + + @Test + void fromMapDisabledWithEmptyDeny() { + PolicyConfig cfg = PolicyConfig.fromMap(Map.of( + "enabled", false, + "mode", "lock", + "deny", List.of() + )); + assertFalse(cfg.enabled()); + assertTrue(cfg.deny().isEmpty()); + } + + @Test + void loadWritesDefaultConfigWhenMissing(@TempDir Path dir) throws Exception { + Path file = dir.resolve("config.yml"); + PolicyConfig cfg = PolicyConfig.load(file); + assertTrue(Files.exists(file)); + assertEquals(PolicyConfig.defaults(), cfg); + assertEquals(cfg, PolicyConfig.load(file)); + } + + @Test + void loadParsesYamlFile(@TempDir Path dir) throws Exception { + Path file = dir.resolve("config.yml"); + Files.writeString(file, """ + enabled: false + mode: suggest + reason: "no cheats" + deny: + - esp + - hitboxes + """); + PolicyConfig cfg = PolicyConfig.load(file); + assertFalse(cfg.enabled()); + assertEquals(PolicyMode.SUGGEST, cfg.mode()); + assertEquals("no cheats", cfg.reason()); + assertEquals(List.of("esp", "hitboxes"), cfg.deny()); + } + + @Test + void loadThrowsOnUnknownMode(@TempDir Path dir) throws Exception { + Path file = dir.resolve("config.yml"); + Files.writeString(file, "mode: banana\n"); + assertThrows(IllegalArgumentException.class, () -> PolicyConfig.load(file)); + } + + @Test + void loadOfEmptyFileFallsBackToDefaults(@TempDir Path dir) throws Exception { + Path file = dir.resolve("config.yml"); + Files.writeString(file, ""); + assertEquals(PolicyConfig.defaults(), PolicyConfig.load(file)); + } +} diff --git a/server-plugin/src/test/java/dev/loki/lovisual/policy/PolicyMessageTest.java b/server-plugin/src/test/java/dev/loki/lovisual/policy/PolicyMessageTest.java new file mode 100644 index 0000000..e921dd0 --- /dev/null +++ b/server-plugin/src/test/java/dev/loki/lovisual/policy/PolicyMessageTest.java @@ -0,0 +1,48 @@ +package dev.loki.lovisual.policy; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PolicyMessageTest { + + @Test + void encodePolicyWritesAllFields() { + PolicyConfig cfg = new PolicyConfig(true, PolicyMode.SUGGEST, "server rules", + List.of("esp", "freecam")); + String json = PolicyMessage.encodePolicy(cfg, "lobby-1"); + JsonObject parsed = JsonParser.parseString(json).getAsJsonObject(); + assertEquals("policy", parsed.get("type").getAsString()); + assertEquals("suggest", parsed.get("mode").getAsString()); + assertEquals("server rules", parsed.get("reason").getAsString()); + assertEquals("lobby-1", parsed.get("server").getAsString()); + assertEquals(2, parsed.getAsJsonArray("deny").size()); + assertEquals("esp", parsed.getAsJsonArray("deny").get(0).getAsString()); + } + + @Test + void encodePolicyEscapesReasonAndDefaultsServerToEmpty() { + PolicyConfig cfg = new PolicyConfig(true, PolicyMode.LOCK, "say \"hi\"\nline2", List.of()); + JsonObject parsed = JsonParser.parseString(PolicyMessage.encodePolicy(cfg, null)).getAsJsonObject(); + assertEquals("say \"hi\"\nline2", parsed.get("reason").getAsString()); + assertEquals("", parsed.get("server").getAsString()); + assertEquals(0, parsed.getAsJsonArray("deny").size()); + } + + @Test + void isRequestAcceptsOnlyTypedRequests() { + assertTrue(PolicyMessage.isRequest("{\"type\":\"request\"}")); + assertTrue(PolicyMessage.isRequest(" { \"type\" : \"request\" } ")); + assertFalse(PolicyMessage.isRequest("{\"type\":\"policy\"}")); + assertFalse(PolicyMessage.isRequest("{\"type\":42}")); + assertFalse(PolicyMessage.isRequest("garbage")); + assertFalse(PolicyMessage.isRequest("")); + assertFalse(PolicyMessage.isRequest(null)); + } +}