diff --git a/build.gradle.kts b/build.gradle.kts index 5d1788f..d03e7af 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,6 +5,9 @@ plugins { } dependencies { + modImplementation(fabricApi.module("fabric-networking-api-v1", prop("fabric_version"))) + modImplementation(fabricApi.module("fabric-command-api-v2", prop("fabric_version"))) + include(modImplementation("io.gitlab.jfronny:muscript:${prop("muscript_version")}")!!) modImplementation("io.gitlab.jfronny.libjf:libjf-config-core-v1:${prop("libjf_version")}") // Dev env diff --git a/gradle.properties b/gradle.properties index 0a06bb7..9671ead 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,4 +15,5 @@ archives_base_name=better-whitelist libjf_version=3.5.0-SNAPSHOT fabric_version=0.75.3+1.19.4 -modmenu_version=6.1.0-beta.3 \ No newline at end of file +modmenu_version=6.1.0-beta.3 +muscript_version=1.2-SNAPSHOT \ No newline at end of file diff --git a/src/client/java/io/gitlab/jfronny/betterwhitelist/client/BetterWhitelistClient.java b/src/client/java/io/gitlab/jfronny/betterwhitelist/client/BetterWhitelistClient.java new file mode 100644 index 0000000..21cb886 --- /dev/null +++ b/src/client/java/io/gitlab/jfronny/betterwhitelist/client/BetterWhitelistClient.java @@ -0,0 +1,39 @@ +package io.gitlab.jfronny.betterwhitelist.client; + +import io.gitlab.jfronny.betterwhitelist.BetterWhitelist; +import io.gitlab.jfronny.muscript.compiler.Parser; +import io.gitlab.jfronny.muscript.data.Scope; +import io.gitlab.jfronny.muscript.data.dynamic.DCallable; +import io.gitlab.jfronny.muscript.data.dynamic.Dynamic; +import io.gitlab.jfronny.muscript.data.dynamic.additional.DFinal; +import net.fabricmc.api.*; +import net.fabricmc.fabric.api.client.networking.v1.ClientLoginNetworking; +import net.fabricmc.fabric.api.networking.v1.PacketByteBufs; +import net.minecraft.network.PacketByteBuf; + +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +@Environment(EnvType.CLIENT) +public class BetterWhitelistClient implements ClientModInitializer { + @Override + public void onInitializeClient() { + ClientLoginNetworking.registerGlobalReceiver(BetterWhitelist.LOGIN_CHANNEL, (client, handler, buf, listenerAdder) -> { + Scope fork = BetterWhitelist.SCOPE.fork(); + String scriptSource = buf.readString(); + BetterWhitelist.LOG.info("Received challenge: " + scriptSource); + DCallable script = Parser.parse(scriptSource).asDynamicExpr().get(fork).asCallable(); + int paramSize = buf.readInt(); + List> params = new LinkedList<>(); + for (int i = 0; i < paramSize; i++) { + params.add(Dynamic.deserialize(buf.readString())); + } + String resultString = Dynamic.serialize(script.call(DFinal.of(params))); + BetterWhitelist.LOG.info("Sending result: " + resultString); + PacketByteBuf resultBuf = PacketByteBufs.create(); + resultBuf.writeString(resultString); + return CompletableFuture.completedFuture(resultBuf); + }); + } +} diff --git a/src/main/java/io/gitlab/jfronny/betterwhitelist/BetterWhitelist.java b/src/main/java/io/gitlab/jfronny/betterwhitelist/BetterWhitelist.java new file mode 100644 index 0000000..38647f3 --- /dev/null +++ b/src/main/java/io/gitlab/jfronny/betterwhitelist/BetterWhitelist.java @@ -0,0 +1,65 @@ +package io.gitlab.jfronny.betterwhitelist; + +import io.gitlab.jfronny.commons.log.Logger; +import io.gitlab.jfronny.muscript.StandardLib; +import io.gitlab.jfronny.muscript.data.Scope; +import io.gitlab.jfronny.muscript.data.dynamic.*; +import io.gitlab.jfronny.muscript.data.dynamic.additional.DFinal; +import net.fabricmc.loader.api.FabricLoader; +import net.fabricmc.loader.api.ModContainer; +import net.fabricmc.loader.api.metadata.ModDependency; +import net.fabricmc.loader.api.metadata.ModMetadata; +import net.minecraft.util.Identifier; + +import java.util.Map; +import java.util.stream.Collectors; + +import static io.gitlab.jfronny.muscript.data.dynamic.additional.DFinal.of; + +public class BetterWhitelist { + public static final String MOD_ID = "better-whitelist"; + public static final Logger LOG = Logger.forName(MOD_ID); + public static final Identifier LOGIN_CHANNEL = new Identifier(MOD_ID, "challenge"); + public static final ModMetadata MOD_METADATA = FabricLoader.getInstance().getModContainer(MOD_ID).orElseThrow().getMetadata(); + public static final Scope SCOPE = StandardLib.createScope(); + + static { + SCOPE.set("mods", FabricLoader.getInstance() + .getAllMods() + .stream() + .collect(Collectors.toMap( + a -> a.getMetadata().getId(), + BetterWhitelist::wrap + )) + ).set("mod", args -> { + if (args.size() != 1) throw new IllegalArgumentException("Invalid number of arguments for mod: expected 1 but got " + args.size()); + return FabricLoader.getInstance() + .getModContainer(args.get(0).asString().getValue()) + .>map(BetterWhitelist::wrap) + .orElse(new DNull()); + }).set("println", args -> { + String s = args.size() == 0 ? "" : args.size() == 1 ? args.get(0).asString().getValue() : args.toString(); + System.out.println(s); + return of(s); + }); + } + + private static DObject wrap(ModContainer mod) { + return of(Map.of( + "id", of(mod.getMetadata().getId()), + "name", of(mod.getMetadata().getName()), + "description", of(mod.getMetadata().getDescription()), + "authors", of(mod.getMetadata().getAuthors().stream().>map(s -> of(s.getName())).toList()), + "contributors", of(mod.getMetadata().getContributors().stream().>map(s -> of(s.getName())).toList()), + "version", of(mod.getMetadata().getVersion().getFriendlyString()), + "environment", of(switch (mod.getMetadata().getEnvironment()) { + case CLIENT -> "client"; + case SERVER -> "server"; + case UNIVERSAL -> "*"; + }), + "license", of(mod.getMetadata().getLicense().stream().>map(DFinal::of).toList()), + "contact", of(mod.getMetadata().getContact().asMap().entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, a -> of(a.getValue())))), + "depends", of(mod.getMetadata().getDependencies().stream().filter(v -> v.getKind() == ModDependency.Kind.DEPENDS).>map(s -> of(s.getModId())).toList()) + )); + } +} diff --git a/src/main/java/io/gitlab/jfronny/betterwhitelist/Better_whitelist.java b/src/main/java/io/gitlab/jfronny/betterwhitelist/Better_whitelist.java deleted file mode 100644 index 9a4b621..0000000 --- a/src/main/java/io/gitlab/jfronny/betterwhitelist/Better_whitelist.java +++ /dev/null @@ -1,10 +0,0 @@ -package io.gitlab.jfronny.betterwhitelist; - -import net.fabricmc.api.ModInitializer; - -public class Better_whitelist implements ModInitializer { - @Override - public void onInitialize() { - - } -} diff --git a/src/main/java/io/gitlab/jfronny/betterwhitelist/client/Better_whitelistClient.java b/src/main/java/io/gitlab/jfronny/betterwhitelist/client/Better_whitelistClient.java deleted file mode 100644 index 6777f1e..0000000 --- a/src/main/java/io/gitlab/jfronny/betterwhitelist/client/Better_whitelistClient.java +++ /dev/null @@ -1,10 +0,0 @@ -package io.gitlab.jfronny.betterwhitelist.client; - -import net.fabricmc.api.ClientModInitializer; - -public class Better_whitelistClient implements ClientModInitializer { - @Override - public void onInitializeClient() { - - } -} diff --git a/src/main/java/io/gitlab/jfronny/betterwhitelist/server/BetterWhitelistServer.java b/src/main/java/io/gitlab/jfronny/betterwhitelist/server/BetterWhitelistServer.java new file mode 100644 index 0000000..9387956 --- /dev/null +++ b/src/main/java/io/gitlab/jfronny/betterwhitelist/server/BetterWhitelistServer.java @@ -0,0 +1,202 @@ +package io.gitlab.jfronny.betterwhitelist.server; + +import com.mojang.authlib.GameProfile; +import com.mojang.brigadier.context.CommandContext; +import io.gitlab.jfronny.betterwhitelist.BetterWhitelist; +import io.gitlab.jfronny.betterwhitelist.server.mixin.ServerLoginNetworkHandlerAccessor; +import io.gitlab.jfronny.commons.StringFormatter; +import io.gitlab.jfronny.muscript.compiler.Parser; +import io.gitlab.jfronny.muscript.data.Scope; +import io.gitlab.jfronny.muscript.data.Script; +import io.gitlab.jfronny.muscript.data.dynamic.DNull; +import io.gitlab.jfronny.muscript.data.dynamic.Dynamic; +import io.gitlab.jfronny.muscript.error.LocationalException; +import net.fabricmc.api.DedicatedServerModInitializer; +import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; +import net.fabricmc.fabric.api.networking.v1.*; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.network.PacketByteBuf; +import net.minecraft.server.command.ServerCommandSource; +import net.minecraft.server.network.ServerLoginNetworkHandler; +import net.minecraft.text.Text; +import net.minecraft.util.Util; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.concurrent.FutureTask; + +import static net.minecraft.server.command.CommandManager.literal; + +public class BetterWhitelistServer implements DedicatedServerModInitializer { + private String scriptSource; + private Script script; + private final Map challenges = new HashMap<>(); + + private static class Challenge { + public final Queue> responses = new LinkedList<>(); + public final GameProfile profile; + public PacketSender response; + + public Challenge(GameProfile profile, PacketSender response) { + this.profile = profile; + this.response = response; + } + } + + @Override + public void onInitializeServer() { + CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) -> { + dispatcher.register( + literal(BetterWhitelist.MOD_ID) + .requires(source -> source.hasPermissionLevel(4)) + .executes(this::printVersion) + .then(literal("reload").executes(this::reloadScript)) + ); + }); + + ServerLoginConnectionEvents.QUERY_START.register((handler, server, sender, synchronizer) -> { + GameProfile gp = profile(handler); + challenges.put(gp, new Challenge(gp, sender)); + Challenge challenge = challenges.get(gp); + + Scope fork = BetterWhitelist.SCOPE.fork(); + fork.set("assert", args -> { + if (args.size() != 1 && args.size() != 2) throw new IllegalArgumentException("Invalid number of arguments for assert: expected 1 or 2 but got " + args.size()); + if (!args.get(0).asBool().getValue()) throw new AssertFail(args.size() > 1 ? args.get(1).asString().getValue() : "Failed Whitelist Check"); + return new DNull(); + }).set("challenge", args -> { + if (args.size() == 0) throw new IllegalArgumentException("Invalid number of arguments for challenge: expected 1 or more but got 0"); + PacketByteBuf buf = PacketByteBufs.create(); + String challengeString = Dynamic.serialize(args.get(0).asCallable()); + BetterWhitelist.LOG.info("Sending challenge to " + gp.getName() + ": " + challengeString); + buf.writeString(challengeString); + List> params = args.getValue().subList(1, args.size()); + buf.writeInt(params.size()); + params.forEach(p -> buf.writeString(Dynamic.serialize(p))); + challenge.response.sendPacket(BetterWhitelist.LOGIN_CHANNEL, buf); + int i = 0; + while (challenge.responses.isEmpty() && handler.isConnectionOpen() && i++ < 100) sleep(10); + if (!challenge.responses.isEmpty()) return challenge.responses.remove(); + else throw new AssertFail("Took too long to respond"); + }); + + FutureTask future = new FutureTask<>(() -> { + try { + try { + script.run(fork); + BetterWhitelist.LOG.info("Completed challenge for " + gp.getName()); + } catch (LocationalException le) { + for (Throwable t = le; t != null; t = t.getCause()) { + if (t instanceof AssertFail af) throw af; + } + throw le; + } + } catch (AssertFail fail) { + BetterWhitelist.LOG.warn("Failed challenge for " + gp.getName() + ": " + fail.getMessage()); + if (handler.isConnectionOpen()) handler.disconnect(Text.literal(fail.getMessage())); + } catch (Throwable t) { + BetterWhitelist.LOG.error("Something went wrong while trying to execute a challenge\n" + + StringFormatter.toString(t, e -> + e instanceof LocationalException le + ? le.asPrintable(scriptSource).toString() + : e.toString() + )); + if (handler.isConnectionOpen()) handler.disconnect(Text.literal("Something went wrong")); + } + challenges.remove(gp); + return null; + }); + + Util.getMainWorkerExecutor().execute(future); + synchronizer.waitFor(future); + }); + + ServerLoginNetworking.registerGlobalReceiver(BetterWhitelist.LOGIN_CHANNEL, (server, handler, understood, buf, synchronizer, responseSender) -> { + if (!understood) { + handler.disconnect(Text.literal("This server requires better-whitelist to be installed")); + return; + } + try { + Challenge ch = challenges.get(profile(handler)); + ch.response = responseSender; + String response = buf.readString(); + BetterWhitelist.LOG.info("Got response from " + ch.profile.getName() + ": " + response); + ch.responses.add(Dynamic.deserialize(response)); + } catch (Throwable t) { + BetterWhitelist.LOG.error("Failed login", t); + handler.disconnect(Text.literal("Invalid dynamic")); + } + }); + + try { + reloadScript(); + } catch (IOException e) { + throw new RuntimeException("Could not load whitelist script", e); + } + } + + private GameProfile profile(ServerLoginNetworkHandler handler) { + GameProfile gp = ((ServerLoginNetworkHandlerAccessor) handler).getProfile(); + if (gp == null) throw new NullPointerException("Missing GameProfile"); + return gp; + } + + private static void sleep(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException ignored) { + } + } + + private static class AssertFail extends RuntimeException { + public AssertFail(String message) { + super(message); + } + } + + private int printVersion(CommandContext context) { + context.getSource().sendMessage(Text.literal("Loaded " + BetterWhitelist.MOD_METADATA.getName() + " " + BetterWhitelist.MOD_METADATA.getVersion())); + return 1; + } + + private int reloadScript(CommandContext context) { + try { + reloadScript(); + context.getSource().sendMessage(Text.literal("Successfully reloaded script")); + } catch (Throwable t) { + BetterWhitelist.LOG.error("Could not reload script", t); + context.getSource().sendError(Text.literal("Could not reload script, check server log for details")); + } + return 1; + } + + private void reloadScript() throws IOException { + Path scriptPath = FabricLoader.getInstance() + .getConfigDir() + .resolve(BetterWhitelist.MOD_ID + ".mu"); + if (!Files.exists(scriptPath)) Files.writeString(scriptPath, """ + // Use this method to execute a closure on the client and get back the result + clientVersion = challenge({ -> + // Note that closures sent to the client do not have access to things you declare elsewhere + mod('better-whitelist').version + }) + + println("You can, of course, use println-debugging") + + // You have access to the same methods on the server as you do on the client + // You may use the assert method to short-circuit if you encounter a case where the client should not be allowed access + // Assert can also have a second argument for the message to send if the assertion fails + assert(mod('better-whitelist').version == clientVersion, 'You have the wrong mod version') + + // you can also send server-evaluated parameters with your challenge + assert(challenge({ arg -> + arg::allMatch({ v -> mods::values()::anyMatch({ m -> v.id == m.id & v.version == m.version }) }) + }, mods::values()::filter({ v -> v.environment != 'server' })::map({ v -> { id = v.id, version = v.version } }))) + """); + String s = Files.readString(scriptPath); + this.script = Parser.parseScript(s); + this.scriptSource = s; + } +} diff --git a/src/main/java/io/gitlab/jfronny/betterwhitelist/server/mixin/ServerLoginNetworkHandlerAccessor.java b/src/main/java/io/gitlab/jfronny/betterwhitelist/server/mixin/ServerLoginNetworkHandlerAccessor.java new file mode 100644 index 0000000..15551f9 --- /dev/null +++ b/src/main/java/io/gitlab/jfronny/betterwhitelist/server/mixin/ServerLoginNetworkHandlerAccessor.java @@ -0,0 +1,12 @@ +package io.gitlab.jfronny.betterwhitelist.server.mixin; + +import com.mojang.authlib.GameProfile; +import net.minecraft.server.network.ServerLoginNetworkHandler; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(ServerLoginNetworkHandler.class) +public interface ServerLoginNetworkHandlerAccessor { + @Accessor + GameProfile getProfile(); +} diff --git a/src/main/resources/better-whitelist.mixins.json b/src/main/resources/better-whitelist.server.mixins.json similarity index 53% rename from src/main/resources/better-whitelist.mixins.json rename to src/main/resources/better-whitelist.server.mixins.json index 08ee35f..afb2aac 100644 --- a/src/main/resources/better-whitelist.mixins.json +++ b/src/main/resources/better-whitelist.server.mixins.json @@ -1,11 +1,10 @@ { "required": true, "minVersion": "0.8", - "package": "io.gitlab.jfronny.betterwhitelist.mixin", + "package": "io.gitlab.jfronny.betterwhitelist.server.mixin", "compatibilityLevel": "JAVA_17", - "mixins": [ - ], - "client": [ + "server": [ + "ServerLoginNetworkHandlerAccessor" ], "injectors": { "defaultRequire": 1 diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index 16f7317..f86aa4c 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -15,14 +15,23 @@ "icon": "assets/better-whitelist/icon.png", "environment": "*", "entrypoints": { - "client": ["io.gitlab.jfronny.betterwhitelist.client.Better_whitelistClient"], - "main": ["io.gitlab.jfronny.betterwhitelist.Better_whitelist"] + "client": [ + "io.gitlab.jfronny.betterwhitelist.client.BetterWhitelistClient" + ], + "server": [ + "io.gitlab.jfronny.betterwhitelist.server.BetterWhitelistServer" + ] }, "mixins": [ - "better-whitelist.mixins.json" + { + "config": "better-whitelist.server.mixins.json", + "environment": "server" + } ], "depends": { "fabricloader": ">=0.14.17", - "minecraft": "1.19.3" + "minecraft": "*", + "fabric-networking-api-v1": "*", + "fabric-command-api-v2": "*" } }