diff options
Diffstat (limited to 'mod/src/main')
56 files changed, 2003 insertions, 0 deletions
diff --git a/mod/src/main/java/com/google/gson/typeadapters/RuntimeTypeAdapterFactory.java b/mod/src/main/java/com/google/gson/typeadapters/RuntimeTypeAdapterFactory.java new file mode 100644 index 0000000..51f05ff --- /dev/null +++ b/mod/src/main/java/com/google/gson/typeadapters/RuntimeTypeAdapterFactory.java @@ -0,0 +1,272 @@ +/* + * Copyright (C) 2011 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.gson.typeadapters; + +import com.google.gson.*; +import com.google.gson.reflect.TypeToken; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Adapts values whose runtime type may differ from their declaration type. This + * is necessary when a field's type is not the same type that GSON should create + * when deserializing that field. For example, consider these types: + * <pre> {@code + * abstract class Shape { + * int x; + * int y; + * } + * class Circle extends Shape { + * int radius; + * } + * class Rectangle extends Shape { + * int width; + * int height; + * } + * class Diamond extends Shape { + * int width; + * int height; + * } + * class Drawing { + * Shape bottomShape; + * Shape topShape; + * } + * }</pre> + * <p>Without additional type information, the serialized JSON is ambiguous. Is + * the bottom shape in this drawing a rectangle or a diamond? <pre> {@code + * { + * "bottomShape": { + * "width": 10, + * "height": 5, + * "x": 0, + * "y": 0 + * }, + * "topShape": { + * "radius": 2, + * "x": 4, + * "y": 1 + * } + * }}</pre> + * This class addresses this problem by adding type information to the + * serialized JSON and honoring that type information when the JSON is + * deserialized: <pre> {@code + * { + * "bottomShape": { + * "type": "Diamond", + * "width": 10, + * "height": 5, + * "x": 0, + * "y": 0 + * }, + * "topShape": { + * "type": "Circle", + * "radius": 2, + * "x": 4, + * "y": 1 + * } + * }}</pre> + * Both the type field name ({@code "type"}) and the type labels ({@code + * "Rectangle"}) are configurable. + * + * <h3>Registering Types</h3> + * Create a {@code RuntimeTypeAdapterFactory} by passing the base type and type field + * name to the {@link #of} factory method. If you don't supply an explicit type + * field name, {@code "type"} will be used. <pre> {@code + * RuntimeTypeAdapterFactory<Shape> shapeAdapterFactory + * = RuntimeTypeAdapterFactory.of(Shape.class, "type"); + * }</pre> + * Next register all of your subtypes. Every subtype must be explicitly + * registered. This protects your application from injection attacks. If you + * don't supply an explicit type label, the type's simple name will be used. + * <pre> {@code + * shapeAdapterFactory.registerSubtype(Rectangle.class, "Rectangle"); + * shapeAdapterFactory.registerSubtype(Circle.class, "Circle"); + * shapeAdapterFactory.registerSubtype(Diamond.class, "Diamond"); + * }</pre> + * Finally, register the type adapter factory in your application's GSON builder: + * <pre> {@code + * Gson gson = new GsonBuilder() + * .registerTypeAdapterFactory(shapeAdapterFactory) + * .create(); + * }</pre> + * Like {@code GsonBuilder}, this API supports chaining: <pre> {@code + * RuntimeTypeAdapterFactory<Shape> shapeAdapterFactory = RuntimeTypeAdapterFactory.of(Shape.class) + * .registerSubtype(Rectangle.class) + * .registerSubtype(Circle.class) + * .registerSubtype(Diamond.class); + * }</pre> + * + * <h3>Serialization and deserialization</h3> + * In order to serialize and deserialize a polymorphic object, + * you must specify the base type explicitly. + * <pre> {@code + * Diamond diamond = new Diamond(); + * String json = gson.toJson(diamond, Shape.class); + * }</pre> + * And then: + * <pre> {@code + * Shape shape = gson.fromJson(json, Shape.class); + * }</pre> + */ +public final class RuntimeTypeAdapterFactory<T> implements TypeAdapterFactory { + private final Class<?> baseType; + private final String typeFieldName; + private final Map<String, Class<?>> labelToSubtype = new LinkedHashMap<>(); + private final Map<Class<?>, String> subtypeToLabel = new LinkedHashMap<>(); + private final boolean maintainType; + + private RuntimeTypeAdapterFactory(Class<?> baseType, String typeFieldName, boolean maintainType) { + if (typeFieldName == null || baseType == null) { + throw new NullPointerException(); + } + this.baseType = baseType; + this.typeFieldName = typeFieldName; + this.maintainType = maintainType; + } + + /** + * Creates a new runtime type adapter using for {@code baseType} using {@code + * typeFieldName} as the type field name. Type field names are case sensitive. + * {@code maintainType} flag decide if the type will be stored in pojo or not. + */ + public static <T> RuntimeTypeAdapterFactory<T> of(Class<T> baseType, String typeFieldName, boolean maintainType) { + return new RuntimeTypeAdapterFactory<>(baseType, typeFieldName, maintainType); + } + + /** + * Creates a new runtime type adapter using for {@code baseType} using {@code + * typeFieldName} as the type field name. Type field names are case sensitive. + */ + public static <T> RuntimeTypeAdapterFactory<T> of(Class<T> baseType, String typeFieldName) { + return new RuntimeTypeAdapterFactory<>(baseType, typeFieldName, false); + } + + /** + * Creates a new runtime type adapter for {@code baseType} using {@code "type"} as + * the type field name. + */ + public static <T> RuntimeTypeAdapterFactory<T> of(Class<T> baseType) { + return new RuntimeTypeAdapterFactory<>(baseType, "type", false); + } + + /** + * Registers {@code type} identified by {@code label}. Labels are case + * sensitive. + * + * @throws IllegalArgumentException if either {@code type} or {@code label} + * have already been registered on this type adapter. + */ + public RuntimeTypeAdapterFactory<T> registerSubtype(Class<? extends T> type, String label) { + if (type == null || label == null) { + throw new NullPointerException(); + } + if (subtypeToLabel.containsKey(type) || labelToSubtype.containsKey(label)) { + throw new IllegalArgumentException("types and labels must be unique"); + } + labelToSubtype.put(label, type); + subtypeToLabel.put(type, label); + return this; + } + + /** + * Registers {@code type} identified by its {@link Class#getSimpleName simple + * name}. Labels are case sensitive. + * + * @throws IllegalArgumentException if either {@code type} or its simple name + * have already been registered on this type adapter. + */ + public RuntimeTypeAdapterFactory<T> registerSubtype(Class<? extends T> type) { + return registerSubtype(type, type.getSimpleName()); + } + + @Override + public <R> TypeAdapter<R> create(Gson gson, TypeToken<R> type) { + // Workaround found at https://github.com/google/gson/issues/712#issuecomment-148955110 + if (null == type || !baseType.isAssignableFrom(type.getRawType())) { + // if (type.getRawType() != baseType) { + return null; + } + + final TypeAdapter<JsonElement> jsonElementAdapter = gson.getAdapter(JsonElement.class); + final Map<String, TypeAdapter<?>> labelToDelegate = new LinkedHashMap<>(); + final Map<Class<?>, TypeAdapter<?>> subtypeToDelegate = new LinkedHashMap<>(); + for (Map.Entry<String, Class<?>> entry : labelToSubtype.entrySet()) { + TypeAdapter<?> delegate = gson.getDelegateAdapter(this, TypeToken.get(entry.getValue())); + labelToDelegate.put(entry.getKey(), delegate); + subtypeToDelegate.put(entry.getValue(), delegate); + } + + return new TypeAdapter<R>() { + @Override public R read(JsonReader in) throws IOException { + JsonElement jsonElement = jsonElementAdapter.read(in); + JsonElement labelJsonElement; + if (maintainType) { + labelJsonElement = jsonElement.getAsJsonObject().get(typeFieldName); + } else { + labelJsonElement = jsonElement.getAsJsonObject().remove(typeFieldName); + } + + if (labelJsonElement == null) { + throw new JsonParseException("cannot deserialize " + baseType + + " because it does not define a field named " + typeFieldName); + } + String label = labelJsonElement.getAsString(); + @SuppressWarnings("unchecked") // registration requires that subtype extends T + TypeAdapter<R> delegate = (TypeAdapter<R>) labelToDelegate.get(label); + if (delegate == null) { + throw new JsonParseException("cannot deserialize " + baseType + " subtype named " + + label + "; did you forget to register a subtype?"); + } + return delegate.fromJsonTree(jsonElement); + } + + @Override public void write(JsonWriter out, R value) throws IOException { + Class<?> srcType = value.getClass(); + String label = subtypeToLabel.get(srcType); + @SuppressWarnings("unchecked") // registration requires that subtype extends T + TypeAdapter<R> delegate = (TypeAdapter<R>) subtypeToDelegate.get(srcType); + if (delegate == null) { + throw new JsonParseException("cannot serialize " + srcType.getName() + + "; did you forget to register a subtype?"); + } + JsonObject jsonObject = delegate.toJsonTree(value).getAsJsonObject(); + + if (maintainType) { + jsonElementAdapter.write(out, jsonObject); + return; + } + + JsonObject clone = new JsonObject(); + + if (jsonObject.has(typeFieldName)) { + throw new JsonParseException("cannot serialize " + srcType.getName() + + " because it already defines a field named " + typeFieldName); + } + clone.add(typeFieldName, new JsonPrimitive(label)); + + for (Map.Entry<String, JsonElement> e : jsonObject.entrySet()) { + clone.add(e.getKey(), e.getValue()); + } + jsonElementAdapter.write(out, clone); + } + }.nullSafe(); + } +} diff --git a/mod/src/main/java/moe/ymc/acron/Acron.java b/mod/src/main/java/moe/ymc/acron/Acron.java new file mode 100644 index 0000000..d6f6214 --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/Acron.java @@ -0,0 +1,32 @@ +package moe.ymc.acron; + +import moe.ymc.acron.config.Config; +import moe.ymc.acron.config.json.ConfigDeserializer; +import net.fabricmc.api.ModInitializer; +import net.fabricmc.loader.api.FabricLoader; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.io.IOException; +import java.nio.file.Path; + +public class Acron implements ModInitializer { + private static final Logger LOGGER = LogManager.getLogger(); + + @Override + public void onInitialize() { + LOGGER.debug("onInitialize"); + try { + final Path config = FabricLoader + .getInstance().getConfigDir() + .resolve("acron.json"); + if (!config.toFile().exists()) { + throw new IllegalStateException("Cannot find config/acron.json."); + } + final Config cfg = ConfigDeserializer.deserialize(config.toFile(), true); + Config.setGlobalConfig(cfg); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/mod/src/main/java/moe/ymc/acron/MinecraftServerHolder.java b/mod/src/main/java/moe/ymc/acron/MinecraftServerHolder.java new file mode 100644 index 0000000..f522884 --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/MinecraftServerHolder.java @@ -0,0 +1,28 @@ +package moe.ymc.acron; + +import net.minecraft.server.dedicated.MinecraftDedicatedServer; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.NotNull; + +public class MinecraftServerHolder { + private static final Logger LOGGER = LogManager.getLogger(); + public static MinecraftDedicatedServer server; + + public static void setServer(@NotNull MinecraftDedicatedServer server) { + if (MinecraftServerHolder.server != null) { + throw new IllegalStateException(); + } + LOGGER.debug("Got MinecraftDedicatedServer on thread {}.", + Thread.currentThread().getName()); + MinecraftServerHolder.server = server; + } + + public static @NotNull MinecraftDedicatedServer getServer() { + if (server == null) { + throw new IllegalStateException(String.format("[%s] getServer() called before a server is ready.", + Thread.currentThread().getName())); + } + return server; + } +} diff --git a/mod/src/main/java/moe/ymc/acron/auth/Action.java b/mod/src/main/java/moe/ymc/acron/auth/Action.java new file mode 100644 index 0000000..17d29a3 --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/auth/Action.java @@ -0,0 +1,10 @@ +package moe.ymc.acron.auth; + +import com.google.gson.annotations.SerializedName; + +public enum Action { + @SerializedName("allow") + ALLOW, + @SerializedName("deny") + DENY +} diff --git a/mod/src/main/java/moe/ymc/acron/auth/Client.java b/mod/src/main/java/moe/ymc/acron/auth/Client.java new file mode 100644 index 0000000..2124ad4 --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/auth/Client.java @@ -0,0 +1,9 @@ +package moe.ymc.acron.auth; + +import org.jetbrains.annotations.NotNull; + +public record Client(@NotNull String id, + @NotNull String token, + @NotNull Action policyMode, + @NotNull Rule[] rules) { +} diff --git a/mod/src/main/java/moe/ymc/acron/auth/PolicyChecker.java b/mod/src/main/java/moe/ymc/acron/auth/PolicyChecker.java new file mode 100644 index 0000000..2ab7b97 --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/auth/PolicyChecker.java @@ -0,0 +1,42 @@ +package moe.ymc.acron.auth; + +import moe.ymc.acron.jvav.Pair; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.NotNull; + +public class PolicyChecker { + private static final Logger LOGGER = LogManager.getLogger(); + + public static Pair<Action, Boolean> check(@NotNull Client client, + @NotNull String command) { + final String commandToMatch = command.startsWith("/") ? + command.substring(1) : + command; + for (int i = 0; i < client.rules().length; i++) { + final Rule rule = client.rules()[i]; + if (rule.cmdPattern().matcher(commandToMatch).matches()) { + if (rule.action() == Action.DENY) { + LOGGER.warn("The command from client {}, `{}`, was " + + "explicitly denied by rule #{} (starting from 1).", + client.id(), + command, + i + 1); + } else { + LOGGER.warn("The command from client {}, `{}`, was " + + "explicitly allowed by rule #{} (starting from 1).", + client.id(), + command, + i + 1); + } + return new Pair<>(rule.action(), rule.display()); + } + } + LOGGER.warn("The command from client {}, `{}`, was " + + "implicitly {} by the default policy mode.", + client.id(), + command, + client.policyMode() == Action.ALLOW ? "allowed" : "denied"); + return new Pair<>(client.policyMode() == Action.ALLOW ? Action.ALLOW : Action.DENY, false); + } +} diff --git a/mod/src/main/java/moe/ymc/acron/auth/Rule.java b/mod/src/main/java/moe/ymc/acron/auth/Rule.java new file mode 100644 index 0000000..55ad0d7 --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/auth/Rule.java @@ -0,0 +1,10 @@ +package moe.ymc.acron.auth; + +import org.jetbrains.annotations.NotNull; + +import java.util.regex.Pattern; + +public record Rule(@NotNull Pattern cmdPattern, + @NotNull Action action, + boolean display) { +} diff --git a/mod/src/main/java/moe/ymc/acron/c2s/ReqCmd.java b/mod/src/main/java/moe/ymc/acron/c2s/ReqCmd.java new file mode 100644 index 0000000..6f34b07 --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/c2s/ReqCmd.java @@ -0,0 +1,51 @@ +package moe.ymc.acron.c2s; + +import com.google.gson.*; +import com.google.gson.annotations.SerializedName; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.lang.reflect.Type; + +public record ReqCmd(@SerializedName("id") int id, + @SerializedName("cmd") @NotNull String cmd, + @SerializedName("config") @Nullable ReqSetConfig config) + implements Request { + @Override + public void validate() { + if (cmd == null) { + throw new IllegalArgumentException("Property 'cmd' cannot be null."); + } + } + + @Override + public int getId() { + return id; + } + + public static class ReqCmdDeserializer implements JsonDeserializer<ReqCmd> { + @Override + public ReqCmd deserialize(JsonElement json, + Type typeOfT, + JsonDeserializationContext context) throws JsonParseException { + final JsonObject object = json.getAsJsonObject(); + final int id = object.has("id") ? + object.get("id").getAsInt() : + -1; + final String cmd = object.has("cmd") ? + object.get("cmd").getAsString() : + null; + // We cannot use context#deserialize here + // because RuntimeTypeAdapterFactory keeps kicking in + // and asking for the 'type' property + // which is obviously redundant for an inner field. + // Thus, I pass it directly to the deserializer + // to bypass the RuntimeTypeAdapterFactory. + final ReqSetConfig reqSetConfig = object.has("config") ? + new ReqSetConfig.ReqSetConfigDeserializer() + .deserialize(object.get("config"), ReqSetConfig.class, context) : + null; + return new ReqCmd(id, cmd, reqSetConfig); + } + } +} diff --git a/mod/src/main/java/moe/ymc/acron/c2s/ReqSetConfig.java b/mod/src/main/java/moe/ymc/acron/c2s/ReqSetConfig.java new file mode 100644 index 0000000..fcddf35 --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/c2s/ReqSetConfig.java @@ -0,0 +1,63 @@ +package moe.ymc.acron.c2s; + +import com.google.gson.*; +import com.google.gson.annotations.SerializedName; +import moe.ymc.acron.common.Vec2f; +import moe.ymc.acron.common.Vec3d; +import moe.ymc.acron.common.WorldKey; +import org.jetbrains.annotations.Nullable; + +import java.lang.reflect.Type; + +public record ReqSetConfig(@SerializedName("id") int id, + @SerializedName("world") @Nullable WorldKey world, + @SerializedName("pos") @Nullable Vec3d pos, + @SerializedName("rot") @Nullable Vec2f rot, + @SerializedName("name") @Nullable String name) + implements Request { + @Override + public void validate() { + } + + @Override + public int getId() { + return id; + } + + public static class ReqSetConfigDeserializer implements JsonDeserializer<ReqSetConfig> { + @Override + public ReqSetConfig deserialize(JsonElement json, + Type typeOfT, + JsonDeserializationContext context) throws JsonParseException { + final JsonObject object = json.getAsJsonObject(); + final int id = object.has("id") ? + object.get("id").getAsInt() : + -1; + final WorldKey world; + if (object.has("world")) { + world = context.deserialize(object.get("world"), WorldKey.class); + // https://stackoverflow.com/a/49574019 + if (world == null) { + throw new JsonParseException("Invalid world"); + } + } else { + world = null; + } + final Vec3d pos = object.has("pos") ? + context.deserialize(object.get("pos"), Vec3d.class) : + null; + final Vec2f rot = object.has("rot") ? + context.deserialize(object.get("rot"), Vec2f.class) : + null; + final String name = object.has("name") ? + object.get("name").getAsString() : + null; + return new ReqSetConfig(id, + world, + pos, + rot, + name); + } + } + +}
\ No newline at end of file diff --git a/mod/src/main/java/moe/ymc/acron/c2s/Request.java b/mod/src/main/java/moe/ymc/acron/c2s/Request.java new file mode 100644 index 0000000..af81705 --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/c2s/Request.java @@ -0,0 +1,6 @@ +package moe.ymc.acron.c2s; + +public interface Request { + int getId(); + void validate() throws IllegalArgumentException; +} diff --git a/mod/src/main/java/moe/ymc/acron/cmd/CmdOut.java b/mod/src/main/java/moe/ymc/acron/cmd/CmdOut.java new file mode 100644 index 0000000..55eadf1 --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/cmd/CmdOut.java @@ -0,0 +1,53 @@ +package moe.ymc.acron.cmd; + +import io.netty.channel.Channel; +import moe.ymc.acron.s2c.response.EventCmdOut; +import moe.ymc.acron.serialization.Serializer; +import net.minecraft.server.command.CommandOutput; +import net.minecraft.text.Text; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.NotNull; + +import java.util.UUID; + +public class CmdOut implements CommandOutput { + private static final Logger LOGGER = LogManager.getLogger(); + + private final @NotNull Channel channel; + private final int id; + private final boolean display; + + public CmdOut(@NotNull Channel channel, + int id, + boolean display) { + this.channel = channel; + this.id = id; + this.display = display; + } + + @Override + public void sendSystemMessage(Text message, UUID sender) { + LOGGER.debug("sendSystemMessage[{}]: {}", + id, + message.getString()); + channel.writeAndFlush( + Serializer.serialize(new EventCmdOut(id, sender, message.getString())) + ); + } + + @Override + public boolean shouldReceiveFeedback() { + return true; + } + + @Override + public boolean shouldTrackOutput() { + return true; + } + + @Override + public boolean shouldBroadcastConsoleToOps() { + return display; + } +} diff --git a/mod/src/main/java/moe/ymc/acron/cmd/CmdQueue.java b/mod/src/main/java/moe/ymc/acron/cmd/CmdQueue.java new file mode 100644 index 0000000..3c49143 --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/cmd/CmdQueue.java @@ -0,0 +1,17 @@ +package moe.ymc.acron.cmd; + +import io.netty.channel.Channel; +import moe.ymc.acron.MinecraftServerHolder; +import moe.ymc.acron.net.ClientConfiguration; +import org.jetbrains.annotations.NotNull; + +public class CmdQueue { + public static void enqueue(int id, + boolean display, + @NotNull final Channel channel, + @NotNull final ClientConfiguration configuration, + @NotNull final String command) { + MinecraftServerHolder.getServer().enqueueCommand(command, + new CmdSrc(channel, id, display, configuration, MinecraftServerHolder.getServer())); + } +} diff --git a/mod/src/main/java/moe/ymc/acron/cmd/CmdResConsumer.java b/mod/src/main/java/moe/ymc/acron/cmd/CmdResConsumer.java new file mode 100644 index 0000000..d22b77e --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/cmd/CmdResConsumer.java @@ -0,0 +1,33 @@ +package moe.ymc.acron.cmd; + +import com.mojang.brigadier.ResultConsumer; +import com.mojang.brigadier.context.CommandContext; +import io.netty.channel.Channel; +import moe.ymc.acron.s2c.response.EventCmdRes; +import moe.ymc.acron.serialization.Serializer; +import net.minecraft.server.command.ServerCommandSource; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.NotNull; + +public class CmdResConsumer implements ResultConsumer<ServerCommandSource> { + private static final Logger LOGGER = LogManager.getLogger(); + + private final @NotNull Channel channel; + private final int id; + + public CmdResConsumer(@NotNull Channel channel, + int id) { + this.channel = channel; + this.id = id; + } + + @Override + public void onCommandComplete(CommandContext<ServerCommandSource> context, boolean success, int result) { + LOGGER.debug("onCommandComplete[{}]: {} {}", + id, + success, + result); + channel.writeAndFlush(Serializer.serialize(new EventCmdRes(id, success, result))); + } +} diff --git a/mod/src/main/java/moe/ymc/acron/cmd/CmdSrc.java b/mod/src/main/java/moe/ymc/acron/cmd/CmdSrc.java new file mode 100644 index 0000000..983b4ed --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/cmd/CmdSrc.java @@ -0,0 +1,34 @@ +package moe.ymc.acron.cmd; + +import io.netty.channel.Channel; +import moe.ymc.acron.net.ClientConfiguration; +import net.minecraft.command.argument.EntityAnchorArgumentType; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.command.ServerCommandSource; +import net.minecraft.text.LiteralText; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.NotNull; + +class CmdSrc extends ServerCommandSource { + private static final Logger LOGGER = LogManager.getLogger(); + + public CmdSrc(@NotNull Channel channel, + int id, + boolean display, + @NotNull ClientConfiguration configuration, + @NotNull MinecraftServer server) { + super(new CmdOut(channel, id, display), + configuration.pos(), + configuration.rot(), + configuration.world(), + 4, + configuration.name(), + new LiteralText(configuration.name()), + server, + null, + false, + new CmdResConsumer(channel, id), + EntityAnchorArgumentType.EntityAnchor.FEET); + } +} diff --git a/mod/src/main/java/moe/ymc/acron/common/Vec2f.java b/mod/src/main/java/moe/ymc/acron/common/Vec2f.java new file mode 100644 index 0000000..5ab3dfd --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/common/Vec2f.java @@ -0,0 +1,25 @@ +package moe.ymc.acron.common; + +import com.google.gson.*; +import com.google.gson.annotations.SerializedName; + +import java.lang.reflect.Type; + +public record Vec2f(@SerializedName("x") float x, + @SerializedName("y") float y) { + public static class Vec2fDeserializer implements JsonDeserializer<Vec2f> { + @Override + public Vec2f deserialize(JsonElement json, + Type typeOfT, + JsonDeserializationContext context) throws JsonParseException { + final JsonObject object = json.getAsJsonObject(); + final float x = object.has("x") ? + object.get("x").getAsFloat() : + 0.0f; + final float y = object.has("y") ? + object.get("y").getAsFloat() : + 0.0f; + return new Vec2f(x, y); + } + } +}
\ No newline at end of file diff --git a/mod/src/main/java/moe/ymc/acron/common/Vec3d.java b/mod/src/main/java/moe/ymc/acron/common/Vec3d.java new file mode 100644 index 0000000..593019f --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/common/Vec3d.java @@ -0,0 +1,34 @@ +package moe.ymc.acron.common; + +import com.google.gson.*; +import com.google.gson.annotations.SerializedName; +import org.jetbrains.annotations.NotNull; + +import java.lang.reflect.Type; + +public record Vec3d(@SerializedName("x") double x, + @SerializedName("y") double y, + @SerializedName("z") double z) { + public Vec3d(@NotNull net.minecraft.util.math.Vec3d vec3d) { + this(vec3d.x, vec3d.y, vec3d.z); + } + + public static class Vec3dDeserializer implements JsonDeserializer<Vec3d> { + @Override + public Vec3d deserialize(JsonElement json, + Type typeOfT, + JsonDeserializationContext context) throws JsonParseException { + final JsonObject object = json.getAsJsonObject(); + final double x = object.has("x") ? + object.get("x").getAsDouble() : + 0.0; + final double y = object.has("y") ? + object.get("y").getAsDouble() : + 0.0; + final double z = object.has("z") ? + object.get("z").getAsDouble() : + 0.0; + return new Vec3d(x, y, z); + } + } +}
\ No newline at end of file diff --git a/mod/src/main/java/moe/ymc/acron/common/WorldKey.java b/mod/src/main/java/moe/ymc/acron/common/WorldKey.java new file mode 100644 index 0000000..fa10d54 --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/common/WorldKey.java @@ -0,0 +1,35 @@ +package moe.ymc.acron.common; + +import com.google.gson.annotations.SerializedName; +import net.minecraft.util.Identifier; +import net.minecraft.world.dimension.DimensionType; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public enum WorldKey { + @SerializedName("overworld") + OVERWORLD, + @SerializedName("nether") + NETHER, + @SerializedName("end") + END; + + private static final Logger LOGGER = LogManager.getLogger(); + + public static @Nullable WorldKey create(@NotNull Identifier identifier) { + if (identifier.equals(DimensionType.OVERWORLD_ID)) { + return OVERWORLD; + } else if (identifier.equals(DimensionType.THE_NETHER_ID)) { + return NETHER; + } else if (identifier.equals(DimensionType.THE_END_ID)) { + return END; + } else { + LOGGER.warn("Unknown world {}:{}. Returning NULL to the client.", + identifier.getNamespace(), + identifier.getPath()); + return null; + } + } +}
\ No newline at end of file diff --git a/mod/src/main/java/moe/ymc/acron/config/Config.java b/mod/src/main/java/moe/ymc/acron/config/Config.java new file mode 100644 index 0000000..3749c25 --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/config/Config.java @@ -0,0 +1,30 @@ +package moe.ymc.acron.config; + +import moe.ymc.acron.auth.Client; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.NotNull; + +import java.net.InetAddress; +import java.util.Map; + +public record Config(@NotNull InetAddress address, + int port, + boolean useNativeTransport, + @NotNull Map<String, Client> clients) { + private static final Logger LOGGER = LogManager.getLogger(); + private static Config globalConfig; + + @NotNull + public static Config getGlobalConfig() { + return globalConfig; + } + + public static void setGlobalConfig(@NotNull Config globalConfig) { + Config.globalConfig = globalConfig; + LOGGER.info("Config loaded with {} clients. Listening on {}:{}.", + globalConfig.clients.size(), + globalConfig.address.toString(), + globalConfig.port); + } +}
\ No newline at end of file diff --git a/mod/src/main/java/moe/ymc/acron/config/ConfigReloadCmd.java b/mod/src/main/java/moe/ymc/acron/config/ConfigReloadCmd.java new file mode 100644 index 0000000..2774c4d --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/config/ConfigReloadCmd.java @@ -0,0 +1,41 @@ +package moe.ymc.acron.config; + +import com.mojang.brigadier.Command; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import moe.ymc.acron.config.json.ConfigDeserializationException; +import moe.ymc.acron.config.json.ConfigDeserializer; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.server.command.ServerCommandSource; +import net.minecraft.text.LiteralText; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.io.IOException; +import java.nio.file.Path; + +public class ConfigReloadCmd implements Command<ServerCommandSource> { + private static final Logger LOGGER = LogManager.getLogger(); + + @Override + public int run(CommandContext<ServerCommandSource> context) throws CommandSyntaxException { + LOGGER.info("Reloading rules."); + try { + final Path config = FabricLoader + .getInstance().getConfigDir() + .resolve("acron.json"); + if (!config.toFile().exists()) { + throw new IllegalStateException("Cannot find config/acron.json."); + } + final Config cfg = ConfigDeserializer.deserialize(config.toFile(), false); + Config.setGlobalConfig(cfg); + context.getSource().sendFeedback(new LiteralText("Rules reloaded."), true); + return 0; + } catch (IOException | ConfigDeserializationException e) { + LOGGER.error("Cannot reload config.", e); + context.getSource().sendError(new LiteralText("Cannot reload rules: " + + e.getMessage())); + return 1; + } + } +} diff --git a/mod/src/main/java/moe/ymc/acron/config/json/Client.java b/mod/src/main/java/moe/ymc/acron/config/json/Client.java new file mode 100644 index 0000000..4d31308 --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/config/json/Client.java @@ -0,0 +1,45 @@ +package moe.ymc.acron.config.json; + +import com.google.gson.annotations.SerializedName; +import moe.ymc.acron.auth.Action; +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +class Client implements ConfigJsonObject<moe.ymc.acron.auth.Client> { + @SerializedName("id") + private final String id; + + @SerializedName("token") + private final String token; + + @SerializedName("policy_mode") + private final Action policyMode; + + @SerializedName("rules") + private final List<Rule> rules; + + private Client(String id, + String token, + Action policyMode, + List<Rule> rules) { + this.id = id; + this.token = token; + this.policyMode = policyMode; + this.rules = rules; + } + + @Override + public @NotNull moe.ymc.acron.auth.Client create(boolean startup) throws ConfigDeserializationException { + if (id == null || id.trim().equals("") || + token == null || token.trim().equals("")) { + throw new ConfigDeserializationException(".clients[].id or .clients[].token is not supplied."); + } + return new moe.ymc.acron.auth.Client(id, + token, + policyMode == null ? Action.DENY : policyMode, + rules == null ? new moe.ymc.acron.auth.Rule[]{} : + rules.stream().map(rule -> rule.create(startup)).toList() + .toArray(new moe.ymc.acron.auth.Rule[rules.size()])); + } +} diff --git a/mod/src/main/java/moe/ymc/acron/config/json/Config.java b/mod/src/main/java/moe/ymc/acron/config/json/Config.java new file mode 100644 index 0000000..e8c5a83 --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/config/json/Config.java @@ -0,0 +1,103 @@ +package moe.ymc.acron.config.json; + +import com.google.gson.annotations.SerializedName; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.NotNull; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +class Config implements ConfigJsonObject<moe.ymc.acron.config.Config> { + private static final Logger LOGGER = LogManager.getLogger(); + + @SerializedName("listen") + private final String listen; + + @SerializedName("port") + private final Integer port; + + @SerializedName("native_transport") + private final boolean nativeTransport; + + @SerializedName("clients") + private final List<Client> clients; + + private Config(String listen, + Integer port, + boolean nativeTransport, + List<Client> clients) { + this.listen = listen; + this.port = port; + this.nativeTransport = nativeTransport; + this.clients = clients; + } + + @Override + public @NotNull moe.ymc.acron.config.Config create(boolean startup) throws ConfigDeserializationException { + final InetAddress address; + final int p; + final boolean nt; + if (!startup) { + address = moe.ymc.acron.config.Config.getGlobalConfig().address(); + p = moe.ymc.acron.config.Config.getGlobalConfig().port(); + nt = moe.ymc.acron.config.Config.getGlobalConfig().useNativeTransport(); + } else { + if (listen == null || listen.trim().equals("")) { + address = InetAddress.getLoopbackAddress(); + } else { + try { + address = InetAddress.getByName(listen); + } catch (UnknownHostException e) { + throw new ConfigDeserializationException("Cannot parse address: " + e.getMessage(), + true); + } + } + if (port == null) { + p = 25575; + } else { + if (port < 0 || port > 65535) { + throw new ConfigDeserializationException("The port is out of range.", true); + } + p = port; + } + nt = nativeTransport; + } + + + Map<String, moe.ymc.acron.auth.Client> map; + try { + if (clients != null) { + map = clients.stream() + .collect(Collectors.<Client, String, moe.ymc.acron.auth.Client> + toMap(client -> client.create(startup).id(), + client -> client.create(startup))); + } else { + map = new HashMap<>(0); + } + } catch (IllegalStateException e) { + // Collision. + LOGGER.error("Duplicate clients with the same ID in the Acron configuration. All clients are ignored. " + + "Fix the configuration and reload.", e); + if (!startup) { + throw new ConfigDeserializationException("Duplicate clients with the same ID: " + e.getMessage()); + } + map = new HashMap<>(0); + } catch (ConfigDeserializationException e) { + LOGGER.error("Cannot parse the Acron configuration. All clients are ignored. " + + "Fix the configuration and reload.", e); + if (!startup) { + throw e; + } + map = new HashMap<>(0); + } + return new moe.ymc.acron.config.Config(address, + p, + nt, + map); + } +} diff --git a/mod/src/main/java/moe/ymc/acron/config/json/ConfigDeserializationException.java b/mod/src/main/java/moe/ymc/acron/config/json/ConfigDeserializationException.java new file mode 100644 index 0000000..baf5b35 --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/config/json/ConfigDeserializationException.java @@ -0,0 +1,18 @@ +package moe.ymc.acron.config.json; + +public class ConfigDeserializationException extends RuntimeException { + private final boolean fetal; + + public ConfigDeserializationException(String message) { + this(message, false); + } + + public ConfigDeserializationException(String message, boolean fetal) { + super(message); + this.fetal = fetal; + } + + public boolean isFetal() { + return fetal; + } +} diff --git a/mod/src/main/java/moe/ymc/acron/config/json/ConfigDeserializer.java b/mod/src/main/java/moe/ymc/acron/config/json/ConfigDeserializer.java new file mode 100644 index 0000000..e91b355 --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/config/json/ConfigDeserializer.java @@ -0,0 +1,27 @@ +package moe.ymc.acron.config.json; + +import com.google.gson.Gson; +import moe.ymc.acron.config.Config; +import org.jetbrains.annotations.NotNull; + +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.io.Reader; + +public class ConfigDeserializer { + public static @NotNull Config deserialize(@NotNull File file, boolean startup) + throws ConfigDeserializationException, IOException { + final Reader reader = new FileReader(file); + final moe.ymc.acron.config.json.Config config; + try { + config = new Gson() + .fromJson(reader, moe.ymc.acron.config.json.Config.class); + } catch (Throwable e) { + throw new ConfigDeserializationException("Cannot parse JSON: " + e.getMessage(), + true); + } + reader.close(); + return config.create(startup); + } +} diff --git a/mod/src/main/java/moe/ymc/acron/config/json/ConfigJsonObject.java b/mod/src/main/java/moe/ymc/acron/config/json/ConfigJsonObject.java new file mode 100644 index 0000000..0efd9a9 --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/config/json/ConfigJsonObject.java @@ -0,0 +1,7 @@ +package moe.ymc.acron.config.json; + +import org.jetbrains.annotations.NotNull; + +public interface ConfigJsonObject<T> { + @NotNull T create(boolean startup) throws ConfigDeserializationException; +} diff --git a/mod/src/main/java/moe/ymc/acron/config/json/Rule.java b/mod/src/main/java/moe/ymc/acron/config/json/Rule.java new file mode 100644 index 0000000..114e17d --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/config/json/Rule.java @@ -0,0 +1,35 @@ +package moe.ymc.acron.config.json; + +import com.google.gson.annotations.SerializedName; +import moe.ymc.acron.auth.Action; +import org.jetbrains.annotations.NotNull; + +import java.util.regex.Pattern; + +class Rule implements ConfigJsonObject<moe.ymc.acron.auth.Rule> { + @SerializedName("regex") + private final String regex; + + @SerializedName("action") + private final Action action; + + @SerializedName("display") + private final boolean display; + + private Rule(String regex, + Action action, + boolean display) { + this.regex = regex; + this.action = action; + this.display = display; + } + + public @NotNull moe.ymc.acron.auth.Rule create(boolean startup) throws ConfigDeserializationException { + if (regex == null || regex.trim().equals("") || + action == null) throw new ConfigDeserializationException(".clients.[]rules.regex or .clients.[]rules.action is" + + "not specified."); + return new moe.ymc.acron.auth.Rule(Pattern.compile(regex), + action, + display); + } +} diff --git a/mod/src/main/java/moe/ymc/acron/jvav/Pair.java b/mod/src/main/java/moe/ymc/acron/jvav/Pair.java new file mode 100644 index 0000000..29b83dc --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/jvav/Pair.java @@ -0,0 +1,4 @@ +package moe.ymc.acron.jvav; + +public record Pair<L, R>(L l, R r) { +}
\ No newline at end of file diff --git a/mod/src/main/java/moe/ymc/acron/mixin/CommandManagerMixin.java b/mod/src/main/java/moe/ymc/acron/mixin/CommandManagerMixin.java new file mode 100644 index 0000000..9aaed2e --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/mixin/CommandManagerMixin.java @@ -0,0 +1,38 @@ +package moe.ymc.acron.mixin; + +import com.mojang.brigadier.CommandDispatcher; +import moe.ymc.acron.config.ConfigReloadCmd; +import net.minecraft.server.command.CommandManager; +import net.minecraft.server.command.ServerCommandSource; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import static net.minecraft.server.command.CommandManager.literal; + + +@Mixin(CommandManager.class) +public abstract class CommandManagerMixin { + private static final Logger AC_LOGGER = LogManager.getLogger(); + + @Shadow + @Final + private CommandDispatcher<ServerCommandSource> dispatcher; + + @Inject(method = "<init>", at = @At("RETURN")) + private void onRegister(CommandManager.RegistrationEnvironment arg, CallbackInfo ci) { + AC_LOGGER.debug("onRegister"); + dispatcher.register( + literal("acron").requires(player -> player.hasPermissionLevel(4)).then( + literal("rule").then( + literal("update") + .executes(new ConfigReloadCmd())) + ) + ); + } +} diff --git a/mod/src/main/java/moe/ymc/acron/mixin/LivingEntityMixin.java b/mod/src/main/java/moe/ymc/acron/mixin/LivingEntityMixin.java new file mode 100644 index 0000000..9e16569 --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/mixin/LivingEntityMixin.java @@ -0,0 +1,41 @@ +package moe.ymc.acron.mixin; + +import moe.ymc.acron.s2c.Entity; +import moe.ymc.acron.s2c.EventQueue; +import moe.ymc.acron.s2c.event.EventEntityDeath; +import net.minecraft.entity.EntityType; +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.damage.DamageSource; +import net.minecraft.entity.damage.DamageTracker; +import net.minecraft.world.World; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(LivingEntity.class) +public abstract class LivingEntityMixin extends net.minecraft.entity.Entity { + private static final Logger AC_LOGGER = LogManager.getLogger(); + + @Shadow public abstract DamageTracker getDamageTracker(); + + public LivingEntityMixin(EntityType<?> type, World world) { + super(type, world); + } + + // The original onDeath() will call getDamageTracker().update(), + // which clears all recent damages, making the getDeathMessage() + // output always generic. + // Thus, we need to use @At("HEAD") to get the injection called + // before it does anything else. + @Inject(at = @At("HEAD"), method = "onDeath") + public void onDeath(DamageSource source, CallbackInfo ci) { + AC_LOGGER.debug("onDeath[{}]", + getUuid()); + EventQueue.enqueue(new EventEntityDeath(new Entity(this), + getDamageTracker().getDeathMessage().getString())); + } +}
\ No newline at end of file diff --git a/mod/src/main/java/moe/ymc/acron/mixin/MinecraftDedicatedServerMixin.java b/mod/src/main/java/moe/ymc/acron/mixin/MinecraftDedicatedServerMixin.java new file mode 100644 index 0000000..32d2fbf --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/mixin/MinecraftDedicatedServerMixin.java @@ -0,0 +1,21 @@ +package moe.ymc.acron.mixin; + +import moe.ymc.acron.MinecraftServerHolder; +import net.minecraft.server.dedicated.MinecraftDedicatedServer; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(MinecraftDedicatedServer.class) +public class MinecraftDedicatedServerMixin { + private static final Logger AC_LOGGER = LogManager.getLogger(); + + @Inject(at = @At("RETURN"), method = "<init>") + private void init(CallbackInfo info) { + AC_LOGGER.debug("init"); + MinecraftServerHolder.setServer((MinecraftDedicatedServer) (Object) this); + } +} diff --git a/mod/src/main/java/moe/ymc/acron/mixin/MinecraftServerMixin.java b/mod/src/main/java/moe/ymc/acron/mixin/MinecraftServerMixin.java new file mode 100644 index 0000000..cb813b4 --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/mixin/MinecraftServerMixin.java @@ -0,0 +1,33 @@ +package moe.ymc.acron.mixin; + +import moe.ymc.acron.s2c.EventQueue; +import moe.ymc.acron.s2c.event.EventLagging; +import net.minecraft.server.MinecraftServer; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +@Mixin(MinecraftServer.class) +public class MinecraftServerMixin { + private static final Logger AC_LOGGER = LogManager.getLogger(); + + @Redirect(method = "runServer()V", + remap = false, + at = @At(value = "INVOKE", + target = "Lorg/apache/logging/log4j/Logger;warn(Ljava/lang/String;Ljava/lang/Object;Ljava/lang/Object;)V")) + private void startServer(Logger instance, String s, Object o1, Object o2) { + instance.warn(s, o1, o2); + if (s.equals("Can't keep up! " + + "Is the server overloaded? " + + "Running {}ms or {} ticks behind") && + o1 instanceof Long && + o2 instanceof Long) { + AC_LOGGER.debug("Lag: {}ms, {} ticks", + o1, + o2); + EventQueue.enqueue(new EventLagging((long) o1, (long) o2)); + } + } +} diff --git a/mod/src/main/java/moe/ymc/acron/mixin/ServerLoginNetworkHandlerMixin.java b/mod/src/main/java/moe/ymc/acron/mixin/ServerLoginNetworkHandlerMixin.java new file mode 100644 index 0000000..94a48b5 --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/mixin/ServerLoginNetworkHandlerMixin.java @@ -0,0 +1,47 @@ +package moe.ymc.acron.mixin; + +import com.mojang.authlib.GameProfile; +import moe.ymc.acron.s2c.Entity; +import moe.ymc.acron.s2c.EventQueue; +import moe.ymc.acron.s2c.event.EventDisconnected; +import moe.ymc.acron.s2c.event.EventPlayerJoined; +import net.minecraft.network.ClientConnection; +import net.minecraft.server.network.ServerLoginNetworkHandler; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.Text; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.Nullable; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(ServerLoginNetworkHandler.class) +public class ServerLoginNetworkHandlerMixin { + private static final Logger AC_LOGGER = LogManager.getLogger(); + + @Shadow + @Nullable + GameProfile profile; + + @Shadow + @Final + public ClientConnection connection; + + @Inject(at = @At("RETURN"), method = "onDisconnected") + private void onDisconnected(Text reason, CallbackInfo ci) { + EventQueue.enqueue(new EventDisconnected(profile == null ? null : + new Entity(profile), + reason.getString())); + } + + @Inject(at = @At("RETURN"), method = "addToServer") + private void addToServer(ServerPlayerEntity entity, CallbackInfo ci) { + AC_LOGGER.debug("addToServer: {}", + entity.getUuid()); + EventQueue.enqueue(new EventPlayerJoined(new Entity(entity))); + } +} diff --git a/mod/src/main/java/moe/ymc/acron/mixin/ServerNetworkIoMixin.java b/mod/src/main/java/moe/ymc/acron/mixin/ServerNetworkIoMixin.java new file mode 100644 index 0000000..f49914e --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/mixin/ServerNetworkIoMixin.java @@ -0,0 +1,65 @@ +package moe.ymc.acron.mixin; + +import io.netty.bootstrap.ServerBootstrap; +import io.netty.channel.ChannelFuture; +import io.netty.channel.MultithreadEventLoopGroup; +import io.netty.channel.ServerChannel; +import io.netty.channel.epoll.Epoll; +import io.netty.channel.epoll.EpollServerSocketChannel; +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.channel.socket.nio.NioServerSocketChannel; +import moe.ymc.acron.config.Config; +import moe.ymc.acron.net.AcronInitializer; +import net.minecraft.server.ServerNetworkIo; +import net.minecraft.util.Lazy; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import java.util.List; + +import static net.minecraft.server.ServerNetworkIo.EPOLL_CHANNEL; + +@Mixin(ServerNetworkIo.class) +public class ServerNetworkIoMixin { + private static final Logger AC_LOGGER = LogManager.getLogger(); + + @Shadow + @Final + private List<ChannelFuture> channels; + + @Shadow + @Final + public static Lazy<NioEventLoopGroup> DEFAULT_CHANNEL; + + @Inject(at = @At("RETURN"), method = "<init>") + private void init(CallbackInfo info) { + AC_LOGGER.debug("Adding Acron channel."); + Lazy<? extends MultithreadEventLoopGroup> group; + Class<? extends ServerChannel> channel; + if (Epoll.isAvailable() && Config.getGlobalConfig().useNativeTransport()) { + channel = EpollServerSocketChannel.class; + group = EPOLL_CHANNEL; + AC_LOGGER.info("Using native transport."); + } else { + channel = NioServerSocketChannel.class; + group = DEFAULT_CHANNEL; + AC_LOGGER.info("Not using native transport due to " + + "it is either disabled in acron.json or not available."); + } + channels.add(new ServerBootstrap() + .channel(channel) + .childHandler(new AcronInitializer()) + .group(group.get()) + .localAddress(Config.getGlobalConfig().address(), + Config.getGlobalConfig().port()) + .bind() + .syncUninterruptibly()); + + } +} diff --git a/mod/src/main/java/moe/ymc/acron/mixin/ServerPlayNetworkHandlerMixin.java b/mod/src/main/java/moe/ymc/acron/mixin/ServerPlayNetworkHandlerMixin.java new file mode 100644 index 0000000..58bef78 --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/mixin/ServerPlayNetworkHandlerMixin.java @@ -0,0 +1,43 @@ +package moe.ymc.acron.mixin; + +import moe.ymc.acron.s2c.Entity; +import moe.ymc.acron.s2c.EventQueue; +import moe.ymc.acron.s2c.event.EventDisconnected; +import moe.ymc.acron.s2c.event.EventPlayerMessage; +import net.minecraft.network.ClientConnection; +import net.minecraft.server.filter.TextStream; +import net.minecraft.server.network.ServerPlayNetworkHandler; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.Text; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(ServerPlayNetworkHandler.class) +public class ServerPlayNetworkHandlerMixin { + private static final Logger AC_LOGGER = LogManager.getLogger(); + + @Shadow + public ServerPlayerEntity player; + + @Shadow + @Final + public ClientConnection connection; + + @Inject(at = @At("RETURN"), method = "handleMessage") + private void handleMessage(TextStream.Message message, CallbackInfo ci) { + EventQueue.enqueue(new EventPlayerMessage(new Entity(player), + message.getRaw())); + } + + @Inject(at = @At("RETURN"), method = "onDisconnected") + private void onDisconnected(Text reason, CallbackInfo ci) { + EventQueue.enqueue(new EventDisconnected(new Entity(player), + reason.getString())); + } +} diff --git a/mod/src/main/java/moe/ymc/acron/mixin/ServerPlayerEntityMixin.java b/mod/src/main/java/moe/ymc/acron/mixin/ServerPlayerEntityMixin.java new file mode 100644 index 0000000..4c6758b --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/mixin/ServerPlayerEntityMixin.java @@ -0,0 +1,32 @@ +package moe.ymc.acron.mixin; + +import moe.ymc.acron.s2c.EventQueue; +import moe.ymc.acron.s2c.event.EventEntityDeath; +import net.minecraft.entity.EntityType; +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.damage.DamageSource; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.world.World; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(ServerPlayerEntity.class) +public abstract class ServerPlayerEntityMixin extends LivingEntity { + private static final Logger AC_LOGGER = LogManager.getLogger(); + + public ServerPlayerEntityMixin(EntityType<? extends LivingEntity> entityType, World world) { + super(entityType, world); + } + + @Inject(at = @At("HEAD"), method = "onDeath") + public void onDeath(DamageSource source, CallbackInfo ci) { + AC_LOGGER.debug("onDeath: {}", + getUuid()); + EventQueue.enqueue(new EventEntityDeath(new moe.ymc.acron.s2c.Entity(this), + getDamageTracker().getDeathMessage().getString())); + } +}
\ No newline at end of file diff --git a/mod/src/main/java/moe/ymc/acron/net/AcronInitializer.java b/mod/src/main/java/moe/ymc/acron/net/AcronInitializer.java new file mode 100644 index 0000000..c9953e3 --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/net/AcronInitializer.java @@ -0,0 +1,25 @@ +package moe.ymc.acron.net; + +import io.netty.channel.ChannelInitializer; +import io.netty.channel.socket.SocketChannel; +import io.netty.handler.codec.http.HttpObjectAggregator; +import io.netty.handler.codec.http.HttpServerCodec; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * A channel initializer for all Acron handlers. + */ +public class AcronInitializer extends ChannelInitializer<SocketChannel> { + private static final Logger LOGGER = LogManager.getLogger(); + + @Override + protected void initChannel(SocketChannel ch) throws Exception { + LOGGER.debug("initChannel"); + ch.pipeline() + .addLast(new HttpServerCodec()) + .addLast(new HttpObjectAggregator(65536)) + .addLast(new AuthHandler()) + ; + } +} diff --git a/mod/src/main/java/moe/ymc/acron/net/Attributes.java b/mod/src/main/java/moe/ymc/acron/net/Attributes.java new file mode 100644 index 0000000..ddb0f5c --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/net/Attributes.java @@ -0,0 +1,13 @@ +package moe.ymc.acron.net; + +import io.netty.handler.codec.http.websocketx.WebSocketServerHandshaker; +import io.netty.util.AttributeKey; + +final class Attributes { + public static final AttributeKey<ClientIdentification> ID = + AttributeKey.newInstance("CLENT_ID"); + public static final AttributeKey<ClientConfiguration> CONFIGURATION = + AttributeKey.newInstance("CLIENT_CONFIG"); + public static final AttributeKey<WebSocketServerHandshaker> HANDSHAKER = + AttributeKey.newInstance("HANDSHAKER"); +} diff --git a/mod/src/main/java/moe/ymc/acron/net/AuthHandler.java b/mod/src/main/java/moe/ymc/acron/net/AuthHandler.java new file mode 100644 index 0000000..3e42e14 --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/net/AuthHandler.java @@ -0,0 +1,98 @@ +package moe.ymc.acron.net; + +import io.netty.channel.ChannelFutureListener; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.handler.codec.http.*; +import io.netty.handler.codec.http.websocketx.WebSocketServerHandshaker; +import io.netty.handler.codec.http.websocketx.WebSocketServerHandshakerFactory; +import moe.ymc.acron.auth.Client; +import moe.ymc.acron.config.Config; +import org.apache.commons.codec.digest.DigestUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Handle handshake request and authentication. + * We cannot use WebSocketServerProtocolHandler because it does not allow + * us doing anything before handshaking. + */ +public class AuthHandler extends SimpleChannelInboundHandler<HttpRequest> { + private static final Logger LOGGER = LogManager.getLogger(); + + @Override + protected void channelRead0(ChannelHandlerContext ctx, HttpRequest msg) throws Exception { + LOGGER.debug("channelRead0: {}", msg.uri()); + if (msg.method() != HttpMethod.GET) { + ctx.channel().writeAndFlush(new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, + HttpResponseStatus.BAD_REQUEST)) + .addListener(ChannelFutureListener.CLOSE); + return; + } + HttpHeaders headers = msg.headers(); + + if (!"Upgrade".equalsIgnoreCase(headers.get(HttpHeaderNames.CONNECTION)) || + !"WebSocket".equalsIgnoreCase(headers.get(HttpHeaderNames.UPGRADE))) { + ctx.channel().writeAndFlush(new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, + HttpResponseStatus.BAD_REQUEST)) + .addListener(ChannelFutureListener.CLOSE); + return; + } + + final QueryStringDecoder decoder = new QueryStringDecoder(msg.uri()); + if (!decoder.path().equals("/ws")) { + ctx.fireChannelRead(msg); + return; + } + if (decoder.parameters().isEmpty() || + decoder.parameters().get("id") == null || + decoder.parameters().get("id").size() != 1 || + decoder.parameters().get("token") == null || + decoder.parameters().get("token").size() != 1) { + ctx.writeAndFlush(new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, + HttpResponseStatus.BAD_REQUEST)) + .addListener(ChannelFutureListener.CLOSE); + return; + } + + final String id = decoder.parameters().get("id").get(0); + final String token = decoder.parameters().get("token").get(0); + final String versionRaw = (decoder.parameters().get("version") == null || + decoder.parameters().get("version").isEmpty()) ? "0" : + decoder.parameters().get("version").get(0); + try { + if (Integer.parseInt(versionRaw) != 0) { + ctx.writeAndFlush(new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, + HttpResponseStatus.BAD_REQUEST)) + .addListener(ChannelFutureListener.CLOSE); + return; + } + } catch (NumberFormatException ignored) { + ctx.writeAndFlush(new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, + HttpResponseStatus.BAD_REQUEST)) + .addListener(ChannelFutureListener.CLOSE); + return; + } + + final Client client = Config.getGlobalConfig().clients().get(id); + if (client == null || + !client.token().equals(DigestUtils.sha256Hex(token))) { + ctx.writeAndFlush(new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, + HttpResponseStatus.UNAUTHORIZED)) + .addListener(ChannelFutureListener.CLOSE); + return; + } + ctx.channel().attr(Attributes.ID).set(new ClientIdentification(0, client)); + WebSocketServerHandshakerFactory wsFactory = + new WebSocketServerHandshakerFactory("/ws", null, true); + final WebSocketServerHandshaker handshaker = wsFactory.newHandshaker(msg); + if (handshaker == null) { + WebSocketServerHandshakerFactory.sendUnsupportedVersionResponse(ctx.channel()); + return; + } + ctx.channel().attr(Attributes.HANDSHAKER).set(handshaker); + handshaker.handshake(ctx.channel(), msg); + ctx.pipeline().replace(this, "websocketHandler", new WSFrameHandler()); + ctx.fireUserEventTriggered(new HandshakeComplete()); + } +} diff --git a/mod/src/main/java/moe/ymc/acron/net/ClientConfiguration.java b/mod/src/main/java/moe/ymc/acron/net/ClientConfiguration.java new file mode 100644 index 0000000..450ccd4 --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/net/ClientConfiguration.java @@ -0,0 +1,20 @@ +package moe.ymc.acron.net; + +import net.minecraft.server.world.ServerWorld; +import net.minecraft.util.math.Vec2f; +import net.minecraft.util.math.Vec3d; +import org.jetbrains.annotations.NotNull; + +public record ClientConfiguration(@NotNull Vec3d pos, + @NotNull Vec2f rot, + @NotNull ServerWorld world, + @NotNull String name) { + public ClientConfiguration(@NotNull ServerWorld world, + @NotNull String name) { + // Rcon defaults. @see RconCommandOutput + this(Vec3d.of(world.getSpawnPos()), + Vec2f.ZERO, + world, + name); + } +} diff --git a/mod/src/main/java/moe/ymc/acron/net/ClientIdentification.java b/mod/src/main/java/moe/ymc/acron/net/ClientIdentification.java new file mode 100644 index 0000000..1cb4375 --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/net/ClientIdentification.java @@ -0,0 +1,11 @@ +package moe.ymc.acron.net; + +import moe.ymc.acron.auth.Client; +import org.jetbrains.annotations.NotNull; + +/** + * Read only client configurations. + */ +public record ClientIdentification(int version, + @NotNull Client client) { +} diff --git a/mod/src/main/java/moe/ymc/acron/net/HandshakeComplete.java b/mod/src/main/java/moe/ymc/acron/net/HandshakeComplete.java new file mode 100644 index 0000000..348b5e2 --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/net/HandshakeComplete.java @@ -0,0 +1,7 @@ +package moe.ymc.acron.net; + +/** + * User event used to tell WSFrameHandler that the handshake is complete. + */ +public class HandshakeComplete { +} diff --git a/mod/src/main/java/moe/ymc/acron/net/WSFrameHandler.java b/mod/src/main/java/moe/ymc/acron/net/WSFrameHandler.java new file mode 100644 index 0000000..912e73a --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/net/WSFrameHandler.java @@ -0,0 +1,174 @@ +package moe.ymc.acron.net; + +import com.google.gson.JsonParseException; +import io.netty.channel.Channel; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.handler.codec.http.websocketx.*; +import moe.ymc.acron.MinecraftServerHolder; +import moe.ymc.acron.auth.Action; +import moe.ymc.acron.auth.PolicyChecker; +import moe.ymc.acron.c2s.ReqCmd; +import moe.ymc.acron.c2s.ReqSetConfig; +import moe.ymc.acron.c2s.Request; +import moe.ymc.acron.cmd.CmdQueue; +import moe.ymc.acron.jvav.Pair; +import moe.ymc.acron.s2c.Event; +import moe.ymc.acron.s2c.EventQueue; +import moe.ymc.acron.s2c.response.EventError; +import moe.ymc.acron.s2c.response.EventOk; +import moe.ymc.acron.serialization.Serializer; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.util.math.Vec2f; +import net.minecraft.util.math.Vec3d; +import net.minecraft.world.World; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.NotNull; + +/** + * The handler for WebSocket requests. + */ +public class WSFrameHandler extends SimpleChannelInboundHandler<WebSocketFrame> { + private static final Logger LOGGER = LogManager.getLogger(); + + @Override + public void handlerAdded(ChannelHandlerContext ctx) throws Exception { + super.handlerAdded(ctx); + } + + @Override + protected void channelRead0(ChannelHandlerContext ctx, WebSocketFrame msg) throws Exception { + LOGGER.debug("channelRead0: {} {}", + this, + ctx.channel()); + final WebSocketServerHandshaker handshaker = + ctx.channel().attr(Attributes.HANDSHAKER).get(); + if (msg instanceof CloseWebSocketFrame) { + handshaker.close(ctx.channel(), (CloseWebSocketFrame) msg.retain()); + return; + } + if (msg instanceof PingWebSocketFrame) { + ctx.write(new PongWebSocketFrame(msg.content().retain())); + return; + } + if (msg instanceof BinaryWebSocketFrame) { + throw new UnsupportedOperationException("Only text frames are accepted."); + } + final TextWebSocketFrame frame = (TextWebSocketFrame) msg; + + final ClientIdentification identification = ctx.channel().attr(Attributes.ID).get(); + final ClientConfiguration configuration = ctx.channel().attr(Attributes.CONFIGURATION).get(); + int id; + final Request request; + try { + request = Serializer.deserialize(frame); + id = request.getId(); + } catch (JsonParseException | IllegalArgumentException | IllegalStateException e) { + ctx.channel().writeAndFlush( + Serializer.serialize(new EventError(-2, EventError.Code.BAD_REQUEST.value, e.getMessage())) + ); + return; + } + try { + ctx.channel().writeAndFlush(Serializer.serialize(handle(request, + identification, + configuration, + ctx.channel()))); + } catch (Throwable e) { + LOGGER.info("An error occurred while processing the request. " + + "This may just be a malformed request. " + + "It is reported to the client.", + e); + ctx.channel().writeAndFlush( + Serializer.serialize(new EventError(id, EventError.Code.SERVER_ERROR.value, e.getMessage())) + ); + } + } + + @NotNull + private Event handle(@NotNull Request request, + @NotNull ClientIdentification identification, + @NotNull ClientConfiguration configuration, + @NotNull Channel channel) throws Throwable { + if (request instanceof final ReqCmd reqCmd) { + LOGGER.info("Client {} executed a command: `{}`.", + identification.client().id(), + reqCmd.cmd()); + final Pair<Action, Boolean> res = PolicyChecker.check(identification.client(), + reqCmd.cmd()); + if (res.l() == Action.DENY) { + return new EventError(reqCmd.id(), + EventError.Code.FORBIDDEN.value, "This client is not allowed to " + + "execute this command."); + } + // TODO: Ok event may be sent after executing the command. + CmdQueue.enqueue(reqCmd.id(), + res.r(), + channel, + reqCmd.config() == null ? + configuration : + convertConfiguration(reqCmd.config()), + reqCmd.cmd()); + return new EventOk(request.getId()); + } else if (request instanceof final ReqSetConfig reqSetConfig) { + channel.attr(Attributes.CONFIGURATION).set(convertConfiguration(reqSetConfig)); + return new EventOk(request.getId()); + } + // This should not occur. + throw new IllegalStateException("This should not occur."); + } + + private ClientConfiguration convertConfiguration(@NotNull ReqSetConfig request) { + final ServerWorld world; + if (request.world() != null) { + switch (request.world()) { + case OVERWORLD -> world = MinecraftServerHolder.getServer().getWorld(World.OVERWORLD); + case NETHER -> world = MinecraftServerHolder.getServer().getWorld(World.NETHER); + case END -> world = MinecraftServerHolder.getServer().getWorld(World.END); + default -> throw new IllegalArgumentException(); + } + } else { + world = MinecraftServerHolder.getServer().getOverworld(); + } + if (world == null) { + throw new IllegalStateException(String.format("The requested world %s is not available at this time.", + request.world())); + } + return new ClientConfiguration( + request.pos() == null ? + Vec3d.of(world.getSpawnPos()) : + new Vec3d(request.pos().x(), request.pos().y(), request.pos().z()), + request.rot() == null ? + Vec2f.ZERO : + new Vec2f(request.rot().x(), request.rot().y()), + world, + request.name() == null ? this.toString() : request.name() + ); + } + + @Override + public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { + LOGGER.debug("handshakeComplete: {} {}", + this, + ctx.channel()); + if (evt instanceof HandshakeComplete) { + final ClientIdentification identification = ctx.channel().attr(Attributes.ID).get(); + LOGGER.info("Client {} connected. It has {} rules with {} policy mode.", + identification.client().id(), + identification.client().rules().length, + identification.client().policyMode()); + final ServerWorld defaultWorld = MinecraftServerHolder.getServer().getOverworld(); + if (defaultWorld == null) { + throw new IllegalStateException("The default world is not available at this time."); + } + final ClientConfiguration configuration = + new ClientConfiguration(defaultWorld, + identification.client().id()); + ctx.channel().attr(Attributes.CONFIGURATION).set(configuration); + EventQueue.registerMessageRecipient(ctx.channel()); + } else { + ctx.fireUserEventTriggered(evt); + } + } +} diff --git a/mod/src/main/java/moe/ymc/acron/s2c/Entity.java b/mod/src/main/java/moe/ymc/acron/s2c/Entity.java new file mode 100644 index 0000000..3e0add1 --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/s2c/Entity.java @@ -0,0 +1,26 @@ +package moe.ymc.acron.s2c; + +import com.google.gson.annotations.SerializedName; +import com.mojang.authlib.GameProfile; +import moe.ymc.acron.common.Vec3d; +import moe.ymc.acron.common.WorldKey; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.UUID; + +public record Entity(@SerializedName("name") @NotNull String name, + @SerializedName("uuid") @NotNull UUID uuid, + @SerializedName("pos") @Nullable Vec3d pos, + @SerializedName("world") @Nullable WorldKey world) { + public Entity(@NotNull net.minecraft.entity.Entity entity) { + this(entity.getName().getString(), + entity.getUuid(), + new Vec3d(entity.getPos()), + WorldKey.create(entity.world.getRegistryKey().getValue())); + } + + public Entity(@NotNull GameProfile profile) { + this(profile.getName(), profile.getId(), null, null); + } +} diff --git a/mod/src/main/java/moe/ymc/acron/s2c/Event.java b/mod/src/main/java/moe/ymc/acron/s2c/Event.java new file mode 100644 index 0000000..1abc35c --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/s2c/Event.java @@ -0,0 +1,4 @@ +package moe.ymc.acron.s2c; + +public interface Event { +} diff --git a/mod/src/main/java/moe/ymc/acron/s2c/EventQueue.java b/mod/src/main/java/moe/ymc/acron/s2c/EventQueue.java new file mode 100644 index 0000000..8c470a1 --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/s2c/EventQueue.java @@ -0,0 +1,28 @@ +package moe.ymc.acron.s2c; + +import io.netty.channel.Channel; +import io.netty.channel.group.ChannelGroup; +import io.netty.channel.group.DefaultChannelGroup; +import io.netty.util.concurrent.GlobalEventExecutor; +import moe.ymc.acron.serialization.Serializer; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.NotNull; + +public class EventQueue { + private static final Logger LOGGER = LogManager.getLogger(); + + private static final ChannelGroup sMessageRecipients = + new DefaultChannelGroup(GlobalEventExecutor.INSTANCE); + + public static void registerMessageRecipient(@NotNull Channel channel) { + sMessageRecipients.add(channel); + } + + public static void enqueue(@NotNull Event message) { + LOGGER.debug("Enqueue: {} ({} channels)", + message, + sMessageRecipients.size()); + sMessageRecipients.writeAndFlush(Serializer.serialize(message)); + } +} diff --git a/mod/src/main/java/moe/ymc/acron/s2c/event/EventDisconnected.java b/mod/src/main/java/moe/ymc/acron/s2c/event/EventDisconnected.java new file mode 100644 index 0000000..610fc58 --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/s2c/event/EventDisconnected.java @@ -0,0 +1,12 @@ +package moe.ymc.acron.s2c.event; + +import com.google.gson.annotations.SerializedName; +import moe.ymc.acron.s2c.Entity; +import moe.ymc.acron.s2c.Event; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public record EventDisconnected(@SerializedName("player") @Nullable Entity player, + @SerializedName("reason") @NotNull String reason) + implements Event { +} diff --git a/mod/src/main/java/moe/ymc/acron/s2c/event/EventEntityDeath.java b/mod/src/main/java/moe/ymc/acron/s2c/event/EventEntityDeath.java new file mode 100644 index 0000000..4735241 --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/s2c/event/EventEntityDeath.java @@ -0,0 +1,12 @@ +package moe.ymc.acron.s2c.event; + +import com.google.gson.annotations.SerializedName; +import moe.ymc.acron.s2c.Entity; +import moe.ymc.acron.s2c.Event; +import org.jetbrains.annotations.NotNull; + +// TODO: More detailed death report. +public record EventEntityDeath(@SerializedName("entity") @NotNull Entity entity, + @SerializedName("message") @NotNull String message) + implements Event { +} diff --git a/mod/src/main/java/moe/ymc/acron/s2c/event/EventLagging.java b/mod/src/main/java/moe/ymc/acron/s2c/event/EventLagging.java new file mode 100644 index 0000000..30974df --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/s2c/event/EventLagging.java @@ -0,0 +1,9 @@ +package moe.ymc.acron.s2c.event; + +import com.google.gson.annotations.SerializedName; +import moe.ymc.acron.s2c.Event; + +public record EventLagging(@SerializedName("ms") long ms, + @SerializedName("ticks") long ticks) + implements Event { +} diff --git a/mod/src/main/java/moe/ymc/acron/s2c/event/EventPlayerJoined.java b/mod/src/main/java/moe/ymc/acron/s2c/event/EventPlayerJoined.java new file mode 100644 index 0000000..408680b --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/s2c/event/EventPlayerJoined.java @@ -0,0 +1,10 @@ +package moe.ymc.acron.s2c.event; + +import com.google.gson.annotations.SerializedName; +import moe.ymc.acron.s2c.Entity; +import moe.ymc.acron.s2c.Event; +import org.jetbrains.annotations.NotNull; + +public record EventPlayerJoined(@SerializedName("player") @NotNull Entity player) + implements Event { +} diff --git a/mod/src/main/java/moe/ymc/acron/s2c/event/EventPlayerMessage.java b/mod/src/main/java/moe/ymc/acron/s2c/event/EventPlayerMessage.java new file mode 100644 index 0000000..2769493 --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/s2c/event/EventPlayerMessage.java @@ -0,0 +1,12 @@ +package moe.ymc.acron.s2c.event; + +import com.google.gson.annotations.SerializedName; +import moe.ymc.acron.s2c.Entity; +import moe.ymc.acron.s2c.Event; +import org.jetbrains.annotations.NotNull; + +public record EventPlayerMessage(@SerializedName("player") @NotNull Entity player, + @SerializedName("text") @NotNull String text) + implements Event { + +} diff --git a/mod/src/main/java/moe/ymc/acron/s2c/response/EventCmdOut.java b/mod/src/main/java/moe/ymc/acron/s2c/response/EventCmdOut.java new file mode 100644 index 0000000..a4cb798 --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/s2c/response/EventCmdOut.java @@ -0,0 +1,13 @@ +package moe.ymc.acron.s2c.response; + +import com.google.gson.annotations.SerializedName; +import moe.ymc.acron.s2c.Event; +import org.jetbrains.annotations.NotNull; + +import java.util.UUID; + +public record EventCmdOut(@SerializedName("id") int id, + @SerializedName("sender") @NotNull UUID sender, + @SerializedName("out") @NotNull String out) + implements Event { +} diff --git a/mod/src/main/java/moe/ymc/acron/s2c/response/EventCmdRes.java b/mod/src/main/java/moe/ymc/acron/s2c/response/EventCmdRes.java new file mode 100644 index 0000000..8c1b6a9 --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/s2c/response/EventCmdRes.java @@ -0,0 +1,10 @@ +package moe.ymc.acron.s2c.response; + +import com.google.gson.annotations.SerializedName; +import moe.ymc.acron.s2c.Event; + +public record EventCmdRes(@SerializedName("id") int id, + @SerializedName("success") boolean success, + @SerializedName("result") int result) + implements Event { +} diff --git a/mod/src/main/java/moe/ymc/acron/s2c/response/EventError.java b/mod/src/main/java/moe/ymc/acron/s2c/response/EventError.java new file mode 100644 index 0000000..370e8f3 --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/s2c/response/EventError.java @@ -0,0 +1,22 @@ +package moe.ymc.acron.s2c.response; + +import com.google.gson.annotations.SerializedName; +import moe.ymc.acron.s2c.Event; +import org.jetbrains.annotations.Nullable; + +public record EventError(@SerializedName("id") int id, + @SerializedName("code") int code, + @SerializedName("message") @Nullable String message) + implements Event { + public enum Code { + SERVER_ERROR(500), + BAD_REQUEST(400), + FORBIDDEN(403) + ; + + public final int value; + Code(int value) { + this.value = value; + } + } +} diff --git a/mod/src/main/java/moe/ymc/acron/s2c/response/EventOk.java b/mod/src/main/java/moe/ymc/acron/s2c/response/EventOk.java new file mode 100644 index 0000000..eb8c82d --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/s2c/response/EventOk.java @@ -0,0 +1,8 @@ +package moe.ymc.acron.s2c.response; + +import com.google.gson.annotations.SerializedName; +import moe.ymc.acron.s2c.Event; + +public record EventOk(@SerializedName("id") int id) + implements Event { +} diff --git a/mod/src/main/java/moe/ymc/acron/serialization/Serializer.java b/mod/src/main/java/moe/ymc/acron/serialization/Serializer.java new file mode 100644 index 0000000..8091c25 --- /dev/null +++ b/mod/src/main/java/moe/ymc/acron/serialization/Serializer.java @@ -0,0 +1,60 @@ +package moe.ymc.acron.serialization; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonParseException; +import com.google.gson.typeadapters.RuntimeTypeAdapterFactory; +import io.netty.handler.codec.http.websocketx.TextWebSocketFrame; +import moe.ymc.acron.c2s.ReqCmd; +import moe.ymc.acron.c2s.ReqSetConfig; +import moe.ymc.acron.c2s.Request; +import moe.ymc.acron.common.Vec2f; +import moe.ymc.acron.common.Vec3d; +import moe.ymc.acron.s2c.Event; +import moe.ymc.acron.s2c.event.*; +import moe.ymc.acron.s2c.response.EventCmdOut; +import moe.ymc.acron.s2c.response.EventCmdRes; +import moe.ymc.acron.s2c.response.EventError; +import moe.ymc.acron.s2c.response.EventOk; +import org.jetbrains.annotations.NotNull; + +public class Serializer { + @NotNull + public static Request deserialize(@NotNull TextWebSocketFrame frame) + throws JsonParseException, IllegalArgumentException, IllegalStateException { + final String text = frame.text(); + final RuntimeTypeAdapterFactory<Request> adapter = + RuntimeTypeAdapterFactory.of(Request.class, "type") + .registerSubtype(ReqSetConfig.class, "set_config") + .registerSubtype(ReqCmd.class, "cmd"); + final Gson gson = new GsonBuilder() + .registerTypeAdapter(ReqSetConfig.class, new ReqSetConfig.ReqSetConfigDeserializer()) + .registerTypeAdapter(Vec3d.class, new Vec3d.Vec3dDeserializer()) + .registerTypeAdapter(Vec2f.class, new Vec2f.Vec2fDeserializer()) + .registerTypeAdapter(ReqCmd.class, new ReqCmd.ReqCmdDeserializer()) + .registerTypeAdapterFactory(adapter) + .create(); + final Request request = gson.fromJson(text, Request.class); + request.validate(); + return request; + } + + @NotNull + public static TextWebSocketFrame serialize(@NotNull Event message) { + final RuntimeTypeAdapterFactory<Event> adapter = + RuntimeTypeAdapterFactory.of(Event.class, "type") + .registerSubtype(EventDisconnected.class, "disconnect") + .registerSubtype(EventPlayerMessage.class, "message") + .registerSubtype(EventPlayerJoined.class, "join") + .registerSubtype(EventEntityDeath.class, "death") + .registerSubtype(EventCmdOut.class, "cmd_out") + .registerSubtype(EventCmdRes.class, "cmd_result") + .registerSubtype(EventLagging.class, "lagging") + .registerSubtype(EventError.class, "error") + .registerSubtype(EventOk.class, "ok"); + final Gson gson = new GsonBuilder() + .registerTypeAdapterFactory(adapter) + .create(); + return new TextWebSocketFrame(gson.toJson(message, message.getClass())); + } +} diff --git a/mod/src/main/resources/acron.mixins.json b/mod/src/main/resources/acron.mixins.json new file mode 100644 index 0000000..5d0911f --- /dev/null +++ b/mod/src/main/resources/acron.mixins.json @@ -0,0 +1,21 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "moe.ymc.acron.mixin", + "compatibilityLevel": "JAVA_8", + "mixins": [ + "CommandManagerMixin", + "LivingEntityMixin", + "MinecraftDedicatedServerMixin", + "MinecraftServerMixin", + "ServerLoginNetworkHandlerMixin", + "ServerNetworkIoMixin", + "ServerPlayerEntityMixin", + "ServerPlayNetworkHandlerMixin" + ], + "client": [ + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/mod/src/main/resources/fabric.mod.json b/mod/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..999aeeb --- /dev/null +++ b/mod/src/main/resources/fabric.mod.json @@ -0,0 +1,24 @@ +{ + "schemaVersion": 1, + "id": "acron", + "version": "${version}", + "name": "Acron", + "description": "WebSocket based remote server management", + "authors": ["YuutaW"], + "contact": {}, + "license": "GPL-2.0", + "icon": "assets/acron/icon.png", + "environment": "server", + "entrypoints": { + "main": [ + "moe.ymc.acron.Acron" + ] + }, + "mixins": [ + "acron.mixins.json" + ], + "depends": { + "fabricloader": ">=0.14.4", + "minecraft": "1.17.1" + } +} |