diff --git a/README.md b/README.md index 7bc2660..e925875 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ The backend server connects outbound to the public proxy. Bungee/Waterfall then - Multiple public proxies can connect to one backend - One proxy can expose multiple named backend servers - `/dsp2p status` and `/dsp2p doctor` commands +- Temporary browser setup wizard with console-generated login key - Simple `config.properties` setup ## Requirements @@ -113,6 +114,9 @@ tunnel.tls.keyStore=certs/frontend.p12 tunnel.tls.keyStorePassword= tunnel.tls.requireClientAuth=false +webwizard.bindHost=127.0.0.1 +webwizard.port=8765 + routes=minecraft route.minecraft.frontendBindHost=127.0.0.1 route.minecraft.frontendBindPort=25566 @@ -179,6 +183,9 @@ tunnel.tls.enabled=false tunnel.tls.allowInsecure=true tunnel.tls.trustOnFirstUse=true +webwizard.bindHost=127.0.0.1 +webwizard.port=8765 + routes=minecraft route.minecraft.backendTargetHost=127.0.0.1 route.minecraft.backendTargetPort=25565 @@ -348,6 +355,50 @@ Shows role, node name, whether the tunnel is connected, connected tunnel count, Checks the config and prints setup/security hints. +```text +/dsp2p webwizard +``` + +Toggles a temporary browser setup wizard. + +When the wizard starts, the server console prints: + +```text +Please sign in with this key: +``` + +Open the printed URL in a browser and sign in with the temporary key. + +The wizard provides guided sections for: + +- server mode and node name +- proxy listener settings +- backend proxy connections +- Minecraft routes +- tunnel token and TLS +- heartbeat and reconnect settings +- advanced raw config keys + +The web UI includes: + +- `Save Config` +- `Save + Reload DirtSimpleP2P` +- `Reload Current Config` +- `Generate New Token` + +Reloading from the wizard restarts the DirtSimpleP2P tunnel agent with the latest config. It does not run a full Minecraft server reload. + +Run `/dsp2p webwizard` again to stop the wizard when setup is complete. + +By default, the wizard binds to: + +```properties +webwizard.bindHost=127.0.0.1 +webwizard.port=8765 +``` + +Keep it on `127.0.0.1` unless you understand the risk of exposing a config editor over the network. + Permission: ```text @@ -456,3 +507,7 @@ tunnel.tls.allowInsecure=false ``` Trust-on-first-use is convenient, but the first TLS connection is the sensitive moment. For the strictest setup, manually copy the proxy certificate fingerprint into the backend config before the first connection. + +The Web Wizard is intended as a temporary setup tool. It uses a random key printed to the server console and should be stopped when configuration is complete. + +The reload button reloads DirtSimpleP2P itself. It intentionally does not run Paper/Bukkit `/reload`, because full server reloads are risky on production Minecraft servers. diff --git a/common/target/proxylink-common-0.1.0-SNAPSHOT.jar b/common/target/proxylink-common-0.1.0-SNAPSHOT.jar index 3ec3fa9..96d3463 100644 Binary files a/common/target/proxylink-common-0.1.0-SNAPSHOT.jar and b/common/target/proxylink-common-0.1.0-SNAPSHOT.jar differ diff --git a/plugin/src/main/java/me/proxylink/plugin/PluginRuntime.java b/plugin/src/main/java/me/proxylink/plugin/PluginRuntime.java index c7148c4..747de60 100644 --- a/plugin/src/main/java/me/proxylink/plugin/PluginRuntime.java +++ b/plugin/src/main/java/me/proxylink/plugin/PluginRuntime.java @@ -7,6 +7,7 @@ import me.proxylink.plugin.tunnel.BackendAgent; import me.proxylink.plugin.tunnel.FrontendAgent; import me.proxylink.plugin.tunnel.ManagedAgent; import me.proxylink.plugin.tunnel.TunnelStatus; +import me.proxylink.plugin.web.WebWizardServer; import java.io.IOException; import java.io.InputStream; @@ -22,7 +23,11 @@ import java.util.logging.Logger; public final class PluginRuntime { private final Logger logger; private ManagedAgent agent; + private WebWizardServer webWizard; private Path configPath; + private Path dataFolder; + private String defaultConfigResource; + private AgentConfig.Role requiredRole; private AgentConfig config; private String lastStartupError = ""; @@ -31,6 +36,9 @@ public final class PluginRuntime { } public boolean start(Path dataFolder, String defaultConfigResource, AgentConfig.Role requiredRole) { + this.dataFolder = dataFolder; + this.defaultConfigResource = defaultConfigResource; + this.requiredRole = requiredRole; try { Files.createDirectories(dataFolder); configPath = dataFolder.resolve("config.properties"); @@ -61,6 +69,10 @@ public final class PluginRuntime { } public void stop() { + if (webWizard != null) { + webWizard.close(); + webWizard = null; + } if (agent != null) { agent.close(); agent = null; @@ -77,6 +89,7 @@ public final class PluginRuntime { lines.add("Connected tunnels: " + status.connectedTunnels()); lines.add("Active streams: " + status.activeStreams()); lines.add("TLS: " + (status.tlsEnabled() ? "enabled" : "disabled")); + lines.add("Web wizard: " + webWizardStatus()); lines.add("Last event: " + status.lastEvent()); if (!lastStartupError.isBlank()) { lines.add("Startup error: " + lastStartupError); @@ -122,6 +135,74 @@ public final class PluginRuntime { return lines; } + public synchronized List toggleWebWizardLines() { + List lines = new ArrayList<>(); + if (configPath == null) { + lines.add("Web wizard: unavailable because config.properties is not loaded"); + return lines; + } + + if (webWizard != null && webWizard.isRunning()) { + webWizard.close(); + webWizard = null; + lines.add("Web wizard: stopped"); + return lines; + } + + try { + webWizard = WebWizardServer.start(configPath, logger, this::reloadFromConfig); + lines.add("Web wizard: running"); + lines.add("URL: " + webWizard.uri()); + lines.add("Login key: printed in the server console"); + lines.add("Run /dsp2p webwizard again to stop it"); + } catch (Exception e) { + lines.add("Web wizard failed to start: " + + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage())); + logger.log(Level.WARNING, "Unable to start DirtSimpleP2P Web Wizard", e); + } + return lines; + } + + public synchronized String reloadFromConfig() { + if (dataFolder == null || configPath == null || defaultConfigResource == null || requiredRole == null) { + return "Reload is unavailable because DirtSimpleP2P has not finished initializing."; + } + + try { + ensureConfigExists(configPath, defaultConfigResource); + SecretTokenBootstrap.prepare(configPath, logger); + + AgentConfig loaded = AgentConfigIO.load(configPath); + loaded = TlsBootstrap.prepare(dataFolder, configPath, loaded, logger); + loaded.validateOrThrow(); + + if (loaded.role() != requiredRole) { + throw new IllegalArgumentException("This plugin entrypoint requires role=" + requiredRole.name().toLowerCase() + + " but config.properties has role=" + loaded.role().name().toLowerCase()); + } + + ManagedAgent previous = agent; + agent = null; + if (previous != null) { + previous.close(); + } + + ManagedAgent replacement = createAgent(loaded); + replacement.start(); + agent = replacement; + config = loaded; + lastStartupError = ""; + + String message = "Reloaded DirtSimpleP2P config and restarted the tunnel agent."; + logger.info(message); + return message; + } catch (Exception e) { + lastStartupError = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage(); + logger.log(Level.SEVERE, "DirtSimpleP2P reload failed. Fix config.properties and reload again.", e); + return "Reload failed: " + lastStartupError; + } + } + public TunnelStatus status() { if (agent != null) { return agent.status(); @@ -178,4 +259,11 @@ public final class PluginRuntime { private static String lower(AgentConfig.Role role) { return role.name().toLowerCase(java.util.Locale.ROOT); } + + private String webWizardStatus() { + if (webWizard == null || !webWizard.isRunning()) { + return "stopped"; + } + return "running at " + webWizard.uri(); + } } diff --git a/plugin/src/main/java/me/proxylink/plugin/bungee/DirtSimpleBungeeCommand.java b/plugin/src/main/java/me/proxylink/plugin/bungee/DirtSimpleBungeeCommand.java index 42698e3..31632c4 100644 --- a/plugin/src/main/java/me/proxylink/plugin/bungee/DirtSimpleBungeeCommand.java +++ b/plugin/src/main/java/me/proxylink/plugin/bungee/DirtSimpleBungeeCommand.java @@ -23,8 +23,9 @@ final class DirtSimpleBungeeCommand extends Command { switch (subcommand) { case "status" -> sendBlock(sender, "DirtSimpleP2P Status", runtime.statusLines()); case "doctor" -> sendBlock(sender, "DirtSimpleP2P Doctor", runtime.doctorLines()); + case "webwizard" -> sendBlock(sender, "DirtSimpleP2P Web Wizard", runtime.toggleWebWizardLines()); default -> { - send(sender, ChatColor.RED + "Usage: /dsp2p status or /dsp2p doctor"); + send(sender, ChatColor.RED + "Usage: /dsp2p status, /dsp2p doctor, or /dsp2p webwizard"); } } } diff --git a/plugin/src/main/java/me/proxylink/plugin/paper/DirtSimplePaperCommand.java b/plugin/src/main/java/me/proxylink/plugin/paper/DirtSimplePaperCommand.java index fcbcc16..358c0fb 100644 --- a/plugin/src/main/java/me/proxylink/plugin/paper/DirtSimplePaperCommand.java +++ b/plugin/src/main/java/me/proxylink/plugin/paper/DirtSimplePaperCommand.java @@ -12,7 +12,7 @@ import java.util.List; import java.util.Locale; final class DirtSimplePaperCommand implements CommandExecutor, TabCompleter { - private static final List SUBCOMMANDS = List.of("status", "doctor"); + private static final List SUBCOMMANDS = List.of("status", "doctor", "webwizard"); private final PluginRuntime runtime; @@ -26,7 +26,8 @@ final class DirtSimplePaperCommand implements CommandExecutor, TabCompleter { switch (subcommand) { case "status" -> sendBlock(sender, "DirtSimpleP2P Status", runtime.statusLines()); case "doctor" -> sendBlock(sender, "DirtSimpleP2P Doctor", runtime.doctorLines()); - default -> sender.sendMessage(ChatColor.RED + "Usage: /dsp2p status or /dsp2p doctor"); + case "webwizard" -> sendBlock(sender, "DirtSimpleP2P Web Wizard", runtime.toggleWebWizardLines()); + default -> sender.sendMessage(ChatColor.RED + "Usage: /dsp2p status, /dsp2p doctor, or /dsp2p webwizard"); } return true; } diff --git a/plugin/src/main/java/me/proxylink/plugin/web/WebWizardServer.java b/plugin/src/main/java/me/proxylink/plugin/web/WebWizardServer.java new file mode 100644 index 0000000..1b0b408 --- /dev/null +++ b/plugin/src/main/java/me/proxylink/plugin/web/WebWizardServer.java @@ -0,0 +1,871 @@ +package me.proxylink.plugin.web; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import me.proxylink.plugin.ConfigFileEditor; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.BindException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.regex.Pattern; + +public final class WebWizardServer implements AutoCloseable { + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private static final HexFormat HEX = HexFormat.of().withLowerCase(); + private static final Pattern CONFIG_KEY = Pattern.compile("[A-Za-z0-9._-]+"); + private static final int MAX_FORM_BYTES = 512 * 1024; + private static final String SESSION_COOKIE = "dsp2p_wizard"; + + private final Path configPath; + private final Logger logger; + private final Supplier reloadAction; + private final HttpServer server; + private final ExecutorService executor; + private final String loginKey; + private final String sessionToken; + private final URI uri; + private final AtomicBoolean running = new AtomicBoolean(true); + + private WebWizardServer( + Path configPath, + Logger logger, + Supplier reloadAction, + HttpServer server, + ExecutorService executor, + String loginKey, + String sessionToken, + URI uri + ) { + this.configPath = configPath; + this.logger = logger; + this.reloadAction = reloadAction; + this.server = server; + this.executor = executor; + this.loginKey = loginKey; + this.sessionToken = sessionToken; + this.uri = uri; + } + + public static WebWizardServer start(Path configPath, Logger logger, Supplier reloadAction) throws IOException { + WizardSettings settings = WizardSettings.load(configPath); + IOException lastFailure = null; + int firstPort = settings.port(); + int attempts = firstPort == 0 ? 1 : 31; + + for (int attempt = 0; attempt < attempts; attempt++) { + int port = firstPort == 0 ? 0 : firstPort + attempt; + try { + return bind(configPath, logger, reloadAction, settings.bindHost(), port); + } catch (BindException e) { + lastFailure = e; + } + } + + throw lastFailure == null ? new IOException("Unable to bind web wizard") : lastFailure; + } + + public URI uri() { + return uri; + } + + public boolean isRunning() { + return running.get(); + } + + @Override + public void close() { + if (!running.compareAndSet(true, false)) { + return; + } + server.stop(0); + executor.shutdownNow(); + logger.info("DirtSimpleP2P Web Wizard stopped"); + } + + private static WebWizardServer bind( + Path configPath, + Logger logger, + Supplier reloadAction, + String bindHost, + int port + ) throws IOException { + HttpServer server = HttpServer.create(new InetSocketAddress(bindHost, port), 0); + ExecutorService executor = Executors.newThreadPerTaskExecutor( + Thread.ofVirtual().name("proxylink-webwizard-", 1).factory() + ); + String loginKey = randomHex(18); + String sessionToken = randomHex(32); + URI uri = URI.create("http://" + uriHost(bindHost) + ":" + server.getAddress().getPort() + "/"); + WebWizardServer webWizard = new WebWizardServer( + configPath, + logger, + reloadAction, + server, + executor, + loginKey, + sessionToken, + uri + ); + + server.createContext("/", webWizard::handleIndex); + server.createContext("/login", webWizard::handleLogin); + server.createContext("/logout", webWizard::handleLogout); + server.createContext("/save", webWizard::handleSave); + server.setExecutor(executor); + server.start(); + + logger.warning("DirtSimpleP2P Web Wizard started at " + uri); + logger.warning("Please sign in with this key: " + loginKey); + logger.warning("The Web Wizard can edit config.properties. Stop it with /dsp2p webwizard when finished."); + return webWizard; + } + + private void handleIndex(HttpExchange exchange) throws IOException { + try { + addNoStoreHeaders(exchange); + if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { + sendText(exchange, 405, "Method not allowed"); + return; + } + + if (!isAuthenticated(exchange)) { + sendHtml(exchange, 200, loginPage("")); + return; + } + + sendHtml(exchange, 200, wizardPage("")); + } catch (Exception e) { + handleError(exchange, e); + } + } + + private void handleLogin(HttpExchange exchange) throws IOException { + try { + addNoStoreHeaders(exchange); + if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) { + sendText(exchange, 405, "Method not allowed"); + return; + } + + Map> form = readForm(exchange); + if (!loginKey.equals(first(form, "key"))) { + sendHtml(exchange, 403, loginPage("That key did not match. Check the server console and try again.")); + return; + } + + exchange.getResponseHeaders().add("Set-Cookie", + SESSION_COOKIE + "=" + sessionToken + "; Path=/; HttpOnly; SameSite=Strict"); + redirect(exchange, "/"); + } catch (Exception e) { + handleError(exchange, e); + } + } + + private void handleLogout(HttpExchange exchange) throws IOException { + try { + addNoStoreHeaders(exchange); + exchange.getResponseHeaders().add("Set-Cookie", + SESSION_COOKIE + "=deleted; Path=/; Max-Age=0; HttpOnly; SameSite=Strict"); + redirect(exchange, "/"); + } catch (Exception e) { + handleError(exchange, e); + } + } + + private void handleSave(HttpExchange exchange) throws IOException { + try { + addNoStoreHeaders(exchange); + if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) { + sendText(exchange, 405, "Method not allowed"); + return; + } + if (!isAuthenticated(exchange)) { + sendHtml(exchange, 401, loginPage("Sign in again before saving changes.")); + return; + } + + Map> form = readForm(exchange); + String action = first(form, "action"); + String message; + + if ("reloadOnly".equals(action)) { + message = reloadAction.get(); + } else { + saveWizardForm(form); + if ("generateToken".equals(action)) { + String token = randomHex(32); + ConfigFileEditor.setProperty(configPath, "tunnel.authToken", token); + message = "Generated and saved a new tunnel.authToken. Copy it to the other DirtSimpleP2P nodes."; + } else { + message = "Saved config.properties."; + } + + if ("saveReload".equals(action)) { + message = message + " " + reloadAction.get(); + } + } + + sendHtml(exchange, 200, wizardPage(message)); + } catch (Exception e) { + handleError(exchange, e); + } + } + + private void saveWizardForm(Map> form) throws IOException { + List frontendNames = namesFrom(first(form, "frontends")); + String newFrontend = first(form, "newFrontendName").trim(); + if (!newFrontend.isBlank()) { + validateConfigKey(newFrontend); + if (!frontendNames.contains(newFrontend)) { + frontendNames.add(newFrontend); + } + } + + List routeNames = namesFrom(first(form, "routes")); + String newRoute = first(form, "newRouteName").trim(); + if (!newRoute.isBlank()) { + validateConfigKey(newRoute); + if (!routeNames.contains(newRoute)) { + routeNames.add(newRoute); + } + } + + saveKey(form, "role"); + saveKey(form, "node.name"); + saveKey(form, "tunnel.listenHost"); + saveKey(form, "tunnel.listenPort"); + saveKey(form, "tunnel.authToken"); + saveKey(form, "tunnel.connectTimeoutMillis"); + saveKey(form, "tunnel.heartbeatIntervalMillis"); + saveKey(form, "tunnel.heartbeatTimeoutMillis"); + saveKey(form, "tunnel.heartbeatMissesBeforeDisconnect"); + saveKey(form, "tunnel.reconnectInitialMillis"); + saveKey(form, "tunnel.reconnectMaxMillis"); + saveBoolean(form, "tunnel.tls.enabled"); + saveBoolean(form, "tunnel.tls.allowInsecure"); + saveBoolean(form, "tunnel.tls.autoGenerate"); + saveKey(form, "tunnel.tls.keyStore"); + saveKey(form, "tunnel.tls.keyStorePassword"); + saveBoolean(form, "tunnel.tls.requireClientAuth"); + saveBoolean(form, "tunnel.tls.trustOnFirstUse"); + saveKey(form, "webwizard.bindHost"); + saveKey(form, "webwizard.port"); + + ConfigFileEditor.setProperty(configPath, "frontends", String.join(",", frontendNames)); + for (String frontend : frontendNames) { + saveNamed(form, "frontend." + frontend + ".connectHost"); + saveNamed(form, "frontend." + frontend + ".connectPort"); + saveNamed(form, "frontend." + frontend + ".tls.pinnedCertificateSha256"); + } + + ConfigFileEditor.setProperty(configPath, "routes", String.join(",", routeNames)); + for (String route : routeNames) { + saveNamed(form, "route." + route + ".frontendBindHost"); + saveNamed(form, "route." + route + ".frontendBindPort"); + saveNamed(form, "route." + route + ".backendNode"); + saveNamed(form, "route." + route + ".backendTargetHost"); + saveNamed(form, "route." + route + ".backendTargetPort"); + } + + for (String key : form.getOrDefault("rawKeys", List.of())) { + validateConfigKey(key); + ConfigFileEditor.setProperty(configPath, key, first(form, "raw." + key)); + } + + String newRawKey = first(form, "newRawKey").trim(); + if (!newRawKey.isBlank()) { + validateConfigKey(newRawKey); + ConfigFileEditor.setProperty(configPath, newRawKey, first(form, "newRawValue")); + } + } + + private void saveKey(Map> form, String key) throws IOException { + ConfigFileEditor.setProperty(configPath, key, first(form, key)); + } + + private void saveNamed(Map> form, String key) throws IOException { + ConfigFileEditor.setProperty(configPath, key, first(form, key)); + } + + private void saveBoolean(Map> form, String key) throws IOException { + ConfigFileEditor.setProperty(configPath, key, Boolean.toString(form.containsKey(key))); + } + + private String loginPage(String message) { + return page("Sign In", """ + + """.formatted(alert(message))); + } + + private String wizardPage(String message) throws IOException { + Properties properties = loadProperties(configPath); + Set guidedKeys = new HashSet<>(); + String role = value(properties, "role", "backend"); + List frontendNames = namesFrom(value(properties, "frontends", defaultFrontends(properties))); + List routeNames = namesFrom(value(properties, "routes", "minecraft")); + + StringBuilder frontends = new StringBuilder(); + for (String frontend : frontendNames) { + guidedKeys.add("frontend." + frontend + ".connectHost"); + guidedKeys.add("frontend." + frontend + ".connectPort"); + guidedKeys.add("frontend." + frontend + ".tls.pinnedCertificateSha256"); + frontends.append(""" +
+
%s
+
+ %s + %s + %s +
+
+ """.formatted( + html(frontend), + textInput(properties, "frontend." + frontend + ".connectHost", "Proxy IP or domain", "proxy.example.com"), + textInput(properties, "frontend." + frontend + ".connectPort", "Tunnel port", "24445"), + textInput(properties, "frontend." + frontend + ".tls.pinnedCertificateSha256", "TLS pin", "") + )); + } + + StringBuilder routes = new StringBuilder(); + for (String route : routeNames) { + guidedKeys.add("route." + route + ".frontendBindHost"); + guidedKeys.add("route." + route + ".frontendBindPort"); + guidedKeys.add("route." + route + ".backendNode"); + guidedKeys.add("route." + route + ".backendTargetHost"); + guidedKeys.add("route." + route + ".backendTargetPort"); + routes.append(""" +
+
%s
+
+ %s + %s + %s + %s + %s +
+
+ """.formatted( + html(route), + textInput(properties, "route." + route + ".frontendBindHost", "Proxy local bind host", "127.0.0.1"), + textInput(properties, "route." + route + ".frontendBindPort", "Proxy local bind port", "25566"), + textInput(properties, "route." + route + ".backendNode", "Backend node name", "paper-backend"), + textInput(properties, "route." + route + ".backendTargetHost", "Backend target host", "127.0.0.1"), + textInput(properties, "route." + route + ".backendTargetPort", "Backend Minecraft port", "25565") + )); + } + + guidedKeys.addAll(List.of( + "role", + "node.name", + "tunnel.listenHost", + "tunnel.listenPort", + "tunnel.authToken", + "tunnel.connectTimeoutMillis", + "tunnel.heartbeatIntervalMillis", + "tunnel.heartbeatTimeoutMillis", + "tunnel.heartbeatMissesBeforeDisconnect", + "tunnel.reconnectInitialMillis", + "tunnel.reconnectMaxMillis", + "tunnel.tls.enabled", + "tunnel.tls.allowInsecure", + "tunnel.tls.autoGenerate", + "tunnel.tls.keyStore", + "tunnel.tls.keyStorePassword", + "tunnel.tls.requireClientAuth", + "tunnel.tls.trustOnFirstUse", + "webwizard.bindHost", + "webwizard.port", + "frontends", + "routes" + )); + + String advancedRows = advancedRows(properties, guidedKeys); + + return page("Setup Wizard", """ +
+
+
Configuration Wizard
+

