Complete private authentication, server integrations and activity reporting

Add Fabric/Forge version builds and Paper integration, preserve permanent player identities across renames, harden session authorization, and surface privacy-conscious launcher/server activity in the panel.
This commit is contained in:
SCOPEDD committed 2026-09-28 13:31:17 -04:00
commit a1f19e86c6
74 files changed
+2108 -95

No files matched your search

@@ -0,0 +1,117 @@
package net.scopenet.integration;
import com.google.gson.*;
import java.util.*;
import java.util.function.LongSupplier;
/** All callers (including asynchronous chat events) share one short lock. */
public final class Activity {
private record Player(String name, long since) {}
private final Map<UUID, Player> online = new LinkedHashMap<>();
private final Map<UUID, JsonObject> stats = new LinkedHashMap<>();
private final List<JsonObject> events = new ArrayList<>();
private final Map<String, JsonObject> actions = new LinkedHashMap<>();
private long dropped;
private final LongSupplier clock;
private static final int MAX_PLAYERS = 5000;
private static final int MAX_EVENTS = 500;
public Activity() { this(System::nanoTime); }
Activity(LongSupplier clock) { this.clock = clock; }
public synchronized void join(UUID id, String name) {
if (online.containsKey(id)) return;
if (online.size() >= MAX_PLAYERS) return;
online.put(id, new Player(name, clock.getAsLong()));
add(id, name, "joins", 1);
event(id, name, "join", null);
}
public synchronized void leave(UUID id, String name) {
Player player = online.remove(id);
if (player == null) return;
add(id, name, "playtime_secs", Math.max(0, (clock.getAsLong() - player.since()) / 1_000_000_000L));
event(id, name, "leave", null);
}
public synchronized void add(UUID id, String name, String key, long count) {
if (count <= 0 || (!stats.containsKey(id) && stats.size() >= MAX_PLAYERS)) return;
JsonObject row = stats.computeIfAbsent(id, unused -> identity(id, name));
row.addProperty(key, Math.min(1_000_000L, (row.has(key) ? row.get(key).getAsLong() : 0) + count));
}
public synchronized void event(UUID id, String name, String kind, String detail) {
if (events.size() >= 10_000) { dropped++; return; }
JsonObject event = identity(id, name);
event.addProperty("kind", kind);
if (detail != null) event.addProperty("detail", detail.substring(0, Math.min(256, detail.length())));
event.addProperty("at", System.currentTimeMillis());
events.add(event);
}
/** Repeated vanilla actions are aggregated within each reporting interval. */
public synchronized void action(UUID id, String name, String action, long count) {
String key = id + ":" + action;
if (count <= 0) return;
if (!actions.containsKey(key) && actions.size() >= 10_000) { dropped++; return; }
JsonObject row = actions.computeIfAbsent(key, unused -> {
JsonObject value = identity(id, name);
value.addProperty("kind", "action");
value.addProperty("action", action);
value.addProperty("count", 0);
value.addProperty("at", System.currentTimeMillis());
return value;
});
row.addProperty("count", row.get("count").getAsLong() + count);
}
public synchronized JsonObject drain(double tps) {
long now = clock.getAsLong();
JsonArray players = new JsonArray();
online.replaceAll((id, player) -> {
long seconds = Math.max(0, (now - player.since()) / 1_000_000_000L);
add(id, player.name(), "playtime_secs", seconds);
players.add(identity(id, player.name()));
return new Player(player.name(), player.since() + seconds * 1_000_000_000L);
});
JsonObject payload = new JsonObject();
payload.addProperty("batch_id", UUID.randomUUID().toString());
payload.addProperty("tps", Double.isFinite(tps) ? Math.max(0, Math.min(20, tps)) : 20);
payload.add("online", players);
JsonArray rows = new JsonArray();
stats.values().forEach(rows::add);
payload.add("stats", rows);
JsonArray activity = new JsonArray();
int eventCount = Math.min(events.size(), MAX_EVENTS - 1);
events.subList(0, eventCount).forEach(activity::add);
events.subList(0, eventCount).clear();
var iterator = actions.values().iterator();
while (iterator.hasNext() && activity.size() < MAX_EVENTS - 1) {
JsonObject action = iterator.next();
action.addProperty("detail", action.remove("action").getAsString() + " +" + action.remove("count").getAsLong());
activity.add(action);
iterator.remove();
}
if (dropped > 0) {
JsonObject gap = new JsonObject();
gap.addProperty("kind", "telemetry_gap");
gap.addProperty("detail", dropped + " events exceeded the local queue capacity");
activity.add(gap);
dropped = 0;
}
payload.add("events", activity);
stats.clear();
return payload;
}
public synchronized void leaveAll() {
new LinkedHashMap<>(online).forEach((id, player) -> leave(id, player.name()));
}
private static JsonObject identity(UUID id, String name) {
JsonObject object = new JsonObject();
object.addProperty("uuid", id.toString());
object.addProperty("name", name);
return object;
}
}
@@ -0,0 +1,123 @@
package net.scopenet.integration;
import com.google.gson.*;
import java.util.UUID;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.*;
public final class Integration implements AutoCloseable {
public static final String UNAVAILABLE = "SCOPENET sign-in is unavailable. Please try again shortly.";
public final Activity activity = new Activity();
private final PanelClient client;
private final JsonObject hello;
private final Consumer<String> log;
private final BiConsumer<UUID, String> kick;
private final ExecutorService syncWorker = Executors.newSingleThreadExecutor(r -> daemon(r, "scopenet-sync"));
private final ExecutorService loginWorker = new ThreadPoolExecutor(2, 4, 30, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(64), r -> daemon(r, "scopenet-login"), new ThreadPoolExecutor.AbortPolicy());
private final AtomicBoolean syncing = new AtomicBoolean();
private volatile boolean closed;
private boolean registered;
private JsonObject pending;
private long nextSync;
private long ticks;
private long sampleStart = System.nanoTime();
public Integration(Settings settings, String software, String mcVersion, String version,
boolean onlineMode, int maxPlayers, Consumer<String> log, BiConsumer<UUID, String> kick) {
if (!onlineMode) throw new IllegalArgumentException("SCOPENET requires online-mode=true and authlib-injector");
this.client = new PanelClient(settings);
this.log = log;
this.kick = kick;
hello = new JsonObject();
hello.addProperty("software", software);
hello.addProperty("mc_version", mcVersion);
hello.addProperty("plugin_version", version);
hello.addProperty("online_mode", onlineMode);
hello.addProperty("max_players", maxPlayers);
}
/** Null means allowed. Errors, missing settings and overload always deny. */
public CompletableFuture<String> login(UUID id, String name, String ip) {
if (closed) return CompletableFuture.completedFuture(UNAVAILABLE);
try {
return CompletableFuture.supplyAsync(() -> {
JsonObject request = new JsonObject();
request.addProperty("uuid", id.toString());
request.addProperty("name", name);
if (ip != null) request.addProperty("ip", ip);
try { return PanelClient.verdict(client.post("login", request)); }
catch (Exception e) {
if (e instanceof InterruptedException) Thread.currentThread().interrupt();
return UNAVAILABLE;
}
}, loginWorker).completeOnTimeout(UNAVAILABLE, 6, TimeUnit.SECONDS);
} catch (RejectedExecutionException e) {
return CompletableFuture.completedFuture(UNAVAILABLE);
}
}
/** Called on the server thread. Network and JSON work runs on the worker. */
public void tick() {
if (closed) return;
ticks++;
long now = System.nanoTime();
if (now < nextSync || !syncing.compareAndSet(false, true)) return;
double tps = Math.min(20, ticks * 1_000_000_000.0 / Math.max(1, now - sampleStart));
ticks = 0;
sampleStart = now;
nextSync = now + TimeUnit.SECONDS.toNanos(30);
syncWorker.execute(() -> {
try { sync(tps); }
catch (Exception e) {
if (e instanceof InterruptedException) Thread.currentThread().interrupt();
log.accept("SCOPENET sync failed; retained batch will be retried (" + e.getClass().getSimpleName() + ")");
} finally { syncing.set(false); }
});
}
private void sync(double tps) throws Exception {
if (!registered) {
JsonObject response = client.post("hello", hello);
if (!response.has("server_id")) throw new IllegalStateException("Invalid hello response");
registered = true;
}
if (pending == null) pending = activity.drain(tps);
JsonObject response = client.post("sync", pending);
if (!response.has("ok") || !response.get("ok").getAsBoolean() || !response.has("kick")
|| !response.get("kick").isJsonArray()) throw new IllegalStateException("Invalid sync response");
pending = null;
for (JsonElement element : response.getAsJsonArray("kick")) {
try {
JsonObject entry = element.getAsJsonObject();
String message = entry.has("message") && !entry.get("message").isJsonNull()
? entry.get("message").getAsString() : "Your server access was revoked.";
kick.accept(UUID.fromString(entry.get("uuid").getAsString()), message);
} catch (RuntimeException e) { log.accept("SCOPENET ignored a malformed kick entry"); }
}
}
@Override public void close() {
closed = true;
loginWorker.shutdownNow();
activity.leaveAll();
Future<?> flush = syncWorker.submit(() -> {
try {
// A retained retry may contain an earlier online snapshot.
sync(20);
sync(20);
} catch (Exception e) { log.accept("SCOPENET final sync failed; unsent activity may be lost"); }
});
try { flush.get(12, TimeUnit.SECONDS); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
catch (ExecutionException | TimeoutException e) { log.accept("SCOPENET final sync did not finish"); }
finally { syncWorker.shutdownNow(); }
}
private static Thread daemon(Runnable task, String name) {
Thread thread = new Thread(task, name);
thread.setDaemon(true);
return thread;
}
}
@@ -0,0 +1,45 @@
package net.scopenet.integration;
import com.google.gson.*;
import java.io.IOException;
import java.net.URI;
import java.net.http.*;
import java.time.Duration;
public final class PanelClient {
private final Settings settings;
private final HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3))
.followRedirects(HttpClient.Redirect.NEVER).build();
public PanelClient(Settings settings) { this.settings = settings; }
public JsonObject post(String endpoint, JsonObject payload) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder(URI.create(settings.panel() + "/api/server/v1/" + endpoint))
.timeout(Duration.ofSeconds(5))
.header("Authorization", "Bearer " + settings.token())
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(payload.toString())).build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
// Never log the token, request body, or an untrusted HTML error page.
throw new IOException("Panel " + endpoint + " returned HTTP " + response.statusCode());
}
try {
return JsonParser.parseString(response.body()).getAsJsonObject();
} catch (RuntimeException e) {
throw new IOException("Invalid panel response", e);
}
}
public static String verdict(JsonObject response) throws IOException {
JsonElement allowed = response.get("allowed");
if (allowed == null || !allowed.isJsonPrimitive() || !allowed.getAsJsonPrimitive().isBoolean()) {
throw new IOException("Missing login verdict");
}
if (allowed.getAsBoolean()) return null;
JsonElement message = response.get("message");
return message != null && message.isJsonPrimitive() && message.getAsJsonPrimitive().isString()
? message.getAsString() : "You do not have access to this server.";
}
}
@@ -0,0 +1,39 @@
package net.scopenet.integration;
import java.io.IOException;
import java.io.Reader;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Properties;
public record Settings(URI panel, String token) {
public Settings {
if (panel == null || panel.getHost() == null || panel.getRawUserInfo() != null
|| panel.getRawQuery() != null || panel.getRawFragment() != null
|| !("https".equals(panel.getScheme()) || "http".equals(panel.getScheme()))) {
throw new IllegalArgumentException("panel-url must be an HTTP(S) URL without credentials, query or fragment");
}
token = token == null ? "" : token.trim();
if (!token.matches("sn_[A-Za-z0-9]{40}")) {
throw new IllegalArgumentException("Set the server token from the panel's Servers page");
}
}
public static Settings of(String panel, String token) {
return new Settings(URI.create(panel.trim().replaceAll("/+$", "")), token);
}
public static Settings load(Path path) throws IOException {
if (!Files.exists(path)) {
Files.createDirectories(path.toAbsolutePath().getParent());
Files.writeString(path, "# SCOPENET server integration\npanel-url=https://panel.example.com\ntoken=\n", StandardCharsets.UTF_8);
}
Properties properties = new Properties();
try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
properties.load(reader);
}
return of(properties.getProperty("panel-url", ""), properties.getProperty("token", ""));
}
}
@@ -0,0 +1,46 @@
package net.scopenet.integration;
import com.google.gson.JsonObject;
import org.junit.jupiter.api.Test;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.*;
class ActivityTest {
@Test void deltasAndFractionalPlaytimeSurviveSnapshots() {
AtomicLong time = new AtomicLong();
Activity activity = new Activity(time::get);
UUID id = UUID.randomUUID();
activity.join(id, "Steve");
activity.join(id, "Steve");
activity.add(id, "Steve", "blocks_broken", 3);
time.set(1_500_000_000L);
JsonObject first = activity.drain(19.5);
JsonObject row = first.getAsJsonArray("stats").get(0).getAsJsonObject();
assertEquals(1, row.get("joins").getAsInt());
assertEquals(1, row.get("playtime_secs").getAsInt());
assertEquals(3, row.get("blocks_broken").getAsInt());
time.set(2_000_000_000L);
JsonObject second = activity.drain(20);
assertEquals(1, second.getAsJsonArray("stats").get(0).getAsJsonObject().get("playtime_secs").getAsInt());
assertFalse(second.getAsJsonArray("stats").get(0).getAsJsonObject().has("joins"));
assertNotEquals(first.get("batch_id"), second.get("batch_id"));
activity.leave(id, "Steve");
activity.leave(id, "Steve");
JsonObject last = activity.drain(20);
assertEquals(0, last.getAsJsonArray("online").size());
assertEquals(1, last.getAsJsonArray("events").size());
}
@Test void shutdownClearsOnlineAndIncludesLastSessionTime() {
AtomicLong time = new AtomicLong();
Activity activity = new Activity(time::get);
activity.join(UUID.randomUUID(), "Steve");
time.set(3_000_000_000L);
activity.leaveAll();
JsonObject payload = activity.drain(Double.NaN);
assertEquals(0, payload.getAsJsonArray("online").size());
assertEquals(3, payload.getAsJsonArray("stats").get(0).getAsJsonObject().get("playtime_secs").getAsInt());
assertEquals(20, payload.get("tps").getAsInt());
}
}
@@ -0,0 +1,85 @@
package net.scopenet.integration;
import com.google.gson.*;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.*;
import java.net.*;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.*;
class PanelClientTest {
private HttpServer server;
private Settings settings;
private final String token = "sn_" + "a".repeat(40);
@BeforeEach void start() throws Exception {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.start();
settings = Settings.of("http://127.0.0.1:" + server.getAddress().getPort(), token);
}
@AfterEach void stop() { server.stop(0); }
private void endpoint(String path, int status, String response, AtomicReference<JsonObject> received) {
server.createContext("/api/server/v1/" + path, exchange -> {
assertEquals("Bearer " + token, exchange.getRequestHeaders().getFirst("Authorization"));
assertEquals("POST", exchange.getRequestMethod());
received.set(JsonParser.parseString(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)).getAsJsonObject());
byte[] bytes = response.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(status, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
});
}
@Test void postsAuthenticatedIdentityAndPreservesDenialMessage() throws Exception {
AtomicReference<JsonObject> received = new AtomicReference<>();
endpoint("login", 200, "{\"allowed\":false,\"message\":\"Account disabled\"}", received);
Integration integration = integration();
UUID id = UUID.randomUUID();
assertEquals("Account disabled", integration.login(id, "Steve", "203.0.113.9").get(2, TimeUnit.SECONDS));
assertEquals(id.toString(), received.get().get("uuid").getAsString());
assertEquals("203.0.113.9", received.get().get("ip").getAsString());
integration.close();
}
@Test void malformedAndHttpFailuresDenyAccess() throws Exception {
endpoint("login", 200, "{}", new AtomicReference<>());
Integration integration = integration();
assertEquals(Integration.UNAVAILABLE, integration.login(UUID.randomUUID(), "Steve", null).get(2, TimeUnit.SECONDS));
server.removeContext("/api/server/v1/login");
endpoint("login", 503, "{\"allowed\":true}", new AtomicReference<>());
assertEquals(Integration.UNAVAILABLE, integration.login(UUID.randomUUID(), "Steve", null).get(2, TimeUnit.SECONDS));
integration.close();
}
@Test void onlyExplicitBooleanTrueAllowsLogin() throws Exception {
assertNull(PanelClient.verdict(JsonParser.parseString("{\"allowed\":true}").getAsJsonObject()));
for (String bad : List.of("{}", "{\"allowed\":\"true\"}", "{\"allowed\":null}", "{\"allowed\":1}")) {
assertThrows(IOException.class, () -> PanelClient.verdict(JsonParser.parseString(bad).getAsJsonObject()));
}
}
@Test void redirectsNeverForwardTheServerToken() {
server.createContext("/api/server/v1/login", exchange -> {
exchange.getResponseHeaders().set("Location", "http://127.0.0.1:1/stolen");
exchange.sendResponseHeaders(302, -1);
exchange.close();
});
assertThrows(IOException.class, () -> new PanelClient(settings).post("login", new JsonObject()));
}
@Test void invalidSettingsAndOfflineModeAreRejected() {
assertThrows(IllegalArgumentException.class, () -> Settings.of("https://user:pass@panel.example.com", token));
assertThrows(IllegalArgumentException.class, () -> Settings.of("file:///tmp/panel", token));
assertThrows(IllegalArgumentException.class, () -> Settings.of("https://panel.example.com", ""));
assertThrows(IllegalArgumentException.class, () -> new Integration(settings, "test", "1", "1", false, 20, s -> {}, (id, msg) -> {}));
}
private Integration integration() {
return new Integration(settings, "test", "1.21.1", "test", true, 20, s -> {}, (id, msg) -> {});
}
}