commit 640d4f45f27d6b87de7aac01313efa980e55ee0b Author: Xelara Networks Date: Sun Jun 21 12:44:51 2026 -0400 first commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..b1a12e4 --- /dev/null +++ b/README.md @@ -0,0 +1,342 @@ +# DirtSimpleP2P + +DirtSimpleP2P lets a public Bungee/Waterfall proxy reach a Paper/Spigot server that is running at home behind NAT. + +You do not need to open the Minecraft port on your home router. The backend server connects outbound to your public proxy/VPS, then Minecraft traffic is carried through that tunnel. + +## What You Get + +- One jar file for both servers +- No home router port forwarding +- Works with a public Bungee/Waterfall proxy +- Works with a backend Paper/Spigot server +- Secret-token authentication +- Automatic secret-token generation +- Optional TLS encryption with generated self-signed certificates +- Multiple Minecraft player connections over one tunnel +- Fast reconnect with heartbeat-based latency tolerance +- `/dsp2p status` and `/dsp2p doctor` commands +- Simple properties config files + +## Current Status + +This is still a development build, not a paid release build yet. + +The tunnel core exists and the plugin jar builds. TLS can now generate a self-signed certificate when enabled, but before paid release it should get more real-world testing on actual Bungee and Paper servers. + +## Build The Jar + +From this project folder, run: + +```bash +mvn clean package +``` + +The plugin jar will be here: + +```bash +plugin/target/DirtSimpleP2P-0.1.0-SNAPSHOT.jar +``` + +Use this same jar on both servers. + +## Basic Setup + +You need two Minecraft-side servers: + +- Public proxy server: BungeeCord, Waterfall, or another Bungee-compatible proxy +- Backend server: Paper or Spigot + +The public proxy is usually on a VPS. The backend server can be at home. + +## Step 1: Install On Bungee + +Put this jar in your Bungee/Waterfall `plugins` folder: + +```bash +DirtSimpleP2P-0.1.0-SNAPSHOT.jar +``` + +Start the proxy once, then stop it. + +DirtSimpleP2P will create: + +```bash +plugins/DirtSimpleP2P/config.properties +``` + +Open that file. + +## Step 2: Configure Bungee + +On the Bungee/proxy server, use: + +```properties +role=frontend +node.name=bungee-frontend + +tunnel.listenHost=0.0.0.0 +tunnel.listenPort=24445 +tunnel.authToken=GENERATED_AUTOMATICALLY + +tunnel.connectTimeoutMillis=5000 +tunnel.heartbeatIntervalMillis=2000 +tunnel.heartbeatTimeoutMillis=8000 +tunnel.heartbeatMissesBeforeDisconnect=4 +tunnel.reconnectInitialMillis=250 +tunnel.reconnectMaxMillis=10000 + +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 +``` + +Change: + +- `tunnel.listenPort` only if port `24445` is already used + +The plugin automatically generates `tunnel.authToken` on first start. + +Copy the generated Bungee token into the backend server config. The token must match on both sides. + +To enable TLS on Bungee, change: + +```properties +tunnel.tls.enabled=true +tunnel.tls.allowInsecure=false +``` + +When Bungee starts, DirtSimpleP2P will generate: + +```text +plugins/DirtSimpleP2P/certs/frontend.p12 +``` + +It will also fill in `tunnel.tls.keyStorePassword` if that value is blank. + +## Step 3: Point Bungee At The Local Tunnel + +In your Bungee server config, set the backend server address to: + +```text +127.0.0.1:25566 +``` + +That is not your home server IP. DirtSimpleP2P listens there locally on the proxy server. + +## Step 4: Install On Paper Or Spigot + +Put the same jar in your backend Minecraft server `plugins` folder: + +```bash +DirtSimpleP2P-0.1.0-SNAPSHOT.jar +``` + +Start the backend server once, then stop it. + +DirtSimpleP2P will create: + +```bash +plugins/DirtSimpleP2P/config.properties +``` + +Open that file. + +## Step 5: Configure Paper Or Spigot + +On the backend server, use: + +```properties +role=backend +node.name=paper-backend + +tunnel.connectHost=YOUR_BUNGEE_OR_VPS_IP +tunnel.connectPort=24445 +tunnel.authToken=PASTE_THE_BUNGEE_TOKEN_HERE + +tunnel.connectTimeoutMillis=5000 +tunnel.heartbeatIntervalMillis=2000 +tunnel.heartbeatTimeoutMillis=8000 +tunnel.heartbeatMissesBeforeDisconnect=4 +tunnel.reconnectInitialMillis=250 +tunnel.reconnectMaxMillis=10000 + +tunnel.tls.enabled=false +tunnel.tls.allowInsecure=true +tunnel.tls.trustOnFirstUse=true +tunnel.tls.pinnedCertificateSha256= + +routes=minecraft +route.minecraft.backendTargetHost=127.0.0.1 +route.minecraft.backendTargetPort=25565 +``` + +Change: + +- `tunnel.connectHost` to your public proxy/VPS IP or domain name +- `tunnel.authToken` to the same secret used on Bungee +- `route.minecraft.backendTargetPort` if your backend Minecraft server is not on `25565` + +Do not put your home IP in the Bungee config. The backend connects out to the proxy. + +To enable TLS on Paper/Spigot, change: + +```properties +tunnel.tls.enabled=true +tunnel.tls.allowInsecure=false +``` + +Leave this enabled for the easiest setup: + +```properties +tunnel.tls.trustOnFirstUse=true +``` + +On the first successful TLS connection, the backend saves the Bungee certificate fingerprint here: + +```properties +tunnel.tls.pinnedCertificateSha256= +``` + +After that, the backend will only trust that same Bungee certificate. + +## Step 6: Start Everything + +Start the public Bungee/Waterfall proxy first. + +Then start the backend Paper/Spigot server. + +If it works, the Bungee logs should show that a backend tunnel authenticated. + +Players should connect to your normal public proxy address. Bungee sends them to `127.0.0.1:25566`, and DirtSimpleP2P carries the connection to the backend server. + +## Commands + +Run these commands in game or from the server console: + +```text +/dsp2p status +``` + +Shows whether the agent is running, whether the tunnel is connected, active streams, TLS state, and the last important event. + +```text +/dsp2p doctor +``` + +Checks the config and prints useful setup/security hints. + +Permission: + +```text +dirtsimplep2p.command +``` + +On Paper/Spigot it defaults to server operators. On Bungee, give that permission to staff who should see tunnel diagnostics. + +## Firewall Notes + +On the public proxy/VPS, allow: + +```text +24445/tcp +``` + +On the home router, you do not need to forward: + +```text +25565/tcp +``` + +Your backend server must be allowed to make outbound connections to the proxy/VPS. + +## Reconnect Behavior + +DirtSimpleP2P is tuned to notice real drops quickly while still tolerating short latency spikes. + +Default behavior: + +- Heartbeat every `2` seconds +- Warns in status if heartbeats are delayed +- Disconnects after about `8` seconds of no tunnel traffic +- First reconnect retry after about `250ms` +- Backoff grows up to `10` seconds if the proxy/VPS is still unreachable + +Useful config values: + +```properties +tunnel.heartbeatIntervalMillis=2000 +tunnel.heartbeatTimeoutMillis=8000 +tunnel.heartbeatMissesBeforeDisconnect=4 +tunnel.reconnectInitialMillis=250 +tunnel.reconnectMaxMillis=10000 +``` + +If your users are very far from the VPS or your home internet has frequent latency spikes, increase `tunnel.heartbeatMissesBeforeDisconnect` to `5` or `6`. + +## Common Problems + +### Plugin says the token is still the default + +Restart once on each side so DirtSimpleP2P can generate a token. + +Then copy the generated Bungee token into the backend config. The token must be the same on both sides. + +### Backend says connection refused + +Check that: + +- Bungee/proxy server is running +- DirtSimpleP2P started on Bungee +- `tunnel.connectHost` points to the proxy/VPS +- `tunnel.connectPort` matches the Bungee `tunnel.listenPort` +- The VPS firewall allows that TCP port + +### Bungee cannot connect to backend server + +Make sure your Bungee backend server entry points to: + +```text +127.0.0.1:25566 +``` + +Also make sure the backend Paper/Spigot server is running and connected to the tunnel. + +### Players disconnect or freeze + +Check both server logs. Look for: + +- reconnect messages +- heartbeat timeout messages +- stream reset messages +- backend target connection failed + +## Security Notes + +The secret token is important. Do not share it. + +Local-test configs use: + +```properties +tunnel.tls.enabled=false +tunnel.tls.allowInsecure=true +``` + +For a better setup, enable TLS on both sides: + +```properties +tunnel.tls.enabled=true +tunnel.tls.allowInsecure=false +``` + +The Bungee side generates a self-signed certificate. The Paper/Spigot side pins that certificate after the first successful connection. + +Trust-on-first-use is convenient, but the very first TLS connection is still the sensitive moment. For a stricter setup, copy the fingerprint from the Bungee log and paste it into the backend config before the first connection. + diff --git a/common/pom.xml b/common/pom.xml new file mode 100644 index 0000000..ef6bfba --- /dev/null +++ b/common/pom.xml @@ -0,0 +1,15 @@ + + + 4.0.0 + + + me.proxylink + dirtsimplep2p + 0.1.0-SNAPSHOT + + + proxylink-common + jar + diff --git a/common/src/main/java/me/proxylink/common/AgentConfig.java b/common/src/main/java/me/proxylink/common/AgentConfig.java new file mode 100644 index 0000000..fec41e8 --- /dev/null +++ b/common/src/main/java/me/proxylink/common/AgentConfig.java @@ -0,0 +1,206 @@ +package me.proxylink.common; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.regex.Pattern; + +public record AgentConfig( + Role role, + String nodeName, + TunnelConfig tunnel, + TlsConfig tls, + List routes +) { + private static final Pattern ROUTE_NAME = Pattern.compile("[A-Za-z0-9._-]+"); + + public AgentConfig { + Objects.requireNonNull(role, "role"); + nodeName = requireNonBlank(nodeName, "nodeName"); + Objects.requireNonNull(tunnel, "tunnel"); + Objects.requireNonNull(tls, "tls"); + routes = List.copyOf(Objects.requireNonNull(routes, "routes")); + } + + public void validateOrThrow() { + List errors = new ArrayList<>(); + + if (routes.isEmpty()) { + errors.add("At least one route is required"); + } + + if (tunnel.authToken().length() < 32) { + errors.add("tunnel.authToken must be at least 32 characters"); + } + if (isPlaceholderSecret(tunnel.authToken())) { + errors.add("tunnel.authToken is still the default placeholder and must be changed"); + } + + if (!tls.enabled() && !tunnel.allowInsecure()) { + errors.add("TLS is disabled, so tunnel.tls.allowInsecure must be true for explicit local testing"); + } + + if (role == Role.FRONTEND) { + validateHostPort(errors, "tunnel.listen", tunnel.listenHost(), tunnel.listenPort()); + if (tls.enabled() && !tls.autoGenerate()) { + requirePath(errors, "tunnel.tls.keyStore", tls.keyStorePath()); + requireSecret(errors, "tunnel.tls.keyStorePassword", tls.keyStorePassword()); + } + } + + if (role == Role.BACKEND) { + validateHostPort(errors, "tunnel.connect", tunnel.connectHost(), tunnel.connectPort()); + if (tls.enabled()) { + if (tls.trustStorePath() == null && tls.pinnedCertificateSha256().isBlank() && !tls.trustOnFirstUse()) { + errors.add("TLS is enabled on backend, so set tunnel.tls.pinnedCertificateSha256, set tunnel.tls.trustOnFirstUse=true, or configure a truststore"); + } + if (tls.trustStorePath() != null) { + requireSecret(errors, "tunnel.tls.trustStorePassword", tls.trustStorePassword()); + } + if (tls.requireClientAuth()) { + requirePath(errors, "tunnel.tls.keyStore", tls.keyStorePath()); + requireSecret(errors, "tunnel.tls.keyStorePassword", tls.keyStorePassword()); + } + } + } + + if (tunnel.connectTimeoutMillis() < 1000) { + errors.add("tunnel.connectTimeoutMillis must be at least 1000"); + } + if (tunnel.heartbeatIntervalMillis() < 1000) { + errors.add("tunnel.heartbeatIntervalMillis must be at least 1000"); + } + if (tunnel.heartbeatTimeoutMillis() <= tunnel.heartbeatIntervalMillis()) { + errors.add("tunnel.heartbeatTimeoutMillis must be greater than tunnel.heartbeatIntervalMillis"); + } + if (tunnel.heartbeatMissesBeforeDisconnect() < 2) { + errors.add("tunnel.heartbeatMissesBeforeDisconnect must be at least 2"); + } + if (tunnel.reconnectInitialMillis() < 250) { + errors.add("tunnel.reconnectInitialMillis must be at least 250"); + } + if (tunnel.reconnectMaxMillis() < tunnel.reconnectInitialMillis()) { + errors.add("tunnel.reconnectMaxMillis must be greater than or equal to tunnel.reconnectInitialMillis"); + } + + for (RouteConfig route : routes) { + if (!ROUTE_NAME.matcher(route.name()).matches()) { + errors.add("Route name contains invalid characters: " + route.name()); + } + if (role == Role.FRONTEND) { + validateHostPort(errors, "route." + route.name() + ".frontendBind", route.frontendBindHost(), route.frontendBindPort()); + } + if (role == Role.BACKEND) { + validateHostPort(errors, "route." + route.name() + ".backendTarget", route.backendTargetHost(), route.backendTargetPort()); + } + } + + if (!errors.isEmpty()) { + throw new IllegalArgumentException("Invalid configuration:\n - " + String.join("\n - ", errors)); + } + } + + public RouteConfig requireRoute(String routeName) throws ProtocolException { + for (RouteConfig route : routes) { + if (route.name().equals(routeName)) { + return route; + } + } + throw new ProtocolException("Unknown route: " + routeName); + } + + private static void validateHostPort(List errors, String keyPrefix, String host, int port) { + if (host == null || host.isBlank()) { + errors.add(keyPrefix + "Host must not be blank"); + } + if (port < 1 || port > 65535) { + errors.add(keyPrefix + "Port must be between 1 and 65535"); + } + } + + private static void requirePath(List errors, String key, Path path) { + if (path == null) { + errors.add(key + " is required when TLS is enabled"); + } + } + + private static void requireSecret(List errors, String key, String value) { + if (value == null || value.isBlank()) { + errors.add(key + " is required when TLS is enabled"); + } + } + + private static String requireNonBlank(String value, String name) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(name + " must not be blank"); + } + return value; + } + + private static boolean isPlaceholderSecret(String value) { + String normalized = value.toLowerCase(Locale.ROOT); + return normalized.contains("change_me") + || normalized.contains("replace-with") + || normalized.contains("placeholder"); + } + + public enum Role { + FRONTEND, + BACKEND; + + public static Role parse(String value) { + return Role.valueOf(value.trim().toUpperCase(Locale.ROOT)); + } + } + + public record TunnelConfig( + String listenHost, + int listenPort, + String connectHost, + int connectPort, + String authToken, + boolean allowInsecure, + int connectTimeoutMillis, + long heartbeatIntervalMillis, + long heartbeatTimeoutMillis, + int heartbeatMissesBeforeDisconnect, + long reconnectInitialMillis, + long reconnectMaxMillis + ) { + public TunnelConfig { + authToken = requireNonBlank(authToken, "authToken"); + } + } + + public record TlsConfig( + boolean enabled, + Path keyStorePath, + String keyStorePassword, + Path trustStorePath, + String trustStorePassword, + boolean requireClientAuth, + boolean autoGenerate, + boolean trustOnFirstUse, + String pinnedCertificateSha256 + ) { + public TlsConfig { + keyStorePassword = keyStorePassword == null ? "" : keyStorePassword; + trustStorePassword = trustStorePassword == null ? "" : trustStorePassword; + pinnedCertificateSha256 = pinnedCertificateSha256 == null ? "" : pinnedCertificateSha256.trim(); + } + } + + public record RouteConfig( + String name, + String frontendBindHost, + int frontendBindPort, + String backendTargetHost, + int backendTargetPort + ) { + public RouteConfig { + name = requireNonBlank(name, "name"); + } + } +} diff --git a/common/src/main/java/me/proxylink/common/AgentConfigIO.java b/common/src/main/java/me/proxylink/common/AgentConfigIO.java new file mode 100644 index 0000000..e035d27 --- /dev/null +++ b/common/src/main/java/me/proxylink/common/AgentConfigIO.java @@ -0,0 +1,141 @@ +package me.proxylink.common; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Properties; + +public final class AgentConfigIO { + private AgentConfigIO() { + } + + public static AgentConfig load(Path path) throws IOException { + Properties properties = new Properties(); + try (InputStream input = Files.newInputStream(path)) { + properties.load(input); + } + Path baseDir = path.toAbsolutePath().getParent(); + return fromProperties(properties, baseDir); + } + + public static AgentConfig fromProperties(Properties properties) { + return fromProperties(properties, null); + } + + public static AgentConfig fromProperties(Properties properties, Path baseDir) { + AgentConfig.Role role = AgentConfig.Role.parse(required(properties, "role")); + String nodeName = value(properties, "node.name", role.name().toLowerCase()); + + AgentConfig.TunnelConfig tunnel = new AgentConfig.TunnelConfig( + value(properties, "tunnel.listenHost", "0.0.0.0"), + intValue(properties, "tunnel.listenPort", -1), + value(properties, "tunnel.connectHost", ""), + intValue(properties, "tunnel.connectPort", -1), + required(properties, "tunnel.authToken"), + boolValue(properties, "tunnel.tls.allowInsecure", false), + intValue(properties, "tunnel.connectTimeoutMillis", 5000), + longValue(properties, "tunnel.heartbeatIntervalMillis", 2000L), + longValue(properties, "tunnel.heartbeatTimeoutMillis", 8000L), + intValue(properties, "tunnel.heartbeatMissesBeforeDisconnect", 4), + longValue(properties, "tunnel.reconnectInitialMillis", 250L), + longValue(properties, "tunnel.reconnectMaxMillis", 10000L) + ); + + AgentConfig.TlsConfig tls = new AgentConfig.TlsConfig( + boolValue(properties, "tunnel.tls.enabled", true), + pathValue(properties, "tunnel.tls.keyStore", baseDir), + value(properties, "tunnel.tls.keyStorePassword", ""), + pathValue(properties, "tunnel.tls.trustStore", baseDir), + value(properties, "tunnel.tls.trustStorePassword", ""), + boolValue(properties, "tunnel.tls.requireClientAuth", false), + boolValue(properties, "tunnel.tls.autoGenerate", true), + boolValue(properties, "tunnel.tls.trustOnFirstUse", true), + value(properties, "tunnel.tls.pinnedCertificateSha256", "") + ); + + return new AgentConfig(role, nodeName, tunnel, tls, routes(properties)); + } + + private static List routes(Properties properties) { + String routesValue = required(properties, "routes"); + List routes = new ArrayList<>(); + for (String rawName : routesValue.split(",")) { + String name = rawName.trim(); + if (name.isEmpty()) { + continue; + } + String prefix = "route." + name + "."; + routes.add(new AgentConfig.RouteConfig( + name, + value(properties, prefix + "frontendBindHost", "127.0.0.1"), + intValue(properties, prefix + "frontendBindPort", -1), + value(properties, prefix + "backendTargetHost", "127.0.0.1"), + intValue(properties, prefix + "backendTargetPort", -1) + )); + } + return routes; + } + + private static String required(Properties properties, String key) { + String value = properties.getProperty(key); + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("Missing required config key: " + key); + } + return value.trim(); + } + + private static String value(Properties properties, String key, String defaultValue) { + String value = properties.getProperty(key); + if (value == null) { + return defaultValue; + } + return value.trim(); + } + + private static int intValue(Properties properties, String key, int defaultValue) { + String value = properties.getProperty(key); + if (value == null || value.isBlank()) { + return defaultValue; + } + try { + return Integer.parseInt(value.trim()); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Config key must be an integer: " + key, e); + } + } + + private static long longValue(Properties properties, String key, long defaultValue) { + String value = properties.getProperty(key); + if (value == null || value.isBlank()) { + return defaultValue; + } + try { + return Long.parseLong(value.trim()); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Config key must be a long integer: " + key, e); + } + } + + private static boolean boolValue(Properties properties, String key, boolean defaultValue) { + String value = properties.getProperty(key); + if (value == null || value.isBlank()) { + return defaultValue; + } + return Boolean.parseBoolean(value.trim()); + } + + private static Path pathValue(Properties properties, String key, Path baseDir) { + String value = properties.getProperty(key); + if (value == null || value.isBlank()) { + return null; + } + Path path = Path.of(value.trim()); + if (!path.isAbsolute() && baseDir != null) { + return baseDir.resolve(path).normalize(); + } + return path; + } +} diff --git a/common/src/main/java/me/proxylink/common/AuthPayloads.java b/common/src/main/java/me/proxylink/common/AuthPayloads.java new file mode 100644 index 0000000..67eaddd --- /dev/null +++ b/common/src/main/java/me/proxylink/common/AuthPayloads.java @@ -0,0 +1,144 @@ +package me.proxylink.common; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Objects; + +public final class AuthPayloads { + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + private AuthPayloads() { + } + + public record Hello(int protocolVersion, String role, String nodeName, byte[] clientNonce) { + public Hello { + Objects.requireNonNull(role, "role"); + Objects.requireNonNull(nodeName, "nodeName"); + clientNonce = copyNonce(clientNonce, "clientNonce"); + } + + @Override + public byte[] clientNonce() { + return Arrays.copyOf(clientNonce, clientNonce.length); + } + } + + public record Challenge(byte[] serverNonce) { + public Challenge { + serverNonce = copyNonce(serverNonce, "serverNonce"); + } + + @Override + public byte[] serverNonce() { + return Arrays.copyOf(serverNonce, serverNonce.length); + } + } + + public record Response(byte[] hmac) { + public Response { + if (hmac == null || hmac.length != ProtocolConstants.AUTH_RESPONSE_BYTES) { + throw new IllegalArgumentException("hmac must be " + ProtocolConstants.AUTH_RESPONSE_BYTES + " bytes"); + } + hmac = Arrays.copyOf(hmac, hmac.length); + } + + @Override + public byte[] hmac() { + return Arrays.copyOf(hmac, hmac.length); + } + } + + public static byte[] randomNonce() { + byte[] nonce = new byte[ProtocolConstants.AUTH_NONCE_BYTES]; + SECURE_RANDOM.nextBytes(nonce); + return nonce; + } + + public static byte[] encodeHello(Hello hello) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream output = new DataOutputStream(bytes); + output.writeInt(hello.protocolVersion()); + output.writeUTF(hello.role()); + output.writeUTF(hello.nodeName()); + output.write(hello.clientNonce()); + output.flush(); + return bytes.toByteArray(); + } + + public static Hello decodeHello(byte[] payload) throws IOException { + DataInputStream input = new DataInputStream(new ByteArrayInputStream(payload)); + int protocolVersion = input.readInt(); + String role = input.readUTF(); + String nodeName = input.readUTF(); + byte[] clientNonce = input.readNBytes(ProtocolConstants.AUTH_NONCE_BYTES); + if (clientNonce.length != ProtocolConstants.AUTH_NONCE_BYTES || input.available() != 0) { + throw new ProtocolException("Invalid HELLO payload"); + } + return new Hello(protocolVersion, role, nodeName, clientNonce); + } + + public static byte[] encodeChallenge(Challenge challenge) throws IOException { + return challenge.serverNonce(); + } + + public static Challenge decodeChallenge(byte[] payload) throws IOException { + if (payload.length != ProtocolConstants.AUTH_NONCE_BYTES) { + throw new ProtocolException("Invalid AUTH_CHALLENGE payload"); + } + return new Challenge(payload); + } + + public static byte[] encodeResponse(Response response) { + return response.hmac(); + } + + public static Response decodeResponse(byte[] payload) throws IOException { + if (payload.length != ProtocolConstants.AUTH_RESPONSE_BYTES) { + throw new ProtocolException("Invalid AUTH_RESPONSE payload"); + } + return new Response(payload); + } + + public static byte[] computeResponse(String authToken, Hello hello, Challenge challenge) throws ProtocolException { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(authToken.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + updateUtf8(mac, ProtocolConstants.AUTH_CONTEXT); + updateUtf8(mac, hello.role()); + updateUtf8(mac, hello.nodeName()); + mac.update(hello.clientNonce()); + mac.update(challenge.serverNonce()); + return mac.doFinal(); + } catch (GeneralSecurityException e) { + throw new ProtocolException("Unable to compute authentication response", e); + } + } + + public static boolean constantTimeEquals(byte[] expected, byte[] actual) { + return MessageDigest.isEqual(expected, actual); + } + + private static void updateUtf8(Mac mac, String value) { + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + mac.update((byte) ((bytes.length >>> 8) & 0xff)); + mac.update((byte) (bytes.length & 0xff)); + mac.update(bytes); + } + + private static byte[] copyNonce(byte[] nonce, String name) { + if (nonce == null || nonce.length != ProtocolConstants.AUTH_NONCE_BYTES) { + throw new IllegalArgumentException(name + " must be " + ProtocolConstants.AUTH_NONCE_BYTES + " bytes"); + } + return Arrays.copyOf(nonce, nonce.length); + } +} diff --git a/common/src/main/java/me/proxylink/common/Frame.java b/common/src/main/java/me/proxylink/common/Frame.java new file mode 100644 index 0000000..331f082 --- /dev/null +++ b/common/src/main/java/me/proxylink/common/Frame.java @@ -0,0 +1,38 @@ +package me.proxylink.common; + +import java.util.Arrays; +import java.util.Objects; + +public final class Frame { + private static final byte[] EMPTY = new byte[0]; + + private final FrameType type; + private final long streamId; + private final byte[] payload; + + public Frame(FrameType type, long streamId, byte[] payload) { + this.type = Objects.requireNonNull(type, "type"); + this.streamId = streamId; + this.payload = payload == null || payload.length == 0 ? EMPTY : Arrays.copyOf(payload, payload.length); + } + + public static Frame empty(FrameType type, long streamId) { + return new Frame(type, streamId, EMPTY); + } + + public FrameType type() { + return type; + } + + public long streamId() { + return streamId; + } + + public byte[] payload() { + return payload.length == 0 ? EMPTY : Arrays.copyOf(payload, payload.length); + } + + public int payloadLength() { + return payload.length; + } +} diff --git a/common/src/main/java/me/proxylink/common/FrameCodec.java b/common/src/main/java/me/proxylink/common/FrameCodec.java new file mode 100644 index 0000000..15608c8 --- /dev/null +++ b/common/src/main/java/me/proxylink/common/FrameCodec.java @@ -0,0 +1,70 @@ +package me.proxylink.common; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +public final class FrameCodec { + private final DataInputStream input; + private final DataOutputStream output; + + public FrameCodec(InputStream input, OutputStream output) { + this.input = new DataInputStream(input); + this.output = new DataOutputStream(output); + } + + public Frame readFrame() throws IOException { + int magic; + try { + magic = input.readInt(); + } catch (EOFException eof) { + return null; + } + + if (magic != ProtocolConstants.MAGIC) { + throw new ProtocolException("Invalid frame magic"); + } + + int version = Short.toUnsignedInt(input.readShort()); + if (version != ProtocolConstants.PROTOCOL_VERSION) { + throw new ProtocolException("Unsupported protocol version: " + version); + } + + int typeCode = Byte.toUnsignedInt(input.readByte()); + input.readByte(); + long streamId = input.readLong(); + int payloadLength = input.readInt(); + + if (payloadLength < 0 || payloadLength > ProtocolConstants.MAX_FRAME_PAYLOAD_BYTES) { + throw new ProtocolException("Invalid frame payload length: " + payloadLength); + } + + byte[] payload = input.readNBytes(payloadLength); + if (payload.length != payloadLength) { + throw new EOFException("Unexpected end of stream while reading frame payload"); + } + + return new Frame(FrameType.fromCode(typeCode), streamId, payload); + } + + public void writeFrame(Frame frame) throws IOException { + byte[] payload = frame.payload(); + if (payload.length > ProtocolConstants.MAX_FRAME_PAYLOAD_BYTES) { + throw new ProtocolException("Frame payload is too large: " + payload.length); + } + + synchronized (output) { + output.writeInt(ProtocolConstants.MAGIC); + output.writeShort(ProtocolConstants.PROTOCOL_VERSION); + output.writeByte(frame.type().code()); + output.writeByte(0); + output.writeLong(frame.streamId()); + output.writeInt(payload.length); + output.write(payload); + output.flush(); + } + } +} diff --git a/common/src/main/java/me/proxylink/common/FrameType.java b/common/src/main/java/me/proxylink/common/FrameType.java new file mode 100644 index 0000000..f9c265f --- /dev/null +++ b/common/src/main/java/me/proxylink/common/FrameType.java @@ -0,0 +1,46 @@ +package me.proxylink.common; + +import java.util.HashMap; +import java.util.Map; + +public enum FrameType { + HELLO(1), + AUTH_CHALLENGE(2), + AUTH_RESPONSE(3), + AUTH_OK(4), + AUTH_FAILED(5), + PING(6), + PONG(7), + STREAM_OPEN(10), + STREAM_DATA(11), + STREAM_CLOSE(12), + STREAM_RESET(13), + ERROR(20), + GOAWAY(21); + + private static final Map BY_CODE = new HashMap<>(); + + static { + for (FrameType type : values()) { + BY_CODE.put(type.code, type); + } + } + + private final int code; + + FrameType(int code) { + this.code = code; + } + + public int code() { + return code; + } + + public static FrameType fromCode(int code) throws ProtocolException { + FrameType type = BY_CODE.get(code); + if (type == null) { + throw new ProtocolException("Unknown frame type: " + code); + } + return type; + } +} diff --git a/common/src/main/java/me/proxylink/common/ProtocolConstants.java b/common/src/main/java/me/proxylink/common/ProtocolConstants.java new file mode 100644 index 0000000..9ffaa6f --- /dev/null +++ b/common/src/main/java/me/proxylink/common/ProtocolConstants.java @@ -0,0 +1,14 @@ +package me.proxylink.common; + +public final class ProtocolConstants { + public static final int MAGIC = 0x44535032; + public static final int PROTOCOL_VERSION = 1; + public static final int MAX_FRAME_PAYLOAD_BYTES = 1024 * 1024; + public static final int STREAM_DATA_CHUNK_BYTES = 16 * 1024; + public static final int AUTH_NONCE_BYTES = 32; + public static final int AUTH_RESPONSE_BYTES = 32; + public static final String AUTH_CONTEXT = "DirtSimpleP2P ProxyLink Agent Auth v1"; + + private ProtocolConstants() { + } +} diff --git a/common/src/main/java/me/proxylink/common/ProtocolException.java b/common/src/main/java/me/proxylink/common/ProtocolException.java new file mode 100644 index 0000000..f723fe1 --- /dev/null +++ b/common/src/main/java/me/proxylink/common/ProtocolException.java @@ -0,0 +1,13 @@ +package me.proxylink.common; + +import java.io.IOException; + +public class ProtocolException extends IOException { + public ProtocolException(String message) { + super(message); + } + + public ProtocolException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/common/src/main/java/me/proxylink/common/StreamOpenPayload.java b/common/src/main/java/me/proxylink/common/StreamOpenPayload.java new file mode 100644 index 0000000..28c7396 --- /dev/null +++ b/common/src/main/java/me/proxylink/common/StreamOpenPayload.java @@ -0,0 +1,34 @@ +package me.proxylink.common; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.util.Objects; + +public record StreamOpenPayload(String routeName) { + public StreamOpenPayload { + Objects.requireNonNull(routeName, "routeName"); + if (routeName.isBlank()) { + throw new IllegalArgumentException("routeName must not be blank"); + } + } + + public byte[] encode() throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream output = new DataOutputStream(bytes); + output.writeUTF(routeName); + output.flush(); + return bytes.toByteArray(); + } + + public static StreamOpenPayload decode(byte[] payload) throws IOException { + DataInputStream input = new DataInputStream(new ByteArrayInputStream(payload)); + String routeName = input.readUTF(); + if (input.available() != 0) { + throw new ProtocolException("Invalid STREAM_OPEN payload"); + } + return new StreamOpenPayload(routeName); + } +} diff --git a/common/target/classes/me/proxylink/common/AgentConfig$Role.class b/common/target/classes/me/proxylink/common/AgentConfig$Role.class new file mode 100644 index 0000000..fb2c49b Binary files /dev/null and b/common/target/classes/me/proxylink/common/AgentConfig$Role.class differ diff --git a/common/target/classes/me/proxylink/common/AgentConfig$RouteConfig.class b/common/target/classes/me/proxylink/common/AgentConfig$RouteConfig.class new file mode 100644 index 0000000..2227cd4 Binary files /dev/null and b/common/target/classes/me/proxylink/common/AgentConfig$RouteConfig.class differ diff --git a/common/target/classes/me/proxylink/common/AgentConfig$TlsConfig.class b/common/target/classes/me/proxylink/common/AgentConfig$TlsConfig.class new file mode 100644 index 0000000..328828c Binary files /dev/null and b/common/target/classes/me/proxylink/common/AgentConfig$TlsConfig.class differ diff --git a/common/target/classes/me/proxylink/common/AgentConfig$TunnelConfig.class b/common/target/classes/me/proxylink/common/AgentConfig$TunnelConfig.class new file mode 100644 index 0000000..1b876f6 Binary files /dev/null and b/common/target/classes/me/proxylink/common/AgentConfig$TunnelConfig.class differ diff --git a/common/target/classes/me/proxylink/common/AgentConfig.class b/common/target/classes/me/proxylink/common/AgentConfig.class new file mode 100644 index 0000000..7119968 Binary files /dev/null and b/common/target/classes/me/proxylink/common/AgentConfig.class differ diff --git a/common/target/classes/me/proxylink/common/AgentConfigIO.class b/common/target/classes/me/proxylink/common/AgentConfigIO.class new file mode 100644 index 0000000..7f8ee76 Binary files /dev/null and b/common/target/classes/me/proxylink/common/AgentConfigIO.class differ diff --git a/common/target/classes/me/proxylink/common/AuthPayloads$Challenge.class b/common/target/classes/me/proxylink/common/AuthPayloads$Challenge.class new file mode 100644 index 0000000..1a3d2d7 Binary files /dev/null and b/common/target/classes/me/proxylink/common/AuthPayloads$Challenge.class differ diff --git a/common/target/classes/me/proxylink/common/AuthPayloads$Hello.class b/common/target/classes/me/proxylink/common/AuthPayloads$Hello.class new file mode 100644 index 0000000..ad407f2 Binary files /dev/null and b/common/target/classes/me/proxylink/common/AuthPayloads$Hello.class differ diff --git a/common/target/classes/me/proxylink/common/AuthPayloads$Response.class b/common/target/classes/me/proxylink/common/AuthPayloads$Response.class new file mode 100644 index 0000000..63ac882 Binary files /dev/null and b/common/target/classes/me/proxylink/common/AuthPayloads$Response.class differ diff --git a/common/target/classes/me/proxylink/common/AuthPayloads.class b/common/target/classes/me/proxylink/common/AuthPayloads.class new file mode 100644 index 0000000..014f120 Binary files /dev/null and b/common/target/classes/me/proxylink/common/AuthPayloads.class differ diff --git a/common/target/classes/me/proxylink/common/Frame.class b/common/target/classes/me/proxylink/common/Frame.class new file mode 100644 index 0000000..cdd6925 Binary files /dev/null and b/common/target/classes/me/proxylink/common/Frame.class differ diff --git a/common/target/classes/me/proxylink/common/FrameCodec.class b/common/target/classes/me/proxylink/common/FrameCodec.class new file mode 100644 index 0000000..2346515 Binary files /dev/null and b/common/target/classes/me/proxylink/common/FrameCodec.class differ diff --git a/common/target/classes/me/proxylink/common/FrameType.class b/common/target/classes/me/proxylink/common/FrameType.class new file mode 100644 index 0000000..8439d5c Binary files /dev/null and b/common/target/classes/me/proxylink/common/FrameType.class differ diff --git a/common/target/classes/me/proxylink/common/ProtocolConstants.class b/common/target/classes/me/proxylink/common/ProtocolConstants.class new file mode 100644 index 0000000..5638f8e Binary files /dev/null and b/common/target/classes/me/proxylink/common/ProtocolConstants.class differ diff --git a/common/target/classes/me/proxylink/common/ProtocolException.class b/common/target/classes/me/proxylink/common/ProtocolException.class new file mode 100644 index 0000000..0064f45 Binary files /dev/null and b/common/target/classes/me/proxylink/common/ProtocolException.class differ diff --git a/common/target/classes/me/proxylink/common/StreamOpenPayload.class b/common/target/classes/me/proxylink/common/StreamOpenPayload.class new file mode 100644 index 0000000..e7c20b6 Binary files /dev/null and b/common/target/classes/me/proxylink/common/StreamOpenPayload.class differ diff --git a/common/target/maven-archiver/pom.properties b/common/target/maven-archiver/pom.properties new file mode 100644 index 0000000..5f523d7 --- /dev/null +++ b/common/target/maven-archiver/pom.properties @@ -0,0 +1,3 @@ +artifactId=proxylink-common +groupId=me.proxylink +version=0.1.0-SNAPSHOT diff --git a/common/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/common/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst new file mode 100644 index 0000000..30f6586 --- /dev/null +++ b/common/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst @@ -0,0 +1,16 @@ +me/proxylink/common/AgentConfig$RouteConfig.class +me/proxylink/common/AgentConfig.class +me/proxylink/common/AgentConfigIO.class +me/proxylink/common/AuthPayloads$Response.class +me/proxylink/common/FrameType.class +me/proxylink/common/AuthPayloads$Challenge.class +me/proxylink/common/ProtocolConstants.class +me/proxylink/common/FrameCodec.class +me/proxylink/common/AuthPayloads$Hello.class +me/proxylink/common/AgentConfig$TunnelConfig.class +me/proxylink/common/AgentConfig$Role.class +me/proxylink/common/StreamOpenPayload.class +me/proxylink/common/AuthPayloads.class +me/proxylink/common/AgentConfig$TlsConfig.class +me/proxylink/common/ProtocolException.class +me/proxylink/common/Frame.class diff --git a/common/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/common/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst new file mode 100644 index 0000000..73b22f1 --- /dev/null +++ b/common/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst @@ -0,0 +1,9 @@ +/home/bitnix/Desktop/DirtSimpleP2P/common/src/main/java/me/proxylink/common/AgentConfig.java +/home/bitnix/Desktop/DirtSimpleP2P/common/src/main/java/me/proxylink/common/AgentConfigIO.java +/home/bitnix/Desktop/DirtSimpleP2P/common/src/main/java/me/proxylink/common/AuthPayloads.java +/home/bitnix/Desktop/DirtSimpleP2P/common/src/main/java/me/proxylink/common/Frame.java +/home/bitnix/Desktop/DirtSimpleP2P/common/src/main/java/me/proxylink/common/FrameCodec.java +/home/bitnix/Desktop/DirtSimpleP2P/common/src/main/java/me/proxylink/common/FrameType.java +/home/bitnix/Desktop/DirtSimpleP2P/common/src/main/java/me/proxylink/common/ProtocolConstants.java +/home/bitnix/Desktop/DirtSimpleP2P/common/src/main/java/me/proxylink/common/ProtocolException.java +/home/bitnix/Desktop/DirtSimpleP2P/common/src/main/java/me/proxylink/common/StreamOpenPayload.java diff --git a/common/target/proxylink-common-0.1.0-SNAPSHOT.jar b/common/target/proxylink-common-0.1.0-SNAPSHOT.jar new file mode 100644 index 0000000..037db0d Binary files /dev/null and b/common/target/proxylink-common-0.1.0-SNAPSHOT.jar differ diff --git a/plugin/pom.xml b/plugin/pom.xml new file mode 100644 index 0000000..1fb80d1 --- /dev/null +++ b/plugin/pom.xml @@ -0,0 +1,98 @@ + + + 4.0.0 + + + me.proxylink + dirtsimplep2p + 0.1.0-SNAPSHOT + + + dirtsimplep2p-plugin + jar + + + + papermc + https://repo.papermc.io/repository/maven-public/ + + + spigotmc-repo + https://hub.spigotmc.org/nexus/content/repositories/snapshots/ + + + sonatype-oss-snapshots + https://oss.sonatype.org/content/repositories/snapshots + + + + + + me.proxylink + proxylink-common + ${project.version} + + + org.bouncycastle + bcprov-jdk18on + 1.78.1 + + + org.bouncycastle + bcpkix-jdk18on + 1.78.1 + + + org.spigotmc + spigot-api + 1.20.1-R0.1-SNAPSHOT + provided + + + net.md-5 + bungeecord-api + 1.21-R0.4 + provided + + + + + DirtSimpleP2P-${project.version} + + + src/main/resources + true + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + + package + + shade + + + false + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + + diff --git a/plugin/src/main/java/me/proxylink/plugin/ConfigFileEditor.java b/plugin/src/main/java/me/proxylink/plugin/ConfigFileEditor.java new file mode 100644 index 0000000..05dbbce --- /dev/null +++ b/plugin/src/main/java/me/proxylink/plugin/ConfigFileEditor.java @@ -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 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); + } +} diff --git a/plugin/src/main/java/me/proxylink/plugin/PluginRuntime.java b/plugin/src/main/java/me/proxylink/plugin/PluginRuntime.java new file mode 100644 index 0000000..94f8cf5 --- /dev/null +++ b/plugin/src/main/java/me/proxylink/plugin/PluginRuntime.java @@ -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 statusLines() { + TunnelStatus status = status(); + List 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 doctorLines() { + List 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); + } +} diff --git a/plugin/src/main/java/me/proxylink/plugin/SecretTokenBootstrap.java b/plugin/src/main/java/me/proxylink/plugin/SecretTokenBootstrap.java new file mode 100644 index 0000000..429c3d2 --- /dev/null +++ b/plugin/src/main/java/me/proxylink/plugin/SecretTokenBootstrap.java @@ -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"); + } +} diff --git a/plugin/src/main/java/me/proxylink/plugin/bungee/DirtSimpleBungeeCommand.java b/plugin/src/main/java/me/proxylink/plugin/bungee/DirtSimpleBungeeCommand.java new file mode 100644 index 0000000..42698e3 --- /dev/null +++ b/plugin/src/main/java/me/proxylink/plugin/bungee/DirtSimpleBungeeCommand.java @@ -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 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)); + } +} diff --git a/plugin/src/main/java/me/proxylink/plugin/bungee/DirtSimpleBungeePlugin.java b/plugin/src/main/java/me/proxylink/plugin/bungee/DirtSimpleBungeePlugin.java new file mode 100644 index 0000000..2013249 --- /dev/null +++ b/plugin/src/main/java/me/proxylink/plugin/bungee/DirtSimpleBungeePlugin.java @@ -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; + } + } +} diff --git a/plugin/src/main/java/me/proxylink/plugin/paper/DirtSimplePaperCommand.java b/plugin/src/main/java/me/proxylink/plugin/paper/DirtSimplePaperCommand.java new file mode 100644 index 0000000..fcbcc16 --- /dev/null +++ b/plugin/src/main/java/me/proxylink/plugin/paper/DirtSimplePaperCommand.java @@ -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 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 onTabComplete(CommandSender sender, Command command, String alias, String[] args) { + if (args.length != 1) { + return List.of(); + } + String prefix = args[0].toLowerCase(Locale.ROOT); + List 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 lines) { + sender.sendMessage(ChatColor.GOLD + title); + for (String line : lines) { + sender.sendMessage(ChatColor.GRAY + "- " + ChatColor.WHITE + line); + } + } +} diff --git a/plugin/src/main/java/me/proxylink/plugin/paper/DirtSimplePaperPlugin.java b/plugin/src/main/java/me/proxylink/plugin/paper/DirtSimplePaperPlugin.java new file mode 100644 index 0000000..6ddd09c --- /dev/null +++ b/plugin/src/main/java/me/proxylink/plugin/paper/DirtSimplePaperPlugin.java @@ -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; + } + } +} diff --git a/plugin/src/main/java/me/proxylink/plugin/tls/CertificateFingerprints.java b/plugin/src/main/java/me/proxylink/plugin/tls/CertificateFingerprints.java new file mode 100644 index 0000000..2e1979b --- /dev/null +++ b/plugin/src/main/java/me/proxylink/plugin/tls/CertificateFingerprints.java @@ -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(); + } +} diff --git a/plugin/src/main/java/me/proxylink/plugin/tls/SelfSignedCertificateGenerator.java b/plugin/src/main/java/me/proxylink/plugin/tls/SelfSignedCertificateGenerator.java new file mode 100644 index 0000000..4f0efbe --- /dev/null +++ b/plugin/src/main/java/me/proxylink/plugin/tls/SelfSignedCertificateGenerator.java @@ -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("=", "_"); + } +} diff --git a/plugin/src/main/java/me/proxylink/plugin/tls/TlsBootstrap.java b/plugin/src/main/java/me/proxylink/plugin/tls/TlsBootstrap.java new file mode 100644 index 0000000..229ca29 --- /dev/null +++ b/plugin/src/main/java/me/proxylink/plugin/tls/TlsBootstrap.java @@ -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); + } +} diff --git a/plugin/src/main/java/me/proxylink/plugin/tunnel/BackendAgent.java b/plugin/src/main/java/me/proxylink/plugin/tunnel/BackendAgent.java new file mode 100644 index 0000000..d18b8c2 --- /dev/null +++ b/plugin/src/main/java/me/proxylink/plugin/tunnel/BackendAgent.java @@ -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 learnedTlsFingerprintConsumer; + private final ExecutorService executor = Executors.newThreadPerTaskExecutor( + new NamedThreadFactory("proxylink-backend", true) + ); + private final AtomicBoolean running = new AtomicBoolean(false); + private final AtomicReference learnedTlsFingerprint = new AtomicReference<>(""); + private final AtomicReference lastEvent = new AtomicReference<>("not started"); + private volatile BackendTunnel activeTunnel; + + public BackendAgent(AgentConfig config) { + this(config, ignored -> { + }); + } + + public BackendAgent(AgentConfig config, Consumer 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 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))); + } + } + } +} diff --git a/plugin/src/main/java/me/proxylink/plugin/tunnel/FrontendAgent.java b/plugin/src/main/java/me/proxylink/plugin/tunnel/FrontendAgent.java new file mode 100644 index 0000000..1f914d4 --- /dev/null +++ b/plugin/src/main/java/me/proxylink/plugin/tunnel/FrontendAgent.java @@ -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 activeTunnel = new AtomicReference<>(); + private final AtomicReference lastEvent = new AtomicReference<>("not started"); + private final List 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 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))); + } + } + } +} diff --git a/plugin/src/main/java/me/proxylink/plugin/tunnel/LocalStream.java b/plugin/src/main/java/me/proxylink/plugin/tunnel/LocalStream.java new file mode 100644 index 0000000..6d0cbe2 --- /dev/null +++ b/plugin/src/main/java/me/proxylink/plugin/tunnel/LocalStream.java @@ -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) { + } + } +} diff --git a/plugin/src/main/java/me/proxylink/plugin/tunnel/ManagedAgent.java b/plugin/src/main/java/me/proxylink/plugin/tunnel/ManagedAgent.java new file mode 100644 index 0000000..4ff7eae --- /dev/null +++ b/plugin/src/main/java/me/proxylink/plugin/tunnel/ManagedAgent.java @@ -0,0 +1,10 @@ +package me.proxylink.plugin.tunnel; + +public interface ManagedAgent extends AutoCloseable { + void start() throws Exception; + + TunnelStatus status(); + + @Override + void close(); +} diff --git a/plugin/src/main/java/me/proxylink/plugin/tunnel/NamedThreadFactory.java b/plugin/src/main/java/me/proxylink/plugin/tunnel/NamedThreadFactory.java new file mode 100644 index 0000000..d4634b4 --- /dev/null +++ b/plugin/src/main/java/me/proxylink/plugin/tunnel/NamedThreadFactory.java @@ -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; + } +} diff --git a/plugin/src/main/java/me/proxylink/plugin/tunnel/PinnedCertificateTrustManager.java b/plugin/src/main/java/me/proxylink/plugin/tunnel/PinnedCertificateTrustManager.java new file mode 100644 index 0000000..b9679e2 --- /dev/null +++ b/plugin/src/main/java/me/proxylink/plugin/tunnel/PinnedCertificateTrustManager.java @@ -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; + } +} diff --git a/plugin/src/main/java/me/proxylink/plugin/tunnel/TlsSocketFactory.java b/plugin/src/main/java/me/proxylink/plugin/tunnel/TlsSocketFactory.java new file mode 100644 index 0000000..c0e98ad --- /dev/null +++ b/plugin/src/main/java/me/proxylink/plugin/tunnel/TlsSocketFactory.java @@ -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 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; + } +} diff --git a/plugin/src/main/java/me/proxylink/plugin/tunnel/TunnelStatus.java b/plugin/src/main/java/me/proxylink/plugin/tunnel/TunnelStatus.java new file mode 100644 index 0000000..2f3a268 --- /dev/null +++ b/plugin/src/main/java/me/proxylink/plugin/tunnel/TunnelStatus.java @@ -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); + } +} diff --git a/plugin/src/main/resources/bungee-default.properties b/plugin/src/main/resources/bungee-default.properties new file mode 100644 index 0000000..3674d4e --- /dev/null +++ b/plugin/src/main/resources/bungee-default.properties @@ -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 diff --git a/plugin/src/main/resources/bungee.yml b/plugin/src/main/resources/bungee.yml new file mode 100644 index 0000000..6699846 --- /dev/null +++ b/plugin/src/main/resources/bungee.yml @@ -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. diff --git a/plugin/src/main/resources/paper-default.properties b/plugin/src/main/resources/paper-default.properties new file mode 100644 index 0000000..c49e70f --- /dev/null +++ b/plugin/src/main/resources/paper-default.properties @@ -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 diff --git a/plugin/src/main/resources/plugin.yml b/plugin/src/main/resources/plugin.yml new file mode 100644 index 0000000..2cfd1ba --- /dev/null +++ b/plugin/src/main/resources/plugin.yml @@ -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 diff --git a/plugin/target/DirtSimpleP2P-0.1.0-SNAPSHOT.jar b/plugin/target/DirtSimpleP2P-0.1.0-SNAPSHOT.jar new file mode 100644 index 0000000..c8437ce Binary files /dev/null and b/plugin/target/DirtSimpleP2P-0.1.0-SNAPSHOT.jar differ diff --git a/plugin/target/classes/bungee-default.properties b/plugin/target/classes/bungee-default.properties new file mode 100644 index 0000000..3674d4e --- /dev/null +++ b/plugin/target/classes/bungee-default.properties @@ -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 diff --git a/plugin/target/classes/bungee.yml b/plugin/target/classes/bungee.yml new file mode 100644 index 0000000..02fdab5 --- /dev/null +++ b/plugin/target/classes/bungee.yml @@ -0,0 +1,5 @@ +name: DirtSimpleP2P +version: 0.1.0-SNAPSHOT +main: me.proxylink.plugin.bungee.DirtSimpleBungeePlugin +author: DirtSimpleP2P +description: NAT-safe Minecraft TCP tunnel frontend plugin. diff --git a/plugin/target/classes/me/proxylink/plugin/ConfigFileEditor.class b/plugin/target/classes/me/proxylink/plugin/ConfigFileEditor.class new file mode 100644 index 0000000..dacc19c Binary files /dev/null and b/plugin/target/classes/me/proxylink/plugin/ConfigFileEditor.class differ diff --git a/plugin/target/classes/me/proxylink/plugin/PluginRuntime.class b/plugin/target/classes/me/proxylink/plugin/PluginRuntime.class new file mode 100644 index 0000000..62b4da3 Binary files /dev/null and b/plugin/target/classes/me/proxylink/plugin/PluginRuntime.class differ diff --git a/plugin/target/classes/me/proxylink/plugin/SecretTokenBootstrap.class b/plugin/target/classes/me/proxylink/plugin/SecretTokenBootstrap.class new file mode 100644 index 0000000..4d165e6 Binary files /dev/null and b/plugin/target/classes/me/proxylink/plugin/SecretTokenBootstrap.class differ diff --git a/plugin/target/classes/me/proxylink/plugin/bungee/DirtSimpleBungeeCommand.class b/plugin/target/classes/me/proxylink/plugin/bungee/DirtSimpleBungeeCommand.class new file mode 100644 index 0000000..7b40b9e Binary files /dev/null and b/plugin/target/classes/me/proxylink/plugin/bungee/DirtSimpleBungeeCommand.class differ diff --git a/plugin/target/classes/me/proxylink/plugin/bungee/DirtSimpleBungeePlugin.class b/plugin/target/classes/me/proxylink/plugin/bungee/DirtSimpleBungeePlugin.class new file mode 100644 index 0000000..0fe26be Binary files /dev/null and b/plugin/target/classes/me/proxylink/plugin/bungee/DirtSimpleBungeePlugin.class differ diff --git a/plugin/target/classes/me/proxylink/plugin/paper/DirtSimplePaperCommand.class b/plugin/target/classes/me/proxylink/plugin/paper/DirtSimplePaperCommand.class new file mode 100644 index 0000000..80383e3 Binary files /dev/null and b/plugin/target/classes/me/proxylink/plugin/paper/DirtSimplePaperCommand.class differ diff --git a/plugin/target/classes/me/proxylink/plugin/paper/DirtSimplePaperPlugin.class b/plugin/target/classes/me/proxylink/plugin/paper/DirtSimplePaperPlugin.class new file mode 100644 index 0000000..0296025 Binary files /dev/null and b/plugin/target/classes/me/proxylink/plugin/paper/DirtSimplePaperPlugin.class differ diff --git a/plugin/target/classes/me/proxylink/plugin/tls/CertificateFingerprints.class b/plugin/target/classes/me/proxylink/plugin/tls/CertificateFingerprints.class new file mode 100644 index 0000000..1f34c39 Binary files /dev/null and b/plugin/target/classes/me/proxylink/plugin/tls/CertificateFingerprints.class differ diff --git a/plugin/target/classes/me/proxylink/plugin/tls/SelfSignedCertificateGenerator.class b/plugin/target/classes/me/proxylink/plugin/tls/SelfSignedCertificateGenerator.class new file mode 100644 index 0000000..4821bf0 Binary files /dev/null and b/plugin/target/classes/me/proxylink/plugin/tls/SelfSignedCertificateGenerator.class differ diff --git a/plugin/target/classes/me/proxylink/plugin/tls/TlsBootstrap.class b/plugin/target/classes/me/proxylink/plugin/tls/TlsBootstrap.class new file mode 100644 index 0000000..2f9d1f8 Binary files /dev/null and b/plugin/target/classes/me/proxylink/plugin/tls/TlsBootstrap.class differ diff --git a/plugin/target/classes/me/proxylink/plugin/tunnel/BackendAgent$1.class b/plugin/target/classes/me/proxylink/plugin/tunnel/BackendAgent$1.class new file mode 100644 index 0000000..b6459b3 Binary files /dev/null and b/plugin/target/classes/me/proxylink/plugin/tunnel/BackendAgent$1.class differ diff --git a/plugin/target/classes/me/proxylink/plugin/tunnel/BackendAgent$BackendTunnel.class b/plugin/target/classes/me/proxylink/plugin/tunnel/BackendAgent$BackendTunnel.class new file mode 100644 index 0000000..780d289 Binary files /dev/null and b/plugin/target/classes/me/proxylink/plugin/tunnel/BackendAgent$BackendTunnel.class differ diff --git a/plugin/target/classes/me/proxylink/plugin/tunnel/BackendAgent.class b/plugin/target/classes/me/proxylink/plugin/tunnel/BackendAgent.class new file mode 100644 index 0000000..208e111 Binary files /dev/null and b/plugin/target/classes/me/proxylink/plugin/tunnel/BackendAgent.class differ diff --git a/plugin/target/classes/me/proxylink/plugin/tunnel/FrontendAgent$1.class b/plugin/target/classes/me/proxylink/plugin/tunnel/FrontendAgent$1.class new file mode 100644 index 0000000..d630224 Binary files /dev/null and b/plugin/target/classes/me/proxylink/plugin/tunnel/FrontendAgent$1.class differ diff --git a/plugin/target/classes/me/proxylink/plugin/tunnel/FrontendAgent$FrontendTunnel.class b/plugin/target/classes/me/proxylink/plugin/tunnel/FrontendAgent$FrontendTunnel.class new file mode 100644 index 0000000..6607a65 Binary files /dev/null and b/plugin/target/classes/me/proxylink/plugin/tunnel/FrontendAgent$FrontendTunnel.class differ diff --git a/plugin/target/classes/me/proxylink/plugin/tunnel/FrontendAgent.class b/plugin/target/classes/me/proxylink/plugin/tunnel/FrontendAgent.class new file mode 100644 index 0000000..fe63388 Binary files /dev/null and b/plugin/target/classes/me/proxylink/plugin/tunnel/FrontendAgent.class differ diff --git a/plugin/target/classes/me/proxylink/plugin/tunnel/LocalStream.class b/plugin/target/classes/me/proxylink/plugin/tunnel/LocalStream.class new file mode 100644 index 0000000..fda2448 Binary files /dev/null and b/plugin/target/classes/me/proxylink/plugin/tunnel/LocalStream.class differ diff --git a/plugin/target/classes/me/proxylink/plugin/tunnel/ManagedAgent.class b/plugin/target/classes/me/proxylink/plugin/tunnel/ManagedAgent.class new file mode 100644 index 0000000..170dba6 Binary files /dev/null and b/plugin/target/classes/me/proxylink/plugin/tunnel/ManagedAgent.class differ diff --git a/plugin/target/classes/me/proxylink/plugin/tunnel/NamedThreadFactory.class b/plugin/target/classes/me/proxylink/plugin/tunnel/NamedThreadFactory.class new file mode 100644 index 0000000..089badc Binary files /dev/null and b/plugin/target/classes/me/proxylink/plugin/tunnel/NamedThreadFactory.class differ diff --git a/plugin/target/classes/me/proxylink/plugin/tunnel/PinnedCertificateTrustManager.class b/plugin/target/classes/me/proxylink/plugin/tunnel/PinnedCertificateTrustManager.class new file mode 100644 index 0000000..492499a Binary files /dev/null and b/plugin/target/classes/me/proxylink/plugin/tunnel/PinnedCertificateTrustManager.class differ diff --git a/plugin/target/classes/me/proxylink/plugin/tunnel/TlsSocketFactory.class b/plugin/target/classes/me/proxylink/plugin/tunnel/TlsSocketFactory.class new file mode 100644 index 0000000..dd60367 Binary files /dev/null and b/plugin/target/classes/me/proxylink/plugin/tunnel/TlsSocketFactory.class differ diff --git a/plugin/target/classes/me/proxylink/plugin/tunnel/TunnelStatus.class b/plugin/target/classes/me/proxylink/plugin/tunnel/TunnelStatus.class new file mode 100644 index 0000000..fe3ec2b Binary files /dev/null and b/plugin/target/classes/me/proxylink/plugin/tunnel/TunnelStatus.class differ diff --git a/plugin/target/classes/paper-default.properties b/plugin/target/classes/paper-default.properties new file mode 100644 index 0000000..c49e70f --- /dev/null +++ b/plugin/target/classes/paper-default.properties @@ -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 diff --git a/plugin/target/classes/plugin.yml b/plugin/target/classes/plugin.yml new file mode 100644 index 0000000..8600ce2 --- /dev/null +++ b/plugin/target/classes/plugin.yml @@ -0,0 +1,16 @@ +name: DirtSimpleP2P +version: 0.1.0-SNAPSHOT +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 diff --git a/plugin/target/maven-archiver/pom.properties b/plugin/target/maven-archiver/pom.properties new file mode 100644 index 0000000..ce03317 --- /dev/null +++ b/plugin/target/maven-archiver/pom.properties @@ -0,0 +1,3 @@ +artifactId=dirtsimplep2p-plugin +groupId=me.proxylink +version=0.1.0-SNAPSHOT diff --git a/plugin/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/plugin/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst new file mode 100644 index 0000000..1b4f038 --- /dev/null +++ b/plugin/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst @@ -0,0 +1,22 @@ +me/proxylink/plugin/tunnel/FrontendAgent.class +me/proxylink/plugin/tunnel/BackendAgent.class +me/proxylink/plugin/tunnel/TlsSocketFactory.class +me/proxylink/plugin/paper/DirtSimplePaperPlugin.class +me/proxylink/plugin/tunnel/FrontendAgent$1.class +me/proxylink/plugin/tls/CertificateFingerprints.class +me/proxylink/plugin/tunnel/PinnedCertificateTrustManager.class +me/proxylink/plugin/tunnel/BackendAgent$1.class +me/proxylink/plugin/bungee/DirtSimpleBungeeCommand.class +me/proxylink/plugin/tunnel/TunnelStatus.class +me/proxylink/plugin/tunnel/LocalStream.class +me/proxylink/plugin/tls/TlsBootstrap.class +me/proxylink/plugin/tunnel/FrontendAgent$FrontendTunnel.class +me/proxylink/plugin/tls/SelfSignedCertificateGenerator.class +me/proxylink/plugin/PluginRuntime.class +me/proxylink/plugin/ConfigFileEditor.class +me/proxylink/plugin/SecretTokenBootstrap.class +me/proxylink/plugin/tunnel/NamedThreadFactory.class +me/proxylink/plugin/bungee/DirtSimpleBungeePlugin.class +me/proxylink/plugin/paper/DirtSimplePaperCommand.class +me/proxylink/plugin/tunnel/BackendAgent$BackendTunnel.class +me/proxylink/plugin/tunnel/ManagedAgent.class diff --git a/plugin/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/plugin/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst new file mode 100644 index 0000000..43668ac --- /dev/null +++ b/plugin/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst @@ -0,0 +1,18 @@ +/home/bitnix/Desktop/DirtSimpleP2P/plugin/src/main/java/me/proxylink/plugin/ConfigFileEditor.java +/home/bitnix/Desktop/DirtSimpleP2P/plugin/src/main/java/me/proxylink/plugin/PluginRuntime.java +/home/bitnix/Desktop/DirtSimpleP2P/plugin/src/main/java/me/proxylink/plugin/SecretTokenBootstrap.java +/home/bitnix/Desktop/DirtSimpleP2P/plugin/src/main/java/me/proxylink/plugin/bungee/DirtSimpleBungeeCommand.java +/home/bitnix/Desktop/DirtSimpleP2P/plugin/src/main/java/me/proxylink/plugin/bungee/DirtSimpleBungeePlugin.java +/home/bitnix/Desktop/DirtSimpleP2P/plugin/src/main/java/me/proxylink/plugin/paper/DirtSimplePaperCommand.java +/home/bitnix/Desktop/DirtSimpleP2P/plugin/src/main/java/me/proxylink/plugin/paper/DirtSimplePaperPlugin.java +/home/bitnix/Desktop/DirtSimpleP2P/plugin/src/main/java/me/proxylink/plugin/tls/CertificateFingerprints.java +/home/bitnix/Desktop/DirtSimpleP2P/plugin/src/main/java/me/proxylink/plugin/tls/SelfSignedCertificateGenerator.java +/home/bitnix/Desktop/DirtSimpleP2P/plugin/src/main/java/me/proxylink/plugin/tls/TlsBootstrap.java +/home/bitnix/Desktop/DirtSimpleP2P/plugin/src/main/java/me/proxylink/plugin/tunnel/BackendAgent.java +/home/bitnix/Desktop/DirtSimpleP2P/plugin/src/main/java/me/proxylink/plugin/tunnel/FrontendAgent.java +/home/bitnix/Desktop/DirtSimpleP2P/plugin/src/main/java/me/proxylink/plugin/tunnel/LocalStream.java +/home/bitnix/Desktop/DirtSimpleP2P/plugin/src/main/java/me/proxylink/plugin/tunnel/ManagedAgent.java +/home/bitnix/Desktop/DirtSimpleP2P/plugin/src/main/java/me/proxylink/plugin/tunnel/NamedThreadFactory.java +/home/bitnix/Desktop/DirtSimpleP2P/plugin/src/main/java/me/proxylink/plugin/tunnel/PinnedCertificateTrustManager.java +/home/bitnix/Desktop/DirtSimpleP2P/plugin/src/main/java/me/proxylink/plugin/tunnel/TlsSocketFactory.java +/home/bitnix/Desktop/DirtSimpleP2P/plugin/src/main/java/me/proxylink/plugin/tunnel/TunnelStatus.java diff --git a/plugin/target/original-DirtSimpleP2P-0.1.0-SNAPSHOT.jar b/plugin/target/original-DirtSimpleP2P-0.1.0-SNAPSHOT.jar new file mode 100644 index 0000000..73f5623 Binary files /dev/null and b/plugin/target/original-DirtSimpleP2P-0.1.0-SNAPSHOT.jar differ diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..a8a7b33 --- /dev/null +++ b/pom.xml @@ -0,0 +1,44 @@ + + + 4.0.0 + + me.proxylink + dirtsimplep2p + 0.1.0-SNAPSHOT + pom + + DirtSimpleP2P + NAT-safe Minecraft TCP tunnel as one universal Bungee and Paper/Spigot plugin jar. + + + common + plugin + + + + 21 + UTF-8 + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + 21 + + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + + +