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:
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", ""));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user