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 | ||||
| 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/<base64 encoded key>` | ||||
| 
 | ||||
| Example: | ||||
| Make a GET request to `/kill/<secret key>`: | ||||
| ``` | ||||
| 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 | ||||
| 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,63 +6,59 @@ | |||
| 
 | ||||
| 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")); | ||||
|         if (getConfig().killswitchListen() == null) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|             ratelimit.runTaskTimerAsynchronously(getPlugin(), 0, 20 * 300); | ||||
|         String secret = loadSecret(getPlugin().getDataFolder().toPath().resolve("killswitch secret key.txt")); | ||||
| 
 | ||||
|         var listenAddress = getConfig().killswitchListen().split(":"); | ||||
|         InetSocketAddress bindAddress; | ||||
|  | @ -72,47 +68,23 @@ public class KillswitchModule extends TweaksModule implements CommandExecutor, H | |||
|             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); | ||||
|             } | ||||
|         } | ||||
|         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"); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -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
	
	 Minecon724
				Minecon724