Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

RestPAPI exposes PlaceholderAPI values from each Bukkit/Paper backend over HTTP. Install PlaceholderAPI and this plugin on **each** backend you want to query. Velocity forwards Minecraft traffic, not these HTTP requests.

## HTTP server

RestPAPI embeds **Jetty 12.1** directly. Spark Java is not used.

Jetty keeps its internal server work on its platform-thread pool and dispatches blocking application work through a Java virtual-thread executor. The REST handlers are declared as blocking handlers, so offline-player resolution and PlaceholderAPI evaluation can run on virtual threads without occupying the Minecraft server thread.

Routing uses Jetty's native `PathMappingsHandler` and `UriTemplatePathSpec`. Request concurrency is enforced by `QoSHandler`, and source-address allowlisting is enforced by `InetAccessHandler`.

## Configuration

On first start, `plugins/RestPAPI/config.yml` is created with two random UUID tokens. Keep them private. Example:
Expand All @@ -12,6 +20,7 @@ bind: 0.0.0.0
tokens:
- "replace-with-a-long-random-secret"
max-concurrent: 16
shutdown-timeout-ms: 5000
rate-limit:
requests: 60
window-seconds: 60
Expand All @@ -20,11 +29,17 @@ allowed-ips: []

Tokens must contain at least 16 characters, cannot be blank or padded with spaces, and must be unique. A missing or invalid token configuration prevents startup. To rotate tokens, temporarily include old and new tokens, run `/restpapi reload`, update clients, then remove the old token and reload again. Never print the tokens or put them in browser JavaScript.

`bind` selects the interface **inside the container** (default `0.0.0.0`). `allowed-ips` is an exact match list of socket peer IPs; an empty list permits any peer with a valid token. Behind Nginx it will normally see the proxy address, not the original client. It deliberately ignores `X-Forwarded-For`. The rate limit is per socket peer and uses a fixed window; no more than 4096 distinct peers are tracked. The concurrency limit returns HTTP 503 instead of queuing unbounded lookups.
`bind` selects the interface **inside the container** (default `0.0.0.0`). `allowed-ips` is enforced by Jetty against the real remote address of the connection rather than forwarded headers. An empty list permits any peer with a valid token. Values may use Jetty address patterns such as an exact address or CIDR range. Behind Nginx, RestPAPI will normally see the proxy address unless the network topology preserves the original peer address; it deliberately does not trust `X-Forwarded-For` for access control.

`max-concurrent` is enforced by Jetty's `QoSHandler`. Requests beyond the configured number are rejected immediately with HTTP 503 instead of accumulating an unbounded queue. Jetty's virtual-thread executor has its own resource guard, while `max-concurrent` remains the application-level limit for REST requests. `shutdown-timeout-ms` controls how long Jetty waits for in-flight requests to finish during reload or shutdown; new requests are rejected with HTTP 503 once graceful shutdown begins.

The fixed-window rate limit remains implemented by RestPAPI because Jetty's built-in DoS rate limiting uses per-second/leaky-bucket semantics rather than the existing `requests` plus arbitrary `window-seconds` contract. No more than 4096 distinct peers are tracked by the fixed-window limiter.

Placeholder evaluation, including `Bukkit.getOfflinePlayer(UUID)`, is never scheduled onto the Minecraft main thread. Both player and server placeholder routes execute from Jetty request handling. Third-party PlaceholderAPI expansions queried through this API must therefore support off-thread evaluation; PlaceholderAPI does not make expansion code thread-safe automatically.

Placeholder evaluation, including `Bukkit.getOfflinePlayer(UUID)`, runs directly on Spark's Jetty worker thread and is not scheduled onto the Minecraft main thread. This keeps offline-player lookups and PlaceholderAPI evaluation off the server tick thread, but it also means every expansion queried through this API must support off-thread execution. PlaceholderAPI does not make third-party expansions thread-safe automatically. There is no server-side lookup timeout because a synchronous expansion running on the current Spark worker cannot be safely preempted; use `max-concurrent` to bound concurrent evaluations and configure request timeouts in the HTTP client or reverse proxy.
There is no server-side placeholder timeout because synchronous third-party expansion code cannot be safely preempted. Configure request timeouts in the HTTP client or reverse proxy and use `max-concurrent` to bound simultaneous evaluations.

The command `/restpapi reload` reads the file again, validates it before stopping the old listener, and attempts to restore the old listener if binding the new one fails. A failed rollback disables the plugin. Listener shutdown and startup take place off the Minecraft main thread; the command reports the result after they finish. Requests that begin after shutdown starts receive 503. A successful reload updates both the port and token set.
The command `/restpapi reload` reads the file again, validates it before stopping the old listener, and attempts to restore the old listener if binding the new one fails. Reload lifecycle work runs on a dedicated Java virtual-thread executor. Jetty's `GracefulHandler` drains in-flight requests for up to `shutdown-timeout-ms` while rejecting new requests with 503. A failed rollback disables the plugin. A successful reload updates the port, bind address, tokens, limits, graceful-shutdown timeout, and allowlist.

