feat(policy): velocity plugin sending a lovisual:policy payload

This commit is contained in:
loki5512344 2026-09-30 12:08:02 +02:00
parent af2b417289
commit 711fcafc3a
Signed by: boba
GPG key ID: 253067914055423B
9 changed files with 543 additions and 0 deletions

View file

@ -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) }
}
}

View file

@ -0,0 +1,5 @@
plugins {
id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
}
rootProject.name = 'lovisual-policy'

View file

@ -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<String> deny) {
private static final String DEFAULT_REASON = "Правила сервера";
private static final List<String> 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<String> denyList(Object raw) {
if (!(raw instanceof List<?> list)) {
return List.of();
}
List<String> out = new ArrayList<>(list.size());
for (Object item : list) {
if (item != null) {
out.add(String.valueOf(item));
}
}
return out;
}
}

View file

@ -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;
}
}
}

View file

@ -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)");
};
}
}

View file

@ -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<UUID, String> 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);
}
}

View file

@ -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]");
}
}

View file

@ -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));
}
}

View file

@ -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));
}
}