Skip to content
Merged
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
2 changes: 2 additions & 0 deletions haproxy/src/main/kotlin/org/jire/netty/haproxy/HAProxy.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public object HAProxy {
public fun ServerBootstrap.childHandlerProxied(
childHandler: ChannelInboundHandler,
mode: HAProxyMode = HAProxyMode.AUTO,
trustPredicate: HAProxyTrustPredicate = HAProxyTrustPredicate.LOOPBACK_ONLY,
idleTimeout: Long = DEFAULT_IDLE_TIMEOUT,
idleTimeoutUnit: TimeUnit = DEFAULT_IDLE_TIMEOUT_UNIT,
): ServerBootstrap =
Expand All @@ -37,6 +38,7 @@ public object HAProxy {
HAProxyChannelInitializer(
childHandler,
mode,
trustPredicate,
idleTimeout,
idleTimeoutUnit,
),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.jire.netty.haproxy

import com.github.michaelbull.logging.InlineLogger
import io.netty.channel.Channel
import io.netty.channel.ChannelHandler.Sharable
import io.netty.channel.ChannelInboundHandler
Expand Down Expand Up @@ -29,6 +30,10 @@ import java.util.concurrent.TimeUnit
* @param mode The [HAProxyMode] to use. Default is [HAProxyMode.AUTO] which will
* support both proxied and non-proxied connections.
*
* @param trustPredicate The [HAProxyTrustPredicate] deciding which peers' PROXY headers
* are honored. Untrusted peers are handled as plain connections under [HAProxyMode.AUTO]
* and closed under [HAProxyMode.ON]. Default is [HAProxyTrustPredicate.LOOPBACK_ONLY].
*
* @param idleTimeout The timeout duration after which an idle connection will be closed.
* Default to [DEFAULT_IDLE_TIMEOUT].
* @param idleTimeoutUnit The time unit for the [idleTimeout].
Expand All @@ -40,6 +45,7 @@ public class HAProxyChannelInitializer
public constructor(
override val childHandler: ChannelInboundHandler,
private val mode: HAProxyMode = HAProxyMode.AUTO,
private val trustPredicate: HAProxyTrustPredicate = HAProxyTrustPredicate.LOOPBACK_ONLY,
private val idleTimeout: Long = DEFAULT_IDLE_TIMEOUT,
private val idleTimeoutUnit: TimeUnit = DEFAULT_IDLE_TIMEOUT_UNIT,
) : ChannelInitializer<Channel>(),
Expand All @@ -53,6 +59,25 @@ public class HAProxyChannelInitializer
override fun initChannel(ch: Channel) {
val pipeline = ch.pipeline()

if (mode == HAProxyMode.OFF) {
addChildHandler(pipeline)
return
}

if (!trustPredicate.isTrusted(ch.remoteAddress())) {
when (mode) {
HAProxyMode.AUTO -> addChildHandler(pipeline)

else -> {
logger.warn {
"Untrusted peer ${ch.remoteAddress()} on PROXY-required channel, closing"
}
ch.close()
}
}
return
}

when (mode) {
HAProxyMode.AUTO -> {
addIdleStateHandler(pipeline)
Expand All @@ -62,21 +87,21 @@ public class HAProxyChannelInitializer
)
}

HAProxyMode.ON -> {
else -> {
addIdleStateHandler(pipeline)
addHAProxyHandlers(pipeline)
}

HAProxyMode.OFF -> {
pipeline.replace(
this@HAProxyChannelInitializer,
HAPROXY_CHANNEL_INITIALIZER_CHILD_NAME,
childHandler,
)
}
}
}

private fun addChildHandler(pipeline: ChannelPipeline) {
pipeline.replace(
this@HAProxyChannelInitializer,
HAPROXY_CHANNEL_INITIALIZER_CHILD_NAME,
childHandler,
)
}

public fun addIdleStateHandler(pipeline: ChannelPipeline) {
pipeline.addLast(
HAPROXY_IDLE_STATE_HANDLER_NAME,
Expand All @@ -97,4 +122,8 @@ public class HAProxyChannelInitializer
messageHandler,
)
}

private companion object {
private val logger = InlineLogger()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package org.jire.netty.haproxy

import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.SocketAddress

/**
* Decides whether a [HAProxy](https://en.wikipedia.org/wiki/HAProxy) protocol header
* received from a directly connected peer will be honored as the "real" source address.
*
* Evaluated once per connection, on the event loop, against the peer's transport-level
* remote address, never against the address claimed inside a PROXY header.
* Implementations must be cheap and non-blocking.
*/
public fun interface HAProxyTrustPredicate {
public fun isTrusted(remoteAddress: SocketAddress?): Boolean

public companion object {
/**
* Trusts loopback peers only (`127.0.0.0/8`, `::1`).
*/
@JvmStatic
public val LOOPBACK_ONLY: HAProxyTrustPredicate =
HAProxyTrustPredicate { addr ->
(addr as? InetSocketAddress)?.address?.isLoopbackAddress == true
}

/**
* Honors the PROXY header from any peer. Spoofable by design, only for networks
* where every peer is already trusted.
*/
@JvmStatic
public val TRUST_ALL: HAProxyTrustPredicate = HAProxyTrustPredicate { true }

/**
* Trusts peers matching any of the given entries, each an IP literal with an
* optional prefix length, e.g. `"10.0.0.5"`, `"10.0.0.0/8"`, `"fd00::/8"`.
* Hostnames are rejected and address families never cross-match.
*/
@JvmStatic
public fun ofCidrs(vararg cidrs: String): HAProxyTrustPredicate {
require(cidrs.isNotEmpty()) { "At least one CIDR is required" }
val rules = cidrs.map(::parseCidr)
return HAProxyTrustPredicate { addr ->
val inet = (addr as? InetSocketAddress)?.address
if (inet == null) {
false
} else {
val bytes = normalize(inet.address)
rules.any { it.matches(bytes) }
}
}
}

private class CidrRule(
private val network: ByteArray,
private val prefixLength: Int,
) {
fun matches(address: ByteArray): Boolean {
if (address.size != network.size) return false
val fullBytes = prefixLength ushr 3
for (i in 0 until fullBytes) {
if (address[i] != network[i]) return false
}
val remainderBits = prefixLength and 7
if (remainderBits == 0) return true
val mask = 0xFF shl (8 - remainderBits) and 0xFF
return address[fullBytes].toInt() and mask == network[fullBytes].toInt() and mask
}
}

private fun parseCidr(cidr: String): CidrRule {
val slash = cidr.indexOf('/')
val literal = if (slash == -1) cidr else cidr.substring(0, slash)
// InetAddress.getByName resolves hostnames via DNS, only allow IP literals
require(
literal.isNotEmpty() &&
literal.all { it.isDigit() || it in 'a'..'f' || it in 'A'..'F' || it == '.' || it == ':' },
) {
"Expected an IP literal, got \"$cidr\""
}
val network = normalize(InetAddress.getByName(literal).address)
val maxPrefix = network.size * 8
val prefix =
if (slash == -1) {
maxPrefix
} else {
val parsed = cidr.substring(slash + 1).toIntOrNull()
require(parsed != null && parsed in 0..maxPrefix) {
"Invalid prefix length in \"$cidr\""
}
parsed
}
return CidrRule(network, prefix)
}

private fun normalize(bytes: ByteArray): ByteArray {
if (bytes.size != 16) return bytes
for (i in 0..9) {
if (bytes[i] != 0.toByte()) return bytes
}
if (bytes[10] != 0xFF.toByte() || bytes[11] != 0xFF.toByte()) return bytes
return bytes.copyOfRange(12, 16)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package org.jire.netty.haproxy

import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import java.net.InetAddress
import java.net.InetSocketAddress
import kotlin.test.assertFalse
import kotlin.test.assertTrue

class HAProxyTrustPredicateTest {
private fun addr(ip: String): InetSocketAddress = InetSocketAddress(InetAddress.getByName(ip), 43594)

@Test
fun `loopback only trusts loopback`() {
val predicate = HAProxyTrustPredicate.LOOPBACK_ONLY
assertTrue(predicate.isTrusted(addr("127.0.0.1")))
assertTrue(predicate.isTrusted(addr("127.53.2.1")))
assertTrue(predicate.isTrusted(addr("::1")))
assertFalse(predicate.isTrusted(addr("8.8.8.8")))
assertFalse(predicate.isTrusted(null))
}

@Test
fun `trust all trusts anything`() {
assertTrue(HAProxyTrustPredicate.TRUST_ALL.isTrusted(addr("8.8.8.8")))
assertTrue(HAProxyTrustPredicate.TRUST_ALL.isTrusted(null))
}

@ParameterizedTest
@CsvSource(
"10.0.0.0/8, 10.255.255.255, true",
"10.0.0.0/8, 11.0.0.0, false",
"10.0.0.0/12, 10.15.255.255, true",
"10.0.0.0/12, 10.16.0.0, false",
"10.0.0.4/31, 10.0.0.5, true",
"10.0.0.4/31, 10.0.0.6, false",
"10.0.0.5/32, 10.0.0.5, true",
"10.0.0.5/32, 10.0.0.4, false",
"10.0.0.5, 10.0.0.5, true",
"10.0.0.5, 10.0.0.6, false",
"0.0.0.0/0, 203.0.113.7, true",
"fd00::/8, fd12:3456::1, true",
"fd00::/8, fe80::1, false",
"'::1', '::1', true",
"'::/0', 'fe80::1', true",
)
fun `cidr matching`(
cidr: String,
peer: String,
expected: Boolean,
) {
val predicate = HAProxyTrustPredicate.ofCidrs(cidr)
if (expected) {
assertTrue(predicate.isTrusted(addr(peer)))
} else {
assertFalse(predicate.isTrusted(addr(peer)))
}
}

@Test
fun `families never cross-match`() {
assertFalse(HAProxyTrustPredicate.ofCidrs("0.0.0.0/0").isTrusted(addr("fe80::1")))
assertFalse(HAProxyTrustPredicate.ofCidrs("::/0").isTrusted(addr("8.8.8.8")))
}

@Test
fun `ipv4-mapped ipv6 is normalized on both sides`() {
assertTrue(HAProxyTrustPredicate.ofCidrs("10.0.0.5/32").isTrusted(addr("::ffff:10.0.0.5")))
assertTrue(HAProxyTrustPredicate.ofCidrs("::ffff:10.0.0.5").isTrusted(addr("10.0.0.5")))
assertFalse(HAProxyTrustPredicate.ofCidrs("10.0.0.5/32").isTrusted(addr("::ffff:10.0.0.6")))
}

@Test
fun `any matching entry trusts the peer`() {
val predicate = HAProxyTrustPredicate.ofCidrs("192.168.0.0/16", "10.0.0.5")
assertTrue(predicate.isTrusted(addr("192.168.4.20")))
assertTrue(predicate.isTrusted(addr("10.0.0.5")))
assertFalse(predicate.isTrusted(addr("10.0.0.6")))
}

@Test
fun `invalid input is rejected`() {
assertThrows<IllegalArgumentException> { HAProxyTrustPredicate.ofCidrs() }
assertThrows<IllegalArgumentException> { HAProxyTrustPredicate.ofCidrs("proxy.internal") }
assertThrows<IllegalArgumentException> { HAProxyTrustPredicate.ofCidrs("10.0.0.0/33") }
assertThrows<IllegalArgumentException> { HAProxyTrustPredicate.ofCidrs("10.0.0.0/-1") }
assertThrows<IllegalArgumentException> { HAProxyTrustPredicate.ofCidrs("10.0.0.0/") }
assertThrows<IllegalArgumentException> { HAProxyTrustPredicate.ofCidrs("/8") }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import net.rsprot.protocol.metrics.impl.ConcurrentNetworkTrafficMonitor
import net.rsprot.protocol.metrics.impl.NoopNetworkTrafficMonitor
import net.rsprot.protocol.metrics.lock.TrafficMonitorLock
import org.jire.netty.haproxy.HAProxyMode
import org.jire.netty.haproxy.HAProxyTrustPredicate

/**
* The abstract network service factory is used to build the network service that is used
Expand Down Expand Up @@ -119,6 +120,13 @@ public abstract class AbstractNetworkServiceFactory<R> {
public open val haproxyMode: HAProxyMode
get() = HAProxyMode.OFF

/**
* Gets the trust predicate deciding which peers' PROXY headers are honored
* when [haproxyMode] is enabled. By default, only loopback peers are trusted.
*/
public open val haproxyTrustPredicate: HAProxyTrustPredicate
get() = HAProxyTrustPredicate.LOOPBACK_ONLY

/**
* Gets the bootstrap factory builder to register the network service.
* The bootstrap builder offers the initial socket and Netty configurations
Expand Down Expand Up @@ -383,6 +391,7 @@ public abstract class AbstractNetworkServiceFactory<R> {
getJs5GroupProvider(),
getIdleStateHandlerSuppliers(),
haproxyMode,
haproxyTrustPredicate,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import net.rsprot.protocol.metrics.NetworkTrafficMonitor
import net.rsprot.protocol.threads.IllegalThreadAccessException
import org.jire.netty.haproxy.HAProxy.childHandlerProxied
import org.jire.netty.haproxy.HAProxyMode
import org.jire.netty.haproxy.HAProxyTrustPredicate
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ExecutorService
import java.util.concurrent.ScheduledExecutorService
Expand Down Expand Up @@ -110,6 +111,7 @@ public class NetworkService<R>
js5GroupProvider: Js5GroupProvider,
public val idleStateHandlerSuppliers: IdleStateHandlerSuppliers,
public val haProxyMode: HAProxyMode,
public val haProxyTrustPredicate: HAProxyTrustPredicate,
) {
public var encoderRepositories: MessageEncoderRepositories = MessageEncoderRepositories(huffmanCodecProvider)
public val js5Authorizer: Js5Authorizer = if (betaWorld) ConcurrentJs5Authorizer() else NoopJs5Authorizer
Expand Down Expand Up @@ -155,7 +157,7 @@ public class NetworkService<R>
measureTime {
val bootstrap = bootstrapBuilder.build(messageSizeEstimator)
val initializer = LoginChannelInitializer(this)
bootstrap.childHandlerProxied(initializer, haProxyMode)
bootstrap.childHandlerProxied(initializer, haProxyMode, haProxyTrustPredicate)
this.bossGroup = bootstrap.config().group()
this.childGroup = bootstrap.config().childGroup()
val host = this.host
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import net.rsprot.protocol.metrics.impl.ConcurrentNetworkTrafficMonitor
import net.rsprot.protocol.metrics.impl.NoopNetworkTrafficMonitor
import net.rsprot.protocol.metrics.lock.TrafficMonitorLock
import org.jire.netty.haproxy.HAProxyMode
import org.jire.netty.haproxy.HAProxyTrustPredicate

/**
* The abstract network service factory is used to build the network service that is used
Expand Down Expand Up @@ -119,6 +120,13 @@ public abstract class AbstractNetworkServiceFactory<R> {
public open val haproxyMode: HAProxyMode
get() = HAProxyMode.OFF

/**
* Gets the trust predicate deciding which peers' PROXY headers are honored
* when [haproxyMode] is enabled. By default, only loopback peers are trusted.
*/
public open val haproxyTrustPredicate: HAProxyTrustPredicate
get() = HAProxyTrustPredicate.LOOPBACK_ONLY

/**
* Gets the bootstrap factory builder to register the network service.
* The bootstrap builder offers the initial socket and Netty configurations
Expand Down Expand Up @@ -383,6 +391,7 @@ public abstract class AbstractNetworkServiceFactory<R> {
getJs5GroupProvider(),
getIdleStateHandlerSuppliers(),
haproxyMode,
haproxyTrustPredicate,
)
}

Expand Down
Loading
Loading