diff --git a/proxy-bungeecord/src/main/kotlin/app/simplecloud/plugin/proxy/bungeecord/listener/ProxyPingListener.kt b/proxy-bungeecord/src/main/kotlin/app/simplecloud/plugin/proxy/bungeecord/listener/ProxyPingListener.kt index 3e05d5b..8c0a003 100644 --- a/proxy-bungeecord/src/main/kotlin/app/simplecloud/plugin/proxy/bungeecord/listener/ProxyPingListener.kt +++ b/proxy-bungeecord/src/main/kotlin/app/simplecloud/plugin/proxy/bungeecord/listener/ProxyPingListener.kt @@ -3,12 +3,12 @@ package app.simplecloud.plugin.proxy.bungeecord.listener import app.simplecloud.plugin.proxy.bungeecord.ProxyBungeeCordPlugin import app.simplecloud.plugin.proxy.shared.config.motd.MaxPlayerDisplayType import app.simplecloud.plugin.proxy.shared.handler.ServerIconLoader +import app.simplecloud.plugin.proxy.shared.handler.TrustedPingSourceMatcher import net.md_5.bungee.api.Favicon import net.md_5.bungee.api.ServerPing.* import net.md_5.bungee.api.event.ProxyPingEvent import net.md_5.bungee.api.plugin.Listener import net.md_5.bungee.event.EventHandler -import java.net.InetAddress import java.net.InetSocketAddress import java.nio.file.Path import java.util.* @@ -21,6 +21,11 @@ class ProxyPingListener( Path.of(plugin.proxyPlugin.serverIconsPath) ) { image -> Favicon.create(image) } + private val trustedPingSourceMatcher = TrustedPingSourceMatcher( + { plugin.proxyPlugin.proxyEssentialsConfig.get().trustedPingSources }, + { plugin.logger.warning(it) } + ) + @EventHandler fun onPing(event: ProxyPingEvent) { val layout = plugin.proxyPlugin.motdLayoutHandler.getCurrentMotdLayout() @@ -35,9 +40,7 @@ class ProxyPingListener( response.descriptionComponent = net.kyori.adventure.text.serializer.bungeecord.BungeeComponentSerializer.get().serialize(motd)[0] val socketAddress = event.connection.socketAddress as? InetSocketAddress - val remoteAddress = socketAddress?.address?.hostAddress ?: "" - val localAddress = InetAddress.getLocalHost().hostAddress - val isLocalPing = remoteAddress == localAddress + val isTrustedPing = trustedPingSourceMatcher.isTrusted(socketAddress?.address) // server icon if (layout.serverIcon.enabled) { @@ -45,7 +48,7 @@ class ProxyPingListener( ?.let { response.setFavicon(it) } } - if (!isLocalPing) { + if (!isTrustedPing) { // player list (hover text) val samplePlayers = if (layout.playerList.enabled && layout.playerList.entries.isNotEmpty()) { layout.playerList.entries.map { PlayerInfo(it, UUID.randomUUID()) }.toTypedArray() diff --git a/proxy-bungeecord/src/main/resources/config/config.yml b/proxy-bungeecord/src/main/resources/config/config.yml index f8d68c6..e522141 100644 --- a/proxy-bungeecord/src/main/resources/config/config.yml +++ b/proxy-bungeecord/src/main/resources/config/config.yml @@ -32,6 +32,11 @@ whitelist: players: - Notch +# Internal ping sources that should keep the proxy's real player count response. +# Local interface addresses are detected automatically. Add CIDR ranges here only +# when another trusted internal host pings this proxy, for example 192.168.102.10/32. +trusted-ping-sources: [] + # ─────────────────────────────────────────────────────────────────────────────── # Tablist # Configures tablist layouts and update intervals for connected players. diff --git a/proxy-shared/src/main/kotlin/app/simplecloud/plugin/proxy/shared/config/ProxyEssentialsConfig.kt b/proxy-shared/src/main/kotlin/app/simplecloud/plugin/proxy/shared/config/ProxyEssentialsConfig.kt index ecebb71..522e923 100644 --- a/proxy-shared/src/main/kotlin/app/simplecloud/plugin/proxy/shared/config/ProxyEssentialsConfig.kt +++ b/proxy-shared/src/main/kotlin/app/simplecloud/plugin/proxy/shared/config/ProxyEssentialsConfig.kt @@ -25,6 +25,7 @@ data class ProxyEssentialsConfig( ) ), val whitelist: WhitelistConfig = WhitelistConfig(), + @Setting("trusted-ping-sources") val trustedPingSources: List = emptyList(), val tablist: List = listOf( TabListGroup( name = "global", diff --git a/proxy-shared/src/main/kotlin/app/simplecloud/plugin/proxy/shared/handler/TrustedPingSourceMatcher.kt b/proxy-shared/src/main/kotlin/app/simplecloud/plugin/proxy/shared/handler/TrustedPingSourceMatcher.kt new file mode 100644 index 0000000..a3310ec --- /dev/null +++ b/proxy-shared/src/main/kotlin/app/simplecloud/plugin/proxy/shared/handler/TrustedPingSourceMatcher.kt @@ -0,0 +1,119 @@ +package app.simplecloud.plugin.proxy.shared.handler + +import java.net.InetAddress +import java.net.NetworkInterface + +class TrustedPingSourceMatcher( + private val trustedSources: () -> List, + private val warn: (String) -> Unit +) { + private val localAddresses: Set by lazy { discoverLocalAddresses() } + + @Volatile + private var cachedRanges = TrustedAddressRangeCache() + + fun isTrusted(remoteAddress: InetAddress?): Boolean { + if (remoteAddress == null) return false + if (remoteAddress.isLoopbackAddress || localAddresses.contains(remoteAddress)) return true + + return ranges().any { it.contains(remoteAddress) } + } + + private fun ranges(): List { + val currentEntries = trustedSources().map { it.trim() }.filter { it.isNotEmpty() } + val currentCache = cachedRanges + if (currentEntries == currentCache.entries) { + return currentCache.ranges + } + + val parsedRanges = currentEntries.mapNotNull { entry -> + TrustedAddressRange.parse(entry) ?: run { + warn("Ignoring invalid trusted ping source '$entry'. Expected an IP address or CIDR range.") + null + } + } + + cachedRanges = TrustedAddressRangeCache(currentEntries, parsedRanges) + return parsedRanges + } + + private fun discoverLocalAddresses(): Set { + val addresses = mutableSetOf() + + runCatching { + NetworkInterface.getNetworkInterfaces().asSequence() + .filter { it.isUp } + .flatMap { it.inetAddresses.asSequence() } + .forEach { addresses.add(it) } + }.onFailure { + warn("Unable to discover local network addresses: ${it.message}") + } + + runCatching { addresses.add(InetAddress.getLocalHost()) } + addresses.add(InetAddress.getLoopbackAddress()) + + return addresses + } + + private data class TrustedAddressRangeCache( + val entries: List = emptyList(), + val ranges: List = emptyList() + ) + + private data class TrustedAddressRange( + private val addressBytes: ByteArray, + private val prefixLength: Int + ) { + fun contains(address: InetAddress): Boolean { + val candidateBytes = address.address + if (candidateBytes.size != addressBytes.size) return false + + val fullBytes = prefixLength / BITS_PER_BYTE + val remainingBits = prefixLength % BITS_PER_BYTE + + for (index in 0 until fullBytes) { + if (candidateBytes[index] != addressBytes[index]) return false + } + + if (remainingBits == 0) return true + + val mask = (0xFF shl (BITS_PER_BYTE - remainingBits)) and 0xFF + return (candidateBytes[fullBytes].toInt() and mask) == (addressBytes[fullBytes].toInt() and mask) + } + + companion object { + private const val BITS_PER_BYTE = 8 + private val IPV4_REGEX = Regex("""\d{1,3}(?:\.\d{1,3}){3}""") + private val IPV6_REGEX = Regex("""[0-9a-fA-F:.]+""") + + fun parse(entry: String): TrustedAddressRange? { + val parts = entry.split('/', limit = 2).map { it.trim() } + val address = parseLiteralAddress(parts[0]) ?: return null + val maxPrefixLength = address.address.size * BITS_PER_BYTE + val prefixLength = when (parts.size) { + 1 -> maxPrefixLength + else -> parts[1].toIntOrNull() ?: return null + } + + if (prefixLength !in 0..maxPrefixLength) return null + + return TrustedAddressRange(address.address, prefixLength) + } + + private fun parseLiteralAddress(entry: String): InetAddress? { + if (entry.matches(IPV4_REGEX)) { + val octets = entry.split('.').map { it.toInt() } + if (octets.all { it in 0..255 }) { + return InetAddress.getByAddress(octets.map { it.toByte() }.toByteArray()) + } + } + + if (':' in entry && entry.matches(IPV6_REGEX)) { + return runCatching { InetAddress.getByName(entry) }.getOrNull() + } + + return null + } + } + } +} diff --git a/proxy-velocity/src/main/kotlin/app/simplecloud/plugin/proxy/velocity/listener/ProxyPingListener.kt b/proxy-velocity/src/main/kotlin/app/simplecloud/plugin/proxy/velocity/listener/ProxyPingListener.kt index eab3d29..f3c53fb 100644 --- a/proxy-velocity/src/main/kotlin/app/simplecloud/plugin/proxy/velocity/listener/ProxyPingListener.kt +++ b/proxy-velocity/src/main/kotlin/app/simplecloud/plugin/proxy/velocity/listener/ProxyPingListener.kt @@ -3,13 +3,13 @@ package app.simplecloud.plugin.proxy.velocity.listener import app.simplecloud.plugin.proxy.shared.ProxyPlugin import app.simplecloud.plugin.proxy.shared.config.motd.MaxPlayerDisplayType import app.simplecloud.plugin.proxy.shared.handler.ServerIconLoader +import app.simplecloud.plugin.proxy.shared.handler.TrustedPingSourceMatcher import app.simplecloud.plugin.proxy.velocity.ProxyVelocityPlugin import com.velocitypowered.api.event.Subscribe import com.velocitypowered.api.event.proxy.ProxyPingEvent import com.velocitypowered.api.proxy.server.ServerPing import com.velocitypowered.api.proxy.server.ServerPing.SamplePlayer import com.velocitypowered.api.util.Favicon -import java.net.InetAddress import java.nio.file.Path import java.util.* import kotlin.jvm.optionals.getOrNull @@ -23,6 +23,11 @@ class ProxyPingListener( Path.of(proxyPlugin.serverIconsPath) ) { image -> Favicon.create(image) } + private val trustedPingSourceMatcher = TrustedPingSourceMatcher( + { proxyPlugin.proxyEssentialsConfig.get().trustedPingSources }, + { plugin.logger.warn(it) } + ) + @Subscribe fun onProxyPing(event: ProxyPingEvent) { val layout = proxyPlugin.motdLayoutHandler.getCurrentMotdLayout() @@ -34,9 +39,7 @@ class ProxyPingListener( val motd = plugin.deserializeToComponent("${entry.line1}\n${entry.line2}") - val remoteAddress = event.connection.remoteAddress.address.hostAddress - val localAddress = InetAddress.getLocalHost().hostAddress - val isLocalPing = remoteAddress == localAddress + val isTrustedPing = trustedPingSourceMatcher.isTrusted(event.connection.remoteAddress.address) val builder = event.ping.asBuilder().description(motd) @@ -47,7 +50,7 @@ class ProxyPingListener( } // player list (hover text) - if (!isLocalPing) { + if (!isTrustedPing) { val players = event.ping.players.getOrNull() val onlinePlayers = players?.online ?: 0 val realMax = players?.max ?: 0 diff --git a/proxy-velocity/src/main/resources/config/config.yml b/proxy-velocity/src/main/resources/config/config.yml index f8d68c6..e522141 100644 --- a/proxy-velocity/src/main/resources/config/config.yml +++ b/proxy-velocity/src/main/resources/config/config.yml @@ -32,6 +32,11 @@ whitelist: players: - Notch +# Internal ping sources that should keep the proxy's real player count response. +# Local interface addresses are detected automatically. Add CIDR ranges here only +# when another trusted internal host pings this proxy, for example 192.168.102.10/32. +trusted-ping-sources: [] + # ─────────────────────────────────────────────────────────────────────────────── # Tablist # Configures tablist layouts and update intervals for connected players.