first commit
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user