feat(policy): velocity plugin sending a lovisual:policy payload
This commit is contained in:
parent
af2b417289
commit
711fcafc3a
9 changed files with 543 additions and 0 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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]");
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue