Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand All @@ -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()
Expand All @@ -35,17 +40,15 @@ 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) {
serverIconLoader.get(layout.serverIcon.file) { plugin.logger.warning(it) }
?.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()
Expand Down
5 changes: 5 additions & 0 deletions proxy-bungeecord/src/main/resources/config/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ data class ProxyEssentialsConfig(
)
),
val whitelist: WhitelistConfig = WhitelistConfig(),
@Setting("trusted-ping-sources") val trustedPingSources: List<String> = emptyList(),
val tablist: List<TabListGroup> = listOf(
TabListGroup(
name = "global",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String>,
private val warn: (String) -> Unit
) {
private val localAddresses: Set<InetAddress> 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<TrustedAddressRange> {
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<InetAddress> {
val addresses = mutableSetOf<InetAddress>()

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<String> = emptyList(),
val ranges: List<TrustedAddressRange> = 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
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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)

Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions proxy-velocity/src/main/resources/config/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down