Added Webwizard
This commit is contained in:
@@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'");
|
||||
}
|
||||
|
||||
private static String attr(String value) {
|
||||
return html(value);
|
||||
}
|
||||
|
||||
private static void addNoStoreHeaders(HttpExchange exchange) {
|
||||
exchange.getResponseHeaders().set("Cache-Control", "no-store");
|
||||
exchange.getResponseHeaders().set("X-Content-Type-Options", "nosniff");
|
||||
}
|
||||
|
||||
private static void sendHtml(HttpExchange exchange, int statusCode, String html) throws IOException {
|
||||
byte[] bytes = html.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().set("Content-Type", "text/html; charset=utf-8");
|
||||
exchange.sendResponseHeaders(statusCode, bytes.length);
|
||||
try (OutputStream output = exchange.getResponseBody()) {
|
||||
output.write(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
private static void sendText(HttpExchange exchange, int statusCode, String text) throws IOException {
|
||||
byte[] bytes = text.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8");
|
||||
exchange.sendResponseHeaders(statusCode, bytes.length);
|
||||
try (OutputStream output = exchange.getResponseBody()) {
|
||||
output.write(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
private static void redirect(HttpExchange exchange, String location) throws IOException {
|
||||
exchange.getResponseHeaders().set("Location", location);
|
||||
exchange.sendResponseHeaders(303, -1);
|
||||
exchange.close();
|
||||
}
|
||||
|
||||
private void handleError(HttpExchange exchange, Exception e) throws IOException {
|
||||
logger.log(Level.WARNING, "Web Wizard request failed", e);
|
||||
if (exchange.getResponseCode() == -1) {
|
||||
sendHtml(exchange, 500, page("Error", "<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
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user