Refactor kill switch
All checks were successful
/ build (push) Successful in 8m32s

Signed-off-by: Minecon724 <minecon724@noreply.git.m724.eu>
This commit is contained in:
Minecon724 2025-05-17 08:49:13 +02:00
commit 2213ebe3cf
Signed by untrusted user who does not match committer: m724
GPG key ID: A02E6E67AB961189
5 changed files with 176 additions and 107 deletions

View file

@ -1,32 +1,34 @@
Killswitch immediately stops the server. Killswitch immediately stops (kills) the server.
### Warning ### Warning
This terminates the server process, meaning it's like you'd pull the power. \ This terminates the server process (not the OS), meaning it's **like you pulled the power cable.** \
So you will lose some progress (since the last auto save), or worst case your world (or other data) gets corrupted. 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. \ No key is required. \
`/servkill` is the command. Permission: `tweaks724.servkill`. \ `/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 ### Over the internet
HTTP is insecure, meaning others *could* intercept your request to the server and get your key. \ **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. \
To encrypt, put this behind a proxy to get HTTPS or a VPN (directly to the server, not a commercial VPN) \ I recommend putting this behind a controlled[^3] VPN, or an HTTPS proxy with good access control. \
Or regenerate the key after every usage. Or regenerate the key every use.
Make a GET request to `/key/<base64 encoded key>` Make a GET request to `/kill/<secret key>`:
Example:
``` ```
https://127.0.0.1:57932/key/lNwANMSZhLiTWhNxSoqQ5Q== GET http://127.0.0.1:57932/kill/lNwANMSZhLiTWhNxSoqQ5Q==
|_ server address _| |_ base64 encoded key _| |_ 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` \ The key is in `plugins/Tweaks724/killswitch secret key.txt`. You can provide your own key. The key should be plaintext (not bytes).
To use it with HTTP server, encode it to base64.
Rate limit is 1 request / 5 minutes 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.

View file

@ -6,113 +6,85 @@
package eu.m724.tweaks.module.killswitch; 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.DebugLogger;
import eu.m724.tweaks.module.TweaksModule; import eu.m724.tweaks.module.TweaksModule;
import eu.m724.tweaks.module.killswitch.server.KillswitchSecureHttpServer;
import org.bukkit.command.Command; import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender; import org.bukkit.command.CommandSender;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.net.InetSocketAddress; import java.net.InetSocketAddress;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.util.Base64; import java.util.Base64;
public class KillswitchModule extends TweaksModule implements CommandExecutor, HttpHandler { public class KillswitchModule extends TweaksModule implements CommandExecutor {
private final Ratelimit ratelimit = new Ratelimit(); private String loadSecret(Path file) {
String secret;
private byte[] secret; if (Files.exists(file)) {
private String secretEncoded;
private void loadKey(File file) {
if (file.exists()) {
try { try {
this.secret = Files.readAllBytes(file.toPath()); secret = Files.readString(file);
} catch (IOException e) { } catch (IOException e) {
throw new RuntimeException("Reading killswitch key", e); throw new RuntimeException("Reading killswitch key", e);
} }
DebugLogger.fine("Loaded key"); DebugLogger.fine("Loaded key");
} else { } else {
byte[] buf = new byte[16];
try { try {
byte[] buf = new byte[16];
SecureRandom.getInstanceStrong().nextBytes(buf); SecureRandom.getInstanceStrong().nextBytes(buf);
Files.write(file.toPath(), buf);
secret = Base64.getEncoder().encodeToString(buf);
Files.writeString(file, secret);
} catch (IOException | NoSuchAlgorithmException e) { } catch (IOException | NoSuchAlgorithmException e) {
throw new RuntimeException("Generating killswitch key", e); throw new RuntimeException("Generating killswitch key", e);
} }
this.secret = buf; DebugLogger.info("Killswitch secret key generated and saved to %s", file);
DebugLogger.info("Killswitch key generated and saved to " + file.getPath());
} }
this.secretEncoded = Base64.getEncoder().encodeToString(secret); return secret;
} }
@Override @Override
protected void onInit() { protected void onInit() {
registerCommand("servkill", this); registerCommand("servkill", this);
if (getConfig().killswitchListen() != null) { if (getConfig().killswitchListen() == null) {
loadKey(new File(getPlugin().getDataFolder(), "storage/killswitch key")); return;
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);
}
} }
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() { private void kill() {
DebugLogger.info("Killing server on request");
Runtime.getRuntime().halt(0); Runtime.getRuntime().halt(0);
} }
@Override @Override
public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) { 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(); kill();
return true; 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");
}
}
} }

View file

@ -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<InetAddress> requests = new HashSet<>();
boolean submitRequest(InetAddress address) {
return requests.add(address);
}
@Override
public void run() {
requests = new HashSet<>();
}
}

View file

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

View file

@ -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<InetAddress, AtomicInteger> 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;
}
}