first commit

This commit is contained in:
2026-06-21 12:44:51 -04:00
commit 640d4f45f2
86 changed files with 3216 additions and 0 deletions
@@ -0,0 +1,39 @@
package me.proxylink.plugin;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
public final class ConfigFileEditor {
private ConfigFileEditor() {
}
public static void setProperty(Path path, String key, String value) throws IOException {
List<String> lines = Files.exists(path)
? Files.readAllLines(path, StandardCharsets.UTF_8)
: new ArrayList<>();
String prefix = key + "=";
boolean updated = false;
for (int i = 0; i < lines.size(); i++) {
String trimmed = lines.get(i).trim();
if (!trimmed.startsWith("#") && trimmed.startsWith(prefix)) {
lines.set(i, prefix + value);
updated = true;
break;
}
}
if (!updated) {
if (!lines.isEmpty() && !lines.get(lines.size() - 1).isBlank()) {
lines.add("");
}
lines.add(prefix + value);
}
Files.write(path, lines, StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,171 @@
package me.proxylink.plugin;
import me.proxylink.common.AgentConfig;
import me.proxylink.common.AgentConfigIO;
import me.proxylink.plugin.tls.TlsBootstrap;
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 java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;
public final class PluginRuntime {
private final Logger logger;
private ManagedAgent agent;
private Path configPath;
private AgentConfig config;
private String lastStartupError = "";
public PluginRuntime(Logger logger) {
this.logger = Objects.requireNonNull(logger, "logger");
}
public boolean start(Path dataFolder, String defaultConfigResource, AgentConfig.Role requiredRole) {
try {
Files.createDirectories(dataFolder);
configPath = dataFolder.resolve("config.properties");
ensureConfigExists(configPath, defaultConfigResource);
SecretTokenBootstrap.prepare(configPath, logger);
config = AgentConfigIO.load(configPath);
config = TlsBootstrap.prepare(dataFolder, configPath, config, logger);
config.validateOrThrow();
if (config.role() != requiredRole) {
throw new IllegalArgumentException("This plugin entrypoint requires role=" + requiredRole.name().toLowerCase()
+ " but config.properties has role=" + config.role().name().toLowerCase());
}
agent = createAgent(config);
agent.start();
lastStartupError = "";
logger.info("DirtSimpleP2P started role=" + config.role().name().toLowerCase()
+ " node=" + config.nodeName());
return true;
} catch (Exception e) {
lastStartupError = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
logger.log(Level.SEVERE, "DirtSimpleP2P did not start. Fix plugins/DirtSimpleP2P/config.properties and restart.", e);
stop();
return false;
}
}
public void stop() {
if (agent != null) {
agent.close();
agent = null;
}
}
public List<String> statusLines() {
TunnelStatus status = status();
List<String> lines = new ArrayList<>();
lines.add("Role: " + lower(status.role()));
lines.add("Node: " + status.nodeName());
lines.add("Agent: " + (status.running() ? "running" : "stopped"));
lines.add("Tunnel: " + (status.tunnelConnected() ? "connected" : "not connected"));
lines.add("Active streams: " + status.activeStreams());
lines.add("TLS: " + (status.tlsEnabled() ? "enabled" : "disabled"));
lines.add("Last event: " + status.lastEvent());
if (!lastStartupError.isBlank()) {
lines.add("Startup error: " + lastStartupError);
}
return lines;
}
public List<String> doctorLines() {
List<String> lines = new ArrayList<>();
lines.add("Checking DirtSimpleP2P...");
if (configPath == null) {
lines.add("Config: not loaded yet");
return lines;
}
lines.add("Config file: " + configPath);
try {
AgentConfig loaded = AgentConfigIO.load(configPath);
loaded.validateOrThrow();
lines.add("Config: valid");
lines.add("Role: " + lower(loaded.role()));
lines.add("TLS: " + (loaded.tls().enabled() ? "enabled" : "disabled"));
if (!loaded.tls().enabled()) {
lines.add("Security: TLS is disabled. This is okay for local testing, not ideal for paid production use.");
} else if (loaded.role() == AgentConfig.Role.FRONTEND && loaded.tls().keyStorePath() != null) {
lines.add("TLS keystore: " + loaded.tls().keyStorePath());
} else if (loaded.role() == AgentConfig.Role.BACKEND && loaded.tls().pinnedCertificateSha256().isBlank()) {
lines.add("TLS pin: not pinned yet. It will be saved after the first successful TLS connection.");
} else if (loaded.role() == AgentConfig.Role.BACKEND) {
lines.add("TLS pin: configured");
}
} catch (Exception e) {
lines.add("Config problem: " + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
}
lines.addAll(statusLines());
return lines;
}
public TunnelStatus status() {
if (agent != null) {
return agent.status();
}
if (config != null) {
return TunnelStatus.stopped(config.role(), config.nodeName(), config.tls().enabled(),
lastStartupError.isBlank() ? "not running" : "startup failed");
}
return TunnelStatus.stopped(AgentConfig.Role.BACKEND, "unknown", false,
lastStartupError.isBlank() ? "not running" : "startup failed");
}
private ManagedAgent createAgent(AgentConfig config) {
if (config.role() == AgentConfig.Role.FRONTEND) {
return new FrontendAgent(config);
}
return new BackendAgent(config, this::saveLearnedTlsFingerprint);
}
private void saveLearnedTlsFingerprint(String fingerprint) {
if (configPath == null || fingerprint == null || fingerprint.isBlank()) {
return;
}
try {
ConfigFileEditor.setProperty(configPath, "tunnel.tls.pinnedCertificateSha256", fingerprint);
config = AgentConfigIO.load(configPath);
logger.info("Saved frontend TLS certificate pin to " + configPath);
} catch (Exception e) {
logger.log(Level.WARNING, "Unable to save frontend TLS certificate pin", e);
}
}
private void ensureConfigExists(Path configPath, String resourceName) throws IOException {
if (Files.exists(configPath)) {
return;
}
try (InputStream input = PluginRuntime.class.getClassLoader().getResourceAsStream(resourceName)) {
if (input == null) {
throw new IOException("Missing bundled config resource: " + resourceName);
}
Files.copy(input, configPath, StandardCopyOption.COPY_ATTRIBUTES);
}
logger.warning("Created default DirtSimpleP2P config at " + configPath
+ ". Edit the secret token and network settings, then restart the server.");
}
private static String lower(AgentConfig.Role role) {
return role.name().toLowerCase(java.util.Locale.ROOT);
}
}
@@ -0,0 +1,53 @@
package me.proxylink.plugin;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.SecureRandom;
import java.util.HexFormat;
import java.util.Locale;
import java.util.Properties;
import java.util.logging.Logger;
final class SecretTokenBootstrap {
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private static final HexFormat HEX = HexFormat.of().withLowerCase();
private SecretTokenBootstrap() {
}
static void prepare(Path configPath, Logger logger) throws IOException {
Properties properties = new Properties();
try (InputStream input = Files.newInputStream(configPath)) {
properties.load(input);
}
String currentToken = properties.getProperty("tunnel.authToken", "");
if (!needsGeneratedToken(currentToken)) {
return;
}
String generatedToken = generateToken();
ConfigFileEditor.setProperty(configPath, "tunnel.authToken", generatedToken);
logger.warning("Generated a new DirtSimpleP2P secret token in " + configPath
+ ". Copy this same token to the other server's DirtSimpleP2P config.");
}
private static String generateToken() {
byte[] bytes = new byte[32];
SECURE_RANDOM.nextBytes(bytes);
return HEX.formatHex(bytes);
}
private static boolean needsGeneratedToken(String value) {
if (value == null || value.isBlank()) {
return true;
}
String normalized = value.toLowerCase(Locale.ROOT);
return normalized.contains("change_me")
|| normalized.contains("replace-with")
|| normalized.contains("placeholder")
|| normalized.contains("auto_generated");
}
}
@@ -0,0 +1,42 @@
package me.proxylink.plugin.bungee;
import me.proxylink.plugin.PluginRuntime;
import net.md_5.bungee.api.ChatColor;
import net.md_5.bungee.api.CommandSender;
import net.md_5.bungee.api.chat.TextComponent;
import net.md_5.bungee.api.plugin.Command;
import java.util.List;
import java.util.Locale;
final class DirtSimpleBungeeCommand extends Command {
private final PluginRuntime runtime;
DirtSimpleBungeeCommand(PluginRuntime runtime) {
super("dsp2p", "dirtsimplep2p.command", "dirtsimplep2p");
this.runtime = runtime;
}
@Override
public void execute(CommandSender sender, String[] args) {
String subcommand = args.length == 0 ? "status" : args[0].toLowerCase(Locale.ROOT);
switch (subcommand) {
case "status" -> sendBlock(sender, "DirtSimpleP2P Status", runtime.statusLines());
case "doctor" -> sendBlock(sender, "DirtSimpleP2P Doctor", runtime.doctorLines());
default -> {
send(sender, ChatColor.RED + "Usage: /dsp2p status or /dsp2p doctor");
}
}
}
private static void sendBlock(CommandSender sender, String title, List<String> lines) {
send(sender, ChatColor.GOLD + title);
for (String line : lines) {
send(sender, ChatColor.GRAY + "- " + ChatColor.WHITE + line);
}
}
private static void send(CommandSender sender, String message) {
sender.sendMessage(TextComponent.fromLegacyText(message));
}
}
@@ -0,0 +1,24 @@
package me.proxylink.plugin.bungee;
import me.proxylink.common.AgentConfig;
import me.proxylink.plugin.PluginRuntime;
import net.md_5.bungee.api.plugin.Plugin;
public final class DirtSimpleBungeePlugin extends Plugin {
private PluginRuntime runtime;
@Override
public void onEnable() {
runtime = new PluginRuntime(getLogger());
runtime.start(getDataFolder().toPath(), "bungee-default.properties", AgentConfig.Role.FRONTEND);
getProxy().getPluginManager().registerCommand(this, new DirtSimpleBungeeCommand(runtime));
}
@Override
public void onDisable() {
if (runtime != null) {
runtime.stop();
runtime = null;
}
}
}
@@ -0,0 +1,55 @@
package me.proxylink.plugin.paper;
import me.proxylink.plugin.PluginRuntime;
import org.bukkit.ChatColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import java.util.ArrayList;
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 final PluginRuntime runtime;
DirtSimplePaperCommand(PluginRuntime runtime) {
this.runtime = runtime;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
String subcommand = args.length == 0 ? "status" : args[0].toLowerCase(Locale.ROOT);
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");
}
return true;
}
@Override
public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
if (args.length != 1) {
return List.of();
}
String prefix = args[0].toLowerCase(Locale.ROOT);
List<String> matches = new ArrayList<>();
for (String subcommand : SUBCOMMANDS) {
if (subcommand.startsWith(prefix)) {
matches.add(subcommand);
}
}
return matches;
}
private static void sendBlock(CommandSender sender, String title, List<String> lines) {
sender.sendMessage(ChatColor.GOLD + title);
for (String line : lines) {
sender.sendMessage(ChatColor.GRAY + "- " + ChatColor.WHITE + line);
}
}
}
@@ -0,0 +1,31 @@
package me.proxylink.plugin.paper;
import me.proxylink.common.AgentConfig;
import me.proxylink.plugin.PluginRuntime;
import org.bukkit.command.PluginCommand;
import org.bukkit.plugin.java.JavaPlugin;
public final class DirtSimplePaperPlugin extends JavaPlugin {
private PluginRuntime runtime;
@Override
public void onEnable() {
runtime = new PluginRuntime(getLogger());
runtime.start(getDataFolder().toPath(), "paper-default.properties", AgentConfig.Role.BACKEND);
PluginCommand command = getCommand("dsp2p");
if (command != null) {
DirtSimplePaperCommand commandHandler = new DirtSimplePaperCommand(runtime);
command.setExecutor(commandHandler);
command.setTabCompleter(commandHandler);
}
}
@Override
public void onDisable() {
if (runtime != null) {
runtime.stop();
runtime = null;
}
}
}
@@ -0,0 +1,46 @@
package me.proxylink.plugin.tls;
import java.security.MessageDigest;
import java.security.cert.CertificateEncodingException;
import java.security.cert.X509Certificate;
import java.util.HexFormat;
import java.util.Locale;
public final class CertificateFingerprints {
private static final HexFormat HEX = HexFormat.of().withUpperCase();
private CertificateFingerprints() {
}
public static String sha256(X509Certificate certificate) throws CertificateEncodingException {
try {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(certificate.getEncoded());
return colonDelimited(HEX.formatHex(digest));
} catch (java.security.NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 is not available", e);
}
}
public static String normalize(String fingerprint) {
if (fingerprint == null) {
return "";
}
return fingerprint
.replace(":", "")
.replace("-", "")
.replace(" ", "")
.trim()
.toUpperCase(Locale.ROOT);
}
private static String colonDelimited(String hex) {
StringBuilder builder = new StringBuilder(hex.length() + hex.length() / 2);
for (int i = 0; i < hex.length(); i += 2) {
if (i > 0) {
builder.append(':');
}
builder.append(hex, i, i + 2);
}
return builder.toString();
}
}
@@ -0,0 +1,108 @@
package me.proxylink.plugin.tls;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.X509v3CertificateBuilder;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.OperatorCreationException;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import java.io.IOException;
import java.io.OutputStream;
import java.math.BigInteger;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.GeneralSecurityException;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.KeyStore;
import java.security.SecureRandom;
import java.security.Security;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
public final class SelfSignedCertificateGenerator {
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private static final String PROVIDER = "BC";
private static final String ALIAS = "dirtsimplep2p";
private SelfSignedCertificateGenerator() {
}
public static String createPkcs12(Path keyStorePath, char[] password, String nodeName)
throws IOException, GeneralSecurityException {
ensureProvider();
Files.createDirectories(keyStorePath.toAbsolutePath().getParent());
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(3072, SECURE_RANDOM);
KeyPair keyPair = keyPairGenerator.generateKeyPair();
Instant now = Instant.now();
Date notBefore = Date.from(now.minus(1, ChronoUnit.DAYS));
Date notAfter = Date.from(now.plus(3650, ChronoUnit.DAYS));
BigInteger serial = new BigInteger(160, SECURE_RANDOM).abs();
X500Name subject = new X500Name("CN=DirtSimpleP2P " + sanitizeCn(nodeName));
X509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder(
subject,
serial,
notBefore,
notAfter,
subject,
keyPair.getPublic()
);
ContentSigner signer;
try {
signer = new JcaContentSignerBuilder("SHA256withRSA")
.setProvider(PROVIDER)
.build(keyPair.getPrivate());
} catch (OperatorCreationException e) {
throw new GeneralSecurityException("Unable to create self-signed certificate signer", e);
}
X509CertificateHolder holder = builder.build(signer);
X509Certificate certificate = new JcaX509CertificateConverter()
.setProvider(PROVIDER)
.getCertificate(holder);
certificate.verify(keyPair.getPublic());
KeyStore keyStore = KeyStore.getInstance("PKCS12");
keyStore.load(null, password);
keyStore.setKeyEntry(ALIAS, keyPair.getPrivate(), password, new java.security.cert.Certificate[]{certificate});
try (OutputStream output = Files.newOutputStream(keyStorePath)) {
keyStore.store(output, password);
}
return CertificateFingerprints.sha256(certificate);
}
public static String fingerprintFromPkcs12(Path keyStorePath, char[] password)
throws IOException, GeneralSecurityException {
KeyStore keyStore = KeyStore.getInstance("PKCS12");
try (java.io.InputStream input = Files.newInputStream(keyStorePath)) {
keyStore.load(input, password);
}
X509Certificate certificate = (X509Certificate) keyStore.getCertificate(ALIAS);
if (certificate == null) {
throw new GeneralSecurityException("Keystore does not contain certificate alias " + ALIAS);
}
return CertificateFingerprints.sha256(certificate);
}
private static void ensureProvider() {
if (Security.getProvider(PROVIDER) == null) {
Security.addProvider(new BouncyCastleProvider());
}
}
private static String sanitizeCn(String value) {
return value == null ? "node" : value.replace(",", "_").replace("=", "_");
}
}
@@ -0,0 +1,73 @@
package me.proxylink.plugin.tls;
import me.proxylink.common.AgentConfig;
import me.proxylink.common.AgentConfigIO;
import me.proxylink.plugin.ConfigFileEditor;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.util.HexFormat;
import java.util.logging.Logger;
public final class TlsBootstrap {
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private static final HexFormat HEX = HexFormat.of().withLowerCase();
private TlsBootstrap() {
}
public static AgentConfig prepare(Path dataFolder, Path configPath, AgentConfig config, Logger logger)
throws IOException, GeneralSecurityException {
if (!config.tls().enabled()) {
return config;
}
if (config.role() == AgentConfig.Role.FRONTEND && config.tls().autoGenerate()) {
return prepareFrontend(dataFolder, configPath, config, logger);
}
if (config.role() == AgentConfig.Role.BACKEND
&& config.tls().trustOnFirstUse()
&& config.tls().pinnedCertificateSha256().isBlank()) {
logger.warning("TLS trust-on-first-use is enabled. The first successful frontend certificate will be pinned in config.properties.");
}
return config;
}
private static AgentConfig prepareFrontend(Path dataFolder, Path configPath, AgentConfig config, Logger logger)
throws IOException, GeneralSecurityException {
Path keyStorePath = config.tls().keyStorePath();
if (keyStorePath == null) {
keyStorePath = dataFolder.resolve("certs/frontend.p12").normalize();
ConfigFileEditor.setProperty(configPath, "tunnel.tls.keyStore", "certs/frontend.p12");
}
String password = config.tls().keyStorePassword();
if (password == null || password.isBlank()) {
password = randomHex(32);
ConfigFileEditor.setProperty(configPath, "tunnel.tls.keyStorePassword", password);
}
String fingerprint;
if (Files.exists(keyStorePath)) {
fingerprint = SelfSignedCertificateGenerator.fingerprintFromPkcs12(keyStorePath, password.toCharArray());
logger.info("Using existing DirtSimpleP2P TLS certificate: " + fingerprint);
} else {
fingerprint = SelfSignedCertificateGenerator.createPkcs12(keyStorePath, password.toCharArray(), config.nodeName());
logger.info("Generated DirtSimpleP2P TLS certificate: " + fingerprint);
}
logger.info("Backend nodes should pin this TLS fingerprint automatically on first connect, or manually set tunnel.tls.pinnedCertificateSha256.");
return AgentConfigIO.load(configPath);
}
private static String randomHex(int bytes) {
byte[] data = new byte[bytes];
SECURE_RANDOM.nextBytes(data);
return HEX.formatHex(data);
}
}
@@ -0,0 +1,392 @@
package me.proxylink.plugin.tunnel;
import me.proxylink.common.AgentConfig;
import me.proxylink.common.AuthPayloads;
import me.proxylink.common.Frame;
import me.proxylink.common.FrameCodec;
import me.proxylink.common.FrameType;
import me.proxylink.common.ProtocolConstants;
import me.proxylink.common.ProtocolException;
import me.proxylink.common.StreamOpenPayload;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.time.Duration;
import java.util.Arrays;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
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.logging.Level;
import java.util.logging.Logger;
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 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 AtomicReference<String> lastEvent = new AtomicReference<>("not started");
private volatile BackendTunnel activeTunnel;
public BackendAgent(AgentConfig config) {
this(config, ignored -> {
});
}
public BackendAgent(AgentConfig config, Consumer<String> learnedTlsFingerprintConsumer) {
this.config = config;
this.learnedTlsFingerprintConsumer = learnedTlsFingerprintConsumer;
}
@Override
public void start() {
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());
}
@Override
public TunnelStatus status() {
BackendTunnel tunnel = activeTunnel;
boolean connected = running.get() && tunnel != null && tunnel.isOpen();
int activeStreams = connected ? tunnel.streamCount() : 0;
return new TunnelStatus(
config.role(),
config.nodeName(),
running.get(),
connected,
activeStreams,
config.tls().enabled(),
lastEvent.get()
);
}
@Override
public void close() {
if (!running.compareAndSet(true, false)) {
return;
}
BackendTunnel tunnel = activeTunnel;
if (tunnel != null) {
tunnel.close("backend shutting down");
}
executor.shutdownNow();
lastEvent.set("backend stopped");
LOGGER.info("Backend agent stopped");
}
private void runReconnectLoop() {
long delayMillis = config.tunnel().reconnectInitialMillis();
while (running.get()) {
try {
runSingleConnection();
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);
}
}
if (running.get()) {
sleepWithBackoff(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);
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();
}
private AgentConfig connectionConfig() {
String pin = learnedTlsFingerprint.get();
if (pin.isBlank()) {
return config;
}
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(),
pin
);
return new AgentConfig(config.role(), config.nodeName(), config.tunnel(), pinnedTls, config.routes());
}
private void learnTlsFingerprint(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);
}
}
private void authenticateToFrontend(FrameCodec codec) throws IOException {
AuthPayloads.Hello hello = new AuthPayloads.Hello(
ProtocolConstants.PROTOCOL_VERSION,
"backend",
config.nodeName(),
AuthPayloads.randomNonce()
);
codec.writeFrame(new Frame(FrameType.HELLO, 0, AuthPayloads.encodeHello(hello)));
Frame challengeFrame = codec.readFrame();
if (challengeFrame == null || challengeFrame.type() != FrameType.AUTH_CHALLENGE || challengeFrame.streamId() != 0) {
throw new ProtocolException("Expected AUTH_CHALLENGE frame");
}
AuthPayloads.Challenge challenge = AuthPayloads.decodeChallenge(challengeFrame.payload());
byte[] response = AuthPayloads.computeResponse(config.tunnel().authToken(), hello, challenge);
codec.writeFrame(new Frame(FrameType.AUTH_RESPONSE, 0, AuthPayloads.encodeResponse(new AuthPayloads.Response(response))));
Frame result = codec.readFrame();
if (result == null) {
throw new ProtocolException("Authentication ended before AUTH_OK");
}
if (result.type() == FrameType.AUTH_FAILED) {
throw new ProtocolException("Frontend rejected authentication: "
+ new String(result.payload(), StandardCharsets.UTF_8));
}
if (result.type() != FrameType.AUTH_OK) {
throw new ProtocolException("Expected AUTH_OK frame, got " + result.type());
}
}
private void sleepWithBackoff(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");
try {
Thread.sleep(sleepMillis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private final class BackendTunnel {
private final Socket socket;
private final FrameCodec codec;
private final AtomicBoolean open = new AtomicBoolean(true);
private final Map<Long, LocalStream> streams = new ConcurrentHashMap<>();
private final ScheduledExecutorService heartbeat = Executors.newSingleThreadScheduledExecutor(
new NamedThreadFactory("proxylink-backend-heartbeat")
);
private volatile long lastReadMillis = System.currentTimeMillis();
private BackendTunnel(Socket socket, FrameCodec codec) {
this.socket = socket;
this.codec = codec;
}
boolean isOpen() {
return open.get();
}
int streamCount() {
return streams.size();
}
void runReadLoop() {
scheduleHeartbeat();
try {
while (open.get()) {
Frame frame = codec.readFrame();
if (frame == null) {
throw new IOException("Frontend tunnel reached EOF");
}
lastReadMillis = System.currentTimeMillis();
handleFrame(frame);
}
} catch (Exception e) {
if (open.get()) {
LOGGER.log(Level.WARNING, "Backend tunnel read loop stopped", e);
}
} finally {
close("frontend tunnel disconnected");
if (activeTunnel == this) {
activeTunnel = null;
}
}
}
void close(String reason) {
if (!open.compareAndSet(true, false)) {
return;
}
try {
codec.writeFrame(new Frame(FrameType.GOAWAY, 0, reason.getBytes(StandardCharsets.UTF_8)));
} catch (IOException ignored) {
}
heartbeat.shutdownNow();
try {
socket.close();
} catch (IOException ignored) {
}
for (LocalStream stream : streams.values()) {
stream.markClosed();
stream.closeSocket();
}
streams.clear();
lastEvent.set("closed backend tunnel: " + reason);
LOGGER.info(() -> "Closed backend tunnel reason=" + reason);
}
private void scheduleHeartbeat() {
long interval = config.tunnel().heartbeatIntervalMillis();
heartbeat.scheduleAtFixedRate(() -> {
if (!open.get()) {
return;
}
long idleMillis = System.currentTimeMillis() - lastReadMillis;
long disconnectAfterMillis = disconnectAfterMillis(interval);
if (idleMillis > disconnectAfterMillis) {
close("heartbeat timeout after " + Duration.ofMillis(idleMillis));
return;
}
if (idleMillis > interval * 2) {
lastEvent.set("heartbeat delayed " + idleMillis + "ms; tunnel still open");
}
try {
codec.writeFrame(Frame.empty(FrameType.PING, 0));
} catch (IOException e) {
close("failed to write heartbeat ping");
}
}, interval, interval, TimeUnit.MILLISECONDS);
}
private long disconnectAfterMillis(long interval) {
long missedLimit = interval * config.tunnel().heartbeatMissesBeforeDisconnect();
return Math.max(interval, Math.min(config.tunnel().heartbeatTimeoutMillis(), missedLimit));
}
private void handleFrame(Frame frame) throws IOException {
switch (frame.type()) {
case PING -> codec.writeFrame(Frame.empty(FrameType.PONG, 0));
case PONG -> {
}
case STREAM_OPEN -> openBackendStream(frame);
case STREAM_DATA -> writeToLocalStream(frame);
case STREAM_CLOSE, STREAM_RESET -> closeStream(frame.streamId(), false, frame.type(), "remote closed");
case ERROR -> LOGGER.warning(() -> "Tunnel error from frontend: " + new String(frame.payload(), StandardCharsets.UTF_8));
case GOAWAY -> close("remote goaway: " + new String(frame.payload(), StandardCharsets.UTF_8));
default -> throw new ProtocolException("Unexpected frame from frontend: " + frame.type());
}
}
private void openBackendStream(Frame frame) throws IOException {
StreamOpenPayload payload = StreamOpenPayload.decode(frame.payload());
AgentConfig.RouteConfig route;
try {
route = config.requireRoute(payload.routeName());
} catch (ProtocolException e) {
sendReset(frame.streamId(), e.getMessage());
return;
}
Socket backendSocket = new Socket();
try {
backendSocket.setTcpNoDelay(true);
backendSocket.setKeepAlive(true);
backendSocket.connect(
new InetSocketAddress(route.backendTargetHost(), route.backendTargetPort()),
config.tunnel().connectTimeoutMillis()
);
LocalStream stream = new LocalStream(frame.streamId(), backendSocket);
streams.put(frame.streamId(), stream);
executor.execute(() -> pumpLocalToTunnel(stream, "backend-target-" + route.name()));
LOGGER.fine(() -> "Opened backend stream " + frame.streamId() + " route=" + route.name());
} catch (IOException e) {
try {
backendSocket.close();
} catch (IOException ignored) {
}
sendReset(frame.streamId(), "backend target connection failed");
LOGGER.log(Level.WARNING, "Unable to connect route " + route.name()
+ " target " + route.backendTargetHost() + ":" + route.backendTargetPort(), e);
}
}
private void writeToLocalStream(Frame frame) throws IOException {
LocalStream stream = streams.get(frame.streamId());
if (stream == null || stream.isClosed()) {
sendReset(frame.streamId(), "unknown backend stream");
return;
}
stream.write(frame.payload());
}
private void pumpLocalToTunnel(LocalStream stream, String label) {
byte[] buffer = new byte[ProtocolConstants.STREAM_DATA_CHUNK_BYTES];
try (InputStream input = stream.socket().getInputStream()) {
int read;
while (open.get() && !stream.isClosed() && (read = input.read(buffer)) != -1) {
byte[] payload = Arrays.copyOf(buffer, read);
codec.writeFrame(new Frame(FrameType.STREAM_DATA, stream.streamId(), payload));
}
closeStream(stream.streamId(), true, FrameType.STREAM_CLOSE, label + " eof");
} catch (IOException e) {
closeStream(stream.streamId(), true, FrameType.STREAM_RESET, label + " io error");
}
}
private void closeStream(long streamId, boolean notifyRemote, FrameType closeType, String reason) {
LocalStream stream = streams.remove(streamId);
if (stream == null || !stream.markClosed()) {
return;
}
stream.closeSocket();
if (notifyRemote && open.get()) {
try {
codec.writeFrame(new Frame(closeType, streamId, reason.getBytes(StandardCharsets.UTF_8)));
} catch (IOException e) {
close("failed to send stream close");
}
}
}
private void sendReset(long streamId, String reason) throws IOException {
if (open.get()) {
codec.writeFrame(new Frame(FrameType.STREAM_RESET, streamId, reason.getBytes(StandardCharsets.UTF_8)));
}
}
}
}
@@ -0,0 +1,396 @@
package me.proxylink.plugin.tunnel;
import me.proxylink.common.AgentConfig;
import me.proxylink.common.AuthPayloads;
import me.proxylink.common.Frame;
import me.proxylink.common.FrameCodec;
import me.proxylink.common.FrameType;
import me.proxylink.common.ProtocolConstants;
import me.proxylink.common.ProtocolException;
import me.proxylink.common.StreamOpenPayload;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import java.util.logging.Level;
import java.util.logging.Logger;
public final class FrontendAgent implements ManagedAgent {
private static final Logger LOGGER = Logger.getLogger(FrontendAgent.class.getName());
private final AgentConfig config;
private final ExecutorService executor = Executors.newThreadPerTaskExecutor(
new NamedThreadFactory("proxylink-frontend", true)
);
private final AtomicBoolean running = new AtomicBoolean(false);
private final AtomicReference<FrontendTunnel> activeTunnel = new AtomicReference<>();
private final AtomicReference<String> lastEvent = new AtomicReference<>("not started");
private final List<ServerSocket> routeServerSockets = new ArrayList<>();
private volatile ServerSocket tunnelServerSocket;
public FrontendAgent(AgentConfig config) {
this.config = config;
}
@Override
public void start() throws IOException, GeneralSecurityException {
if (!running.compareAndSet(false, true)) {
return;
}
tunnelServerSocket = TlsSocketFactory.createServerSocket(config);
executor.execute(this::acceptBackendTunnels);
for (AgentConfig.RouteConfig route : config.routes()) {
ServerSocket routeSocket = new ServerSocket();
routeSocket.setReuseAddress(true);
routeSocket.bind(new InetSocketAddress(route.frontendBindHost(), route.frontendBindPort()));
routeServerSockets.add(routeSocket);
executor.execute(() -> acceptPlayers(route, routeSocket));
LOGGER.info(() -> "Route " + route.name() + " listening for proxy connections on "
+ route.frontendBindHost() + ":" + route.frontendBindPort());
}
LOGGER.info(() -> "Frontend tunnel listener running on "
+ config.tunnel().listenHost() + ":" + config.tunnel().listenPort());
lastEvent.set("listening on " + config.tunnel().listenHost() + ":" + config.tunnel().listenPort());
}
@Override
public TunnelStatus status() {
FrontendTunnel tunnel = activeTunnel.get();
boolean connected = running.get() && tunnel != null && tunnel.isOpen();
int activeStreams = connected ? tunnel.streamCount() : 0;
return new TunnelStatus(
config.role(),
config.nodeName(),
running.get(),
connected,
activeStreams,
config.tls().enabled(),
lastEvent.get()
);
}
@Override
public void close() {
if (!running.compareAndSet(true, false)) {
return;
}
closeQuietly(tunnelServerSocket);
for (ServerSocket serverSocket : routeServerSockets) {
closeQuietly(serverSocket);
}
FrontendTunnel tunnel = activeTunnel.getAndSet(null);
if (tunnel != null) {
tunnel.close("frontend shutting down");
}
executor.shutdownNow();
lastEvent.set("frontend stopped");
LOGGER.info("Frontend agent stopped");
}
private void acceptBackendTunnels() {
while (running.get()) {
try {
Socket socket = tunnelServerSocket.accept();
socket.setTcpNoDelay(true);
socket.setKeepAlive(true);
executor.execute(() -> handleBackendTunnel(socket));
} catch (IOException e) {
if (running.get()) {
LOGGER.log(Level.WARNING, "Failed to accept backend tunnel", e);
}
}
}
}
private void handleBackendTunnel(Socket socket) {
try {
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);
if (previous != null) {
previous.close("replaced by a new authenticated backend tunnel");
}
LOGGER.info(() -> "Authenticated backend tunnel from node=" + hello.nodeName()
+ " remote=" + socket.getRemoteSocketAddress());
lastEvent.set("backend authenticated: " + hello.nodeName());
tunnel.runReadLoop();
} catch (Exception e) {
lastEvent.set("backend tunnel setup failed: " + e.getMessage());
LOGGER.log(Level.WARNING, "Backend tunnel closed during setup or read loop", e);
closeQuietly(socket);
}
}
private AuthPayloads.Hello authenticateBackend(FrameCodec codec) throws IOException {
Frame helloFrame = codec.readFrame();
if (helloFrame == null || helloFrame.type() != FrameType.HELLO || helloFrame.streamId() != 0) {
throw new ProtocolException("Expected HELLO frame");
}
AuthPayloads.Hello hello = AuthPayloads.decodeHello(helloFrame.payload());
if (hello.protocolVersion() != ProtocolConstants.PROTOCOL_VERSION) {
throw new ProtocolException("Backend protocol version mismatch: " + hello.protocolVersion());
}
if (!"backend".equals(hello.role())) {
throw new ProtocolException("Only backend agents may authenticate to the frontend listener");
}
AuthPayloads.Challenge challenge = new AuthPayloads.Challenge(AuthPayloads.randomNonce());
codec.writeFrame(new Frame(FrameType.AUTH_CHALLENGE, 0, AuthPayloads.encodeChallenge(challenge)));
Frame responseFrame = codec.readFrame();
if (responseFrame == null || responseFrame.type() != FrameType.AUTH_RESPONSE || responseFrame.streamId() != 0) {
throw new ProtocolException("Expected AUTH_RESPONSE frame");
}
AuthPayloads.Response response = AuthPayloads.decodeResponse(responseFrame.payload());
byte[] expected = AuthPayloads.computeResponse(config.tunnel().authToken(), hello, challenge);
if (!AuthPayloads.constantTimeEquals(expected, response.hmac())) {
codec.writeFrame(new Frame(FrameType.AUTH_FAILED, 0, "authentication failed".getBytes(StandardCharsets.UTF_8)));
throw new ProtocolException("Backend authentication failed for node=" + hello.nodeName());
}
codec.writeFrame(new Frame(FrameType.AUTH_OK, 0, "ok".getBytes(StandardCharsets.UTF_8)));
return hello;
}
private void acceptPlayers(AgentConfig.RouteConfig route, ServerSocket serverSocket) {
while (running.get()) {
try {
Socket playerSocket = serverSocket.accept();
playerSocket.setTcpNoDelay(true);
playerSocket.setKeepAlive(true);
FrontendTunnel tunnel = activeTunnel.get();
if (tunnel == null || !tunnel.isOpen()) {
LOGGER.warning(() -> "Rejecting player connection for route " + route.name()
+ " because no backend tunnel is authenticated");
closeQuietly(playerSocket);
continue;
}
tunnel.openStream(route, playerSocket);
} catch (IOException e) {
if (running.get()) {
LOGGER.log(Level.WARNING, "Failed accepting player connection for route " + route.name(), e);
}
}
}
}
private void clearActiveTunnel(FrontendTunnel tunnel) {
activeTunnel.compareAndSet(tunnel, null);
}
private static void closeQuietly(ServerSocket serverSocket) {
if (serverSocket != null) {
try {
serverSocket.close();
} catch (IOException ignored) {
}
}
}
private static void closeQuietly(Socket socket) {
if (socket != null) {
try {
socket.close();
} catch (IOException ignored) {
}
}
}
private final class FrontendTunnel {
private final Socket socket;
private final FrameCodec codec;
private final String backendNodeName;
private final AtomicBoolean open = new AtomicBoolean(true);
private final AtomicLong nextStreamId = new AtomicLong(1);
private final Map<Long, LocalStream> streams = new ConcurrentHashMap<>();
private final ScheduledExecutorService heartbeat = Executors.newSingleThreadScheduledExecutor(
new NamedThreadFactory("proxylink-frontend-heartbeat")
);
private volatile long lastReadMillis = System.currentTimeMillis();
private FrontendTunnel(Socket socket, FrameCodec codec, String backendNodeName) {
this.socket = socket;
this.codec = codec;
this.backendNodeName = backendNodeName;
}
boolean isOpen() {
return open.get();
}
int streamCount() {
return streams.size();
}
void openStream(AgentConfig.RouteConfig route, Socket playerSocket) {
long streamId = nextStreamId.getAndAdd(2);
try {
LocalStream stream = new LocalStream(streamId, playerSocket);
streams.put(streamId, stream);
codec.writeFrame(new Frame(FrameType.STREAM_OPEN, streamId, new StreamOpenPayload(route.name()).encode()));
executor.execute(() -> pumpLocalToTunnel(stream, "frontend-player-" + route.name()));
LOGGER.fine(() -> "Opened stream " + streamId + " route=" + route.name());
} catch (IOException e) {
streams.remove(streamId);
closeQuietly(playerSocket);
LOGGER.log(Level.WARNING, "Failed to open stream for route " + route.name(), e);
}
}
void runReadLoop() {
scheduleHeartbeat();
try {
while (open.get()) {
Frame frame = codec.readFrame();
if (frame == null) {
throw new IOException("Backend tunnel reached EOF");
}
lastReadMillis = System.currentTimeMillis();
handleFrame(frame);
}
} catch (Exception e) {
if (open.get()) {
LOGGER.log(Level.WARNING, "Frontend tunnel read loop stopped for backend=" + backendNodeName, e);
}
} finally {
close("backend tunnel disconnected");
clearActiveTunnel(this);
}
}
void close(String reason) {
if (!open.compareAndSet(true, false)) {
return;
}
try {
codec.writeFrame(new Frame(FrameType.GOAWAY, 0, reason.getBytes(StandardCharsets.UTF_8)));
} catch (IOException ignored) {
}
heartbeat.shutdownNow();
closeQuietly(socket);
for (LocalStream stream : streams.values()) {
stream.markClosed();
stream.closeSocket();
}
streams.clear();
lastEvent.set("closed backend tunnel: " + reason);
LOGGER.info(() -> "Closed frontend tunnel for backend=" + backendNodeName + " reason=" + reason);
}
private void scheduleHeartbeat() {
long interval = config.tunnel().heartbeatIntervalMillis();
heartbeat.scheduleAtFixedRate(() -> {
if (!open.get()) {
return;
}
long idleMillis = System.currentTimeMillis() - lastReadMillis;
long disconnectAfterMillis = disconnectAfterMillis(interval);
if (idleMillis > disconnectAfterMillis) {
close("heartbeat timeout after " + Duration.ofMillis(idleMillis));
return;
}
if (idleMillis > interval * 2) {
lastEvent.set("heartbeat delayed " + idleMillis + "ms; tunnel still open");
}
try {
codec.writeFrame(Frame.empty(FrameType.PING, 0));
} catch (IOException e) {
close("failed to write heartbeat ping");
}
}, interval, interval, TimeUnit.MILLISECONDS);
}
private long disconnectAfterMillis(long interval) {
long missedLimit = interval * config.tunnel().heartbeatMissesBeforeDisconnect();
return Math.max(interval, Math.min(config.tunnel().heartbeatTimeoutMillis(), missedLimit));
}
private void handleFrame(Frame frame) throws IOException {
switch (frame.type()) {
case PING -> codec.writeFrame(Frame.empty(FrameType.PONG, 0));
case PONG -> {
}
case STREAM_DATA -> writeToLocalStream(frame);
case STREAM_CLOSE, STREAM_RESET -> closeStream(frame.streamId(), false, frame.type(), "remote closed");
case ERROR -> LOGGER.warning(() -> "Tunnel error from backend: " + new String(frame.payload(), StandardCharsets.UTF_8));
case GOAWAY -> close("remote goaway: " + new String(frame.payload(), StandardCharsets.UTF_8));
default -> throw new ProtocolException("Unexpected frame from backend: " + frame.type());
}
}
private void writeToLocalStream(Frame frame) throws IOException {
LocalStream stream = streams.get(frame.streamId());
if (stream == null || stream.isClosed()) {
sendReset(frame.streamId(), "unknown frontend stream");
return;
}
stream.write(frame.payload());
}
private void pumpLocalToTunnel(LocalStream stream, String label) {
byte[] buffer = new byte[ProtocolConstants.STREAM_DATA_CHUNK_BYTES];
try (InputStream input = stream.socket().getInputStream()) {
int read;
while (open.get() && !stream.isClosed() && (read = input.read(buffer)) != -1) {
byte[] payload = Arrays.copyOf(buffer, read);
codec.writeFrame(new Frame(FrameType.STREAM_DATA, stream.streamId(), payload));
}
closeStream(stream.streamId(), true, FrameType.STREAM_CLOSE, label + " eof");
} catch (IOException e) {
closeStream(stream.streamId(), true, FrameType.STREAM_RESET, label + " io error");
}
}
private void closeStream(long streamId, boolean notifyRemote, FrameType closeType, String reason) {
LocalStream stream = streams.remove(streamId);
if (stream == null || !stream.markClosed()) {
return;
}
stream.closeSocket();
if (notifyRemote && open.get()) {
try {
codec.writeFrame(new Frame(closeType, streamId, reason.getBytes(StandardCharsets.UTF_8)));
} catch (IOException e) {
close("failed to send stream close");
}
}
}
private void sendReset(long streamId, String reason) throws IOException {
if (open.get()) {
codec.writeFrame(new Frame(FrameType.STREAM_RESET, streamId, reason.getBytes(StandardCharsets.UTF_8)));
}
}
}
}
@@ -0,0 +1,49 @@
package me.proxylink.plugin.tunnel;
import java.io.IOException;
import java.io.OutputStream;
import java.net.Socket;
import java.util.concurrent.atomic.AtomicBoolean;
final class LocalStream {
private final long streamId;
private final Socket socket;
private final OutputStream output;
private final AtomicBoolean closed = new AtomicBoolean(false);
LocalStream(long streamId, Socket socket) throws IOException {
this.streamId = streamId;
this.socket = socket;
this.output = socket.getOutputStream();
}
long streamId() {
return streamId;
}
Socket socket() {
return socket;
}
boolean markClosed() {
return closed.compareAndSet(false, true);
}
boolean isClosed() {
return closed.get();
}
void write(byte[] payload) throws IOException {
synchronized (output) {
output.write(payload);
output.flush();
}
}
void closeSocket() {
try {
socket.close();
} catch (IOException ignored) {
}
}
}
@@ -0,0 +1,10 @@
package me.proxylink.plugin.tunnel;
public interface ManagedAgent extends AutoCloseable {
void start() throws Exception;
TunnelStatus status();
@Override
void close();
}
@@ -0,0 +1,31 @@
package me.proxylink.plugin.tunnel;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicInteger;
final class NamedThreadFactory implements ThreadFactory {
private final String prefix;
private final boolean virtual;
private final AtomicInteger nextId = new AtomicInteger(1);
NamedThreadFactory(String prefix) {
this(prefix, false);
}
NamedThreadFactory(String prefix, boolean virtual) {
this.prefix = prefix;
this.virtual = virtual;
}
@Override
public Thread newThread(Runnable runnable) {
if (virtual) {
return Thread.ofVirtual()
.name(prefix + "-", nextId.getAndIncrement())
.unstarted(runnable);
}
Thread thread = new Thread(runnable, prefix + "-" + nextId.getAndIncrement());
thread.setDaemon(false);
return thread;
}
}
@@ -0,0 +1,57 @@
package me.proxylink.plugin.tunnel;
import me.proxylink.plugin.tls.CertificateFingerprints;
import javax.net.ssl.X509TrustManager;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
final class PinnedCertificateTrustManager implements X509TrustManager {
private final String expectedFingerprint;
private final boolean trustOnFirstUse;
private volatile String learnedFingerprint;
PinnedCertificateTrustManager(String expectedFingerprint, boolean trustOnFirstUse) {
this.expectedFingerprint = CertificateFingerprints.normalize(expectedFingerprint);
this.trustOnFirstUse = trustOnFirstUse;
}
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
throw new CertificateException("Client certificate authentication is not enabled");
}
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
if (chain == null || chain.length == 0) {
throw new CertificateException("Server did not provide a TLS certificate");
}
chain[0].checkValidity();
String actualPretty = CertificateFingerprints.sha256(chain[0]);
String actual = CertificateFingerprints.normalize(actualPretty);
if (!expectedFingerprint.isBlank()) {
if (!expectedFingerprint.equals(actual)) {
throw new CertificateException("Frontend TLS certificate fingerprint mismatch. Expected "
+ expectedFingerprint + " but got " + actualPretty);
}
return;
}
if (!trustOnFirstUse) {
throw new CertificateException("No pinned frontend TLS certificate fingerprint is configured");
}
learnedFingerprint = actualPretty;
}
@Override
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
String learnedFingerprint() {
return learnedFingerprint;
}
}
@@ -0,0 +1,176 @@
package me.proxylink.plugin.tunnel;
import me.proxylink.common.AgentConfig;
import javax.net.ServerSocketFactory;
import javax.net.SocketFactory;
import javax.net.ssl.KeyManager;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLServerSocket;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.TrustManager;
import javax.net.ssl.TrustManagerFactory;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.GeneralSecurityException;
import java.security.KeyStore;
import java.util.function.Consumer;
final class TlsSocketFactory {
private TlsSocketFactory() {
}
static ServerSocket createServerSocket(AgentConfig config) throws IOException, GeneralSecurityException {
InetSocketAddress bindAddress = new InetSocketAddress(
config.tunnel().listenHost(),
config.tunnel().listenPort()
);
if (!config.tls().enabled()) {
ServerSocket serverSocket = new ServerSocket();
serverSocket.setReuseAddress(true);
serverSocket.bind(bindAddress);
return serverSocket;
}
SSLContext context = sslContext(
config.tls().keyStorePath(),
config.tls().keyStorePassword(),
config.tls().trustStorePath(),
config.tls().trustStorePassword(),
null
);
ServerSocketFactory factory = context.getServerSocketFactory();
SSLServerSocket serverSocket = (SSLServerSocket) factory.createServerSocket();
serverSocket.setReuseAddress(true);
serverSocket.setNeedClientAuth(config.tls().requireClientAuth());
serverSocket.setEnabledProtocols(enabledProtocols(serverSocket.getSupportedProtocols()));
serverSocket.bind(bindAddress);
return serverSocket;
}
static Socket createClientSocket(AgentConfig config) throws IOException, GeneralSecurityException {
return createClientSocket(config, ignored -> {
});
}
static Socket createClientSocket(AgentConfig config, Consumer<String> learnedFingerprintConsumer)
throws IOException, GeneralSecurityException {
Socket socket;
PinnedCertificateTrustManager pinningTrustManager = null;
if (!config.tls().enabled()) {
socket = SocketFactory.getDefault().createSocket();
} else {
pinningTrustManager = pinningTrustManager(config);
SSLContext context = sslContext(
config.tls().keyStorePath(),
config.tls().keyStorePassword(),
config.tls().trustStorePath(),
config.tls().trustStorePassword(),
pinningTrustManager
);
socket = context.getSocketFactory().createSocket();
}
socket.setTcpNoDelay(true);
socket.setKeepAlive(true);
socket.connect(
new InetSocketAddress(config.tunnel().connectHost(), config.tunnel().connectPort()),
config.tunnel().connectTimeoutMillis()
);
if (socket instanceof SSLSocket sslSocket) {
sslSocket.setEnabledProtocols(enabledProtocols(sslSocket.getSupportedProtocols()));
sslSocket.startHandshake();
if (pinningTrustManager != null && pinningTrustManager.learnedFingerprint() != null) {
learnedFingerprintConsumer.accept(pinningTrustManager.learnedFingerprint());
}
}
return socket;
}
private static SSLContext sslContext(
Path keyStorePath,
String keyStorePassword,
Path trustStorePath,
String trustStorePassword,
TrustManager pinningTrustManager
) throws IOException, GeneralSecurityException {
KeyManager[] keyManagers = keyStorePath == null ? null : keyManagers(keyStorePath, keyStorePassword);
TrustManager[] trustManagers;
if (pinningTrustManager != null) {
trustManagers = new TrustManager[]{pinningTrustManager};
} else {
trustManagers = trustStorePath == null ? null : trustManagers(trustStorePath, trustStorePassword);
}
SSLContext context = SSLContext.getInstance("TLS");
context.init(keyManagers, trustManagers, null);
return context;
}
private static PinnedCertificateTrustManager pinningTrustManager(AgentConfig config) {
if (!config.tls().pinnedCertificateSha256().isBlank() || config.tls().trustOnFirstUse()) {
return new PinnedCertificateTrustManager(
config.tls().pinnedCertificateSha256(),
config.tls().trustOnFirstUse()
);
}
return null;
}
private static KeyManager[] keyManagers(Path keyStorePath, String password)
throws IOException, GeneralSecurityException {
KeyStore keyStore = loadStore(keyStorePath, password);
KeyManagerFactory factory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
factory.init(keyStore, password.toCharArray());
return factory.getKeyManagers();
}
private static TrustManager[] trustManagers(Path trustStorePath, String password)
throws IOException, GeneralSecurityException {
KeyStore trustStore = loadStore(trustStorePath, password);
TrustManagerFactory factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
factory.init(trustStore);
return factory.getTrustManagers();
}
private static KeyStore loadStore(Path path, String password) throws IOException, GeneralSecurityException {
KeyStore store = KeyStore.getInstance(KeyStore.getDefaultType());
try (InputStream input = Files.newInputStream(path)) {
store.load(input, password.toCharArray());
}
return store;
}
private static String[] enabledProtocols(String[] supported) {
boolean tls13 = false;
boolean tls12 = false;
for (String protocol : supported) {
if ("TLSv1.3".equals(protocol)) {
tls13 = true;
}
if ("TLSv1.2".equals(protocol)) {
tls12 = true;
}
}
if (tls13 && tls12) {
return new String[]{"TLSv1.3", "TLSv1.2"};
}
if (tls13) {
return new String[]{"TLSv1.3"};
}
if (tls12) {
return new String[]{"TLSv1.2"};
}
return supported;
}
}
@@ -0,0 +1,17 @@
package me.proxylink.plugin.tunnel;
import me.proxylink.common.AgentConfig;
public record TunnelStatus(
AgentConfig.Role role,
String nodeName,
boolean running,
boolean tunnelConnected,
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);
}
}
@@ -0,0 +1,33 @@
# 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.
role=frontend
node.name=bungee-frontend
tunnel.listenHost=0.0.0.0
tunnel.listenPort=24445
# Replaced automatically on first start. Copy the generated value to the backend config.
tunnel.authToken=AUTO_GENERATED_ON_FIRST_START
# Fast reconnect defaults. Heartbeats are frequent, but the tunnel waits for
# multiple missed heartbeats before declaring a real drop.
tunnel.connectTimeoutMillis=5000
tunnel.heartbeatIntervalMillis=2000
tunnel.heartbeatTimeoutMillis=8000
tunnel.heartbeatMissesBeforeDisconnect=4
tunnel.reconnectInitialMillis=250
tunnel.reconnectMaxMillis=10000
# Set this to true for encrypted tunnels. When enabled, the plugin generates
# certs/frontend.p12 if it does not already exist.
tunnel.tls.enabled=false
tunnel.tls.allowInsecure=true
tunnel.tls.autoGenerate=true
tunnel.tls.keyStore=certs/frontend.p12
tunnel.tls.keyStorePassword=
tunnel.tls.requireClientAuth=false
routes=minecraft
route.minecraft.frontendBindHost=127.0.0.1
route.minecraft.frontendBindPort=25566
+5
View File
@@ -0,0 +1,5 @@
name: DirtSimpleP2P
version: ${project.version}
main: me.proxylink.plugin.bungee.DirtSimpleBungeePlugin
author: DirtSimpleP2P
description: NAT-safe Minecraft TCP tunnel frontend plugin.
@@ -0,0 +1,31 @@
# DirtSimpleP2P Paper/Spigot backend config.
# Put this same jar in the backend Minecraft server plugins folder.
# No router port forwarding is required because this side connects outbound.
role=backend
node.name=paper-backend
tunnel.connectHost=YOUR_BUNGEE_OR_VPS_IP
tunnel.connectPort=24445
# Replaced automatically on first start. Paste the same value from the Bungee config here.
tunnel.authToken=AUTO_GENERATED_ON_FIRST_START
# Fast reconnect defaults. Heartbeats are frequent, but the tunnel waits for
# multiple missed heartbeats before declaring a real drop.
tunnel.connectTimeoutMillis=5000
tunnel.heartbeatIntervalMillis=2000
tunnel.heartbeatTimeoutMillis=8000
tunnel.heartbeatMissesBeforeDisconnect=4
tunnel.reconnectInitialMillis=250
tunnel.reconnectMaxMillis=10000
# Set this to true for encrypted tunnels. With trustOnFirstUse enabled, the
# backend pins the frontend certificate after the first successful TLS connect.
tunnel.tls.enabled=false
tunnel.tls.allowInsecure=true
tunnel.tls.trustOnFirstUse=true
tunnel.tls.pinnedCertificateSha256=
routes=minecraft
route.minecraft.backendTargetHost=127.0.0.1
route.minecraft.backendTargetPort=25565
+16
View File
@@ -0,0 +1,16 @@
name: DirtSimpleP2P
version: ${project.version}
main: me.proxylink.plugin.paper.DirtSimplePaperPlugin
api-version: '1.20'
load: STARTUP
author: DirtSimpleP2P
description: NAT-safe Minecraft TCP tunnel backend plugin.
commands:
dsp2p:
description: DirtSimpleP2P status and diagnostics.
usage: /dsp2p status
permission: dirtsimplep2p.command
permissions:
dirtsimplep2p.command:
description: Allows DirtSimpleP2P status and diagnostics commands.
default: op