## Requests

Expand Down Expand Up @@ -53,4 +68,4 @@ Then query `https://api.example.com/survival/UUID/player_name` with the `Token`

## Build

Use JDK 25 and `bash gradlew test shadowJar` (Gradle 9.8.0). The plugin targets Paper API 1.21.11; the shaded JAR is written under `build/libs`.
Use JDK 25 and `bash gradlew test shadowJar` (Gradle 9.8.0). The plugin targets Paper API 1.21.11 and embeds Jetty 12.1.13; the shaded JAR is written under `build/libs`.
8 changes: 4 additions & 4 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,14 @@ tasks.jar {

shadowJar {
relocate 'org.bstats', 'me.fredthedoggy.restpapi.libs.bstats'
relocate 'spark', 'me.fredthedoggy.restpapi.libs.spark'
relocate 'org.eclipse.jetty', 'me.fredthedoggy.restpapi.libs.jetty'
relocate 'com.google.gson', 'me.fredthedoggy.restpapi.libs.gson'
archiveBaseName.set('Restpapi')
archiveVersion.set('1.0.5')
archiveClassifier.set('')
// Removed manifest block as it's not needed for Bukkit plugins

// Explicitly include the main source set output for relocation
from sourceSets.main.output

}

// Make sure 'build' depends on 'shadowJar'
Expand All @@ -51,7 +49,9 @@ dependencies {
compileOnly 'io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT'
compileOnly 'me.clip:placeholderapi:2.12.3'
implementation 'org.bstats:bstats-bukkit:3.0.2'
implementation 'com.sparkjava:spark-core:2.9.4'
implementation('org.eclipse.jetty:jetty-server:12.1.13') {
exclude group: 'org.slf4j', module: 'slf4j-api'
}
implementation 'com.google.code.gson:gson:2.11.0'
testImplementation 'org.junit.jupiter:junit-jupiter:5.11.4'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
Expand Down
246 changes: 246 additions & 0 deletions src/main/java/me/fredthedoggy/restpapi/JettyServer.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
package me.fredthedoggy.restpapi;

import com.google.gson.Gson;
import me.clip.placeholderapi.PlaceholderAPI;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.http.HttpStatus;
import org.eclipse.jetty.http.pathmap.UriTemplatePathSpec;
import org.eclipse.jetty.io.Content;
import org.eclipse.jetty.server.Handler;
import org.eclipse.jetty.server.Request;
import org.eclipse.jetty.server.Response;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.ServerConnector;
import org.eclipse.jetty.server.handler.GracefulHandler;
import org.eclipse.jetty.server.handler.InetAccessHandler;
import org.eclipse.jetty.server.handler.PathMappingsHandler;
import org.eclipse.jetty.server.handler.QoSHandler;
import org.eclipse.jetty.util.Callback;
import org.eclipse.jetty.util.thread.QueuedThreadPool;
import org.eclipse.jetty.util.thread.VirtualThreadPool;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Level;

final class JettyServer {
private static final Gson JSON = new Gson();
private static final UriTemplatePathSpec SERVER_ROUTE = new UriTemplatePathSpec("/server/{placeholder}");
private static final UriTemplatePathSpec PLAYER_ROUTE = new UriTemplatePathSpec("/{uuid}/{placeholder}");

private final Restpapi plugin;
private final RestConfig config;
private final RequestLimiter limiter;
private final AtomicBoolean stopped = new AtomicBoolean(false);
private Server server;

JettyServer(Restpapi plugin, RestConfig config) {
this.plugin = plugin;
this.config = config;
limiter = new RequestLimiter(config.rateLimit(), config.rateWindowSeconds());
}

void start() {
Server jetty = null;
try {
VirtualThreadPool virtualThreads = new VirtualThreadPool();
virtualThreads.setName("restpapi-virtual");

QueuedThreadPool platformThreads = new QueuedThreadPool();
platformThreads.setName("restpapi-jetty");
platformThreads.setVirtualThreadsExecutor(virtualThreads);

jetty = new Server(platformThreads);
jetty.addBean(virtualThreads);
jetty.setStopTimeout(config.shutdownTimeoutMillis());

ServerConnector connector = new ServerConnector(jetty);
connector.setHost(config.bind());
connector.setPort(config.port());
jetty.addConnector(connector);

PathMappingsHandler routes = new PathMappingsHandler.NoContext();
routes.addMapping(SERVER_ROUTE, new PlaceholderHandler(SERVER_ROUTE, false));
routes.addMapping(PLAYER_ROUTE, new PlaceholderHandler(PLAYER_ROUTE, true));

Handler routed = new Handler.Sequence(routes, new NotFoundHandler());

JsonQoSHandler qos = new JsonQoSHandler(routed);
qos.setMaxRequestCount(config.maxConcurrent());
qos.setMaxSuspendedRequestCount(0);
qos.setRejectStatusCode(HttpStatus.SERVICE_UNAVAILABLE_503);

Handler protectedHandler = qos;
if (!config.allowedIps().isEmpty()) {
JsonInetAccessHandler access = new JsonInetAccessHandler(protectedHandler);
access.include(config.allowedIps().toArray(String[]::new));
protectedHandler = access;
}

jetty.setHandler(new JsonGracefulHandler(protectedHandler));
jetty.start();
server = jetty;
} catch (Exception exception) {
if (jetty != null) {
try {
jetty.stop();
} catch (Exception stopException) {
exception.addSuppressed(stopException);
}
}
throw new IllegalStateException("Jetty HTTP server could not start", exception);
}
}

private final class PlaceholderHandler extends Handler.Abstract {
private final UriTemplatePathSpec route;
private final boolean playerRoute;

private PlaceholderHandler(UriTemplatePathSpec route, boolean playerRoute) {
this.route = route;
this.playerRoute = playerRoute;
}

@Override
public boolean handle(Request request, Response response, Callback callback) {
if (!"GET".equals(request.getMethod())) {
return writeJson(response, callback, 404, "Invalid URI");
}
if (!plugin.isEnabled()) {
return writeJson(response, callback, 503, "Service Unavailable");
}
if (!authorized(request.getHeaders().get("Token"))) {
return writeJson(response, callback, 401, "Unauthorized");
}

String peer = Request.getRemoteAddr(request);
if (!limiter.allow(peer)) {
return writeJson(response, callback, 429, "Too Many Requests");
}

try {
Map<String, String> params = route.getPathParams(Request.getPathInContext(request));
String name = params.get("placeholder");
if (name == null || name.isEmpty() || name.length() > 256 || name.indexOf('%') >= 0) {
return writeJson(response, callback, 400, "Invalid Placeholder");
}

OfflinePlayer player = null;
if (playerRoute) {
UUID playerId;
try {
playerId = UUID.fromString(params.get("uuid"));
} catch (IllegalArgumentException | NullPointerException exception) {
return writeJson(response, callback, 400, "Invalid UUID");
}

player = Bukkit.getOfflinePlayer(playerId);
if (!player.hasPlayedBefore() && !player.isOnline()) {
return writeJson(response, callback, 400, "Player Has Not Played Before");
}
}

String expression = "%" + name + "%";
String result = PlaceholderAPI.setPlaceholders(player, expression);
if (!plugin.isEnabled()) {
return writeJson(response, callback, 503, "Service Unavailable");
}

int status = placeholderStatus(expression, result);
return writeJson(response, callback, status, status == 406 ? "Invalid Placeholder" : result);
} catch (Exception exception) {
plugin.getLogger().log(Level.WARNING, "Placeholder lookup failed", exception);
return writeJson(response, callback, 500, "Internal Server Error");
}
}
}

boolean authorized(String provided) {
if (provided == null || provided.isEmpty()) return false;
byte[] candidate = provided.getBytes(StandardCharsets.UTF_8);
boolean matched = false;
for (String token : config.tokens()) {
matched |= MessageDigest.isEqual(candidate, token.getBytes(StandardCharsets.UTF_8));
}
return matched;
}

static int placeholderStatus(String expression, String result) {
return expression.equals(result) ? 406 : 200;
}

static String jsonPayload(int status, String message) {
return JSON.toJson(new Lookup(status, message));
}

private static boolean writeJson(Response response, Callback callback, int status, String message) {
response.setStatus(status);
response.getHeaders().put(HttpHeader.CONTENT_TYPE, "application/json; charset=UTF-8");
Content.Sink.write(response, true, jsonPayload(status, message), callback);
return true;
}

void stop() {
if (!stopped.compareAndSet(false, true)) return;
Server current = server;
server = null;
if (current == null) return;

try {
current.stop();
} catch (Exception exception) {
plugin.getLogger().log(Level.WARNING, "Jetty HTTP server did not stop cleanly", exception);
}
}

private static final class JsonGracefulHandler extends GracefulHandler {
private JsonGracefulHandler(Handler handler) {
super(handler);
}

@Override
protected void handleShutdownRejection(Request request, Response response, Callback callback) {
writeJson(response, callback, 503, "Service Unavailable");
}
}

private static final class JsonQoSHandler extends QoSHandler {
private JsonQoSHandler(Handler handler) {
super(handler);
}

@Override
protected void reject(Request request, Response response, Callback callback, int status) {
writeJson(response, callback, status, "Service Busy");
}
}

private static final class JsonInetAccessHandler extends InetAccessHandler {
private JsonInetAccessHandler(Handler handler) {
super(handler);
}

@Override
protected boolean onConditionsNotMet(Request request, Response response, Callback callback) {
return writeJson(response, callback, 403, "Forbidden");
}
}

private static final class NotFoundHandler extends Handler.Abstract.NonBlocking {
@Override
public boolean handle(Request request, Response response, Callback callback) {
return writeJson(response, callback, 404, "Invalid URI");
}
}

private record Lookup(String status, String message) {
private Lookup(int status, String message) {
this(Integer.toString(status), message);
}
}
}
54 changes: 0 additions & 54 deletions src/main/java/me/fredthedoggy/restpapi/NoLogging.java

This file was deleted.

Loading
Loading