first commit

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