DirtSimpleP2P Control Center

+

Configure the tunnel with guided fields, save safely, then reload DirtSimpleP2P without restarting the Minecraft server.

+
+
+ Config file + %s + Wizard URL: %s +
+
+ %s +
+
+ +
+
+
1

Server Identity

Choose which side this plugin is running on.

+
+ %s + %s +
+
+
+
2

Proxy Listener

Used on BungeeCord or Waterfall. Backend nodes connect to this public tunnel port.

+
+ %s + %s +
+
+
+
3

Backend Connections

Used on Paper or Spigot. Add every public proxy this backend should connect to.

+ %s +
+ +
+ %s +
+
+
4

Minecraft Routes

Map Bungee local ports to named backend nodes and backend Minecraft ports.

+ %s +
+ +
+ %s +
+
+
5

Security

Use a shared secret token. Enable TLS for real deployments.

+
+ %s + %s + %s + %s + %s + %s + %s + %s +
+
+
+
6

Reliability

Fast reconnect defaults are already tuned for most Minecraft networks.

+
+ %s + %s + %s + %s + %s +
+
+
+
7

Web Wizard Access

Keep this bound to localhost unless you are putting it behind your own secure tunnel.

+
+ %s + %s +
+
+
+
8

Advanced Raw Settings

Extra config keys are preserved here for power users.

