Compare commits

..
75 Commits
Author SHA1 Message Date
Jones cc52289500 Release 2.0.14 (#149) 2024-01-06 14:55:30 +01:00
Jones ac1f6d616b Reword and simplify the README (#148) 2024-01-02 21:26:04 +01:00
Jones bdd941171e Use correct security contact email (#147) 2024-01-02 21:09:25 +01:00
Jones 1e18a29d1e Update license year to 2023-2024 (#146) 2024-01-02 18:17:20 +01:00
Jones 5671c2029e Change default color scheme in messages (#145) 2024-01-01 23:55:33 +01:00
Jones 32aeee4f06 Fix license link in README (#144) 2023-12-31 18:10:28 +01:00
Jones 7b5ce41420 Show server uptime in statistics (#143)
* style: small code clean up

* feat: add server uptime to statistics
2023-12-30 21:44:16 +01:00
Jones a66f60f177 Recode Velocity injection (#142)
* feat(bungee): implement empty hostname check

* feat: recode Velocity injection

* feat: recode action bar counters

* fix(bungee): make sure the connection is open
2023-12-30 21:24:53 +01:00
Jones 326a1ecc07 Release 2.0.13 (#140) 2023-12-30 15:57:53 +01:00
Jones 6af5a9fc4b Make sure the workflow uses Java 17 (#139) 2023-12-30 01:17:55 +01:00
Jones ac78d88721 Bump Velocity API from 3.2.0 to 3.3.0 (#138) 2023-12-30 01:12:27 +01:00
Jones 75abebde7c Release 2.0.12 (#136) 2023-12-25 14:24:08 +01:00
Jones 28b1615b78 Implement maximum age to verified players in database (#134) 2023-12-21 22:27:26 +01:00
Jones 600275669b Bump gradle from 8.4 to 8.5 (#133) 2023-12-20 14:41:21 +01:00
Jones 0c3761be11 Fix event priorities for some event listeners (#132)
* fix(bungee): use highest event priority for audience listener

* fix(bungee): use lowest event priority for fallback listener

* fix(bukkit): use highest event priority for audience listener
2023-12-19 16:36:13 +01:00
Jones b7d8ec74d0 Bump adventure from 4.14.0 to 4.15.0 (#131) 2023-12-19 16:26:24 +01:00
dependabot[bot] 96efa574e0 Bump netty-all from 4.1.101.Final to 4.1.104.Final (#130)
Bumps [io.netty:netty-all](https://github.com/netty/netty) from 4.1.101.Final to 4.1.104.Final.
- [Commits](https://github.com/netty/netty/compare/netty-4.1.101.Final...netty-4.1.104.Final)

---
updated-dependencies:
- dependency-name: io.netty:netty-all
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-19 15:49:58 +01:00
Jones 8db3d0257f Release 2.0.11 (#129) 2023-12-17 14:50:36 +01:00
Jones 106a44f78a Recode map captcha so it's harder for AI to solve (#128)
* feat: make map captcha harder for AI to solve

* feat: make distortions configurable

* fix: use correct distortions factor dividor

* fix: memory issues with map precomputing/preparing

* fix: characters sometimes ended up out of the frame

* fix: don't randomize x position of the character

* fix: font size issue

* feat: optimize FallbackChannelHandler

* feat: make captcha timing configurable

* fix: 1.7 and 1.8 clients dying because of my stupidity
2023-12-17 14:47:42 +01:00
Jones a5872f1b6e Fix Chat packet using the wrong packet id in 1.20.2 (#127) 2023-12-15 17:54:22 +01:00
Jones e9c212b9e4 Release 2.0.10 (#126) 2023-12-15 16:02:44 +01:00
Jones 42da141f2b Remove lockdown mode (#125)
* https://discord.com/channels/923308209769426994/1180500530896842832
2023-12-15 15:49:50 +01:00
Jones e0bc83f8ef Implement Minecraft 1.20.4 support (#124)
* feat: implement GameEvent packet

* fix: make Chat & Disconnect packet work on 1.20.3+

* fix: don't send ChunkData twice

* fix: invalid serverbound PositionLook packet id on 1.20.3+

* fix: remove custom read-timeout value

* fix: trigger channel read after Fallback packet decode

* fix: only expect 10 unique kick packets instead of 16

* fix: map item id mapping

* feat: 1.20.3 barrier block mappings
2023-12-15 15:44:14 +01:00
Jones 59822ef541 Optimize in-class performance during verification (#123)
* style: use Getter for the entire class instead of a single field

* style: reformat code using IntelliSense

* fix: optimize IP address connection ratelimiter
2023-12-07 21:16:20 +01:00
Jones 38fbfff896 Fix README badge line separator not displaying (#122) 2023-12-07 21:00:18 +01:00
Jones 6725975cf1 Add CodeFactor rating to README (#121)
* feat: add 1.20.3 protocol version to allowed protocols

* feat: update packet ids for 1.20.3

* chore: add code factor rating to README
2023-12-07 20:57:41 +01:00
Jones d59fd4b431 Avoid using custom exceptions in the decoder (#119) 2023-12-04 16:48:28 +01:00
Jones a76ca2f91d Add optional map captcha check to verification (#118)
* feat: change field type "encodedFlags" of Abilities packet to int

* feat: add MapData packet
feat: add SetSlot packet

* feat: implement map captcha state

* feat: [wip] implement simple map captcha

* feat: cache captcha spawn position packet

* feat: add a chat message when you verify and enter the captcha

* fix: Chat packet error on 1.7

* feat: make map prepared amount and dictionary customizable

* fix: SetSlot packet error on 1.7

* fix: make map data work on 1.7

* feat: randomize font size & font style

* fix: implement KeepAlive during captcha and add timer

* feat: add color and x/y randomization to map captcha

* feat: add green and red color palette

* feat: implement captcha validation

* fix: chat packet not working on 1.7-1.18.2

* fix: only draw important used pixels

* feat: optimize color palette

* feat: set map image dimensions to 86x86

* feat: set background color of image to white/light gray

* feat: recode map info preparing and map data packet sending

* fix: x/y offset for 1.8+ in map info

* fix: add prefix to translated messages for the map captcha

* feat: make some map captcha options changeable

* fix: set the default font size to the average (instead of max)

* fix: Chat packet not working on 1.17-1.18.1

* fix: only send DefaultSpawnPosition to >=1.18.2 clients

* fix: return if captcha is correct to prevent further code execution

* feat: cache dynamic DefaultSpawnPosition packet
2023-12-02 14:13:30 +01:00
Jones 30803ba6e4 Fix permanent "Loading terrain" screen on 1.18.2+ (#117)
* feat: remove unused Chat packet

* feat: set JoinGame entityId to 0

* fix: add DefaultSpawnPosition to fix "Loading terrain" screen

* fix: 1.20.2 barrier block state id
2023-11-28 17:30:12 +01:00
Jones 194232804b Remove unnecessary metadata in the compilation process (#116)
* build: exclude unnecessary version information

* build: remove unnecessary file timestamps
2023-11-25 16:48:28 +01:00
Jones 19c97b26b6 Relocate some packages of net.kyori (#115)
* build(deps): relocate cappuccino

* build(deps): relocate net.kyori (except for text and audience)
2023-11-25 16:23:31 +01:00
Jones d66719b3e0 Fix state checks in verification not being handled properly (#114) 2023-11-24 21:06:19 +01:00
Michel Elkenwaat 9226908002 style: add copyright header to UpdateChecker.java 2023-11-24 19:37:35 +01:00
Jones ac74f14dc0 Make RegistrySync packet use the implemented nbt writing method (#112) 2023-11-23 17:54:53 +01:00
Michel Elkenwaat 8f11a53b38 fix: use ByteBufOutputStream in an AutoClosable statement 2023-11-23 17:54:21 +01:00
Jones 0b4394624f Release 2.0.9 (#111) 2023-11-22 15:56:26 +01:00
Michel Elkenwaat d8f7e288e8 build(deps): bump gradle from 8.3 to 8.4 2023-11-21 18:18:35 +01:00
Jones 3f3411ca9d Implement nameless compound tag for 1.20.2+ chunks (#110) 2023-11-21 17:41:59 +01:00
Jones 14ae85384a Optimize verification by using different data types (#109)
* fix: optimize ConcurrentHashMap usage in Fallback

* feat: remove max-verifying-players option from config

* fix: use hashset for whitelist and blacklisted protocol ids

* fix: yaml configuration formatting error on empty collections
2023-11-16 16:58:30 +01:00
Jones 8204477eb6 Implement protocol version whitelist and blacklist (#108)
* feat: allow certain protocol Ids to be exempt from verification

* fix(bungee): run blacklist check even if verification is disabled

* fix: run Geyser check before creating a new GameProfile

* feat: the blacklist should cache the InetAddress and not a String

* fix: support IPv6 (and more) in all subcommand arguments

* feat: add blacklisted protocol versions
2023-11-16 16:39:35 +01:00
Jones 44754e5027 Use correct values for webhook attack statistics (#107) 2023-11-14 12:01:05 +01:00
dependabot[bot] 847936ed08 Bump io.netty:netty-all from 4.1.100.Final to 4.1.101.Final (#105)
Bumps [io.netty:netty-all](https://github.com/netty/netty) from 4.1.100.Final to 4.1.101.Final.
- [Commits](https://github.com/netty/netty/compare/netty-4.1.100.Final...netty-4.1.101.Final)

---
updated-dependencies:
- dependency-name: io.netty:netty-all
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-11-13 14:06:47 +01:00
Jones aa90a1fa22 Release 2.0.8 (#104) 2023-11-10 14:09:55 +01:00
Jones 9ac8447226 Fix Geyser exemption sometimes skipping Java players (#103) 2023-11-10 14:07:21 +01:00
Jones 6b6acb8371 Rename MySQL table to avoid naming collisions (#102)
* fix: rename MySQL table from "verified_players" to "sonar_verified_players"

* feat: change timestamp field type to datetime (sql timestamp)

* feat(db): allow empty passwords
2023-11-07 18:02:38 +01:00
Jones f3af903114 Fix MySQL driver issues on BungeeCord and Bukkit (#101)
* fix(bungee, bukkit): MySQL not loading correctly

* style: fix link to a credited class

* fix(db): don't log anything if connectionSource is null
2023-11-07 17:19:58 +01:00
Jones 67bd3786d7 Optimize some parts of the code (#100)
* build: simplify main build.gradle.kts

* build(deps): relocate org.simpleyaml

* reformat(all): code clean up using IntelliJ's automatic tool

* fix: optimize packet registry by removing unused statements

* fix: resolve some grammar mistakes in comments

* fix: optimize memory formatting by a bit
2023-11-07 16:40:50 +01:00
Jones 2b379b0dfa Fix NullPointerException in BungeeCord HandlerBoss on 1.20.2 (#98)
* fix: BungeeCord HandlerBoss only storing the channel wrapper locally, therefore, causing a NPE

* fix(bungee): mark channel as closed when it's inactive
2023-11-06 17:54:14 +01:00
Jones b81cc0e9f5 Compress NBT dimension codecs with GZIP (#97)
* feat: compress nbt dimension codecs with GZIP with snbt2nbt

* refactor: mappings -> codecs

* feat: remove credits since I'm using my own program the nbt codecs now
2023-11-04 23:51:49 +01:00
Jones 0efc6ae157 Recode some of the verification and improve networking (#96)
* feat: implement block state id from https://github.com/PrismarineJS/minecraft-data/pull/786

* feat: recode dimension nbt codec for JoinGame packet

* feat(fallback): recode gravity/block collisions check

* fix: remove unnecessary debug message

* fix: dynamicSpawnYPosition using a set Y buffer

* feat: refactor some verification-related configuration values

* fix: forEnumConstant() not saving default config values

* fix: remove Geyser characters from valid name regex

* feat: apply migration fix for every config string

* fix: don't do .toUpperCase() for every string

* feat: add more description to some enum config types

* fix: move "recommended" to listing in comment

* fix: remove mc association before queuing the connection

* refactor: closed -> knownDisconnect

* fix: incompatibility with latest BungeeCord

* refactor: fallbackPlayer -> user

* fix: BungeeCord kick message serialization not working
2023-11-04 22:19:54 +01:00
Jones 4ae14c58a6 Use a different verbose layout during active attacks (#95)
* feat: implement separate action bar during attack

* fix: logins per second not being displayed correctly

* feat: set action bar refresh delay to 200ms instead of 125ms
2023-11-04 15:43:38 +01:00
Jones 3bb752f435 Recode command and subcommand execution logic (#94) 2023-11-02 17:51:56 +01:00
Jones eeb9a446fb Run custom Sonar API events asynchronously (#93)
* feat: run all custom Sonar events asynchronously

* fix: handle potential errors within the event executor service
2023-11-02 17:31:30 +01:00
Jones dfe728f7a9 Implement a custom asynchronous service system (#92)
* feat: use own asynchronous service repository

* feat: also use a custom executor for the verbose handler
2023-11-02 17:20:39 +01:00
Jones 3945e813dc Allow verification to only be active during attacks (#90)
* refactor: AttackStatus -> AttackTracker

* feat: implement different verification timing types (always, never, during_attack)

* fix: invalid suppression warnings

* fix: don't collect total stats async as it could mess with the values
2023-11-02 16:55:24 +01:00
Jones 5068ebcd91 Release 2.0.7 (#89) 2023-11-01 13:48:08 +01:00
Jones f20d162dcc Fix Geyser exemption not working on the standalone variant (#88)
* Revert "https://github.com/jonesdevelopment/sonar/pull/87/commits/648489b01bafcaf711316228fac6d93eac5eba2c"

* fix: avoid unnecessary performance loss by removing the floodgate check

* fix: move UserVerifyFailedEvent publishing after channel active check

* fix: make UserVerifyFailedEvent reason optional

* fix: use final field for UserVerifyFailedEvent reason

* fix: use channel attribute to detect floodgate players
2023-10-31 15:49:01 +01:00
Jones acc9df0b8d Recode attack detection algorithm to avoid spam (#87)
* feat: use floodgate API to determine if a player is joining through Geyser or not

* fix: normal floodgate players not being detected

* fix: Discord webhook spam

* feat: update value for minimum players for attack

* feat: make minimum attack duration configurable

* feat: implement attack start and stop thresholds

* fix: discord webhook embed description not being reusable

* feat: recode Discord webhook post timing algorithm
2023-10-28 22:37:20 +02:00
Jones 3636674eff Implement Discord webhook notifications (#86)
* fix: possible (unconfirmed) concurrency issues

* fix: don't ignore auto-closeable in UpdateChecker

* feat: [wip] add Discord Webhook support

* feat: recode attack detection system
feat: add AttackDetectedEvent, AttackMitigatedEvent
feat: allow more control over attack detection behavior

* fix: run most of the webhook attack handling async

* fix: reset discord webhook if they are disabled after reload

* fix: don't log too-short attacks

* fix: duration formatting showing milliseconds

* feat: implement webhook embed message footer

* fix: change misleading configuration comment
2023-10-28 15:04:44 +02:00
Jones d2b3b0adae Handle database errors instead of just throwing exceptions (#84)
* fix(db): handle the error instead of simply throwing an exception

* feat(db): revert support for multiple dependencies being loaded at once
2023-10-28 12:02:35 +02:00
Jones 194c7645ed Cache BungeeCord kick packets during verification (#82) 2023-10-28 01:32:46 +02:00
Jones 860594400a Implement max-online-per-ip check on BungeeCord (#81)
* feat(bungee): implement missing max online per ip check

* fix(bungee): use correct component serialization for the lockdown notification message

* fix(bungee): use correct disconnect message for max online per ip check

* fix(bungee): add 1 to current online per ip count because the player joining isn't counted

* fix(bungee): use adventure audience for sending the message
2023-10-28 01:20:25 +02:00
Jones 20f5b135aa Fix lockdown notification not working as intended (#80)
* fix: lockdown notification kicking administrators

* fix: lockdown notification not being deserialized properly
2023-10-28 01:04:01 +02:00
Jones ef31dd4c87 Release 2.0.6 (#77) 2023-10-25 17:42:59 +02:00
Jones 8361f1990d Implement BungeeCord support (#76)
* feat: remove Velocity's (de-)compression dependency

* feat: [wip] implement Fallback for BungeeCord

* feat: disable legacy pipelines by default (can be enabled again)

* fix(bungee): NPE on handshake

* fix: use correct protocol version for channel initialization

* feat: implement login packet spam fix

* feat(bungee): implement duplicate login/status packet spam fix

* feat: implement cleaner FallbackHandlerBoss

* fix(bungee): traffic handler not being injected properly

* fix(bungee): run verification check after running everything in the event loop

* fix(bungee): only throw exceptions for up- and downstream bridge

* fix(bungee): total traffic statistic not working

* fix(bukkit, bungee): logger wrapper not supporting arguments

* fix(bungee): perform the already-online check by the username, not the uuid

* style: fix FallbackChannelHandler comments

* style: don't annotate FallbackUserWrapper as EqualsAndHashCode

* style: fix ToString annotation in EmptyChunkData

* feat(fallback): remove 1.19/1.19.1 chat nbt mappings

* feat: use expiring cache for joins per second counter

* fix(bungee): don't log the join attempt if the login is canceled

* fix(all): lockdown mode not being saved correctly

* fix(bungee): closeWith method only working during verification

* fix(bungee): use Velocity's server write mark

* fix(bungee): don't set BB allocator if it's already set
2023-10-22 14:32:37 +02:00
Jones bf9e77a437 Implement better adventure audience system (#75)
* feat: recode verbose handler by implementing per-platform audiences

* feat: remove /sonar verbose [other player]

* feat: remove ServerWrapper and replace it with the platform

* feat: reset action bar when unsubscribing from verbose

* feat: recode tab suggestions and help message caching

* feat: make Sonar main command translatable/configurable

* feat: don't cache copyright year

* fix: tab suggestions breaking after the first iteration

* feat: run only-players and require-console checks in the Subcommand class to remove duplicate code

* fix(all): replace instance check with condition check

* style: improve codestyle in YAML configuration loader

* fix: argumentsRequired property not working at all

* style: small codestyle improvements and comment fixes
2023-10-21 12:11:23 +02:00
Jones 7f604dd4ad Release 2.0.5 (#74) 2023-10-20 17:41:16 +02:00
Jones ab96929070 Fix EmptyChunkData packet being broken on 1.17+ (#73) 2023-10-20 17:36:43 +02:00
Michel Elkenwaat fa20826b8e Merge remote-tracking branch 'origin/main' 2023-10-20 17:22:25 +02:00
Michel Elkenwaat c8c212a3ee Fix issues with the plugin description (#69) 2023-10-20 17:22:08 +02:00
Michel Elkenwaat ad7d3e62eb Fix issues with the plugin description https://github.com/jonesdevelopment/sonar/pull/69 2023-10-20 17:21:30 +02:00
Ismael 343d71d18c Fix incompatibilities with Gradle 7.x (#69)
Fix "java.lang.IllegalStateException: Module entity with name: sonar.bukkit should be available"
2023-10-20 16:38:52 +02:00
Jones a2e252aece Use a better conversion technique to improve update checker (#72) 2023-10-20 15:09:31 +02:00
Jones 124bed86dc Fix invalid root tag in chunk data packet (#70)
* fix: use Velocity's JoinGame packet for consistency (https://github.com/jonesdevelopment/sonar/issues/68)

* fix: incorrect motion blocking root tag in EmptyChunkData packet (https://github.com/jonesdevelopment/sonar/issues/68)
2023-10-20 14:19:53 +02:00
Jones 971d43df00 Recode IPv4 regex pattern check (#67) 2023-10-19 17:03:52 +02:00
161 changed files with 4355 additions and 3115 deletions

No files matched your search

+16 -25
View File
@@ -9,6 +9,7 @@
<!-- Badges & icons -->
[![](https://github.com/jonesdevelopment/sonar/actions/workflows/gradle.yml/badge.svg)](https://github.com/jonesdevelopment/sonar/actions/workflows/gradle.yml)
[![](https://www.codefactor.io/repository/github/jonesdevelopment/sonar/badge/main)](https://www.codefactor.io/repository/github/jonesdevelopment/sonar/overview/main)
[![](https://img.shields.io/github/v/release/jonesdevelopment/sonar)](https://github.com/jonesdevelopment/sonar/releases)
[![](https://img.shields.io/github/issues/jonesdevelopment/sonar)](https://github.com/jonesdevelopment/sonar/issues)
[![](https://img.shields.io/discord/923308209769426994.svg?logo=discord)](https://jonesdev.xyz/discord)
@@ -24,7 +25,7 @@
|
[Discord](https://jonesdev.xyz/discord)
|
[License](https://github.com/jonesdevelopment/sonar/blob/main/README.md#license)
[License](https://github.com/jonesdevelopment/sonar/?tab=readme-ov-file#license)
</div>
## Design and Goal
@@ -32,27 +33,25 @@
* Effective, lightweight, and easy-to-use
* No unnecessary features and clean code
* Protection against all kinds of bot attacks
* No player should be annoyed by any sort of captcha
* No player should be annoyed by any sort of [CAPTCHA](https://en.wikipedia.org/wiki/CAPTCHA)
* No sort of checking for VPNs or proxies
* Multi-platform support (Velocity, BungeeCord, and Bukkit)
* Multi-platform support (See [supported versions](https://docs.jonesdev.xyz/sonar/supported-versions))
## Checks
- New player logins are queued to prevent spam login attacks.
- [Fallback](https://github.com/jonesdevelopment/sonar#fallback) is Sonar's main component designed to prevent
all types of bots.
- Spigot & BungeeCord
- The compression method is changed
to [Velocity's libdeflate](https://github.com/PaperMC/Velocity/tree/dev/3.0.0/native).
- [Fallback](https://github.com/jonesdevelopment/sonar#fallback) is Sonar's main anti-bot component
- Sonar queues new player logins to prevent spam login attacks
- Sonar checks the handshake packets for legitimacy
- Protective components only on BungeeCord:
- Sonar validates the login/server ping packets
- Sonar handles all server pings asynchronously
- The Varint decoder is updated to Velocity's
improved [MinecraftVarintFrameDecoder](https://github.com/PaperMC/Velocity/blob/dev/3.0.0/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/MinecraftVarintFrameDecoder.java).
### Fallback
Fallback analyzes a player's behavior before joining the actual server, therefore stopping malicious traffic from ever
reaching the backend.
It is supposed to be an instant, powerful, and invisible method of verification that should prevent all typical and
advanced types of bots.
Fallback analyzes a player's behavior before joining the actual server, therefore stopping malicious traffic from ever reaching the backend.
It is supposed to be an instant, powerful, and invisible method of verification that should prevent all typical and advanced types of bots.
* Sends the player to a lightweight fake server when they connect for the first time.
* Analyzes if the player is sending the necessary packets.
@@ -60,16 +59,11 @@ advanced types of bots.
* Checks if the player is obeying client gravity.
* Checks if the player is colliding with blocks correctly.
Fallback also protects from huge spambot attacks since it queues the incoming connections, therefore making it
technically impossible to have a ton of bots join the server at the same time.
Fallback also protects against huge spambot attacks since it queues the incoming connections, therefore making it technically impossible to have a ton of bots join the server at the same time.
### False positives
Fallback is unlikely to ever falsely prevent a player from joining the server since Minecraft uses the TCP protocol which means that packets are always sent in the correct order. Therefore, lag or ping should not affect the bot check.
However, there are some edge cases where Fallback might not receive packets within the necessary time period. In this case, Sonar tries to account for these edge cases in order to prevent false positives. For example, some higher Minecraft versions have a bug where the client sometimes sends a packet out of order. Sonar accounts for that and does not falsely blacklist clients.
If you or one of your players experiences a false positive, make sure to report it by opening
a [GitHub issue](https://github.com/jonesdevelopment/sonar/issues) or a ticket
on [Discord](https://jonesdev.xyz/discord/).
Fallback is unlikely to ever falsely prevent a player from joining the server since Minecraft uses the TCP protocol, which means that packets are always sent in the correct order. Therefore, lag or ping should not affect the bot check. However, there are some edge cases where Fallback might not receive packets within the necessary time period. In this case, Sonar tries to account for these edge cases in order to prevent false positives. If you or one of your players experiences a false positive, make sure to report it by opening a [GitHub issue](https://github.com/jonesdevelopment/sonar/issues) or a ticket on [Discord](https://jonesdev.xyz/discord/).
## Building
@@ -79,9 +73,7 @@ You can also take a look at the [gradle documentation](https://docs.gradle.org/c
## Contributing
If you are interested in contributing, you can check out
the [Contributing Guidelines](https://github.com/jonesdevelopment/sonar/blob/main/.github/CONTRIBUTING.md) for detailed
instructions.
If you are interested in contributing, you can check out the [Contributing Guidelines](https://github.com/jonesdevelopment/sonar/blob/main/.github/CONTRIBUTING.md) for detailed instructions.
## License
@@ -90,5 +82,4 @@ Sonar is licensed under the [GNU General Public License 3.0](https://www.gnu.org
## Credits
- Special thanks to the [contributors of Sonar](https://github.com/jonesdevelopment/sonar/graphs/contributors).
- The nbt mappings were taken from [LimboAPI](https://github.com/Elytrium/LimboAPI).
- The compression and Varint decoding was taken from [Velocity](https://github.com/PaperMC/Velocity).
- The Varint decoding was taken from [Velocity](https://github.com/PaperMC/Velocity).
+3 -7
View File
@@ -13,13 +13,9 @@ If you discover a security vulnerability within Sonar, please follow these steps
## Contact
If you have any questions or concerns regarding the security of this project, please open a ticket on Discord or contact me via email at admin@jonesdev.xyz.
If you have any questions or concerns regarding the security of this project, please open a ticket on Discord or contact me via email at contact@jonesdev.xyz.
## Acknowledgments
Special thanks to the following people for their contributions:
- [xism4](https://github.com/xism4)
- fixed serious issues with packet decode logic on BungeeCord [#5](https://github.com/jonesdevelopment/sonar-legacy/pull/5) (Severity: medium)
- fixed a false blacklisting issue when a player looses connection [#9](https://github.com/jonesdevelopment/sonar-legacy/pull/9) (Severity: low)
- [NoGodHenry](https://github.com/NoGodHenry)
- fixed a ConcurrentModificationException in the configuration [#3](https://github.com/jonesdevelopment/sonar-legacy/pull/3) (Severity: low)
- Special thanks to the [contributors of Sonar](https://github.com/jonesdevelopment/sonar/graphs/contributors),
and also those who reported security issues or bugs.
+2 -2
View File
@@ -22,10 +22,10 @@ jobs:
steps:
- uses: actions/checkout@v3
- name: Set up JDK 11
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '11'
java-version: '17'
distribution: 'temurin'
- name: Build with Gradle
uses: gradle/gradle-build-action@bd5760595778326ba7f1441bcf7e88b49de61a25 # v2.6.0
+18 -8
View File
@@ -22,12 +22,9 @@ allprojects {
repositories {
mavenCentral() // Lombok
maven(url = "https://jitpack.io") // simple-yaml
maven(url = "https://repo.papermc.io/repository/maven-public") // Velocity natives
maven(url = "https://repo.jonesdev.xyz/releases/") // Bungee & Velocity proxy module
}
}
subprojects {
apply(plugin = "java")
apply(plugin = "com.github.johnrengelman.shadow")
@@ -38,7 +35,7 @@ subprojects {
testCompileOnly("org.projectlombok:lombok:1.18.30")
testAnnotationProcessor("org.projectlombok:lombok:1.18.30")
val adventureVersion = "4.14.0"
val adventureVersion = "4.15.0"
// adventure
implementation("net.kyori:adventure-text-minimessage:$adventureVersion")
@@ -49,7 +46,7 @@ subprojects {
implementation("com.j256.ormlite:ormlite-jdbc:6.1") // ORM
implementation("xyz.jonesdev:cappuccino:0.1.6-SNAPSHOT") // expiring cache
compileOnly("io.netty:netty-all:4.1.100.Final") // netty
compileOnly("io.netty:netty-all:4.1.104.Final") // netty
}
}
@@ -72,7 +69,10 @@ tasks {
shadowJar {
// Set the file name of the shadowed jar
archiveFileName.set("${rootProject.name}.jar")
archiveFileName.set("Sonar.jar")
// Remove file timestamps
isPreserveFileTimestamps = false
// bStats has to be relocated to the Sonar package otherwise it throws an exception
// https://github.com/Bastian/bstats-metrics/blob/master/base/src/main/java/org/bstats/MetricsBase.java#L251
@@ -80,10 +80,20 @@ tasks {
// https://github.com/jonesdevelopment/sonar/issues/46
// Relocate some packages, so we don't run into issues where we accidentally use Velocity's classes
relocate("net.kyori.adventure.nbt", "xyz.jonesdev.sonar.libs.nbt")
relocate("org.simpleyaml", "xyz.jonesdev.sonar.libs.yaml")
relocate("com.google.gson", "xyz.jonesdev.sonar.libs.gson")
relocate("com.j256.ormlite", "xyz.jonesdev.sonar.libs.ormlite")
relocate("com.velocitypowered.natives", "xyz.jonesdev.sonar.libs.natives")
relocate("xyz.jonesdev.cappuccino", "xyz.jonesdev.sonar.libs.cappuccino")
relocate("net.kyori.examination", "xyz.jonesdev.sonar.libs.examination")
relocate("net.kyori.option", "xyz.jonesdev.sonar.libs.option")
// We have to be careful here, so we don't accidentally break adventure
relocate("net.kyori.adventure", "xyz.jonesdev.sonar.libs.adventure") {
exclude("net.kyori.adventure.text.**")
exclude("net.kyori.adventure.audience.**")
}
// Exclude unnecessary metadata information
exclude("META-INF/versions/**")
}
compileJava {
+2 -1
View File
@@ -1,2 +1,3 @@
group=xyz.jonesdev.sonar
version=2.0.4
version=2.0.14
description=Effective Anti-bot plugin for Velocity, BungeeCord, and Bukkit (1.7-latest)
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
+1 -1
View File
@@ -1,4 +1,4 @@
rootProject.name = "Sonar"
rootProject.name = "sonar"
sequenceOf("api", "bukkit", "bungee", "common", "velocity").forEach {
val path = "sonar-$it"
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -17,14 +17,17 @@
package xyz.jonesdev.sonar.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.attack.AttackTracker;
import xyz.jonesdev.sonar.api.command.subcommand.SubcommandRegistry;
import xyz.jonesdev.sonar.api.config.SonarConfiguration;
import xyz.jonesdev.sonar.api.controller.VerifiedPlayerController;
import xyz.jonesdev.sonar.api.event.SonarEventManager;
import xyz.jonesdev.sonar.api.fallback.Fallback;
import xyz.jonesdev.sonar.api.logger.LoggerWrapper;
import xyz.jonesdev.sonar.api.server.ServerWrapper;
import xyz.jonesdev.sonar.api.timer.SystemTimer;
import xyz.jonesdev.sonar.api.verbose.Verbose;
import java.io.File;
@@ -34,10 +37,14 @@ public interface Sonar {
DecimalFormat DECIMAL_FORMAT = new DecimalFormat("#,###.##");
String LINE_SEPARATOR = "\n"; // Using System.lineSeparator is broken, for some reason...
Gson GENERAL_GSON = new GsonBuilder()
.disableInnerClassSerialization()
.create();
/**
* @return A small wrapper for the server
* @return The platform the plugin is being run on
*/
@NotNull ServerWrapper getServer();
@NotNull SonarPlatform getPlatform();
/**
* @return A small wrapper for the plugin logger so we can use the logger everywhere
@@ -54,6 +61,8 @@ public interface Sonar {
@NotNull Verbose getVerboseHandler();
@NotNull SystemTimer getLaunchTimer();
/**
* Set a custom verbose handler
*/
@@ -76,6 +85,12 @@ public interface Sonar {
return SonarEventManager.INSTANCE;
}
@NotNull
default AttackTracker getAttackTracker() {
// We don't want anyone else to create a new instance.
return AttackTracker.INSTANCE;
}
@NotNull
default Fallback getFallback() {
// We don't want anyone else to create a new instance.
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -0,0 +1,157 @@
/*
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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 3 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, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.api.attack;
import lombok.*;
import org.jetbrains.annotations.Nullable;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.config.SonarConfiguration;
import xyz.jonesdev.sonar.api.event.impl.AttackDetectedEvent;
import xyz.jonesdev.sonar.api.event.impl.AttackMitigatedEvent;
import xyz.jonesdev.sonar.api.profiler.JVMProfiler;
import xyz.jonesdev.sonar.api.statistics.Counters;
import xyz.jonesdev.sonar.api.statistics.Statistics;
import xyz.jonesdev.sonar.api.timer.SystemTimer;
import java.util.Optional;
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class AttackTracker implements JVMProfiler {
public static final AttackTracker INSTANCE = new AttackTracker();
private @Nullable AttackStatistics currentAttack;
private int attackThreshold;
@Getter
@ToString
@RequiredArgsConstructor
public static final class AttackStatistics {
private final SystemTimer duration = new SystemTimer();
private final SystemTimer timer = new SystemTimer();
private int peakJoinsPerSecond;
private int peakConnectionsPerSecond;
private double peakProcessCPUUsage;
private long peakProcessMemoryUsage;
// Calculate during-attack-statistics using their deltas
private int successfulVerifications, failedVerifications;
}
public void checkIfUnderAttack() {
final int joinsPerSecond = Counters.LOGINS_PER_SECOND.estimatedSize();
final int verifyingPlayers = Sonar.get().getFallback().getConnected().size();
final int queuedPlayers = Sonar.get().getFallback().getQueue().getQueuedPlayers().size();
final int minPlayers = Sonar.get().getConfig().getMinPlayersForAttack();
if (joinsPerSecond > minPlayers // Check the number of bots/joins per second.
|| verifyingPlayers > minPlayers // Check the number of verifying players.
|| queuedPlayers > minPlayers) { // Check the number of queued players.
// Increment attack threshold
++attackThreshold;
// An attack has been detected
if (currentAttack == null) {
currentAttack = new AttackStatistics();
currentAttack.successfulVerifications = Sonar.get().getVerifiedPlayerController().estimatedSize();
currentAttack.failedVerifications = Statistics.FAILED_VERIFICATIONS.get();
Sonar.get().getEventManager().publish(new AttackDetectedEvent());
} else {
// Reset attack timer if we're still under attack
currentAttack.timer.reset();
}
// Update attack statistics
if (joinsPerSecond > currentAttack.peakJoinsPerSecond) {
// Update joins per second peak if necessary
currentAttack.peakJoinsPerSecond = joinsPerSecond;
}
final int connectionsPerSecond = Counters.CONNECTIONS_PER_SECOND.estimatedSize();
if (connectionsPerSecond > currentAttack.peakConnectionsPerSecond) {
// Update connections per second peak if necessary
currentAttack.peakConnectionsPerSecond = connectionsPerSecond;
}
final double processCPUUsage = getProcessCPUUsage();
if (processCPUUsage > currentAttack.peakProcessCPUUsage) {
// Update cpu usage peak if necessary
currentAttack.peakProcessCPUUsage = processCPUUsage;
}
final long processMemoryUsage = getUsedMemory();
if (processMemoryUsage > currentAttack.peakProcessMemoryUsage) {
// Update memory consumption peak if necessary
currentAttack.peakProcessMemoryUsage = processMemoryUsage;
}
} else if (currentAttack != null) {
if (currentAttack.duration.delay() > Sonar.get().getConfig().getMinAttackDuration()
&& currentAttack.timer.delay() > Sonar.get().getConfig().getAttackCooldownDelay()) {
// The current attack has stopped
Sonar.get().getEventManager().publish(new AttackMitigatedEvent(currentAttack));
if (++attackThreshold > Sonar.get().getConfig().getMinAttackThreshold()) {
// Save attack statistics
final long deltaInMillis = currentAttack.duration.delay();
final String peakCPU = Sonar.DECIMAL_FORMAT.format(currentAttack.peakProcessCPUUsage);
final String peakMem = formatMemory(currentAttack.peakProcessMemoryUsage);
final String peakBPS = Sonar.DECIMAL_FORMAT.format(currentAttack.peakJoinsPerSecond);
final String peakCPS = Sonar.DECIMAL_FORMAT.format(currentAttack.peakConnectionsPerSecond);
final long minutes = deltaInMillis / (60 * 1000); // Convert milliseconds to minutes
final double seconds = (deltaInMillis % (60 * 1000)) / 1000D; // Convert remaining milliseconds to seconds
final String formattedDuration = String.format("%d minutes, %.0f seconds", minutes, seconds);
final String startTimestamp = String.valueOf(currentAttack.duration.getStart() / 1000L);
final String endTimestamp = String.valueOf(System.currentTimeMillis() / 1000L);
final long blacklisted = Sonar.get().getFallback().getBlacklisted().estimatedSize();
// Calculate during-attack-statistics using their deltas
final long totalVerified = Sonar.get().getVerifiedPlayerController().estimatedSize();
final long verified = Math.max(totalVerified - currentAttack.successfulVerifications, 0);
final long totalFailed = Statistics.FAILED_VERIFICATIONS.get();
final long failed = Math.max(totalFailed - currentAttack.failedVerifications, 0);
// Post webhook to Discord
Optional.ofNullable(Sonar.get().getConfig().getDiscordWebhook()).ifPresent(webhook -> webhook.post(() -> {
final SonarConfiguration.Webhook.Embed config = Sonar.get().getConfig().getWebhook().getEmbed();
final String description = config.getDescription()
.replace("%start-timestamp%", startTimestamp)
.replace("%end-timestamp%", endTimestamp)
.replace("%duration%", formattedDuration)
.replace("%peak-cpu%", peakCPU)
.replace("%peak-memory%", peakMem)
.replace("%peak-bps%", peakBPS)
.replace("%peak-cps%", peakCPS)
.replace("%total-blacklisted%", Sonar.DECIMAL_FORMAT.format(blacklisted))
.replace("%total-failed%", Sonar.DECIMAL_FORMAT.format(failed))
.replace("%total-success%", Sonar.DECIMAL_FORMAT.format(verified));
return new SonarConfiguration.Webhook.Embed(
config.getTitle(), config.getTitleUrl(), description, config.getR(), config.getG(), config.getB());
}));
}
// Reset the attack status
currentAttack = null;
// Reset threshold
attackThreshold = 0;
}
} else {
// Reset threshold
attackThreshold = 0;
}
}
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
public boolean isCurrentlyUnderAttack() {
final int minDelay = Sonar.get().getConfig().getAttackCooldownDelay();
return currentAttack != null && currentAttack.timer.delay() < minDelay;
}
}
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -19,12 +19,14 @@ package xyz.jonesdev.sonar.api.command;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.ToString;
import xyz.jonesdev.sonar.api.command.subcommand.Subcommand;
@Getter
@RequiredArgsConstructor
@ToString(of = "rawArguments")
public final class CommandInvocation {
private final InvocationSource sender;
private final Subcommand command;
private final Subcommand subcommand;
private final String[] rawArguments;
}
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -23,11 +23,15 @@ import net.kyori.adventure.audience.Audience;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.minimessage.MiniMessage;
import java.util.function.Predicate;
@Getter
@RequiredArgsConstructor
public abstract class InvocationSource {
private final String name;
private final Audience audience;
private final boolean player;
private final Predicate<String> permissionFunction;
/**
* Sends an empty chat message to the command executor
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -18,14 +18,18 @@
package xyz.jonesdev.sonar.api.command;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.event.HoverEvent;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.minimessage.MiniMessage;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.cappuccino.Cappuccino;
import xyz.jonesdev.cappuccino.ExpiringCache;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.command.subcommand.Subcommand;
import xyz.jonesdev.sonar.api.command.subcommand.argument.Argument;
import java.util.*;
import java.util.stream.Collectors;
import static java.util.Collections.emptyList;
public interface SonarCommand {
List<String> TAB_SUGGESTIONS = new ArrayList<>();
@@ -34,63 +38,114 @@ public interface SonarCommand {
ExpiringCache<Object> DELAY = Cappuccino.buildExpiring(500L);
int COPYRIGHT_YEAR = Calendar.getInstance().get(Calendar.YEAR);
List<Component> CACHED_HELP_MESSAGE = new ArrayList<>();
List<Component> CACHED_HELP_MESSAGE = new Vector<>();
default void cacheHelpMessage() {
CACHED_HELP_MESSAGE.addAll(Arrays.asList(
Component.text("Running Sonar " + Sonar.get().getVersion()
+ " on " + Sonar.get().getServer().getPlatform().getDisplayName()
+ ".", NamedTextColor.YELLOW),
Component.text("(C) " + COPYRIGHT_YEAR + " Jones Development and Sonar Contributors", NamedTextColor.YELLOW),
Component.text("https://github.com/jonesdevelopment/sonar", NamedTextColor.GREEN)
.clickEvent(ClickEvent.clickEvent(ClickEvent.Action.OPEN_URL, "https://github.com/jonesdevelopment/sonar")),
Component.empty(),
Component.text("Need help or have any questions?", NamedTextColor.YELLOW),
Component.textOfChildren(
Component.text("Open a ticket on the Discord ", NamedTextColor.YELLOW)
.hoverEvent(HoverEvent.hoverEvent(HoverEvent.Action.SHOW_TEXT, Component.text("(Click to open Discord)")))
.clickEvent(ClickEvent.clickEvent(ClickEvent.Action.OPEN_URL, "https://jonesdev.xyz/discord/")),
Component.text("or open a new issue on GitHub.", NamedTextColor.YELLOW)
.hoverEvent(HoverEvent.hoverEvent(HoverEvent.Action.SHOW_TEXT, Component.text("(Click to open GitHub)")))
.clickEvent(ClickEvent.clickEvent(ClickEvent.Action.OPEN_URL, "https://github.com/jonesdevelopment/sonar" +
"/issues"))
),
Component.empty()
));
Sonar.get().getSubcommandRegistry().getSubcommands().forEach(sub -> {
Component component = Component.textOfChildren(
Component.text(" ▪ ", NamedTextColor.GRAY),
Component.text("/sonar " + sub.getInfo().name(), NamedTextColor.GREEN),
Component.text(" - ", NamedTextColor.GRAY),
Component.text(sub.getInfo().description(), NamedTextColor.WHITE)
);
Component hoverComponent = Component.textOfChildren(
Component.text("Only players: ", NamedTextColor.GRAY),
Component.text(sub.getInfo().onlyPlayers() ? "✔" : "✗",
sub.getInfo().onlyPlayers() ? NamedTextColor.GREEN : NamedTextColor.RED),
Component.newline(),
Component.text("Require console: ", NamedTextColor.GRAY),
Component.text(sub.getInfo().onlyConsole() ? "✔" : "✗",
sub.getInfo().onlyConsole() ? NamedTextColor.GREEN : NamedTextColor.RED),
Component.newline(),
Component.text("Permission: ", NamedTextColor.GRAY),
Component.text(sub.getPermission(), NamedTextColor.WHITE)
);
if (sub.getInfo().aliases().length > 0) {
hoverComponent = hoverComponent
.append(Component.newline())
.append(Component.text("Aliases: ", NamedTextColor.GRAY))
.append(Component.text(sub.getAliases(), NamedTextColor.WHITE));
default void handle(final @NotNull InvocationSource source, final String[] args) {
if (source.isPlayer()) {
// Check if the player actually has the permission to run the command
if (!source.getPermissionFunction().test("sonar.command")) {
source.sendMessage(Sonar.get().getConfig().getNoPermission());
return;
}
component = component
.clickEvent(ClickEvent.clickEvent(ClickEvent.Action.SUGGEST_COMMAND,
"/sonar " + sub.getInfo().name() + " "))
.hoverEvent(HoverEvent.hoverEvent(HoverEvent.Action.SHOW_TEXT, hoverComponent));
CACHED_HELP_MESSAGE.add(component);
// Checking if it contains will only break more since it can throw
// a NullPointerException if the cache is being accessed from parallel threads
DELAY.cleanUp(); // Clean up the cache
final long mapTimestamp = DELAY.asMap().getOrDefault(source, -1L);
// There were some exploits with spamming commands in the past.
// Spamming should be prevented, especially if some heavy operations are done,
// which is not the case here but let's still stay safe!
if (mapTimestamp > 0L) {
source.sendMessage(Sonar.get().getConfig().getCommands().getCommandCoolDown());
// Format delay
final long timestamp = System.currentTimeMillis();
final double left = 0.5D - (timestamp - mapTimestamp) / 1000D;
source.sendMessage(Sonar.get().getConfig().getCommands().getCommandCoolDownLeft()
.replace("%time-left%", Sonar.DECIMAL_FORMAT.format(left)));
return;
}
DELAY.put(source);
}
if (args.length > 0) {
// Search subcommand if command arguments are present
final Optional<Subcommand> subcommand = Sonar.get().getSubcommandRegistry().getSubcommands().stream()
.filter(sub -> sub.getInfo().name().equalsIgnoreCase(args[0])
|| Arrays.stream(sub.getInfo().aliases())
.anyMatch(alias -> alias.equalsIgnoreCase(args[0])))
.findFirst();
// Check permissions for subcommands
if (subcommand.isPresent()) {
if (!subcommand.get().getInfo().onlyConsole()
&& !source.getPermissionFunction().test(subcommand.get().getPermission())) {
source.sendMessage(Sonar.get().getConfig().getCommands().getSubCommandNoPerm()
.replace("%permission%", subcommand.get().getPermission()));
return;
}
subcommand.get().invoke(source, args);
return;
}
}
// Re-use the old, cached help message since we don't want to scan
// for each subcommand and it's arguments/attributes every time
// someone runs /sonar since the subcommand don't change
for (final Component component : CACHED_HELP_MESSAGE) {
source.sendMessage(component);
}
}
static void prepareCachedMessages() {
// Cache help message
CACHED_HELP_MESSAGE.clear();
for (final String message : Sonar.get().getConfig().getCommands().getHelpHeader()) {
CACHED_HELP_MESSAGE.add(MiniMessage.miniMessage().deserialize(message
.replace("%version%", Sonar.get().getVersion().getFormatted())
.replace("%platform%", Sonar.get().getPlatform().getDisplayName())
.replace("%copyright_year%", String.valueOf(Calendar.getInstance().get(Calendar.YEAR)))));
}
final String subcommandFormat = Sonar.get().getConfig().getCommands().getHelpSubcommands();
Sonar.get().getSubcommandRegistry().getSubcommands().forEach(subcommand -> {
final Component deserialized = MiniMessage.miniMessage().deserialize(subcommandFormat
.replace("%subcommand%", subcommand.getInfo().name())
.replace("%description%", subcommand.getInfo().description())
.replace("%only_players%", subcommand.getInfo().onlyPlayers() ? "<green>✔</green>" : "<red>✗</red>")
.replace("%require_console%", subcommand.getInfo().onlyConsole() ? "<green>✔</green>" : "<red>✗</red>")
.replace("%permission%", subcommand.getPermission())
.replace("%aliases%", subcommand.getAliases()));
CACHED_HELP_MESSAGE.add(deserialized);
});
// Don't re-cache tab suggestions
if (!TAB_SUGGESTIONS.isEmpty()) return;
// Cache tab suggestions
for (final Subcommand subcommand : Sonar.get().getSubcommandRegistry().getSubcommands()) {
TAB_SUGGESTIONS.add(subcommand.getInfo().name());
if (subcommand.getInfo().aliases().length > 0) {
TAB_SUGGESTIONS.addAll(Arrays.asList(subcommand.getInfo().aliases()));
}
final List<String> parsedArguments = Arrays.stream(subcommand.getInfo().arguments())
.map(Argument::value)
.collect(Collectors.toList());
ARG_TAB_SUGGESTIONS.put(subcommand.getInfo().name(), parsedArguments);
for (final String alias : subcommand.getInfo().aliases()) {
ARG_TAB_SUGGESTIONS.put(alias, parsedArguments);
}
}
}
default List<String> getCachedTabSuggestions(final String @NotNull [] arguments) {
if (arguments.length <= 1) {
return TAB_SUGGESTIONS;
} else if (arguments.length == 2) {
final String subCommandName = arguments[0].toLowerCase();
return ARG_TAB_SUGGESTIONS.getOrDefault(subCommandName, emptyList());
}
return emptyList();
}
}
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -28,17 +28,14 @@ import xyz.jonesdev.sonar.api.command.subcommand.argument.Argument;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.Arrays;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@Getter
public abstract class Subcommand {
private final @NotNull SubcommandInfo info;
private final String permission, aliases, arguments;
protected static final Sonar SONAR = Sonar.get();
protected static final Pattern IP_PATTERN = Pattern.compile(
"^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])([.])){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$"
);
public Subcommand() {
info = getClass().getAnnotation(SubcommandInfo.class);
@@ -52,12 +49,7 @@ public abstract class Subcommand {
.collect(Collectors.joining(", "));
}
protected static @Nullable InetAddress checkIP(final InvocationSource source, final String rawIP) {
if (!IP_PATTERN.matcher(rawIP).matches()) {
source.sendMessage(SONAR.getConfig().getCommands().getIncorrectIpAddress());
return null;
}
protected static @Nullable InetAddress getInetAddressIfValid(final InvocationSource source, final String rawIP) {
final InetAddress inetAddress;
try {
inetAddress = InetAddress.getByName(rawIP);
@@ -67,7 +59,7 @@ public abstract class Subcommand {
return null;
}
} catch (UnknownHostException exception) {
source.sendMessage(SONAR.getConfig().getCommands().getUnknownIpAddress());
source.sendMessage(SONAR.getConfig().getCommands().getIncorrectIpAddress());
return null;
}
return inetAddress;
@@ -78,5 +70,31 @@ public abstract class Subcommand {
.replace("%usage%", info.name() + " (" + arguments + ")"));
}
public abstract void execute(final @NotNull CommandInvocation invocation);
public final void invoke(final @NotNull InvocationSource invocationSource, final String @NotNull [] arguments) {
// Check if the subcommand can only be executed by players
if (getInfo().onlyPlayers() && !invocationSource.isPlayer()) {
invocationSource.sendMessage(Sonar.get().getConfig().getCommands().getPlayersOnly());
return;
}
// Check if the subcommand can only be executed though console
if (getInfo().onlyConsole() && invocationSource.isPlayer()) {
invocationSource.sendMessage(Sonar.get().getConfig().getCommands().getConsoleOnly());
return;
}
final CommandInvocation commandInvocation = new CommandInvocation(invocationSource, this, arguments);
// The subcommands has arguments which are not present in the executed command
if (getInfo().argumentsRequired() && getInfo().arguments().length > 0 && arguments.length <= 1) {
invocationSource.sendMessage(Sonar.get().getConfig().getCommands().getIncorrectCommandUsage()
.replace("%usage%", getInfo().name() + " (" + getArguments() + ")"));
return;
}
// Execute the sub command
execute(commandInvocation);
}
protected abstract void execute(final @NotNull CommandInvocation invocation);
}
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -18,6 +18,7 @@
package xyz.jonesdev.sonar.api.config;
import lombok.Getter;
import org.jetbrains.annotations.NotNull;
import org.simpleyaml.configuration.comments.format.YamlCommentFormat;
import org.simpleyaml.configuration.file.YamlFile;
import xyz.jonesdev.sonar.api.Sonar;
@@ -36,8 +37,8 @@ public final class SimpleYamlConfig {
private static final List<String> HEADER = Arrays.asList(
"",
"Running Sonar version " + Sonar.get().getVersion()
+ " on " + Sonar.get().getServer().getPlatform().getDisplayName(),
String.format("Running Sonar version %s on %s",
Sonar.get().getVersion(), Sonar.get().getPlatform().getDisplayName()),
"Need help or have questions? https://jonesdev.xyz/discord",
"https://github.com/jonesdevelopment/sonar",
""
@@ -47,7 +48,7 @@ public final class SimpleYamlConfig {
this(new File(dataFolder, fileName + ".yml"), dataFolder);
}
private SimpleYamlConfig(final File file, final File folder) {
private SimpleYamlConfig(final File file, final @NotNull File folder) {
if (!folder.exists() && !folder.mkdir()) {
throw new IllegalStateException("Could not create folder?!");
}
@@ -93,12 +94,27 @@ public final class SimpleYamlConfig {
}
public String getString(final String path, final String def) {
final Object object = getObject(path, def);
if (object instanceof String) {
return (String) object;
}
Sonar.get().getLogger().info("[config] Migrated {} to {}", path, def);
set(path, def);
return def;
}
public Object getObject(final String path, final Object def) {
yaml.addDefault(path, def);
return yaml.getString(path, def);
return yaml.get(path, def);
}
public List<String> getStringList(final String path, final List<String> def) {
yaml.addDefault(path, def);
return yaml.getStringList(path);
}
public List<Integer> getIntList(final String path, final List<Integer> def) {
yaml.addDefault(path, def);
return yaml.getIntegerList(path);
}
}
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -21,15 +21,17 @@ import lombok.*;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.minimessage.MiniMessage;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.command.SonarCommand;
import xyz.jonesdev.sonar.api.dependencies.Dependency;
import xyz.jonesdev.sonar.api.webhook.DiscordWebhook;
import java.awt.*;
import java.io.File;
import java.net.InetAddress;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.*;
import java.util.regex.Pattern;
import static xyz.jonesdev.sonar.api.Sonar.LINE_SEPARATOR;
@@ -48,9 +50,9 @@ public final class SonarConfiguration {
@Getter
private final Verification verification = new Verification();
@Getter
private final Lockdown lockdown = new Lockdown();
@Getter
private final Database database = new Database();
@Getter
private final Webhook webhook = new Webhook();
public SonarConfiguration(final @NotNull File pluginFolder) {
this.pluginFolder = pluginFolder;
@@ -68,12 +70,19 @@ public final class SonarConfiguration {
@Getter
private int minPlayersForAttack;
@Getter
private int minAttackDuration;
@Getter
private int minAttackThreshold;
@Getter
private int attackCooldownDelay;
@Getter
private Component tooManyOnlinePerIp;
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public static final class Verbose {
private String actionBarLayout;
private String actionBarLayoutDuringAttack;
private List<String> animation;
}
@@ -86,9 +95,56 @@ public final class SonarConfiguration {
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public static final class Verification {
private boolean enabled;
private boolean checkGravity;
private boolean checkCollisions;
private Timing timing;
@Getter
public enum Timing {
ALWAYS, DURING_ATTACK, NEVER
}
private final Map map = new Map();
private final Gravity gravity = new Gravity();
@Getter
public static final class Map {
private Timing timing;
private boolean randomizePositions;
private boolean randomizeFontSize;
private double distortionsFactorX;
private double distortionsFactorY;
private int randomLinesAmount;
private int randomOvalsAmount;
private int precomputeAmount;
private int maxDuration;
private int maxTries;
private String dictionary;
private Component enterCode;
private Component failedCaptcha;
private String enterCodeActionBar;
private List<String> fonts;
}
@Getter
public static final class Gravity {
private boolean enabled;
private Gamemode gamemode;
private int maxMovementTicks;
private int maxIgnoredTicks;
private Component youAreBeingChecked;
@Getter
@RequiredArgsConstructor
public enum Gamemode {
SURVIVAL(0),
CREATIVE(1),
ADVENTURE(2),
// Keep this for backwards compatibility
SPECTATOR(2);
private final int id;
}
}
private boolean logConnections;
private boolean logDuringAttack;
private boolean debugXYZPositions;
@@ -99,15 +155,14 @@ public final class SonarConfiguration {
private String failedLog;
private String successLog;
private String blacklistLog;
private short gamemodeId;
private int maxBrandLength;
private int maxMovementTicks;
private int maxIgnoredTicks;
private int maxVerifyingPlayers;
private int maxLoginPackets;
private int maxPing;
private int readTimeout;
private int reconnectDelay;
private final Collection<Integer> whitelistedProtocols = new HashSet<>(0);
private final Collection<Integer> blacklistedProtocols = new HashSet<>(0);
private Component tooManyPlayers;
private Component tooFastReconnect;
@@ -119,6 +174,8 @@ public final class SonarConfiguration {
private Component alreadyVerifying;
private Component alreadyQueued;
private Component blacklisted;
private Component protocolBlacklisted;
private Component currentlyPreparing;
}
@Getter
@@ -127,7 +184,6 @@ public final class SonarConfiguration {
private String incorrectCommandUsage;
private String incorrectIpAddress;
private String illegalIpAddress;
private String unknownIpAddress;
private String subCommandNoPerm;
private String playersOnly;
private String consoleOnly;
@@ -157,10 +213,11 @@ public final class SonarConfiguration {
private String networkStatistics;
private String cpuStatistics;
private List<String> helpHeader;
private String helpSubcommands;
private String verboseSubscribed;
private String verboseUnsubscribed;
private String verboseSubscribedOther;
private String verboseUnsubscribedOther;
private String reloading;
private String reloaded;
@@ -173,11 +230,10 @@ public final class SonarConfiguration {
@Getter
@RequiredArgsConstructor
public enum Type {
MYSQL(new Dependency[]{Dependency.MYSQL}, "com.mysql.cj.jdbc.NonRegisteringDriver"),
MARIADB(new Dependency[]{Dependency.MYSQL, Dependency.MARIADB}, "org.mariadb.jdbc.Driver"),
MYSQL(Dependency.MYSQL, "com.mysql.cj.jdbc.NonRegisteringDriver"),
NONE(null, null);
private final Dependency[] dependencies;
private final Dependency dependency;
private final String driverClassName;
}
@@ -187,23 +243,43 @@ public final class SonarConfiguration {
private String name;
private String username;
private String password;
private int maximumAge;
}
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public static final class Lockdown {
@Setter
private boolean enabled;
private boolean notifyAdmins;
private boolean logAttempts;
private Component disconnect;
private String bypassPermission;
private String activated;
private String deactivated;
private String notification;
private String consoleLog;
public static final class Webhook {
private String url;
private String username;
private String avatarUrl;
private String content;
private final Footer footer = new Footer();
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public static final class Footer {
private String text;
private String iconUrl;
}
private final Embed embed = new Embed();
@Getter
@NoArgsConstructor
@AllArgsConstructor
public static final class Embed {
private String title;
private String titleUrl;
@Setter
private String description;
private int r, g, b;
}
}
@Getter
private @Nullable DiscordWebhook discordWebhook;
public void load() {
if (generalConfig == null) {
generalConfig = new SimpleYamlConfig(pluginFolder, "config");
@@ -219,23 +295,32 @@ public final class SonarConfiguration {
// General options
generalConfig.getYaml().setComment("language",
"Name of the language file Sonar should use for messages"
);
"Name of the language file Sonar should use for messages");
String language = generalConfig.getString("language", "en");
generalConfig.getYaml().setComment("max-online-per-ip",
"Maximum number of players online with the same IP address"
);
"Maximum number of players online with the same IP address");
maxOnlinePerIp = clamp(generalConfig.getInt("max-online-per-ip", 3), 1, Byte.MAX_VALUE);
generalConfig.getYaml().setComment("min-players-for-attack",
"Minimum number of new players in order for an attack to be detected"
);
minPlayersForAttack = clamp(generalConfig.getInt("min-players-for-attack", 5), 2, 1024);
"Minimum number of new players in order for an attack to be detected");
minPlayersForAttack = clamp(generalConfig.getInt("min-players-for-attack", 8), 2, 1024);
generalConfig.getYaml().setComment("min-attack-duration",
"Amount of time (in milliseconds) that has to pass in order for an attack to be over");
minAttackDuration = clamp(generalConfig.getInt("min-attack-duration", 30000), 1000, 900000);
generalConfig.getYaml().setComment("min-attack-threshold",
"Number of times an incident has to be reported in order to be acknowledged as an attack"
+ LINE_SEPARATOR + "This number acts as a buffer to filter out false attack notifications");
minAttackThreshold = clamp(generalConfig.getInt("min-attack-threshold", 2), 0, 20);
generalConfig.getYaml().setComment("attack-cooldown-delay",
"Amount of time (in milliseconds) that has to pass in order for a new attack to be detected");
attackCooldownDelay = clamp(generalConfig.getInt("attack-cooldown-delay", 3000), 100, 30000);
generalConfig.getYaml().setComment("log-player-addresses",
"Should Sonar log players' IP addresses in console?"
);
"Should Sonar log players' IP addresses in console?");
logPlayerAddresses = generalConfig.getBoolean("log-player-addresses", true);
// Message settings
@@ -255,7 +340,8 @@ public final class SonarConfiguration {
messagesConfig.getYaml().setComment("prefix",
"Placeholder for every '%prefix%' in this configuration file");
prefix = formatString(messagesConfig.getString("prefix", "<yellow><bold>Sonar<reset> <gray>» <white>"));
prefix = formatString(messagesConfig.getString("prefix",
"<b><yellow>Sonar</b> <gray>» <white>"));
messagesConfig.getYaml().setComment("support-url",
"Placeholder for every '%support-url%' in this configuration file");
@@ -264,10 +350,9 @@ public final class SonarConfiguration {
// Database
generalConfig.getYaml().setComment("database.type",
"Type of database Sonar uses to store verified players"
+ LINE_SEPARATOR + "Possible types: NONE, MYSQL, MARIADB (experimental)"
);
final String newDatabaseType = generalConfig.getString("database.type", Database.Type.NONE.name());
database.type = Database.Type.valueOf(newDatabaseType.toUpperCase());
+ LINE_SEPARATOR + "Possible types: NONE, MYSQL");
database.type = Database.Type.valueOf(
generalConfig.getString("database.type", Database.Type.NONE.name()).toUpperCase());
generalConfig.getYaml().setComment("database",
"You can connect Sonar to a database to keep verified players even after restarting your server"
@@ -295,26 +380,9 @@ public final class SonarConfiguration {
"Password for authenticating with the SQL database");
database.password = generalConfig.getString("database.password", "");
// Lockdown
generalConfig.getYaml().setComment("lockdown",
"You can lock your server down using '/sonar lockdown' if, for example,"
+ LINE_SEPARATOR + "bots are bypassing the verification or any other reason");
generalConfig.getYaml().setComment("lockdown.enabled",
"Should Sonar prevent all players from joining the server?");
lockdown.enabled = generalConfig.getBoolean("lockdown.enabled", false);
generalConfig.getYaml().setComment("lockdown.log-attempts",
"Should Sonar log new login attempts during lockdown?");
lockdown.logAttempts = generalConfig.getBoolean("lockdown.log-attempts", true);
generalConfig.getYaml().setComment("lockdown.notify-admins",
"Should Sonar notify admins when they join the server during lockdown?");
lockdown.notifyAdmins = generalConfig.getBoolean("lockdown.notify-admins", true);
generalConfig.getYaml().setComment("lockdown.bypass-permission",
"Which permission does a player need in order to bypass the lockdown mode?"
+ LINE_SEPARATOR + "Players with this permission will also receive admin notifications when joining");
lockdown.bypassPermission = generalConfig.getString("lockdown.bypass-permission", "sonar.lockdown.bypass");
generalConfig.getYaml().setComment("database.maximum-age",
"How many days should Sonar keep verified players in the database?");
database.maximumAge = clamp(generalConfig.getInt("database.maximum-age", 5), 1, 365);
// Queue
generalConfig.getYaml().setComment("queue",
@@ -328,37 +396,131 @@ public final class SonarConfiguration {
generalConfig.getYaml().setComment("verification",
"Every new player that joins for the first time will be sent to"
+ LINE_SEPARATOR + "a lightweight limbo server where advanced bot checks are performed");
generalConfig.getYaml().setComment("verification.enabled",
"Should Sonar verify new players? (Recommended)");
verification.enabled = generalConfig.getBoolean("verification.enabled", true);
generalConfig.getYaml().setComment("verification.timing",
"When should Sonar verify new players?"
+ LINE_SEPARATOR + "Possible types: ALWAYS, DURING_ATTACK, NEVER"
+ LINE_SEPARATOR + "- ALWAYS: New players will always be checked (Recommended)"
+ LINE_SEPARATOR + "- DURING_ATTACK: New players will only be checked during an attack"
+ LINE_SEPARATOR + "- NEVER: New players will never be checked");
verification.timing = Verification.Timing.valueOf(
generalConfig.getString("verification.timing", Verification.Timing.ALWAYS.name()).toUpperCase());
generalConfig.getYaml().setComment("verification.checks.gravity",
"Checks if the players' falling motion is following Minecraft's gravity formula"
+ LINE_SEPARATOR + "All predicted motions are precalculated in order to save performance");
generalConfig.getYaml().setComment("verification.checks.gravity.enabled",
"Should Sonar check for valid client gravity? (Recommended)");
verification.checkGravity = generalConfig.getBoolean("verification.checks.gravity.enabled", true);
verification.gravity.enabled = generalConfig.getBoolean("verification.checks.gravity.enabled", true);
generalConfig.getYaml().setComment("verification.checks.gravity.max-movement-ticks",
"Maximum number of ticks the player has to fall in order to be allowed to hit the platform");
verification.maxMovementTicks = clamp(generalConfig.getInt("verification.checks.gravity.max-movement-ticks", 8),
2, 100);
verification.gravity.maxMovementTicks = clamp(generalConfig.getInt(
"verification.checks.gravity.max-movement-ticks", 8), 2, 100);
generalConfig.getYaml().setComment("verification.checks.gravity.max-ignored-ticks",
"Maximum number of ignored Y movement changes before a player fails verification");
verification.maxIgnoredTicks = clamp(generalConfig.getInt("verification.checks.gravity.max-ignored-ticks", 5), 1,
128);
verification.gravity.maxIgnoredTicks = clamp(generalConfig.getInt(
"verification.checks.gravity.max-ignored-ticks", 5), 1, 128);
generalConfig.getYaml().setComment("verification.checks.collisions",
"Checks if the players collides with barrier blocks spawned below the player"
+ LINE_SEPARATOR + "Note: The collision check will be skipped if the gravity check is disabled");
generalConfig.getYaml().setComment("verification.checks.collisions.enabled",
"Should Sonar check for valid client collisions? (Recommended)");
verification.checkCollisions = generalConfig.getBoolean("verification.checks.collisions.enabled", true);
generalConfig.getYaml().setComment("verification.checks.gravity.gamemode",
"The gamemode of the player during verification"
+ LINE_SEPARATOR + "Possible types: SURVIVAL, CREATIVE, ADVENTURE, SPECTATOR"
+ LINE_SEPARATOR + "- SURVIVAL: all UI components are visible"
+ LINE_SEPARATOR + "- CREATIVE: health and hunger are hidden"
+ LINE_SEPARATOR + "- ADVENTURE: all UI components are visible");
verification.gravity.gamemode = Verification.Gravity.Gamemode.valueOf(
generalConfig.getString("verification.checks.gravity.gamemode",
Verification.Gravity.Gamemode.ADVENTURE.name()).toUpperCase());
generalConfig.getYaml().setComment("verification.gamemode",
"The gamemode of the player during verification (0, 1, 2, or 3)");
verification.gamemodeId = (short) clamp(generalConfig.getInt("verification.gamemode", 3), 0, 3);
generalConfig.getYaml().setComment("verification.checks.map-captcha",
"Make the player type a code from a virtual map in chat");
generalConfig.getYaml().setComment("verification.checks.map-captcha.timing",
"When should Sonar make the player solve a captcha?"
+ LINE_SEPARATOR + "Possible types: ALWAYS, DURING_ATTACK, NEVER"
+ LINE_SEPARATOR + "- ALWAYS: New players will always receive a captcha"
+ LINE_SEPARATOR + "- DURING_ATTACK: New players will only receive a captcha during an attack"
+ LINE_SEPARATOR + "- NEVER: New players will never receive a captcha (Recommended)");
verification.map.timing = Verification.Timing.valueOf(
generalConfig.getString("verification.checks.map-captcha.timing",
Verification.Timing.NEVER.name()).toUpperCase());
generalConfig.getYaml().setComment("verification.checks.map-captcha.random-position",
"Should Sonar randomize the X and Y position of the captcha?");
verification.map.randomizePositions = generalConfig.getBoolean("verification.checks.map-captcha.random-position",
true);
generalConfig.getYaml().setComment("verification.checks.map-captcha.distortions-factor-x",
"How much should Sonar distort characters (factor for randomization)?");
verification.map.distortionsFactorX = clamp(generalConfig.getInt(
"verification.checks.map-captcha.distortions-factor-x", 50), 0, 100) / 100D;
generalConfig.getYaml().setComment("verification.checks.map-captcha.distortions-factor-y",
"How much should Sonar distort characters (factor for randomization)?");
verification.map.distortionsFactorY = clamp(generalConfig.getInt(
"verification.checks.map-captcha.distortions-factor-y", 50), 0, 100) / 100D;
generalConfig.getYaml().setComment("verification.checks.map-captcha.random-lines",
"How many random lines behind the captcha should Sonar draw?");
verification.map.randomLinesAmount = generalConfig.getInt("verification.checks.map-captcha.random-lines", 4);
generalConfig.getYaml().setComment("verification.checks.map-captcha.random-ovals",
"How many random ovals behind the captcha should Sonar draw?");
verification.map.randomOvalsAmount = generalConfig.getInt("verification.checks.map-captcha.random-ovals", 1);
generalConfig.getYaml().setComment("verification.checks.map-captcha.random-font-size",
"Should Sonar randomize the size of the font used for rendering the captcha?");
verification.map.randomizeFontSize = generalConfig.getBoolean("verification.checks.map-captcha.random-font-size",
true);
generalConfig.getYaml().setComment("verification.checks.map-captcha.precompute",
"How many answers should Sonar precompute (prepare)?"
+ LINE_SEPARATOR + "This task happens asynchronously in the background;"
+ LINE_SEPARATOR + "Players are able to join once one captcha has been prepared");
verification.map.precomputeAmount = generalConfig.getInt("verification.checks.map-captcha.precompute", 1000);
generalConfig.getYaml().setComment("verification.checks.map-captcha.max-duration",
"How long should Sonar wait until the player fails the captcha?");
verification.map.maxDuration = generalConfig.getInt("verification.checks.map-captcha.max-duration", 45000);
generalConfig.getYaml().setComment("verification.checks.map-captcha.max-tries",
"How many times must a player fail the captcha before failing the verification?");
verification.map.maxTries = generalConfig.getInt("verification.checks.map-captcha.max-tries", 3);
generalConfig.getYaml().setComment("verification.checks.map-captcha.dictionary",
"Characters (letters and numbers) that are allowed to appear in the answer to the captcha");
verification.map.dictionary = generalConfig.getString("verification.checks.map-captcha.dictionary", "123456789");
generalConfig.getYaml().setComment("verification.checks.map-captcha.fonts",
"Which font types should Sonar use for the map captcha codes?");
verification.map.fonts = generalConfig.getStringList("verification.checks.map-captcha.fonts",
Arrays.asList(Font.DIALOG, Font.DIALOG_INPUT, Font.SERIF, Font.SANS_SERIF));
generalConfig.getYaml().setComment("verification.checks.valid-name-regex",
"Regex for validating usernames during verification");
verification.validNameRegex = Pattern.compile(generalConfig.getString(
"verification.checks.valid-name-regex", "^[a-zA-Z0-9_]+$"));
generalConfig.getYaml().setComment("verification.checks.valid-brand-regex",
"Regex for validating client brands during verification");
verification.validBrandRegex = Pattern.compile(generalConfig.getString(
"verification.checks.valid-brand-regex", "^[!-~ ]+$"));
generalConfig.getYaml().setComment("verification.checks.valid-locale-regex",
"Regex for validating client locale during verification");
verification.validLocaleRegex = Pattern.compile(generalConfig.getString(
"verification.checks.valid-locale-regex", "^[a-zA-Z_]+$"));
generalConfig.getYaml().setComment("verification.checks.max-brand-length",
"Maximum client brand length during verification");
verification.maxBrandLength = generalConfig.getInt("verification.checks.max-brand-length", 64);
generalConfig.getYaml().setComment("verification.checks.max-ping",
"Ping (in milliseconds) a player has to have in order to timeout");
verification.maxPing = clamp(generalConfig.getInt("verification.checks.max-ping", 10000), 500, 30000);
generalConfig.getYaml().setComment("verification.checks.max-login-packets",
"Maximum number of login packets the player has to send in order to be kicked");
verification.maxLoginPackets = clamp(generalConfig.getInt("verification.checks.max-login-packets", 256), 128, 8192);
generalConfig.getYaml().setComment("verification.log-connections",
"Should Sonar log new verification attempts?");
@@ -373,53 +535,115 @@ public final class SonarConfiguration {
+ LINE_SEPARATOR + "This is not recommended for production servers but can be helpful for spotting errors.");
verification.debugXYZPositions = generalConfig.getBoolean("verification.debug-xyz-positions", false);
generalConfig.getYaml().setComment("verification.valid-name-regex",
"Regex for validating usernames during verification");
verification.validNameRegex = Pattern.compile(generalConfig.getString(
"verification.valid-name-regex", "^[a-zA-Z0-9_.*!]+$"));
generalConfig.getYaml().setComment("verification.valid-brand-regex",
"Regex for validating client brands during verification");
verification.validBrandRegex = Pattern.compile(generalConfig.getString(
"verification.valid-brand-regex", "^[!-~ ]+$"));
generalConfig.getYaml().setComment("verification.valid-locale-regex",
"Regex for validating client locale during verification");
verification.validLocaleRegex = Pattern.compile(generalConfig.getString(
"verification.valid-locale-regex", "^[a-zA-Z_]+$"));
generalConfig.getYaml().setComment("verification.max-brand-length",
"Maximum client brand length during verification");
verification.maxBrandLength = generalConfig.getInt("verification.max-brand-length", 64);
generalConfig.getYaml().setComment("verification.max-ping",
"Ping (in milliseconds) a player has to have in order to timeout");
verification.maxPing = clamp(generalConfig.getInt("verification.max-ping", 10000), 500, 30000);
generalConfig.getYaml().setComment("verification.read-timeout",
"Amount of time that has to pass before a player times out");
verification.readTimeout = clamp(generalConfig.getInt("verification.read-timeout", 3500), 500, 30000);
generalConfig.getYaml().setComment("verification.max-login-packets",
"Maximum number of login packets the player has to send in order to be kicked");
verification.maxLoginPackets = clamp(generalConfig.getInt("verification.max-login-packets", 256), 128, 8192);
generalConfig.getYaml().setComment("verification.max-players",
"Maximum number of players verifying at the same time");
verification.maxVerifyingPlayers = clamp(generalConfig.getInt("verification.max-players", 1024), 1,
Short.MAX_VALUE);
generalConfig.getYaml().setComment("verification.rejoin-delay",
"Minimum number of rejoin delay during verification");
verification.reconnectDelay = clamp(generalConfig.getInt("verification.rejoin-delay", 8000), 0, 100000);
generalConfig.getYaml().setComment("verification.whitelisted-protocols",
"List of protocol IDs which are not checked by Sonar (verification bypass)"
+ LINE_SEPARATOR + "You can find the full list of all protocol IDs here:"
+ LINE_SEPARATOR + "https://wiki.vg/Protocol_version_numbers"
+ LINE_SEPARATOR + "For example, Minecraft 1.20 has the ID 763.");
verification.whitelistedProtocols.clear();
verification.whitelistedProtocols.addAll(generalConfig.getIntList("verification.whitelisted-protocols",
new ArrayList<>(0)));
generalConfig.getYaml().setComment("verification.blacklisted-protocols",
"List of protocol IDs which are unable to join the server at all");
verification.blacklistedProtocols.clear();
verification.blacklistedProtocols.addAll(generalConfig.getIntList("verification.blacklisted-protocols",
new ArrayList<>(0)));
generalConfig.getYaml().setComment("webhook",
"Bot attack notifications can also be sent to your Discord server using webhooks");
generalConfig.getYaml().setComment("webhook.url",
"URL of the Discord webhook (Set this to '' to disable webhooks)");
webhook.url = generalConfig.getString("webhook.url", "");
generalConfig.getYaml().setComment("webhook.username",
"Username of the Discord webhook sender");
webhook.username = generalConfig.getString("webhook.username", "Sonar");
generalConfig.getYaml().setComment("webhook.avatar-url",
"URL to the avatar of the Discord webhook sender (Set this to '' to disable)");
webhook.avatarUrl = generalConfig.getString("webhook.avatar-url", "");
generalConfig.getYaml().setComment("webhook.content",
"Content of the Discord webhook message (Set this to '' to disable)"
+ LINE_SEPARATOR + "You can use this to e.g. ping staff members using <@userId>"
+ LINE_SEPARATOR + "If you want to ping roles, you will need to use <@&roleId>");
webhook.content = generalConfig.getString("webhook.content", "");
generalConfig.getYaml().setComment("webhook.embed.footer",
"Small footer message of the Discord webhook embed");
generalConfig.getYaml().setComment("webhook.embed.footer.text",
"Content of the footer message of the Discord webhook embed");
webhook.footer.text = generalConfig.getString("webhook.embed.footer.text",
"© Jones Development and Sonar Contributors");
generalConfig.getYaml().setComment("webhook.embed.footer.icon-url",
"URL of the footer message icon of the Discord webhook embed");
webhook.footer.iconUrl = generalConfig.getString("webhook.embed.footer.icon-url", "");
final String realEmbedPath = "webhook.embed";
final String embedPath = realEmbedPath + ".";
generalConfig.getYaml().setComment(realEmbedPath,
"Embed Discord webhook message that is sent when an attack has stopped");
generalConfig.getYaml().setComment(embedPath + "title",
"Title of the Discord webhook embed");
webhook.embed.title = generalConfig.getString(embedPath + "title", ":white_check_mark: Attack mitigated");
generalConfig.getYaml().setComment(embedPath + "title-url",
"Clickable URL of the title of the Discord webhook embed");
webhook.embed.titleUrl = generalConfig.getString(embedPath + "title-url", "");
generalConfig.getYaml().setComment(embedPath + "description",
"Description (content) of the Discord webhook embed");
webhook.embed.description = fromList(generalConfig.getStringList(embedPath + "description", Arrays.asList(
"The attack on your server has been mitigated.",
"",
"Attack start: <t:%start-timestamp%:T>",
"Attack end: <t:%end-timestamp%:T>",
"Attack duration: %duration%",
"",
"Peak process CPU usage during the attack: %peak-cpu%%",
"Peak process memory usage during the attack: %peak-memory%",
"Peak bots per second during the attack: %peak-bps%",
"",
"Blacklisted IP addresses during the attack: %total-blacklisted%",
"Failed verifications during the attack: %total-failed%",
"Successful verifications during the attack: %total-success%"
)), LINE_SEPARATOR);
generalConfig.getYaml().setComment(embedPath + "color",
"RGB colors of the Discord webhook embed"
+ LINE_SEPARATOR + "Color picker: https://www.rapidtables.com/web/color/RGB_Color.html");
webhook.embed.r = generalConfig.getInt(embedPath + "color.red", 0);
webhook.embed.g = generalConfig.getInt(embedPath + "color.green", 255);
webhook.embed.b = generalConfig.getInt(embedPath + "color.blue", 0);
if (!webhook.url.isEmpty()) {
if (webhook.username.isEmpty()) {
throw new IllegalStateException("Webhook username cannot be empty");
}
discordWebhook = new DiscordWebhook(webhook.url);
} else if (discordWebhook != null) {
// Reset if Discord webhooks were disabled
discordWebhook = null;
}
// load this here otherwise it could cause issues
messagesConfig.getYaml().setComment("header",
"Placeholder for every '%header%' in this configuration file");
header = fromList(messagesConfig.getStringList("header",
Arrays.asList(
"<yellow><bold>Sonar<reset>",
"<reset>"
"<b><yellow>Sonar</b><reset>",
""
)));
messagesConfig.getYaml().setComment("footer",
@@ -448,7 +672,7 @@ public final class SonarConfiguration {
"%prefix%<red>Usage: /sonar %usage%"));
messagesConfig.getYaml().setComment("commands.invalid-ip-address",
"Message that is shown when someone provides an invalid IP address (Invalid characters)");
"Message that is shown when someone provides an invalid IP address (Invalid format)");
commands.incorrectIpAddress = formatString(messagesConfig.getString("commands.invalid-ip-address",
"%prefix%The IP address you provided seems to be invalid."));
@@ -457,11 +681,6 @@ public final class SonarConfiguration {
commands.illegalIpAddress = formatString(messagesConfig.getString("commands.illegal-ip-address",
"%prefix%The IP address you provided seems to be either a local or loopback IP."));
messagesConfig.getYaml().setComment("commands.unknown-ip-address",
"Message that is shown when someone provides an unknown IP address (Unknown Host)");
commands.unknownIpAddress = formatString(messagesConfig.getString("commands.unknown-ip-address",
"%prefix%The IP address you provided seems to be unknown."));
messagesConfig.getYaml().setComment("commands.player-only",
"Message that is shown when the console runs a command that is player-only");
commands.playersOnly = formatString(messagesConfig.getString("commands.player-only",
@@ -484,28 +703,32 @@ public final class SonarConfiguration {
commands.subCommandNoPerm = formatString(messagesConfig.getString("commands.subcommand-no-permission",
"%prefix%<red>You do not have permission to execute this subcommand. <gray>(%permission%)"));
messagesConfig.getYaml().setComment("lockdown",
"Translations for '/sonar lockdown'");
messagesConfig.getYaml().setComment("lockdown.notification",
"Message that is shown when an admin joins the server during lockdown");
lockdown.notification = formatString(messagesConfig.getString("lockdown.notification",
"%prefix%<green>Hey, the server is currently in lockdown mode. If you want to disable the lockdown mode,"
+ " type <white>/sonar lockdown<green>."
));
messagesConfig.getYaml().setComment("lockdown.console-log",
"Message that is shown to console when a normal player tries joining the server during lockdown");
lockdown.consoleLog = messagesConfig.getString("lockdown.console-log",
"%player% (%ip%, %protocol%) tried to join during lockdown mode.");
messagesConfig.getYaml().setComment("lockdown.disconnect-message",
"Message that is shown to a normal player when they try joining the server during lockdown");
lockdown.disconnect = deserialize(fromList(messagesConfig.getStringList("lockdown.disconnect-message",
messagesConfig.getYaml().setComment("commands.main",
"Translations for '/sonar'");
messagesConfig.getYaml().setComment("commands.main.header",
"Informational message that is shown above everything when running the main command");
commands.helpHeader = messagesConfig.getStringList("commands.main.header",
Arrays.asList(
"%header%",
"<red>The server is currently locked down, please try again later.",
"%footer%"
))));
"<yellow>Running <b>Sonar</b> %version% on %platform%.",
"<gray><click:open_url:'https://github.com/jonesdevelopment/sonar'>(C) %copyright_year% Jones Development and" +
" Sonar Contributors</click>",
"",
"<gray>Need help or have any questions?",
"<white><click:open_url:'https://jonesdev.xyz/discord/'><hover:show_text:'(Click to open Discord)'>Open a " +
"ticket on the Discord </hover></click><click:open_url:'https://github" +
".com/jonesdevelopment/sonar/issues'><hover:show_text:'(Click to open GitHub)'>or open a new issue on" +
" GitHub.",
""
));
messagesConfig.getYaml().setComment("commands.main.subcommands",
"Formatting of the list of subcommands shown when running the main command");
commands.helpSubcommands = formatString(messagesConfig.getString("commands.main.subcommands",
"<click:suggest_command:'/sonar %subcommand% '><hover:show_text:'<gray>Only players: " +
"</gray>%only_players%<br><gray>Require console: </gray>%require_console%<br><gray>Permission: " +
"</gray><white>%permission%<br><gray>Aliases: </gray>%aliases%'><dark_aqua> ▪ <gray>/sonar " +
"%subcommand% <white>%description%"));
SonarCommand.prepareCachedMessages();
messagesConfig.getYaml().setComment("commands.reload",
"Translations for '/sonar reload'");
@@ -519,18 +742,6 @@ public final class SonarConfiguration {
commands.reloaded = formatString(messagesConfig.getString("commands.reload.finish",
"%prefix%<green>Successfully reloaded <gray>(%taken%ms)"));
messagesConfig.getYaml().setComment("commands.lockdown",
"Translations for '/sonar lockdown'");
messagesConfig.getYaml().setComment("commands.lockdown.enabled",
"Message that is shown when a player enables server lockdown");
lockdown.activated = formatString(messagesConfig.getString("commands.lockdown.enabled",
"%prefix%The server is now in lockdown mode."));
messagesConfig.getYaml().setComment("commands.lockdown.disabled",
"Message that is shown when a player disables server lockdown");
lockdown.deactivated = formatString(messagesConfig.getString("commands.lockdown.disabled",
"%prefix%The server is no longer in lockdown mode."));
messagesConfig.getYaml().setComment("commands.verbose",
"Translations for '/sonar verbose'");
messagesConfig.getYaml().setComment("commands.verbose.subscribed",
@@ -543,16 +754,6 @@ public final class SonarConfiguration {
commands.verboseUnsubscribed = formatString(messagesConfig.getString("commands.verbose.unsubscribed",
"%prefix%You are no longer viewing Sonar verbose."));
messagesConfig.getYaml().setComment("commands.verbose.subscribed-other",
"Message that is shown when a player makes another player subscribe to Sonar verbose");
commands.verboseSubscribedOther = formatString(messagesConfig.getString("commands.verbose.subscribed-other",
"%prefix%%player% is now viewing Sonar verbose."));
messagesConfig.getYaml().setComment("commands.verbose.unsubscribed-other",
"Message that is shown when a player makes another player unsubscribe from Sonar verbose");
commands.verboseUnsubscribedOther = formatString(messagesConfig.getString("commands.verbose.unsubscribed-other",
"%prefix%%player% is no longer viewing Sonar verbose."));
messagesConfig.getYaml().setComment("commands.blacklist",
"Translations for '/sonar blacklist'");
messagesConfig.getYaml().setComment("commands.blacklist.empty",
@@ -633,7 +834,7 @@ public final class SonarConfiguration {
messagesConfig.getYaml().setComment("commands.statistics.header",
"Informational message that is shown above everything when viewing the statistics");
commands.statisticsHeader = formatString(messagesConfig.getString("commands.statistics.header",
"%prefix%<yellow>Showing %type% statistics for this session:"));
"%prefix%Showing %type% statistics for this session:"));
messagesConfig.getYaml().setComment("commands.statistics.unknown-type",
"Message that is shown when a player tries viewing an unknown statistic");
@@ -644,45 +845,46 @@ public final class SonarConfiguration {
"Format of the general statistics message");
commands.generalStatistics = formatString(fromList(messagesConfig.getStringList("commands.statistics.general",
Arrays.asList(
" <gray>▪ <green>Verified IP addresses: <white>%verified%",
" <gray>▪ <green>Verifying IP addresses: <white>%verifying%",
" <gray>▪ <green>Blacklisted IP addresses: <white>%blacklisted%",
" <gray>▪ <green>Currently queued logins: <white>%queued%",
" <gray>▪ <green>Total non-unique joins: <white>%total_joins%",
" <gray>▪ <green>Total verification attempts: <white>%total_attempts%",
" <gray>▪ <green>Total failed verifications: <white>%total_failed%"
" <dark_aqua>▪ <gray>Verified IP addresses: <white>%verified%",
" <dark_aqua>▪ <gray>Verifying IP addresses: <white>%verifying%",
" <dark_aqua>▪ <gray>Blacklisted IP addresses: <white>%blacklisted%",
" <dark_aqua>▪ <gray>Currently queued logins: <white>%queued%",
" <dark_aqua>▪ <gray>Total non-unique joins: <white>%total_joins%",
" <dark_aqua>▪ <gray>Total verification attempts: <white>%total_attempts%",
" <dark_aqua>▪ <gray>Total failed verifications: <white>%total_failed%",
" <dark_aqua>▪ <gray>Server uptime: <white>%uptime%"
))));
messagesConfig.getYaml().setComment("commands.statistics.cpu",
"Format of the CPU statistics message");
commands.cpuStatistics = formatString(fromList(messagesConfig.getStringList("commands.statistics.cpu",
Arrays.asList(
" <gray>▪ <green>Process CPU usage right now: <white>%process_cpu%%",
" <gray>▪ <green>System CPU usage right now: <white>%system_cpu%%",
" <gray>▪ <green>Per-core process CPU usage: <white>%average_process_cpu%%",
" <gray>▪ <green>Per-core system CPU usage: <white>%average_system_cpu%%",
" <gray>▪ <green>General system load average: <white>%load_average%%",
" <gray>▪ <green>Total amount of virtual cpus: <white>%virtual_cores%"
" <dark_aqua>▪ <gray>Process CPU usage right now: <white>%process_cpu%%",
" <dark_aqua>▪ <gray>System CPU usage right now: <white>%system_cpu%%",
" <dark_aqua>▪ <gray>Per-core process CPU usage: <white>%average_process_cpu%%",
" <dark_aqua>▪ <gray>Per-core system CPU usage: <white>%average_system_cpu%%",
" <dark_aqua>▪ <gray>General system load average: <white>%load_average%%",
" <dark_aqua>▪ <gray>Total amount of virtual cpus: <white>%virtual_cores%"
))));
messagesConfig.getYaml().setComment("commands.statistics.memory",
"Format of the memory statistics message");
commands.memoryStatistics = formatString(fromList(messagesConfig.getStringList("commands.statistics.memory",
Arrays.asList(
" <gray>▪ <green>Total free memory: <white>%free_memory%",
" <gray>▪ <green>Total used memory: <white>%used_memory%",
" <gray>▪ <green>Total maximum memory: <white>%max_memory%",
" <gray>▪ <green>Total allocated memory: <white>%total_memory%"
" <dark_aqua>▪ <gray>Total free memory: <white>%free_memory%",
" <dark_aqua>▪ <gray>Total used memory: <white>%used_memory%",
" <dark_aqua>▪ <gray>Total maximum memory: <white>%max_memory%",
" <dark_aqua>▪ <gray>Total allocated memory: <white>%total_memory%"
))));
messagesConfig.getYaml().setComment("commands.statistics.network",
"Format of the network statistics message");
commands.networkStatistics = formatString(fromList(messagesConfig.getStringList("commands.statistics.network",
Arrays.asList(
" <gray>▪ <green>Current incoming used bandwidth: <white>%incoming%",
" <gray>▪ <green>Current outgoing used bandwidth: <white>%outgoing%",
" <gray>▪ <green>Total incoming used bandwidth: <white>%ttl_incoming%",
" <gray>▪ <green>Total outgoing used bandwidth: <white>%ttl_outgoing%"
" <dark_aqua>▪ <gray>Current incoming used bandwidth: <white>%incoming%",
" <dark_aqua>▪ <gray>Current outgoing used bandwidth: <white>%outgoing%",
" <dark_aqua>▪ <gray>Total incoming used bandwidth: <white>%ttl_incoming%",
" <dark_aqua>▪ <gray>Total outgoing used bandwidth: <white>%ttl_outgoing%"
))));
messagesConfig.getYaml().setComment("verification",
@@ -709,6 +911,36 @@ public final class SonarConfiguration {
verification.successLog = formatString(messagesConfig.getString("verification.logs.successful",
"%name% has been verified successfully (%time%s!)."));
messagesConfig.getYaml().setComment("verification.welcome",
"Message that is shown to the player when they are being checked for valid gravity");
verification.gravity.youAreBeingChecked = deserialize(formatString(messagesConfig.getString("verification.welcome",
"%prefix%<gray>Please wait a moment for the verification to finish...")));
messagesConfig.getYaml().setComment("verification.captcha.enter-code",
"Message that is shown to the player when they have to enter the answer to the captcha");
verification.map.enterCode = deserialize(formatString(messagesConfig.getString("verification.captcha.enter-code",
"%prefix%<green>Please enter the code in chat that is displayed on the map.")));
messagesConfig.getYaml().setComment("verification.captcha.action-bar",
"Timer that is shown to the player when they have to enter the answer to the captcha"
+ LINE_SEPARATOR + "(Set this to '' to disable the action bar message)");
verification.map.enterCodeActionBar = formatString(messagesConfig.getString("verification.captcha.action-bar",
"%prefix%<green>You have %time-left% seconds left to enter the code in chat"));
messagesConfig.getYaml().setComment("verification.captcha.incorrect",
"Message that is shown to the player when they enter the wrong answer in chat");
verification.map.failedCaptcha = deserialize(formatString(messagesConfig.getString("verification.captcha.incorrect",
"%prefix%<red>You have entered the wrong code. Please try again.")));
messagesConfig.getYaml().setComment("verification.currently-preparing",
"Disconnect message that is shown when someone joins while the captcha hasn't been prepared yet");
verification.currentlyPreparing = deserialize(fromList(messagesConfig.getStringList(
"verification.currently-preparing",
Arrays.asList(
"%header%",
"<yellow>Your anti-bot data has not been prepared yet.",
"<gray>Please wait a few seconds before trying to verify again.",
"%footer%"
))));
messagesConfig.getYaml().setComment("verification.too-many-players",
"Disconnect message that is shown when too many players are verifying at the same time");
verification.tooManyPlayers = deserialize(fromList(messagesConfig.getStringList("verification.too-many-players",
@@ -778,6 +1010,17 @@ public final class SonarConfiguration {
"%footer%"
))));
messagesConfig.getYaml().setComment("verification.blacklisted-protocol",
"Disconnect message that is shown when someone joins with a blacklisted version");
verification.protocolBlacklisted = deserialize(fromList(messagesConfig.getStringList("verification" +
".blacklisted-protocol",
Arrays.asList(
"%header%",
"<red>You are using a version that is not allowed on our server.",
"<gold>Need help logging in? <gray>%support-url%",
"%footer%"
))));
messagesConfig.getYaml().setComment("verification.already-online",
"Disconnect message that is shown when someone tries verifying with an account that is online");
verification.alreadyConnected = deserialize(fromList(messagesConfig.getStringList("verification.already-online",
@@ -811,41 +1054,51 @@ public final class SonarConfiguration {
messagesConfig.getYaml().setComment("verbose",
"Translations for all messages regarding Sonar's verbose output");
messagesConfig.getYaml().setComment("verbose.layout",
"General layout for the verbose action-bar" +
LINE_SEPARATOR + "Placeholders and their descriptions:" +
LINE_SEPARATOR + "- %queued% Number of queued connections" +
LINE_SEPARATOR + "- %verifying% Number of verifying connections" +
LINE_SEPARATOR + "- %blacklisted% Number of blacklisted IP addresses" +
LINE_SEPARATOR + "- %total-joins% Number of total joins (not unique!)" +
LINE_SEPARATOR + "- %per-second-joins% Number of joins per second" +
LINE_SEPARATOR + "- %verify-total% Number of total verification attempts" +
LINE_SEPARATOR + "- %verify-success% Number of verified IP addresses" +
LINE_SEPARATOR + "- %verify-failed% Number of failed verifications" +
LINE_SEPARATOR + "- %incoming-traffic% Incoming bandwidth usage per second" +
LINE_SEPARATOR + "- %outgoing-traffic% Outgoing bandwidth usage per second" +
LINE_SEPARATOR + "- %incoming-traffic-ttl% Total incoming bandwidth usage" +
LINE_SEPARATOR + "- %outgoing-traffic-ttl% Total outgoing bandwidth usage" +
LINE_SEPARATOR + "- %used-memory% Amount of used memory (JVM process)" +
LINE_SEPARATOR + "- %total-memory% Amount of total memory (JVM process)" +
LINE_SEPARATOR + "- %max-memory% Amount of max memory (JVM process)" +
LINE_SEPARATOR + "- %free-memory% Amount of free memory (JVM process)" +
LINE_SEPARATOR + "- %animation% Animated spinning circle (by default)"
);
verbose.actionBarLayout = formatString(messagesConfig.getString("verbose.layout",
messagesConfig.getYaml().setComment("verbose.layout.normal",
"General layout for the verbose action-bar"
+ LINE_SEPARATOR + "Placeholders:"
+ LINE_SEPARATOR + "- '%queued%' Number of queued connections"
+ LINE_SEPARATOR + "- '%verifying%' Number of verifying connections"
+ LINE_SEPARATOR + "- '%blacklisted%' Number of blacklisted IP addresses"
+ LINE_SEPARATOR + "- '%total-joins%' Number of total attempted joins"
+ LINE_SEPARATOR + "- '%verify-total%' Number of total verification attempts"
+ LINE_SEPARATOR + "- '%verify-success%' Number of verified IP addresses"
+ LINE_SEPARATOR + "- '%verify-failed%' Number of failed verifications"
+ LINE_SEPARATOR + "- '%logins-per-second%' Number of logins per second"
+ LINE_SEPARATOR + "- '%connections-per-second%' Number of connections per second"
+ LINE_SEPARATOR + "- '%attack-duration%' Duration of the current attack"
+ LINE_SEPARATOR + "- '%incoming-traffic%' Incoming bandwidth usage per second"
+ LINE_SEPARATOR + "- '%outgoing-traffic%' Outgoing bandwidth usage per second"
+ LINE_SEPARATOR + "- '%incoming-traffic-ttl%' Total incoming bandwidth usage"
+ LINE_SEPARATOR + "- '%outgoing-traffic-ttl%' Total outgoing bandwidth usage"
+ LINE_SEPARATOR + "- '%used-memory%' Amount of used memory of the process"
+ LINE_SEPARATOR + "- '%total-memory%' Amount of total memory of the process"
+ LINE_SEPARATOR + "- '%max-memory%' Amount of max memory of the process"
+ LINE_SEPARATOR + "- '%free-memory%' Amount of free memory of the process"
+ LINE_SEPARATOR + "- '%animation%' Customizable animated symbol"
+ LINE_SEPARATOR + "Translations for Sonar's normal verbose output");
verbose.actionBarLayout = formatString(messagesConfig.getString("verbose.layout.normal",
String.join(" <dark_aqua>╺ ", Arrays.asList(
"%prefix%<gray>Queued <white>%queued%",
"%prefix%<gray>CPS <white>%connections-per-second%",
"<gray>Logins/s <white>%logins-per-second%",
"<gray>Verifying <white>%verifying%"
+ " <dark_aqua>| <green>⬆ <white>%outgoing-traffic%/s <red>⬇ <white>%incoming-traffic%/s"
+ " <green><bold>%animation%<reset>"))));
messagesConfig.getYaml().setComment("verbose.layout.attack",
"Translations for Sonar's verbose output during an active attack");
verbose.actionBarLayoutDuringAttack = formatString(messagesConfig.getString("verbose.layout.attack",
String.join(" <dark_aqua>╺ ", Arrays.asList(
"%prefix%<gray>CPS <white>%connections-per-second%",
"<gray>Logins/s <white>%logins-per-second%",
"<gray>Queue <white>%queued%",
"<gray>Verifying <white>%verifying%",
"<gray>Blacklisted <white>%blacklisted%" +
" <dark_aqua>| <green>⬆ <white>%outgoing-traffic%/s <red>⬇ <white>%incoming-traffic%/s" +
" <green><bold>%animation%<reset>"
))));
messagesConfig.getYaml().setComment("verbose.animation",
"Alternative symbols:"
+ LINE_SEPARATOR + "- ▙"
+ LINE_SEPARATOR + "- ▛"
+ LINE_SEPARATOR + "- ▜"
+ LINE_SEPARATOR + "- ▟");
"<gray>Duration <white>%attack-duration%"
+ " <dark_aqua>| <green>⬆ <white>%outgoing-traffic%/s <red>⬇ <white>%incoming-traffic%/s"
+ " <green><bold>%animation%<reset>"))));
messagesConfig.getYaml().setComment("verbose.animation", "Animation for the action bar"
+ LINE_SEPARATOR + "Alternatives:"
+ LINE_SEPARATOR + "- ▙, ▛, ▜, ▟"
+ LINE_SEPARATOR + "- ⬈, ⬊, ⬋, ⬉");
verbose.animation = Collections.unmodifiableList(messagesConfig.getStringList("verbose.animation",
Arrays.asList("◜", "◝", "◞", "◟")
));
@@ -866,7 +1119,11 @@ public final class SonarConfiguration {
}
private @NotNull String fromList(final @NotNull Collection<String> list) {
return formatString(String.join("<newline>", list));
return fromList(list, "<newline>");
}
private @NotNull String fromList(final @NotNull Collection<String> list, final String newline) {
return formatString(String.join(newline, list));
}
private static @NotNull Component deserialize(final String legacy) {
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -25,6 +25,7 @@ import com.j256.ormlite.table.TableUtils;
import lombok.Getter;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Range;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.config.SonarConfiguration;
import xyz.jonesdev.sonar.api.dependencies.DependencyLoader;
@@ -32,6 +33,9 @@ import xyz.jonesdev.sonar.api.model.VerifiedPlayer;
import java.net.InetAddress;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
@@ -65,11 +69,16 @@ public final class VerifiedPlayerController {
TableUtils.createTableIfNotExists(connectionSource, VerifiedPlayer.class);
} catch (SQLException exception) {
// Duplicate index
// I know this isn't the best method of handling it,
// but I don't know how else I could address this issue.
}
dao = DaoManager.createDao(connectionSource, VerifiedPlayer.class);
queryBuilder = dao.queryBuilder();
// Make sure to clear all outdated entries first
clearOld(Sonar.get().getConfig().getDatabase().getMaximumAge());
// Add all entries from the database to the cache
dao.queryForAll().forEach(this::_add);
} catch (Throwable throwable) {
Sonar.get().getLogger().error("Error setting up database connection: {}", throwable);
@@ -92,7 +101,11 @@ public final class VerifiedPlayerController {
}
DB_UPDATE_SERVICE.execute(() -> {
Objects.requireNonNull(connectionSource);
// We cannot throw a NullPointerException within the executor service
// because we want to handle the error instead of simply throwing an exception
if (connectionSource == null) {
return;
}
try {
final List<VerifiedPlayer> verifiedPlayer = queryBuilder.where()
@@ -134,7 +147,11 @@ public final class VerifiedPlayerController {
}
DB_UPDATE_SERVICE.execute(() -> {
Objects.requireNonNull(connectionSource);
// We cannot throw a NullPointerException within the executor service
// because we want to handle the error instead of simply throwing an exception
if (connectionSource == null) {
return;
}
try {
dao.create(player);
@@ -187,6 +204,44 @@ public final class VerifiedPlayerController {
}
}
/**
* Clear all old entries using the given timestamp.
*/
public void clearOld(final @Range(from = 1, to = 365) int maximumAge) {
// Don't try to update the column if the database type is NONE
if (cachedDatabaseType == SonarConfiguration.Database.Type.NONE) {
return;
}
DB_UPDATE_SERVICE.execute(() -> {
// We cannot throw a NullPointerException within the executor service
// because we want to handle the error instead of simply throwing an exception
if (connectionSource == null) {
return;
}
try {
final long timestamp = Instant.now()
.minus(maximumAge, ChronoUnit.DAYS)
.getEpochSecond() * 1000L; // convert to ms
final List<VerifiedPlayer> oldEntries = queryBuilder.where()
.lt("timestamp", new Timestamp(timestamp))
.query();
if (oldEntries != null) {
for (final VerifiedPlayer player : oldEntries) {
dao.delete(player);
}
Sonar.get().getLogger().info("Removed {} database entries older than {} days.",
oldEntries.size(), maximumAge);
}
} catch (SQLException exception) {
Sonar.get().getLogger().error("Error trying to clear old entries: {}", exception);
}
});
}
/**
* @param inetAddress IP address
* @param uuid UUID associated to the IP
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -38,8 +38,9 @@ public enum Dependency {
MYSQL(
"com/mysql",
"mysql-connector-j",
"8.1.0"
"8.2.0"
),
// TODO: MariaDB still seems to have some issues; investigate!
// https://mvnrepository.com/artifact/org.mariadb.jdbc/mariadb-java-client
MARIADB(
"org/mariadb/jdbc",
@@ -47,10 +48,8 @@ public enum Dependency {
"3.1.4"
);
private @NotNull
final Path tempFilePath;
private @NotNull
final URL mvnRepoURL;
private final Path tempFilePath;
private final URL mvnRepoURL;
Dependency(final @NotNull String groupId,
final @NotNull String artifactId,
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -22,6 +22,7 @@ import com.j256.ormlite.support.ConnectionSource;
import lombok.experimental.UtilityClass;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.SonarPlatform;
import xyz.jonesdev.sonar.api.config.SonarConfiguration;
import java.io.File;
@@ -36,44 +37,42 @@ import java.util.Properties;
@UtilityClass
public class DependencyLoader {
public ConnectionSource setUpDriverAndConnect() throws Throwable {
final SonarConfiguration config = Sonar.get().getConfig();
final SonarConfiguration.Database database = Sonar.get().getConfig().getDatabase();
final URL[] urls = new URL[config.getDatabase().getType().getDependencies().length];
for (final Dependency dependency : config.getDatabase().getType().getDependencies()) {
final URL url = dependency.getClassLoaderURL();
final ClassLoader currentClassLoader = DependencyLoader.class.getClassLoader();
final ClassLoader currentClassLoader = DependencyLoader.class.getClassLoader();
ClassLoader classLoader = currentClassLoader;
// Velocity doesn't include the JDBC driver, which is why we need to load it manually
if (Sonar.get().getPlatform() == SonarPlatform.VELOCITY) {
final URL url = database.getType().getDependency().getClassLoaderURL();
final Method addPath = currentClassLoader.getClass().getDeclaredMethod("addPath", Path.class);
addPath.setAccessible(true);
addPath.invoke(currentClassLoader, new File(url.toURI()).toPath());
urls[dependency.ordinal()] = url;
classLoader = new ExternalClassLoader(new URL[]{url});
}
final String type = config.getDatabase().getType().name().toLowerCase();
final String databaseURL = String.format("jdbc:%s://%s:%d/%s",
type, config.getDatabase().getUrl(), config.getDatabase().getPort(), config.getDatabase().getName());
final String jdbcURL = String.format("jdbc:%s://%s:%d/%s",
database.getType().name().toLowerCase(), database.getUrl(), database.getPort(), database.getName());
final ExternalClassLoader classLoader = new ExternalClassLoader(urls);
final Connection connection = connect(classLoader, databaseURL, config.getDatabase());
return new JdbcSingleConnectionSource(databaseURL, connection);
final Connection connection = connect(classLoader, jdbcURL, database);
return new JdbcSingleConnectionSource(jdbcURL, connection);
}
// Mostly taken from
// https://github.com/Elytrium/LimboAuth/blob/master/src/main/java/net/elytrium/limboauth/dependencies/DatabaseLibrary.java#L134
// https://github.com/Elytrium/LimboAuth/blob/master/src/main/java/net/elytrium/limboauth/dependencies/DatabaseLibrary.java#L137
private Connection connect(final @NotNull ClassLoader classLoader,
final @NotNull String databaseURL,
final @NotNull SonarConfiguration.Database config) throws Throwable {
final Class<?> driverClass = classLoader.loadClass(config.getType().getDriverClassName());
final @NotNull SonarConfiguration.Database database) throws Throwable {
final Class<?> driverClass = classLoader.loadClass(database.getType().getDriverClassName());
final Object driver = driverClass.getDeclaredConstructor().newInstance();
DriverManager.registerDriver((Driver) driver);
final Properties properties = new Properties();
properties.put("user", config.getUsername());
if (!config.getPassword().isEmpty()) {
properties.put("password", config.getPassword());
}
properties.put("user", database.getUsername());
properties.put("password", database.getPassword());
final Method connect = driverClass.getDeclaredMethod("connect", String.class, Properties.class);
connect.setAccessible(true);
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -21,24 +21,40 @@ import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.Sonar;
import java.util.Arrays;
import java.util.Collection;
import java.util.Vector;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class SonarEventManager {
public static final SonarEventManager INSTANCE = new SonarEventManager();
private static final Collection<SonarEventListener> EVENT_LISTENERS = new Vector<>(0);
private static final ExecutorService EVENT_SERVICE = Executors.newSingleThreadExecutor();
/**
* Publishes the given event to all listeners
*/
@ApiStatus.Internal
public void publish(final @NotNull SonarEvent event) {
for (final SonarEventListener eventListener : EVENT_LISTENERS) {
eventListener.handle(event);
// Don't post a task if there are no listeners
if (EVENT_LISTENERS.isEmpty()) {
return;
}
EVENT_SERVICE.execute(() -> {
for (final SonarEventListener eventListener : EVENT_LISTENERS) {
try {
eventListener.handle(event);
} catch (Throwable throwable) {
Sonar.get().getLogger().error("Could not pass {} to listener: {}",
event.getClass().getSimpleName(), throwable);
}
}
});
}
/**
@@ -47,6 +63,7 @@ public final class SonarEventManager {
* @param listeners One (or more) listeners to register
* @see #unregisterListener(SonarEventListener...) Unregister an event listener
*/
@SuppressWarnings("unused") // External API usage
public synchronized void registerListener(final @NotNull SonarEventListener... listeners) {
EVENT_LISTENERS.addAll(Arrays.asList(listeners));
}
@@ -58,6 +75,7 @@ public final class SonarEventManager {
* @apiNote This does not have any effect if the given listeners are not registered
* @see #registerListener(SonarEventListener...) Register an event listener
*/
@SuppressWarnings("unused") // External API usage
public synchronized void unregisterListener(final @NotNull SonarEventListener... listeners) {
EVENT_LISTENERS.removeAll(Arrays.asList(listeners));
}
@@ -0,0 +1,23 @@
/*
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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 3 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, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.api.event.impl;
import xyz.jonesdev.sonar.api.event.SonarEvent;
public final class AttackDetectedEvent implements SonarEvent {
}
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -15,28 +15,19 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.api.server;
package xyz.jonesdev.sonar.api.event.impl;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import xyz.jonesdev.sonar.api.SonarPlatform;
import xyz.jonesdev.sonar.api.command.InvocationSource;
import java.util.Optional;
import lombok.ToString;
import xyz.jonesdev.sonar.api.attack.AttackTracker;
import xyz.jonesdev.sonar.api.event.SonarEvent;
@Getter
@ToString
@EqualsAndHashCode
@RequiredArgsConstructor
public abstract class ServerWrapper {
/**
* Platform of the server (Velocity, BungeeCord, Spigot)
*/
private final SonarPlatform platform;
/**
* @param username Username of the player
* @return Optional player wrapped as InvocationSender
* @see InvocationSource
*/
public abstract Optional<InvocationSource> getOnlinePlayer(final String username);
public final class AttackMitigatedEvent implements SonarEvent {
private final AttackTracker.AttackStatistics attackStatistics;
}
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -31,5 +31,5 @@ import xyz.jonesdev.sonar.api.fallback.FallbackUser;
@RequiredArgsConstructor
public final class UserBlacklistedEvent implements SonarEvent {
@ApiStatus.Experimental
private final FallbackUser<?, ?> user;
private final FallbackUser<?> user;
}
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -19,16 +19,23 @@ package xyz.jonesdev.sonar.api.event.impl;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.ToString;
import org.jetbrains.annotations.Nullable;
import xyz.jonesdev.sonar.api.event.SonarEvent;
import xyz.jonesdev.sonar.api.fallback.FallbackUser;
import java.util.Optional;
@Getter
@ToString
@EqualsAndHashCode
@RequiredArgsConstructor
public final class UserVerifyFailedEvent implements SonarEvent {
private final FallbackUser<?, ?> user;
private final String reason;
private final FallbackUser<?> user;
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
private final Optional<String> reason;
public UserVerifyFailedEvent(final FallbackUser<?> user, final @Nullable String reason) {
this.user = user;
this.reason = Optional.ofNullable(reason);
}
}
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -30,5 +30,5 @@ import xyz.jonesdev.sonar.api.fallback.FallbackUser;
@RequiredArgsConstructor
public final class UserVerifyJoinEvent implements SonarEvent {
private final String username;
private final FallbackUser<?, ?> user;
private final FallbackUser<?> user;
}
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -33,6 +33,6 @@ import java.util.UUID;
public final class UserVerifySuccessEvent implements SonarEvent {
private final String username;
private final UUID offlineUuid;
private final FallbackUser<?, ?> user;
private final FallbackUser<?> user;
private final long timeTakenToVerify;
}
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -24,6 +24,7 @@ import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.cappuccino.Cappuccino;
import xyz.jonesdev.cappuccino.ExpiringCache;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.config.SonarConfiguration;
import xyz.jonesdev.sonar.api.logger.LoggerWrapper;
import java.net.InetAddress;
@@ -38,11 +39,10 @@ public final class Fallback {
public static final Fallback INSTANCE = new Fallback();
private static final @NotNull Sonar SONAR = Objects.requireNonNull(Sonar.get());
private final Map<String, InetAddress> connected = new ConcurrentHashMap<>();
private final Map<String, InetAddress> connected = new ConcurrentHashMap<>(64, 0.75f);
// Only block the player for a few minutes to avoid issues
private final ExpiringCache<String> blacklisted = Cappuccino.buildExpiring(
10L, TimeUnit.MINUTES, 2500L
);
private final ExpiringCache<InetAddress> blacklisted = Cappuccino.buildExpiring(
10L, TimeUnit.MINUTES, 5000L);
private final @NotNull FallbackQueue queue = FallbackQueue.INSTANCE;
private final @NotNull FallbackRatelimiter ratelimiter = FallbackRatelimiter.INSTANCE;
@@ -64,8 +64,18 @@ public final class Fallback {
}
};
public boolean isPotentiallyUnderAttack() {
final int min = SONAR.getConfig().getMinPlayersForAttack();
return connected.size() > min || queue.getQueuedPlayers().size() > min;
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
public boolean shouldVerifyNewPlayers() {
return performTimingCheck(Sonar.get().getConfig().getVerification().getTiming());
}
public boolean shouldDoMapCaptcha() {
return performTimingCheck(Sonar.get().getConfig().getVerification().getMap().getTiming());
}
private boolean performTimingCheck(final SonarConfiguration.Verification.Timing timing) {
return timing == SonarConfiguration.Verification.Timing.ALWAYS
|| (timing == SonarConfiguration.Verification.Timing.DURING_ATTACK
&& Sonar.get().getAttackTracker().isCurrentlyUnderAttack());
}
}
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -50,14 +50,14 @@ public final class FallbackQueue {
}
public void poll() {
final int max = Sonar.get().getConfig().getQueue().getMaxQueuePolls();
final int maxQueuePolls = Sonar.get().getConfig().getQueue().getMaxQueuePolls();
int index = 0;
// We need to be very careful here since we don't want any concurrency issues.
// We need to be cautious here since we don't want any concurrency issues.
final Iterator<Map.Entry<InetAddress, Runnable>> iterator = queuedPlayers.entrySet().iterator();
while (iterator.hasNext()) {
// Break if we reached our maximum entries
if (++index > max) {
if (++index > maxQueuePolls) {
break;
}
// Run the cached runnable
@@ -66,5 +66,10 @@ public final class FallbackQueue {
// Remove runnable from iterator
iterator.remove();
}
// Run the attack check task
Sonar.get().getAttackTracker().checkIfUnderAttack();
// Clean up the cache of rate-limited IPs
Sonar.get().getFallback().getRatelimiter().cleanUpCache();
}
}
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -31,18 +31,26 @@ public final class FallbackRatelimiter {
@Setter
private ExpiringCache<InetAddress> expiringCache;
/**
* We don't want to clean up the cache in the
* {@link #attempt(InetAddress)} method because
* it would take up too many resources.
*/
public void cleanUpCache() {
expiringCache.cleanUp(false);
}
/**
* Checks if the player has tried verifying too fast
*
* @param inetAddress IP address of the player
* @return Whether the player is allowed to verify
*/
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
public boolean attempt(final @NotNull InetAddress inetAddress) {
// Clean up the cache
expiringCache.cleanUp(false);
// Cache the IP address if it's not already cached
if (!expiringCache.has(inetAddress)) {
// Cache the IP address
expiringCache.put(inetAddress);
return true;
}
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -33,12 +33,10 @@ import xyz.jonesdev.sonar.api.statistics.Statistics;
import java.net.InetAddress;
import java.util.concurrent.TimeUnit;
public interface FallbackUser<X, Y> {
public interface FallbackUser<T> {
@NotNull Fallback getFallback();
@NotNull X getPlayer();
@NotNull Y getConnection();
@NotNull T getConnection();
@NotNull Channel getChannel();
@@ -92,9 +90,6 @@ public interface FallbackUser<X, Y> {
*/
default void fail(final @Nullable String reason) {
if (getChannel().isActive()) {
// Call the VerifyFailedEvent for external API usage
Sonar.get().getEventManager().publish(new UserVerifyFailedEvent(this, reason));
disconnect(Sonar.get().getConfig().getVerification().getVerificationFailed());
if (reason != null) {
@@ -107,6 +102,9 @@ public interface FallbackUser<X, Y> {
Statistics.FAILED_VERIFICATIONS.increment();
// Call the VerifyFailedEvent for external API usage
Sonar.get().getEventManager().publish(new UserVerifyFailedEvent(this, reason));
// Make sure old entries are removed
PREVIOUS_FAILS.cleanUp();
@@ -115,7 +113,7 @@ public interface FallbackUser<X, Y> {
// Call the BotBlacklistedEvent for external API usage
Sonar.get().getEventManager().publish(new UserBlacklistedEvent(this));
getFallback().getBlacklisted().put(getInetAddress().toString());
getFallback().getBlacklisted().put(getInetAddress());
getFallback().getLogger().info(Sonar.get().getConfig().getVerification().getBlacklistLog()
.replace("%ip%", Sonar.get().getConfig().formatAddress(getInetAddress()))
.replace("%protocol%", String.valueOf(getProtocolVersion().getProtocol())));
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -20,11 +20,9 @@ package xyz.jonesdev.sonar.api.fallback.protocol;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.*;
@Getter
@RequiredArgsConstructor
public enum ProtocolVersion {
UNKNOWN(-1),
@@ -67,12 +65,13 @@ public enum ProtocolVersion {
MINECRAFT_1_19_3(761),
MINECRAFT_1_19_4(762),
MINECRAFT_1_20(763),
MINECRAFT_1_20_2(764);
MINECRAFT_1_20_2(764),
MINECRAFT_1_20_3(765);
@Getter
private final int protocol;
public static final ProtocolVersion MINIMUM_VERSION = MINECRAFT_1_7_2;
public static final ProtocolVersion LATEST_VERSION;
public static final Map<Integer, ProtocolVersion> ID_TO_PROTOCOL_CONSTANT;
public static final Set<ProtocolVersion> SUPPORTED_VERSIONS;
@@ -96,7 +95,8 @@ public enum ProtocolVersion {
}
}
SUPPORTED_VERSIONS = versions;
SUPPORTED_VERSIONS = Collections.unmodifiableSet(versions);
LATEST_VERSION = (ProtocolVersion) versions.toArray()[versions.size() - 1];
}
}
@@ -104,10 +104,15 @@ public enum ProtocolVersion {
return ID_TO_PROTOCOL_CONSTANT.getOrDefault(protocol, UNKNOWN);
}
public boolean inBetween(final ProtocolVersion first, final ProtocolVersion last) {
return compareTo(first) >= 0 && compareTo(last) <= 0;
}
public boolean isUnknown() {
return this == UNKNOWN;
}
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
public boolean isLegacy() {
return this == LEGACY;
}
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -17,6 +17,10 @@
package xyz.jonesdev.sonar.api.logger;
import org.jetbrains.annotations.NotNull;
import java.lang.reflect.Array;
/**
* The whole purpose of doing this is, so we can have one logger for every module.
* We wouldn't need to get the plugin logger every time we try to use it.
@@ -28,4 +32,62 @@ public interface LoggerWrapper {
void warn(final String message, final Object... args);
void error(final String message, final Object... args);
// Taken from
// https://github.com/j256/ormlite-core/blob/master/src/main/java/com/j256/ormlite/logger/Logger.java
String ARG_STRING = "{}";
int ARG_STRING_LENGTH = ARG_STRING.length();
Object UNKNOWN_ARG = new Object();
default String buildFullMessage(final @NotNull String msg, final Object... args) {
StringBuilder sb = null;
int lastIndex = 0;
int argC = 0;
while (true) {
int argIndex = msg.indexOf(ARG_STRING, lastIndex);
// no more {} arguments?
if (argIndex == -1) {
break;
}
if (sb == null) {
// we build this lazily in case there is no {} in the msg
sb = new StringBuilder(128);
}
// add the string before the arg-string
sb.append(msg, lastIndex, argIndex);
// shift our last-index past the arg-string
lastIndex = argIndex + ARG_STRING_LENGTH;
// add the arguments
if (argC < args.length) {
appendArg(sb, args[argC]);
}
argC++;
}
if (sb == null) {
return msg;
} else {
sb.append(msg, lastIndex, msg.length());
return sb.toString();
}
}
default void appendArg(final StringBuilder stringBuilder, final Object arg) {
if (arg == UNKNOWN_ARG) {
// ignore it
} else if (arg == null) {
stringBuilder.append("null");
} else if (arg.getClass().isArray()) {
stringBuilder.append('[');
int length = Array.getLength(arg);
for (int i = 0; i < length; i++) {
if (i > 0) {
stringBuilder.append(", ");
}
appendArg(stringBuilder, Array.get(arg, i));
}
stringBuilder.append(']');
} else {
stringBuilder.append(arg);
}
}
}
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -24,12 +24,13 @@ import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
import java.sql.Timestamp;
import java.util.UUID;
@Getter
@ToString
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@DatabaseTable(tableName = "verified_players")
@DatabaseTable(tableName = "sonar_verified_players")
public final class VerifiedPlayer {
@SuppressWarnings("unused")
@DatabaseField(generatedId = true)
@@ -51,12 +52,15 @@ public final class VerifiedPlayer {
)
private UUID playerUUID;
@DatabaseField(columnName = "timestamp", canBeNull = false)
private long timestamp;
@DatabaseField(
columnName = "timestamp",
canBeNull = false
)
private Timestamp timestamp;
public VerifiedPlayer(final String inetAddress, final UUID playerUUID, final long timestamp) {
this.inetAddress = inetAddress;
this.playerUUID = playerUUID;
this.timestamp = timestamp;
this.timestamp = new Timestamp(timestamp);
}
}
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -26,11 +26,16 @@ public interface JVMProfiler {
Runtime RUNTIME = Runtime.getRuntime();
OperatingSystemMXBean OPERATING_SYSTEM_MX_BEAN = (OperatingSystemMXBean) ManagementFactory.getOperatingSystemMXBean();
RuntimeMXBean RUNTIME_MX_BEAN = ManagementFactory.getRuntimeMXBean();
char[] MEMORY_UNITS = {'k', 'M', 'G', 'T', 'P', 'E'};
default String formatMemory(final long m) {
if (m < 1024L) return m + " B";
final int z = (63 - Long.numberOfLeadingZeros(m)) / 10;
return String.format("%.1f %sB", (double) m / (1L << (z * 10)), " KMGTPE".charAt(z));
default String formatMemory(final long size) {
if (size < 1024L) {
return size + " B";
}
final int group = (63 - Long.numberOfLeadingZeros(size)) / 10;
final double formattedSize = (double) size / (1L << (group * 10));
final char unit = MEMORY_UNITS[group - 1];
return String.format("%.1f %sB", formattedSize, unit);
}
default int getVirtualCores() {
@@ -0,0 +1,28 @@
/*
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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 3 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, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.api.statistics;
import xyz.jonesdev.cappuccino.Cappuccino;
import xyz.jonesdev.cappuccino.ExpiringCache;
import java.util.concurrent.TimeUnit;
public interface Counters {
ExpiringCache<Long> LOGINS_PER_SECOND = Cappuccino.buildExpiring(1L, TimeUnit.SECONDS);
ExpiringCache<Long> CONNECTIONS_PER_SECOND = Cappuccino.buildExpiring(1L, TimeUnit.SECONDS);
}
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -18,9 +18,14 @@
package xyz.jonesdev.sonar.api.timer;
import lombok.Getter;
import org.jetbrains.annotations.NotNull;
import java.text.SimpleDateFormat;
@Getter
public final class SystemTimer {
private static final SimpleDateFormat FORMATTER = new SimpleDateFormat("mm:ss");
private long start = System.currentTimeMillis();
public void reset() {
@@ -35,6 +40,10 @@ public final class SystemTimer {
return delay() >= amount;
}
public @NotNull String formattedDelay() {
return FORMATTER.format(delay());
}
@Override
public String toString() {
return String.format("%.3f", delay() / 1000D);
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -19,59 +19,71 @@ package xyz.jonesdev.sonar.api.verbose;
import lombok.Getter;
import lombok.val;
import net.kyori.adventure.audience.Audience;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.minimessage.MiniMessage;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.attack.AttackTracker;
import xyz.jonesdev.sonar.api.profiler.JVMProfiler;
import xyz.jonesdev.sonar.api.statistics.Counters;
import xyz.jonesdev.sonar.api.statistics.Statistics;
import xyz.jonesdev.sonar.api.timer.SystemTimer;
import java.util.Collection;
import java.util.Map;
import java.util.Vector;
import java.util.concurrent.ConcurrentHashMap;
import static xyz.jonesdev.sonar.api.Sonar.DECIMAL_FORMAT;
import static xyz.jonesdev.sonar.api.fallback.traffic.TrafficCounter.INCOMING;
import static xyz.jonesdev.sonar.api.fallback.traffic.TrafficCounter.OUTGOING;
@Getter
public abstract class Verbose implements JVMProfiler {
protected final @NotNull Collection<String> subscribers = new Vector<>(0);
private final SystemTimer secondTimer = new SystemTimer();
protected int joinsPerSecond, totalJoins;
private int lastTotalJoins, animationIndex;
public final class Verbose implements JVMProfiler, Counters {
private final @NotNull Collection<String> subscribers = new Vector<>(0);
private final @NotNull Map<String, Audience> audiences = new ConcurrentHashMap<>();
private int animationIndex;
// Run action bar verbose
public final void update() {
public void update() {
// Clean up all blacklisted IPs
Sonar.get().getFallback().getBlacklisted().cleanUp(false);
// Clean up all counters
LOGINS_PER_SECOND.cleanUp(false);
CONNECTIONS_PER_SECOND.cleanUp(false);
totalJoins = Statistics.TOTAL_TRAFFIC.get();
// Statistically determine the joins per second without any caches
if (totalJoins > 0 && secondTimer.elapsed(1000L)) {
secondTimer.reset();
joinsPerSecond = totalJoins - lastTotalJoins;
lastTotalJoins = totalJoins;
}
// Don't prepare component if there are no subscribers
if (subscribers.isEmpty()) return;
// Prepare the action bar format component
final Component component = prepareActionBarFormat();
// Send the action bar to all online players
broadcast(prepareActionBarFormat());
for (final String subscriber : subscribers) {
final Audience audience = audiences.get(subscriber);
if (audience == null) continue;
audience.sendActionBar(component);
}
}
protected @NotNull Component prepareActionBarFormat() {
return MiniMessage.miniMessage().deserialize(Sonar.get().getConfig().getVerbose().getActionBarLayout()
public @NotNull Component prepareActionBarFormat() {
final AttackTracker.AttackStatistics attackStatistics = Sonar.get().getAttackTracker().getCurrentAttack();
final SystemTimer attackDuration = attackStatistics == null ? null : attackStatistics.getDuration();
return MiniMessage.miniMessage().deserialize((attackDuration != null
? Sonar.get().getConfig().getVerbose().getActionBarLayoutDuringAttack()
: Sonar.get().getConfig().getVerbose().getActionBarLayout())
.replace("%queued%",
DECIMAL_FORMAT.format(Sonar.get().getFallback().getQueue().getQueuedPlayers().size()))
.replace("%verifying%", DECIMAL_FORMAT.format(Sonar.get().getFallback().getConnected().size()))
.replace("%blacklisted%",
DECIMAL_FORMAT.format(Sonar.get().getFallback().getBlacklisted().estimatedSize()))
.replace("%total-joins%", DECIMAL_FORMAT.format(totalJoins))
.replace("%per-second-joins%", DECIMAL_FORMAT.format(joinsPerSecond))
.replace("%total-joins%", DECIMAL_FORMAT.format(Statistics.TOTAL_TRAFFIC.get()))
.replace("%logins-per-second%", DECIMAL_FORMAT.format(LOGINS_PER_SECOND.estimatedSize()))
.replace("%connections-per-second%", DECIMAL_FORMAT.format(CONNECTIONS_PER_SECOND.estimatedSize()))
.replace("%verify-total%", DECIMAL_FORMAT.format(Statistics.REAL_TRAFFIC.get()))
.replace("%verify-success%",
DECIMAL_FORMAT.format(Sonar.get().getVerifiedPlayerController().estimatedSize()))
.replace("%verify-failed%", DECIMAL_FORMAT.format(Statistics.FAILED_VERIFICATIONS.get()))
.replace("%attack-duration%", attackDuration == null ? "00:00" : attackDuration.formattedDelay())
.replace("%incoming-traffic%", INCOMING.getCachedSecond())
.replace("%outgoing-traffic%", OUTGOING.getCachedSecond())
.replace("%incoming-traffic-ttl%", INCOMING.getCachedTtl())
@@ -83,34 +95,31 @@ public abstract class Verbose implements JVMProfiler {
.replace("%animation%", nextAnimation()));
}
protected final String nextAnimation() {
public String nextAnimation() {
val animations = Sonar.get().getConfig().getVerbose().getAnimation();
final int nextIndex = ++animationIndex % animations.size();
return animations.toArray(new String[0])[nextIndex];
}
// Run action bar verbose
protected abstract void broadcast(final Component component);
/**
* @param subscriber Name of the player who subscribed
* @return Whether the player is subscribed or not
* @param name Name of the audience
* @return Whether the audience is subscribed or not
*/
public final boolean isSubscribed(final @NotNull String subscriber) {
return subscribers.contains(subscriber);
public boolean isSubscribed(final @NotNull String name) {
return subscribers.contains(name);
}
/**
* @param subscriber Name of the player to subscribe
* @param name Name of the audience to subscribe
*/
public final void subscribe(final @NotNull String subscriber) {
subscribers.add(subscriber);
public void subscribe(final String name) {
subscribers.add(name);
}
/**
* @param subscriber Name of the player to unsubscribe
* @param name Name of the audience to unsubscribe
*/
public final void unsubscribe(final @NotNull String subscriber) {
subscribers.remove(subscriber);
public void unsubscribe(final String name) {
subscribers.remove(name);
}
}
@@ -0,0 +1,123 @@
/*
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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 3 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, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.api.webhook;
import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.config.SonarConfiguration;
import javax.net.ssl.HttpsURLConnection;
import java.io.OutputStream;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Supplier;
@RequiredArgsConstructor
public final class DiscordWebhook {
private final String url;
private static final ExecutorService HTTP_REQUEST_SERVICE = Executors.newSingleThreadExecutor();
public void post(final Supplier<SonarConfiguration.Webhook.Embed> embed) {
HTTP_REQUEST_SERVICE.execute(() -> {
try {
final String serializedContent = prepareSerializedContent(embed.get());
final HttpsURLConnection urlConnection = prepareConnection(new URL(url), serializedContent);
try (final OutputStream outputStream = urlConnection.getOutputStream()) {
outputStream.write(serializedContent.getBytes(StandardCharsets.UTF_8));
}
// Check if the connection was a success
if (urlConnection.getResponseCode() != 204) {
throw new IllegalStateException("Unexpected response code " + urlConnection.getResponseCode());
}
} catch (Exception exception) {
Sonar.get().getLogger().error("Could not send webhook: {}", exception);
}
});
}
private String prepareSerializedContent(final SonarConfiguration.Webhook.@NotNull Embed embed) {
final String content = Sonar.get().getConfig().getWebhook().getContent();
final String username = Sonar.get().getConfig().getWebhook().getUsername();
final String avatarUrl = Sonar.get().getConfig().getWebhook().getAvatarUrl();
int rgb = embed.getR();
rgb = (rgb << 8) + embed.getG();
rgb = (rgb << 8) + embed.getB();
final Webhook.EmbedMessage embedMessage = new Webhook.EmbedMessage(
embed.getTitle(), embed.getDescription(), embed.getTitleUrl(), rgb, new Webhook.Field[0],
new Webhook.Footer(Sonar.get().getConfig().getWebhook().getFooter().getText(),
Sonar.get().getConfig().getWebhook().getFooter().getIconUrl()));
final List<Webhook.EmbedMessage> embeds = Collections.singletonList(embedMessage);
final Webhook webhook = new Webhook(content, username, avatarUrl, false, embeds);
return Sonar.GENERAL_GSON.toJson(webhook);
}
private @NotNull HttpsURLConnection prepareConnection(final @NotNull URL url,
final @NotNull String serializedContent) throws Exception {
final HttpsURLConnection urlConnection = (HttpsURLConnection) url.openConnection();
// Set connection timeout
urlConnection.setConnectTimeout(15000);
// Set necessary properties
urlConnection.setRequestProperty("Content-Type", "application/json");
urlConnection.setRequestProperty("Content-Length", String.valueOf(serializedContent.length()));
urlConnection.setRequestProperty("User-Agent", "Sonar");
urlConnection.setDoOutput(true);
urlConnection.setRequestMethod("POST");
return urlConnection;
}
@SuppressWarnings("unused")
@RequiredArgsConstructor
static final class Webhook {
private final String content, username, avatar_url;
private final boolean tts;
private final List<EmbedMessage> embeds;
@RequiredArgsConstructor
static final class EmbedMessage {
private final String title;
private final String description;
private final String url;
private final int color;
private final Field[] fields;
private final Footer footer;
}
@RequiredArgsConstructor
static final class Field {
private final String name;
private final String value;
private final boolean inline;
}
@RequiredArgsConstructor
static final class Footer {
private final String text;
private final String icon_url;
}
}
}
+2 -3
View File
@@ -7,17 +7,16 @@ plugins {
apply(plugin = "net.minecrell.plugin-yml.bukkit")
bukkit {
name = rootProject.name
name = "Sonar"
version = rootProject.version.toString().split("-")[0]
main = "xyz.jonesdev.sonar.bukkit.SonarBukkitPlugin"
authors = listOf("Jones Development", "Sonar Contributors")
website = "https://jonesdev.xyz/discord/"
description = "Effective Anti-bot plugin for Velocity, BungeeCord, and Bukkit (1.7-latest)"
load = BukkitPluginDescription.PluginLoadOrder.POSTWORLD
commands {
register("sonar") {
// No permission check here
// No permission checks here
}
}
}
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -18,31 +18,31 @@
package xyz.jonesdev.sonar.bukkit;
import lombok.Getter;
import net.kyori.adventure.platform.bukkit.BukkitAudiences;
import org.bstats.bukkit.Metrics;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.SonarPlatform;
import xyz.jonesdev.sonar.api.command.InvocationSource;
import xyz.jonesdev.sonar.api.fallback.traffic.TrafficCounter;
import xyz.jonesdev.sonar.api.logger.LoggerWrapper;
import xyz.jonesdev.sonar.api.server.ServerWrapper;
import xyz.jonesdev.sonar.bukkit.command.BukkitInvocationSource;
import xyz.jonesdev.sonar.bukkit.audience.AudienceListener;
import xyz.jonesdev.sonar.bukkit.command.BukkitSonarCommand;
import xyz.jonesdev.sonar.bukkit.verbose.ActionBarVerbose;
import xyz.jonesdev.sonar.common.boot.SonarBootstrap;
import java.util.Objects;
import java.util.Optional;
import java.util.logging.Level;
@Getter
public final class SonarBukkit extends SonarBootstrap<SonarBukkitPlugin> {
public static SonarBukkit INSTANCE;
public SonarBukkit(final @NotNull SonarBukkitPlugin plugin) {
super(plugin, plugin.getDataFolder(), new ActionBarVerbose(plugin.getServer()));
super(plugin, plugin.getDataFolder(), SonarPlatform.BUKKIT);
INSTANCE = this;
}
/**
* Wrapper for Bukkit audiences
*/
private final BukkitAudiences bukkitAudiences = BukkitAudiences.create(getPlugin());
/**
* Create a wrapper for the plugin logger, so we can use it outside
* the velocity module.
@@ -53,34 +53,17 @@ public final class SonarBukkit extends SonarBootstrap<SonarBukkitPlugin> {
@Override
public void info(final String message, final Object... args) {
getPlugin().getLogger().log(Level.INFO, message, args);
getPlugin().getLogger().info(buildFullMessage(message, args));
}
@Override
public void warn(final String message, final Object... args) {
getPlugin().getLogger().log(Level.WARNING, message, args);
getPlugin().getLogger().warning(buildFullMessage(message, args));
}
@Override
public void error(final String message, final Object... args) {
getPlugin().getLogger().log(Level.SEVERE, message, args);
}
};
/**
* Create a wrapper object for our server, so we can use it outside
* the velocity module.
* <br>
* We have to do this, so we can access all necessary API functions.
*/
public final ServerWrapper server = new ServerWrapper(SonarPlatform.BUKKIT) {
@Override
public Optional<InvocationSource> getOnlinePlayer(final String username) {
return getPlugin().getServer().getOnlinePlayers().stream()
.filter(player -> player.getName().equalsIgnoreCase(username))
.findFirst()
.map(BukkitInvocationSource::new);
getPlugin().getLogger().severe(buildFullMessage(message, args));
}
};
@@ -88,21 +71,12 @@ public final class SonarBukkit extends SonarBootstrap<SonarBukkitPlugin> {
public void enable() {
// Initialize bStats.org metrics
new Metrics(getPlugin(), getServer().getPlatform().getMetricsId());
new Metrics(getPlugin(), getPlatform().getMetricsId());
// Register Sonar command
Objects.requireNonNull(getPlugin().getCommand("sonar")).setExecutor(new BukkitSonarCommand());
// Register Fallback queue task
getPlugin().getServer().getScheduler().runTaskTimerAsynchronously(getPlugin(), getFallback().getQueue()::poll,
10L, 10L);
// Register traffic counter reset task
getPlugin().getServer().getScheduler().runTaskTimerAsynchronously(getPlugin(), TrafficCounter::reset,
20L, 20L);
// Register action bar verbose task
getPlugin().getServer().getScheduler().runTaskTimerAsynchronously(getPlugin(), getVerboseHandler()::update,
2L, 2L);
// Register audience register listener
getPlugin().getServer().getPluginManager().registerEvents(new AudienceListener(), getPlugin());
}
}
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -0,0 +1,42 @@
/*
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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 3 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, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.bukkit.audience;
import net.kyori.adventure.audience.Audience;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.bukkit.SonarBukkit;
public final class AudienceListener implements Listener {
@EventHandler(priority = EventPriority.HIGHEST)
public void handle(final @NotNull PlayerJoinEvent event) {
final Audience audience = SonarBukkit.INSTANCE.getBukkitAudiences().player(event.getPlayer());
Sonar.get().getVerboseHandler().getAudiences().put(event.getPlayer().getName(), audience);
}
@EventHandler(priority = EventPriority.HIGHEST)
public void handle(final @NotNull PlayerQuitEvent event) {
Sonar.get().getVerboseHandler().getAudiences().remove(event.getPlayer().getName());
}
}
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -17,16 +17,17 @@
package xyz.jonesdev.sonar.bukkit.command;
import net.kyori.adventure.platform.bukkit.BukkitAudiences;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.command.InvocationSource;
import xyz.jonesdev.sonar.bukkit.SonarBukkit;
public final class BukkitInvocationSource extends InvocationSource {
private static final BukkitAudiences AUDIENCES = BukkitAudiences.create(SonarBukkit.INSTANCE.getPlugin());
public BukkitInvocationSource(final @NotNull CommandSender sender) {
super(sender.getName(), AUDIENCES.sender(sender));
super(sender.getName(),
SonarBukkit.INSTANCE.getBukkitAudiences().sender(sender),
sender instanceof Player,
sender::hasPermission);
}
}
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -17,28 +17,19 @@
package xyz.jonesdev.sonar.bukkit.command;
import net.kyori.adventure.text.Component;
import org.bukkit.command.*;
import org.bukkit.entity.Player;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabExecutor;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.command.CommandInvocation;
import xyz.jonesdev.sonar.api.command.InvocationSource;
import xyz.jonesdev.sonar.api.command.SonarCommand;
import xyz.jonesdev.sonar.api.command.subcommand.Subcommand;
import xyz.jonesdev.sonar.api.command.subcommand.argument.Argument;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import static java.util.Collections.emptyList;
public final class BukkitSonarCommand implements CommandExecutor, TabExecutor, SonarCommand {
{
cacheHelpMessage();
}
@Override
public boolean onCommand(final CommandSender sender,
@@ -47,97 +38,13 @@ public final class BukkitSonarCommand implements CommandExecutor, TabExecutor, S
final String[] args) {
// Create our own invocation source wrapper to handle messages properly
final InvocationSource invocationSource = new BukkitInvocationSource(sender);
if (!(sender instanceof ConsoleCommandSender)) {
// Check if the player actually has the permission to run the command
if (!sender.hasPermission("sonar.command")) {
invocationSource.sendMessage(Sonar.get().getConfig().getNoPermission());
return false;
}
// Checking if it contains will only break more since it can throw
// a NullPointerException if the cache is being accessed from parallel threads
DELAY.cleanUp(); // Clean up the cache
final long mapTimestamp = DELAY.asMap().getOrDefault(sender, -1L);
// There were some exploits with spamming commands in the past.
// Spamming should be prevented, especially if some heavy operations are done,
// which is not the case here but let's still stay safe!
if (mapTimestamp > 0L) {
invocationSource.sendMessage(Sonar.get().getConfig().getCommands().getCommandCoolDown());
// Format delay
final long timestamp = System.currentTimeMillis();
final double left = 0.5D - (timestamp - mapTimestamp) / 1000D;
invocationSource.sendMessage(Sonar.get().getConfig().getCommands().getCommandCoolDownLeft()
.replace("%time-left%", Sonar.DECIMAL_FORMAT.format(left)));
return false;
}
DELAY.put(sender);
}
Optional<Subcommand> subcommand = Optional.empty();
if (args.length > 0) {
// Search subcommand if command arguments are present
subcommand = Sonar.get().getSubcommandRegistry().getSubcommands().parallelStream()
.filter(sub -> sub.getInfo().name().equalsIgnoreCase(args[0])
|| Arrays.stream(sub.getInfo().aliases())
.anyMatch(alias -> alias.equalsIgnoreCase(args[0])))
.findFirst();
// Check permissions for subcommands
if (subcommand.isPresent()) {
if (!subcommand.get().getInfo().onlyConsole()
&& !sender.hasPermission(subcommand.get().getPermission())
) {
invocationSource.sendMessage(Sonar.get().getConfig().getCommands().getSubCommandNoPerm()
.replace("%permission%", subcommand.get().getPermission()));
return false;
}
}
}
subcommand.ifPresent(sub -> {
if (sub.getInfo().onlyPlayers() && !(sender instanceof Player)) {
invocationSource.sendMessage(Sonar.get().getConfig().getCommands().getPlayersOnly());
return;
}
if (sub.getInfo().onlyConsole() && !(sender instanceof ConsoleCommandSender)) {
invocationSource.sendMessage(Sonar.get().getConfig().getCommands().getConsoleOnly());
return;
}
final CommandInvocation commandInvocation = new CommandInvocation(invocationSource, sub, args);
// The subcommands has arguments which are not present in the executed command
if (sub.getInfo().arguments().length > 0
&& commandInvocation.getRawArguments().length <= 1) {
invocationSource.sendMessage(Sonar.get().getConfig().getCommands().getIncorrectCommandUsage()
.replace("%usage%", sub.getInfo().name() + " (" + sub.getArguments() + ")"));
return;
}
// Execute the sub command with the custom invocation properties
sub.execute(commandInvocation);
});
if (!subcommand.isPresent()) {
// Re-use the old, cached help message since we don't want to scan
// for each subcommand and it's arguments/attributes every time
// someone runs /sonar since the subcommand don't change
for (final Component component : CACHED_HELP_MESSAGE) {
invocationSource.sendMessage(component);
}
}
return false;
// Pass the invocation source and command arguments to our command handler
handle(invocationSource, args);
return true; // Valid
}
@Override
public List<String> onTabComplete(final CommandSender sender,
public List<String> onTabComplete(final @NotNull CommandSender sender,
final Command command,
final String commandAlias,
final String @NotNull [] args) {
@@ -145,33 +52,6 @@ public final class BukkitSonarCommand implements CommandExecutor, TabExecutor, S
if (!sender.hasPermission("sonar.command")) {
return emptyList();
}
if (args.length <= 1) {
if (TAB_SUGGESTIONS.isEmpty()) {
for (final Subcommand subcommand : Sonar.get().getSubcommandRegistry().getSubcommands()) {
TAB_SUGGESTIONS.add(subcommand.getInfo().name());
if (subcommand.getInfo().aliases().length > 0) {
TAB_SUGGESTIONS.addAll(Arrays.asList(subcommand.getInfo().aliases()));
}
}
}
return TAB_SUGGESTIONS;
} else if (args.length == 2) {
if (ARG_TAB_SUGGESTIONS.isEmpty()) {
for (final Subcommand subcommand : Sonar.get().getSubcommandRegistry().getSubcommands()) {
final List<String> parsedArguments = Arrays.stream(subcommand.getInfo().arguments())
.map(Argument::value)
.collect(Collectors.toList());
ARG_TAB_SUGGESTIONS.put(subcommand.getInfo().name(), parsedArguments);
for (final String alias : subcommand.getInfo().aliases()) {
ARG_TAB_SUGGESTIONS.put(alias, parsedArguments);
}
}
}
final String subCommandName = args[0].toLowerCase();
return ARG_TAB_SUGGESTIONS.getOrDefault(subCommandName, emptyList());
}
return emptyList();
return getCachedTabSuggestions(args);
}
}
@@ -1,41 +0,0 @@
/*
* Copyright (C) 2023 Sonar Contributors
*
* 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 3 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, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.bukkit.verbose;
import lombok.RequiredArgsConstructor;
import net.kyori.adventure.text.Component;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import xyz.jonesdev.sonar.api.verbose.Verbose;
@RequiredArgsConstructor
public final class ActionBarVerbose extends Verbose {
private final Server server;
@Override
public void broadcast(final Component component) {
synchronized (subscribers) {
for (final String subscriber : subscribers) {
final Player player = server.getPlayer(subscriber);
if (player != null) {
// TODO: use an adventure audience
}
}
}
}
}
+3 -9
View File
@@ -5,24 +5,18 @@ plugins {
apply(plugin = "net.minecrell.plugin-yml.bungee")
bungee {
name = rootProject.name
name = "Sonar"
version = rootProject.version.toString().split("-")[0]
main = "xyz.jonesdev.sonar.bungee.SonarBungeePlugin"
author = "Jones Development, Sonar Contributors"
description = "Effective Anti-bot plugin for Velocity, BungeeCord, and Bukkit (1.7-latest)"
}
dependencies {
compileOnly(project(":api"))
compileOnly(project(":common"))
implementation("com.velocitypowered:velocity-native:1.1.9") {
exclude(group = "com.google.guava")
exclude(group = "io.netty")
exclude(group = "org.checkerframework")
}
compileOnly("net.md_5:bungeecord:1.20.2-rc2-SNAPSHOT")
compileOnly("net.md_5:bungeecord-proxy:master-SNAPSHOT")
testCompileOnly("net.md_5:bungeecord-proxy:master-SNAPSHOT")
// MiniMessage platform support
implementation("net.kyori:adventure-platform-bungeecord:4.3.1")
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -18,34 +18,32 @@
package xyz.jonesdev.sonar.bungee;
import lombok.Getter;
import net.kyori.adventure.platform.bungeecord.BungeeAudiences;
import org.bstats.bungeecord.Metrics;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.SonarPlatform;
import xyz.jonesdev.sonar.api.command.InvocationSource;
import xyz.jonesdev.sonar.api.fallback.traffic.TrafficCounter;
import xyz.jonesdev.sonar.api.logger.LoggerWrapper;
import xyz.jonesdev.sonar.api.server.ServerWrapper;
import xyz.jonesdev.sonar.bungee.command.BungeeInvocationSource;
import xyz.jonesdev.sonar.bungee.audience.AudienceListener;
import xyz.jonesdev.sonar.bungee.command.BungeeSonarCommand;
import xyz.jonesdev.sonar.bungee.fallback.FallbackListener;
import xyz.jonesdev.sonar.bungee.fallback.injection.BaseInjectionHelper;
import xyz.jonesdev.sonar.bungee.fallback.injection.ChildChannelInitializer;
import xyz.jonesdev.sonar.bungee.verbose.VerboseWrapper;
import xyz.jonesdev.sonar.common.boot.SonarBootstrap;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
@Getter
public final class SonarBungee extends SonarBootstrap<SonarBungeePlugin> {
public static SonarBungee INSTANCE;
public SonarBungee(final @NotNull SonarBungeePlugin plugin) {
super(plugin, plugin.getDataFolder(), new VerboseWrapper(plugin.getServer()));
super(plugin, plugin.getDataFolder(), SonarPlatform.BUNGEE);
INSTANCE = this;
}
/**
* Wrapper for BungeeCord audiences
*/
private final BungeeAudiences bungeeAudiences = BungeeAudiences.create(getPlugin());
/**
* Create a wrapper for the plugin logger, so we can use it outside
* the velocity module.
@@ -56,34 +54,17 @@ public final class SonarBungee extends SonarBootstrap<SonarBungeePlugin> {
@Override
public void info(final String message, final Object... args) {
getPlugin().getLogger().log(Level.INFO, message, args);
getPlugin().getLogger().info(buildFullMessage(message, args));
}
@Override
public void warn(final String message, final Object... args) {
getPlugin().getLogger().log(Level.WARNING, message, args);
getPlugin().getLogger().warning(buildFullMessage(message, args));
}
@Override
public void error(final String message, final Object... args) {
getPlugin().getLogger().log(Level.SEVERE, message, args);
}
};
/**
* Create a wrapper object for our server, so we can use it outside
* the velocity module.
* <br>
* We have to do this, so we can access all necessary API functions.
*/
public final ServerWrapper server = new ServerWrapper(SonarPlatform.BUNGEE) {
@Override
public Optional<InvocationSource> getOnlinePlayer(final String username) {
return getPlugin().getServer().getPlayers().stream()
.filter(player -> player.getName().equalsIgnoreCase(username))
.findFirst()
.map(BungeeInvocationSource::new);
getPlugin().getLogger().severe(buildFullMessage(message, args));
}
};
@@ -91,25 +72,16 @@ public final class SonarBungee extends SonarBootstrap<SonarBungeePlugin> {
public void enable() {
// Initialize bStats.org metrics
new Metrics(getPlugin(), getServer().getPlatform().getMetricsId());
new Metrics(getPlugin(), getPlatform().getMetricsId());
// Register Sonar command
getPlugin().getServer().getPluginManager().registerCommand(getPlugin(), new BungeeSonarCommand());
// Register Fallback listener
getPlugin().getServer().getPluginManager().registerListener(getPlugin(), new FallbackListener(getFallback()));
getPlugin().getServer().getPluginManager().registerListener(getPlugin(), new FallbackListener());
// Register Fallback queue task
getPlugin().getServer().getScheduler().schedule(getPlugin(), getFallback().getQueue()::poll,
500L, 500L, TimeUnit.MILLISECONDS);
// Register traffic counter reset task
getPlugin().getServer().getScheduler().schedule(getPlugin(), TrafficCounter::reset,
1L, 1L, TimeUnit.SECONDS);
// Register action bar verbose task
getPlugin().getServer().getScheduler().schedule(getPlugin(), getVerboseHandler()::update,
100L, 100L, TimeUnit.MILLISECONDS);
// Register audience register listener
getPlugin().getServer().getPluginManager().registerListener(getPlugin(), new AudienceListener());
// Inject base into ProtocolUtils
BaseInjectionHelper.inject(ChildChannelInitializer.INSTANCE);
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -0,0 +1,42 @@
/*
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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 3 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, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.bungee.audience;
import net.kyori.adventure.audience.Audience;
import net.md_5.bungee.api.event.PlayerDisconnectEvent;
import net.md_5.bungee.api.event.PostLoginEvent;
import net.md_5.bungee.api.plugin.Listener;
import net.md_5.bungee.event.EventHandler;
import net.md_5.bungee.event.EventPriority;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.bungee.SonarBungee;
public final class AudienceListener implements Listener {
@EventHandler(priority = EventPriority.HIGHEST)
public void handle(final @NotNull PostLoginEvent event) {
final Audience audience = SonarBungee.INSTANCE.getBungeeAudiences().player(event.getPlayer());
Sonar.get().getVerboseHandler().getAudiences().put(event.getPlayer().getName(), audience);
}
@EventHandler(priority = EventPriority.HIGHEST)
public void handle(final @NotNull PlayerDisconnectEvent event) {
Sonar.get().getVerboseHandler().getAudiences().remove(event.getPlayer().getName());
}
}
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -17,16 +17,17 @@
package xyz.jonesdev.sonar.bungee.command;
import net.kyori.adventure.platform.bungeecord.BungeeAudiences;
import net.md_5.bungee.api.CommandSender;
import net.md_5.bungee.api.connection.ProxiedPlayer;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.command.InvocationSource;
import xyz.jonesdev.sonar.bungee.SonarBungee;
public final class BungeeInvocationSource extends InvocationSource {
private static final BungeeAudiences AUDIENCES = BungeeAudiences.create(SonarBungee.INSTANCE.getPlugin());
public BungeeInvocationSource(final @NotNull CommandSender sender) {
super(sender.getName(), AUDIENCES.sender(sender));
super(sender.getName(),
SonarBungee.INSTANCE.getBungeeAudiences().sender(sender),
sender instanceof ProxiedPlayer,
sender::hasPermission);
}
}
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -17,159 +17,34 @@
package xyz.jonesdev.sonar.bungee.command;
import net.kyori.adventure.text.Component;
import net.md_5.bungee.api.CommandSender;
import net.md_5.bungee.api.connection.ProxiedPlayer;
import net.md_5.bungee.api.plugin.Command;
import net.md_5.bungee.api.plugin.TabExecutor;
import net.md_5.bungee.command.ConsoleCommandSender;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.command.CommandInvocation;
import xyz.jonesdev.sonar.api.command.InvocationSource;
import xyz.jonesdev.sonar.api.command.SonarCommand;
import xyz.jonesdev.sonar.api.command.subcommand.Subcommand;
import xyz.jonesdev.sonar.api.command.subcommand.argument.Argument;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import static java.util.Collections.emptyList;
public final class BungeeSonarCommand extends Command implements TabExecutor, SonarCommand {
public BungeeSonarCommand() {
super("sonar");
cacheHelpMessage();
}
@Override
@SuppressWarnings({"redundantSuppression"})
public void execute(final @NotNull CommandSender sender, final String[] args) {
// Create our own invocation source wrapper to handle messages properly
final InvocationSource invocationSource = new BungeeInvocationSource(sender);
if (!(sender instanceof ConsoleCommandSender)) {
// Check if the player actually has the permission to run the command
if (!sender.hasPermission("sonar.command")) {
invocationSource.sendMessage(Sonar.get().getConfig().getNoPermission());
return;
}
// Checking if it contains will only break more since it can throw
// a NullPointerException if the cache is being accessed from parallel threads
DELAY.cleanUp(); // Clean up the cache
final long mapTimestamp = DELAY.asMap().getOrDefault(sender, -1L);
// There were some exploits with spamming commands in the past.
// Spamming should be prevented, especially if some heavy operations are done,
// which is not the case here but let's still stay safe!
if (mapTimestamp > 0L) {
invocationSource.sendMessage(Sonar.get().getConfig().getCommands().getCommandCoolDown());
// Format delay
final long timestamp = System.currentTimeMillis();
final double left = 0.5D - (timestamp - mapTimestamp) / 1000D;
invocationSource.sendMessage(Sonar.get().getConfig().getCommands().getCommandCoolDownLeft()
.replace("%time-left%", Sonar.DECIMAL_FORMAT.format(left)));
return;
}
DELAY.put(sender);
}
Optional<Subcommand> subcommand = Optional.empty();
if (args.length > 0) {
// Search subcommand if command arguments are present
subcommand = Sonar.get().getSubcommandRegistry().getSubcommands().parallelStream()
.filter(sub -> sub.getInfo().name().equalsIgnoreCase(args[0])
|| Arrays.stream(sub.getInfo().aliases())
.anyMatch(alias -> alias.equalsIgnoreCase(args[0])))
.findFirst();
// Check permissions for subcommands
if (subcommand.isPresent()) {
if (!subcommand.get().getInfo().onlyConsole()
&& !sender.hasPermission(subcommand.get().getPermission())
) {
invocationSource.sendMessage(Sonar.get().getConfig().getCommands().getSubCommandNoPerm()
.replace("%permission%", subcommand.get().getPermission()));
return;
}
}
}
subcommand.ifPresent(sub -> {
if (sub.getInfo().onlyPlayers() && !(sender instanceof ProxiedPlayer)) {
invocationSource.sendMessage(Sonar.get().getConfig().getCommands().getPlayersOnly());
return;
}
if (sub.getInfo().onlyConsole() && !(sender instanceof ConsoleCommandSender)) {
invocationSource.sendMessage(Sonar.get().getConfig().getCommands().getConsoleOnly());
return;
}
final CommandInvocation commandInvocation = new CommandInvocation(invocationSource, sub, args);
// The subcommands has arguments which are not present in the executed command
if (sub.getInfo().arguments().length > 0
&& commandInvocation.getRawArguments().length <= 1) {
invocationSource.sendMessage(Sonar.get().getConfig().getCommands().getIncorrectCommandUsage()
.replace("%usage%", sub.getInfo().name() + " (" + sub.getArguments() + ")"));
return;
}
// Execute the sub command with the custom invocation properties
sub.execute(commandInvocation);
});
if (!subcommand.isPresent()) {
// Re-use the old, cached help message since we don't want to scan
// for each subcommand and it's arguments/attributes every time
// someone runs /sonar since the subcommand don't change
for (final Component component : CACHED_HELP_MESSAGE) {
invocationSource.sendMessage(component);
}
}
// Pass the invocation source and command arguments to our command handler
handle(invocationSource, args);
}
@Override
public Iterable<String> onTabComplete(final CommandSender sender, final String @NotNull [] args) {
public Iterable<String> onTabComplete(final @NotNull CommandSender sender, final String @NotNull [] args) {
// Do not allow tab completion if the player does not have the required permission
if (!sender.hasPermission("sonar.command")) {
return emptyList();
}
if (args.length <= 1) {
if (TAB_SUGGESTIONS.isEmpty()) {
for (final Subcommand subcommand : Sonar.get().getSubcommandRegistry().getSubcommands()) {
TAB_SUGGESTIONS.add(subcommand.getInfo().name());
if (subcommand.getInfo().aliases().length > 0) {
TAB_SUGGESTIONS.addAll(Arrays.asList(subcommand.getInfo().aliases()));
}
}
}
return TAB_SUGGESTIONS;
} else if (args.length == 2) {
if (ARG_TAB_SUGGESTIONS.isEmpty()) {
for (final Subcommand subcommand : Sonar.get().getSubcommandRegistry().getSubcommands()) {
final List<String> parsedArguments = Arrays.stream(subcommand.getInfo().arguments())
.map(Argument::value)
.collect(Collectors.toList());
ARG_TAB_SUGGESTIONS.put(subcommand.getInfo().name(), parsedArguments);
for (final String alias : subcommand.getInfo().aliases()) {
ARG_TAB_SUGGESTIONS.put(alias, parsedArguments);
}
}
}
final String subCommandName = args[0].toLowerCase();
return ARG_TAB_SUGGESTIONS.getOrDefault(subCommandName, emptyList());
}
return emptyList();
return getCachedTabSuggestions(args);
}
}
@@ -0,0 +1,87 @@
/*
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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 3 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, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.bungee.fallback;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.haproxy.HAProxyMessage;
import net.md_5.bungee.api.ProxyServer;
import net.md_5.bungee.connection.DownstreamBridge;
import net.md_5.bungee.connection.UpstreamBridge;
import net.md_5.bungee.netty.ChannelWrapper;
import net.md_5.bungee.netty.HandlerBoss;
import net.md_5.bungee.netty.PacketHandler;
import net.md_5.bungee.protocol.PacketWrapper;
import org.jetbrains.annotations.NotNull;
public final class FallbackHandlerBoss extends HandlerBoss {
private ChannelWrapper channel;
private PacketHandler handler;
@Override
public void setHandler(final @NotNull PacketHandler handler) {
this.handler = handler;
super.setHandler(handler);
}
@Override
public void channelActive(final ChannelHandlerContext ctx) throws Exception {
if (handler != null) {
channel = new ChannelWrapper(ctx);
super.channelActive(ctx);
}
}
@Override
public void channelInactive(final ChannelHandlerContext ctx) throws Exception {
if (handler != null) {
channel.markClosed();
super.channelInactive(ctx);
}
}
@Override
public void channelWritabilityChanged(final ChannelHandlerContext ctx) throws Exception {
if (handler != null) {
handler.writabilityChanged(channel);
super.channelWritabilityChanged(ctx);
}
}
@Override
public void channelRead(final ChannelHandlerContext ctx, final Object msg) throws Exception {
if (msg instanceof HAProxyMessage || msg instanceof PacketWrapper) {
super.channelRead(ctx, msg);
}
}
@Override
public void exceptionCaught(final @NotNull ChannelHandlerContext ctx, final Throwable throwable) throws Exception {
if (ctx.channel().isActive()) {
if (handler instanceof DownstreamBridge || handler instanceof UpstreamBridge) {
try {
handler.exception(throwable);
} catch (Exception exception) {
ProxyServer.getInstance().getLogger().severe(handler + " - exception processing exception: " + exception);
}
}
channel.markClosed();
ctx.close();
}
}
}
@@ -0,0 +1,364 @@
/*
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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 3 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, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.bungee.fallback;
import io.netty.channel.Channel;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelPipeline;
import io.netty.handler.codec.CorruptedFrameException;
import io.netty.handler.codec.DecoderException;
import lombok.Getter;
import lombok.val;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.serializer.json.JSONComponentSerializer;
import net.md_5.bungee.BungeeCord;
import net.md_5.bungee.EncryptionUtil;
import net.md_5.bungee.api.config.ListenerInfo;
import net.md_5.bungee.chat.ComponentSerializer;
import net.md_5.bungee.connection.InitialHandler;
import net.md_5.bungee.netty.ChannelWrapper;
import net.md_5.bungee.protocol.PlayerPublicKey;
import net.md_5.bungee.protocol.packet.Handshake;
import net.md_5.bungee.protocol.packet.Kick;
import net.md_5.bungee.protocol.packet.LoginRequest;
import net.md_5.bungee.protocol.packet.StatusRequest;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import xyz.jonesdev.sonar.api.ReflectiveOperationException;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.event.impl.UserVerifyJoinEvent;
import xyz.jonesdev.sonar.api.fallback.Fallback;
import xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion;
import xyz.jonesdev.sonar.api.statistics.Counters;
import xyz.jonesdev.sonar.api.statistics.Statistics;
import xyz.jonesdev.sonar.common.fallback.FallbackChannelHandler;
import xyz.jonesdev.sonar.common.fallback.FallbackTimeoutHandler;
import xyz.jonesdev.sonar.common.fallback.FallbackVerificationHandler;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacketDecoder;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacketEncoder;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacketRegistry;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.login.LoginSuccess;
import xyz.jonesdev.sonar.common.fallback.traffic.TrafficChannelHooker;
import java.net.InetAddress;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import static net.md_5.bungee.netty.PipelineUtils.*;
import static xyz.jonesdev.sonar.api.fallback.FallbackPipelines.*;
import static xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion.MINECRAFT_1_19_1;
import static xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion.MINECRAFT_1_19_3;
import static xyz.jonesdev.sonar.common.utility.geyser.GeyserUtil.isGeyserConnection;
public final class FallbackInitialHandler extends InitialHandler {
public FallbackInitialHandler(final @NotNull BungeeCord bungee, final ListenerInfo listener) {
super(bungee, listener);
this.bungee = bungee;
}
@Getter
private ChannelWrapper channelWrapper;
private final @NotNull BungeeCord bungee;
private @Nullable FallbackUserWrapper user;
@Getter
private ProtocolVersion protocolVersion;
private boolean receivedLoginPacket;
private boolean receivedStatusPacket;
private static final @NotNull Fallback FALLBACK = Objects.requireNonNull(Sonar.get().getFallback());
@Override
public void connected(final ChannelWrapper channelWrapper) throws Exception {
this.channelWrapper = channelWrapper;
super.connected(channelWrapper);
// Increase connections per second for the action bar verbose
Counters.CONNECTIONS_PER_SECOND.put(System.nanoTime());
}
@Override
public void handle(final @NotNull Handshake handshake) throws Exception {
// Check if the host is invalid
if (handshake.getHost().isEmpty()) {
throw new CorruptedFrameException("Hostname is empty");
}
super.handle(handshake);
}
@Override
public void handle(final StatusRequest statusRequest) throws Exception {
// Fix status packet spam exploit
if (receivedStatusPacket) {
throw new CorruptedFrameException("Duplicate status packet");
}
receivedStatusPacket = true;
// Run the rest of the method asynchronously
CompletableFuture.runAsync(() -> {
// The channel always stays open because the client sends
// a StatusRequest and a Ping packet after one another
if (!isConnected()) {
throw new CorruptedFrameException("Not connected anymore");
}
try {
super.handle(statusRequest);
} catch (Exception exception) {
throw new DecoderException(exception);
}
});
}
@Override
public void handle(final LoginRequest loginRequest) throws Exception {
// Increase joins per second for the action bar verbose
Counters.LOGINS_PER_SECOND.put(System.nanoTime());
// Fix login packet spam exploit
if (receivedLoginPacket || user != null) {
throw new CorruptedFrameException("Duplicate login packet");
}
receivedLoginPacket = true;
// Cache protocol version so other handlers don't throw NPEs
final int protocolId = getHandshake().getProtocolVersion();
protocolVersion = ProtocolVersion.fromId(protocolId);
final Channel channel = channelWrapper.getHandle();
// Run in the channel's event loop
channel.eventLoop().execute(() -> {
// Do not continue if the connection is closed or marked as disconnected
if (channelWrapper.isClosed() || channelWrapper.isClosing()) return;
try {
// Hook the custom traffic pipeline, so we can count the incoming and outgoing traffic
final ChannelPipeline pipeline = channel.pipeline();
TrafficChannelHooker.hook(pipeline, PACKET_DECODER, PACKET_ENCODER);
// Increase total traffic statistic
Statistics.TOTAL_TRAFFIC.increment();
final InetAddress inetAddress = getAddress().getAddress();
// Check the blacklist here since we cannot let the player "ghost join"
if (FALLBACK.getBlacklisted().has(inetAddress)) {
closeWith(getKickPacket(Sonar.get().getConfig().getVerification().getBlacklisted()));
return;
}
// Check if the verification is enabled
if (!Sonar.get().getFallback().shouldVerifyNewPlayers()) {
super.handle(loginRequest);
return;
}
// Completely skip Geyser connections
if (isGeyserConnection(channel)) {
FALLBACK.getLogger().info("Skipping Geyser player: {}{}",
loginRequest.getData(), Sonar.get().getConfig().formatAddress(inetAddress));
super.handle(loginRequest);
return;
}
// Check if the protocol ID of the player is not allowed to enter the server
if (Sonar.get().getConfig().getVerification().getBlacklistedProtocols().contains(protocolId)) {
closeWith(getKickPacket(Sonar.get().getConfig().getVerification().getProtocolBlacklisted()));
return;
}
val uuid = UUID.nameUUIDFromBytes(("OfflinePlayer:" + loginRequest.getData()).getBytes(StandardCharsets.UTF_8));
// Check if the player is already verified
if (Sonar.get().getVerifiedPlayerController().has(inetAddress, uuid)) {
super.handle(loginRequest);
return;
}
// Check if the protocol ID of the player is allowed to bypass verification
if (Sonar.get().getConfig().getVerification().getWhitelistedProtocols().contains(protocolId)) {
super.handle(loginRequest);
return;
}
// Create wrapped Fallback user
user = new FallbackUserWrapper(
FALLBACK, this, channel, channel.pipeline(),
inetAddress, protocolVersion
);
// Perform default BungeeCord checks
if (bungee.config.isEnforceSecureProfile()
&& user.getProtocolVersion().compareTo(MINECRAFT_1_19_3) < 0) {
final PlayerPublicKey publicKey = loginRequest.getPublicKey();
if (publicKey == null) {
disconnect(bungee.getTranslation("secure_profile_required"));
return;
}
if (Instant.ofEpochMilli(publicKey.getExpiry()).isBefore(Instant.now())) {
disconnect(bungee.getTranslation("secure_profile_expired"));
return;
}
if (user.getProtocolVersion().compareTo(MINECRAFT_1_19_1) < 0
&& !EncryptionUtil.check(publicKey, null)) {
disconnect(bungee.getTranslation("secure_profile_invalid"));
return;
}
}
// Check if the player is already queued since we don't want bots to flood the queue
if (FALLBACK.getQueue().getQueuedPlayers().containsKey(inetAddress)) {
closeWith(getKickPacket(Sonar.get().getConfig().getVerification().getAlreadyQueued()));
return;
}
// Check if Fallback is already verifying a player
// → is another player with the same IP address connected to Fallback?
if (FALLBACK.getConnected().containsKey(loginRequest.getData())
|| FALLBACK.getConnected().containsValue(inetAddress)) {
closeWith(getKickPacket(Sonar.get().getConfig().getVerification().getAlreadyVerifying()));
return;
}
// Check if the IP address is currently being rate-limited
if (!FALLBACK.getRatelimiter().attempt(inetAddress)) {
closeWith(getKickPacket(Sonar.get().getConfig().getVerification().getTooFastReconnect()));
return;
}
// We have to add this pipeline to monitor whenever the client disconnects
// to remove them from the list of connected and queued players
pipeline.addFirst(FALLBACK_HANDLER, new FallbackChannelHandler(loginRequest.getData(), inetAddress));
// Queue the connection for further processing
FALLBACK.getQueue().queue(inetAddress, () -> channel.eventLoop().execute(() -> {
// Do not continue if the connection is closed or marked as disconnected
if (channelWrapper.isClosed() || channelWrapper.isClosing()) return;
// Check if the username matches the valid name regex to prevent
// UTF-16 names or other types of exploits
if (!Sonar.get().getConfig().getVerification().getValidNameRegex().matcher(loginRequest.getData()).matches()) {
closeWith(getKickPacket(Sonar.get().getConfig().getVerification().getInvalidUsername()));
return;
}
// Add better timeout handler to avoid known exploits or issues
// We also want to timeout bots quickly to avoid flooding
final int readTimeout = Sonar.get().getConfig().getVerification().getReadTimeout();
pipeline.replace(TIMEOUT_HANDLER, TIMEOUT_HANDLER,
new FallbackTimeoutHandler(readTimeout, TimeUnit.MILLISECONDS));
// Disconnect if the protocol version could not be resolved
if (user.getProtocolVersion().isUnknown()) {
closeWith(getKickPacket(Sonar.get().getConfig().getVerification().getInvalidProtocol()));
return;
}
// Check if the player is already connected to the proxy but still tries to verify
final int limit = bungee.config.getPlayerLimit();
if (limit > 0 && bungee.getOnlineCount() >= limit) {
disconnect(bungee.getTranslation("proxy_full"));
return;
} else if (!isOnlineMode() && bungee.getPlayer(loginRequest.getData()) != null) {
closeWith(getKickPacket(Sonar.get().getConfig().getVerification().getAlreadyConnected()));
return;
}
// The player joined the verification
Statistics.REAL_TRAFFIC.increment();
if (Sonar.get().getConfig().getVerification().isLogConnections()) {
// Only log the processing message if the server isn't under attack.
// We let the user override this through the configuration.
if (!Sonar.get().getAttackTracker().isCurrentlyUnderAttack()
|| Sonar.get().getConfig().getVerification().isLogDuringAttack()) {
FALLBACK.getLogger().info(Sonar.get().getConfig().getVerification().getConnectLog()
.replace("%name%", loginRequest.getData())
.replace("%ip%", Sonar.get().getConfig().formatAddress(inetAddress))
.replace("%protocol%", String.valueOf(user.getProtocolVersion().getProtocol())));
}
}
// Call the VerifyJoinEvent for external API usage
Sonar.get().getEventManager().publish(new UserVerifyJoinEvent(loginRequest.getData(), user));
// Mark the player as connected → verifying players
FALLBACK.getConnected().put(loginRequest.getData(), inetAddress);
// This sometimes happens when the channel hangs, but the player is still connecting
// This also fixes a unique issue with TCPShield and other reverse proxies
if (user.getPipeline().get(PACKET_ENCODER) == null
|| user.getPipeline().get(PACKET_DECODER) == null) {
channelWrapper.close();
return;
}
// Replace normal encoder to allow custom packets
final FallbackPacketEncoder encoder = new FallbackPacketEncoder(user.getProtocolVersion());
user.getPipeline().replace(PACKET_ENCODER, FALLBACK_PACKET_ENCODER, encoder);
// Send LoginSuccess packet to make the client think they are joining the server
user.write(new LoginSuccess(loginRequest.getData(), uuid));
// The LoginSuccess packet has been sent, now we can change the registry state
encoder.updateRegistry(user.getProtocolVersion().compareTo(ProtocolVersion.MINECRAFT_1_20_2) >= 0
? FallbackPacketRegistry.CONFIG : FallbackPacketRegistry.GAME);
// Replace normal decoder to allow custom packets
user.getPipeline().replace(PACKET_DECODER, FALLBACK_PACKET_DECODER,
new FallbackPacketDecoder(user, new FallbackVerificationHandler(user, loginRequest.getData(), uuid)));
}));
} catch (Throwable throwable) {
throw new ReflectiveOperationException(throwable);
}
});
}
private static final Map<Component, Kick> CACHED_KICK_PACKETS = new ConcurrentHashMap<>(10);
public static @NotNull Kick getKickPacket(final @NotNull Component component) {
Kick cachedKickPacket = CACHED_KICK_PACKETS.get(component);
if (cachedKickPacket == null) {
final String serialized = JSONComponentSerializer.json().serialize(component);
cachedKickPacket = new Kick(ComponentSerializer.deserialize(serialized));
CACHED_KICK_PACKETS.put(component, cachedKickPacket);
}
return cachedKickPacket;
}
// Mostly taken from Velocity
public void closeWith(final Object msg) {
if (channelWrapper.getHandle().isActive()) {
boolean is17 = protocolVersion.compareTo(ProtocolVersion.MINECRAFT_1_8) < 0
&& protocolVersion.compareTo(ProtocolVersion.MINECRAFT_1_7_2) >= 0;
if (is17) {
channelWrapper.getHandle().eventLoop().execute(() -> {
channelWrapper.getHandle().config().setAutoRead(false);
channelWrapper.getHandle().eventLoop().schedule(() -> {
channelWrapper.markClosed();
channelWrapper.getHandle().writeAndFlush(msg).addListener(ChannelFutureListener.CLOSE);
}, 250L, TimeUnit.MILLISECONDS);
});
} else {
channelWrapper.markClosed();
channelWrapper.getHandle().writeAndFlush(msg).addListener(ChannelFutureListener.CLOSE);
}
}
}
}
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -17,113 +17,43 @@
package xyz.jonesdev.sonar.bungee.fallback;
import com.velocitypowered.natives.compression.VelocityCompressor;
import com.velocitypowered.natives.util.Natives;
import io.netty.channel.ChannelPipeline;
import lombok.RequiredArgsConstructor;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.serializer.json.JSONComponentSerializer;
import net.md_5.bungee.BungeeCord;
import net.md_5.bungee.api.chat.TextComponent;
import net.md_5.bungee.api.connection.PendingConnection;
import net.md_5.bungee.api.event.PostLoginEvent;
import net.md_5.bungee.api.event.LoginEvent;
import net.md_5.bungee.api.plugin.Listener;
import net.md_5.bungee.compress.PacketCompressor;
import net.md_5.bungee.compress.PacketDecompressor;
import net.md_5.bungee.connection.InitialHandler;
import net.md_5.bungee.event.EventHandler;
import net.md_5.bungee.netty.ChannelWrapper;
import net.md_5.bungee.protocol.packet.Kick;
import net.md_5.bungee.event.EventPriority;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.ReflectiveOperationException;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.fallback.Fallback;
import xyz.jonesdev.sonar.bungee.fallback.compress.FallbackPacketCompressor;
import xyz.jonesdev.sonar.bungee.fallback.compress.FallbackPacketDecompressor;
import xyz.jonesdev.sonar.bungee.fallback.handler.FallbackInitialHandler;
import xyz.jonesdev.sonar.bungee.SonarBungee;
import java.lang.reflect.Field;
import static net.md_5.bungee.netty.PipelineUtils.FRAME_PREPENDER;
import java.net.InetAddress;
import java.util.Objects;
@RequiredArgsConstructor
public final class FallbackListener implements Listener {
private final Fallback fallback;
private static final Field CHANNEL_WRAPPER;
static {
try {
CHANNEL_WRAPPER = InitialHandler.class.getDeclaredField("ch");
CHANNEL_WRAPPER.setAccessible(true);
} catch (Throwable throwable) {
throw new ReflectiveOperationException(throwable);
}
}
@EventHandler
@SuppressWarnings("deprecation")
public void handle(final @NotNull PostLoginEvent event) throws Throwable {
if (Sonar.get().getConfig().getLockdown().isEnabled()) {
if (!event.getPlayer().hasPermission(Sonar.get().getConfig().getLockdown().getBypassPermission())) {
final PendingConnection pendingConnection = event.getPlayer().getPendingConnection();
// Try to close the channel with a custom serialized disconnect component
if (pendingConnection instanceof FallbackInitialHandler) {
final FallbackInitialHandler fallbackInitialHandler = (FallbackInitialHandler) pendingConnection;
final Component component = Sonar.get().getConfig().getLockdown().getDisconnect();
final String serialized = JSONComponentSerializer.json().serialize(component);
fallbackInitialHandler.closeWith(new Kick(serialized));
} else {
// Fallback by disconnecting without a message
pendingConnection.disconnect();
Sonar.get().getLogger().warn("Fallback handler of {} is missing", event.getPlayer().getName());
}
@EventHandler(priority = EventPriority.LOWEST)
public void handle(final @NotNull LoginEvent event) {
final InetAddress inetAddress = event.getConnection().getAddress().getAddress();
if (Sonar.get().getConfig().getLockdown().isLogAttempts()) {
Sonar.get().getLogger().info(Sonar.get().getConfig().getLockdown().getConsoleLog()
.replace("%player%", event.getPlayer().getName())
.replace("%ip%", Sonar.get().getConfig()
.formatAddress(event.getPlayer().getAddress().getAddress()))
.replace("%protocol%",
String.valueOf(event.getPlayer().getPendingConnection().getVersion())));
}
return;
} else if (Sonar.get().getConfig().getLockdown().isNotifyAdmins()) {
event.getPlayer().sendMessage(new TextComponent(Sonar.get().getConfig().getLockdown().getNotification()));
// Check if the number of online players using the same IP address as
// the connecting player is greater than the configured amount
final int maxOnlinePerIp = Sonar.get().getConfig().getMaxOnlinePerIp();
if (maxOnlinePerIp > 0) {
final long onlinePerIp = SonarBungee.INSTANCE.getPlugin().getServer().getPlayers().stream()
.filter(player -> Objects.equals(player.getAddress().getAddress(), inetAddress))
.count()
+ 1 /* add 1 because the player hasn't been added to the list of online players yet */;
// We use '>=' because the player connecting to the server hasn't joined yet
if (onlinePerIp >= maxOnlinePerIp) {
final FallbackInitialHandler fallbackInitialHandler = (FallbackInitialHandler) event.getConnection();
final Component component = Sonar.get().getConfig().getTooManyOnlinePerIp();
fallbackInitialHandler.closeWith(FallbackInitialHandler.getKickPacket(component));
}
}
final InitialHandler initialHandler = (InitialHandler) event.getPlayer().getPendingConnection();
final ChannelWrapper channelWrapper = (ChannelWrapper) CHANNEL_WRAPPER.get(initialHandler);
final ChannelPipeline pipeline = channelWrapper.getHandle().pipeline();
// You don't need the frame decoder anymore
if (pipeline.get(FRAME_PREPENDER) != null) {
pipeline.remove(FRAME_PREPENDER);
}
final int compressionThreshold = BungeeCord.getInstance().getConfig().getCompressionThreshold();
// Replace compression handlers
if (compressionThreshold > 0
&& pipeline.get(PacketCompressor.class) != null
&& pipeline.get(PacketDecompressor.class) != null) {
final VelocityCompressor compressor = Natives.compress.get().create(-1);
// Replace (de)compressor with Velocity's to ensure better performance
pipeline.replace(
PacketCompressor.class,
"compress",
// Create a new compressor instance with the Velocity compressor
new FallbackPacketCompressor(compressionThreshold, compressor)
);
pipeline.replace(
PacketDecompressor.class,
"decompress",
// Create a new decompressor instance with the Velocity decompressor
new FallbackPacketDecompressor(compressionThreshold, compressor)
);
}
}
}
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -23,21 +23,18 @@ import io.netty.util.ReferenceCountUtil;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import net.kyori.adventure.text.Component;
import net.md_5.bungee.netty.ChannelWrapper;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.fallback.Fallback;
import xyz.jonesdev.sonar.api.fallback.FallbackUser;
import xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion;
import xyz.jonesdev.sonar.bungee.fallback.handler.FallbackInitialHandler;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.play.Disconnect;
import java.net.InetAddress;
@Getter
@RequiredArgsConstructor
public final class FallbackUserWrapper implements FallbackUser<ChannelWrapper, FallbackInitialHandler> {
public final class FallbackUserWrapper implements FallbackUser<FallbackInitialHandler> {
private final Fallback fallback;
private final ChannelWrapper player;
private final FallbackInitialHandler connection;
private final Channel channel;
private final ChannelPipeline pipeline;
@@ -1,115 +0,0 @@
/*
* Copyright (C) 2023 Sonar Contributors
*
* 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 3 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, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.bungee.fallback.compress;
import com.velocitypowered.natives.compression.VelocityCompressor;
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import lombok.AllArgsConstructor;
import lombok.Getter;
import net.md_5.bungee.compress.PacketCompressor;
import org.jetbrains.annotations.NotNull;
import java.util.zip.DataFormatException;
import static com.velocitypowered.natives.util.MoreByteBufUtils.ensureCompatible;
import static com.velocitypowered.natives.util.MoreByteBufUtils.preferredBuffer;
import static xyz.jonesdev.sonar.common.utility.protocol.VarIntUtil.*;
@Getter
@AllArgsConstructor
public final class FallbackPacketCompressor extends PacketCompressor {
private int compressionThreshold;
private final @NotNull VelocityCompressor compressor;
private static final int MAX_COMPRESSED_LENGTH = 1 << 21;
@Override
public void setThreshold(final int compressionThreshold) {
this.compressionThreshold = compressionThreshold;
}
@Override
public void handlerAdded(final ChannelHandlerContext ctx) throws Exception {
// Do nothing
}
@Override
public void handlerRemoved(final ChannelHandlerContext ctx) throws Exception {
compressor.close();
}
@Override
protected void encode(final ChannelHandlerContext ctx,
final @NotNull ByteBuf msg,
final ByteBuf out) throws Exception {
final int uncompressed = msg.readableBytes();
if (uncompressed < compressionThreshold) {
// Under the threshold, do nothing.
writeVarInt(out, uncompressed + 1);
writeVarInt(out, 0);
out.writeBytes(msg);
} else {
handleCompressed(ctx, msg, out);
}
}
private void handleCompressed(final @NotNull ChannelHandlerContext ctx,
final @NotNull ByteBuf msg,
final ByteBuf out) throws DataFormatException {
final int uncompressed = msg.readableBytes();
write21BitVarInt(out, 0); // Fake packet length
writeVarInt(out, uncompressed);
final ByteBuf compatibleIn = ensureCompatible(ctx.alloc(), compressor, msg);
final int startCompressed = out.writerIndex();
try {
compressor.deflate(compatibleIn, out);
} finally {
compatibleIn.release();
}
final int compressedLength = out.writerIndex() - startCompressed;
if (compressedLength >= MAX_COMPRESSED_LENGTH) {
throw new DataFormatException("The server sent a very large (over 2MiB compressed) packet.");
}
int writerIndex = out.writerIndex();
int packetLength = out.readableBytes() - 3;
out.writerIndex(0);
write21BitVarInt(out, packetLength); // Rewrite packet length
out.writerIndex(writerIndex);
}
@Override
protected ByteBuf allocateBuffer(final ChannelHandlerContext ctx,
final @NotNull ByteBuf msg,
final boolean preferDirect) throws Exception {
int uncompressed = msg.readableBytes();
if (uncompressed < compressionThreshold) {
int finalBufferSize = uncompressed + 1;
finalBufferSize += varIntBytes(finalBufferSize);
return ctx.alloc().directBuffer(finalBufferSize);
}
final int initialBufferSize = (uncompressed - 1) + 3 + varIntBytes(uncompressed);
return preferredBuffer(ctx.alloc(), compressor, initialBufferSize);
}
}
@@ -1,98 +0,0 @@
/*
* Copyright (C) 2023 Sonar Contributors
*
* 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 3 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, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.bungee.fallback.compress;
import com.velocitypowered.natives.compression.VelocityCompressor;
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.CorruptedFrameException;
import lombok.Getter;
import net.md_5.bungee.compress.PacketDecompressor;
import org.jetbrains.annotations.NotNull;
import java.util.List;
import static com.velocitypowered.natives.util.MoreByteBufUtils.ensureCompatible;
import static com.velocitypowered.natives.util.MoreByteBufUtils.preferredBuffer;
import static xyz.jonesdev.sonar.common.utility.protocol.VarIntUtil.readVarInt;
@Getter
public final class FallbackPacketDecompressor extends PacketDecompressor {
private final int compressionThreshold;
private final @NotNull VelocityCompressor compressor;
private static final int VANILLA_MAXIMUM_UNCOMPRESSED_SIZE = 8 * 1024 * 1024; // 8MiB
private static final int HARD_MAXIMUM_UNCOMPRESSED_SIZE = 16 * 1024 * 1024; // 16MiB
private static final int UNCOMPRESSED_CAP = Boolean.getBoolean("sonar.increased-compression-cap")
? HARD_MAXIMUM_UNCOMPRESSED_SIZE : VANILLA_MAXIMUM_UNCOMPRESSED_SIZE;
public FallbackPacketDecompressor(final int compressionThreshold,
final @NotNull VelocityCompressor compressor) {
super(); // super(compressionThreshold);
this.compressionThreshold = compressionThreshold;
this.compressor = compressor;
}
@Override
public void handlerAdded(final ChannelHandlerContext ctx) throws Exception {
// Do nothing
}
@Override
public void handlerRemoved(final ChannelHandlerContext ctx) throws Exception {
compressor.close();
}
@Override
protected void decode(final ChannelHandlerContext ctx,
final @NotNull ByteBuf in,
final List<Object> out) throws Exception {
final int claimedUncompressedSize = readVarInt(in);
if (claimedUncompressedSize == 0) {
// This message is not compressed.
out.add(in.retain());
// Check for invalid packet size after the packet is retained
final int size = in.readableBytes();
if (size > compressionThreshold + 2) {
throw new CorruptedFrameException("Invalid uncompressed packet size " + size);
}
return;
}
if (claimedUncompressedSize < compressionThreshold) {
throw new CorruptedFrameException(claimedUncompressedSize + " is less than " + compressionThreshold);
}
if (claimedUncompressedSize > UNCOMPRESSED_CAP) {
throw new CorruptedFrameException(claimedUncompressedSize + " exceeds maximum size");
}
final ByteBuf compatibleIn = ensureCompatible(ctx.alloc(), compressor, in);
final ByteBuf uncompressed = preferredBuffer(ctx.alloc(), compressor, claimedUncompressedSize);
try {
compressor.inflate(compatibleIn, uncompressed, claimedUncompressedSize);
out.add(uncompressed);
} catch (Exception exception) {
uncompressed.release();
throw exception;
} finally {
compatibleIn.release();
}
}
}
@@ -1,91 +0,0 @@
/*
* Copyright (C) 2023 Sonar Contributors
*
* 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 3 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, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.bungee.fallback.handler;
import io.netty.channel.Channel;
import io.netty.channel.ChannelFutureListener;
import net.md_5.bungee.BungeeCord;
import net.md_5.bungee.api.config.ListenerInfo;
import net.md_5.bungee.connection.InitialHandler;
import net.md_5.bungee.netty.ChannelWrapper;
import net.md_5.bungee.protocol.packet.LoginRequest;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.fallback.Fallback;
import xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion;
import xyz.jonesdev.sonar.bungee.fallback.FallbackUserWrapper;
import java.net.InetAddress;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
public final class FallbackInitialHandler extends InitialHandler {
public FallbackInitialHandler(final BungeeCord bungee, final ListenerInfo listener) {
super(bungee, listener);
}
private static final @NotNull Fallback FALLBACK = Objects.requireNonNull(Sonar.get().getFallback());
private ChannelWrapper channelWrapper;
@SuppressWarnings("unused") // TODO: remove later
private @Nullable FallbackUserWrapper player;
@Override
public void connected(final ChannelWrapper channelWrapper) throws Exception {
this.channelWrapper = channelWrapper;
super.connected(channelWrapper);
}
@Override
public void handle(final LoginRequest loginRequest) throws Exception {
final InetAddress inetAddress = getAddress().getAddress();
if (player == null && !Sonar.get().getVerifiedPlayerController().has(inetAddress)) {
final Channel channel = channelWrapper.getHandle();
player = new FallbackUserWrapper(
FALLBACK, channelWrapper, this,
channel, channel.pipeline(), inetAddress,
ProtocolVersion.fromId(getHandshake().getProtocolVersion())
);
// TODO: implement Fallback
}
super.handle(loginRequest);
}
// Mostly taken from Velocity
public void closeWith(final Object msg) {
if (player != null && player.getChannel().isActive()) {
boolean is17 = player.getProtocolVersion().compareTo(ProtocolVersion.MINECRAFT_1_8) < 0
&& player.getProtocolVersion().compareTo(ProtocolVersion.MINECRAFT_1_7_2) >= 0;
if (is17) {
player.getChannel().eventLoop().execute(() -> {
channelWrapper.getHandle().config().setAutoRead(false);
player.getChannel().eventLoop().schedule(() -> {
channelWrapper.markClosed();
player.getChannel().writeAndFlush(msg).addListener(ChannelFutureListener.CLOSE);
}, 250L, TimeUnit.MILLISECONDS);
});
} else {
channelWrapper.markClosed();
player.getChannel().writeAndFlush(msg).addListener(ChannelFutureListener.CLOSE);
}
}
}
}
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -17,16 +17,15 @@
package xyz.jonesdev.sonar.bungee.fallback.injection;
import io.netty.buffer.PooledByteBufAllocator;
import io.netty.channel.*;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import net.md_5.bungee.BungeeCord;
import net.md_5.bungee.netty.HandlerBoss;
import net.md_5.bungee.netty.PipelineUtils;
import net.md_5.bungee.protocol.Varint21LengthFieldPrepender;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.ReflectiveOperationException;
import xyz.jonesdev.sonar.bungee.fallback.FallbackHandlerBoss;
import xyz.jonesdev.sonar.bungee.fallback.varint.Varint21FrameDecoder;
import xyz.jonesdev.sonar.common.fallback.FallbackTimeoutHandler;
@@ -41,15 +40,13 @@ public final class BaseChannelInitializer extends ChannelInitializer<Channel> {
private static final boolean REPLACE_VAR_INT_DECODER = !Boolean.getBoolean("sonar.do-not-replace-decoder");
private static final WriteBufferWaterMark MARK;
// https://github.com/PaperMC/Velocity/blob/dev/3.0.0/proxy/src/main/java/com/velocitypowered/proxy/network/ConnectionManager.java#L57
private static final WriteBufferWaterMark SERVER_WRITE_MARK = new WriteBufferWaterMark(1 << 20, 1 << 21);
private static final Varint21LengthFieldPrepender FRAME_ENCODER;
static {
try {
final Field markField = PipelineUtils.class.getDeclaredField("MARK");
markField.setAccessible(true);
MARK = (WriteBufferWaterMark) markField.get(null);
final Field frameEncoder = PipelineUtils.class.getDeclaredField("framePrepender");
frameEncoder.setAccessible(true);
FRAME_ENCODER = (Varint21LengthFieldPrepender) frameEncoder.get(null);
@@ -68,15 +65,13 @@ public final class BaseChannelInitializer extends ChannelInitializer<Channel> {
}
channel.config().setOption(ChannelOption.TCP_NODELAY, true);
channel.config().setAllocator(PooledByteBufAllocator.DEFAULT);
channel.config().setWriteBufferWaterMark(MARK);
channel.config().setWriteBufferWaterMark(SERVER_WRITE_MARK);
channel.pipeline().addLast(FRAME_DECODER, REPLACE_VAR_INT_DECODER
? new Varint21FrameDecoder() : new net.md_5.bungee.protocol.Varint21FrameDecoder());
channel.pipeline().addLast(TIMEOUT_HANDLER, new FallbackTimeoutHandler(
BungeeCord.getInstance().config.getTimeout(), TimeUnit.MILLISECONDS
));
BungeeCord.getInstance().config.getTimeout(), TimeUnit.MILLISECONDS));
channel.pipeline().addLast(FRAME_PREPENDER, FRAME_ENCODER);
channel.pipeline().addLast(BOSS_HANDLER, new HandlerBoss());
channel.pipeline().addLast(BOSS_HANDLER, new FallbackHandlerBoss());
}
}
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -23,15 +23,15 @@ import io.netty.handler.codec.haproxy.HAProxyMessageDecoder;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import net.md_5.bungee.BungeeCord;
import net.md_5.bungee.api.ProxyServer;
import net.md_5.bungee.api.config.ListenerInfo;
import net.md_5.bungee.api.event.ClientConnectEvent;
import net.md_5.bungee.netty.HandlerBoss;
import net.md_5.bungee.netty.PipelineUtils;
import net.md_5.bungee.protocol.*;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.ReflectiveOperationException;
import xyz.jonesdev.sonar.bungee.fallback.handler.FallbackInitialHandler;
import xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion;
import xyz.jonesdev.sonar.bungee.fallback.FallbackHandlerBoss;
import xyz.jonesdev.sonar.bungee.fallback.FallbackInitialHandler;
import java.lang.reflect.Field;
import java.net.SocketAddress;
@@ -43,7 +43,7 @@ public final class ChildChannelInitializer extends ChannelInitializer<Channel> {
public static final ChildChannelInitializer INSTANCE = new ChildChannelInitializer();
private static final BaseChannelInitializer BASE = BaseChannelInitializer.INSTANCE;
private static final BungeeCord BUNGEE = BungeeCord.getInstance();
private static final KickStringWriter LEGACY_KICK;
static {
@@ -63,7 +63,7 @@ public final class ChildChannelInitializer extends ChannelInitializer<Channel> {
final SocketAddress remoteAddress = channel.remoteAddress() == null ? channel.parent().localAddress()
: channel.remoteAddress();
if (BungeeCord.getInstance().getConnectionThrottle() != null && BungeeCord.getInstance().getConnectionThrottle().throttle(remoteAddress)) {
if (BUNGEE.getConnectionThrottle() != null && BUNGEE.getConnectionThrottle().throttle(remoteAddress)) {
channel.close();
} else {
final ListenerInfo listener = channel.attr(PipelineUtils.LISTENER).get();
@@ -80,13 +80,12 @@ public final class ChildChannelInitializer extends ChannelInitializer<Channel> {
}
channel.pipeline().addBefore(FRAME_DECODER, LEGACY_DECODER, new LegacyDecoder());
channel.pipeline().addAfter(FRAME_DECODER, PACKET_DECODER, new MinecraftDecoder(Protocol.HANDSHAKE, true,
ProxyServer.getInstance().getProtocolVersion()));
channel.pipeline().addAfter(FRAME_PREPENDER, PACKET_ENCODER, new MinecraftEncoder(Protocol.HANDSHAKE,
true, ProxyServer.getInstance().getProtocolVersion()));
channel.pipeline().addAfter(FRAME_DECODER, PACKET_DECODER, new MinecraftDecoder(
Protocol.HANDSHAKE, true, ProtocolVersion.LATEST_VERSION.getProtocol()));
channel.pipeline().addAfter(FRAME_PREPENDER, PACKET_ENCODER, new MinecraftEncoder(
Protocol.HANDSHAKE, true, ProtocolVersion.LATEST_VERSION.getProtocol()));
channel.pipeline().addBefore(FRAME_PREPENDER, LEGACY_KICKER, LEGACY_KICK);
channel.pipeline().get(HandlerBoss.class).setHandler(new FallbackInitialHandler(BungeeCord.getInstance(),
listener));
channel.pipeline().get(FallbackHandlerBoss.class).setHandler(new FallbackInitialHandler(BUNGEE, listener));
if (listener.isProxyProtocol()) {
channel.pipeline().addFirst(new HAProxyMessageDecoder());
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -21,6 +21,7 @@ import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.ByteToMessageDecoder;
import io.netty.handler.codec.CorruptedFrameException;
import org.jetbrains.annotations.NotNull;
import java.util.List;
@@ -29,7 +30,7 @@ import java.util.List;
public final class Varint21FrameDecoder extends ByteToMessageDecoder {
@Override
protected void decode(final ChannelHandlerContext ctx,
protected void decode(final @NotNull ChannelHandlerContext ctx,
final ByteBuf byteBuf,
final List<Object> out) throws Exception {
if (!ctx.channel().isActive() || !byteBuf.isReadable()) {
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -1,41 +0,0 @@
/*
* Copyright (C) 2023 Sonar Contributors
*
* 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 3 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, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.bungee.verbose;
import lombok.RequiredArgsConstructor;
import net.kyori.adventure.text.Component;
import net.md_5.bungee.api.ProxyServer;
import net.md_5.bungee.api.connection.ProxiedPlayer;
import xyz.jonesdev.sonar.api.verbose.Verbose;
@RequiredArgsConstructor
public final class VerboseWrapper extends Verbose {
private final ProxyServer server;
@Override
public void broadcast(final Component component) {
synchronized (subscribers) {
for (final String subscriber : subscribers) {
final ProxiedPlayer player = server.getPlayer(subscriber);
if (player != null) {
// TODO: use an adventure audience
}
}
}
}
}
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -24,6 +24,7 @@ import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.cappuccino.Cappuccino;
import xyz.jonesdev.cappuccino.ExpiringCache;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.SonarPlatform;
import xyz.jonesdev.sonar.api.SonarSupplier;
import xyz.jonesdev.sonar.api.command.subcommand.SubcommandRegistry;
import xyz.jonesdev.sonar.api.config.SonarConfiguration;
@@ -32,6 +33,7 @@ import xyz.jonesdev.sonar.api.fallback.FallbackRatelimiter;
import xyz.jonesdev.sonar.api.timer.SystemTimer;
import xyz.jonesdev.sonar.api.verbose.Verbose;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPreparer;
import xyz.jonesdev.sonar.common.service.ServiceRepository;
import xyz.jonesdev.sonar.common.subcommand.SubcommandRegistryHolder;
import xyz.jonesdev.sonar.common.update.UpdateChecker;
@@ -48,19 +50,21 @@ public abstract class SonarBootstrap<T> implements Sonar {
private SonarConfiguration config;
private VerifiedPlayerController verifiedPlayerController;
private File dataDirectory;
private final SonarPlatform platform;
private final SubcommandRegistry subcommandRegistry;
private final SystemTimer launchTimer = new SystemTimer();
public SonarBootstrap(final @NotNull T plugin,
final File dataDirectory,
final Verbose verboseHandler) {
// Set the API to this instance so the config doesn't have issues
final SonarPlatform platform) {
// Set the Sonar API
SonarSupplier.set(this);
// Set the plugin instance before anything else
this.plugin = plugin;
this.dataDirectory = dataDirectory;
this.verboseHandler = verboseHandler;
this.platform = platform;
this.verboseHandler = new Verbose();
this.config = new SonarConfiguration(dataDirectory);
this.subcommandRegistry = new SubcommandRegistryHolder();
}
@@ -82,6 +86,9 @@ public abstract class SonarBootstrap<T> implements Sonar {
getLogger().info("Successfully initialized components in {}s!", launchTimer);
getLogger().info("Enabling all tasks and features...");
// Start all service threads
ServiceRepository.register();
try {
// Run the per-platform initialization method
enable();
@@ -126,7 +133,10 @@ public abstract class SonarBootstrap<T> implements Sonar {
public void shutdown() {
getLogger().info("Starting shutdown process...");
// ...
// Shut down service threads
ServiceRepository.shutdown();
getLogger().info("Successfully shut down. Goodbye!");
}
}
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -20,25 +20,25 @@ package xyz.jonesdev.sonar.common.fallback;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.fallback.Fallback;
import java.net.InetAddress;
import java.net.InetSocketAddress;
@RequiredArgsConstructor
public final class FallbackChannelHandler extends ChannelInboundHandlerAdapter {
private static final Fallback FALLBACK = Sonar.get().getFallback();
private final String username;
private static final @NotNull Fallback FALLBACK = Sonar.get().getFallback();
private final @NotNull String username;
private final @NotNull InetAddress inetAddress;
@Override
public void channelUnregistered(final ChannelHandlerContext ctx) {
final InetAddress inetAddress = ((InetSocketAddress) ctx.channel().remoteAddress()).getAddress();
// Remove the IP address from the queue
public void channelUnregistered(final @NotNull ChannelHandlerContext ctx) {
// Remove the username from the connected players
FALLBACK.getConnected().remove(username);
// Remove the IP address from the queue
FALLBACK.getQueue().remove(inetAddress);
// Make sure other handlers also know we've disconnected
ctx.fireChannelUnregistered();
}
}
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -21,24 +21,26 @@ import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.timeout.IdleState;
import io.netty.handler.timeout.IdleStateEvent;
import io.netty.handler.timeout.IdleStateHandler;
import org.jetbrains.annotations.NotNull;
import java.util.concurrent.TimeUnit;
public final class FallbackTimeoutHandler extends IdleStateHandler {
private boolean closed;
private boolean knownDisconnect;
public FallbackTimeoutHandler(final long timeout, final TimeUnit timeUnit) {
super(timeout, 0L, 0L, timeUnit);
}
@Override
protected void channelIdle(final ChannelHandlerContext ctx, final IdleStateEvent idleStateEvent) throws Exception {
protected void channelIdle(final ChannelHandlerContext ctx,
final @NotNull IdleStateEvent idleStateEvent) throws Exception {
assert idleStateEvent.state() == IdleState.READER_IDLE;
readTimedOut(ctx);
}
private void readTimedOut(final ChannelHandlerContext ctx) {
if (!closed) {
if (!knownDisconnect) {
// The netty (default) ReadTimeoutHandler would normally just throw an Exception
// The default ReadTimeoutHandler does only check for the boolean 'closed' and
@@ -47,7 +49,7 @@ public final class FallbackTimeoutHandler extends IdleStateHandler {
ctx.close();
}
closed = true;
knownDisconnect = true;
}
}
}
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -20,20 +20,25 @@ package xyz.jonesdev.sonar.common.fallback;
import io.netty.buffer.ByteBuf;
import io.netty.handler.codec.CorruptedFrameException;
import io.netty.handler.codec.DecoderException;
import lombok.Setter;
import lombok.val;
import net.kyori.adventure.text.minimessage.MiniMessage;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.event.impl.UserVerifySuccessEvent;
import xyz.jonesdev.sonar.api.fallback.FallbackUser;
import xyz.jonesdev.sonar.api.model.VerifiedPlayer;
import xyz.jonesdev.sonar.api.timer.SystemTimer;
import xyz.jonesdev.sonar.common.fallback.protocol.*;
import xyz.jonesdev.sonar.common.fallback.protocol.map.ItemMapType;
import xyz.jonesdev.sonar.common.fallback.protocol.map.MapInfoPreparer;
import xyz.jonesdev.sonar.common.fallback.protocol.map.PreparedMapInfo;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.config.FinishConfiguration;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.login.LoginAcknowledged;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.play.*;
import xyz.jonesdev.sonar.common.utility.protocol.ProtocolUtil;
import java.util.Objects;
import java.util.Random;
import java.util.UUID;
import java.util.regex.Pattern;
@@ -44,22 +49,28 @@ import static xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion.*;
import static xyz.jonesdev.sonar.common.fallback.protocol.FallbackPreparer.*;
public final class FallbackVerificationHandler implements FallbackPacketListener {
private final @NotNull FallbackUser<?, ?> user;
private static final Random RANDOM = new Random();
// General
private final SystemTimer login = new SystemTimer();
private final @NotNull FallbackUser<?> user;
private final String username;
private final UUID playerUuid;
private @NotNull State state = State.LOGIN_ACK; // 1.20.2
// Checks
private short expectedTransactionId;
private long expectedKeepAliveId;
private int expectedTeleportId = -1;
private int tick, totalReceivedPackets;
private int ignoredMovementTicks;
private int expectedKeepAliveId, expectedTeleportId = -1;
private int tick, totalReceivedPackets, ignoredMovementTicks;
private double posX, posY, posZ, lastY;
private boolean resolvedClientBrand, resolvedClientSettings;
@Setter
private @NotNull State state = State.LOGIN_ACK;
private boolean listenForMovements;
private final SystemTimer login = new SystemTimer();
private static final Random RANDOM = new Random();
// Map captcha
private final SystemTimer keepAlive = new SystemTimer();
private final SystemTimer actionBar = new SystemTimer();
private @Nullable PreparedMapInfo captcha;
private int captchaTriesLeft;
public enum State {
// 1.20.2 configuration state
@@ -69,12 +80,14 @@ public final class FallbackVerificationHandler implements FallbackPacketListener
// post-JOIN checks
CLIENT_SETTINGS, PLUGIN_MESSAGE, TRANSACTION,
// PLAY checks
TELEPORT, POSITION, COLLISIONS,
TELEPORT, POSITION,
// Captcha
MAP_CAPTCHA,
// Done
SUCCESS
}
public FallbackVerificationHandler(final @NotNull FallbackUser<?, ?> user,
public FallbackVerificationHandler(final @NotNull FallbackUser<?> user,
final @NotNull String username,
final @NotNull UUID playerUuid) {
this.user = user;
@@ -90,8 +103,8 @@ public final class FallbackVerificationHandler implements FallbackPacketListener
private void configure() {
// Set the state to CONFIGURE to avoid false positives
state = State.CONFIGURE;
// Send necessary configuration packets to the client
user.delayedWrite(synchronizeRegistry);
// Send the necessary configuration packets to the client
user.delayedWrite(REGISTRY_SYNC);
user.delayedWrite(FINISH_CONFIGURATION);
// Send all packets in one flush
user.getChannel().flush();
@@ -142,11 +155,12 @@ public final class FallbackVerificationHandler implements FallbackPacketListener
final boolean v1_20_2 = user.getProtocolVersion().compareTo(MINECRAFT_1_20_2) >= 0;
if (!v1_20_2) {
// Set the state to CLIENT_SETTINGS to avoid false positives
// and go on with the flow of the verification.
// (1.20.2 needs the LOGIN_ACK state) and go on with
// the flow of the verification.
state = State.CLIENT_SETTINGS;
}
// Select the JoinGame packet for the respective protocol version
user.write(getJoinPacketForVersion(user.getProtocolVersion()));
// Send the JoinGame packet so we can continue the verification
user.write(joinGame);
if (v1_20_2) {
// Perform the transaction check since the ClientSettings
// and PluginMessage packets have already been validated.
@@ -165,33 +179,47 @@ public final class FallbackVerificationHandler implements FallbackPacketListener
// Teleport the player to the spawn position
user.delayedWrite(new PositionLook(
SPAWN_X_POSITION, dynamicSpawnYPosition, SPAWN_Z_POSITION,
0f, 0f, expectedTeleportId, false
));
0f, -90f, expectedTeleportId, false));
// Make sure the player escapes the 1.18.2+ "Loading terrain" screen
if (user.getProtocolVersion().compareTo(MINECRAFT_1_18_2) >= 0) {
user.delayedWrite(dynamicSpawnPosition);
}
}
private void sendChunkData() {
// Set the state to POSITION to avoid false positives
// and go on with the flow of the verification.
state = State.POSITION;
// 1.20.3 packets introduced game events
if (user.getProtocolVersion().compareTo(MINECRAFT_1_20_3) >= 0) {
// Make sure the client knows that we're sending chunks next
user.delayedWrite(START_WRITING_CHUNKS);
}
// Teleport player into the fake lobby by sending an empty chunk
user.delayedWrite(EMPTY_CHUNK_DATA);
if (Sonar.get().getConfig().getVerification().isCheckCollisions()) {
// Send an UpdateSectionBlocks packet with a platform of blocks
// to check if the player collides with the solid platform.
user.delayedWrite(UPDATE_SECTION_BLOCKS);
// Send an UpdateSectionBlocks packet with a platform of blocks
// to check if the player collides with the solid platform.
user.delayedWrite(updateSectionBlocks);
// Checking gravity is disabled, just finish verification
if (!Sonar.get().getConfig().getVerification().getGravity().isEnabled()) {
// Switch to captcha state if needed
captchaOrFinish();
} else {
// Make sure the player knows we are checking them
user.delayedWrite(youAreBeingChecked);
// Send all packets in one flush
user.getChannel().flush();
}
// Send all packets in one flush
user.getChannel().flush();
}
private static boolean validateClientLocale(final @SuppressWarnings("unused") @NotNull FallbackUser<?, ?> user,
private static boolean validateClientLocale(final @SuppressWarnings("unused") @NotNull FallbackUser<?> user,
final String locale) {
// Check the client locale by performing a simple regex check on it
final Pattern pattern = Sonar.get().getConfig().getVerification().getValidLocaleRegex();
return pattern.matcher(locale).matches(); // Disallow non-ascii characters (by default)
}
private static boolean validateClientBrand(final @NotNull FallbackUser<?, ?> user, final ByteBuf content) {
private static boolean validateClientBrand(final @NotNull FallbackUser<?> user, final ByteBuf content) {
// We have to catch every DecoderException, so we can fail and punish
// the player instead of only disconnecting them due to an exception.
try {
@@ -218,13 +246,60 @@ public final class FallbackVerificationHandler implements FallbackPacketListener
public void handle(final @NotNull FallbackPacket packet) {
// The player has already been verified, drop all other packets
if (state == State.SUCCESS) return;
// Check if the player is not sending a ton of packets to the server
final int maxPackets = Sonar.get().getConfig().getVerification().getMaxLoginPackets() + maxPredictionTick;
checkFrame(++totalReceivedPackets < maxPackets, "too many packets");
// Check for timeout since the player could be sending packets but not important ones
final long timeout = Sonar.get().getConfig().getVerification().getMaxPing();
// We are expecting a captcha code in chat, drop all other packets
if (state == State.MAP_CAPTCHA) {
// Check if the player took too long to enter the captcha
final int maxDuration = Sonar.get().getConfig().getVerification().getMap().getMaxDuration();
checkFrame(!login.elapsed(maxDuration), "took too long to enter captcha");
// Handle incoming chat messages
if (packet instanceof Chat) {
final Chat chat = (Chat) packet;
Objects.requireNonNull(captcha);
if (!chat.getMessage().equals(captcha.getInfo().getAnswer())) {
// Captcha is incorrect
checkFrame(captchaTriesLeft-- > 0, "failed captcha too often");
user.write(incorrectCaptcha);
return;
}
// Captcha is correct
finish();
return;
}
// Every second
if (actionBar.elapsed(1000L)) {
final String actionBarMessage = Sonar.get().getConfig().getVerification().getMap().getEnterCodeActionBar();
// Only send action bar if the message is actually supposed to be sent
if (!actionBarMessage.isEmpty()) {
final String timeLeft = String.format("%.0f", (maxDuration - login.delay()) / 1000D);
user.write(new Chat(MiniMessage.miniMessage().deserialize(
actionBarMessage.replace("%time-left%", timeLeft)), Chat.GAME_INFO_TYPE));
// Make sure to reset the timer
actionBar.reset();
}
}
// Every about 10 seconds
if (keepAlive.elapsed(timeout)) {
// Send the message again to remind the player
user.delayedWrite(enterCodeMessage);
// Send a KeepAlive packet to prevent timeout
user.delayedWrite(CAPTCHA_KEEP_ALIVE);
// Send both packets in one flush
user.getChannel().flush();
// Make sure to reset the timer
keepAlive.reset();
}
return;
}
// Check if the player is not sending a ton of packets to the server
final int maxPackets = Sonar.get().getConfig().getVerification().getMaxLoginPackets() + maxMovementTick;
checkFrame(++totalReceivedPackets < maxPackets, "too many packets");
// Check if the time limit has exceeded
if (login.elapsed(timeout)) {
user.getChannel().close();
@@ -323,18 +398,6 @@ public final class FallbackVerificationHandler implements FallbackPacketListener
// Check if the transaction ID is valid
checkFrame(transaction.getId() == expectedTransactionId, "invalid transaction id");
// Checking gravity is disabled, just finish verification
if (!Sonar.get().getConfig().getVerification().isCheckGravity()) {
finish();
return;
}
// TODO: implement ChunkData packet for 1.20.2 and fix block state Ids for barriers
if (user.getProtocolVersion().compareTo(MINECRAFT_1_20_2) >= 0) {
finish();
return;
}
// First, send an Abilities packet to the client to make
// sure the player falls even in spectator mode.
// Then, teleport the player to the spawn position.
@@ -405,11 +468,8 @@ public final class FallbackVerificationHandler implements FallbackPacketListener
// The player is not allowed to move away from the collision platform.
// This should not happen unless the max movement tick is configured to a high number.
checkFrame(Math.abs(x - BLOCKS_PER_ROW) <= BLOCKS_PER_ROW, "moved too far (x)");
checkFrame(Math.abs(z - BLOCKS_PER_ROW) <= BLOCKS_PER_ROW, "moved too far (z)");
// The onGround property can never be true when we aren't checking for collisions
checkFrame(!ground || state == State.COLLISIONS, "invalid ground state");
checkFrame(Math.abs(x - BLOCKS_PER_ROW) < BLOCKS_PER_ROW, "moved too far (x)");
checkFrame(Math.abs(z - BLOCKS_PER_ROW) < BLOCKS_PER_ROW, "moved too far (z)");
// Check if the client hasn't moved before sending the first movement packet
if (!listenForMovements) {
@@ -427,44 +487,39 @@ public final class FallbackVerificationHandler implements FallbackPacketListener
// We have to account for this or the player will fail the verification.
if (deltaY == 0) {
// Check for too many ignored Y ticks
final int maxIgnoredTicks = Sonar.get().getConfig().getVerification().getMaxIgnoredTicks();
final int maxIgnoredTicks = Sonar.get().getConfig().getVerification().getGravity().getMaxIgnoredTicks();
checkFrame(++ignoredMovementTicks < maxIgnoredTicks, "too many ignored ticks");
return;
}
// Calculate the difference between the player's Y coordinate and the expected Y coordinate
final double collisionOffsetY = DEFAULT_Y_COLLIDE_POSITION - y;
// Check if the player is colliding by performing a basic Y offset check.
// The offset cannot be greater than 0 since the blocks will not let the player fall through them.
checkFrame(collisionOffsetY <= 0, "fell through blocks: " + collisionOffsetY);
if (tick > maxMovementTick) {
if (!Sonar.get().getConfig().getVerification().isCheckCollisions()) {
// Checking collisions is disabled, just finish verification
finish();
return;
}
if (state != State.COLLISIONS) {
// Now we don't care about gravity anymore,
// we just want the player to collide with the blocks.
state = State.COLLISIONS;
}
// Calculate the difference between the player's Y coordinate and the expected Y coordinate
final double offsetY = DEFAULT_Y_COLLIDE_POSITION - y;
// Log/debug position if enabled in the configuration
if (Sonar.get().getConfig().getVerification().isDebugXYZPositions()) {
user.getFallback().getLogger().info("{}: {}/{}/{} - offset: {}", username, x, y, z, offsetY);
user.getFallback().getLogger().info("{}: {}/{}/{} - deltaY: {} - ground: {} - collision: {}",
username, x, y, z, deltaY, ground, collisionOffsetY);
}
// Check if the player is colliding by performing a basic Y offset check.
// The offset cannot be greater than 0 since the blocks will not let the player fall through them.
checkFrame(offsetY <= 0, "no collisions: " + offsetY);
// The player is colliding to blocks, finish verification
if (ground) {
// The player is colliding to blocks, finish verification
finish();
captchaOrFinish();
return;
}
} else {
// Check if the player is spoofing the ground state
checkFrame(!ground, "spoofed ground state");
}
if (!ground && tick < maxPredictionTick) {
// Make sure we don't run out of predicted Y motions
checkFrame(tick < preparedCachedYMotions.length, "too many movements");
if (!ground) {
final double predictedY = preparedCachedYMotions[tick];
final double offsetY = Math.abs(deltaY - predictedY);
@@ -481,17 +536,46 @@ public final class FallbackVerificationHandler implements FallbackPacketListener
tick++;
}
private void assertState(final @NotNull State expectedState) {
if (expectedState != state) {
user.fail("expected " + expectedState + ", got " + state);
private void captchaOrFinish() {
if (user.getFallback().shouldDoMapCaptcha()) {
if (MapInfoPreparer.getPreparedCAPTCHAs() == 0) {
// This should not happen, but we have to return if there is no captcha prepared
user.disconnect(Sonar.get().getConfig().getVerification().getCurrentlyPreparing());
return;
}
// Set the state to MAP_CAPTCHA, so we don't handle any unnecessary packets
state = State.MAP_CAPTCHA;
if (!Sonar.get().getConfig().getVerification().getGravity().isEnabled()
&& user.getProtocolVersion().compareTo(MINECRAFT_1_18_2) >= 0) {
// Make sure the player escapes the 1.18.2+ "Loading terrain" screen
user.delayedWrite(CAPTCHA_SPAWN_POSITION);
}
// Initialize the map captcha
handleMapCaptcha();
} else {
// Finish the verification
finish();
}
}
private void checkFrame(final boolean condition, final String message) {
if (!condition) {
user.fail(message);
throw new CorruptedFrameException(message);
}
private void handleMapCaptcha() {
// Reset max tries
captchaTriesLeft = Sonar.get().getConfig().getVerification().getMap().getMaxTries();
// Set slot to map
user.delayedWrite(new SetSlot(0, 36, 1, 0,
ItemMapType.FILLED_MAP.getId(user.getProtocolVersion()), SetSlot.MAP_NBT));
// Send random captcha to the player
captcha = MapInfoPreparer.getRandomCaptcha();
Objects.requireNonNull(captcha).write(user);
// Teleport the player to the position above the platform
user.delayedWrite(CAPTCHA_POSITION);
// Make sure the player cannot move
user.delayedWrite(CAPTCHA_ABILITIES);
// Make sure the player knows what to do
user.delayedWrite(enterCodeMessage);
// Send all packets in one flush
user.getChannel().flush();
}
private void finish() {
@@ -514,4 +598,26 @@ public final class FallbackVerificationHandler implements FallbackPacketListener
.replace("%name%", username)
.replace("%time%", login.toString()));
}
/**
* Fails the verification if a certain state is unexpected.
*
* @param expectedState Expected state
*/
private void assertState(final @NotNull State expectedState) {
checkFrame(state == expectedState, "expected " + expectedState + ", got " + state);
}
/**
* Checks if a certain condition is met, fails the verification if not.
*
* @param condition Condition to fail if it's false
* @param message Messages displayed in the stacktrace
*/
private void checkFrame(final boolean condition, final String message) {
if (!condition) {
user.fail(message);
throw new CorruptedFrameException(message);
}
}
}
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -31,16 +31,15 @@ import static xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacketRegistry
import static xyz.jonesdev.sonar.common.utility.protocol.VarIntUtil.readVarInt;
public final class FallbackPacketDecoder extends ChannelInboundHandlerAdapter {
private final FallbackUser<?, ?> user;
private final FallbackUser<?> user;
private final FallbackPacketListener listener;
private @NotNull FallbackPacketRegistry.ProtocolRegistry registry;
private FallbackPacketRegistry.ProtocolRegistry registry;
public FallbackPacketDecoder(final @NotNull FallbackUser<?, ?> user,
public FallbackPacketDecoder(final @NotNull FallbackUser<?> user,
final @NotNull FallbackPacketListener listener) {
this.user = user;
this.registry = (user.getProtocolVersion().compareTo(MINECRAFT_1_20_2) >= 0 ? LOGIN : GAME)
.getProtocolRegistry(SERVERBOUND, user.getProtocolVersion());
this.listener = listener;
updateRegistry(user.getProtocolVersion().compareTo(MINECRAFT_1_20_2) >= 0 ? LOGIN : GAME);
}
public void updateRegistry(final @NotNull FallbackPacketRegistry registry) {
@@ -66,8 +65,7 @@ public final class FallbackPacketDecoder extends ChannelInboundHandlerAdapter {
final int packetId = readVarInt(byteBuf);
final FallbackPacket packet = registry.createPacket(packetId);
// If the packet hasn't been found, skip it
// TODO: Can we fail the verification afterwards?
// If the packet isn't found, skip it
if (packet == null) {
byteBuf.readerIndex(originalReaderIndex);
return;
@@ -93,6 +91,9 @@ public final class FallbackPacketDecoder extends ChannelInboundHandlerAdapter {
// Let our verification handler process the packet
listener.handle(packet);
// Fire channel read to avoid timeout
ctx.fireChannelRead(packet);
} finally {
// Release the ByteBuf to avoid memory leaks
byteBuf.release();
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -23,7 +23,7 @@ import lombok.Data;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.config.FinishConfiguration;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.config.RegistrySync;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.config.RegistryData;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.login.LoginAcknowledged;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.login.LoginSuccess;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.play.*;
@@ -48,13 +48,13 @@ public enum FallbackPacketRegistry {
CONFIG {
{
clientbound.register(Disconnect.class, Disconnect::new,
map(0x01, MINECRAFT_1_20_2, false));
map(0x01, MINECRAFT_1_20_2, true));
clientbound.register(FinishConfiguration.class, FinishConfiguration::new,
map(0x02, MINECRAFT_1_20_2, false));
clientbound.register(KeepAlive.class, KeepAlive::new,
map(0x03, MINECRAFT_1_20_2, false));
clientbound.register(RegistrySync.class, RegistrySync::new,
map(0x05, MINECRAFT_1_20_2, false));
clientbound.register(RegistryData.class, RegistryData::new,
map(0x05, MINECRAFT_1_20_2, true));
serverbound.register(ClientSettings.class, ClientSettings::new,
map(0x00, MINECRAFT_1_20_2, false));
@@ -166,7 +166,8 @@ public enum FallbackPacketRegistry {
map(0x40, MINECRAFT_1_19_1, true),
map(0x3F, MINECRAFT_1_19_3, true),
map(0x43, MINECRAFT_1_19_4, true),
map(0x45, MINECRAFT_1_20_2, true));
map(0x45, MINECRAFT_1_20_2, true),
map(0x47, MINECRAFT_1_20_3, true));
clientbound.register(Transaction.class, Transaction::new,
map(0x32, MINECRAFT_1_7_2, true),
map(0x11, MINECRAFT_1_9, true),
@@ -180,19 +181,73 @@ public enum FallbackPacketRegistry {
map(0x2E, MINECRAFT_1_19_3, true),
map(0x32, MINECRAFT_1_19_4, true),
map(0x33, MINECRAFT_1_20_2, true));
clientbound.register(DefaultSpawnPosition.class, DefaultSpawnPosition::new,
map(0x05, MINECRAFT_1_7_2, true),
map(0x43, MINECRAFT_1_9, true),
map(0x45, MINECRAFT_1_12, true),
map(0x46, MINECRAFT_1_12_1, true),
map(0x49, MINECRAFT_1_13, true),
map(0x4D, MINECRAFT_1_14, true),
map(0x4E, MINECRAFT_1_15, true),
map(0x42, MINECRAFT_1_16, true),
map(0x4B, MINECRAFT_1_17, true),
map(0x4A, MINECRAFT_1_19, true),
map(0x4D, MINECRAFT_1_19_1, true),
map(0x4C, MINECRAFT_1_19_3, true),
map(0x50, MINECRAFT_1_19_4, true),
map(0x52, MINECRAFT_1_20_2, true),
map(0x54, MINECRAFT_1_20_3, true));
clientbound.register(MapData.class, MapData::new,
map(0x34, MINECRAFT_1_7_2, true),
map(0x24, MINECRAFT_1_9, true),
map(0x26, MINECRAFT_1_13, true),
map(0x27, MINECRAFT_1_15, true),
map(0x26, MINECRAFT_1_16, true),
map(0x25, MINECRAFT_1_16_2, true),
map(0x27, MINECRAFT_1_17, true),
map(0x24, MINECRAFT_1_19, true),
map(0x26, MINECRAFT_1_19_1, true),
map(0x25, MINECRAFT_1_19_3, true),
map(0x29, MINECRAFT_1_19_4, true),
map(0x2A, MINECRAFT_1_20_2, true));
clientbound.register(SetSlot.class, SetSlot::new,
map(0x2F, MINECRAFT_1_7_2, true),
map(0x16, MINECRAFT_1_9, true),
map(0x17, MINECRAFT_1_13, true),
map(0x16, MINECRAFT_1_14, true),
map(0x17, MINECRAFT_1_15, true),
map(0x16, MINECRAFT_1_16, true),
map(0x15, MINECRAFT_1_16_2, true),
map(0x16, MINECRAFT_1_17, true),
map(0x13, MINECRAFT_1_19, true),
map(0x12, MINECRAFT_1_19_3, true),
map(0x14, MINECRAFT_1_19_4, true),
map(0x15, MINECRAFT_1_20_2, true));
clientbound.register(Chat.class, Chat::new,
map(0x02, MINECRAFT_1_7_2, true),
map(0x0F, MINECRAFT_1_9, true),
map(0x0E, MINECRAFT_1_13, true),
map(0x0F, MINECRAFT_1_15, true),
map(0x0E, MINECRAFT_1_16, true),
map(0x0F, MINECRAFT_1_18_2, true),
map(0x5F, MINECRAFT_1_19, true),
map(0x62, MINECRAFT_1_19_1, true),
map(0x60, MINECRAFT_1_19_3, true),
map(0x64, MINECRAFT_1_19_4, true),
map(0x67, MINECRAFT_1_20_2, true));
map(0x02, MINECRAFT_1_7_2, false),
map(0x0F, MINECRAFT_1_9, false),
map(0x0E, MINECRAFT_1_13, false),
map(0x0F, MINECRAFT_1_15, false),
map(0x0E, MINECRAFT_1_16, false),
map(0x0F, MINECRAFT_1_17, false),
map(0x5F, MINECRAFT_1_19, false),
map(0x62, MINECRAFT_1_19_1, false),
map(0x60, MINECRAFT_1_19_3, false),
map(0x64, MINECRAFT_1_19_4, false),
map(0x67, MINECRAFT_1_20_2, false),
map(0x69, MINECRAFT_1_20_3, false));
clientbound.register(GameEvent.class, GameEvent::new,
map(0x20, MINECRAFT_1_20_3, true));
serverbound.register(Chat.class, Chat::new,
map(0x01, MINECRAFT_1_7_2, false),
map(0x02, MINECRAFT_1_9, false),
map(0x03, MINECRAFT_1_12, false),
map(0x02, MINECRAFT_1_12_1, false),
map(0x03, MINECRAFT_1_14, false),
map(0x04, MINECRAFT_1_19, false),
map(0x05, MINECRAFT_1_19_1, false),
map(0x05, MINECRAFT_1_19_3, false));
serverbound.register(KeepAlive.class, KeepAlive::new,
map(0x00, MINECRAFT_1_7_2, false),
map(0x0B, MINECRAFT_1_9, false),
@@ -206,7 +261,8 @@ public enum FallbackPacketRegistry {
map(0x12, MINECRAFT_1_19_1, false),
map(0x11, MINECRAFT_1_19_3, false),
map(0x12, MINECRAFT_1_19_4, false),
map(0x14, MINECRAFT_1_20_2, false));
map(0x14, MINECRAFT_1_20_2, false),
map(0x15, MINECRAFT_1_20_3, false));
serverbound.register(ClientSettings.class, ClientSettings::new,
map(0x15, MINECRAFT_1_7_2, false),
map(0x04, MINECRAFT_1_9, false),
@@ -244,7 +300,8 @@ public enum FallbackPacketRegistry {
map(0x15, MINECRAFT_1_19_1, false),
map(0x14, MINECRAFT_1_19_3, false),
map(0x15, MINECRAFT_1_19_4, false),
map(0x17, MINECRAFT_1_20_2, false));
map(0x17, MINECRAFT_1_20_2, false),
map(0x18, MINECRAFT_1_20_3, false));
serverbound.register(TeleportConfirm.class, TeleportConfirm::new,
map(0x00, MINECRAFT_1_9, false));
serverbound.register(Position.class, Position::new,
@@ -260,7 +317,8 @@ public enum FallbackPacketRegistry {
map(0x14, MINECRAFT_1_19_1, false),
map(0x13, MINECRAFT_1_19_3, false),
map(0x14, MINECRAFT_1_19_4, false),
map(0x16, MINECRAFT_1_20_2, false));
map(0x16, MINECRAFT_1_20_2, false),
map(0x17, MINECRAFT_1_20_3, false));
serverbound.register(Player.class, Player::new,
map(0x03, MINECRAFT_1_7_2, false),
map(0x0F, MINECRAFT_1_9, false),
@@ -274,7 +332,8 @@ public enum FallbackPacketRegistry {
map(0x17, MINECRAFT_1_19_1, false),
map(0x16, MINECRAFT_1_19_3, false),
map(0x17, MINECRAFT_1_19_4, false),
map(0x19, MINECRAFT_1_20_2, false));
map(0x19, MINECRAFT_1_20_2, false),
map(0x1A, MINECRAFT_1_20_3, false));
serverbound.register(Transaction.class, Transaction::new,
map(0x0F, MINECRAFT_1_7_2, false),
map(0x05, MINECRAFT_1_9, false),
@@ -287,7 +346,8 @@ public enum FallbackPacketRegistry {
map(0x20, MINECRAFT_1_19_1, false),
map(0x1F, MINECRAFT_1_19_3, false),
map(0x20, MINECRAFT_1_19_4, false),
map(0x23, MINECRAFT_1_20_2, false));
map(0x23, MINECRAFT_1_20_2, false),
map(0x24, MINECRAFT_1_20_3, false));
}
};
@@ -308,14 +368,13 @@ public enum FallbackPacketRegistry {
private final Map<ProtocolVersion, ProtocolRegistry> versions;
PacketRegistry() {
Map<ProtocolVersion, ProtocolRegistry> mutableVersions = new EnumMap<>(ProtocolVersion.class);
for (ProtocolVersion version : ProtocolVersion.values()) {
final Map<ProtocolVersion, ProtocolRegistry> mutableVersions = new EnumMap<>(ProtocolVersion.class);
for (final ProtocolVersion version : ProtocolVersion.values()) {
if (!version.isLegacy() && !version.isUnknown()) {
mutableVersions.put(version, new ProtocolRegistry(version));
}
}
versions = Collections.unmodifiableMap(mutableVersions);
this.versions = Collections.unmodifiableMap(mutableVersions);
}
ProtocolRegistry getProtocolRegistry(final ProtocolVersion version) {
@@ -345,7 +404,7 @@ public enum FallbackPacketRegistry {
break;
}
final ProtocolRegistry registry = this.versions.get(protocol);
final ProtocolRegistry registry = versions.get(protocol);
if (registry == null) {
throw new IllegalArgumentException("Unknown protocol version "
+ current.protocolVersion);
@@ -374,35 +433,12 @@ public enum FallbackPacketRegistry {
private static ProtocolVersion getProtocolVersion(final @NotNull PacketMapping current,
final @NotNull PacketMapping next,
final @NotNull ProtocolVersion from) {
final ProtocolVersion lastValid = current.lastValidProtocolVersion;
if (lastValid != null) {
if (next != current) {
throw new IllegalArgumentException("Cannot add a mapping after last valid mapping");
}
if (from.compareTo(lastValid) > 0) {
throw new IllegalArgumentException(
"Last mapping version cannot be higher than highest mapping version");
}
}
final ProtocolVersion last = (ProtocolVersion) SUPPORTED_VERSIONS.toArray()[SUPPORTED_VERSIONS.size() - 1];
final ProtocolVersion to = current == next ? lastValid != null
? lastValid : last : next.protocolVersion;
final ProtocolVersion lastInList = lastValid != null ? lastValid : last;
if (from.compareTo(to) >= 0 && from != lastInList) {
throw new IllegalArgumentException(String.format(
"Next mapping version (%s) should be lower then current (%s)", to, from));
}
return to;
return current == next ? LATEST_VERSION : next.protocolVersion;
}
}
public static class ProtocolRegistry {
public final ProtocolVersion version;
private final ProtocolVersion version;
private final IntObjectMap<Supplier<? extends FallbackPacket>> packetIdToSupplier =
new IntObjectHashMap<>(16, 0.5f);
private final Map<Class<? extends FallbackPacket>, Integer> packetClassToId =
@@ -436,15 +472,12 @@ public enum FallbackPacketRegistry {
private final int id;
private final ProtocolVersion protocolVersion;
private final boolean encodeOnly;
private final ProtocolVersion lastValidProtocolVersion;
PacketMapping(final int id,
final ProtocolVersion protocolVersion,
final ProtocolVersion lastValidProtocolVersion,
final boolean packetDecoding) {
this.id = id;
this.protocolVersion = protocolVersion;
this.lastValidProtocolVersion = lastValidProtocolVersion;
this.encodeOnly = packetDecoding;
}
}
@@ -452,6 +485,6 @@ public enum FallbackPacketRegistry {
private static @NotNull PacketMapping map(final int id,
final ProtocolVersion version,
final boolean encodeOnly) {
return new PacketMapping(id, version, null, encodeOnly);
return new PacketMapping(id, version, encodeOnly);
}
}
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -18,111 +18,87 @@
package xyz.jonesdev.sonar.common.fallback.protocol;
import lombok.experimental.UtilityClass;
import net.kyori.adventure.nbt.BinaryTagIO;
import net.kyori.adventure.nbt.BinaryTagTypes;
import net.kyori.adventure.nbt.CompoundBinaryTag;
import net.kyori.adventure.nbt.ListBinaryTag;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion;
import xyz.jonesdev.sonar.api.config.SonarConfiguration;
import xyz.jonesdev.sonar.common.fallback.protocol.block.BlockPosition;
import xyz.jonesdev.sonar.common.fallback.protocol.block.BlockType;
import xyz.jonesdev.sonar.common.fallback.protocol.block.ChangedBlock;
import xyz.jonesdev.sonar.common.fallback.protocol.dimension.DimensionInfo;
import xyz.jonesdev.sonar.common.fallback.protocol.map.MapInfoPreparer;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.config.FinishConfiguration;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.config.RegistrySync;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.play.Abilities;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.play.EmptyChunkData;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.play.JoinGame;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.play.UpdateSectionBlocks;
import java.io.InputStream;
import java.util.Collections;
import java.util.Objects;
import static xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion.*;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.config.RegistryData;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.play.*;
@UtilityClass
public class FallbackPreparer {
// Mappings
private final CompoundBinaryTag CHAT_TYPE_119;
private final CompoundBinaryTag CHAT_TYPE_1191;
private final CompoundBinaryTag DAMAGE_TYPE_1194;
private final CompoundBinaryTag DAMAGE_TYPE_120;
static {
CHAT_TYPE_119 = getMapping("chat_1_19.nbt");
CHAT_TYPE_1191 = getMapping("chat_1_19_1.nbt");
DAMAGE_TYPE_1194 = getMapping("damage_1_19_4.nbt");
DAMAGE_TYPE_120 = getMapping("damage_type_1_20.nbt");
}
// Abilities
public final FallbackPacket DEFAULT_ABILITIES = new Abilities((byte) 0, 0f, 0f);
public final FallbackPacket DEFAULT_ABILITIES = new Abilities(0x00, 0f, 0f);
public final FallbackPacket CAPTCHA_ABILITIES = new Abilities(0x02, 0f, 0f);
// Chunks
public final FallbackPacket EMPTY_CHUNK_DATA = new EmptyChunkData(0, 0);
// Finish Configuration
public final FallbackPacket FINISH_CONFIGURATION = new FinishConfiguration();
// Registry Sync
public FallbackPacket synchronizeRegistry;
// Synchronize Registry
public final FallbackPacket REGISTRY_SYNC = new RegistryData();
// Keep Alive
public final FallbackPacket CAPTCHA_KEEP_ALIVE = new KeepAlive(0L);
// Game Event (1.20.3+)
public final FallbackPacket START_WRITING_CHUNKS = new GameEvent(13, 0);
// Chat
public FallbackPacket enterCodeMessage;
public FallbackPacket youAreBeingChecked;
public FallbackPacket incorrectCaptcha;
// JoinGame
private FallbackPacket legacyJoinGame;
private FallbackPacket joinGame116;
private FallbackPacket joinGame1162;
private FallbackPacket joinGame1182;
private FallbackPacket joinGame1191;
private FallbackPacket joinGame1194;
private JoinGame joinGame120;
// Dimension
private final DimensionInfo DIMENSION = new DimensionInfo(
"minecraft:overworld", "sonar", false, false);
public FallbackPacket joinGame;
// Update Section Blocks
public FallbackPacket updateSectionBlocks;
// Default Spawn Position
public FallbackPacket dynamicSpawnPosition;
// Collisions
public final int BLOCKS_PER_ROW = 8; // 8 * 8 = 64 (max)
private final int SPAWN_BUFFER = 5; // player spawns at 255 + 5 (5 blocks above the platform)
public final int BLOCKS_PER_ROW = 8; // 8 * 8 = 64 (protocol maximum)
public final int SPAWN_X_POSITION = 16 / 2; // middle of the chunk
public final int SPAWN_Z_POSITION = 16 / 2; // middle of the chunk
public final int DEFAULT_Y_COLLIDE_POSITION = 255; // 255 is the maximum Y position allowed
public UpdateSectionBlocks UPDATE_SECTION_BLOCKS;
// Captcha position
public final FallbackPacket CAPTCHA_POSITION = new PositionLook(
SPAWN_X_POSITION, 1337, SPAWN_Z_POSITION, 0f, 90f, 0, false);
public final FallbackPacket CAPTCHA_SPAWN_POSITION = new DefaultSpawnPosition(
SPAWN_X_POSITION, 1337, SPAWN_Z_POSITION);
// Blocks
private final ChangedBlock[] CHANGED_BLOCKS = new ChangedBlock[BLOCKS_PER_ROW * BLOCKS_PER_ROW];
public int maxMovementTick, maxPredictionTick, dynamicSpawnYPosition;
public int maxMovementTick, dynamicSpawnYPosition;
public double[] preparedCachedYMotions;
public double maxFallDistance;
public final int SPAWN_X_POSITION = BLOCKS_PER_ROW; // middle of the chunk
public final int SPAWN_Z_POSITION = BLOCKS_PER_ROW;
public void prepare() {
legacyJoinGame = createJoinGamePacket(MINECRAFT_1_8);
joinGame116 = createJoinGamePacket(MINECRAFT_1_16);
joinGame1162 = createJoinGamePacket(MINECRAFT_1_16_2);
joinGame1182 = createJoinGamePacket(MINECRAFT_1_18_2);
joinGame1191 = createJoinGamePacket(MINECRAFT_1_19_1);
joinGame1194 = createJoinGamePacket(MINECRAFT_1_19_4);
joinGame120 = createJoinGamePacket(MINECRAFT_1_20);
synchronizeRegistry = new RegistrySync(joinGame120.getRegistry());
joinGame = new JoinGame(0,
Sonar.get().getConfig().getVerification().getGravity().getGamemode().getId(),
0,
false,
0,
true,
true,
false,
new String[]{"minecraft:overworld"},
"minecraft:overworld");
maxMovementTick = Sonar.get().getConfig().getVerification().getMaxMovementTicks();
maxPredictionTick = maxMovementTick + 10;
preparedCachedYMotions = new double[maxPredictionTick + 1];
for (int i = 0; i < maxPredictionTick + 1; i++) {
preparedCachedYMotions[i] = -((Math.pow(0.98, i) - 1) * 3.92);
}
// Adjust block and collide Y position based on max fall distance
maxFallDistance = 0;
for (int i = 0; i < maxMovementTick; i++) {
maxFallDistance += preparedCachedYMotions[i];
maxMovementTick = Sonar.get().getConfig().getVerification().getGravity().getMaxMovementTicks();
preparedCachedYMotions = new double[maxMovementTick + 8];
for (int i = 0; i < preparedCachedYMotions.length; i++) {
final double gravity = -((Math.pow(0.98, i) - 1) * 3.92);
preparedCachedYMotions[i] = gravity;
maxFallDistance += gravity;
}
// Set the dynamic spawn buffer
final int DYNAMIC_SPAWN_BUFFER = (int) (SPAWN_BUFFER + maxFallDistance);
// Set the dynamic block and collide Y position based on the maximum fall distance
dynamicSpawnYPosition = DEFAULT_Y_COLLIDE_POSITION + DYNAMIC_SPAWN_BUFFER;
dynamicSpawnYPosition = DEFAULT_Y_COLLIDE_POSITION + (int) Math.ceil(maxFallDistance);
dynamicSpawnPosition = new DefaultSpawnPosition(SPAWN_X_POSITION, dynamicSpawnYPosition, SPAWN_Z_POSITION);
// Prepare collision platform positions
int index = 0;
@@ -132,166 +108,25 @@ public class FallbackPreparer {
x + (BLOCKS_PER_ROW / 2),
DEFAULT_Y_COLLIDE_POSITION,
z + (BLOCKS_PER_ROW / 2),
0, 0
);
0, 0);
CHANGED_BLOCKS[index++] = new ChangedBlock(position, BlockType.BARRIER);
}
}
// Prepare UpdateSectionBlocks packet
UPDATE_SECTION_BLOCKS = new UpdateSectionBlocks(0, 0, CHANGED_BLOCKS);
}
updateSectionBlocks = new UpdateSectionBlocks(0, 0, CHANGED_BLOCKS);
private @Nullable CompoundBinaryTag getMapping(final @NotNull String fileName) {
try (final InputStream inputStream = Sonar.class.getResourceAsStream("/mappings/" + fileName)) {
return BinaryTagIO.reader().read(Objects.requireNonNull(inputStream), BinaryTagIO.Compression.GZIP);
} catch (Throwable throwable) {
Sonar.get().getLogger().error("Could not load mappings for {}: {}", fileName, throwable);
return null;
}
}
public static FallbackPacket getJoinPacketForVersion(final @NotNull ProtocolVersion protocolVersion) {
if (protocolVersion.compareTo(MINECRAFT_1_15_2) <= 0) {
return legacyJoinGame; // 1.7.2-1.15.2
}
if (protocolVersion.compareTo(MINECRAFT_1_16_1) <= 0) {
return joinGame116; // 1.16-1.16.1
}
if (protocolVersion.compareTo(MINECRAFT_1_18) <= 0) {
return joinGame1162; // 1.16.2-1.18
}
if (protocolVersion.compareTo(MINECRAFT_1_19) <= 0) {
return joinGame1182; // 1.18.1-1.19
}
if (protocolVersion.compareTo(MINECRAFT_1_19_3) <= 0) {
return joinGame1191; // 1.19.1-1.19.3
}
if (protocolVersion.compareTo(MINECRAFT_1_19_4) == 0) {
return joinGame1194; // 1.19.4
}
return joinGame120; // 1.20-latest
}
// Partially taken from
// https://github.com/Elytrium/LimboAPI/blob/master/plugin/src/main/java/net/elytrium/limboapi/server/LimboImpl.java#L607
private @NotNull JoinGame createJoinGamePacket(final @NotNull ProtocolVersion protocolVersion) {
final JoinGame joinGame = new JoinGame();
joinGame.setLevelType("flat");
joinGame.setGamemode(Sonar.get().getConfig().getVerification().getGamemodeId());
joinGame.setDimension(0);
joinGame.setReducedDebugInfo(true);
// 1.7.2-1.8.9 don't need any dimension information
if (protocolVersion.compareTo(MINECRAFT_1_8) <= 0) {
return joinGame;
// "You are being checked" message
if (Sonar.get().getConfig().getVerification().getGravity().isEnabled()) {
youAreBeingChecked = new Chat(Sonar.get().getConfig().getVerification().getGravity().getYouAreBeingChecked());
}
joinGame.setDimensionInfo(DIMENSION);
if (Sonar.get().getConfig().getVerification().getMap().getTiming() != SonarConfiguration.Verification.Timing.NEVER) {
enterCodeMessage = new Chat(Sonar.get().getConfig().getVerification().getMap().getEnterCode());
incorrectCaptcha = new Chat(Sonar.get().getConfig().getVerification().getMap().getFailedCaptcha());
final CompoundBinaryTag.Builder registryContainer = CompoundBinaryTag.builder();
final ListBinaryTag encodedDimensionRegistry = ListBinaryTag.builder(BinaryTagTypes.COMPOUND)
.add(createDimensionData(protocolVersion))
.build();
if (protocolVersion.compareTo(MINECRAFT_1_16_2) >= 0) {
final CompoundBinaryTag.Builder dimensionRegistryEntry = CompoundBinaryTag.builder();
dimensionRegistryEntry.putString("type", "minecraft:dimension_type");
dimensionRegistryEntry.put("value", encodedDimensionRegistry);
registryContainer.put("minecraft:dimension_type", dimensionRegistryEntry.build());
final CompoundBinaryTag.Builder effectsTagBuilder = CompoundBinaryTag.builder()
.putInt("sky_color", 7907327)
.putInt("fog_color", 12638463)
.putInt("water_color", 0)
.putInt("water_fog_color", 0);
final CompoundBinaryTag.Builder elementTagBuilder = CompoundBinaryTag.builder()
.putFloat("depth", 0.125f)
.putFloat("temperature", 0.8f)
.putFloat("scale", 0.05f)
.putFloat("downfall", 0.4f)
.putString("category", "plains")
.put("effects", effectsTagBuilder.build());
if (protocolVersion.compareTo(MINECRAFT_1_19_4) >= 0) {
elementTagBuilder.putBoolean("has_precipitation", false);
} else {
elementTagBuilder.putString("precipitation", "rain");
}
registryContainer.put("minecraft:worldgen/biome", CompoundBinaryTag.builder()
.putString("type", "minecraft:worldgen/biome")
.put("value", ListBinaryTag.from(Collections.singletonList(
CompoundBinaryTag.builder()
.putString("name", "minecraft:plains")
.putInt("id", 1)
.put("element", elementTagBuilder.build())
.build()
))).build()
);
if (protocolVersion.compareTo(MINECRAFT_1_19) == 0) {
registryContainer.put("minecraft:chat_type", CHAT_TYPE_119);
} else if (protocolVersion.compareTo(MINECRAFT_1_19_1) >= 0) {
registryContainer.put("minecraft:chat_type", CHAT_TYPE_1191);
}
if (protocolVersion.compareTo(MINECRAFT_1_19_4) == 0) {
registryContainer.put("minecraft:damage_type", DAMAGE_TYPE_1194);
} else if (protocolVersion.compareTo(MINECRAFT_1_20) >= 0) {
registryContainer.put("minecraft:damage_type", DAMAGE_TYPE_120);
}
} else {
registryContainer.put("dimension", encodedDimensionRegistry);
// Precompute captcha answers
MapInfoPreparer.prepare();
}
CompoundBinaryTag currentDimensionData = encodedDimensionRegistry.getCompound(0);
if (protocolVersion.compareTo(MINECRAFT_1_16_2) >= 0) {
currentDimensionData = currentDimensionData.getCompound("element");
}
joinGame.setCurrentDimensionData(currentDimensionData);
joinGame.setLevelNames(new String[]{"minecraft:overworld"});
joinGame.setRegistry(registryContainer.build());
return joinGame;
}
public @NotNull CompoundBinaryTag createDimensionData(final @NotNull ProtocolVersion version) {
final CompoundBinaryTag details = CompoundBinaryTag.builder()
.putBoolean("natural", false)
.putFloat("ambient_light", 0f)
.putBoolean("shrunk", false)
.putBoolean("ultrawarm", false)
.putBoolean("has_ceiling", false)
.putBoolean("has_skylight", true)
.putBoolean("piglin_safe", false)
.putBoolean("bed_works", false)
.putBoolean("respawn_anchor_works", false)
.putBoolean("has_raids", false)
.putInt("logical_height", 256)
.putString("infiniburn", version.compareTo(MINECRAFT_1_18_2) >= 0 ? "#minecraft" +
":infiniburn_nether" : "minecraft:infiniburn_nether")
.putDouble("coordinate_scale", 1.0)
.putString("effects", "minecraft:overworld")
.putInt("min_y", 0)
.putInt("height", 256)
.putInt("monster_spawn_block_light_limit", 0)
.putInt("monster_spawn_light_level", 0)
.build();
if (version.compareTo(MINECRAFT_1_16_2) >= 0) {
return CompoundBinaryTag.builder()
.putString("name", "minecraft:overworld")
.putInt("id", 0)
.put("element", details)
.build();
}
return details.putString("name", "minecraft:overworld");
}
}
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -28,7 +28,10 @@ import java.util.function.Function;
public enum BlockType {
STONE(protocolVersion -> 1),
BARRIER(protocolVersion -> {
// Link: https://github.com/PrismarineJS/minecraft-data/blob/master/data/pc/1.20/blocks.json
// Useful resources:
// - https://github.com/PrismarineJS/minecraft-data/blob/master/data/pc/
// - https://pokechu22.github.io/Burger/1.20.4.html
// - https://github.com/ViaVersion/Mappings/tree/main/mappings
switch (protocolVersion) {
case MINECRAFT_1_7_2:
case MINECRAFT_1_7_6:
@@ -73,7 +76,8 @@ public enum BlockType {
case MINECRAFT_1_20:
return 10225;
case MINECRAFT_1_20_2:
return 1; // TODO: https://github.com/PrismarineJS/minecraft-data/issues/779
case MINECRAFT_1_20_3:
return 10366;
}
});
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -24,18 +24,15 @@ import org.jetbrains.annotations.NotNull;
public final class ChangedBlock {
private final @NotNull BlockPosition position;
private final BlockType type;
private final int legacyChunkPosCrammed;
private final int modernChunkPosCrammed;
private final int legacyBlockState, blockState;
public ChangedBlock(final @NotNull BlockPosition position, final BlockType type) {
this.position = position;
this.type = type;
this.legacyChunkPosCrammed = position.getX()
- (position.getChunkX() << 4) << 12 | position.getZ()
- (position.getChunkZ() << 4) << 8 | position.getY();
this.modernChunkPosCrammed = position.getX()
- (position.getChunkX() << 4) << 8 | position.getZ()
- (position.getChunkZ() << 4) << 4 | position.getY()
- ((position.getY() >> 4) << 4);
final int x = position.getX() - (position.getChunkX() << 4);
final int y = position.getY();
final int z = position.getZ() - (position.getChunkZ() << 4);
this.legacyBlockState = x << 12 | z << 8 | y;
this.blockState = x << 8 | z << 4 | y - ((y >> 4) << 4);
}
}
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -20,11 +20,13 @@ package xyz.jonesdev.sonar.common.fallback.protocol.dimension;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.ToString;
import net.kyori.adventure.nbt.CompoundBinaryTag;
@Getter
@ToString
@RequiredArgsConstructor
public final class DimensionInfo {
private final String identifier, levelName;
private final boolean flat, debug;
private final String identifier;
private final int id;
private final CompoundBinaryTag tag;
}
@@ -0,0 +1,71 @@
/*
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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 3 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, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.common.fallback.protocol.dimension;
import lombok.experimental.UtilityClass;
import net.kyori.adventure.nbt.BinaryTag;
import net.kyori.adventure.nbt.BinaryTagIO;
import net.kyori.adventure.nbt.CompoundBinaryTag;
import net.kyori.adventure.nbt.ListBinaryTag;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.Sonar;
import java.io.InputStream;
import java.util.Objects;
@UtilityClass
public final class DimensionRegistry {
public final DimensionInfo DEFAULT_DIMENSION_1_16;
public final DimensionInfo DEFAULT_DIMENSION_1_18_2;
public final @NotNull CompoundBinaryTag CODEC_1_16;
public final @NotNull CompoundBinaryTag CODEC_1_18_2;
public final @NotNull CompoundBinaryTag CODEC_1_19;
public final @NotNull CompoundBinaryTag CODEC_1_19_1;
public final @NotNull CompoundBinaryTag CODEC_1_19_4;
public final @NotNull CompoundBinaryTag CODEC_1_20;
public final @NotNull CompoundBinaryTag OLD_CODEC;
static {
CODEC_1_16 = getCodec("codec_1_16.nbt");
CODEC_1_18_2 = getCodec("codec_1_18_2.nbt");
CODEC_1_19 = getCodec("codec_1_19.nbt");
CODEC_1_19_1 = getCodec("codec_1_19_1.nbt");
CODEC_1_19_4 = getCodec("codec_1_19_4.nbt");
CODEC_1_20 = getCodec("codec_1_20.nbt");
OLD_CODEC = getCodec("codec_old.nbt");
DEFAULT_DIMENSION_1_16 = getDimension(CODEC_1_16);
DEFAULT_DIMENSION_1_18_2 = getDimension(CODEC_1_18_2);
}
private static @NotNull DimensionInfo getDimension(final @NotNull CompoundBinaryTag tag) {
final ListBinaryTag dimensions = tag.getCompound("minecraft:dimension_type").getList("value");
final BinaryTag elementTag = ((CompoundBinaryTag) dimensions.get(0)).get("element");
return new DimensionInfo("minecraft:overworld", 0, (CompoundBinaryTag) elementTag);
}
private @NotNull CompoundBinaryTag getCodec(final @NotNull String fileName) {
try (final InputStream inputStream = Sonar.class.getResourceAsStream("/codecs/" + fileName)) {
return BinaryTagIO.reader().read(Objects.requireNonNull(inputStream), BinaryTagIO.Compression.GZIP);
} catch (Throwable throwable) {
Sonar.get().getLogger().error("Could not load mappings for {}: {}", fileName, throwable);
throw new IllegalStateException(throwable);
}
}
}
@@ -0,0 +1,83 @@
/*
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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 3 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, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.common.fallback.protocol.map;
import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion;
import java.util.function.Function;
@SuppressWarnings("unused")
@RequiredArgsConstructor
public enum ItemMapType {
FILLED_MAP(protocolVersion -> {
// Useful resources:
// - https://github.com/PrismarineJS/minecraft-data/blob/master/data/pc/
// - https://pokechu22.github.io/Burger/1.20.4.html
// - https://github.com/ViaVersion/Mappings/tree/main/mappings
switch (protocolVersion) {
default:
// 1.7-1.12.2
return 358;
case MINECRAFT_1_13:
case MINECRAFT_1_13_1:
return 608;
case MINECRAFT_1_13_2:
return 613;
case MINECRAFT_1_14:
case MINECRAFT_1_14_1:
case MINECRAFT_1_14_2:
case MINECRAFT_1_14_3:
case MINECRAFT_1_14_4:
case MINECRAFT_1_15:
case MINECRAFT_1_15_1:
case MINECRAFT_1_15_2:
return 671;
case MINECRAFT_1_16:
case MINECRAFT_1_16_1:
case MINECRAFT_1_16_2:
case MINECRAFT_1_16_3:
case MINECRAFT_1_16_4:
return 733;
case MINECRAFT_1_17:
case MINECRAFT_1_17_1:
case MINECRAFT_1_18:
case MINECRAFT_1_18_2:
return 847;
case MINECRAFT_1_19:
case MINECRAFT_1_19_1:
return 886;
case MINECRAFT_1_19_3:
return 914;
case MINECRAFT_1_19_4:
return 937;
case MINECRAFT_1_20:
case MINECRAFT_1_20_2:
return 941;
case MINECRAFT_1_20_3:
return 979;
}
});
private final Function<ProtocolVersion, Integer> function;
public int getId(final @NotNull ProtocolVersion protocolVersion) {
return function.apply(protocolVersion);
}
}
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -15,25 +15,16 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.velocity.verbose;
package xyz.jonesdev.sonar.common.fallback.protocol.map;
import com.velocitypowered.api.proxy.ProxyServer;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import net.kyori.adventure.text.Component;
import xyz.jonesdev.sonar.api.verbose.Verbose;
@Getter
@RequiredArgsConstructor
public final class VerboseWrapper extends Verbose {
private final ProxyServer server;
@Override
public void broadcast(final Component component) {
synchronized (subscribers) {
for (final String subscriber : subscribers) {
server.getPlayer(subscriber).ifPresent(player -> {
player.sendActionBar(component);
});
}
}
}
public final class MapInfo {
private final String answer;
private final int columns, rows;
private final int x, y;
private final byte[] buffer;
}
@@ -0,0 +1,248 @@
/*
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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 3 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, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.common.fallback.protocol.map;
import lombok.Getter;
import lombok.experimental.UtilityClass;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.config.SonarConfiguration;
import xyz.jonesdev.sonar.api.timer.SystemTimer;
import java.awt.*;
import java.awt.font.FontRenderContext;
import java.awt.font.GlyphVector;
import java.awt.geom.AffineTransform;
import java.awt.image.BufferedImage;
import java.util.Arrays;
import java.util.List;
import java.util.Random;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import static java.awt.Font.*;
import static xyz.jonesdev.sonar.common.fallback.protocol.map.PreparedMapInfo.DIMENSIONS;
@UtilityClass
public class MapInfoPreparer {
private final Random RANDOM = new Random();
private final ExecutorService PREPARATION_SERVICE = Executors.newSingleThreadExecutor();
private final int[] FONT_STYLES = new int[]{
PLAIN,
BOLD,
ITALIC,
ITALIC | BOLD
};
private final int[][] COLOR_PALETTE = new int[][]{
new int[]{ // Blue
48,
49,
50,
51
},
new int[]{ // Orange
60,
61,
62,
63,
},
new int[]{ // Green
4,
28,
29,
30,
},
new int[]{ // Black
27,
45,
46,
47,
},
new int[]{ // Red
16,
17,
18,
19
}
};
private PreparedMapInfo[] cached;
@Getter
private int preparedCAPTCHAs;
private boolean currentlyPreparing;
public PreparedMapInfo getRandomCaptcha() {
return cached[RANDOM.nextInt(preparedCAPTCHAs)];
}
public void prepare() {
final SystemTimer timer = new SystemTimer();
Sonar.get().getLogger().info("Precomputing map captcha answers...");
// Make sure we're not running into concurrency issues
if (currentlyPreparing) {
Sonar.get().getLogger().warn("Did not precompute map captcha answers as another task is running.");
return;
}
// Reset all statistics
preparedCAPTCHAs = 0;
currentlyPreparing = true;
final SonarConfiguration.Verification.Map config = Sonar.get().getConfig().getVerification().getMap();
cached = null;
cached = new PreparedMapInfo[config.getPrecomputeAmount()];
// Prepare fonts from config
final String dictionary = config.getDictionary();
final List<String> fonts = config.getFonts();
if (fonts.isEmpty()) {
Sonar.get().getLogger().warn("No fonts found, using fallback font...");
fonts.add(DIALOG);
}
final String[] fontTypes = fonts.toArray(new String[0]);
// Prepare distortions values from config
final double distortionsFactorX = config.getDistortionsFactorX();
final double distortionsFactorY = config.getDistortionsFactorY();
final double halfDistortionsFactorX = distortionsFactorX / 2D;
final double halfDistortionsFactorY = distortionsFactorY / 2D;
for (int _i = 0; _i < cached.length; _i++) {
final int currentIndex = _i;
final byte[] buffer = new byte[PreparedMapInfo.SCALE];
PREPARATION_SERVICE.execute(() -> {
// Create image
final BufferedImage image = new BufferedImage(DIMENSIONS, DIMENSIONS, BufferedImage.TYPE_3BYTE_BGR);
final Graphics2D graphics = (Graphics2D) image.getGraphics();
try {
// Create random font
final String fontType = fontTypes[RANDOM.nextInt(fontTypes.length)];
final int fontStyle = FONT_STYLES[RANDOM.nextInt(FONT_STYLES.length)];
final int fontSize = 27
+ (config.isRandomizeFontSize()
? RANDOM.nextInt(6) - 2 : 5);
@SuppressWarnings("all") final Font answerFont = new Font(fontType, fontStyle, fontSize);
graphics.setFont(answerFont);
// Build answer to the captcha
final StringBuilder answerBuilder = new StringBuilder();
for (int _j = 0; _j < 5; _j++) {
answerBuilder.append(dictionary.charAt(RANDOM.nextInt(dictionary.length())));
}
final String answer = answerBuilder.toString();
// Calculate text position
final int stringWidth = graphics.getFontMetrics().stringWidth(answer);
final int halfWidth = image.getWidth() / 2;
final int halfHeight = image.getHeight() / 2;
final int spacing = 5;
double _x = halfWidth - stringWidth / 2f - spacing;
double _y = halfHeight + fontSize / 3f;
// Draw each character one by one
final FontRenderContext fontRenderContext = graphics.getFontRenderContext();
for (final char c : answer.toCharArray()) {
// Randomize x and y
if (config.isRandomizePositions()) {
_y += RANDOM.nextInt(8) - 4;
}
// Apply random distortion
final double distortionFactorX = RANDOM.nextDouble() * distortionsFactorX - halfDistortionsFactorX;
final double distortionFactorY = RANDOM.nextDouble() * distortionsFactorY - halfDistortionsFactorY;
// Create a GlyphVector for the character
final String character = String.valueOf(c);
final GlyphVector glyphVector = graphics.getFont().createGlyphVector(fontRenderContext, character);
// Apply the distortion to the GlyphVector using AffineTransform
final AffineTransform transform = AffineTransform.getTranslateInstance(_x, _y);
transform.shear(distortionFactorX, distortionFactorY);
final Shape distortedCharacter = glyphVector.getOutline();
final Shape distorted = transform.createTransformedShape(distortedCharacter);
// Draw the distorted character
graphics.fill(distorted);
// Update x by width
_x += glyphVector.getVisualBounds().getWidth() + spacing;
}
// TODO: Color converter
graphics.setColor(Color.RED);
// Draw random lines
for (int i = 0; i < config.getRandomLinesAmount(); i++) {
final int startX = RANDOM.nextInt(halfWidth);
final int startY = RANDOM.nextInt(halfHeight);
final int endX = halfWidth + RANDOM.nextInt(halfWidth);
final int endY = halfHeight + RANDOM.nextInt(halfHeight);
graphics.drawLine(startX, startY, endX, endY);
}
// Draw random ovals
for (int i = 0; i < config.getRandomOvalsAmount(); i++) {
final int startX = RANDOM.nextInt(halfWidth);
final int startY = RANDOM.nextInt(halfHeight);
final int endX = halfWidth + RANDOM.nextInt(halfWidth);
final int endY = halfHeight + RANDOM.nextInt(halfHeight);
graphics.drawOval(startX, startY, endX, endY);
}
// Select random color palette
final int[] colorPalette = COLOR_PALETTE[currentIndex % COLOR_PALETTE.length];
// Select the next color palette for geometry
final int[] nextColorPalette = COLOR_PALETTE[(currentIndex + 1) % COLOR_PALETTE.length];
// Clear background
Arrays.fill(buffer, (byte) 57);
// Color every pixel individually
final int spacingY = halfHeight - fontSize;
for (int x = spacing; x < image.getWidth() - spacing; x++) {
for (int y = spacingY; y < image.getHeight() - spacingY; y++) {
final int pixel = image.getRGB(x, y);
final int index = y * image.getWidth() + x;
if (pixel == -16777216 && RANDOM.nextInt(100) < 97) continue;
// Set color of pixel to random color from the palette
final byte color = (byte) (pixel == -16777216 ? RANDOM.nextInt(100) < 75 ? 14 :
RANDOM.nextInt(Byte.MAX_VALUE)
: pixel != -65536 ? colorPalette[RANDOM.nextInt(colorPalette.length)]
: nextColorPalette[RANDOM.nextInt(nextColorPalette.length)]);
// Write pixel color to buffer
buffer[index] = color;
}
}
// Cache buffer to map
cached[currentIndex] = new PreparedMapInfo(answer, image.getWidth(), image.getHeight(), buffer);
} finally {
// Dispose graphics
graphics.dispose();
// Finished?
if (currentIndex == cached.length - 1) {
Sonar.get().getLogger().info("Successfully precomputed map captcha answers in {}s!", timer);
currentlyPreparing = false;
}
preparedCAPTCHAs++;
}
});
}
}
}
@@ -0,0 +1,65 @@
/*
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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 3 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, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.common.fallback.protocol.map;
import lombok.Getter;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.fallback.FallbackUser;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.play.MapData;
import static xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion.MINECRAFT_1_8;
@Getter
public final class PreparedMapInfo {
public static final int DIMENSIONS = (int) Math.pow(2, 7);
public static final int SCALE = DIMENSIONS * DIMENSIONS;
private final MapInfo info;
private final MapData[] legacy;
private final MapData modern;
public PreparedMapInfo(final String answer,
final int columns, final int rows,
final byte @NotNull [] buffer) {
this.info = new MapInfo(answer, columns, rows, 0, 0, buffer);
// Prepare 1.7 map data using a grid
final byte[][] grid = new byte[DIMENSIONS][DIMENSIONS];
for (int i = 0; i < buffer.length; i++) {
final byte buf = buffer[i];
grid[i & Byte.MAX_VALUE][i >> 7] = buf;
}
this.legacy = new MapData[grid.length];
for (int i = 0; i < grid.length; i++) {
this.legacy[i] = new MapData(new MapInfo(answer, DIMENSIONS, DIMENSIONS, i, 0, grid[i]));
}
// Prepare 1.8+ map data
this.modern = new MapData(info);
}
public void write(final @NotNull FallbackUser<?> user) {
if (user.getProtocolVersion().compareTo(MINECRAFT_1_8) < 0) {
for (final MapData data : legacy) {
user.delayedWrite(data);
}
} else {
user.delayedWrite(modern);
}
}
}
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Sonar Contributors
* Copyright (C) 2023-2024 Sonar Contributors
*
* 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
@@ -18,38 +18,26 @@
package xyz.jonesdev.sonar.common.fallback.protocol.packets.config;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufOutputStream;
import io.netty.handler.codec.EncoderException;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
import net.kyori.adventure.nbt.BinaryTagTypes;
import net.kyori.adventure.nbt.CompoundBinaryTag;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacket;
import xyz.jonesdev.sonar.common.fallback.protocol.dimension.DimensionRegistry;
import java.io.IOException;
import static xyz.jonesdev.sonar.common.utility.protocol.ProtocolUtil.writeNamelessCompoundTag;
@Getter
@ToString
@NoArgsConstructor
@AllArgsConstructor
public final class RegistrySync implements FallbackPacket {
private CompoundBinaryTag dimensionRegistry;
public final class RegistryData implements FallbackPacket {
@Override
public void encode(final @NotNull ByteBuf byteBuf, final ProtocolVersion protocolVersion) {
try (final ByteBufOutputStream stream = new ByteBufOutputStream(byteBuf)) {
stream.writeByte(10); // CompoundTag ID
BinaryTagTypes.COMPOUND.write(dimensionRegistry, stream);
} catch (IOException exception) {
throw new EncoderException("Cannot write NBT CompoundTag");
}
writeNamelessCompoundTag(byteBuf, DimensionRegistry.CODEC_1_20);
}
@Override
public void decode(final ByteBuf byteBuf, final ProtocolVersion protocolVersion) {
throw new UnsupportedOperationException();
}
}
Loaded 100 of 161 files, more files were not shown because too many files have changed in this diff. Show more