diff --git a/rtwrapper-fabric/src/main/java/com/runtoolkit/rtwrapper/RTWrapper.java b/rtwrapper-fabric/src/main/java/com/runtoolkit/rtwrapper/RTWrapper.java index 92abec7..9d58fe3 100644 --- a/rtwrapper-fabric/src/main/java/com/runtoolkit/rtwrapper/RTWrapper.java +++ b/rtwrapper-fabric/src/main/java/com/runtoolkit/rtwrapper/RTWrapper.java @@ -32,6 +32,12 @@ public void onInitialize() { if (RTWrapperConfig.isAutoTick()) { RTWrapperAPI.runNext(); } + // Drains anything scheduled via RTWrapperAPI.executeDelayed(...) + // that has reached its target tick. Independent of the autotick + // queue above — this runs regardless of the autotick setting, + // since a caller explicitly requesting a delay expects it to + // fire on schedule either way. + com.runtoolkit.rtwrapper.api.ScheduledDispatch.tick(); }); LOGGER.info("RTWrapper loaded (native allowlist dispatch, {} commands registered)", diff --git a/rtwrapper-fabric/src/main/java/com/runtoolkit/rtwrapper/api/AuditLog.java b/rtwrapper-fabric/src/main/java/com/runtoolkit/rtwrapper/api/AuditLog.java new file mode 100644 index 0000000..6ad67a9 --- /dev/null +++ b/rtwrapper-fabric/src/main/java/com/runtoolkit/rtwrapper/api/AuditLog.java @@ -0,0 +1,91 @@ +package com.runtoolkit.rtwrapper.api; + +import com.runtoolkit.rtwrapper.RTWrapper; + +import java.time.Instant; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; + +/** + * Records every dispatch attempt: who, when, what command, and the + * outcome. The datapack had no equivalent — the only trace of a wrapper + * call was whatever scoreboard counters load.mcfunction set up + * (#rtw.processed / #rtw.errors, mirrored here by RTWrapperConfig), which + * tell you *how many* things ran, never *what* or *by whom*. That made + * the datapack's permission gap (see RTCommand's Javadoc) unauditable + * even after the fact — an op-level command dispatched by an unintended + * caller left no record beyond the raw increment. + * + * Kept as a bounded in-memory ring buffer plus a logger line per entry. + * Not a database or file-backed store on purpose: RTWrapper has no + * persistence layer elsewhere (RTWrapperConfig's counters are also + * in-memory only, reset on restart), so this matches the mod's existing + * durability guarantees rather than introducing a new one. Server owners + * who need a durable trail already get one for free via the logger line, + * which lands wherever the server's own log configuration sends it. + */ +public final class AuditLog { + + private AuditLog() {} + + public record Entry( + Instant timestamp, + String sourceName, + int sourcePermissionLevel, + String commandLiteral, + List args, + RTDispatchResult.Status status, + String detail + ) {} + + private static final int MAX_ENTRIES = 500; + private static final Deque ENTRIES = new ArrayDeque<>(); + + public static synchronized void record(String sourceName, int sourcePermissionLevel, + String commandLiteral, List args, + RTDispatchResult result) { + Entry entry = new Entry( + Instant.now(), + sourceName, + sourcePermissionLevel, + commandLiteral, + List.copyOf(args), + result.status(), + result.message() + ); + ENTRIES.addLast(entry); + if (ENTRIES.size() > MAX_ENTRIES) { + ENTRIES.removeFirst(); + } + + // Admin-tier commands (see RTCommand) are logged at a higher + // visibility than routine ones, since these are exactly the calls + // the datapack's missing allowlist/permission check used to let + // through unaudited. + boolean isSensitive = sourcePermissionLevel >= 4 || result.status() != RTDispatchResult.Status.SUCCESS; + String line = String.format("[audit] %s (perm %d) -> %s %s => %s%s", + sourceName, sourcePermissionLevel, commandLiteral, args, + result.status(), result.message() != null ? " (" + result.message() + ")" : ""); + if (isSensitive) { + RTWrapper.LOGGER.warn(line); + } else { + RTWrapper.LOGGER.info(line); + } + } + + public static synchronized List recent(int count) { + List all = new ArrayList<>(ENTRIES); + int from = Math.max(0, all.size() - count); + return List.copyOf(all.subList(from, all.size())); + } + + public static synchronized List all() { + return List.copyOf(ENTRIES); + } + + public static synchronized void clear() { + ENTRIES.clear(); + } +} diff --git a/rtwrapper-fabric/src/main/java/com/runtoolkit/rtwrapper/api/CommandCooldown.java b/rtwrapper-fabric/src/main/java/com/runtoolkit/rtwrapper/api/CommandCooldown.java new file mode 100644 index 0000000..11e5ec4 --- /dev/null +++ b/rtwrapper-fabric/src/main/java/com/runtoolkit/rtwrapper/api/CommandCooldown.java @@ -0,0 +1,84 @@ +package com.runtoolkit.rtwrapper.api; + +import com.runtoolkit.rtwrapper.command.RTCommand; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Minimum time between successive runs of the *same command* by the + * *same source*. This is a different guard from RateLimiter: RateLimiter + * caps total throughput per source across all commands in a window; + * CommandCooldown targets repeat-spamming one specific command (e.g. a + * player mashing a "/rtw effect ..." trigger) regardless of how far under + * their overall rate limit they are. + * + * Cooldowns are opt-in per RTCommand via configure(); commands with no + * configured cooldown are unaffected (default behavior is unchanged from + * before this feature existed). Keyed on (source, command) so a cooldown + * on GIVE for player A doesn't block player B, and a cooldown on GIVE + * doesn't affect TP. + */ +public final class CommandCooldown { + + private CommandCooldown() {} + + private static final Map COOLDOWNS_MILLIS = new ConcurrentHashMap<>(); + private static final Map LAST_RUN = new ConcurrentHashMap<>(); + + /** Configure a cooldown (in milliseconds) for a given command. 0 or negative clears it. */ + public static void configure(RTCommand command, long cooldownMillis) { + if (cooldownMillis <= 0) { + COOLDOWNS_MILLIS.remove(command); + } else { + COOLDOWNS_MILLIS.put(command, cooldownMillis); + } + } + + public static long configuredCooldown(RTCommand command) { + return COOLDOWNS_MILLIS.getOrDefault(command, 0L); + } + + private static String key(String sourceName, RTCommand command) { + return sourceName + "\u0000" + command.literal(); + } + + /** + * Returns true and records this attempt as the new "last run" if the + * (source, command) pair is off cooldown. Returns false without + * recording if still on cooldown. Commands with no configured + * cooldown always return true. + */ + public static boolean tryRun(String sourceName, RTCommand command) { + long cooldown = COOLDOWNS_MILLIS.getOrDefault(command, 0L); + if (cooldown <= 0) { + return true; + } + long now = System.currentTimeMillis(); + String k = key(sourceName, command); + Long last = LAST_RUN.get(k); + if (last != null && now - last < cooldown) { + return false; + } + LAST_RUN.put(k, now); + return true; + } + + /** Milliseconds remaining before (source, command) is off cooldown; 0 if already available. */ + public static long remaining(String sourceName, RTCommand command) { + long cooldown = COOLDOWNS_MILLIS.getOrDefault(command, 0L); + if (cooldown <= 0) return 0L; + Long last = LAST_RUN.get(key(sourceName, command)); + if (last == null) return 0L; + long elapsed = System.currentTimeMillis() - last; + return Math.max(0L, cooldown - elapsed); + } + + public static void reset(String sourceName, RTCommand command) { + LAST_RUN.remove(key(sourceName, command)); + } + + public static void resetAll() { + LAST_RUN.clear(); + } +} diff --git a/rtwrapper-fabric/src/main/java/com/runtoolkit/rtwrapper/api/PermissionOverrides.java b/rtwrapper-fabric/src/main/java/com/runtoolkit/rtwrapper/api/PermissionOverrides.java new file mode 100644 index 0000000..ce2b44d --- /dev/null +++ b/rtwrapper-fabric/src/main/java/com/runtoolkit/rtwrapper/api/PermissionOverrides.java @@ -0,0 +1,62 @@ +package com.runtoolkit.rtwrapper.api; + +import com.runtoolkit.rtwrapper.command.RTCommand; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Lets a server operator require a *stricter* permission level than + * RTCommand's compiled-in default for specific commands, via config. + * + * Deliberately one-directional: an override can only raise the effective + * required level, never lower it below RTCommand.minPermissionLevel(). + * Allowing overrides to loosen permissions would let a config file + * silently punch a hole in exactly the allowlist/permission design + * RTCommand's Javadoc describes as the structural fix for the datapack's + * "any writer can trigger op/ban/kick" flaw — a mistyped or tampered + * config could reintroduce that gap. Tightening only has no such failure + * mode: the worst a bad config does is make a command less available, + * never more. + * + * RTWrapperAPI.execute() should consult effectiveLevel(command) instead + * of command.minPermissionLevel() directly once this is wired in. + */ +public final class PermissionOverrides { + + private PermissionOverrides() {} + + private static final Map OVERRIDES = new ConcurrentHashMap<>(); + + /** + * Set a stricter minimum permission level for a command. Silently + * clamps to at least the command's compiled-in default — this method + * cannot be used to loosen permissions, by design (see class Javadoc). + */ + public static void override(RTCommand command, int requiredLevel) { + int floor = command.minPermissionLevel(); + int effective = Math.max(floor, requiredLevel); + if (effective == floor) { + OVERRIDES.remove(command); + } else { + OVERRIDES.put(command, effective); + } + } + + public static void clear(RTCommand command) { + OVERRIDES.remove(command); + } + + public static void clearAll() { + OVERRIDES.clear(); + } + + /** The level actually enforced for this command: the override if set, otherwise the compiled-in default. */ + public static int effectiveLevel(RTCommand command) { + return OVERRIDES.getOrDefault(command, command.minPermissionLevel()); + } + + public static boolean hasOverride(RTCommand command) { + return OVERRIDES.containsKey(command); + } +} diff --git a/rtwrapper-fabric/src/main/java/com/runtoolkit/rtwrapper/api/RTWrapperAPI.java b/rtwrapper-fabric/src/main/java/com/runtoolkit/rtwrapper/api/RTWrapperAPI.java index 4d1661c..cfc0413 100644 --- a/rtwrapper-fabric/src/main/java/com/runtoolkit/rtwrapper/api/RTWrapperAPI.java +++ b/rtwrapper-fabric/src/main/java/com/runtoolkit/rtwrapper/api/RTWrapperAPI.java @@ -53,11 +53,34 @@ private RTWrapperAPI() {} public static RTDispatchResult execute(RTRequest request) { RTCommand command = request.command(); ServerCommandSource source = request.source(); + String sourceName = source.getName(); + int actualLevel = effectivePermissionLevel(source); - int required = command.minPermissionLevel(); + // Permission check now goes through PermissionOverrides so a + // stricter config-set level is honored; it can never be looser + // than RTCommand's compiled-in default (see PermissionOverrides). + int required = PermissionOverrides.effectiveLevel(command); if (!source.hasPermissionLevel(required)) { RTWrapperConfig.incrementErrors(); - return RTDispatchResult.permissionDenied(required, effectivePermissionLevel(source)); + RTDispatchResult denied = RTDispatchResult.permissionDenied(required, actualLevel); + AuditLog.record(sourceName, actualLevel, command.literal(), request.args(), denied); + return denied; + } + + if (!RateLimiter.tryAcquire(sourceName)) { + RTWrapperConfig.incrementErrors(); + RTDispatchResult limited = RTDispatchResult.commandFailed( + "Rate limit exceeded (" + RateLimiter.limit() + " per " + RateLimiter.windowMillis() + "ms)"); + AuditLog.record(sourceName, actualLevel, command.literal(), request.args(), limited); + return limited; + } + + if (!CommandCooldown.tryRun(sourceName, command)) { + RTWrapperConfig.incrementErrors(); + RTDispatchResult onCooldown = RTDispatchResult.commandFailed( + "Command on cooldown, " + CommandCooldown.remaining(sourceName, command) + "ms remaining"); + AuditLog.record(sourceName, actualLevel, command.literal(), request.args(), onCooldown); + return onCooldown; } String fullCommand = buildCommandString(command, request.args()); @@ -66,18 +89,33 @@ public static RTDispatchResult execute(RTRequest request) { try { int result = server.getCommandManager().getDispatcher().execute(fullCommand, source); RTWrapperConfig.incrementProcessed(); + RTDispatchResult dispatchResult; if (result <= 0) { RTWrapperConfig.incrementErrors(); - return RTDispatchResult.commandFailed("Command returned non-positive result: " + fullCommand); + dispatchResult = RTDispatchResult.commandFailed("Command returned non-positive result: " + fullCommand); + } else { + dispatchResult = RTDispatchResult.success(); } - return RTDispatchResult.success(); + AuditLog.record(sourceName, actualLevel, command.literal(), request.args(), dispatchResult); + return dispatchResult; } catch (CommandSyntaxException e) { RTWrapperConfig.incrementProcessed(); RTWrapperConfig.incrementErrors(); - return RTDispatchResult.commandFailed(e.getMessage()); + RTDispatchResult failed = RTDispatchResult.commandFailed(e.getMessage()); + AuditLog.record(sourceName, actualLevel, command.literal(), request.args(), failed); + return failed; } } + /** + * Schedule a request to run delayTicks ticks from now instead of + * immediately. Delegates to ScheduledDispatch; drained by + * RTWrapper's existing tick hook. + */ + public static void executeDelayed(RTRequest request, long delayTicks) { + ScheduledDispatch.schedule(request, delayTicks); + } + /** * Convenience overload: resolve a literal against the allowlist first. * Unknown literals are rejected here, before an RTRequest ever exists. diff --git a/rtwrapper-fabric/src/main/java/com/runtoolkit/rtwrapper/api/RateLimiter.java b/rtwrapper-fabric/src/main/java/com/runtoolkit/rtwrapper/api/RateLimiter.java new file mode 100644 index 0000000..f259199 --- /dev/null +++ b/rtwrapper-fabric/src/main/java/com/runtoolkit/rtwrapper/api/RateLimiter.java @@ -0,0 +1,99 @@ +package com.runtoolkit.rtwrapper.api; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Per-source rate limiting. Each distinct source (identified by + * ServerCommandSource#getName(), which covers both players and the + * console/command-block sources) gets its own fixed-window bucket. + * + * This has no datapack equivalent — the datapack had no per-caller + * throttling at all, only the tick-level 1-per-tick autotick cap, which + * throttles the whole queue, not any individual caller. A single source + * enqueueing 200 requests in a burst could still dominate every tick's + * autotick slot. RateLimiter caps that at the source level, independent + * of the queue. + * + * Fixed-window, not sliding/token-bucket: simple, cheap, and sufficient + * for a command-dispatch guard rather than a general-purpose traffic + * shaper. A source can burst up to the limit at the start of a window + * and again right after it resets — acceptable here since the real goal + * is "stop one source from monopolizing dispatch", not smooth pacing. + */ +public final class RateLimiter { + + private RateLimiter() {} + + /** Default limit: commands allowed per source per window. */ + private static volatile int limit = 10; + + /** Window length in milliseconds. */ + private static volatile long windowMillis = 1000L; + + private static final class Bucket { + long windowStart; + int count; + } + + private static final Map BUCKETS = new ConcurrentHashMap<>(); + + public static void configure(int newLimit, long newWindowMillis) { + if (newLimit <= 0) throw new IllegalArgumentException("limit must be positive"); + if (newWindowMillis <= 0) throw new IllegalArgumentException("windowMillis must be positive"); + limit = newLimit; + windowMillis = newWindowMillis; + } + + public static int limit() { + return limit; + } + + public static long windowMillis() { + return windowMillis; + } + + /** + * Returns true if the source is still under its limit for the current + * window (and records the attempt). Returns false if the source has + * exceeded the limit and should be rejected. + */ + public static boolean tryAcquire(String sourceKey) { + long now = System.currentTimeMillis(); + Bucket bucket = BUCKETS.computeIfAbsent(sourceKey, k -> { + Bucket b = new Bucket(); + b.windowStart = now; + b.count = 0; + return b; + }); + synchronized (bucket) { + if (now - bucket.windowStart >= windowMillis) { + bucket.windowStart = now; + bucket.count = 0; + } + if (bucket.count >= limit) { + return false; + } + bucket.count++; + return true; + } + } + + /** Drops stale buckets so long-running servers don't accumulate memory for players who left. */ + public static void evictOlderThan(long maxAgeMillis) { + long now = System.currentTimeMillis(); + BUCKETS.entrySet().removeIf(e -> { + synchronized (e.getValue()) { + return now - e.getValue().windowStart > maxAgeMillis; + } + }); + } + + public static void reset(String sourceKey) { + BUCKETS.remove(sourceKey); + } + + public static void resetAll() { + BUCKETS.clear(); + } +} diff --git a/rtwrapper-fabric/src/main/java/com/runtoolkit/rtwrapper/api/ScheduledDispatch.java b/rtwrapper-fabric/src/main/java/com/runtoolkit/rtwrapper/api/ScheduledDispatch.java new file mode 100644 index 0000000..8673bda --- /dev/null +++ b/rtwrapper-fabric/src/main/java/com/runtoolkit/rtwrapper/api/ScheduledDispatch.java @@ -0,0 +1,84 @@ +package com.runtoolkit.rtwrapper.api; + +import java.util.ArrayList; +import java.util.List; +import java.util.PriorityQueue; + +/** + * Delayed dispatch: run a request N ticks from now instead of immediately. + * + * Mirrors the intent of the datapack's `schedule function ... ` + * pattern, but without scheduling an actual function call — a task here + * is just an RTRequest plus a target tick, held in a min-heap and drained + * by RTWrapper's existing END_SERVER_TICK hook (see tick(long)). No new + * tick listener is registered; this rides the one that already exists for + * autotick, so the ordering already documented there (one queued autotick + * request drained max per tick) is untouched — ScheduledDispatch has its + * own, independent budget below. + * + * A tick counter, not wall-clock time, is used deliberately: server ticks + * can run slower than 50ms under load, and "N ticks from now" is the + * datapack-equivalent notion of delay (schedule's delay is also + * tick-based under the hood), not "N milliseconds from now". + */ +public final class ScheduledDispatch { + + private ScheduledDispatch() {} + + private record Task(long targetTick, RTRequest request) {} + + private static final PriorityQueue QUEUE = + new PriorityQueue<>((a, b) -> Long.compare(a.targetTick, b.targetTick)); + + private static long currentTick = 0L; + + /** Cap on how many due tasks are dispatched in a single tick, so a pile-up can't spike one tick. */ + private static final int MAX_PER_TICK = 20; + + /** + * Schedule a request to run `delayTicks` ticks from now. delayTicks <= 0 + * runs it on the very next call to tick(). + */ + public static void schedule(RTRequest request, long delayTicks) { + long target = currentTick + Math.max(0, delayTicks); + synchronized (QUEUE) { + QUEUE.add(new Task(target, request)); + } + } + + /** + * Advance the internal clock by one tick and dispatch anything now due. + * Call this from the mod's existing END_SERVER_TICK registration. + * Returns the results of whatever was dispatched this call (possibly empty). + */ + public static List tick() { + currentTick++; + List due = new ArrayList<>(); + synchronized (QUEUE) { + while (!QUEUE.isEmpty() && due.size() < MAX_PER_TICK && QUEUE.peek().targetTick() <= currentTick) { + due.add(QUEUE.poll()); + } + } + List results = new ArrayList<>(due.size()); + for (Task t : due) { + results.add(RTWrapperAPI.execute(t.request())); + } + return results; + } + + public static int pendingCount() { + synchronized (QUEUE) { + return QUEUE.size(); + } + } + + public static void clear() { + synchronized (QUEUE) { + QUEUE.clear(); + } + } + + public static long currentTick() { + return currentTick; + } +} diff --git a/rtwrapper-fabric/src/main/resources/fabric.mod.json b/rtwrapper-fabric/src/main/resources/fabric.mod.json index f11b77d..7e59b99 100644 --- a/rtwrapper-fabric/src/main/resources/fabric.mod.json +++ b/rtwrapper-fabric/src/main/resources/fabric.mod.json @@ -4,15 +4,39 @@ "version": "${version}", "name": "RTWrapper", "description": "Queued, permission-checked native command dispatch API for other mods/datapacks. Fabric reimplementation of the runtoolkit RTWrapper datapack protocol.", - "authors": ["runtoolkit"], + "authors": [ + "runtoolkit" + ], + "contact": { + "homepage": "https://modrinth.com/datapack/rtwrapper", + "issues": "https://github.com/runtoolkit/RTWrapper/issues", + "sources": "https://github.com/runtoolkit/RTWrapper" + }, "license": "MIT", + "icon": "icon.png", + "provides": [ + "runtoolkit" + ], "environment": "*", "entrypoints": { - "main": ["com.runtoolkit.rtwrapper.RTWrapper"] + "main": [ + "com.runtoolkit.rtwrapper.RTWrapper" + ] }, "depends": { "fabricloader": ">=0.16", "fabric-api": "*", "minecraft": "~1.21.1" + }, + "suggests": { + "modmenu": "*" + }, + "custom": { + "modmenu": { + "update_checker": false, + "links": { + "modmenu.modrinth": "https://modrinth.com/datapack/rtwrapper" + } + } } } diff --git a/rtwrapper-fabric/src/main/resources/icon.png b/rtwrapper-fabric/src/main/resources/icon.png new file mode 100644 index 0000000..27715c5 Binary files /dev/null and b/rtwrapper-fabric/src/main/resources/icon.png differ