+ %s +
+ + +
+
+
+ + + + + Sign out +
+
+
+
+ """.formatted( + html(configPath.toString()), + html(uri.toString()), + alert(message), + roleSelect(role), + textInput(properties, "node.name", "Node name", role.equals("frontend") ? "bungee-frontend" : "paper-backend"), + textInput(properties, "tunnel.listenHost", "Tunnel listen host", "0.0.0.0"), + textInput(properties, "tunnel.listenPort", "Tunnel listen port", "24445"), + hiddenText("frontends", String.join(",", frontendNames)), + frontends, + hiddenText("routes", String.join(",", routeNames)), + routes, + textInput(properties, "tunnel.authToken", "Shared tunnel token", "copy the same token to every node"), + checkbox(properties, "tunnel.tls.enabled", "Enable TLS"), + checkbox(properties, "tunnel.tls.allowInsecure", "Allow insecure mode"), + checkbox(properties, "tunnel.tls.autoGenerate", "Auto-generate frontend certificate"), + textInput(properties, "tunnel.tls.keyStore", "Frontend TLS keystore", "certs/frontend.p12"), + textInput(properties, "tunnel.tls.keyStorePassword", "Keystore password", ""), + checkbox(properties, "tunnel.tls.requireClientAuth", "Require TLS client certificates"), + checkbox(properties, "tunnel.tls.trustOnFirstUse", "Trust first TLS certificate"), + textInput(properties, "tunnel.connectTimeoutMillis", "Connect timeout", "5000"), + textInput(properties, "tunnel.heartbeatIntervalMillis", "Heartbeat interval", "2000"), + textInput(properties, "tunnel.heartbeatTimeoutMillis", "Heartbeat timeout", "8000"), + textInput(properties, "tunnel.reconnectInitialMillis", "First reconnect delay", "250"), + textInput(properties, "tunnel.reconnectMaxMillis", "Max reconnect delay", "10000"), + textInput(properties, "webwizard.bindHost", "Wizard bind host", "127.0.0.1"), + textInput(properties, "webwizard.port", "Wizard port", "8765"), + advancedRows + )); + } + + private static String roleSelect(String role) { + return """ + + """.formatted(selected(role, "frontend"), selected(role, "backend")); + } + + private static String textInput(Properties properties, String key, String label, String placeholder) { + return """ + + """.formatted(html(label), attr(key), attr(value(properties, key, "")), attr(placeholder)); + } + + private static String hiddenText(String key, String value) { + return ""; + } + + private static String checkbox(Properties properties, String key, String label) { + String checked = Boolean.parseBoolean(value(properties, key, "false")) ? " checked" : ""; + return """ + + """.formatted(attr(key), checked, html(label)); + } + + private static String advancedRows(Properties properties, Set guidedKeys) { + List keys = new ArrayList<>(properties.stringPropertyNames()); + keys.removeIf(guidedKeys::contains); + keys.sort(Comparator.naturalOrder()); + if (keys.isEmpty()) { + return "

No extra settings found.

"; + } + + StringBuilder rows = new StringBuilder("
"); + for (String key : keys) { + rows.append(""); + } + rows.append("
"); + return rows.toString(); + } + + private static String page(String title, String body) { + return """ + + + + + + DirtSimpleP2P - %s + + +
%s
+ + """.formatted(html(title), body); + } + + private boolean isAuthenticated(HttpExchange exchange) { + String cookieHeader = exchange.getRequestHeaders().getFirst("Cookie"); + if (cookieHeader == null || cookieHeader.isBlank()) { + return false; + } + for (String cookie : cookieHeader.split(";")) { + String trimmed = cookie.trim(); + int equals = trimmed.indexOf('='); + if (equals <= 0) { + continue; + } + String name = trimmed.substring(0, equals); + String value = trimmed.substring(equals + 1); + if (SESSION_COOKIE.equals(name) && sessionToken.equals(value)) { + return true; + } + } + return false; + } + + private static Map> readForm(HttpExchange exchange) throws IOException { + byte[] body; + try (InputStream input = exchange.getRequestBody()) { + body = input.readNBytes(MAX_FORM_BYTES + 1); + } + if (body.length > MAX_FORM_BYTES) { + throw new IOException("Form body is too large"); + } + + Map> values = new LinkedHashMap<>(); + String encoded = new String(body, StandardCharsets.UTF_8); + if (encoded.isBlank()) { + return values; + } + + for (String pair : encoded.split("&")) { + if (pair.isBlank()) { + continue; + } + int equals = pair.indexOf('='); + String key = equals < 0 ? pair : pair.substring(0, equals); + String value = equals < 0 ? "" : pair.substring(equals + 1); + values.computeIfAbsent(urlDecode(key), ignored -> new ArrayList<>()).add(urlDecode(value)); + } + return values; + } + + private static Properties loadProperties(Path configPath) throws IOException { + Properties properties = new Properties(); + try (InputStream input = Files.newInputStream(configPath)) { + properties.load(input); + } + return properties; + } + + private static void validateConfigKey(String key) { + if (!CONFIG_KEY.matcher(key).matches()) { + throw new IllegalArgumentException("Invalid config name: " + key); + } + } + + private static List namesFrom(String raw) { + List names = new ArrayList<>(); + for (String part : raw.split(",")) { + String name = part.trim(); + if (!name.isBlank() && !names.contains(name)) { + validateConfigKey(name); + names.add(name); + } + } + return names; + } + + private static String defaultFrontends(Properties properties) { + String host = value(properties, "tunnel.connectHost", ""); + String port = value(properties, "tunnel.connectPort", ""); + if (!host.isBlank() || !port.isBlank()) { + return "default"; + } + return "proxy1"; + } + + private static String selected(String actual, String expected) { + return expected.equalsIgnoreCase(actual) ? " selected" : ""; + } + + private static String first(Map> form, String key) { + List values = form.get(key); + return values == null || values.isEmpty() ? "" : values.get(0); + } + + private static String value(Properties properties, String key, String defaultValue) { + return properties.getProperty(key, defaultValue).trim(); + } + + private static String alert(String message) { + if (message == null || message.isBlank()) { + return ""; + } + return "
" + html(message) + "
"; + } + + private static String randomHex(int bytes) { + byte[] data = new byte[bytes]; + SECURE_RANDOM.nextBytes(data); + return HEX.formatHex(data); + } + + private static String urlDecode(String value) { + return URLDecoder.decode(value, StandardCharsets.UTF_8); + } + + private static String html(String value) { + return value == null ? "" : value + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } + + private static String attr(String value) { + return html(value); + } + + private static void addNoStoreHeaders(HttpExchange exchange) { + exchange.getResponseHeaders().set("Cache-Control", "no-store"); + exchange.getResponseHeaders().set("X-Content-Type-Options", "nosniff"); + } + + private static void sendHtml(HttpExchange exchange, int statusCode, String html) throws IOException { + byte[] bytes = html.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "text/html; charset=utf-8"); + exchange.sendResponseHeaders(statusCode, bytes.length); + try (OutputStream output = exchange.getResponseBody()) { + output.write(bytes); + } + } + + private static void sendText(HttpExchange exchange, int statusCode, String text) throws IOException { + byte[] bytes = text.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8"); + exchange.sendResponseHeaders(statusCode, bytes.length); + try (OutputStream output = exchange.getResponseBody()) { + output.write(bytes); + } + } + + private static void redirect(HttpExchange exchange, String location) throws IOException { + exchange.getResponseHeaders().set("Location", location); + exchange.sendResponseHeaders(303, -1); + exchange.close(); + } + + private void handleError(HttpExchange exchange, Exception e) throws IOException { + logger.log(Level.WARNING, "Web Wizard request failed", e); + if (exchange.getResponseCode() == -1) { + sendHtml(exchange, 500, page("Error", "

Request failed

" + + html(e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()) + + "

")); + } + } + + private static String uriHost(String host) { + if (host.contains(":") && !host.startsWith("[")) { + return "[" + host + "]"; + } + return host; + } + + private record WizardSettings(String bindHost, int port) { + private static WizardSettings load(Path configPath) throws IOException { + Properties properties = loadProperties(configPath); + String bindHost = properties.getProperty("webwizard.bindHost", "127.0.0.1").trim(); + if (bindHost.isBlank()) { + bindHost = "127.0.0.1"; + } + int port = intValue(properties.getProperty("webwizard.port"), 8765); + if (port < 0 || port > 65535) { + port = 8765; + } + return new WizardSettings(bindHost, port); + } + + private static int intValue(String raw, int defaultValue) { + if (raw == null || raw.isBlank()) { + return defaultValue; + } + try { + return Integer.parseInt(raw.trim()); + } catch (NumberFormatException ignored) { + return defaultValue; + } + } + } +} diff --git a/plugin/src/main/resources/bungee-default.properties b/plugin/src/main/resources/bungee-default.properties index d04aa15..487c129 100644 --- a/plugin/src/main/resources/bungee-default.properties +++ b/plugin/src/main/resources/bungee-default.properties @@ -29,6 +29,11 @@ tunnel.tls.keyStore=certs/frontend.p12 tunnel.tls.keyStorePassword= tunnel.tls.requireClientAuth=false +# Temporary browser setup wizard. Keep this bound to 127.0.0.1 unless you +# understand the risk of exposing a config editor over the network. +webwizard.bindHost=127.0.0.1 +webwizard.port=8765 + routes=minecraft route.minecraft.frontendBindHost=127.0.0.1 route.minecraft.frontendBindPort=25566 diff --git a/plugin/src/main/resources/paper-default.properties b/plugin/src/main/resources/paper-default.properties index 0ab9d60..9a229b0 100644 --- a/plugin/src/main/resources/paper-default.properties +++ b/plugin/src/main/resources/paper-default.properties @@ -37,6 +37,11 @@ tunnel.tls.enabled=false tunnel.tls.allowInsecure=true tunnel.tls.trustOnFirstUse=true +# Temporary browser setup wizard. Keep this bound to 127.0.0.1 unless you +# understand the risk of exposing a config editor over the network. +webwizard.bindHost=127.0.0.1 +webwizard.port=8765 + routes=minecraft route.minecraft.backendTargetHost=127.0.0.1 route.minecraft.backendTargetPort=25565 diff --git a/plugin/src/main/resources/plugin.yml b/plugin/src/main/resources/plugin.yml index 2cfd1ba..86eb6ce 100644 --- a/plugin/src/main/resources/plugin.yml +++ b/plugin/src/main/resources/plugin.yml @@ -7,10 +7,10 @@ author: DirtSimpleP2P description: NAT-safe Minecraft TCP tunnel backend plugin. commands: dsp2p: - description: DirtSimpleP2P status and diagnostics. + description: DirtSimpleP2P status, diagnostics, and setup wizard. usage: /dsp2p status permission: dirtsimplep2p.command permissions: dirtsimplep2p.command: - description: Allows DirtSimpleP2P status and diagnostics commands. + description: Allows DirtSimpleP2P status, diagnostics, and setup wizard commands. default: op diff --git a/plugin/target/DirtSimpleP2P-0.1.0-SNAPSHOT.jar b/plugin/target/DirtSimpleP2P-0.1.0-SNAPSHOT.jar index c1817cb..f2c4694 100644 Binary files a/plugin/target/DirtSimpleP2P-0.1.0-SNAPSHOT.jar and b/plugin/target/DirtSimpleP2P-0.1.0-SNAPSHOT.jar differ diff --git a/plugin/target/classes/bungee-default.properties b/plugin/target/classes/bungee-default.properties index d04aa15..487c129 100644 --- a/plugin/target/classes/bungee-default.properties +++ b/plugin/target/classes/bungee-default.properties @@ -29,6 +29,11 @@ tunnel.tls.keyStore=certs/frontend.p12 tunnel.tls.keyStorePassword= tunnel.tls.requireClientAuth=false +# Temporary browser setup wizard. Keep this bound to 127.0.0.1 unless you +# understand the risk of exposing a config editor over the network. +webwizard.bindHost=127.0.0.1 +webwizard.port=8765 + routes=minecraft route.minecraft.frontendBindHost=127.0.0.1 route.minecraft.frontendBindPort=25566 diff --git a/plugin/target/classes/me/proxylink/plugin/PluginRuntime.class b/plugin/target/classes/me/proxylink/plugin/PluginRuntime.class index def4c30..eb0425a 100644 Binary files a/plugin/target/classes/me/proxylink/plugin/PluginRuntime.class and b/plugin/target/classes/me/proxylink/plugin/PluginRuntime.class differ diff --git a/plugin/target/classes/me/proxylink/plugin/bungee/DirtSimpleBungeeCommand.class b/plugin/target/classes/me/proxylink/plugin/bungee/DirtSimpleBungeeCommand.class index 7b40b9e..ac434f1 100644 Binary files a/plugin/target/classes/me/proxylink/plugin/bungee/DirtSimpleBungeeCommand.class and b/plugin/target/classes/me/proxylink/plugin/bungee/DirtSimpleBungeeCommand.class differ diff --git a/plugin/target/classes/me/proxylink/plugin/paper/DirtSimplePaperCommand.class b/plugin/target/classes/me/proxylink/plugin/paper/DirtSimplePaperCommand.class index 80383e3..8d23832 100644 Binary files a/plugin/target/classes/me/proxylink/plugin/paper/DirtSimplePaperCommand.class and b/plugin/target/classes/me/proxylink/plugin/paper/DirtSimplePaperCommand.class differ diff --git a/plugin/target/classes/me/proxylink/plugin/web/WebWizardServer$WizardSettings.class b/plugin/target/classes/me/proxylink/plugin/web/WebWizardServer$WizardSettings.class new file mode 100644 index 0000000..9b2b7cc Binary files /dev/null and b/plugin/target/classes/me/proxylink/plugin/web/WebWizardServer$WizardSettings.class differ diff --git a/plugin/target/classes/me/proxylink/plugin/web/WebWizardServer.class b/plugin/target/classes/me/proxylink/plugin/web/WebWizardServer.class new file mode 100644 index 0000000..f0ef7cf Binary files /dev/null and b/plugin/target/classes/me/proxylink/plugin/web/WebWizardServer.class differ diff --git a/plugin/target/classes/paper-default.properties b/plugin/target/classes/paper-default.properties index 0ab9d60..9a229b0 100644 --- a/plugin/target/classes/paper-default.properties +++ b/plugin/target/classes/paper-default.properties @@ -37,6 +37,11 @@ tunnel.tls.enabled=false tunnel.tls.allowInsecure=true tunnel.tls.trustOnFirstUse=true +# Temporary browser setup wizard. Keep this bound to 127.0.0.1 unless you +# understand the risk of exposing a config editor over the network. +webwizard.bindHost=127.0.0.1 +webwizard.port=8765 + routes=minecraft route.minecraft.backendTargetHost=127.0.0.1 route.minecraft.backendTargetPort=25565 diff --git a/plugin/target/classes/plugin.yml b/plugin/target/classes/plugin.yml index 8600ce2..8714f89 100644 --- a/plugin/target/classes/plugin.yml +++ b/plugin/target/classes/plugin.yml @@ -7,10 +7,10 @@ author: DirtSimpleP2P description: NAT-safe Minecraft TCP tunnel backend plugin. commands: dsp2p: - description: DirtSimpleP2P status and diagnostics. + description: DirtSimpleP2P status, diagnostics, and setup wizard. usage: /dsp2p status permission: dirtsimplep2p.command permissions: dirtsimplep2p.command: - description: Allows DirtSimpleP2P status and diagnostics commands. + description: Allows DirtSimpleP2P status, diagnostics, and setup wizard commands. default: op diff --git a/plugin/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/plugin/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst index 1b4f038..4c942b2 100644 --- a/plugin/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst +++ b/plugin/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst @@ -12,8 +12,10 @@ me/proxylink/plugin/tunnel/LocalStream.class me/proxylink/plugin/tls/TlsBootstrap.class me/proxylink/plugin/tunnel/FrontendAgent$FrontendTunnel.class me/proxylink/plugin/tls/SelfSignedCertificateGenerator.class +me/proxylink/plugin/web/WebWizardServer$WizardSettings.class me/proxylink/plugin/PluginRuntime.class me/proxylink/plugin/ConfigFileEditor.class +me/proxylink/plugin/web/WebWizardServer.class me/proxylink/plugin/SecretTokenBootstrap.class me/proxylink/plugin/tunnel/NamedThreadFactory.class me/proxylink/plugin/bungee/DirtSimpleBungeePlugin.class diff --git a/plugin/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/plugin/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst index 43668ac..5677589 100644 --- a/plugin/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst +++ b/plugin/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst @@ -16,3 +16,4 @@ /home/bitnix/Desktop/DirtSimpleP2P/plugin/src/main/java/me/proxylink/plugin/tunnel/PinnedCertificateTrustManager.java /home/bitnix/Desktop/DirtSimpleP2P/plugin/src/main/java/me/proxylink/plugin/tunnel/TlsSocketFactory.java /home/bitnix/Desktop/DirtSimpleP2P/plugin/src/main/java/me/proxylink/plugin/tunnel/TunnelStatus.java +/home/bitnix/Desktop/DirtSimpleP2P/plugin/src/main/java/me/proxylink/plugin/web/WebWizardServer.java diff --git a/plugin/target/original-DirtSimpleP2P-0.1.0-SNAPSHOT.jar b/plugin/target/original-DirtSimpleP2P-0.1.0-SNAPSHOT.jar index 27486b5..bde58e7 100644 Binary files a/plugin/target/original-DirtSimpleP2P-0.1.0-SNAPSHOT.jar and b/plugin/target/original-DirtSimpleP2P-0.1.0-SNAPSHOT.jar differ