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.
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
- proxy listener settings
@@ -379,6 +381,8 @@ The wizard provides guided sections for:
- heartbeat and reconnect settings
- 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:
- `Save Config`
Binary file not shown.
@@ -36,4 +36,18 @@ public final class ConfigFileEditor {
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 {
webWizard = WebWizardServer.start(configPath, logger, this::reloadFromConfig);
webWizard = WebWizardServer.start(configPath, logger, this::reloadFromConfig, requiredRole);
lines.add("Web wizard: running");
lines.add("URL: " + webWizard.uri());
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.HttpServer;
import me.proxylink.common.AgentConfig;
import me.proxylink.plugin.ConfigFileEditor;
import java.io.IOException;
@@ -42,6 +43,7 @@ public final class WebWizardServer implements AutoCloseable {
private final Path configPath;
private final Logger logger;
private final Supplier<String> reloadAction;
private final AgentConfig.Role runtimeRole;
private final HttpServer server;
private final ExecutorService executor;
private final String loginKey;
@@ -53,6 +55,7 @@ public final class WebWizardServer implements AutoCloseable {
Path configPath,
Logger logger,
Supplier<String> reloadAction,
AgentConfig.Role runtimeRole,
HttpServer server,
ExecutorService executor,
String loginKey,
@@ -62,6 +65,7 @@ public final class WebWizardServer implements AutoCloseable {
this.configPath = configPath;
this.logger = logger;
this.reloadAction = reloadAction;
this.runtimeRole = runtimeRole;
this.server = server;
this.executor = executor;
this.loginKey = loginKey;
@@ -69,7 +73,12 @@ public final class WebWizardServer implements AutoCloseable {
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);
IOException lastFailure = null;
int firstPort = settings.port();
@@ -78,7 +87,7 @@ public final class WebWizardServer implements AutoCloseable {
for (int attempt = 0; attempt < attempts; attempt++) {
int port = firstPort == 0 ? 0 : firstPort + attempt;
try {
return bind(configPath, logger, reloadAction, settings.bindHost(), port);
return bind(configPath, logger, reloadAction, runtimeRole, settings.bindHost(), port);
} catch (BindException e) {
lastFailure = e;
}
@@ -109,6 +118,7 @@ public final class WebWizardServer implements AutoCloseable {
Path configPath,
Logger logger,
Supplier<String> reloadAction,
AgentConfig.Role runtimeRole,
String bindHost,
int port
) throws IOException {
@@ -118,11 +128,12 @@ public final class WebWizardServer implements AutoCloseable {
);
String loginKey = randomHex(18);
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(
configPath,
logger,
reloadAction,
runtimeRole,
server,
executor,
loginKey,
@@ -138,6 +149,9 @@ public final class WebWizardServer implements AutoCloseable {
server.start();
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("The Web Wizard can edit config.properties. Stop it with /dsp2p webwizard when finished.");
return webWizard;
@@ -214,7 +228,7 @@ public final class WebWizardServer implements AutoCloseable {
if ("reloadOnly".equals(action)) {
message = reloadAction.get();
} else {
saveWizardForm(form);
saveWizardForm(form, action);
if ("generateToken".equals(action)) {
String token = randomHex(32);
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 {
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);
}
}
private void saveWizardForm(Map<String, List<String>> form, String action) throws IOException {
boolean frontendMode = runtimeRole == AgentConfig.Role.FRONTEND;
boolean backendMode = runtimeRole == AgentConfig.Role.BACKEND;
String removeFrontend = actionValue(action, "removeFrontend:");
String removeRoute = actionValue(action, "removeRoute:");
String removeRaw = actionValue(action, "removeRaw:");
boolean removedFrontend = false;
boolean removedRoute = false;
List<String> routeNames = namesFrom(first(form, "routes"));
String newRoute = first(form, "newRouteName").trim();
@@ -252,11 +265,12 @@ public final class WebWizardServer implements AutoCloseable {
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, "tunnel.listenHost");
saveKey(form, "tunnel.listenPort");
saveKey(form, "tunnel.authToken");
saveKey(form, "tunnel.connectTimeoutMillis");
saveKey(form, "tunnel.heartbeatIntervalMillis");
@@ -266,33 +280,74 @@ public final class WebWizardServer implements AutoCloseable {
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");
if (frontendMode) {
saveKey(form, "tunnel.listenHost");
saveKey(form, "tunnel.listenPort");
saveBoolean(form, "tunnel.tls.autoGenerate");
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));
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");
if (frontendMode) {
saveNamed(form, "route." + route + ".frontendBindHost");
saveNamed(form, "route." + route + ".frontendBindPort");
saveNamed(form, "route." + route + ".backendNode");
}
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())) {
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();
@@ -314,6 +369,17 @@ public final class WebWizardServer implements AutoCloseable {
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) {
return page("Sign In", """
<section class="login-shell">
@@ -332,65 +398,284 @@ public final class WebWizardServer implements AutoCloseable {
private String wizardPage(String message) throws IOException {
Properties properties = loadProperties(configPath);
Set<String> guidedKeys = new HashSet<>();
String role = value(properties, "role", "backend");
List<String> frontendNames = namesFrom(value(properties, "frontends", defaultFrontends(properties)));
boolean frontendMode = runtimeRole == AgentConfig.Role.FRONTEND;
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"));
addKnownGuidedKeys(properties, guidedKeys);
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("""
<div class="mini-card">
<div class="mini-title">%s</div>
<div class="field-grid">
%s
%s
%s
</div>
</div>
""".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", "")
));
if (backendMode) {
for (String frontend : frontendNames) {
frontends.append("""
<div class="mini-card">
<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="field-grid">
%s
%s
%s
</div>
</div>
""".formatted(
html(frontend),
attr(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("""
<div class="mini-card">
<div class="mini-title">%s</div>
if (frontendMode) {
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
%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">
%s
%s
%s
%s
%s
</div>
</div>
</section>
""".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")
textInput(properties, "tunnel.listenHost", "Tunnel listen host", "0.0.0.0"),
textInput(properties, "tunnel.listenPort", "Tunnel listen port", "24445")
));
}
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(
"role",
"node.name",
"tunnel.listenHost",
"tunnel.listenPort",
"tunnel.connectHost",
"tunnel.connectPort",
"tunnel.authToken",
"tunnel.connectTimeoutMillis",
"tunnel.heartbeatIntervalMillis",
@@ -405,158 +690,27 @@ public final class WebWizardServer implements AutoCloseable {
"tunnel.tls.keyStorePassword",
"tunnel.tls.requireClientAuth",
"tunnel.tls.trustOnFirstUse",
"tunnel.tls.pinnedCertificateSha256",
"webwizard.bindHost",
"webwizard.port",
"frontends",
"routes"
));
String advancedRows = advancedRows(properties, guidedKeys);
return page("Setup Wizard", """
<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
));
for (String key : properties.stringPropertyNames()) {
if (key.startsWith("frontend.") || key.startsWith("route.")) {
guidedKeys.add(key);
}
}
}
private static String roleSelect(String role) {
private static String roleBadge(String role, String label) {
return """
<label>Mode
<select name="role">
<option value="frontend"%s>Proxy / BungeeCord / Waterfall</option>
<option value="backend"%s>Backend / Paper / Spigot</option>
</select>
<input type="hidden" name="role" value="%s">
<input value="%s" readonly>
</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) {
@@ -586,10 +740,12 @@ public final class WebWizardServer implements AutoCloseable {
StringBuilder rows = new StringBuilder("<div class=\"raw-grid\">");
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 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>");
return rows.toString();
@@ -602,64 +758,75 @@ public final class WebWizardServer implements AutoCloseable {
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DirtSimpleP2P - %s</title>
<title>DirtSimpleP2P - __TITLE__</title>
<style>
:root {
color-scheme: light;
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--ink: #162033;
--muted: #637086;
--line: #d9e0eb;
--panel: #ffffff;
--soft: #f5f7fb;
--blue: #1f66d1;
--teal: #15847a;
--gold: #a86c12;
--ink: #e8edf7;
--muted: #99a7bd;
--line: #26354e;
--panel: #111a2b;
--panel-strong: #162238;
--soft: #0d1524;
--field: #0a1220;
--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); }
main { width: min(1180px, calc(100%% - 32px)); margin: 28px auto 44px; }
body { margin: 0; min-height: 100vh; color: var(--ink); background:
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, .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; }
.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 { font-size: 32px; line-height: 1.1; }
h2 { font-size: 20px; }
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 span, .status-card small { color: #c5d2e4; }
.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: #9db0cb; }
.status-card strong, .status-card small { overflow-wrap: anywhere; }
.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 a { color: #d8e5f6; text-decoration: none; padding: 10px 12px; border-radius: 6px; font-weight: 750; }
.steps a:hover { background: rgba(255,255,255,.10); }
.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: #c8d5ea; text-decoration: none; padding: 10px 12px; border-radius: 6px; font-weight: 750; }
.steps a:hover { background: rgba(91,140,255,.14); color: #ffffff; }
.cards { display: grid; gap: 16px; min-width: 0; }
.panel { padding: 20px; }
.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; }
.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; }
.mini-card { border: 1px solid var(--line); background: var(--soft); border-radius: 8px; padding: 14px; margin: 12px 0; }
.mini-title { font-weight: 850; margin-bottom: 12px; }
.add-row { margin-top: 12px; max-width: 340px; }
label { display: grid; gap: 6px; color: #253149; 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:focus, select:focus { outline: 3px solid rgba(31,102,209,.18); border-color: var(--blue); }
.check { display: flex; align-items: center; gap: 10px; min-height: 42px; border: 1px solid #c7d1df; border-radius: 6px; padding: 0 12px; background: white; }
.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 { display: flex; align-items: center; justify-content: space-between; gap: 12px; font-weight: 850; margin-bottom: 12px; }
.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: #d8e2f2; font-weight: 750; }
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[readonly] { color: #aebbd0; background: #0d1728; }
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; }
.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); }
.action-bar { position: sticky; bottom: 12px; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; padding: 14px; }
button { border: 0; border-radius: 6px; padding: 11px 15px; font: inherit; font-weight: 850; background: var(--blue); color: white; cursor: pointer; }
button.secondary { background: #39465c; }
.action-bar a, .login-shell a { color: var(--blue); font-weight: 800; text-decoration: none; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; color: #33415c; }
.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: linear-gradient(135deg, var(--blue), #37a7ff); color: white; cursor: pointer; }
button.secondary { background: #22314a; color: #dbe7fb; border: 1px solid #344865; }
button.danger { background: #35141d; color: #ffd8dd; border: 1px solid #653040; }
.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; }
.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; }
@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; }
.layout { grid-template-columns: 1fr; }
.steps { position: static; grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -668,9 +835,9 @@ public final class WebWizardServer implements AutoCloseable {
}
</style>
</head>
<body><main>%s</main></body>
<body><main>__BODY__</main></body>
</html>
""".formatted(html(title), body);
""".replace("__TITLE__", html(title)).replace("__BODY__", body);
}
private boolean isAuthenticated(HttpExchange exchange) {
@@ -843,6 +1010,22 @@ public final class WebWizardServer implements AutoCloseable {
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 static WizardSettings load(Path configPath) throws IOException {
Properties properties = loadProperties(configPath);
Binary file not shown.