Added multi server support

This commit is contained in:
2026-06-23 18:15:37 -04:00
parent 640d4f45f2
commit 4414dce4e4
32 changed files with 543 additions and 207 deletions
@@ -74,6 +74,7 @@ public final class PluginRuntime {
lines.add("Node: " + status.nodeName());
lines.add("Agent: " + (status.running() ? "running" : "stopped"));
lines.add("Tunnel: " + (status.tunnelConnected() ? "connected" : "not connected"));
lines.add("Connected tunnels: " + status.connectedTunnels());
lines.add("Active streams: " + status.activeStreams());
lines.add("TLS: " + (status.tlsEnabled() ? "enabled" : "disabled"));
lines.add("Last event: " + status.lastEvent());
@@ -99,6 +100,10 @@ public final class PluginRuntime {
lines.add("Config: valid");
lines.add("Role: " + lower(loaded.role()));
lines.add("TLS: " + (loaded.tls().enabled() ? "enabled" : "disabled"));
lines.add("Routes: " + loaded.routes().size());
if (loaded.role() == AgentConfig.Role.BACKEND) {
lines.add("Frontend endpoints: " + loaded.frontends().size());
}
if (!loaded.tls().enabled()) {
lines.add("Security: TLS is disabled. This is okay for local testing, not ideal for paid production use.");
@@ -136,14 +141,19 @@ public final class PluginRuntime {
return new BackendAgent(config, this::saveLearnedTlsFingerprint);
}
private void saveLearnedTlsFingerprint(String fingerprint) {
private void saveLearnedTlsFingerprint(String endpointName, String fingerprint) {
if (configPath == null || fingerprint == null || fingerprint.isBlank()) {
return;
}
try {
ConfigFileEditor.setProperty(configPath, "tunnel.tls.pinnedCertificateSha256", fingerprint);
ConfigFileEditor.setProperty(configPath,
"frontend." + endpointName + ".tls.pinnedCertificateSha256",
fingerprint);
if (config != null && config.frontends().size() == 1) {
ConfigFileEditor.setProperty(configPath, "tunnel.tls.pinnedCertificateSha256", fingerprint);
}
config = AgentConfigIO.load(configPath);
logger.info("Saved frontend TLS certificate pin to " + configPath);
logger.info("Saved frontend TLS certificate pin for " + endpointName + " to " + configPath);
} catch (Exception e) {
logger.log(Level.WARNING, "Unable to save frontend TLS certificate pin", e);
}
@@ -26,7 +26,7 @@ import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import java.util.function.BiConsumer;
import java.util.logging.Level;
import java.util.logging.Logger;
@@ -34,21 +34,21 @@ public final class BackendAgent implements ManagedAgent {
private static final Logger LOGGER = Logger.getLogger(BackendAgent.class.getName());
private final AgentConfig config;
private final Consumer<String> learnedTlsFingerprintConsumer;
private final BiConsumer<String, String> learnedTlsFingerprintConsumer;
private final ExecutorService executor = Executors.newThreadPerTaskExecutor(
new NamedThreadFactory("proxylink-backend", true)
);
private final AtomicBoolean running = new AtomicBoolean(false);
private final AtomicReference<String> learnedTlsFingerprint = new AtomicReference<>("");
private final Map<String, String> learnedTlsFingerprints = new ConcurrentHashMap<>();
private final Map<String, BackendTunnel> activeTunnels = new ConcurrentHashMap<>();
private final AtomicReference<String> lastEvent = new AtomicReference<>("not started");
private volatile BackendTunnel activeTunnel;
public BackendAgent(AgentConfig config) {
this(config, ignored -> {
this(config, (ignoredEndpoint, ignoredFingerprint) -> {
});
}
public BackendAgent(AgentConfig config, Consumer<String> learnedTlsFingerprintConsumer) {
public BackendAgent(AgentConfig config, BiConsumer<String, String> learnedTlsFingerprintConsumer) {
this.config = config;
this.learnedTlsFingerprintConsumer = learnedTlsFingerprintConsumer;
}
@@ -58,22 +58,30 @@ public final class BackendAgent implements ManagedAgent {
if (!running.compareAndSet(false, true)) {
return;
}
executor.execute(this::runReconnectLoop);
lastEvent.set("connecting to frontend " + config.tunnel().connectHost() + ":" + config.tunnel().connectPort());
LOGGER.info(() -> "Backend agent connecting to frontend "
+ config.tunnel().connectHost() + ":" + config.tunnel().connectPort());
for (AgentConfig.FrontendEndpointConfig frontend : config.frontends()) {
executor.execute(() -> runReconnectLoop(frontend));
}
lastEvent.set("connecting to " + config.frontends().size() + " frontend endpoint(s)");
LOGGER.info(() -> "Backend agent connecting to " + config.frontends().size() + " frontend endpoint(s)");
}
@Override
public TunnelStatus status() {
BackendTunnel tunnel = activeTunnel;
boolean connected = running.get() && tunnel != null && tunnel.isOpen();
int activeStreams = connected ? tunnel.streamCount() : 0;
int connectedTunnels = 0;
int activeStreams = 0;
for (BackendTunnel tunnel : activeTunnels.values()) {
if (tunnel.isOpen()) {
connectedTunnels++;
activeStreams += tunnel.streamCount();
}
}
boolean connected = running.get() && connectedTunnels > 0;
return new TunnelStatus(
config.role(),
config.nodeName(),
running.get(),
connected,
connectedTunnels,
activeStreams,
config.tls().enabled(),
lastEvent.get()
@@ -85,76 +93,94 @@ public final class BackendAgent implements ManagedAgent {
if (!running.compareAndSet(true, false)) {
return;
}
BackendTunnel tunnel = activeTunnel;
if (tunnel != null) {
for (BackendTunnel tunnel : activeTunnels.values()) {
tunnel.close("backend shutting down");
}
activeTunnels.clear();
executor.shutdownNow();
lastEvent.set("backend stopped");
LOGGER.info("Backend agent stopped");
}
private void runReconnectLoop() {
private void runReconnectLoop(AgentConfig.FrontendEndpointConfig frontend) {
long delayMillis = config.tunnel().reconnectInitialMillis();
while (running.get()) {
try {
runSingleConnection();
runSingleConnection(frontend);
delayMillis = config.tunnel().reconnectInitialMillis();
} catch (Exception e) {
if (running.get()) {
lastEvent.set("connection failed: " + e.getMessage());
LOGGER.log(Level.WARNING, "Backend tunnel connection failed", e);
lastEvent.set("connection to " + frontend.name() + " failed: " + e.getMessage());
LOGGER.log(Level.WARNING, "Backend tunnel connection to " + frontend.name() + " failed", e);
}
}
if (running.get()) {
sleepWithBackoff(delayMillis);
sleepWithBackoff(frontend, delayMillis);
delayMillis = Math.min(delayMillis * 2, config.tunnel().reconnectMaxMillis());
}
}
}
private void runSingleConnection() throws IOException, GeneralSecurityException {
Socket socket = TlsSocketFactory.createClientSocket(connectionConfig(), this::learnTlsFingerprint);
FrameCodec codec = new FrameCodec(socket.getInputStream(), socket.getOutputStream());
authenticateToFrontend(codec);
private void runSingleConnection(AgentConfig.FrontendEndpointConfig frontend)
throws IOException, GeneralSecurityException {
Socket socket = null;
boolean handedToTunnel = false;
try {
AgentConfig.FrontendEndpointConfig pinnedFrontend = frontendWithLearnedPin(frontend);
socket = TlsSocketFactory.createClientSocket(
config,
pinnedFrontend,
fingerprint -> learnTlsFingerprint(frontend, fingerprint)
);
FrameCodec codec = new FrameCodec(socket.getInputStream(), socket.getOutputStream());
authenticateToFrontend(codec);
BackendTunnel tunnel = new BackendTunnel(socket, codec);
activeTunnel = tunnel;
lastEvent.set("authenticated to frontend");
LOGGER.info(() -> "Backend tunnel authenticated to "
+ config.tunnel().connectHost() + ":" + config.tunnel().connectPort());
tunnel.runReadLoop();
BackendTunnel tunnel = new BackendTunnel(frontend, socket, codec);
BackendTunnel previous = activeTunnels.put(frontend.name(), tunnel);
if (previous != null) {
previous.close("replaced by a new authenticated tunnel to " + frontend.name());
}
handedToTunnel = true;
lastEvent.set("authenticated to frontend " + frontend.name());
LOGGER.info(() -> "Backend tunnel authenticated to " + frontend.name()
+ " at " + frontend.connectHost() + ":" + frontend.connectPort());
tunnel.runReadLoop();
} finally {
if (!handedToTunnel && socket != null) {
try {
socket.close();
} catch (IOException ignored) {
}
}
}
}
private AgentConfig connectionConfig() {
String pin = learnedTlsFingerprint.get();
if (pin.isBlank()) {
return config;
private AgentConfig.FrontendEndpointConfig frontendWithLearnedPin(AgentConfig.FrontendEndpointConfig frontend) {
if (!frontend.pinnedCertificateSha256().isBlank()) {
return frontend;
}
AgentConfig.TlsConfig tls = config.tls();
AgentConfig.TlsConfig pinnedTls = new AgentConfig.TlsConfig(
tls.enabled(),
tls.keyStorePath(),
tls.keyStorePassword(),
tls.trustStorePath(),
tls.trustStorePassword(),
tls.requireClientAuth(),
tls.autoGenerate(),
tls.trustOnFirstUse(),
String pin = learnedTlsFingerprints.getOrDefault(frontend.name(), "");
if (pin.isBlank()) {
return frontend;
}
return new AgentConfig.FrontendEndpointConfig(
frontend.name(),
frontend.connectHost(),
frontend.connectPort(),
pin
);
return new AgentConfig(config.role(), config.nodeName(), config.tunnel(), pinnedTls, config.routes());
}
private void learnTlsFingerprint(String fingerprint) {
private void learnTlsFingerprint(AgentConfig.FrontendEndpointConfig frontend, String fingerprint) {
if (fingerprint == null || fingerprint.isBlank()) {
return;
}
if (learnedTlsFingerprint.compareAndSet("", fingerprint)) {
learnedTlsFingerprintConsumer.accept(fingerprint);
lastEvent.set("pinned frontend TLS certificate");
LOGGER.info("Pinned frontend TLS certificate fingerprint: " + fingerprint);
if (learnedTlsFingerprints.putIfAbsent(frontend.name(), fingerprint) == null) {
learnedTlsFingerprintConsumer.accept(frontend.name(), fingerprint);
lastEvent.set("pinned frontend TLS certificate for " + frontend.name());
LOGGER.info("Pinned frontend TLS certificate fingerprint for " + frontend.name() + ": " + fingerprint);
}
}
@@ -189,10 +215,10 @@ public final class BackendAgent implements ManagedAgent {
}
}
private void sleepWithBackoff(long delayMillis) {
private void sleepWithBackoff(AgentConfig.FrontendEndpointConfig frontend, long delayMillis) {
long jitter = ThreadLocalRandom.current().nextLong(0, Math.max(1, delayMillis / 4));
long sleepMillis = delayMillis + jitter;
LOGGER.info(() -> "Reconnecting backend tunnel in " + sleepMillis + "ms");
LOGGER.info(() -> "Reconnecting backend tunnel to " + frontend.name() + " in " + sleepMillis + "ms");
try {
Thread.sleep(sleepMillis);
} catch (InterruptedException e) {
@@ -201,6 +227,7 @@ public final class BackendAgent implements ManagedAgent {
}
private final class BackendTunnel {
private final AgentConfig.FrontendEndpointConfig frontend;
private final Socket socket;
private final FrameCodec codec;
private final AtomicBoolean open = new AtomicBoolean(true);
@@ -210,7 +237,8 @@ public final class BackendAgent implements ManagedAgent {
);
private volatile long lastReadMillis = System.currentTimeMillis();
private BackendTunnel(Socket socket, FrameCodec codec) {
private BackendTunnel(AgentConfig.FrontendEndpointConfig frontend, Socket socket, FrameCodec codec) {
this.frontend = frontend;
this.socket = socket;
this.codec = codec;
}
@@ -236,13 +264,11 @@ public final class BackendAgent implements ManagedAgent {
}
} catch (Exception e) {
if (open.get()) {
LOGGER.log(Level.WARNING, "Backend tunnel read loop stopped", e);
LOGGER.log(Level.WARNING, "Backend tunnel read loop stopped for frontend=" + frontend.name(), e);
}
} finally {
close("frontend tunnel disconnected");
if (activeTunnel == this) {
activeTunnel = null;
}
activeTunnels.remove(frontend.name(), this);
}
}
@@ -266,8 +292,8 @@ public final class BackendAgent implements ManagedAgent {
stream.closeSocket();
}
streams.clear();
lastEvent.set("closed backend tunnel: " + reason);
LOGGER.info(() -> "Closed backend tunnel reason=" + reason);
lastEvent.set("closed backend tunnel to " + frontend.name() + ": " + reason);
LOGGER.info(() -> "Closed backend tunnel to " + frontend.name() + " reason=" + reason);
}
private void scheduleHeartbeat() {
@@ -283,7 +309,7 @@ public final class BackendAgent implements ManagedAgent {
return;
}
if (idleMillis > interval * 2) {
lastEvent.set("heartbeat delayed " + idleMillis + "ms; tunnel still open");
lastEvent.set("heartbeat delayed " + idleMillis + "ms for " + frontend.name() + "; tunnel still open");
}
try {
codec.writeFrame(Frame.empty(FrameType.PING, 0));
@@ -40,7 +40,7 @@ public final class FrontendAgent implements ManagedAgent {
new NamedThreadFactory("proxylink-frontend", true)
);
private final AtomicBoolean running = new AtomicBoolean(false);
private final AtomicReference<FrontendTunnel> activeTunnel = new AtomicReference<>();
private final Map<String, FrontendTunnel> activeTunnels = new ConcurrentHashMap<>();
private final AtomicReference<String> lastEvent = new AtomicReference<>("not started");
private final List<ServerSocket> routeServerSockets = new ArrayList<>();
private volatile ServerSocket tunnelServerSocket;
@@ -75,14 +75,21 @@ public final class FrontendAgent implements ManagedAgent {
@Override
public TunnelStatus status() {
FrontendTunnel tunnel = activeTunnel.get();
boolean connected = running.get() && tunnel != null && tunnel.isOpen();
int activeStreams = connected ? tunnel.streamCount() : 0;
int connectedTunnels = 0;
int activeStreams = 0;
for (FrontendTunnel tunnel : activeTunnels.values()) {
if (tunnel.isOpen()) {
connectedTunnels++;
activeStreams += tunnel.streamCount();
}
}
boolean connected = running.get() && connectedTunnels > 0;
return new TunnelStatus(
config.role(),
config.nodeName(),
running.get(),
connected,
connectedTunnels,
activeStreams,
config.tls().enabled(),
lastEvent.get()
@@ -100,10 +107,10 @@ public final class FrontendAgent implements ManagedAgent {
closeQuietly(serverSocket);
}
FrontendTunnel tunnel = activeTunnel.getAndSet(null);
if (tunnel != null) {
for (FrontendTunnel tunnel : activeTunnels.values()) {
tunnel.close("frontend shutting down");
}
activeTunnels.clear();
executor.shutdownNow();
lastEvent.set("frontend stopped");
@@ -130,7 +137,7 @@ public final class FrontendAgent implements ManagedAgent {
FrameCodec codec = new FrameCodec(socket.getInputStream(), socket.getOutputStream());
AuthPayloads.Hello hello = authenticateBackend(codec);
FrontendTunnel tunnel = new FrontendTunnel(socket, codec, hello.nodeName());
FrontendTunnel previous = activeTunnel.getAndSet(tunnel);
FrontendTunnel previous = activeTunnels.put(hello.nodeName(), tunnel);
if (previous != null) {
previous.close("replaced by a new authenticated backend tunnel");
}
@@ -186,10 +193,10 @@ public final class FrontendAgent implements ManagedAgent {
playerSocket.setTcpNoDelay(true);
playerSocket.setKeepAlive(true);
FrontendTunnel tunnel = activeTunnel.get();
FrontendTunnel tunnel = selectTunnelForRoute(route);
if (tunnel == null || !tunnel.isOpen()) {
LOGGER.warning(() -> "Rejecting player connection for route " + route.name()
+ " because no backend tunnel is authenticated");
+ " because no matching backend tunnel is authenticated");
closeQuietly(playerSocket);
continue;
}
@@ -203,8 +210,34 @@ public final class FrontendAgent implements ManagedAgent {
}
}
private FrontendTunnel selectTunnelForRoute(AgentConfig.RouteConfig route) {
if (!route.backendNode().isBlank()) {
return activeTunnels.get(route.backendNode());
}
FrontendTunnel selected = null;
int openTunnels = 0;
for (FrontendTunnel tunnel : activeTunnels.values()) {
if (!tunnel.isOpen()) {
continue;
}
selected = tunnel;
openTunnels++;
}
if (openTunnels == 1) {
return selected;
}
if (openTunnels > 1) {
lastEvent.set("route " + route.name() + " is ambiguous; set route." + route.name() + ".backendNode");
LOGGER.warning(() -> "Route " + route.name()
+ " has no backendNode but multiple backend tunnels are connected");
}
return null;
}
private void clearActiveTunnel(FrontendTunnel tunnel) {
activeTunnel.compareAndSet(tunnel, null);
activeTunnels.remove(tunnel.backendNodeName, tunnel);
}
private static void closeQuietly(ServerSocket serverSocket) {
@@ -62,13 +62,27 @@ final class TlsSocketFactory {
static Socket createClientSocket(AgentConfig config, Consumer<String> learnedFingerprintConsumer)
throws IOException, GeneralSecurityException {
AgentConfig.FrontendEndpointConfig endpoint = new AgentConfig.FrontendEndpointConfig(
"default",
config.tunnel().connectHost(),
config.tunnel().connectPort(),
config.tls().pinnedCertificateSha256()
);
return createClientSocket(config, endpoint, learnedFingerprintConsumer);
}
static Socket createClientSocket(
AgentConfig config,
AgentConfig.FrontendEndpointConfig endpoint,
Consumer<String> learnedFingerprintConsumer
) throws IOException, GeneralSecurityException {
Socket socket;
PinnedCertificateTrustManager pinningTrustManager = null;
if (!config.tls().enabled()) {
socket = SocketFactory.getDefault().createSocket();
} else {
pinningTrustManager = pinningTrustManager(config);
pinningTrustManager = pinningTrustManager(config, endpoint);
SSLContext context = sslContext(
config.tls().keyStorePath(),
config.tls().keyStorePassword(),
@@ -82,7 +96,7 @@ final class TlsSocketFactory {
socket.setTcpNoDelay(true);
socket.setKeepAlive(true);
socket.connect(
new InetSocketAddress(config.tunnel().connectHost(), config.tunnel().connectPort()),
new InetSocketAddress(endpoint.connectHost(), endpoint.connectPort()),
config.tunnel().connectTimeoutMillis()
);
@@ -117,10 +131,16 @@ final class TlsSocketFactory {
return context;
}
private static PinnedCertificateTrustManager pinningTrustManager(AgentConfig config) {
if (!config.tls().pinnedCertificateSha256().isBlank() || config.tls().trustOnFirstUse()) {
private static PinnedCertificateTrustManager pinningTrustManager(
AgentConfig config,
AgentConfig.FrontendEndpointConfig endpoint
) {
String pinnedCertificate = endpoint.pinnedCertificateSha256().isBlank()
? config.tls().pinnedCertificateSha256()
: endpoint.pinnedCertificateSha256();
if (!pinnedCertificate.isBlank() || config.tls().trustOnFirstUse()) {
return new PinnedCertificateTrustManager(
config.tls().pinnedCertificateSha256(),
pinnedCertificate,
config.tls().trustOnFirstUse()
);
}
@@ -7,11 +7,12 @@ public record TunnelStatus(
String nodeName,
boolean running,
boolean tunnelConnected,
int connectedTunnels,
int activeStreams,
boolean tlsEnabled,
String lastEvent
) {
public static TunnelStatus stopped(AgentConfig.Role role, String nodeName, boolean tlsEnabled, String lastEvent) {
return new TunnelStatus(role, nodeName, false, false, 0, tlsEnabled, lastEvent);
return new TunnelStatus(role, nodeName, false, false, 0, 0, tlsEnabled, lastEvent);
}
}
@@ -1,6 +1,7 @@
# DirtSimpleP2P Bungee/Waterfall frontend config.
# Put this same jar in the Bungee plugins folder.
# Bungee should point its backend server entry at route.minecraft.frontendBindHost:route.minecraft.frontendBindPort.
# Add more routes when this proxy should reach multiple backend servers.
role=frontend
node.name=bungee-frontend
@@ -31,3 +32,13 @@ tunnel.tls.requireClientAuth=false
routes=minecraft
route.minecraft.frontendBindHost=127.0.0.1
route.minecraft.frontendBindPort=25566
route.minecraft.backendNode=paper-backend
# Multi-backend example:
# routes=minecraft,lobby
# route.minecraft.frontendBindHost=127.0.0.1
# route.minecraft.frontendBindPort=25566
# route.minecraft.backendNode=survival-backend
# route.lobby.frontendBindHost=127.0.0.1
# route.lobby.frontendBindPort=25567
# route.lobby.backendNode=lobby-backend
@@ -5,8 +5,20 @@
role=backend
node.name=paper-backend
tunnel.connectHost=YOUR_BUNGEE_OR_VPS_IP
tunnel.connectPort=24445
frontends=proxy1
frontend.proxy1.connectHost=YOUR_BUNGEE_OR_VPS_IP
frontend.proxy1.connectPort=24445
frontend.proxy1.tls.pinnedCertificateSha256=
# Multi-proxy example:
# frontends=proxy1,proxy2
# frontend.proxy1.connectHost=proxy1.example.com
# frontend.proxy1.connectPort=24445
# frontend.proxy1.tls.pinnedCertificateSha256=
# frontend.proxy2.connectHost=proxy2.example.com
# frontend.proxy2.connectPort=24445
# frontend.proxy2.tls.pinnedCertificateSha256=
# Replaced automatically on first start. Paste the same value from the Bungee config here.
tunnel.authToken=AUTO_GENERATED_ON_FIRST_START
@@ -24,7 +36,6 @@ tunnel.reconnectMaxMillis=10000
tunnel.tls.enabled=false
tunnel.tls.allowInsecure=true
tunnel.tls.trustOnFirstUse=true
tunnel.tls.pinnedCertificateSha256=
routes=minecraft
route.minecraft.backendTargetHost=127.0.0.1
Binary file not shown.
@@ -1,6 +1,7 @@
# DirtSimpleP2P Bungee/Waterfall frontend config.
# Put this same jar in the Bungee plugins folder.
# Bungee should point its backend server entry at route.minecraft.frontendBindHost:route.minecraft.frontendBindPort.
# Add more routes when this proxy should reach multiple backend servers.
role=frontend
node.name=bungee-frontend
@@ -31,3 +32,13 @@ tunnel.tls.requireClientAuth=false
routes=minecraft
route.minecraft.frontendBindHost=127.0.0.1
route.minecraft.frontendBindPort=25566
route.minecraft.backendNode=paper-backend
# Multi-backend example:
# routes=minecraft,lobby
# route.minecraft.frontendBindHost=127.0.0.1
# route.minecraft.frontendBindPort=25566
# route.minecraft.backendNode=survival-backend
# route.lobby.frontendBindHost=127.0.0.1
# route.lobby.frontendBindPort=25567
# route.lobby.backendNode=lobby-backend
+14 -3
View File
@@ -5,8 +5,20 @@
role=backend
node.name=paper-backend
tunnel.connectHost=YOUR_BUNGEE_OR_VPS_IP
tunnel.connectPort=24445
frontends=proxy1
frontend.proxy1.connectHost=YOUR_BUNGEE_OR_VPS_IP
frontend.proxy1.connectPort=24445
frontend.proxy1.tls.pinnedCertificateSha256=
# Multi-proxy example:
# frontends=proxy1,proxy2
# frontend.proxy1.connectHost=proxy1.example.com
# frontend.proxy1.connectPort=24445
# frontend.proxy1.tls.pinnedCertificateSha256=
# frontend.proxy2.connectHost=proxy2.example.com
# frontend.proxy2.connectPort=24445
# frontend.proxy2.tls.pinnedCertificateSha256=
# Replaced automatically on first start. Paste the same value from the Bungee config here.
tunnel.authToken=AUTO_GENERATED_ON_FIRST_START
@@ -24,7 +36,6 @@ tunnel.reconnectMaxMillis=10000
tunnel.tls.enabled=false
tunnel.tls.allowInsecure=true
tunnel.tls.trustOnFirstUse=true
tunnel.tls.pinnedCertificateSha256=
routes=minecraft
route.minecraft.backendTargetHost=127.0.0.1