Added Webwizard

This commit is contained in:
2026-06-23 18:44:29 -04:00
parent 4414dce4e4
commit 6c7632707f
21 changed files with 1046 additions and 7 deletions
+55
View File
@@ -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: <temporary-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.
Binary file not shown.
@@ -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<String> toggleWebWizardLines() {
List<String> 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();
}
}
@@ -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");
}
}
}
@@ -12,7 +12,7 @@ import java.util.List;
import java.util.Locale;
final class DirtSimplePaperCommand implements CommandExecutor, TabCompleter {
private static final List<String> SUBCOMMANDS = List.of("status", "doctor");
private static final List<String> 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;
}
@@ -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<String> 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<String> 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<String> 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<String> 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<String, List<String>> 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<String, List<String>> 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<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);
}
}
List<String> 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<String, List<String>> form, String key) throws IOException {
ConfigFileEditor.setProperty(configPath, key, first(form, key));
}
private void saveNamed(Map<String, List<String>> form, String key) throws IOException {
ConfigFileEditor.setProperty(configPath, key, first(form, key));
}
private void saveBoolean(Map<String, List<String>> form, String key) throws IOException {
ConfigFileEditor.setProperty(configPath, key, Boolean.toString(form.containsKey(key)));
}
private String loginPage(String message) {
return page("Sign In", """
<section class="login-shell">
<div class="brand-mark">DS</div>
<h1>DirtSimpleP2P Control Center</h1>
<p>Enter the temporary setup key printed in the server console.</p>
%s
<form method="post" action="/login" class="stack">
<label>Login key<input name="key" autocomplete="off" autofocus></label>
<button type="submit">Sign In</button>
</form>
</section>
""".formatted(alert(message)));
}
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)));
List<String> 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("""
<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", "")
));
}
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>
<div class="field-grid">
%s
%s
%s
%s
%s
</div>
</div>
""".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", """
<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) {
return """
<label>Mode
<select name="role">
<option value="frontend"%s>Proxy / BungeeCord / Waterfall</option>
<option value="backend"%s>Backend / Paper / Spigot</option>
</select>
</label>
""".formatted(selected(role, "frontend"), selected(role, "backend"));
}
private static String textInput(Properties properties, String key, String label, String placeholder) {
return """
<label>%s<input name="%s" value="%s" placeholder="%s"></label>
""".formatted(html(label), attr(key), attr(value(properties, key, "")), attr(placeholder));
}
private static String hiddenText(String key, String value) {
return "<input type=\"hidden\" name=\"" + attr(key) + "\" value=\"" + attr(value) + "\">";
}
private static String checkbox(Properties properties, String key, String label) {
String checked = Boolean.parseBoolean(value(properties, key, "false")) ? " checked" : "";
return """
<label class="check"><input type="checkbox" name="%s" value="true"%s><span>%s</span></label>
""".formatted(attr(key), checked, html(label));
}
private static String advancedRows(Properties properties, Set<String> guidedKeys) {
List<String> keys = new ArrayList<>(properties.stringPropertyNames());
keys.removeIf(guidedKeys::contains);
keys.sort(Comparator.naturalOrder());
if (keys.isEmpty()) {
return "<p class=\"muted\">No extra settings found.</p>";
}
StringBuilder rows = new StringBuilder("<div class=\"raw-grid\">");
for (String key : keys) {
rows.append("<label><code>").append(html(key)).append("</code>")
.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>");
}
rows.append("</div>");
return rows.toString();
}
private static String page(String title, String body) {
return """
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DirtSimpleP2P - %s</title>
<style>
:root {
color-scheme: light;
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;
}
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; }
.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 { 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 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); }
.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 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; }
.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; }
.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; }
.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; }
.stack { display: grid; gap: 14px; }
@media (max-width: 880px) {
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)); }
.field-grid, .raw-grid { grid-template-columns: 1fr; }
h1 { font-size: 26px; }
}
</style>
</head>
<body><main>%s</main></body>
</html>
""".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<String, List<String>> 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<String, List<String>> 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<String> namesFrom(String raw) {
List<String> 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<String, List<String>> form, String key) {
List<String> 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 "<div class=\"alert\">" + html(message) + "</div>";
}
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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;");
}
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", "<section class=\"login-shell\"><h1>Request failed</h1><p>"
+ html(e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage())
+ "</p></section>"));
}
}
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;
}
}
}
}
@@ -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
@@ -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
+2 -2
View File
@@ -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
Binary file not shown.
@@ -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
@@ -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
+2 -2
View File
@@ -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
@@ -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
@@ -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