From 2213ebe3cf6df4904d8e5eb8966245c885d8ce09 Mon Sep 17 00:00:00 2001 From: Minecon724 Date: Sat, 17 May 2025 08:49:13 +0200 Subject: [PATCH] Refactor kill switch Signed-off-by: Minecon724 --- docs/KILLSWITCH.md | 40 ++++---- .../module/killswitch/KillswitchModule.java | 96 +++++++------------ .../tweaks/module/killswitch/Ratelimit.java | 26 ----- .../server/KillswitchSecureHttpServer.java | 84 ++++++++++++++++ .../module/killswitch/server/Ratelimit.java | 37 +++++++ 5 files changed, 176 insertions(+), 107 deletions(-) delete mode 100644 src/main/java/eu/m724/tweaks/module/killswitch/Ratelimit.java create mode 100644 src/main/java/eu/m724/tweaks/module/killswitch/server/KillswitchSecureHttpServer.java create mode 100644 src/main/java/eu/m724/tweaks/module/killswitch/server/Ratelimit.java diff --git a/docs/KILLSWITCH.md b/docs/KILLSWITCH.md index 4ac09e3..b431e58 100644 --- a/docs/KILLSWITCH.md +++ b/docs/KILLSWITCH.md @@ -1,32 +1,34 @@ -Killswitch immediately stops the server. +Killswitch immediately stops (kills) the server. ### Warning -This terminates the server process, meaning it's like you'd pull the power. \ -So you will lose some progress (since the last auto save), or worst case your world (or other data) gets corrupted. +This terminates the server process (not the OS), meaning it's **like you pulled the power cable.** \ +You lose some progress (since the last auto save), or in the worst case, **data gets corrupted.** -Terminal froze after kill? `reset` +Terminal froze after kill? Do `reset` -### Over a command +### By command No key is required. \ `/servkill` is the command. Permission: `tweaks724.servkill`. \ -You must grant the permission manually, like with a permission plugin - it's not automatically assigned even to OPs. +**You must grant the permission manually** with a permission plugin—it's not automatically assigned, not even to OPs. -### Over HTTP -HTTP is insecure, meaning others *could* intercept your request to the server and get your key. \ -To encrypt, put this behind a proxy to get HTTPS or a VPN (directly to the server, not a commercial VPN) \ -Or regenerate the key after every usage. +### Over the internet +**HTTP is insecure.** The secret key travels in plain text, meaning whoever oversees the path[^1] to your server *could*[^2] intercept your request to the server and get your key. \ +I recommend putting this behind a controlled[^3] VPN, or an HTTPS proxy with good access control. \ +Or regenerate the key every use. -Make a GET request to `/key/` - -Example: +Make a GET request to `/kill/`: ``` -https://127.0.0.1:57932/key/lNwANMSZhLiTWhNxSoqQ5Q== -|_ server address _| |_ base64 encoded key _| +GET http://127.0.0.1:57932/kill/lNwANMSZhLiTWhNxSoqQ5Q== + |_ endpoint _| |_ secret key _| ``` -The response is a 404 no matter what. Either way, you will notice that the server has stopped. +There is no response; the connection is closed immediately, no matter what.[^4] -The key is generated to `plugins/Tweaks724/storage/killswitch key` \ -To use it with HTTP server, encode it to base64. +The key is in `plugins/Tweaks724/killswitch secret key.txt`. You can provide your own key. The key should be plaintext (not bytes). -Rate limit is 1 request / 5 minutes \ No newline at end of file +The ratelimit is one request per 2 minutes. **Do not send requests when blocked, it resets the timer!** + +[^1]: Typically you, then it's your ISP, maybe its upstream, then the internet backbone (various entities), then your hosting, and finally your server. That's how the Internet works! +[^2]: Though unlikely +[^3]: Direct to the server, think WireGuard. Not a commercial offering! +[^4]: *Stealth*. You will notice that the server has stopped. \ No newline at end of file diff --git a/src/main/java/eu/m724/tweaks/module/killswitch/KillswitchModule.java b/src/main/java/eu/m724/tweaks/module/killswitch/KillswitchModule.java index 9756b88..b393141 100644 --- a/src/main/java/eu/m724/tweaks/module/killswitch/KillswitchModule.java +++ b/src/main/java/eu/m724/tweaks/module/killswitch/KillswitchModule.java @@ -6,113 +6,85 @@ package eu.m724.tweaks.module.killswitch; -import com.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpHandler; -import com.sun.net.httpserver.HttpServer; import eu.m724.tweaks.DebugLogger; import eu.m724.tweaks.module.TweaksModule; +import eu.m724.tweaks.module.killswitch.server.KillswitchSecureHttpServer; import org.bukkit.command.Command; import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; import org.jetbrains.annotations.NotNull; -import java.io.File; import java.io.IOException; import java.net.InetSocketAddress; import java.nio.file.Files; +import java.nio.file.Path; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.util.Base64; -public class KillswitchModule extends TweaksModule implements CommandExecutor, HttpHandler { - private final Ratelimit ratelimit = new Ratelimit(); +public class KillswitchModule extends TweaksModule implements CommandExecutor { + private String loadSecret(Path file) { + String secret; - private byte[] secret; - private String secretEncoded; - - private void loadKey(File file) { - if (file.exists()) { + if (Files.exists(file)) { try { - this.secret = Files.readAllBytes(file.toPath()); + secret = Files.readString(file); } catch (IOException e) { throw new RuntimeException("Reading killswitch key", e); } DebugLogger.fine("Loaded key"); } else { - byte[] buf = new byte[16]; - try { + byte[] buf = new byte[16]; SecureRandom.getInstanceStrong().nextBytes(buf); - Files.write(file.toPath(), buf); + + secret = Base64.getEncoder().encodeToString(buf); + Files.writeString(file, secret); } catch (IOException | NoSuchAlgorithmException e) { throw new RuntimeException("Generating killswitch key", e); } - this.secret = buf; - DebugLogger.info("Killswitch key generated and saved to " + file.getPath()); + DebugLogger.info("Killswitch secret key generated and saved to %s", file); } - this.secretEncoded = Base64.getEncoder().encodeToString(secret); + return secret; } @Override protected void onInit() { registerCommand("servkill", this); - if (getConfig().killswitchListen() != null) { - loadKey(new File(getPlugin().getDataFolder(), "storage/killswitch key")); - - ratelimit.runTaskTimerAsynchronously(getPlugin(), 0, 20 * 300); - - var listenAddress = getConfig().killswitchListen().split(":"); - InetSocketAddress bindAddress; - if (listenAddress.length == 1) { - bindAddress = new InetSocketAddress(Integer.parseInt(listenAddress[0])); - } else { - bindAddress = new InetSocketAddress(listenAddress[0], Integer.parseInt(listenAddress[1])); - } - - try { - HttpServer server = HttpServer.create(bindAddress, 0); - server.createContext("/", this); - server.setExecutor(null); - server.start(); - DebugLogger.fine("server started"); - } catch (IOException e) { - throw new RuntimeException("Starting HTTP server", e); - } + if (getConfig().killswitchListen() == null) { + return; } + + String secret = loadSecret(getPlugin().getDataFolder().toPath().resolve("killswitch secret key.txt")); + + var listenAddress = getConfig().killswitchListen().split(":"); + InetSocketAddress bindAddress; + if (listenAddress.length == 1) { + bindAddress = new InetSocketAddress(Integer.parseInt(listenAddress[0])); + } else { + bindAddress = new InetSocketAddress(listenAddress[0], Integer.parseInt(listenAddress[1])); + } + + new KillswitchSecureHttpServer(secret, this::kill).start(bindAddress); + + DebugLogger.fine("HTTP server started"); } private void kill() { + DebugLogger.info("Killing server on request"); + Runtime.getRuntime().halt(0); } @Override public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) { + DebugLogger.fine("Kill requested by: %s", sender.getName()); + sender.sendMessage("Killing server"); + kill(); return true; } - - @Override - public void handle(HttpExchange exchange) throws IOException { - exchange.sendResponseHeaders(404, -1); - - var path = exchange.getRequestURI().getPath(); - if (!path.startsWith("/key/")) return; - - var address = exchange.getRemoteAddress().getAddress(); - if (!ratelimit.submitRequest(address)) { - DebugLogger.fine(address + " is ratelimited"); - return; - } - - var key = path.substring(5); - if (key.equals(secretEncoded)) { - DebugLogger.fine("Got a request with valid key"); - kill(); - } else { - DebugLogger.fine("Got a request with invalid key"); - } - } } diff --git a/src/main/java/eu/m724/tweaks/module/killswitch/Ratelimit.java b/src/main/java/eu/m724/tweaks/module/killswitch/Ratelimit.java deleted file mode 100644 index 311c30a..0000000 --- a/src/main/java/eu/m724/tweaks/module/killswitch/Ratelimit.java +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright (C) 2025 Minecon724 - * Tweaks724 is licensed under the GNU General Public License. See the LICENSE.md file - * in the project root for the full license text. - */ - -package eu.m724.tweaks.module.killswitch; - -import org.bukkit.scheduler.BukkitRunnable; - -import java.net.InetAddress; -import java.util.HashSet; -import java.util.Set; - -public class Ratelimit extends BukkitRunnable { - private Set requests = new HashSet<>(); - - boolean submitRequest(InetAddress address) { - return requests.add(address); - } - - @Override - public void run() { - requests = new HashSet<>(); - } -} diff --git a/src/main/java/eu/m724/tweaks/module/killswitch/server/KillswitchSecureHttpServer.java b/src/main/java/eu/m724/tweaks/module/killswitch/server/KillswitchSecureHttpServer.java new file mode 100644 index 0000000..8cab1ae --- /dev/null +++ b/src/main/java/eu/m724/tweaks/module/killswitch/server/KillswitchSecureHttpServer.java @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2025 Minecon724 + * Tweaks724 is licensed under the GNU General Public License. See the LICENSE.md file + * in the project root for the full license text. + */ + +package eu.m724.tweaks.module.killswitch.server; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import eu.m724.tweaks.DebugLogger; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.time.Duration; +import java.util.concurrent.Executors; + +public class KillswitchSecureHttpServer implements HttpHandler { + private static final String PATH_PREFIX = "/kill/"; + + private final Ratelimit ratelimit; + + private final String secret; + private final Runnable onTrigger; + + public KillswitchSecureHttpServer(String secret, Runnable onTrigger) { + this.secret = secret; + this.onTrigger = onTrigger; + + this.ratelimit = new Ratelimit(Duration.ofMinutes(2), 1); + } + + public void start(InetSocketAddress bindAddress) { + HttpServer httpServer; + try { + httpServer = HttpServer.create(bindAddress, 0); + } catch (IOException e) { + throw new RuntimeException("Creating HTTP server", e); + } + + httpServer.createContext("/", this); + httpServer.setExecutor(Executors.newVirtualThreadPerTaskExecutor()); + httpServer.start(); + } + + @Override + public void handle(HttpExchange exchange) { + exchange.close(); + + InetAddress clientAddress = exchange.getRemoteAddress().getAddress(); + + if (!ratelimit.makeRequest(clientAddress)) { + logWithIp(clientAddress, "Ratelimited"); + return; + } + + String path = exchange.getRequestURI().getRawPath(); + + if (!path.startsWith(PATH_PREFIX)) { + logWithIp(clientAddress, "Wrong path"); + return; + } + + String key = path.substring(PATH_PREFIX.length()); + + if (key.equals(secret)) { + logWithIpFine(clientAddress, "Correct key"); + onTrigger.run(); + } else { + logWithIp(clientAddress, "Invalid key"); + } + } + + private void logWithIpFine(InetAddress address, String message, Object... format) { + DebugLogger.fine("[Remote %s] %s", address.getHostAddress(), message, format); + } + + private void logWithIp(InetAddress address, String message, Object... format) { + DebugLogger.finer("[Remote %s] %s", address.getHostAddress(), message, format); + } + +} diff --git a/src/main/java/eu/m724/tweaks/module/killswitch/server/Ratelimit.java b/src/main/java/eu/m724/tweaks/module/killswitch/server/Ratelimit.java new file mode 100644 index 0000000..ca4a1f4 --- /dev/null +++ b/src/main/java/eu/m724/tweaks/module/killswitch/server/Ratelimit.java @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2025 Minecon724 + * Tweaks724 is licensed under the GNU General Public License. See the LICENSE.md file + * in the project root for the full license text. + */ + +package eu.m724.tweaks.module.killswitch.server; + +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; + +import java.net.InetAddress; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicInteger; + +public class Ratelimit { + private final Duration blockDuration; + private final int requestsAllowed; + + private final LoadingCache requestCounts; + + public Ratelimit(Duration blockDuration, int requestsAllowed) { + this.blockDuration = blockDuration; + this.requestsAllowed = requestsAllowed; + + this.requestCounts = CacheBuilder.newBuilder() + .expireAfterWrite(blockDuration) + .build(CacheLoader.from(key -> new AtomicInteger(0))); + } + + boolean makeRequest(InetAddress address) { + AtomicInteger requestCount = this.requestCounts.getUnchecked(address); + + return requestCount.getAndIncrement() < requestsAllowed; + } +}