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,12 @@
|
||||
allprojects {
|
||||
group = 'net.scopenet'
|
||||
version = providers.gradleProperty('releaseVersion').getOrElse('0.1.0')
|
||||
repositories { mavenCentral() }
|
||||
}
|
||||
subprojects {
|
||||
apply plugin: 'java'
|
||||
java.sourceCompatibility = JavaVersion.VERSION_17
|
||||
java.targetCompatibility = JavaVersion.VERSION_17
|
||||
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
|
||||
tasks.withType(Test).configureEach { useJUnitPlatform() }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
dependencies {
|
||||
implementation 'com.google.code.gson:gson:2.10.1'
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter:5.11.4'
|
||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||
}
|
||||
@@ -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) -> {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
plugins { id 'fabric-loom' version '1.8.13' }
|
||||
def mc = providers.gradleProperty('mcVersion').getOrElse('1.21.1')
|
||||
if (!(mc in ['1.20.1', '1.21.1'])) throw new GradleException('Use the modern build for 26.x')
|
||||
def targetJava = mc == '1.20.1' ? 17 : 21
|
||||
loom { mixin { defaultRefmapName = 'scopenet.refmap.json' } }
|
||||
if (providers.gradleProperty('verifyMixins').isPresent()) {
|
||||
loom { runs { server { vmArg '-Dscopenet.verifyMixins=true' } } }
|
||||
}
|
||||
java.sourceCompatibility = JavaVersion.toVersion(targetJava)
|
||||
java.targetCompatibility = JavaVersion.toVersion(targetJava)
|
||||
dependencies {
|
||||
minecraft "com.mojang:minecraft:${mc}"
|
||||
mappings loom.officialMojangMappings()
|
||||
modImplementation 'net.fabricmc:fabric-loader:0.16.10'
|
||||
implementation project(':common')
|
||||
}
|
||||
sourceSets.main.java.srcDirs += ['../minecraft/src/main/java', "../minecraft/src/${mc}/java"]
|
||||
sourceSets.main.resources.srcDirs += ['../minecraft/src/main/resources']
|
||||
apply from: '../minecraft/build-info.gradle'
|
||||
processResources {
|
||||
inputs.properties(version: project.version, mc: mc, java: targetJava)
|
||||
filesMatching('fabric.mod.json') { expand(version: project.version, mc: mc, java: targetJava) }
|
||||
}
|
||||
jar {
|
||||
archiveBaseName = "scopenet-fabric-${mc}"
|
||||
from project(':common').sourceSets.main.output
|
||||
}
|
||||
base.archivesName = "scopenet-fabric-${mc}"
|
||||
@@ -0,0 +1,22 @@
|
||||
plugins { id 'net.fabricmc.fabric-loom' version '1.17.17' }
|
||||
def mc = providers.gradleProperty('mcVersion').get()
|
||||
if (providers.gradleProperty('verifyMixins').isPresent()) {
|
||||
loom { runs { server { vmArg '-Dscopenet.verifyMixins=true' } } }
|
||||
}
|
||||
java.sourceCompatibility = JavaVersion.VERSION_25
|
||||
java.targetCompatibility = JavaVersion.VERSION_25
|
||||
dependencies {
|
||||
minecraft "com.mojang:minecraft:${mc}"
|
||||
implementation 'net.fabricmc:fabric-loader:0.19.5'
|
||||
implementation project(':common')
|
||||
}
|
||||
sourceSets.main.java.srcDirs += ['../minecraft/src/main/java', '../minecraft/src/26/java']
|
||||
sourceSets.main.resources.srcDirs += ['../minecraft/src/main/resources']
|
||||
apply from: '../minecraft/build-info.gradle'
|
||||
processResources {
|
||||
inputs.properties(version: project.version, mc: mc)
|
||||
filesMatching('fabric.mod.json') { expand(version: project.version, mc: mc, java: 25) }
|
||||
filesMatching('scopenet.mixins.json') { filter { line -> line.contains('"refmap"') ? null : line } }
|
||||
}
|
||||
base.archivesName = "scopenet-fabric-${mc}"
|
||||
jar { from project(':common').sourceSets.main.output }
|
||||
@@ -0,0 +1,11 @@
|
||||
package net.scopenet.fabric;
|
||||
|
||||
import net.fabricmc.api.DedicatedServerModInitializer;
|
||||
|
||||
/** Server-only; lifecycle and admission hooks are shared mixins. */
|
||||
public final class ScopenetFabric implements DedicatedServerModInitializer {
|
||||
@Override public void onInitializeServer() {
|
||||
if (Boolean.getBoolean("scopenet.verifyMixins"))
|
||||
org.spongepowered.asm.mixin.MixinEnvironment.getCurrentEnvironment().audit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "scopenet",
|
||||
"version": "${version}",
|
||||
"name": "SCOPENET",
|
||||
"environment": "server",
|
||||
"entrypoints": { "server": ["net.scopenet.fabric.ScopenetFabric"] },
|
||||
"mixins": ["scopenet.mixins.json"],
|
||||
"depends": { "fabricloader": ">=0.16.10", "minecraft": "${mc}", "java": ">=${java}" }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
buildscript {
|
||||
repositories {
|
||||
maven { url = 'https://maven.minecraftforge.net/' }
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'net.minecraftforge.gradle:ForgeGradle:6.0.42'
|
||||
classpath 'org.spongepowered:mixingradle:0.7.38'
|
||||
}
|
||||
}
|
||||
apply plugin: 'net.minecraftforge.gradle'
|
||||
apply plugin: 'org.spongepowered.mixin'
|
||||
repositories {
|
||||
exclusiveContent {
|
||||
forRepository { maven { url = 'https://libraries.minecraft.net/' } }
|
||||
filter { includeGroup 'org.lwjgl' }
|
||||
}
|
||||
}
|
||||
def mc = providers.gradleProperty('mcVersion').getOrElse('1.21.1')
|
||||
def forgeVersions = ['1.20.1': '47.4.10', '1.21.1': '52.1.0']
|
||||
if (!forgeVersions.containsKey(mc)) throw new GradleException('Unsupported Minecraft version')
|
||||
java.sourceCompatibility = JavaVersion.toVersion(mc == '1.20.1' ? 17 : 21)
|
||||
java.targetCompatibility = java.sourceCompatibility
|
||||
minecraft { mappings channel: 'official', version: mc }
|
||||
dependencies {
|
||||
minecraft "net.minecraftforge:forge:${mc}-${forgeVersions[mc]}"
|
||||
implementation project(':common')
|
||||
annotationProcessor 'org.spongepowered:mixin:0.8.5:processor'
|
||||
}
|
||||
sourceSets.main.java.srcDirs += ['../minecraft/src/main/java', "../minecraft/src/${mc}/java"]
|
||||
sourceSets.main.resources.srcDirs += ['../minecraft/src/main/resources']
|
||||
apply from: '../minecraft/build-info.gradle'
|
||||
mixin { add sourceSets.main, 'scopenet.refmap.json'; config 'scopenet.mixins.json' }
|
||||
processResources {
|
||||
inputs.properties(version: project.version, mc: mc)
|
||||
filesMatching('META-INF/mods.toml') { expand(version: project.version, mc: mc, forge: forgeVersions[mc].split('\\.')[0]) }
|
||||
}
|
||||
base.archivesName = "scopenet-forge-${mc}"
|
||||
jar {
|
||||
from project(':common').sourceSets.main.output
|
||||
manifest { attributes('MixinConfigs': 'scopenet.mixins.json') }
|
||||
finalizedBy 'reobfJar'
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
plugins { id 'net.minecraftforge.gradle' version '7.0.17' }
|
||||
def mc = providers.gradleProperty('mcVersion').get()
|
||||
def forgeVersions = ['26.1.2': '64.1.0', '26.2': '65.1.0', '26.3': '66.0.6']
|
||||
java.sourceCompatibility = JavaVersion.VERSION_25
|
||||
java.targetCompatibility = JavaVersion.VERSION_25
|
||||
repositories {
|
||||
minecraft.mavenizer(it)
|
||||
maven fg.forgeMaven
|
||||
maven fg.minecraftLibsMaven
|
||||
}
|
||||
dependencies {
|
||||
implementation minecraft.dependency("net.minecraftforge:forge:${mc}-${forgeVersions[mc]}")
|
||||
implementation project(':common')
|
||||
}
|
||||
sourceSets.main.java.srcDirs += ['../minecraft/src/main/java', '../minecraft/src/26/java']
|
||||
sourceSets.main.resources.srcDirs += ['../minecraft/src/main/resources']
|
||||
apply from: '../minecraft/build-info.gradle'
|
||||
processResources {
|
||||
inputs.properties(version: project.version, mc: mc)
|
||||
filesMatching('META-INF/mods.toml') { expand(version: project.version, mc: mc, forge: forgeVersions[mc].split('\\.')[0]) }
|
||||
filesMatching('scopenet.mixins.json') { filter { line -> line.contains('"refmap"') ? null : line } }
|
||||
}
|
||||
base.archivesName = "scopenet-forge-${mc}"
|
||||
jar {
|
||||
from project(':common').sourceSets.main.output
|
||||
manifest { attributes('MixinConfigs': 'scopenet.mixins.json') }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package net.scopenet.forge;
|
||||
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
|
||||
@Mod("scopenet")
|
||||
public final class ScopenetForge {
|
||||
public ScopenetForge() {
|
||||
if (Boolean.getBoolean("scopenet.verifyMixins"))
|
||||
org.spongepowered.asm.mixin.MixinEnvironment.getCurrentEnvironment().audit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
modLoader="javafml"
|
||||
loaderVersion="[${forge},)"
|
||||
license="All Rights Reserved"
|
||||
[[mods]]
|
||||
modId="scopenet"
|
||||
version="${version}"
|
||||
displayName="SCOPENET"
|
||||
displayTest="IGNORE_ALL_VERSION"
|
||||
description="Private authentication, access control and activity reporting for SCOPENET servers."
|
||||
[[dependencies.scopenet]]
|
||||
modId="forge"
|
||||
mandatory=true
|
||||
versionRange="[${forge},)"
|
||||
ordering="NONE"
|
||||
side="SERVER"
|
||||
[[dependencies.scopenet]]
|
||||
modId="minecraft"
|
||||
mandatory=true
|
||||
versionRange="[${mc}]"
|
||||
ordering="NONE"
|
||||
side="SERVER"
|
||||
@@ -0,0 +1,2 @@
|
||||
org.gradle.jvmargs=-Xmx2G
|
||||
org.gradle.parallel=false
|
||||
@@ -0,0 +1,18 @@
|
||||
def generated = layout.buildDirectory.dir('generated/scopenet')
|
||||
sourceSets.main.java.srcDir generated
|
||||
tasks.register('generateBuildInfo') {
|
||||
inputs.property('version', project.version)
|
||||
inputs.property('loader', project.name)
|
||||
outputs.dir generated
|
||||
doLast {
|
||||
def target = generated.get().file('net/scopenet/minecraft/BuildInfo.java').asFile
|
||||
target.parentFile.mkdirs()
|
||||
target.text = """package net.scopenet.minecraft;
|
||||
public final class BuildInfo {
|
||||
public static final String VERSION = "${project.version}";
|
||||
public static final String LOADER = "${project.name}";
|
||||
}
|
||||
"""
|
||||
}
|
||||
}
|
||||
compileJava.dependsOn generateBuildInfo
|
||||
@@ -0,0 +1,16 @@
|
||||
package net.scopenet.minecraft.mixin;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.server.network.ServerGamePacketListenerImpl;
|
||||
import net.minecraft.network.protocol.game.*;
|
||||
import net.scopenet.minecraft.Bridge;
|
||||
import org.spongepowered.asm.mixin.*;
|
||||
import org.spongepowered.asm.mixin.injection.*;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
@Mixin(ServerGamePacketListenerImpl.class)
|
||||
public abstract class CommandMixin {
|
||||
@Shadow public ServerPlayer player;
|
||||
@Inject(method = "handleChatCommand", at = @At("RETURN"))
|
||||
private void scopenet$command(ServerboundChatCommandPacket packet, CallbackInfo ci) {
|
||||
Bridge.command(player, packet.command());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package net.scopenet.minecraft.mixin;
|
||||
|
||||
import com.mojang.authlib.GameProfile;
|
||||
import net.minecraft.network.Connection;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.network.ServerLoginPacketListenerImpl;
|
||||
import net.scopenet.minecraft.Bridge;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import org.spongepowered.asm.mixin.*;
|
||||
import org.spongepowered.asm.mixin.injection.*;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
@Mixin(ServerLoginPacketListenerImpl.class)
|
||||
public abstract class LoginMixin {
|
||||
@Shadow private GameProfile gameProfile;
|
||||
@Shadow @Final public Connection connection;
|
||||
@Shadow public abstract void disconnect(Component reason);
|
||||
@Unique private CompletableFuture<String> scopenet$verdict;
|
||||
|
||||
@Inject(method = "handleAcceptedLogin", at = @At("HEAD"), cancellable = true)
|
||||
private void scopenet$gate(CallbackInfo ci) {
|
||||
if (scopenet$verdict == null)
|
||||
scopenet$verdict = Bridge.login(gameProfile.getId(), gameProfile.getName(), connection.getRemoteAddress());
|
||||
if (!scopenet$verdict.isDone()) { ci.cancel(); return; }
|
||||
String denial = scopenet$verdict.join();
|
||||
if (denial != null) { disconnect(Component.literal(denial)); ci.cancel(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package net.scopenet.minecraft.mixin;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.server.network.ServerGamePacketListenerImpl;
|
||||
import net.minecraft.network.protocol.game.*;
|
||||
import net.scopenet.minecraft.Bridge;
|
||||
import org.spongepowered.asm.mixin.*;
|
||||
import org.spongepowered.asm.mixin.injection.*;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
@Mixin(ServerGamePacketListenerImpl.class)
|
||||
public abstract class CommandMixin {
|
||||
@Shadow public ServerPlayer player;
|
||||
@Inject(method = "handleChatCommand", at = @At("RETURN"))
|
||||
private void scopenet$command(ServerboundChatCommandPacket packet, CallbackInfo ci) {
|
||||
Bridge.command(player, packet.command());
|
||||
}
|
||||
|
||||
@Inject(method = "handleSignedChatCommand", at = @At("RETURN"))
|
||||
private void scopenet$signed(ServerboundChatCommandSignedPacket packet, CallbackInfo ci) {
|
||||
Bridge.command(player, packet.command());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package net.scopenet.minecraft.mixin;
|
||||
|
||||
import com.mojang.authlib.GameProfile;
|
||||
import net.minecraft.network.Connection;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.network.ServerLoginPacketListenerImpl;
|
||||
import net.scopenet.minecraft.Bridge;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import org.spongepowered.asm.mixin.*;
|
||||
import org.spongepowered.asm.mixin.injection.*;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
@Mixin(ServerLoginPacketListenerImpl.class)
|
||||
public abstract class LoginMixin {
|
||||
@Shadow @Final private Connection connection;
|
||||
@Shadow public abstract void disconnect(Component reason);
|
||||
@Unique private CompletableFuture<String> scopenet$verdict;
|
||||
|
||||
@Inject(method = "verifyLoginAndFinishConnectionSetup", at = @At("HEAD"), cancellable = true)
|
||||
private void scopenet$gate(GameProfile profile, CallbackInfo ci) {
|
||||
if (scopenet$verdict == null)
|
||||
scopenet$verdict = Bridge.login(profile.getId(), profile.getName(), connection.getRemoteAddress());
|
||||
if (!scopenet$verdict.isDone()) { ci.cancel(); return; }
|
||||
String denial = scopenet$verdict.join();
|
||||
if (denial != null) { disconnect(Component.literal(denial)); ci.cancel(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package net.scopenet.minecraft.mixin;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.server.network.ServerGamePacketListenerImpl;
|
||||
import net.minecraft.network.protocol.game.*;
|
||||
import net.scopenet.minecraft.Bridge;
|
||||
import org.spongepowered.asm.mixin.*;
|
||||
import org.spongepowered.asm.mixin.injection.*;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
@Mixin(ServerGamePacketListenerImpl.class)
|
||||
public abstract class CommandMixin {
|
||||
@Shadow public ServerPlayer player;
|
||||
@Inject(method = "handleChatCommand", at = @At("RETURN"))
|
||||
private void scopenet$command(ServerboundChatCommandPacket packet, CallbackInfo ci) {
|
||||
Bridge.command(player, packet.command());
|
||||
}
|
||||
|
||||
@Inject(method = "handleSignedChatCommand", at = @At("RETURN"))
|
||||
private void scopenet$signed(ServerboundChatCommandSignedPacket packet, CallbackInfo ci) {
|
||||
Bridge.command(player, packet.command());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package net.scopenet.minecraft.mixin;
|
||||
|
||||
import com.mojang.authlib.GameProfile;
|
||||
import net.minecraft.network.Connection;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.network.ServerLoginPacketListenerImpl;
|
||||
import net.scopenet.minecraft.Bridge;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import org.spongepowered.asm.mixin.*;
|
||||
import org.spongepowered.asm.mixin.injection.*;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
@Mixin(ServerLoginPacketListenerImpl.class)
|
||||
public abstract class LoginMixin {
|
||||
@Shadow @Final private Connection connection;
|
||||
@Shadow public abstract void disconnect(Component reason);
|
||||
@Unique private CompletableFuture<String> scopenet$verdict;
|
||||
|
||||
@Inject(method = "verifyLoginAndFinishConnectionSetup", at = @At("HEAD"), cancellable = true)
|
||||
private void scopenet$gate(GameProfile profile, CallbackInfo ci) {
|
||||
if (scopenet$verdict == null)
|
||||
scopenet$verdict = Bridge.login(profile.id(), profile.name(), connection.getRemoteAddress());
|
||||
if (!scopenet$verdict.isDone()) { ci.cancel(); return; }
|
||||
String denial = scopenet$verdict.join();
|
||||
if (denial != null) { disconnect(Component.literal(denial)); ci.cancel(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package net.scopenet.minecraft;
|
||||
|
||||
import net.scopenet.integration.*;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import java.net.*;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
public final class Bridge {
|
||||
private static volatile Integration integration;
|
||||
private static final Map<UUID, String> online = new HashMap<>();
|
||||
private Bridge() {}
|
||||
|
||||
public static void start(MinecraftServer server) {
|
||||
try {
|
||||
integration = new Integration(Settings.load(Path.of("config/scopenet.properties")),
|
||||
BuildInfo.LOADER, server.getServerVersion(), BuildInfo.VERSION, server.usesAuthentication(),
|
||||
server.getMaxPlayers(), System.err::println,
|
||||
(id, message) -> server.execute(() -> {
|
||||
ServerPlayer player = server.getPlayerList().getPlayer(id);
|
||||
if (player != null) player.connection.disconnect(Component.literal(message));
|
||||
}));
|
||||
} catch (Exception e) {
|
||||
// Throwing aborts startup; silently disabling an auth mod is unsafe.
|
||||
throw new IllegalStateException("SCOPENET cannot start: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
public static CompletableFuture<String> login(UUID uuid, String name, SocketAddress address) {
|
||||
Integration current = integration;
|
||||
if (current == null) return CompletableFuture.completedFuture(Integration.UNAVAILABLE);
|
||||
String ip = address instanceof InetSocketAddress socket && socket.getAddress() != null
|
||||
? socket.getAddress().getHostAddress() : null;
|
||||
return current.login(uuid, name, ip);
|
||||
}
|
||||
|
||||
public static void tick(MinecraftServer server) {
|
||||
Integration current = integration;
|
||||
if (current == null) return;
|
||||
Set<UUID> present = new HashSet<>();
|
||||
for (ServerPlayer player : server.getPlayerList().getPlayers()) {
|
||||
UUID id = player.getUUID();
|
||||
present.add(id);
|
||||
if (!online.containsKey(id)) {
|
||||
String name = player.getName().getString();
|
||||
online.put(id, name);
|
||||
current.activity.join(id, name);
|
||||
}
|
||||
}
|
||||
online.entrySet().removeIf(entry -> {
|
||||
if (present.contains(entry.getKey())) return false;
|
||||
current.activity.leave(entry.getKey(), entry.getValue());
|
||||
return true;
|
||||
});
|
||||
current.tick();
|
||||
}
|
||||
|
||||
public static void stat(ServerPlayer player, String key, int amount) {
|
||||
Integration current = integration;
|
||||
if (current == null) return;
|
||||
current.activity.add(player.getUUID(), player.getName().getString(), key, amount);
|
||||
if (key.equals("deaths")) current.activity.event(player.getUUID(), player.getName().getString(), "death", null);
|
||||
}
|
||||
|
||||
public static void action(ServerPlayer player, String action, int amount) {
|
||||
Integration current = integration;
|
||||
if (current != null) current.activity.action(player.getUUID(), player.getName().getString(), action, amount);
|
||||
}
|
||||
|
||||
public static void command(ServerPlayer player, String command) {
|
||||
String name = command.strip().split("\\s+", 2)[0];
|
||||
Integration current = integration;
|
||||
if (current != null) current.activity.event(player.getUUID(), player.getName().getString(), "command", "/" + name);
|
||||
}
|
||||
|
||||
public static void stop() {
|
||||
Integration current = integration;
|
||||
integration = null;
|
||||
if (current != null) current.close();
|
||||
online.clear();
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package net.scopenet.minecraft.mixin;
|
||||
|
||||
import net.minecraft.server.PlayerAdvancements;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.scopenet.minecraft.Bridge;
|
||||
import org.spongepowered.asm.mixin.*;
|
||||
import org.spongepowered.asm.mixin.injection.*;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||
|
||||
@Mixin(PlayerAdvancements.class)
|
||||
public abstract class AdvancementMixin {
|
||||
@Shadow private ServerPlayer player;
|
||||
@Inject(method = "award", at = @At("RETURN"))
|
||||
private void scopenet$advancement(CallbackInfoReturnable<Boolean> cir) {
|
||||
if (cir.getReturnValue()) Bridge.action(player, "advancement_criterion", 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package net.scopenet.minecraft.mixin;
|
||||
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.InteractionResult;
|
||||
import net.minecraft.world.item.BlockItem;
|
||||
import net.minecraft.world.item.context.BlockPlaceContext;
|
||||
import net.scopenet.minecraft.Bridge;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.*;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||
|
||||
@Mixin(BlockItem.class)
|
||||
public abstract class BlockItemMixin {
|
||||
@Inject(method = "place(Lnet/minecraft/world/item/context/BlockPlaceContext;)Lnet/minecraft/world/InteractionResult;", at = @At("RETURN"))
|
||||
private void scopenet$placed(BlockPlaceContext context, CallbackInfoReturnable<InteractionResult> cir) {
|
||||
if (cir.getReturnValue().consumesAction() && context.getPlayer() instanceof ServerPlayer player) {
|
||||
Bridge.stat(player, "blocks_placed", 1);
|
||||
Bridge.action(player, "block_placed:" + context.getClickedPos().toShortString(), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package net.scopenet.minecraft.mixin;
|
||||
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.server.network.ServerGamePacketListenerImpl;
|
||||
import net.scopenet.minecraft.Bridge;
|
||||
import org.spongepowered.asm.mixin.*;
|
||||
import org.spongepowered.asm.mixin.injection.*;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
@Mixin(ServerGamePacketListenerImpl.class)
|
||||
public abstract class ChatMixin {
|
||||
@Shadow public ServerPlayer player;
|
||||
@Inject(method = "broadcastChatMessage", at = @At("HEAD"))
|
||||
private void scopenet$chat(CallbackInfo ci) { Bridge.stat(player, "messages", 1); }
|
||||
|
||||
@Inject(method = "handleContainerClick", at = @At("RETURN"))
|
||||
private void scopenet$inventory(CallbackInfo ci) { Bridge.action(player, "inventory_click", 1); }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package net.scopenet.minecraft.mixin;
|
||||
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.dedicated.DedicatedServer;
|
||||
import net.scopenet.minecraft.Bridge;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.*;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||
|
||||
@Mixin(DedicatedServer.class)
|
||||
public abstract class DedicatedMixin {
|
||||
@Inject(method = "initServer", at = @At("RETURN"))
|
||||
private void scopenet$start(CallbackInfoReturnable<Boolean> cir) {
|
||||
if (cir.getReturnValue()) Bridge.start((MinecraftServer) (Object) this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package net.scopenet.minecraft.mixin;
|
||||
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.stats.*;
|
||||
import net.scopenet.minecraft.Bridge;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.*;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
@Mixin(ServerPlayer.class)
|
||||
public abstract class PlayerMixin {
|
||||
@Inject(method = "awardStat", at = @At("TAIL"))
|
||||
private void scopenet$stat(Stat<?> stat, int amount, CallbackInfo ci) {
|
||||
Bridge.action((ServerPlayer) (Object) this, stat.getName(), amount);
|
||||
String key = null;
|
||||
if (stat.getType() == Stats.BLOCK_MINED) key = "blocks_broken";
|
||||
else if (stat.getType() == Stats.CUSTOM) {
|
||||
key = switch (stat.getValue().toString()) {
|
||||
case "minecraft:deaths" -> "deaths";
|
||||
case "minecraft:player_kills" -> "player_kills";
|
||||
case "minecraft:mob_kills" -> "mob_kills";
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
if (key != null) Bridge.stat((ServerPlayer) (Object) this, key, amount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package net.scopenet.minecraft.mixin;
|
||||
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.scopenet.minecraft.Bridge;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.*;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
@Mixin(MinecraftServer.class)
|
||||
public abstract class ServerMixin {
|
||||
@Inject(method = "tickServer", at = @At("TAIL"))
|
||||
private void scopenet$tick(CallbackInfo ci) { Bridge.tick((MinecraftServer) (Object) this); }
|
||||
|
||||
@Inject(method = "stopServer", at = @At("HEAD"))
|
||||
private void scopenet$stop(CallbackInfo ci) { Bridge.stop(); }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"required": true,
|
||||
"minVersion": "0.8",
|
||||
"package": "net.scopenet.minecraft.mixin",
|
||||
"compatibilityLevel": "JAVA_17",
|
||||
"refmap": "scopenet.refmap.json",
|
||||
"mixins": ["DedicatedMixin", "ServerMixin", "LoginMixin", "PlayerMixin", "BlockItemMixin", "ChatMixin", "CommandMixin", "AdvancementMixin"],
|
||||
"injectors": { "defaultRequire": 1 }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
repositories { maven { url = 'https://hub.spigotmc.org/nexus/content/repositories/snapshots/' } }
|
||||
dependencies {
|
||||
implementation project(':common')
|
||||
implementation 'com.google.code.gson:gson:2.10.1'
|
||||
compileOnly 'org.spigotmc:spigot-api:1.20.1-R0.1-SNAPSHOT'
|
||||
}
|
||||
processResources {
|
||||
inputs.property 'version', project.version
|
||||
filesMatching('plugin.yml') { expand(version: project.version) }
|
||||
}
|
||||
jar {
|
||||
archiveBaseName = 'scopenet-paper'
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
from project(':common').sourceSets.main.output
|
||||
from { configurations.runtimeClasspath.findAll { it.name.endsWith('.jar') && !it.name.startsWith('common-') }.collect { zipTree(it) } }
|
||||
exclude 'META-INF/*.SF', 'META-INF/*.RSA', 'META-INF/*.DSA'
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package net.scopenet.paper;
|
||||
|
||||
import net.scopenet.integration.*;
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.*;
|
||||
import org.bukkit.event.block.*;
|
||||
import org.bukkit.event.entity.*;
|
||||
import org.bukkit.event.player.*;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public final class ScopenetPlugin extends JavaPlugin implements Listener {
|
||||
private Integration integration;
|
||||
|
||||
@Override public void onEnable() {
|
||||
// Install the deny-by-default listener even if configuration is invalid.
|
||||
getServer().getPluginManager().registerEvents(this, this);
|
||||
saveDefaultConfig();
|
||||
try {
|
||||
integration = new Integration(
|
||||
Settings.of(getConfig().getString("panel-url", ""), getConfig().getString("token", "")),
|
||||
getServer().getName(), Bukkit.getBukkitVersion().split("-")[0], getDescription().getVersion(),
|
||||
Bukkit.getOnlineMode(), Bukkit.getMaxPlayers(), getLogger()::warning,
|
||||
(id, message) -> {
|
||||
if (!isEnabled()) return;
|
||||
Bukkit.getScheduler().runTask(this, () -> {
|
||||
Player player = Bukkit.getPlayer(id);
|
||||
if (player != null) player.kickPlayer(message);
|
||||
});
|
||||
});
|
||||
Bukkit.getScheduler().runTaskTimer(this, integration::tick, 1, 1);
|
||||
// Reloading an auth plugin cannot silently admit existing sessions.
|
||||
for (Player player : Bukkit.getOnlinePlayers()) player.kickPlayer("SCOPENET restarted. Please reconnect.");
|
||||
} catch (Exception e) {
|
||||
getLogger().severe("SCOPENET is blocking joins: " + e.getMessage());
|
||||
for (Player player : Bukkit.getOnlinePlayers()) player.kickPlayer(Integration.UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override public void onDisable() {
|
||||
if (integration != null) integration.close();
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void login(AsyncPlayerPreLoginEvent event) {
|
||||
if (event.getLoginResult() != AsyncPlayerPreLoginEvent.Result.ALLOWED) return;
|
||||
String denial = Integration.UNAVAILABLE;
|
||||
if (integration != null) {
|
||||
try {
|
||||
denial = integration.login(event.getUniqueId(), event.getName(), event.getAddress().getHostAddress())
|
||||
.get(7, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) { Thread.currentThread().interrupt(); }
|
||||
catch (Exception ignored) { /* fail closed */ }
|
||||
}
|
||||
if (denial != null) event.disallow(AsyncPlayerPreLoginEvent.Result.KICK_OTHER, denial);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void join(PlayerJoinEvent event) {
|
||||
if (integration != null) integration.activity.join(event.getPlayer().getUniqueId(), event.getPlayer().getName());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void leave(PlayerQuitEvent event) {
|
||||
if (integration != null) integration.activity.leave(event.getPlayer().getUniqueId(), event.getPlayer().getName());
|
||||
}
|
||||
|
||||
private void add(Player player, String stat) {
|
||||
if (integration != null) integration.activity.add(player.getUniqueId(), player.getName(), stat, 1);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void broken(BlockBreakEvent event) { add(event.getPlayer(), "blocks_broken"); }
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void placed(BlockPlaceEvent event) { add(event.getPlayer(), "blocks_placed"); }
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void chat(AsyncPlayerChatEvent event) { add(event.getPlayer(), "messages"); }
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void command(PlayerCommandPreprocessEvent event) {
|
||||
audit(event.getPlayer(), "command", event.getMessage().strip().split("\\s+", 2)[0]);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void statistic(PlayerStatisticIncrementEvent event) {
|
||||
if (integration == null) return;
|
||||
String item = event.getMaterial() != null ? ":" + event.getMaterial().name()
|
||||
: event.getEntityType() != null ? ":" + event.getEntityType().name() : "";
|
||||
integration.activity.action(event.getPlayer().getUniqueId(), event.getPlayer().getName(),
|
||||
event.getStatistic().name() + item, event.getNewValue() - event.getPreviousValue());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void inventory(InventoryClickEvent event) {
|
||||
if (event.getWhoClicked() instanceof Player player)
|
||||
audit(player, "inventory", event.getAction().name() + " slot=" + event.getRawSlot()
|
||||
+ (event.getCurrentItem() == null ? "" : " item=" + event.getCurrentItem().getType().name()));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void advancement(PlayerAdvancementDoneEvent event) {
|
||||
audit(event.getPlayer(), "advancement", event.getAdvancement().getKey().toString());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void teleport(PlayerTeleportEvent event) {
|
||||
if (event.getTo() != null) audit(event.getPlayer(), "teleport", event.getCause().name() + " → "
|
||||
+ event.getTo().getWorld().getName() + " " + event.getTo().getBlockX() + ","
|
||||
+ event.getTo().getBlockY() + "," + event.getTo().getBlockZ());
|
||||
}
|
||||
|
||||
private void audit(Player player, String kind, String detail) {
|
||||
if (integration != null) integration.activity.event(player.getUniqueId(), player.getName(), kind, detail);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void death(EntityDeathEvent event) {
|
||||
if (event.getEntity() instanceof Player player) {
|
||||
add(player, "deaths");
|
||||
if (integration != null) integration.activity.event(player.getUniqueId(), player.getName(), "death",
|
||||
event instanceof PlayerDeathEvent death ? death.getDeathMessage() : null);
|
||||
}
|
||||
Player killer = event.getEntity().getKiller();
|
||||
if (killer != null) add(killer, event.getEntity() instanceof Player ? "player_kills" : "mob_kills");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# Create a server under Servers in the panel, then paste its token here.
|
||||
panel-url: "https://panel.example.com"
|
||||
token: ""
|
||||
@@ -0,0 +1,6 @@
|
||||
name: SCOPENET
|
||||
version: '${version}'
|
||||
main: net.scopenet.paper.ScopenetPlugin
|
||||
api-version: '1.20'
|
||||
description: SCOPENET private authentication, access control and activity reporting.
|
||||
load: STARTUP
|
||||
@@ -0,0 +1,16 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
maven { url = 'https://maven.fabricmc.net/' }
|
||||
maven { url = 'https://maven.minecraftforge.net/' }
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
rootProject.name = 'scopenet-integrations'
|
||||
include 'common', 'paper'
|
||||
// Build loaders separately: Forge and Loom use different Gradle generations.
|
||||
if (providers.gradleProperty('loader').orNull == 'fabric') include 'fabric'
|
||||
if (providers.gradleProperty('loader').orNull == 'forge') include 'forge'
|
||||
if (providers.gradleProperty('mcVersion').getOrElse('').startsWith('26.')) {
|
||||
if (findProject(':fabric') != null) project(':fabric').buildFileName = 'modern.gradle'
|
||||
if (findProject(':forge') != null) project(':forge').buildFileName = 'modern.gradle'
|
||||
}
|
||||
Reference in new issue
Block a user