From 97e9eba47717aa32e193d37c34a902c7332eec5e Mon Sep 17 00:00:00 2001 From: binarynoise Date: Fri, 23 Jan 2026 00:31:30 +0100 Subject: [PATCH 1/3] make PortalProxy respect X-Real-IP --- portalProxy/src/main/kotlin/IpUtils.kt | 69 ++++++++++++++++++++++++++ portalProxy/src/main/kotlin/Portal.kt | 14 ++++-- 2 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 portalProxy/src/main/kotlin/IpUtils.kt diff --git a/portalProxy/src/main/kotlin/IpUtils.kt b/portalProxy/src/main/kotlin/IpUtils.kt new file mode 100644 index 0000000..9c7a1bd --- /dev/null +++ b/portalProxy/src/main/kotlin/IpUtils.kt @@ -0,0 +1,69 @@ +package de.binarynoise.captiveportalautologin.portalproxy.portal +import java.net.Inet4Address +import java.net.Inet6Address +import java.net.InetAddress +import java.net.UnknownHostException +import io.vertx.core.http.HttpServerRequest + +fun HttpServerRequest.getRealRemoteIP(): String { + val ip = remoteAddress().host() + + val headers = headers() + if (isLanIp(ip) && "X-Real-IP" in headers) { + val realIp = headers["X-Real-IP"] + if (realIp != null) return realIp.split(",").first().trim() + } + return ip +} + +fun isLanIp(ipString: String): Boolean { + val addr = parseIp(ipString) ?: return false + + return when (addr) { + is Inet4Address -> isPrivateIPv4(addr) + is Inet6Address -> isLocalIPv6(addr) + else -> false + } +} + +fun parseIp(ipString: String): InetAddress? = try { + InetAddress.getByName(ipString.trim()) +} catch (_: UnknownHostException) { + null +} catch (_: SecurityException) { + null +} + + +val loopbackV6 = ByteArray(16) { if (it == 15) 1 else 0 } +fun isLocalIPv6(addr: Inet6Address): Boolean { + val bytes = addr.address + val b0 = bytes[0].toInt() + val b1 = bytes[1].toInt() + + // fc00::/7 (1111 110x) + val isUniqueLocal = (b0 and 0xFE) == 0xFC + // fe80::/10 (1111 1110 10xx xxxx) + val isLinkLocal = (b0 == 0xFE) && ((b1 and 0xC0) == 0x80) + val isLoopback = bytes.contentEquals(loopbackV6) + + return isUniqueLocal || isLinkLocal || isLoopback +} + +fun isPrivateIPv4(addr: Inet4Address): Boolean { + val bytes = addr.address + val b0 = bytes[0].toInt() and 0xFF + val b1 = bytes[1].toInt() and 0xFF + + return when (b0) { + // 10.0.0.0/8 + 10 -> true + // 127.0.0.1/8 + 127 -> true + // 172.16.0.0/12 + 172 if (b1 in 16..31) -> true + // 192.168.0.0/16 + 192 if b1 == 168 -> true + else -> false + } +} diff --git a/portalProxy/src/main/kotlin/Portal.kt b/portalProxy/src/main/kotlin/Portal.kt index db27c6d..598852a 100644 --- a/portalProxy/src/main/kotlin/Portal.kt +++ b/portalProxy/src/main/kotlin/Portal.kt @@ -1,6 +1,6 @@ package de.binarynoise.captiveportalautologin.portalproxy.portal -import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.* import kotlinx.coroutines.CoroutineScope import kotlinx.html.* import kotlinx.html.stream.* @@ -26,7 +26,8 @@ fun CoroutineScope.portalRouter(vertx: Vertx): Router { // Login route router.route("/login").handler { ctx -> - val ip = ctx.request().remoteAddress().host() + val ip = ctx.request().getRealRemoteIP() + database[ip] = false log("logged in $ip") ctx.response().putHeader("Location", "/").setStatusCode(302).end() @@ -34,7 +35,8 @@ fun CoroutineScope.portalRouter(vertx: Vertx): Router { // Logout route router.route("/logout").handler { ctx -> - val ip = ctx.request().remoteAddress().host() + val ip = ctx.request().getRealRemoteIP() + database[ip] = true log("logged out $ip") redirect(ctx.request()) @@ -54,7 +56,7 @@ fun redirect(request: HttpServerRequest) { } fun checkCaptured(request: HttpServerRequest): Boolean { - val ip = request.remoteAddress().host() + val ip = request.getRealRemoteIP() return database[ip] ?: true } @@ -89,6 +91,10 @@ private fun servePortalPage(request: HttpServerRequest) { body { h1 { +"Captive Portal" } p { +"You are currently ${if (captured) "captured" else "not captured"}" } + p { + +"Your IP is " + code { +request.getRealRemoteIP() } + } form("/login") { p { From 8c64342863c2ca8f66aeb4ddddc51f3638b0add2 Mon Sep 17 00:00:00 2001 From: binarynoise Date: Fri, 23 Jan 2026 01:00:27 +0100 Subject: [PATCH 2/3] make PortalProxy domain-independent --- portalProxy/src/main/kotlin/Portal.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/portalProxy/src/main/kotlin/Portal.kt b/portalProxy/src/main/kotlin/Portal.kt index 598852a..55d9f77 100644 --- a/portalProxy/src/main/kotlin/Portal.kt +++ b/portalProxy/src/main/kotlin/Portal.kt @@ -10,7 +10,7 @@ import io.vertx.core.http.HttpServerRequest import io.vertx.ext.web.Router import io.vertx.kotlin.coroutines.coroutineRouter -const val portalHost = "binarynoise.de" +const val portalHost = "localhost" const val portalPort = 8000 private val database = ConcurrentHashMap() From a3a48400059cc9dc625caf796678c07640ca4bc2 Mon Sep 17 00:00:00 2001 From: programminghoch10 <16062290+programminghoch10@users.noreply.github.com> Date: Thu, 29 Jan 2026 10:59:40 +0100 Subject: [PATCH 3/3] separate PortalProxy proxy and portal onto different ports --- portalProxy/src/main/kotlin/Main.kt | 36 +++++++++++++++++++-------- portalProxy/src/main/kotlin/Portal.kt | 14 ++++++++--- portalProxy/src/main/kotlin/Proxy.kt | 1 + 3 files changed, 37 insertions(+), 14 deletions(-) diff --git a/portalProxy/src/main/kotlin/Main.kt b/portalProxy/src/main/kotlin/Main.kt index bc07c11..f5b8bd3 100644 --- a/portalProxy/src/main/kotlin/Main.kt +++ b/portalProxy/src/main/kotlin/Main.kt @@ -2,10 +2,11 @@ package de.binarynoise.captiveportalautologin.portalproxy import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.launch -import de.binarynoise.captiveportalautologin.portalproxy.portal.portalHost +import de.binarynoise.captiveportalautologin.portalproxy.portal.portalPort import de.binarynoise.captiveportalautologin.portalproxy.portal.portalRouter import de.binarynoise.captiveportalautologin.portalproxy.proxy.forward import de.binarynoise.captiveportalautologin.portalproxy.proxy.forwardConnect +import de.binarynoise.captiveportalautologin.portalproxy.proxy.proxyPort import de.binarynoise.logger.Logger import de.binarynoise.logger.Logger.log import io.vertx.core.Vertx @@ -18,21 +19,22 @@ import io.vertx.kotlin.coroutines.dispatcher class MainVerticle : CoroutineVerticle() { override suspend fun start() { - val router = Router.router(vertx) // Portal routes - router.route().virtualHost(portalHost).handler { ctx -> + val portalRouter = Router.router(vertx) + portalRouter.route().handler { ctx -> log("route portal") ctx.next() }.subRouter(portalRouter(vertx)) // Proxy routes - router.route().handler { ctx -> + val proxyRouter = Router.router(vertx) + proxyRouter.route().handler { ctx -> log("route /http") forward(ctx.request()) } - val requestHandler: (HttpServerRequest) -> Unit = { request -> + val proxyRequestHandler: (HttpServerRequest) -> Unit = { request -> launch(vertx.dispatcher() + EmptyCoroutineContext) { log(buildString { append("< ") @@ -56,7 +58,7 @@ class MainVerticle : CoroutineVerticle() { if (request.method() == HttpMethod.CONNECT) { forwardConnect(request, vertx) } else { - router.handle(request) + proxyRouter.handle(request) } log(buildString { @@ -71,6 +73,12 @@ class MainVerticle : CoroutineVerticle() { } } + val portalRequestHandler: (HttpServerRequest) -> Unit = { request -> + launch(vertx.dispatcher() + EmptyCoroutineContext) { + portalRouter.handle(request) + } + } + val exceptionHandler = { t: Throwable -> log("Unhandled exception during connection", t) } @@ -78,13 +86,21 @@ class MainVerticle : CoroutineVerticle() { log("Invalid request: $r") } - val server = vertx.createHttpServer() - .requestHandler(requestHandler) + val proxyServer = vertx.createHttpServer() + .requestHandler(proxyRequestHandler) + .exceptionHandler(exceptionHandler) + .invalidRequestHandler(invalidRequestHandler) + .listen(proxyPort, "::") + .coAwait() + log("Started proxy server on port " + proxyServer.actualPort()) + + val portalServer = vertx.createHttpServer() + .requestHandler(portalRequestHandler) .exceptionHandler(exceptionHandler) .invalidRequestHandler(invalidRequestHandler) - .listen(8000, "::") + .listen(portalPort, "::") .coAwait() - log("Started server on port " + server.actualPort()) + log("Started portal server on port " + portalServer.actualPort()) } } diff --git a/portalProxy/src/main/kotlin/Portal.kt b/portalProxy/src/main/kotlin/Portal.kt index 55d9f77..280f535 100644 --- a/portalProxy/src/main/kotlin/Portal.kt +++ b/portalProxy/src/main/kotlin/Portal.kt @@ -10,8 +10,9 @@ import io.vertx.core.http.HttpServerRequest import io.vertx.ext.web.Router import io.vertx.kotlin.coroutines.coroutineRouter -const val portalHost = "localhost" -const val portalPort = 8000 +const val portalPort = 8001 +// TODO: use [System.getenv("PORTAL_HOST")] here +const val friendlyHost = "portal.binarynoise.de" private val database = ConcurrentHashMap() @@ -51,8 +52,13 @@ fun CoroutineScope.portalRouter(vertx: Vertx): Router { return router } +fun getPortalHost(request: HttpServerRequest?) : String { + return request?.getHeader("Host") ?: friendlyHost +} + fun redirect(request: HttpServerRequest) { - request.response().putHeader("Location", "http://$portalHost:$portalPort/").setStatusCode(303).end() + val host = getPortalHost(request) + request.response().putHeader("Location", "http://$host:$portalPort/").setStatusCode(303).end() } fun checkCaptured(request: HttpServerRequest): Boolean { @@ -111,7 +117,7 @@ private fun servePortalPage(request: HttpServerRequest) { p { +"This page can be opened again at" br() - val href = "http://$portalHost:$portalPort/" + val href = "http://${getPortalHost(request)}:$portalPort/" a(href = href) { +href } } } diff --git a/portalProxy/src/main/kotlin/Proxy.kt b/portalProxy/src/main/kotlin/Proxy.kt index 2bf1220..684c10a 100644 --- a/portalProxy/src/main/kotlin/Proxy.kt +++ b/portalProxy/src/main/kotlin/Proxy.kt @@ -17,6 +17,7 @@ import io.vertx.kotlin.coroutines.coAwait private val allowlistDomain = listOf("am-i-captured.binarynoise.de", "www.google.com") private val allowlistPort = listOf("80", "443") +val proxyPort = 8000 fun forward(request: HttpServerRequest) { if (checkCaptured(request)) {