Signed-off-by: Minecon724 <minecon724@noreply.git.m724.eu>
This commit is contained in:
parent
2c835a4eab
commit
2213ebe3cf
5 changed files with 176 additions and 107 deletions
|
|
@ -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.
|
||||||
|
|
@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue