aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorYuuta Liang <yuuta@yuuta.moe>2022-07-13 11:16:27 -0700
committerTrumeet <yuuta@yuuta.moe>2022-07-13 11:16:27 -0700
commit85045e1e4a15e0a5657d189e83dd202a2c37f2b0 (patch)
tree944bc9ee7a86bd413dfc940e210f21d2434ec7d3
downloadacron-85045e1e4a15e0a5657d189e83dd202a2c37f2b0.tar
acron-85045e1e4a15e0a5657d189e83dd202a2c37f2b0.tar.gz
acron-85045e1e4a15e0a5657d189e83dd202a2c37f2b0.tar.bz2
acron-85045e1e4a15e0a5657d189e83dd202a2c37f2b0.zip
First Commit
Signed-off-by: Trumeet <yuuta@yuuta.moe>
-rw-r--r--.gitignore121
-rw-r--r--LICENSE339
-rw-r--r--README.md509
-rw-r--r--acron.json19
-rw-r--r--build.gradle78
-rw-r--r--gradle.properties11
-rw-r--r--settings.gradle9
-rw-r--r--src/main/java/com/google/gson/typeadapters/RuntimeTypeAdapterFactory.java272
-rw-r--r--src/main/java/moe/ymc/acron/Acron.java32
-rw-r--r--src/main/java/moe/ymc/acron/MinecraftServerHolder.java28
-rw-r--r--src/main/java/moe/ymc/acron/auth/Action.java10
-rw-r--r--src/main/java/moe/ymc/acron/auth/Client.java9
-rw-r--r--src/main/java/moe/ymc/acron/auth/PolicyChecker.java39
-rw-r--r--src/main/java/moe/ymc/acron/auth/Rule.java10
-rw-r--r--src/main/java/moe/ymc/acron/c2s/ReqCmd.java51
-rw-r--r--src/main/java/moe/ymc/acron/c2s/ReqSetConfig.java100
-rw-r--r--src/main/java/moe/ymc/acron/c2s/Request.java6
-rw-r--r--src/main/java/moe/ymc/acron/cmd/CmdOut.java53
-rw-r--r--src/main/java/moe/ymc/acron/cmd/CmdQueue.java17
-rw-r--r--src/main/java/moe/ymc/acron/cmd/CmdResConsumer.java33
-rw-r--r--src/main/java/moe/ymc/acron/cmd/CmdSrc.java34
-rw-r--r--src/main/java/moe/ymc/acron/config/Config.java30
-rw-r--r--src/main/java/moe/ymc/acron/config/ConfigReloadCmd.java41
-rw-r--r--src/main/java/moe/ymc/acron/config/json/Client.java45
-rw-r--r--src/main/java/moe/ymc/acron/config/json/Config.java103
-rw-r--r--src/main/java/moe/ymc/acron/config/json/ConfigDeserializationException.java18
-rw-r--r--src/main/java/moe/ymc/acron/config/json/ConfigDeserializer.java27
-rw-r--r--src/main/java/moe/ymc/acron/config/json/ConfigJsonObject.java7
-rw-r--r--src/main/java/moe/ymc/acron/config/json/Rule.java35
-rw-r--r--src/main/java/moe/ymc/acron/jvav/Pair.java4
-rw-r--r--src/main/java/moe/ymc/acron/mixin/CommandManagerMixin.java38
-rw-r--r--src/main/java/moe/ymc/acron/mixin/LivingEntityMixin.java41
-rw-r--r--src/main/java/moe/ymc/acron/mixin/MinecraftDedicatedServerMixin.java21
-rw-r--r--src/main/java/moe/ymc/acron/mixin/MinecraftServerMixin.java32
-rw-r--r--src/main/java/moe/ymc/acron/mixin/ServerLoginNetworkHandlerMixin.java47
-rw-r--r--src/main/java/moe/ymc/acron/mixin/ServerNetworkIoMixin.java65
-rw-r--r--src/main/java/moe/ymc/acron/mixin/ServerPlayNetworkHandlerMixin.java43
-rw-r--r--src/main/java/moe/ymc/acron/mixin/ServerPlayerEntityMixin.java32
-rw-r--r--src/main/java/moe/ymc/acron/net/AcronInitializer.java25
-rw-r--r--src/main/java/moe/ymc/acron/net/Attributes.java13
-rw-r--r--src/main/java/moe/ymc/acron/net/AuthHandler.java91
-rw-r--r--src/main/java/moe/ymc/acron/net/ClientConfiguration.java20
-rw-r--r--src/main/java/moe/ymc/acron/net/ClientIdentification.java11
-rw-r--r--src/main/java/moe/ymc/acron/net/HandshakeComplete.java7
-rw-r--r--src/main/java/moe/ymc/acron/net/WSFrameHandler.java149
-rw-r--r--src/main/java/moe/ymc/acron/s2c/Entity.java19
-rw-r--r--src/main/java/moe/ymc/acron/s2c/Event.java4
-rw-r--r--src/main/java/moe/ymc/acron/s2c/EventCmdDenied.java7
-rw-r--r--src/main/java/moe/ymc/acron/s2c/EventCmdOut.java12
-rw-r--r--src/main/java/moe/ymc/acron/s2c/EventCmdRes.java9
-rw-r--r--src/main/java/moe/ymc/acron/s2c/EventDisconnected.java10
-rw-r--r--src/main/java/moe/ymc/acron/s2c/EventEntityDeath.java10
-rw-r--r--src/main/java/moe/ymc/acron/s2c/EventError.java9
-rw-r--r--src/main/java/moe/ymc/acron/s2c/EventLagging.java8
-rw-r--r--src/main/java/moe/ymc/acron/s2c/EventOk.java7
-rw-r--r--src/main/java/moe/ymc/acron/s2c/EventPlayerJoined.java8
-rw-r--r--src/main/java/moe/ymc/acron/s2c/EventPlayerMessage.java10
-rw-r--r--src/main/java/moe/ymc/acron/s2c/EventQueue.java28
-rw-r--r--src/main/java/moe/ymc/acron/serialization/Serializer.java54
-rw-r--r--src/main/resources/acron.mixins.json21
-rw-r--r--src/main/resources/fabric.mod.json24
61 files changed, 2965 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0779279
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,121 @@
+# Gradle Wrapper
+gradle/
+
+# User-specific stuff
+.idea/
+
+*.iml
+*.ipr
+*.iws
+
+# IntelliJ
+out/
+# mpeltonen/sbt-idea plugin
+.idea_modules/
+
+# JIRA plugin
+atlassian-ide-plugin.xml
+
+# Compiled class file
+*.class
+
+# Log file
+*.log
+
+# BlueJ files
+*.ctxt
+
+# Package Files #
+*.jar
+*.war
+*.nar
+*.ear
+*.zip
+*.tar.gz
+*.rar
+
+# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
+hs_err_pid*
+
+*~
+
+# temporary files which can be created if a process still has a handle open of a deleted file
+.fuse_hidden*
+
+# KDE directory preferences
+.directory
+
+# Linux trash folder which might appear on any partition or disk
+.Trash-*
+
+# .nfs files are created when an open file is removed but is still being accessed
+.nfs*
+
+# General
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# Icon must end with two \r
+Icon
+
+# Thumbnails
+._*
+
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
+# Windows thumbnail cache files
+Thumbs.db
+Thumbs.db:encryptable
+ehthumbs.db
+ehthumbs_vista.db
+
+# Dump file
+*.stackdump
+
+# Folder config file
+[Dd]esktop.ini
+
+# Recycle Bin used on file shares
+$RECYCLE.BIN/
+
+# Windows Installer files
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# Windows shortcuts
+*.lnk
+
+.gradle
+build/
+
+# Ignore Gradle GUI config
+gradle-app.setting
+
+# Cache of project
+.gradletasknamecache
+
+**/build/
+
+# Common working directory
+run/
+
+# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
+!gradle-wrapper.jar
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..d159169
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,339 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 2, June 1991
+
+ Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The licenses for most software are designed to take away your
+freedom to share and change it. By contrast, the GNU General Public
+License is intended to guarantee your freedom to share and change free
+software--to make sure the software is free for all its users. This
+General Public License applies to most of the Free Software
+Foundation's software and to any other program whose authors commit to
+using it. (Some other Free Software Foundation software is covered by
+the GNU Lesser General Public License instead.) You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+this service if you wish), that you receive source code or can get it
+if you want it, that you can change the software or use pieces of it
+in new free programs; and that you know you can do these things.
+
+ To protect your rights, we need to make restrictions that forbid
+anyone to deny you these rights or to ask you to surrender the rights.
+These restrictions translate to certain responsibilities for you if you
+distribute copies of the software, or if you modify it.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must give the recipients all the rights that
+you have. You must make sure that they, too, receive or can get the
+source code. And you must show them these terms so they know their
+rights.
+
+ We protect your rights with two steps: (1) copyright the software, and
+(2) offer you this license which gives you legal permission to copy,
+distribute and/or modify the software.
+
+ Also, for each author's protection and ours, we want to make certain
+that everyone understands that there is no warranty for this free
+software. If the software is modified by someone else and passed on, we
+want its recipients to know that what they have is not the original, so
+that any problems introduced by others will not reflect on the original
+authors' reputations.
+
+ Finally, any free program is threatened constantly by software
+patents. We wish to avoid the danger that redistributors of a free
+program will individually obtain patent licenses, in effect making the
+program proprietary. To prevent this, we have made it clear that any
+patent must be licensed for everyone's free use or not licensed at all.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ GNU GENERAL PUBLIC LICENSE
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+ 0. This License applies to any program or other work which contains
+a notice placed by the copyright holder saying it may be distributed
+under the terms of this General Public License. The "Program", below,
+refers to any such program or work, and a "work based on the Program"
+means either the Program or any derivative work under copyright law:
+that is to say, a work containing the Program or a portion of it,
+either verbatim or with modifications and/or translated into another
+language. (Hereinafter, translation is included without limitation in
+the term "modification".) Each licensee is addressed as "you".
+
+Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope. The act of
+running the Program is not restricted, and the output from the Program
+is covered only if its contents constitute a work based on the
+Program (independent of having been made by running the Program).
+Whether that is true depends on what the Program does.
+
+ 1. You may copy and distribute verbatim copies of the Program's
+source code as you receive it, in any medium, provided that you
+conspicuously and appropriately publish on each copy an appropriate
+copyright notice and disclaimer of warranty; keep intact all the
+notices that refer to this License and to the absence of any warranty;
+and give any other recipients of the Program a copy of this License
+along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and
+you may at your option offer warranty protection in exchange for a fee.
+
+ 2. You may modify your copy or copies of the Program or any portion
+of it, thus forming a work based on the Program, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+ a) You must cause the modified files to carry prominent notices
+ stating that you changed the files and the date of any change.
+
+ b) You must cause any work that you distribute or publish, that in
+ whole or in part contains or is derived from the Program or any
+ part thereof, to be licensed as a whole at no charge to all third
+ parties under the terms of this License.
+
+ c) If the modified program normally reads commands interactively
+ when run, you must cause it, when started running for such
+ interactive use in the most ordinary way, to print or display an
+ announcement including an appropriate copyright notice and a
+ notice that there is no warranty (or else, saying that you provide
+ a warranty) and that users may redistribute the program under
+ these conditions, and telling the user how to view a copy of this
+ License. (Exception: if the Program itself is interactive but
+ does not normally print such an announcement, your work based on
+ the Program is not required to print an announcement.)
+
+These requirements apply to the modified work as a whole. If
+identifiable sections of that work are not derived from the Program,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works. But when you
+distribute the same sections as part of a whole which is a work based
+on the Program, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Program.
+
+In addition, mere aggregation of another work not based on the Program
+with the Program (or with a work based on the Program) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+ 3. You may copy and distribute the Program (or a work based on it,
+under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you also do one of the following:
+
+ a) Accompany it with the complete corresponding machine-readable
+ source code, which must be distributed under the terms of Sections
+ 1 and 2 above on a medium customarily used for software interchange; or,
+
+ b) Accompany it with a written offer, valid for at least three
+ years, to give any third party, for a charge no more than your
+ cost of physically performing source distribution, a complete
+ machine-readable copy of the corresponding source code, to be
+ distributed under the terms of Sections 1 and 2 above on a medium
+ customarily used for software interchange; or,
+
+ c) Accompany it with the information you received as to the offer
+ to distribute corresponding source code. (This alternative is
+ allowed only for noncommercial distribution and only if you
+ received the program in object code or executable form with such
+ an offer, in accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for
+making modifications to it. For an executable work, complete source
+code means all the source code for all modules it contains, plus any
+associated interface definition files, plus the scripts used to
+control compilation and installation of the executable. However, as a
+special exception, the source code distributed need not include
+anything that is normally distributed (in either source or binary
+form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component
+itself accompanies the executable.
+
+If distribution of executable or object code is made by offering
+access to copy from a designated place, then offering equivalent
+access to copy the source code from the same place counts as
+distribution of the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+ 4. You may not copy, modify, sublicense, or distribute the Program
+except as expressly provided under this License. Any attempt
+otherwise to copy, modify, sublicense or distribute the Program is
+void, and will automatically terminate your rights under this License.
+However, parties who have received copies, or rights, from you under
+this License will not have their licenses terminated so long as such
+parties remain in full compliance.
+
+ 5. You are not required to accept this License, since you have not
+signed it. However, nothing else grants you permission to modify or
+distribute the Program or its derivative works. These actions are
+prohibited by law if you do not accept this License. Therefore, by
+modifying or distributing the Program (or any work based on the
+Program), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Program or works based on it.
+
+ 6. Each time you redistribute the Program (or any work based on the
+Program), the recipient automatically receives a license from the
+original licensor to copy, distribute or modify the Program subject to
+these terms and conditions. You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties to
+this License.
+
+ 7. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Program at all. For example, if a patent
+license would not permit royalty-free redistribution of the Program by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under
+any particular circumstance, the balance of the section is intended to
+apply and the section as a whole is intended to apply in other
+circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system, which is
+implemented by public license practices. Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+ 8. If the distribution and/or use of the Program is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Program under this License
+may add an explicit geographical distribution limitation excluding
+those countries, so that distribution is permitted only in or among
+countries not thus excluded. In such case, this License incorporates
+the limitation as if written in the body of this License.
+
+ 9. The Free Software Foundation may publish revised and/or new versions
+of the General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Program
+specifies a version number of this License which applies to it and "any
+later version", you have the option of following the terms and conditions
+either of that version or of any later version published by the Free
+Software Foundation. If the Program does not specify a version number of
+this License, you may choose any version ever published by the Free Software
+Foundation.
+
+ 10. If you wish to incorporate parts of the Program into other free
+programs whose distribution conditions are different, write to the author
+to ask for permission. For software which is copyrighted by the Free
+Software Foundation, write to the Free Software Foundation; we sometimes
+make exceptions for this. Our decision will be guided by the two goals
+of preserving the free status of all derivatives of our free software and
+of promoting the sharing and reuse of software generally.
+
+ NO WARRANTY
+
+ 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
+FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
+OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
+PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
+OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
+TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
+PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
+REPAIR OR CORRECTION.
+
+ 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
+REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
+INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
+OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
+TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
+YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
+PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ This program is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation; either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along
+ with this program; if not, write to the Free Software Foundation, Inc.,
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program is interactive, make it output a short notice like this
+when it starts in an interactive mode:
+
+ Gnomovision version 69, Copyright (C) year name of author
+ Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, the commands you use may
+be called something other than `show w' and `show c'; they could even be
+mouse-clicks or menu items--whatever suits your program.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the program, if
+necessary. Here is a sample; alter the names:
+
+ Yoyodyne, Inc., hereby disclaims all copyright interest in the program
+ `Gnomovision' (which makes passes at compilers) written by James Hacker.
+
+ <signature of Ty Coon>, 1 April 1989
+ Ty Coon, President of Vice
+
+This General Public License does not permit incorporating your program into
+proprietary programs. If your program is a subroutine library, you may
+consider it more useful to permit linking proprietary applications with the
+library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..c68122f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,509 @@
+# Acron
+
+Acron meas *another rcon*. It is a WebSocket based rcon replacement with advanced features.
+
+## Problems with rcon
+
+* [Security] No authorization: All rcon clients are hardcoded with OP level 4 in the Minecraft source code. There are also no permission control, giving any faulty or even malicious client full control over the server.
+* [Security] Simple authentication: All clients are sharing the same secret, making the secret easy to leak and granting attackers unlimited access to the server.
+* [Efficiency] Rcon executes commands in a blocking manner. The server joins the main thread and waits for the command to complete before reading more from the client.
+* [Limit] Rcon does not support pushing server messages to the client. This includes player messages, death messages, server logs, etc. A lot of use cases need such information.
+* [Limit] Rcon has a fixed command length. Although it is not likely for a command to exceed this limit, it still restricts the use cases of rcon.
+* [Limit] Rcon commands are hard coded to run at the spawn point of Overworld. It is impossible to execute commands in other positions or dimensions if the command does not support so itself.
+* [Limit] No Unix domain socket support. Unix domain socket is a great way to do localhost IPC and controlling access using Unix user and groups. However, rcon is forced to listen on a TCP address and port.
+* [Performance] Minecraft creates a new thread per connection accepted, and it blocks for input. Using a thread pool or async IO is much more performant.
+* [Security] Rcon does not support TLS. It is just using plain TCP.
+
+To solve these problems, a better approach is to rewrite rcon.
+
+## Problems Acron solved
+
+* [Security] Authentication and Authorization: With Acron, administrators are able to specify unique tokens for each client, and it is also possible to easily define the commands clients are permitted to execute using regex rules.
+* [Efficiency] Acron uses a command queue to schedule commands. Clients need to specify an ID, and Acron will return the result with the same ID once the command is done. In the meantime, clients can enqueue more commands.
+* [Limit] Server push: Acron will send player messages, death messages of living entities, player join / leave messages, and server lag warnings to the client. Acron also classifies the messages, so clients do not need to parse them manually.
+* [Limit] Command length: Acron does not limit command length.
+* [Limit] Locations and other configurations: Acron clients can specify the world, position, rotation, and name for each command they execute, or they can set a per-connection default.
+* [Limit] **Unix domain socket: Sorry, currently Acron does not support Unix domain socket either. Unix domain sockets will be available in later versions.**
+* [Performance] Acron uses Netty, which is built-in in Minecraft, to performance async IO using thread pools.
+* [Security] TLS: Although Acron does not support TLS itself, it is using WebSocket, which gives the choice of adding a reverse proxy with TLS support.
+
+## Technical Specification
+
+Acron is based on:
+
+1. WebSocket: Instead of designing a Layer 5 protocol, Acron chooses WebSocket to make the implementation of server and client easier. Moreover, WebSocket has a wide range of support compared to plain TCP sockets.
+2. JSON: Although JSON is slow and schema-less, it comes with no addition dependencies as a Minecraft mod because Minecraft depends on GSON internally.
+3. Netty: The WebSocket server is based on Netty because it is built-in in the Minecraft server.
+4. GSON: Acron uses GSON to deserialize / serialize JSON since GSON is also a Minecraft dependency.
+
+## Documentation Notes
+
+For each request JSON parameter, the format is:
+
+`(JSON path)` (type, limit, default value or required): Description.
+
+For each response JSON parameter, the format is:
+
+`(JSON path)` (type, limit, always present or conditions): Description.
+
+## Installation
+
+To build this mod, you need to run `gradle build`, and the output JAR will be at `build/libs/acron-x.x.jar`.
+
+Then, copy it to the `mods/` folder in your Minecraft server working directory.
+
+Finally, edit `<Minecraft server working directory>/config/acron.json` as follows:
+
+```json
+{
+ "port": 25575,
+ "address": "127.0.0.1",
+ "native_transport": false,
+ "clients": [
+ {
+ "id": "client1",
+ "token": "61fe277334300860dbcf8320ad866788e08b7dd930f9f04a3dc4db5e7f6521e2",
+ "policy_mode": "deny",
+ "rules": [
+ {
+ "regex": "^list$",
+ "action": "allow",
+ "display": false
+ },
+ {
+ "regex": "^kick .*$",
+ "action": "allow",
+ "display": true
+ },
+ {
+ "regex": "^stop .*$",
+ "action": "deny"
+ }
+ ]
+ }
+ ]
+}
+```
+
+Finally, start the server.
+
+> **Notes**
+>
+> JSON is not the first choice for configuration files because it takes too much manual labor to write it correctly.
+> However, since Minecraft server bundles GSON, it is redundant for this mod to depend on another configuration parsing library
+> for the sole purpose of loading configurations.
+>
+> To save users' time, we are planning to release a online GUI configuration editor.
+
+## Configuration
+
+### Server configuration
+
+JSON Path: `.`
+
+* `port` (int, [0, 65535], 25575): Port to listen.
+* `address` (string, IPv4 or IPv6 address, "127.0.0.1"): Address to listen.
+* `native_transport` (boolean, true / false, false): Use Epoll when available.
+
+### Client configuration
+
+JSON Path: `.clients.[]`
+
+* `id` (string, any, required): The ID of the client. The client needs to specify it in the connection string.
+* `token` (string, SHA256, required): The SHA256 of the token. The token is generated by the administrator.
+* `policy_mode` (enum, deny / allow, deny): The default rule if its command does not mach any rules in the `rules` array.
+* `rules.[]regex` (string, regex, required): The regex to match the command.
+* `rules.[]action` (enum, deny / allow, required): The action for this rule.
+* `rules.[]display` (boolean, true / false, false): Display the output of the command on chat.
+
+## Client Management
+
+Each client has a unique ID (like a username), and it has a token used to authenticate itself. The administrator needs to add the client to the configuration with an ID (administrator chosen) and a token (administrator generated).
+
+When the client connects, it needs to supply the ID - token pair, or Acron will return HTTP 401 in the WebSocket handshake request.
+
+Each client has some rules and a default policy mode. When it executes a command, Acron will match the command string against the rules,
+from the first to the last, until a match is found, and the corresponding action in the rule is taken.
+It Acron cannot match any rules, it will take the default policy mode.
+
+Auditing is also available. Users may specify the `display` parameter in rules to make the command output to both server logs and chat.
+
+> **Note**
+>
+> Internally, the command will run at OP level 4 (the highest level) after
+> passing rules check.
+
+> **Note**
+>
+> Minecraft accepts commands both starting with `/` or not (but
+> not commands starting with two or more `/`). However, Acron will remove
+> the leading slash if present when matching against rules.
+
+> **Note**
+>
+> If the format of `.port`, `.listen` or `.native_transport` is wrong, Acron will prevent
+> Minecraft server from starting up.
+>
+> However, if the format of anything in `.clients` is wrong, it will print a warning and skip
+> that part because administrators can reload clients during runtime.
+
+### Configuration reloading
+
+Any administrator with OP level 4 can execute the command `/acron rule update`.
+It will instantly read the configuration file
+and apply the changes to clients and rules.
+
+However, this does not affect existing connections since authentication happens
+during WebSocket handshaking.
+
+Note, listen port and address cannot be changed during runtime.
+
+> **Note**
+>
+> Similarly, if Acron finds an error in `.clients` after running `/acron rule update`,
+> it will print a warning and skip the whole new configuration file until the
+> error is fixed.
+
+## Client API
+
+Acron uses polymorphic JSONs when communicating with clients. Therefore, each JSON
+has to contain a valid `type` parameter indicating its type:
+
+```json
+{
+ "type": "cmd",
+ "id": 1,
+ "cmd": "list"
+}
+```
+
+### Request ordering
+
+To work in a full-duplex environment, each command can specify a `id` parameter. Acron will
+return any results or errors with the same ID.
+
+Sample request:
+
+```json
+{
+ "type": "cmd",
+ "id": 1,
+ "cmd": "list"
+}
+```
+
+The parameter `id` can be any integer, but it is the client developer's responsibility to
+make it a unique value, so he or she can identify it.
+
+Parameter `id` defaults to -1.
+
+In response, any non-server-push responses (i. e. messages) will include the same `id` parameter:
+
+```json
+{
+ "type": "cmd_result",
+ "id": 1,
+ "result": 0,
+ "success": true
+}
+```
+
+If the server fails to parse the request and returns an error, it will report the default ID `-2`.
+
+### Error Handling
+
+Error handling: Besides from the handshake request, which will send errors using HTTP status
+codes, all faulty WebSocket requests will receive error in the following format:
+
+```json
+{
+ "type": "error",
+ "id": 1,
+ "message": "Error message. Not machine-readable."
+}
+```
+
+**`.type` and `.id` are included in every request / response, except for further noticed. Thus,
+this document excludes them from the parameter lists.**
+
+### Handshaking
+
+Clients need to use the following connection string when connecting to the Acron server:
+
+```
+ws://host:port/ws?id=client_id&token=client_token&version=0
+```
+
+*A better approach for specifying the authentication parameters is using HTTP headers,
+but the JavaScript client does not allow so. To extend compatibility, Acron forces
+all users to use HTTP query parameters to supply information.*
+
+Parameters:
+
+* `id` (required): Client ID set by the administrator.
+* `token` (required): Client token set by the administrator.
+* `version` (default: 0): API version. Only 0 is accepted at this time.
+
+Responses:
+
+* HTTP 400 (Bad Request): If either `id` or `token` is missing, or `version` is not 0.
+* HTTP 401 (Unauthorized): If either `id` is not found or `token` does not match the record.
+* HTTP 101 (Switching Protocols): The handshake is complete, and the server is upgrading to
+WebSocket.
+
+### Setting Configuration
+
+This allows clients to set a per-connection default configuration to execute commands.
+
+Clients can override the configuration temporarily when executing commands.
+
+Request:
+```json
+{
+ "type": "set_config",
+ "id": 1,
+ "world": "overworld",
+ "pos": {
+ "x": 0.0,
+ "y": 0.0,
+ "z": 0.0
+ },
+ "rot": {
+ "x": 0.0,
+ "y": 0.0
+ },
+ "name": ""
+}
+```
+
+Parameters:
+
+* `.world` (enum, overworld / nether / end, overworld): The world to run commands in.
+* `.pos` (vec3d, *see below*, spawn point of `.world`): The position to run commands at.
+ * `.x` (double, any within border limit, 0.0): X
+ * `.y` (double, any within border limit, 0.0): Y
+ * `.z` (double, any within border limit, 0.0): Z
+* `.rot` (vec2f, *see below*, `0.0 0.0`): Rotation.
+ * `.x` (float, ?, 0.0): X
+ * `.z` (float, ?, 0.0): Z
+* `.name` (string, any, random): Name when running commands.
+
+When the client connects, Acron will set the configuration to default values.
+
+Successful response:
+
+```json
+{
+ "type": "ok"
+}
+```
+
+This shows that the configuration update is successful.
+
+### Executing Commands
+
+The main goal of Acron is to allow clients to run commands. A client can send
+any commands, and Acron will schedule them in the background.
+
+Request:
+
+```json
+{
+ "type": "cmd",
+ "id": 1,
+ "cmd": "list",
+ "config": {
+
+ }
+}
+```
+
+Parameters:
+
+* `.cmd` (string, any valid command, required): The command to execute. It may or may not begin with `/`.
+* `.config` (set_config, *see above*, current connection default configuration): Temporary configuration
+when running this command. It is the same `set_config` object in the above section, but `type` and `id`
+must not be supplied.
+
+Successful response:
+
+```json
+{
+ "type": "ok"
+}
+```
+
+This shows that the command is scheduled.
+
+If the connection breaks before it is done, it is still executed without any output to the connection.
+
+**Command output:**
+
+When the command prints a line, Acron will send the following response:
+
+```json
+{
+ "type": "cmd_out",
+ "id": 1,
+ "sender": "UUID",
+ "out": "..."
+}
+```
+
+Parameters:
+
+* `.sender` (UUID, any UUID, always present): Sender UUID.
+* `.out` (string, any, always present): Output.
+
+**Command result:**
+
+When the command finishes without issues (?), Acron will send the following response:
+
+```json
+{
+ "type": "cmd_result",
+ "id": 1,
+ "result": 0,
+ "success": true
+}
+```
+
+All parameters always present.
+
+> **Note**
+>
+> The result completely depends on Minecraft server's response.
+> It may not be reliable, and the values of `.result` and `.success` are
+> undocumented.
+
+### Receiving Messages
+
+Another major part of Acron is to allow clients receive events from the server.
+
+Every event will have a pre-defined `type` with other custom parameters. Parameter `id` will not
+present in events.
+
+> **Contributor Guide**
+>
+> Internally, all message Acron sends to clients are called events, including
+> command results.
+
+#### Player joined
+
+Response:
+
+```json
+{
+ "type": "join",
+ "player": {
+ "name": "",
+ "uuid": ""
+ }
+}
+```
+
+Parameters:
+
+* `.player` (entity, see below, always present): The player.
+ * `.name` (string, any valid Minecraft username, always present): Username.
+ * `.uuid` (uuid, UUID, always present): UUID.
+
+#### Player Disconnected
+
+Response:
+
+```json
+{
+ "type": "disconnect",
+ "player": {
+ "name": "",
+ "uuid": ""
+ },
+ "reason": ""
+}
+```
+
+Parameters:
+
+* `.player` (entity, see below, null only when the server cannot verify the user): The player.
+ * `.name` (string, any valid Minecraft username, always present): Username.
+ * `.uuid` (uuid, UUID, always present): UUID.
+* `.reason` (string, any valid disconnect reason, always present): Disconnect reason.
+
+#### Player Message
+
+Response:
+
+```json
+{
+ "type": "disconnect",
+ "player": {
+ "name": "",
+ "uuid": ""
+ },
+ "reason": ""
+}
+```
+
+Parameters:
+
+* `.player` (entity, see below, always present): The player.
+ * `.name` (string, any valid Minecraft username, always present): Username.
+ * `.uuid` (uuid, UUID, always present): UUID.
+* `.text` (string, any valid Minecraft message, always present): The message.
+
+#### Entity Death
+
+Response:
+
+```json
+{
+ "type": "death",
+ "entity": {
+ "name": "",
+ "uuid": ""
+ },
+ "message": ""
+}
+```
+
+Parameters:
+
+* `.entity` (entity, see below, always present): The entity.
+ * `.name` (string, any, always present): Default name or custom name of the entity.
+ * `.uuid` (uuid, UUID, always present): UUID.
+* `.text` (string, any valid death message, always present): The user-readable death message.
+
+> **Roadmap**
+>
+> Parsing the death message and sending a more machine-readable message is on the roadmap.
+
+#### Server Lagging
+
+Acron will send this event when the server prints
+`Can't keep up! Is the server overloaded? Running 4313ms or 86 ticks behind` to the standard output.
+
+Response:
+
+```json
+{
+ "type": "lagging",
+ "ms": 100,
+ "ticks": 1000
+}
+```
+
+Parameters:
+
+* `.ms` (long, >= 0, always present): Running {}ms behind.
+* `.ticks` (long, >= 0, always present): Running {} ticks behind.
+
+## Contributing
+
+As a community project, I highly appreciate any help to this project. If you have any suggestions or
+patches, or if you find a bug or security issue, please send them to `yuuta@yuuta.moe`, and mention Acron in
+the email subject. If you are sending a patch, please include `[PATCH]` in the subject as well. Thank you very much.
+
+## License
+
+GPL v2 only. \ No newline at end of file
diff --git a/acron.json b/acron.json
new file mode 100644
index 0000000..83edef5
--- /dev/null
+++ b/acron.json
@@ -0,0 +1,19 @@
+{
+ "clients": [
+ {
+ "id": "1",
+ "token": "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3",
+ "rules": [
+ {
+ "regex": ".*",
+ "action": "allow",
+ "display": true
+ }
+ ]
+ },
+ {
+ "id": "2",
+ "token": "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3"
+ }
+ ]
+} \ No newline at end of file
diff --git a/build.gradle b/build.gradle
new file mode 100644
index 0000000..7614111
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,78 @@
+plugins {
+ id 'fabric-loom' version '0.12-SNAPSHOT'
+ id 'maven-publish'
+}
+
+version = project.mod_version
+group = project.maven_group
+
+repositories {
+ // Add repositories to retrieve artifacts from in here.
+ // You should only use this when depending on other mods because
+ // Loom adds the essential maven repositories to download Minecraft and libraries from automatically.
+ // See https://docs.gradle.org/current/userguide/declaring_repositories.html
+ // for more information about repositories.
+}
+
+dependencies {
+ // To change the versions see the gradle.properties file
+ minecraft "com.mojang:minecraft:${project.minecraft_version}"
+ mappings "net.fabricmc:yarn:${project.yarn_mappings}:v2"
+ modImplementation "net.fabricmc:fabric-loader:${project.loader_version}"
+}
+
+processResources {
+ inputs.property "version", project.version
+ filteringCharset "UTF-8"
+
+ filesMatching("fabric.mod.json") {
+ expand "version": project.version
+ }
+}
+
+def targetJavaVersion = 18
+tasks.withType(JavaCompile).configureEach {
+ // ensure that the encoding is set to UTF-8, no matter what the system default is
+ // this fixes some edge cases with special characters not displaying correctly
+ // see http://yodaconditions.net/blog/fix-for-java-file-encoding-problems-with-gradle.html
+ // If Javadoc is generated, this must be specified in that task too.
+ it.options.encoding = "UTF-8"
+ if (targetJavaVersion >= 10 || JavaVersion.current().isJava10Compatible()) {
+ it.options.release = targetJavaVersion
+ }
+}
+
+java {
+ def javaVersion = JavaVersion.toVersion(targetJavaVersion)
+ if (JavaVersion.current() < javaVersion) {
+ toolchain.languageVersion = JavaLanguageVersion.of(targetJavaVersion)
+ }
+ archivesBaseName = project.archives_base_name
+ // Loom will automatically attach sourcesJar to a RemapSourcesJar task and to the "build" task
+ // if it is present.
+ // If you remove this line, sources will not be generated.
+ withSourcesJar()
+}
+
+jar {
+ from("LICENSE") {
+ rename { "${it}_${project.archivesBaseName}" }
+ }
+}
+
+// configure the maven publication
+publishing {
+ publications {
+ mavenJava(MavenPublication) {
+ from components.java
+ }
+ }
+
+ // See https://docs.gradle.org/current/userguide/publishing_maven.html for information on how to set up publishing.
+ repositories {
+ // Add repositories to publish to here.
+ // Notice: This block does NOT have the same function as the block in the top level.
+ // The repositories here will be used for publishing your artifact, not for
+ // retrieving dependencies.
+ }
+}
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..85e12c2
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,11 @@
+# Done to increase the memory available to gradle.
+org.gradle.jvmargs=-Xmx1G
+# Fabric Properties
+# check these on https://modmuss50.me/fabric.html
+minecraft_version=1.17.1
+yarn_mappings=1.17.1+build.65
+loader_version=0.14.4
+# Mod Properties
+mod_version=1.0
+maven_group=moe.ymc
+archives_base_name=acron
diff --git a/settings.gradle b/settings.gradle
new file mode 100644
index 0000000..f91a4fe
--- /dev/null
+++ b/settings.gradle
@@ -0,0 +1,9 @@
+pluginManagement {
+ repositories {
+ maven {
+ name = 'Fabric'
+ url = 'https://maven.fabricmc.net/'
+ }
+ gradlePluginPortal()
+ }
+}
diff --git a/src/main/java/com/google/gson/typeadapters/RuntimeTypeAdapterFactory.java b/src/main/java/com/google/gson/typeadapters/RuntimeTypeAdapterFactory.java
new file mode 100644
index 0000000..51f05ff
--- /dev/null
+++ b/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/src/main/java/moe/ymc/acron/Acron.java b/src/main/java/moe/ymc/acron/Acron.java
new file mode 100644
index 0000000..d6f6214
--- /dev/null
+++ b/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/src/main/java/moe/ymc/acron/MinecraftServerHolder.java b/src/main/java/moe/ymc/acron/MinecraftServerHolder.java
new file mode 100644
index 0000000..f522884
--- /dev/null
+++ b/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/src/main/java/moe/ymc/acron/auth/Action.java b/src/main/java/moe/ymc/acron/auth/Action.java
new file mode 100644
index 0000000..17d29a3
--- /dev/null
+++ b/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/src/main/java/moe/ymc/acron/auth/Client.java b/src/main/java/moe/ymc/acron/auth/Client.java
new file mode 100644
index 0000000..2124ad4
--- /dev/null
+++ b/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/src/main/java/moe/ymc/acron/auth/PolicyChecker.java b/src/main/java/moe/ymc/acron/auth/PolicyChecker.java
new file mode 100644
index 0000000..5dea02a
--- /dev/null
+++ b/src/main/java/moe/ymc/acron/auth/PolicyChecker.java
@@ -0,0 +1,39 @@
+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) {
+ for (int i = 0; i < client.rules().length; i++) {
+ final Rule rule = client.rules()[i];
+ if (rule.cmdPattern().matcher(command).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/src/main/java/moe/ymc/acron/auth/Rule.java b/src/main/java/moe/ymc/acron/auth/Rule.java
new file mode 100644
index 0000000..55ad0d7
--- /dev/null
+++ b/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/src/main/java/moe/ymc/acron/c2s/ReqCmd.java b/src/main/java/moe/ymc/acron/c2s/ReqCmd.java
new file mode 100644
index 0000000..bcd8d48
--- /dev/null
+++ b/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() :
+ 0;
+ 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/src/main/java/moe/ymc/acron/c2s/ReqSetConfig.java b/src/main/java/moe/ymc/acron/c2s/ReqSetConfig.java
new file mode 100644
index 0000000..dc2c878
--- /dev/null
+++ b/src/main/java/moe/ymc/acron/c2s/ReqSetConfig.java
@@ -0,0 +1,100 @@
+package moe.ymc.acron.c2s;
+
+import com.google.gson.*;
+import com.google.gson.annotations.SerializedName;
+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 0;
+ }
+
+ 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 = object.has("world") ?
+ WorldKey.valueOf(object.get("world").getAsString().toUpperCase()) :
+ 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(-1,
+ world,
+ pos,
+ rot,
+ name);
+ }
+ }
+
+ public enum WorldKey {
+ OVERWORLD,
+ NETHER,
+ END
+ }
+
+ public record Vec3d(@SerializedName("x") double x,
+ @SerializedName("y") double y,
+ @SerializedName("z") double 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);
+ }
+ }
+ }
+
+ 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);
+ }
+ }
+ }
+}
diff --git a/src/main/java/moe/ymc/acron/c2s/Request.java b/src/main/java/moe/ymc/acron/c2s/Request.java
new file mode 100644
index 0000000..af81705
--- /dev/null
+++ b/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/src/main/java/moe/ymc/acron/cmd/CmdOut.java b/src/main/java/moe/ymc/acron/cmd/CmdOut.java
new file mode 100644
index 0000000..717bd0c
--- /dev/null
+++ b/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.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/src/main/java/moe/ymc/acron/cmd/CmdQueue.java b/src/main/java/moe/ymc/acron/cmd/CmdQueue.java
new file mode 100644
index 0000000..3c49143
--- /dev/null
+++ b/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/src/main/java/moe/ymc/acron/cmd/CmdResConsumer.java b/src/main/java/moe/ymc/acron/cmd/CmdResConsumer.java
new file mode 100644
index 0000000..13a65f8
--- /dev/null
+++ b/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.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/src/main/java/moe/ymc/acron/cmd/CmdSrc.java b/src/main/java/moe/ymc/acron/cmd/CmdSrc.java
new file mode 100644
index 0000000..983b4ed
--- /dev/null
+++ b/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/src/main/java/moe/ymc/acron/config/Config.java b/src/main/java/moe/ymc/acron/config/Config.java
new file mode 100644
index 0000000..3749c25
--- /dev/null
+++ b/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/src/main/java/moe/ymc/acron/config/ConfigReloadCmd.java b/src/main/java/moe/ymc/acron/config/ConfigReloadCmd.java
new file mode 100644
index 0000000..2774c4d
--- /dev/null
+++ b/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/src/main/java/moe/ymc/acron/config/json/Client.java b/src/main/java/moe/ymc/acron/config/json/Client.java
new file mode 100644
index 0000000..4d31308
--- /dev/null
+++ b/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/src/main/java/moe/ymc/acron/config/json/Config.java b/src/main/java/moe/ymc/acron/config/json/Config.java
new file mode 100644
index 0000000..e8c5a83
--- /dev/null
+++ b/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/src/main/java/moe/ymc/acron/config/json/ConfigDeserializationException.java b/src/main/java/moe/ymc/acron/config/json/ConfigDeserializationException.java
new file mode 100644
index 0000000..baf5b35
--- /dev/null
+++ b/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/src/main/java/moe/ymc/acron/config/json/ConfigDeserializer.java b/src/main/java/moe/ymc/acron/config/json/ConfigDeserializer.java
new file mode 100644
index 0000000..e91b355
--- /dev/null
+++ b/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/src/main/java/moe/ymc/acron/config/json/ConfigJsonObject.java b/src/main/java/moe/ymc/acron/config/json/ConfigJsonObject.java
new file mode 100644
index 0000000..0efd9a9
--- /dev/null
+++ b/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/src/main/java/moe/ymc/acron/config/json/Rule.java b/src/main/java/moe/ymc/acron/config/json/Rule.java
new file mode 100644
index 0000000..114e17d
--- /dev/null
+++ b/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/src/main/java/moe/ymc/acron/jvav/Pair.java b/src/main/java/moe/ymc/acron/jvav/Pair.java
new file mode 100644
index 0000000..29b83dc
--- /dev/null
+++ b/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/src/main/java/moe/ymc/acron/mixin/CommandManagerMixin.java b/src/main/java/moe/ymc/acron/mixin/CommandManagerMixin.java
new file mode 100644
index 0000000..20de2e7
--- /dev/null
+++ b/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 LOGGER = LogManager.getLogger();
+
+ @Shadow
+ @Final
+ private CommandDispatcher<ServerCommandSource> dispatcher;
+
+ @Inject(method = "<init>", at = @At("RETURN"))
+ private void onRegister(CommandManager.RegistrationEnvironment arg, CallbackInfo ci) {
+ LOGGER.debug("onRegister");
+ dispatcher.register(
+ literal("acron").then(
+ literal("rule").then(
+ literal("update").requires(player -> player.hasPermissionLevel(4))
+ .executes(new ConfigReloadCmd()))
+ )
+ );
+ }
+}
diff --git a/src/main/java/moe/ymc/acron/mixin/LivingEntityMixin.java b/src/main/java/moe/ymc/acron/mixin/LivingEntityMixin.java
new file mode 100644
index 0000000..a4cb744
--- /dev/null
+++ b/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.EventEntityDeath;
+import moe.ymc.acron.s2c.EventQueue;
+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 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) {
+ LOGGER.debug("onDeath[{}]",
+ getUuid());
+ EventQueue.enqueue(new EventEntityDeath(new Entity(this),
+ getDamageTracker().getDeathMessage().getString()));
+ }
+} \ No newline at end of file
diff --git a/src/main/java/moe/ymc/acron/mixin/MinecraftDedicatedServerMixin.java b/src/main/java/moe/ymc/acron/mixin/MinecraftDedicatedServerMixin.java
new file mode 100644
index 0000000..50af0b4
--- /dev/null
+++ b/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 LOGGER = LogManager.getLogger();
+
+ @Inject(at = @At("RETURN"), method = "<init>")
+ private void init(CallbackInfo info) {
+ LOGGER.debug("init");
+ MinecraftServerHolder.setServer((MinecraftDedicatedServer) (Object) this);
+ }
+}
diff --git a/src/main/java/moe/ymc/acron/mixin/MinecraftServerMixin.java b/src/main/java/moe/ymc/acron/mixin/MinecraftServerMixin.java
new file mode 100644
index 0000000..fc2a88c
--- /dev/null
+++ b/src/main/java/moe/ymc/acron/mixin/MinecraftServerMixin.java
@@ -0,0 +1,32 @@
+package moe.ymc.acron.mixin;
+
+import moe.ymc.acron.s2c.EventLagging;
+import moe.ymc.acron.s2c.EventQueue;
+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 LOGGER = LogManager.getLogger();
+
+ @Redirect(method = "runServer()V",
+ 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) {
+ LOGGER.debug("Lag: {}ms, {} ticks",
+ o1,
+ o2);
+ EventQueue.enqueue(new EventLagging((long) o1, (long) o2));
+ }
+ }
+}
diff --git a/src/main/java/moe/ymc/acron/mixin/ServerLoginNetworkHandlerMixin.java b/src/main/java/moe/ymc/acron/mixin/ServerLoginNetworkHandlerMixin.java
new file mode 100644
index 0000000..7e4115e
--- /dev/null
+++ b/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.EventDisconnected;
+import moe.ymc.acron.s2c.EventPlayerJoined;
+import moe.ymc.acron.s2c.EventQueue;
+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 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) {
+ LOGGER.debug("addToServer: {}",
+ entity.getUuid());
+ EventQueue.enqueue(new EventPlayerJoined(new Entity(entity)));
+ }
+}
diff --git a/src/main/java/moe/ymc/acron/mixin/ServerNetworkIoMixin.java b/src/main/java/moe/ymc/acron/mixin/ServerNetworkIoMixin.java
new file mode 100644
index 0000000..7c9b60b
--- /dev/null
+++ b/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 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) {
+ 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;
+ LOGGER.info("Using native transport.");
+ } else {
+ channel = NioServerSocketChannel.class;
+ group = DEFAULT_CHANNEL;
+ 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/src/main/java/moe/ymc/acron/mixin/ServerPlayNetworkHandlerMixin.java b/src/main/java/moe/ymc/acron/mixin/ServerPlayNetworkHandlerMixin.java
new file mode 100644
index 0000000..0bcfb0a
--- /dev/null
+++ b/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.EventDisconnected;
+import moe.ymc.acron.s2c.EventPlayerMessage;
+import moe.ymc.acron.s2c.EventQueue;
+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 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/src/main/java/moe/ymc/acron/mixin/ServerPlayerEntityMixin.java b/src/main/java/moe/ymc/acron/mixin/ServerPlayerEntityMixin.java
new file mode 100644
index 0000000..17c8517
--- /dev/null
+++ b/src/main/java/moe/ymc/acron/mixin/ServerPlayerEntityMixin.java
@@ -0,0 +1,32 @@
+package moe.ymc.acron.mixin;
+
+import moe.ymc.acron.s2c.EventEntityDeath;
+import moe.ymc.acron.s2c.EventQueue;
+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 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) {
+ 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/src/main/java/moe/ymc/acron/net/AcronInitializer.java b/src/main/java/moe/ymc/acron/net/AcronInitializer.java
new file mode 100644
index 0000000..c9953e3
--- /dev/null
+++ b/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/src/main/java/moe/ymc/acron/net/Attributes.java b/src/main/java/moe/ymc/acron/net/Attributes.java
new file mode 100644
index 0000000..ddb0f5c
--- /dev/null
+++ b/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/src/main/java/moe/ymc/acron/net/AuthHandler.java b/src/main/java/moe/ymc/acron/net/AuthHandler.java
new file mode 100644
index 0000000..47abf9f
--- /dev/null
+++ b/src/main/java/moe/ymc/acron/net/AuthHandler.java
@@ -0,0 +1,91 @@
+package moe.ymc.acron.net;
+
+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));
+ 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));
+ 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));
+ 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));
+ return;
+ }
+ } catch (NumberFormatException ignored) {
+ ctx.writeAndFlush(new DefaultFullHttpResponse(HttpVersion.HTTP_1_1,
+ HttpResponseStatus.BAD_REQUEST));
+ 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));
+ 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/src/main/java/moe/ymc/acron/net/ClientConfiguration.java b/src/main/java/moe/ymc/acron/net/ClientConfiguration.java
new file mode 100644
index 0000000..450ccd4
--- /dev/null
+++ b/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/src/main/java/moe/ymc/acron/net/ClientIdentification.java b/src/main/java/moe/ymc/acron/net/ClientIdentification.java
new file mode 100644
index 0000000..1cb4375
--- /dev/null
+++ b/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/src/main/java/moe/ymc/acron/net/HandshakeComplete.java b/src/main/java/moe/ymc/acron/net/HandshakeComplete.java
new file mode 100644
index 0000000..348b5e2
--- /dev/null
+++ b/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/src/main/java/moe/ymc/acron/net/WSFrameHandler.java b/src/main/java/moe/ymc/acron/net/WSFrameHandler.java
new file mode 100644
index 0000000..8eba1f8
--- /dev/null
+++ b/src/main/java/moe/ymc/acron/net/WSFrameHandler.java
@@ -0,0 +1,149 @@
+package moe.ymc.acron.net;
+
+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.EventCmdDenied;
+import moe.ymc.acron.s2c.EventError;
+import moe.ymc.acron.s2c.EventOk;
+import moe.ymc.acron.s2c.EventQueue;
+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 = -2;
+ try {
+ final Request request = Serializer.deserialize(frame);
+ id = request.getId();
+ 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) {
+ ctx.channel().writeAndFlush(Serializer.serialize(new EventCmdDenied(reqCmd.id())));
+ return;
+ }
+ // Write it before enqueueing to prevent potential
+ // thread safety issues.
+ ctx.channel().writeAndFlush(Serializer.serialize(new EventOk(id)));
+ CmdQueue.enqueue(id,
+ res.r(),
+ ctx.channel(),
+ reqCmd.config() == null ?
+ configuration :
+ convertConfiguration(reqCmd.config()),
+ reqCmd.cmd());
+ } else if (request instanceof final ReqSetConfig reqSetConfig) {
+ ctx.channel().attr(Attributes.CONFIGURATION).set(convertConfiguration(reqSetConfig));
+ ctx.channel().writeAndFlush(Serializer.serialize(new EventOk(id)));
+ }
+ } 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, e.getMessage())));
+ }
+ }
+
+ 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/src/main/java/moe/ymc/acron/s2c/Entity.java b/src/main/java/moe/ymc/acron/s2c/Entity.java
new file mode 100644
index 0000000..97bd567
--- /dev/null
+++ b/src/main/java/moe/ymc/acron/s2c/Entity.java
@@ -0,0 +1,19 @@
+package moe.ymc.acron.s2c;
+
+import com.google.gson.annotations.SerializedName;
+import com.mojang.authlib.GameProfile;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.UUID;
+
+public record Entity(@SerializedName("name") @NotNull String name,
+ @SerializedName("uuid") @NotNull UUID uuid) {
+ public Entity(@NotNull net.minecraft.entity.Entity entity) {
+ this(entity.getName().getString(),
+ entity.getUuid());
+ }
+
+ public Entity(@NotNull GameProfile profile) {
+ this(profile.getName(), profile.getId());
+ }
+}
diff --git a/src/main/java/moe/ymc/acron/s2c/Event.java b/src/main/java/moe/ymc/acron/s2c/Event.java
new file mode 100644
index 0000000..1abc35c
--- /dev/null
+++ b/src/main/java/moe/ymc/acron/s2c/Event.java
@@ -0,0 +1,4 @@
+package moe.ymc.acron.s2c;
+
+public interface Event {
+}
diff --git a/src/main/java/moe/ymc/acron/s2c/EventCmdDenied.java b/src/main/java/moe/ymc/acron/s2c/EventCmdDenied.java
new file mode 100644
index 0000000..912b430
--- /dev/null
+++ b/src/main/java/moe/ymc/acron/s2c/EventCmdDenied.java
@@ -0,0 +1,7 @@
+package moe.ymc.acron.s2c;
+
+import com.google.gson.annotations.SerializedName;
+
+public record EventCmdDenied(@SerializedName("id") int id)
+ implements Event {
+}
diff --git a/src/main/java/moe/ymc/acron/s2c/EventCmdOut.java b/src/main/java/moe/ymc/acron/s2c/EventCmdOut.java
new file mode 100644
index 0000000..3b8d06e
--- /dev/null
+++ b/src/main/java/moe/ymc/acron/s2c/EventCmdOut.java
@@ -0,0 +1,12 @@
+package moe.ymc.acron.s2c;
+
+import com.google.gson.annotations.SerializedName;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.UUID;
+
+public record EventCmdOut(@SerializedName("id") int id,
+ @SerializedName("sender") @NotNull UUID sender,
+ @SerializedName("text") @NotNull String text)
+ implements Event {
+}
diff --git a/src/main/java/moe/ymc/acron/s2c/EventCmdRes.java b/src/main/java/moe/ymc/acron/s2c/EventCmdRes.java
new file mode 100644
index 0000000..dae858a
--- /dev/null
+++ b/src/main/java/moe/ymc/acron/s2c/EventCmdRes.java
@@ -0,0 +1,9 @@
+package moe.ymc.acron.s2c;
+
+import com.google.gson.annotations.SerializedName;
+
+public record EventCmdRes(@SerializedName("id") int id,
+ @SerializedName("success") boolean success,
+ @SerializedName("result") int result)
+ implements Event {
+}
diff --git a/src/main/java/moe/ymc/acron/s2c/EventDisconnected.java b/src/main/java/moe/ymc/acron/s2c/EventDisconnected.java
new file mode 100644
index 0000000..bf0e279
--- /dev/null
+++ b/src/main/java/moe/ymc/acron/s2c/EventDisconnected.java
@@ -0,0 +1,10 @@
+package moe.ymc.acron.s2c;
+
+import com.google.gson.annotations.SerializedName;
+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/src/main/java/moe/ymc/acron/s2c/EventEntityDeath.java b/src/main/java/moe/ymc/acron/s2c/EventEntityDeath.java
new file mode 100644
index 0000000..201cdf3
--- /dev/null
+++ b/src/main/java/moe/ymc/acron/s2c/EventEntityDeath.java
@@ -0,0 +1,10 @@
+package moe.ymc.acron.s2c;
+
+import com.google.gson.annotations.SerializedName;
+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/src/main/java/moe/ymc/acron/s2c/EventError.java b/src/main/java/moe/ymc/acron/s2c/EventError.java
new file mode 100644
index 0000000..7f48fa8
--- /dev/null
+++ b/src/main/java/moe/ymc/acron/s2c/EventError.java
@@ -0,0 +1,9 @@
+package moe.ymc.acron.s2c;
+
+import com.google.gson.annotations.SerializedName;
+import org.jetbrains.annotations.Nullable;
+
+public record EventError(@SerializedName("id") int id,
+ @SerializedName("message") @Nullable String message)
+ implements Event {
+}
diff --git a/src/main/java/moe/ymc/acron/s2c/EventLagging.java b/src/main/java/moe/ymc/acron/s2c/EventLagging.java
new file mode 100644
index 0000000..d0a3f61
--- /dev/null
+++ b/src/main/java/moe/ymc/acron/s2c/EventLagging.java
@@ -0,0 +1,8 @@
+package moe.ymc.acron.s2c;
+
+import com.google.gson.annotations.SerializedName;
+
+public record EventLagging(@SerializedName("ms") long ms,
+ @SerializedName("ticks") long ticks)
+ implements Event {
+}
diff --git a/src/main/java/moe/ymc/acron/s2c/EventOk.java b/src/main/java/moe/ymc/acron/s2c/EventOk.java
new file mode 100644
index 0000000..5be5c65
--- /dev/null
+++ b/src/main/java/moe/ymc/acron/s2c/EventOk.java
@@ -0,0 +1,7 @@
+package moe.ymc.acron.s2c;
+
+import com.google.gson.annotations.SerializedName;
+
+public record EventOk(@SerializedName("id") int id)
+ implements Event {
+}
diff --git a/src/main/java/moe/ymc/acron/s2c/EventPlayerJoined.java b/src/main/java/moe/ymc/acron/s2c/EventPlayerJoined.java
new file mode 100644
index 0000000..eaadee1
--- /dev/null
+++ b/src/main/java/moe/ymc/acron/s2c/EventPlayerJoined.java
@@ -0,0 +1,8 @@
+package moe.ymc.acron.s2c;
+
+import com.google.gson.annotations.SerializedName;
+import org.jetbrains.annotations.NotNull;
+
+public record EventPlayerJoined(@SerializedName("player") @NotNull Entity player)
+ implements Event {
+}
diff --git a/src/main/java/moe/ymc/acron/s2c/EventPlayerMessage.java b/src/main/java/moe/ymc/acron/s2c/EventPlayerMessage.java
new file mode 100644
index 0000000..f5273ac
--- /dev/null
+++ b/src/main/java/moe/ymc/acron/s2c/EventPlayerMessage.java
@@ -0,0 +1,10 @@
+package moe.ymc.acron.s2c;
+
+import com.google.gson.annotations.SerializedName;
+import org.jetbrains.annotations.NotNull;
+
+public record EventPlayerMessage(@SerializedName("player") @NotNull Entity player,
+ @SerializedName("text") @NotNull String text)
+ implements Event {
+
+}
diff --git a/src/main/java/moe/ymc/acron/s2c/EventQueue.java b/src/main/java/moe/ymc/acron/s2c/EventQueue.java
new file mode 100644
index 0000000..8c470a1
--- /dev/null
+++ b/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/src/main/java/moe/ymc/acron/serialization/Serializer.java b/src/main/java/moe/ymc/acron/serialization/Serializer.java
new file mode 100644
index 0000000..28c7e18
--- /dev/null
+++ b/src/main/java/moe/ymc/acron/serialization/Serializer.java
@@ -0,0 +1,54 @@
+package moe.ymc.acron.serialization;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+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.s2c.*;
+import org.jetbrains.annotations.NotNull;
+
+public class Serializer {
+ @NotNull
+ public static Request deserialize(@NotNull TextWebSocketFrame frame) {
+ 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(ReqSetConfig.Vec3d.class, new ReqSetConfig.Vec3d.Vec3dDeserializer())
+ .registerTypeAdapter(ReqSetConfig.Vec2f.class, new ReqSetConfig.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_output")
+ .registerSubtype(EventCmdRes.class, "cmd_result")
+ .registerSubtype(EventLagging.class, "lagging")
+ .registerSubtype(EventCmdDenied.class, "cmd_denied")
+ .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/src/main/resources/acron.mixins.json b/src/main/resources/acron.mixins.json
new file mode 100644
index 0000000..5d0911f
--- /dev/null
+++ b/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/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json
new file mode 100644
index 0000000..999aeeb
--- /dev/null
+++ b/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"
+ }
+}