This commit is contained in:
2026-06-23 22:42:23 -04:00
parent 321eca90c4
commit 8acc128324
11 changed files with 453 additions and 252 deletions
+5 -1
View File
@@ -369,7 +369,9 @@ Please sign in with this key: <temporary-key>
Open the printed URL in a browser and sign in with the temporary key. Open the printed URL in a browser and sign in with the temporary key.
The wizard provides guided sections for: The wizard uses a dark control-center UI. It detects whether it is running on Paper/Spigot or BungeeCord/Waterfall and only shows the setup sections that apply to that server type.
Depending on the server type, it provides guided sections for:
- server mode and node name - server mode and node name
- proxy listener settings - proxy listener settings
@@ -379,6 +381,8 @@ The wizard provides guided sections for:
- heartbeat and reconnect settings - heartbeat and reconnect settings
- advanced raw config keys - advanced raw config keys
You can add or remove proxy endpoints, backend routes, and advanced config keys directly from the web UI.
The web UI includes: The web UI includes:
- `Save Config` - `Save Config`
Binary file not shown.
@@ -36,4 +36,18 @@ public final class ConfigFileEditor {
Files.write(path, lines, StandardCharsets.UTF_8); Files.write(path, lines, StandardCharsets.UTF_8);
} }
public static void removeProperty(Path path, String key) throws IOException {
if (!Files.exists(path)) {
return;
}
String prefix = key + "=";
List<String> lines = new ArrayList<>(Files.readAllLines(path, StandardCharsets.UTF_8));
lines.removeIf(line -> {
String trimmed = line.trim();
return !trimmed.startsWith("#") && trimmed.startsWith(prefix);
});
Files.write(path, lines, StandardCharsets.UTF_8);
}
} }
@@ -149,7 +149,7 @@ public final class PluginRuntime {
} }
try { try {
webWizard = WebWizardServer.start(configPath, logger, this::reloadFromConfig); webWizard = WebWizardServer.start(configPath, logger, this::reloadFromConfig, requiredRole);
lines.add("Web wizard: running"); lines.add("Web wizard: running");
lines.add("URL: " + webWizard.uri()); lines.add("URL: " + webWizard.uri());
lines.add("Login key: printed in the server console"); lines.add("Login key: printed in the server console");
@@ -2,6 +2,7 @@ package me.proxylink.plugin.web;
import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer; import com.sun.net.httpserver.HttpServer;
import me.proxylink.common.AgentConfig;
import me.proxylink.plugin.ConfigFileEditor; import me.proxylink.plugin.ConfigFileEditor;
import java.io.IOException; import java.io.IOException;
@@ -42,6 +43,7 @@ public final class WebWizardServer implements AutoCloseable {
private final Path configPath; private final Path configPath;
private final Logger logger; private final Logger logger;
private final Supplier<String> reloadAction; private final Supplier<String> reloadAction;
private final AgentConfig.Role runtimeRole;
private final HttpServer server; private final HttpServer server;
private final ExecutorService executor; private final ExecutorService executor;
private final String loginKey; private final String loginKey;
@@ -53,6 +55,7 @@ public final class WebWizardServer implements AutoCloseable {
Path configPath, Path configPath,
Logger logger, Logger logger,
Supplier<String> reloadAction, Supplier<String> reloadAction,
AgentConfig.Role runtimeRole,
HttpServer server, HttpServer server,
ExecutorService executor, ExecutorService executor,
String loginKey, String loginKey,
@@ -62,6 +65,7 @@ public final class WebWizardServer implements AutoCloseable {
this.configPath = configPath; this.configPath = configPath;
this.logger = logger; this.logger = logger;
this.reloadAction = reloadAction; this.reloadAction = reloadAction;
this.runtimeRole = runtimeRole;
this.server = server; this.server = server;
this.executor = executor; this.executor = executor;
this.loginKey = loginKey; this.loginKey = loginKey;
@@ -69,7 +73,12 @@ public final class WebWizardServer implements AutoCloseable {
this.uri = uri; this.uri = uri;
} }
public static WebWizardServer start(Path configPath, Logger logger, Supplier<String> reloadAction) throws IOException { public static WebWizardServer start(
Path configPath,
Logger logger,
Supplier<String> reloadAction,
AgentConfig.Role runtimeRole
) throws IOException {
WizardSettings settings = WizardSettings.load(configPath); WizardSettings settings = WizardSettings.load(configPath);
IOException lastFailure = null; IOException lastFailure = null;
int firstPort = settings.port(); int firstPort = settings.port();
@@ -78,7 +87,7 @@ public final class WebWizardServer implements AutoCloseable {
for (int attempt = 0; attempt < attempts; attempt++) { for (int attempt = 0; attempt < attempts; attempt++) {
int port = firstPort == 0 ? 0 : firstPort + attempt; int port = firstPort == 0 ? 0 : firstPort + attempt;
try { try {
return bind(configPath, logger, reloadAction, settings.bindHost(), port); return bind(configPath, logger, reloadAction, runtimeRole, settings.bindHost(), port);
} catch (BindException e) { } catch (BindException e) {
lastFailure = e; lastFailure = e;
} }
@@ -109,6 +118,7 @@ public final class WebWizardServer implements AutoCloseable {
Path configPath, Path configPath,
Logger logger, Logger logger,
Supplier<String> reloadAction, Supplier<String> reloadAction,
AgentConfig.Role runtimeRole,
String bindHost, String bindHost,
int port int port
) throws IOException { ) throws IOException {
@@ -118,11 +128,12 @@ public final class WebWizardServer implements AutoCloseable {
); );
String loginKey = randomHex(18); String loginKey = randomHex(18);
String sessionToken = randomHex(32); String sessionToken = randomHex(32);
URI uri = URI.create("http://" + uriHost(bindHost) + ":" + server.getAddress().getPort() + "/"); URI uri = URI.create("http://" + displayUriHost(bindHost) + ":" + server.getAddress().getPort() + "/");
WebWizardServer webWizard = new WebWizardServer( WebWizardServer webWizard = new WebWizardServer(
configPath, configPath,
logger, logger,
reloadAction, reloadAction,
runtimeRole,
server, server,
executor, executor,
loginKey, loginKey,
@@ -138,6 +149,9 @@ public final class WebWizardServer implements AutoCloseable {
server.start(); server.start();
logger.warning("DirtSimpleP2P Web Wizard started at " + uri); logger.warning("DirtSimpleP2P Web Wizard started at " + uri);
if (isWildcardHost(bindHost)) {
logger.warning("Web Wizard is bound to " + bindHost + ". Use your server IP instead of 127.0.0.1 when connecting from another computer.");
}
logger.warning("Please sign in with this key: " + loginKey); 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."); logger.warning("The Web Wizard can edit config.properties. Stop it with /dsp2p webwizard when finished.");
return webWizard; return webWizard;
@@ -214,7 +228,7 @@ public final class WebWizardServer implements AutoCloseable {
if ("reloadOnly".equals(action)) { if ("reloadOnly".equals(action)) {
message = reloadAction.get(); message = reloadAction.get();
} else { } else {
saveWizardForm(form); saveWizardForm(form, action);
if ("generateToken".equals(action)) { if ("generateToken".equals(action)) {
String token = randomHex(32); String token = randomHex(32);
ConfigFileEditor.setProperty(configPath, "tunnel.authToken", token); ConfigFileEditor.setProperty(configPath, "tunnel.authToken", token);
@@ -234,15 +248,14 @@ public final class WebWizardServer implements AutoCloseable {
} }
} }
private void saveWizardForm(Map<String, List<String>> form) throws IOException { private void saveWizardForm(Map<String, List<String>> form, String action) throws IOException {
List<String> frontendNames = namesFrom(first(form, "frontends")); boolean frontendMode = runtimeRole == AgentConfig.Role.FRONTEND;
String newFrontend = first(form, "newFrontendName").trim(); boolean backendMode = runtimeRole == AgentConfig.Role.BACKEND;
if (!newFrontend.isBlank()) { String removeFrontend = actionValue(action, "removeFrontend:");
validateConfigKey(newFrontend); String removeRoute = actionValue(action, "removeRoute:");
if (!frontendNames.contains(newFrontend)) { String removeRaw = actionValue(action, "removeRaw:");
frontendNames.add(newFrontend); boolean removedFrontend = false;
} boolean removedRoute = false;
}
List<String> routeNames = namesFrom(first(form, "routes")); List<String> routeNames = namesFrom(first(form, "routes"));
String newRoute = first(form, "newRouteName").trim(); String newRoute = first(form, "newRouteName").trim();
@@ -252,11 +265,12 @@ public final class WebWizardServer implements AutoCloseable {
routeNames.add(newRoute); routeNames.add(newRoute);
} }
} }
if (!removeRoute.isBlank() && routeNames.size() > 1) {
removedRoute = routeNames.remove(removeRoute);
}
saveKey(form, "role"); ConfigFileEditor.setProperty(configPath, "role", runtimeRole.name().toLowerCase(java.util.Locale.ROOT));
saveKey(form, "node.name"); saveKey(form, "node.name");
saveKey(form, "tunnel.listenHost");
saveKey(form, "tunnel.listenPort");
saveKey(form, "tunnel.authToken"); saveKey(form, "tunnel.authToken");
saveKey(form, "tunnel.connectTimeoutMillis"); saveKey(form, "tunnel.connectTimeoutMillis");
saveKey(form, "tunnel.heartbeatIntervalMillis"); saveKey(form, "tunnel.heartbeatIntervalMillis");
@@ -266,33 +280,74 @@ public final class WebWizardServer implements AutoCloseable {
saveKey(form, "tunnel.reconnectMaxMillis"); saveKey(form, "tunnel.reconnectMaxMillis");
saveBoolean(form, "tunnel.tls.enabled"); saveBoolean(form, "tunnel.tls.enabled");
saveBoolean(form, "tunnel.tls.allowInsecure"); 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.bindHost");
saveKey(form, "webwizard.port"); saveKey(form, "webwizard.port");
ConfigFileEditor.setProperty(configPath, "frontends", String.join(",", frontendNames)); if (frontendMode) {
for (String frontend : frontendNames) { saveKey(form, "tunnel.listenHost");
saveNamed(form, "frontend." + frontend + ".connectHost"); saveKey(form, "tunnel.listenPort");
saveNamed(form, "frontend." + frontend + ".connectPort"); saveBoolean(form, "tunnel.tls.autoGenerate");
saveNamed(form, "frontend." + frontend + ".tls.pinnedCertificateSha256"); saveKey(form, "tunnel.tls.keyStore");
saveKey(form, "tunnel.tls.keyStorePassword");
saveBoolean(form, "tunnel.tls.requireClientAuth");
}
if (backendMode) {
saveBoolean(form, "tunnel.tls.trustOnFirstUse");
List<String> frontendNames = namesFrom(first(form, "frontends"));
String newFrontend = first(form, "newFrontendName").trim();
if (!newFrontend.isBlank()) {
validateConfigKey(newFrontend);
if (!frontendNames.contains(newFrontend)) {
frontendNames.add(newFrontend);
}
}
if (!removeFrontend.isBlank() && frontendNames.size() > 1) {
removedFrontend = frontendNames.remove(removeFrontend);
}
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");
}
if (removedFrontend) {
ConfigFileEditor.removeProperty(configPath, "frontend." + removeFrontend + ".connectHost");
ConfigFileEditor.removeProperty(configPath, "frontend." + removeFrontend + ".connectPort");
ConfigFileEditor.removeProperty(configPath, "frontend." + removeFrontend + ".tls.pinnedCertificateSha256");
}
} }
ConfigFileEditor.setProperty(configPath, "routes", String.join(",", routeNames)); ConfigFileEditor.setProperty(configPath, "routes", String.join(",", routeNames));
for (String route : routeNames) { for (String route : routeNames) {
saveNamed(form, "route." + route + ".frontendBindHost"); if (frontendMode) {
saveNamed(form, "route." + route + ".frontendBindPort"); saveNamed(form, "route." + route + ".frontendBindHost");
saveNamed(form, "route." + route + ".backendNode"); saveNamed(form, "route." + route + ".frontendBindPort");
saveNamed(form, "route." + route + ".backendTargetHost"); saveNamed(form, "route." + route + ".backendNode");
saveNamed(form, "route." + route + ".backendTargetPort"); }
if (backendMode) {
saveNamed(form, "route." + route + ".backendTargetHost");
saveNamed(form, "route." + route + ".backendTargetPort");
}
}
if (removedRoute) {
ConfigFileEditor.removeProperty(configPath, "route." + removeRoute + ".frontendBindHost");
ConfigFileEditor.removeProperty(configPath, "route." + removeRoute + ".frontendBindPort");
ConfigFileEditor.removeProperty(configPath, "route." + removeRoute + ".backendNode");
ConfigFileEditor.removeProperty(configPath, "route." + removeRoute + ".backendTargetHost");
ConfigFileEditor.removeProperty(configPath, "route." + removeRoute + ".backendTargetPort");
} }
for (String key : form.getOrDefault("rawKeys", List.of())) { for (String key : form.getOrDefault("rawKeys", List.of())) {
validateConfigKey(key); validateConfigKey(key);
ConfigFileEditor.setProperty(configPath, key, first(form, "raw." + key)); if (!key.equals(removeRaw)) {
ConfigFileEditor.setProperty(configPath, key, first(form, "raw." + key));
}
}
if (!removeRaw.isBlank()) {
validateConfigKey(removeRaw);
ConfigFileEditor.removeProperty(configPath, removeRaw);
} }
String newRawKey = first(form, "newRawKey").trim(); String newRawKey = first(form, "newRawKey").trim();
@@ -314,6 +369,17 @@ public final class WebWizardServer implements AutoCloseable {
ConfigFileEditor.setProperty(configPath, key, Boolean.toString(form.containsKey(key))); ConfigFileEditor.setProperty(configPath, key, Boolean.toString(form.containsKey(key)));
} }
private static String actionValue(String action, String prefix) {
if (action == null || !action.startsWith(prefix)) {
return "";
}
String value = action.substring(prefix.length()).trim();
if (!value.isBlank()) {
validateConfigKey(value);
}
return value;
}
private String loginPage(String message) { private String loginPage(String message) {
return page("Sign In", """ return page("Sign In", """
<section class="login-shell"> <section class="login-shell">
@@ -332,65 +398,284 @@ public final class WebWizardServer implements AutoCloseable {
private String wizardPage(String message) throws IOException { private String wizardPage(String message) throws IOException {
Properties properties = loadProperties(configPath); Properties properties = loadProperties(configPath);
Set<String> guidedKeys = new HashSet<>(); Set<String> guidedKeys = new HashSet<>();
String role = value(properties, "role", "backend"); boolean frontendMode = runtimeRole == AgentConfig.Role.FRONTEND;
List<String> frontendNames = namesFrom(value(properties, "frontends", defaultFrontends(properties))); boolean backendMode = runtimeRole == AgentConfig.Role.BACKEND;
String role = runtimeRole.name().toLowerCase(java.util.Locale.ROOT);
List<String> frontendNames = backendMode
? namesFrom(value(properties, "frontends", defaultFrontends(properties)))
: List.of();
List<String> routeNames = namesFrom(value(properties, "routes", "minecraft")); List<String> routeNames = namesFrom(value(properties, "routes", "minecraft"));
addKnownGuidedKeys(properties, guidedKeys);
StringBuilder frontends = new StringBuilder(); StringBuilder frontends = new StringBuilder();
for (String frontend : frontendNames) { if (backendMode) {
guidedKeys.add("frontend." + frontend + ".connectHost"); for (String frontend : frontendNames) {
guidedKeys.add("frontend." + frontend + ".connectPort"); frontends.append("""
guidedKeys.add("frontend." + frontend + ".tls.pinnedCertificateSha256"); <div class="mini-card">
frontends.append(""" <div class="mini-title"><span>%s</span><button type="submit" class="icon-button danger" name="action" value="removeFrontend:%s" title="Remove proxy">X</button></div>
<div class="mini-card"> <div class="field-grid">
<div class="mini-title">%s</div> %s
<div class="field-grid"> %s
%s %s
%s </div>
%s </div>
</div> """.formatted(
</div> html(frontend),
""".formatted( attr(frontend),
html(frontend), textInput(properties, "frontend." + frontend + ".connectHost", "Proxy IP or domain", "proxy.example.com"),
textInput(properties, "frontend." + frontend + ".connectHost", "Proxy IP or domain", "proxy.example.com"), textInput(properties, "frontend." + frontend + ".connectPort", "Tunnel port", "24445"),
textInput(properties, "frontend." + frontend + ".connectPort", "Tunnel port", "24445"), textInput(properties, "frontend." + frontend + ".tls.pinnedCertificateSha256", "TLS pin", "")
textInput(properties, "frontend." + frontend + ".tls.pinnedCertificateSha256", "TLS pin", "") ));
)); }
} }
StringBuilder routes = new StringBuilder(); StringBuilder routes = new StringBuilder();
for (String route : routeNames) { for (String route : routeNames) {
guidedKeys.add("route." + route + ".frontendBindHost"); if (frontendMode) {
guidedKeys.add("route." + route + ".frontendBindPort"); routes.append("""
guidedKeys.add("route." + route + ".backendNode"); <div class="mini-card">
guidedKeys.add("route." + route + ".backendTargetHost"); <div class="mini-title"><span>%s</span><button type="submit" class="icon-button danger" name="action" value="removeRoute:%s" title="Remove route">X</button></div>
guidedKeys.add("route." + route + ".backendTargetPort"); <div class="field-grid">
routes.append(""" %s
<div class="mini-card"> %s
<div class="mini-title">%s</div> %s
</div>
</div>
""".formatted(
html(route),
attr(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")
));
} else {
routes.append("""
<div class="mini-card">
<div class="mini-title"><span>%s</span><button type="submit" class="icon-button danger" name="action" value="removeRoute:%s" title="Remove route">X</button></div>
<div class="field-grid">
%s
%s
</div>
</div>
""".formatted(
html(route),
attr(route),
textInput(properties, "route." + route + ".backendTargetHost", "Backend target host", "127.0.0.1"),
textInput(properties, "route." + route + ".backendTargetPort", "Backend Minecraft port", "25565")
));
}
}
String advancedRows = advancedRows(properties, guidedKeys);
String roleLabel = frontendMode ? "Proxy / BungeeCord / Waterfall" : "Backend / Paper / Spigot";
StringBuilder nav = new StringBuilder();
nav.append("<a href=\"#identity\">1. Identity</a>");
if (frontendMode) {
nav.append("<a href=\"#proxy\">2. Proxy Listener</a>");
}
if (backendMode) {
nav.append("<a href=\"#backend\">2. Backend Links</a>");
}
nav.append("<a href=\"#routes\">3. Routes</a>");
nav.append("<a href=\"#security\">4. Security</a>");
nav.append("<a href=\"#reliability\">5. Reliability</a>");
nav.append("<a href=\"#wizard\">6. Wizard Access</a>");
nav.append("<a href=\"#advanced\">7. Advanced</a>");
StringBuilder cards = new StringBuilder();
cards.append("""
<section class="panel" id="identity">
<div class="section-head"><span>1</span><div><h2>Server Identity</h2><p>This wizard is running in %s mode.</p></div></div>
<div class="field-grid">
%s
%s
</div>
</section>
""".formatted(
html(roleLabel),
roleBadge(role, roleLabel),
textInput(properties, "node.name", "Node name", frontendMode ? "bungee-frontend" : "paper-backend")
));
if (frontendMode) {
cards.append("""
<section class="panel" id="proxy">
<div class="section-head"><span>2</span><div><h2>Proxy Listener</h2><p>Backend nodes connect to this public tunnel port.</p></div></div>
<div class="field-grid"> <div class="field-grid">
%s %s
%s %s
%s
%s
%s
</div> </div>
</div> </section>
""".formatted( """.formatted(
html(route), textInput(properties, "tunnel.listenHost", "Tunnel listen host", "0.0.0.0"),
textInput(properties, "route." + route + ".frontendBindHost", "Proxy local bind host", "127.0.0.1"), textInput(properties, "tunnel.listenPort", "Tunnel listen port", "24445")
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")
)); ));
} }
if (backendMode) {
cards.append("""
<section class="panel" id="backend">
<div class="section-head"><span>2</span><div><h2>Backend Connections</h2><p>Add every public proxy this backend should connect to.</p></div></div>
%s
<div class="add-row">
<label>New proxy name<input name="newFrontendName" placeholder="proxy2"></label>
<button type="submit" name="action" value="addFrontend" class="secondary">Add Proxy</button>
</div>
%s
</section>
""".formatted(hiddenText("frontends", String.join(",", frontendNames)), frontends));
}
cards.append("""
<section class="panel" id="routes">
<div class="section-head"><span>3</span><div><h2>Minecraft Routes</h2><p>%s</p></div></div>
%s
<div class="add-row">
<label>%s<input name="newRouteName" placeholder="lobby"></label>
<button type="submit" name="action" value="addRoute" class="secondary">%s</button>
</div>
%s
</section>
""".formatted(
frontendMode
? "Expose local Bungee backend addresses and map them to named backend nodes."
: "Point tunnel routes at the local Minecraft server ports on this backend.",
hiddenText("routes", String.join(",", routeNames)),
frontendMode ? "New backend route name" : "New local route name",
frontendMode ? "Add Backend" : "Add Route",
routes
));
String frontendSecurity = frontendMode ? """
%s
%s
%s
%s
""".formatted(
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")
) : "";
String backendSecurity = backendMode
? checkbox(properties, "tunnel.tls.trustOnFirstUse", "Trust first TLS certificate")
: "";
cards.append("""
<section class="panel" id="security">
<div class="section-head"><span>4</span><div><h2>Security</h2><p>Use one shared secret token across the same private tunnel group.</p></div></div>
<div class="field-grid">
%s
%s
%s
%s
%s
</div>
</section>
""".formatted(
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"),
frontendSecurity,
backendSecurity
));
cards.append("""
<section class="panel" id="reliability">
<div class="section-head"><span>5</span><div><h2>Reliability</h2><p>Fast reconnect defaults are already tuned for most Minecraft networks.</p></div></div>
<div class="field-grid">
%s
%s
%s
%s
%s
%s
</div>
</section>
""".formatted(
textInput(properties, "tunnel.connectTimeoutMillis", "Connect timeout", "5000"),
textInput(properties, "tunnel.heartbeatIntervalMillis", "Heartbeat interval", "2000"),
textInput(properties, "tunnel.heartbeatTimeoutMillis", "Heartbeat timeout", "8000"),
textInput(properties, "tunnel.heartbeatMissesBeforeDisconnect", "Missed heartbeats before disconnect", "4"),
textInput(properties, "tunnel.reconnectInitialMillis", "First reconnect delay", "250"),
textInput(properties, "tunnel.reconnectMaxMillis", "Max reconnect delay", "10000")
));
cards.append("""
<section class="panel" id="wizard">
<div class="section-head"><span>6</span><div><h2>Web Wizard Access</h2><p>Keep this bound to localhost unless you are putting it behind your own secure tunnel.</p></div></div>
<div class="field-grid">
%s
%s
</div>
</section>
<section class="panel" id="advanced">
<div class="section-head"><span>7</span><div><h2>Advanced Raw Settings</h2><p>Extra custom config keys are preserved here.</p></div></div>
%s
<div class="field-grid">
<label>New key<input name="newRawKey" placeholder="example.setting"></label>
<label>New value<input name="newRawValue"></label>
</div>
</section>
<section class="action-bar">
<button type="submit" name="action" value="save">Save Config</button>
<button type="submit" name="action" value="saveReload">Save + Reload DirtSimpleP2P</button>
<button type="submit" name="action" value="reloadOnly" class="secondary">Reload Current Config</button>
<button type="submit" name="action" value="generateToken" class="secondary">Generate New Token</button>
<a href="/logout">Sign out</a>
</section>
""".formatted(
textInput(properties, "webwizard.bindHost", "Wizard bind host", "127.0.0.1"),
textInput(properties, "webwizard.port", "Wizard port", "8765"),
advancedRows
));
return page("Setup Wizard", """
<section class="hero">
<div>
<div class="eyebrow">Configuration Wizard</div>
<h1>%s Control Center</h1>
<p>Configure only the settings that apply to this server type, save safely, then reload DirtSimpleP2P without restarting Minecraft.</p>
</div>
<div class="status-card">
<span>Detected mode</span>
<strong>%s</strong>
<span>Config file</span>
<small>%s</small>
<small>Wizard URL: %s</small>
</div>
</section>
%s
<form method="post" action="/save">
<section class="layout">
<aside class="steps">
%s
</aside>
<div class="cards">
%s
</div>
</section>
</form>
""".formatted(
html(frontendMode ? "Proxy" : "Backend"),
html(roleLabel),
html(configPath.toString()),
html(uri.toString()),
alert(message),
nav,
cards
));
}
private static void addKnownGuidedKeys(Properties properties, Set<String> guidedKeys) {
guidedKeys.addAll(List.of( guidedKeys.addAll(List.of(
"role", "role",
"node.name", "node.name",
"tunnel.listenHost", "tunnel.listenHost",
"tunnel.listenPort", "tunnel.listenPort",
"tunnel.connectHost",
"tunnel.connectPort",
"tunnel.authToken", "tunnel.authToken",
"tunnel.connectTimeoutMillis", "tunnel.connectTimeoutMillis",
"tunnel.heartbeatIntervalMillis", "tunnel.heartbeatIntervalMillis",
@@ -405,158 +690,27 @@ public final class WebWizardServer implements AutoCloseable {
"tunnel.tls.keyStorePassword", "tunnel.tls.keyStorePassword",
"tunnel.tls.requireClientAuth", "tunnel.tls.requireClientAuth",
"tunnel.tls.trustOnFirstUse", "tunnel.tls.trustOnFirstUse",
"tunnel.tls.pinnedCertificateSha256",
"webwizard.bindHost", "webwizard.bindHost",
"webwizard.port", "webwizard.port",
"frontends", "frontends",
"routes" "routes"
)); ));
String advancedRows = advancedRows(properties, guidedKeys); for (String key : properties.stringPropertyNames()) {
if (key.startsWith("frontend.") || key.startsWith("route.")) {
return page("Setup Wizard", """ guidedKeys.add(key);
<section class="hero"> }
<div> }
<div class="eyebrow">Configuration Wizard</div>
<h1>DirtSimpleP2P Control Center</h1>
<p>Configure the tunnel with guided fields, save safely, then reload DirtSimpleP2P without restarting the Minecraft server.</p>
</div>
<div class="status-card">
<span>Config file</span>
<strong>%s</strong>
<small>Wizard URL: %s</small>
</div>
</section>
%s
<form method="post" action="/save">
<section class="layout">
<aside class="steps">
<a href="#identity">1. Identity</a>
<a href="#proxy">2. Proxy</a>
<a href="#backend">3. Backend</a>
<a href="#routes">4. Routes</a>
<a href="#security">5. Security</a>
<a href="#reliability">6. Reliability</a>
<a href="#advanced">7. Advanced</a>
</aside>
<div class="cards">
<section class="panel" id="identity">
<div class="section-head"><span>1</span><div><h2>Server Identity</h2><p>Choose which side this plugin is running on.</p></div></div>
<div class="field-grid">
%s
%s
</div>
</section>
<section class="panel" id="proxy">
<div class="section-head"><span>2</span><div><h2>Proxy Listener</h2><p>Used on BungeeCord or Waterfall. Backend nodes connect to this public tunnel port.</p></div></div>
<div class="field-grid">
%s
%s
</div>
</section>
<section class="panel" id="backend">
<div class="section-head"><span>3</span><div><h2>Backend Connections</h2><p>Used on Paper or Spigot. Add every public proxy this backend should connect to.</p></div></div>
%s
<div class="add-row">
<label>New proxy name<input name="newFrontendName" placeholder="proxy2"></label>
</div>
%s
</section>
<section class="panel" id="routes">
<div class="section-head"><span>4</span><div><h2>Minecraft Routes</h2><p>Map Bungee local ports to named backend nodes and backend Minecraft ports.</p></div></div>
%s
<div class="add-row">
<label>New route name<input name="newRouteName" placeholder="lobby"></label>
</div>
%s
</section>
<section class="panel" id="security">
<div class="section-head"><span>5</span><div><h2>Security</h2><p>Use a shared secret token. Enable TLS for real deployments.</p></div></div>
<div class="field-grid">
%s
%s
%s
%s
%s
%s
%s
%s
</div>
</section>
<section class="panel" id="reliability">
<div class="section-head"><span>6</span><div><h2>Reliability</h2><p>Fast reconnect defaults are already tuned for most Minecraft networks.</p></div></div>
<div class="field-grid">
%s
%s
%s
%s
%s
</div>
</section>
<section class="panel" id="wizard">
<div class="section-head"><span>7</span><div><h2>Web Wizard Access</h2><p>Keep this bound to localhost unless you are putting it behind your own secure tunnel.</p></div></div>
<div class="field-grid">
%s
%s
</div>
</section>
<section class="panel" id="advanced">
<div class="section-head"><span>8</span><div><h2>Advanced Raw Settings</h2><p>Extra config keys are preserved here for power users.</p></div></div>
%s
<div class="field-grid">
<label>New key<input name="newRawKey" placeholder="example.setting"></label>
<label>New value<input name="newRawValue"></label>
</div>
</section>
<section class="action-bar">
<button type="submit" name="action" value="save">Save Config</button>
<button type="submit" name="action" value="saveReload">Save + Reload DirtSimpleP2P</button>
<button type="submit" name="action" value="reloadOnly" class="secondary">Reload Current Config</button>
<button type="submit" name="action" value="generateToken" class="secondary">Generate New Token</button>
<a href="/logout">Sign out</a>
</section>
</div>
</section>
</form>
""".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) { private static String roleBadge(String role, String label) {
return """ return """
<label>Mode <label>Mode
<select name="role"> <input type="hidden" name="role" value="%s">
<option value="frontend"%s>Proxy / BungeeCord / Waterfall</option> <input value="%s" readonly>
<option value="backend"%s>Backend / Paper / Spigot</option>
</select>
</label> </label>
""".formatted(selected(role, "frontend"), selected(role, "backend")); """.formatted(attr(role), attr(label));
} }
private static String textInput(Properties properties, String key, String label, String placeholder) { private static String textInput(Properties properties, String key, String label, String placeholder) {
@@ -586,10 +740,12 @@ public final class WebWizardServer implements AutoCloseable {
StringBuilder rows = new StringBuilder("<div class=\"raw-grid\">"); StringBuilder rows = new StringBuilder("<div class=\"raw-grid\">");
for (String key : keys) { for (String key : keys) {
rows.append("<label><code>").append(html(key)).append("</code>") rows.append("<div class=\"raw-item\"><div class=\"mini-title\"><code>").append(html(key)).append("</code>")
.append("<button type=\"submit\" class=\"icon-button danger\" name=\"action\" value=\"removeRaw:")
.append(attr(key)).append("\" title=\"Remove setting\">X</button></div><label>")
.append("<input type=\"hidden\" name=\"rawKeys\" value=\"").append(attr(key)).append("\">") .append("<input type=\"hidden\" name=\"rawKeys\" value=\"").append(attr(key)).append("\">")
.append("<input name=\"raw.").append(attr(key)).append("\" value=\"") .append("<input name=\"raw.").append(attr(key)).append("\" value=\"")
.append(attr(properties.getProperty(key, ""))).append("\"></label>"); .append(attr(properties.getProperty(key, ""))).append("\"></label></div>");
} }
rows.append("</div>"); rows.append("</div>");
return rows.toString(); return rows.toString();
@@ -602,64 +758,75 @@ public final class WebWizardServer implements AutoCloseable {
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>DirtSimpleP2P - %s</title> <title>DirtSimpleP2P - __TITLE__</title>
<style> <style>
:root { :root {
color-scheme: light; color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--ink: #162033; --ink: #e8edf7;
--muted: #637086; --muted: #99a7bd;
--line: #d9e0eb; --line: #26354e;
--panel: #ffffff; --panel: #111a2b;
--soft: #f5f7fb; --panel-strong: #162238;
--blue: #1f66d1; --soft: #0d1524;
--teal: #15847a; --field: #0a1220;
--gold: #a86c12; --blue: #5b8cff;
--teal: #37d4bd;
--danger: #ff6b7a;
--shadow: rgba(0,0,0,.34);
} }
body { margin: 0; min-height: 100vh; color: var(--ink); background: linear-gradient(180deg, #edf3fb 0, #f8fafc 280px); } body { margin: 0; min-height: 100vh; color: var(--ink); background:
main { width: min(1180px, calc(100%% - 32px)); margin: 28px auto 44px; } radial-gradient(circle at 18% 0%, rgba(91,140,255,.18), transparent 28%),
radial-gradient(circle at 84% 8%, rgba(55,212,189,.12), transparent 24%),
linear-gradient(180deg, #070d18 0, #0b1220 280px, #080d17 100%);
}
main { width: min(1180px, calc(100% - 32px)); margin: 28px auto 44px; }
.hero { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 18px; align-items: stretch; margin-bottom: 18px; } .hero { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 18px; align-items: stretch; margin-bottom: 18px; }
.hero, .panel, .login-shell, .action-bar { background: rgba(255,255,255,.94); border: 1px solid var(--line); border-radius: 8px; box-shadow: 0 18px 40px rgba(22,32,51,.10); } .hero, .panel, .login-shell, .action-bar { background: rgba(17,26,43,.94); border: 1px solid var(--line); border-radius: 8px; box-shadow: 0 18px 48px var(--shadow); }
.hero { padding: 26px; } .hero { padding: 26px; }
.eyebrow { color: var(--teal); font-weight: 800; text-transform: uppercase; letter-spacing: .08em; font-size: 12px; margin-bottom: 8px; } .eyebrow { color: var(--teal); font-weight: 800; text-transform: uppercase; letter-spacing: .08em; font-size: 12px; margin-bottom: 8px; }
h1, h2 { margin: 0; letter-spacing: 0; } h1, h2 { margin: 0; letter-spacing: 0; }
h1 { font-size: 32px; line-height: 1.1; } h1 { font-size: 32px; line-height: 1.1; }
h2 { font-size: 20px; } h2 { font-size: 20px; }
p { color: var(--muted); line-height: 1.5; } p { color: var(--muted); line-height: 1.5; }
.status-card { display: grid; gap: 8px; align-content: center; background: #132238; color: white; border-radius: 8px; padding: 18px; min-width: 0; } .status-card { display: grid; gap: 8px; align-content: center; background: linear-gradient(180deg, #0c1729, #0a1220); color: white; border: 1px solid #2b3d5b; border-radius: 8px; padding: 18px; min-width: 0; }
.status-card span, .status-card small { color: #c5d2e4; } .status-card span, .status-card small { color: #9db0cb; }
.status-card strong, .status-card small { overflow-wrap: anywhere; } .status-card strong, .status-card small { overflow-wrap: anywhere; }
.layout { display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 18px; align-items: start; } .layout { display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 18px; align-items: start; }
.steps { position: sticky; top: 16px; display: grid; gap: 8px; background: #132238; border-radius: 8px; padding: 12px; } .steps { position: sticky; top: 16px; display: grid; gap: 8px; background: rgba(10,18,32,.92); border: 1px solid var(--line); border-radius: 8px; padding: 12px; }
.steps a { color: #d8e5f6; text-decoration: none; padding: 10px 12px; border-radius: 6px; font-weight: 750; } .steps a { color: #c8d5ea; text-decoration: none; padding: 10px 12px; border-radius: 6px; font-weight: 750; }
.steps a:hover { background: rgba(255,255,255,.10); } .steps a:hover { background: rgba(91,140,255,.14); color: #ffffff; }
.cards { display: grid; gap: 16px; min-width: 0; } .cards { display: grid; gap: 16px; min-width: 0; }
.panel { padding: 20px; } .panel { padding: 20px; }
.section-head { display: flex; gap: 12px; align-items: start; margin-bottom: 16px; } .section-head { display: flex; gap: 12px; align-items: start; margin-bottom: 16px; }
.section-head > span { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 8px; color: white; background: var(--blue); font-weight: 850; flex: 0 0 auto; } .section-head > span { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 8px; color: white; background: linear-gradient(135deg, var(--blue), #37a7ff); font-weight: 850; flex: 0 0 auto; }
.section-head p { margin: 4px 0 0; } .section-head p { margin: 4px 0 0; }
.field-grid { display: grid; grid-template-columns: repeat(2, minmax(220px, 1fr)); gap: 14px; } .field-grid { display: grid; grid-template-columns: repeat(2, minmax(220px, 1fr)); gap: 14px; }
.raw-grid { display: grid; grid-template-columns: repeat(2, minmax(220px, 1fr)); gap: 12px; margin-bottom: 16px; } .raw-grid { display: grid; grid-template-columns: repeat(2, minmax(220px, 1fr)); gap: 12px; margin-bottom: 16px; }
.mini-card { border: 1px solid var(--line); background: var(--soft); border-radius: 8px; padding: 14px; margin: 12px 0; } .mini-card, .raw-item { border: 1px solid var(--line); background: linear-gradient(180deg, var(--panel-strong), var(--soft)); border-radius: 8px; padding: 14px; margin: 12px 0; }
.mini-title { font-weight: 850; margin-bottom: 12px; } .mini-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; font-weight: 850; margin-bottom: 12px; }
.add-row { margin-top: 12px; max-width: 340px; } .add-row { margin-top: 12px; display: grid; grid-template-columns: minmax(220px, 340px) auto; align-items: end; gap: 12px; }
label { display: grid; gap: 6px; color: #253149; font-weight: 750; } label { display: grid; gap: 6px; color: #d8e2f2; font-weight: 750; }
input, select { width: 100%%; box-sizing: border-box; padding: 10px 12px; border: 1px solid #c7d1df; border-radius: 6px; background: white; color: var(--ink); font: inherit; } input, select { width: 100%; box-sizing: border-box; padding: 10px 12px; border: 1px solid #334764; border-radius: 6px; background: var(--field); color: var(--ink); font: inherit; }
input:focus, select:focus { outline: 3px solid rgba(31,102,209,.18); border-color: var(--blue); } input[readonly] { color: #aebbd0; background: #0d1728; }
.check { display: flex; align-items: center; gap: 10px; min-height: 42px; border: 1px solid #c7d1df; border-radius: 6px; padding: 0 12px; background: white; } input::placeholder { color: #62738d; }
input:focus, select:focus { outline: 3px solid rgba(91,140,255,.22); border-color: var(--blue); }
.check { display: flex; align-items: center; gap: 10px; min-height: 42px; border: 1px solid #334764; border-radius: 6px; padding: 0 12px; background: var(--field); }
.check input { width: 18px; height: 18px; } .check input { width: 18px; height: 18px; }
.alert { padding: 13px 15px; border-radius: 8px; margin: 14px 0; border: 1px solid #e3c16c; background: #fff8dd; color: #51370a; font-weight: 700; } .alert { padding: 13px 15px; border-radius: 8px; margin: 14px 0; border: 1px solid #725c27; background: #251d0d; color: #f7d47f; font-weight: 700; }
.muted { color: var(--muted); } .muted { color: var(--muted); }
.action-bar { position: sticky; bottom: 12px; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; padding: 14px; } .action-bar { position: sticky; bottom: 12px; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; padding: 14px; backdrop-filter: blur(10px); }
button { border: 0; border-radius: 6px; padding: 11px 15px; font: inherit; font-weight: 850; background: var(--blue); color: white; cursor: pointer; } button { border: 0; border-radius: 6px; padding: 11px 15px; font: inherit; font-weight: 850; background: linear-gradient(135deg, var(--blue), #37a7ff); color: white; cursor: pointer; }
button.secondary { background: #39465c; } button.secondary { background: #22314a; color: #dbe7fb; border: 1px solid #344865; }
.action-bar a, .login-shell a { color: var(--blue); font-weight: 800; text-decoration: none; } button.danger { background: #35141d; color: #ffd8dd; border: 1px solid #653040; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; color: #33415c; } .icon-button { width: 30px; height: 30px; padding: 0; display: grid; place-items: center; border-radius: 6px; flex: 0 0 auto; }
.action-bar a, .login-shell a { color: #8db2ff; font-weight: 800; text-decoration: none; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; color: #b8c8e3; }
.login-shell { max-width: 450px; margin: 76px auto; padding: 28px; } .login-shell { max-width: 450px; margin: 76px auto; padding: 28px; }
.brand-mark { width: 46px; height: 46px; display: grid; place-items: center; background: #132238; color: white; border-radius: 8px; font-weight: 900; margin-bottom: 18px; } .brand-mark { width: 46px; height: 46px; display: grid; place-items: center; background: linear-gradient(135deg, var(--blue), var(--teal)); color: white; border-radius: 8px; font-weight: 900; margin-bottom: 18px; }
.stack { display: grid; gap: 14px; } .stack { display: grid; gap: 14px; }
@media (max-width: 880px) { @media (max-width: 880px) {
main { width: min(100%% - 20px, 1180px); margin: 10px auto 24px; } main { width: min(100% - 20px, 1180px); margin: 10px auto 24px; }
.hero { grid-template-columns: 1fr; padding: 18px; } .hero { grid-template-columns: 1fr; padding: 18px; }
.layout { grid-template-columns: 1fr; } .layout { grid-template-columns: 1fr; }
.steps { position: static; grid-template-columns: repeat(2, minmax(0, 1fr)); } .steps { position: static; grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -668,9 +835,9 @@ public final class WebWizardServer implements AutoCloseable {
} }
</style> </style>
</head> </head>
<body><main>%s</main></body> <body><main>__BODY__</main></body>
</html> </html>
""".formatted(html(title), body); """.replace("__TITLE__", html(title)).replace("__BODY__", body);
} }
private boolean isAuthenticated(HttpExchange exchange) { private boolean isAuthenticated(HttpExchange exchange) {
@@ -843,6 +1010,22 @@ public final class WebWizardServer implements AutoCloseable {
return host; return host;
} }
private static String displayUriHost(String host) {
if ("0.0.0.0".equals(host)) {
return "127.0.0.1";
}
if ("::".equals(host) || "0:0:0:0:0:0:0:0".equals(host)) {
return "[::1]";
}
return uriHost(host);
}
private static boolean isWildcardHost(String host) {
return "0.0.0.0".equals(host)
|| "::".equals(host)
|| "0:0:0:0:0:0:0:0".equals(host);
}
private record WizardSettings(String bindHost, int port) { private record WizardSettings(String bindHost, int port) {
private static WizardSettings load(Path configPath) throws IOException { private static WizardSettings load(Path configPath) throws IOException {
Properties properties = loadProperties(configPath); Properties properties = loadProperties(configPath);
Binary file not shown.