Skip to content

Commit d6df155

Browse files
make PortalProxy domain-independent (#7)
Co-authored-by: programminghoch10 <16062290+programminghoch10@users.noreply.github.com>
1 parent 8c0c26b commit d6df155

File tree

7 files changed

+178
-33
lines changed

7 files changed

+178
-33
lines changed

liberator/src/main/kotlin/de/binarynoise/liberator/portals/BinarynoisePortalProxy.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import okhttp3.Response
1414
@Experimental
1515
object BinarynoisePortalProxy : PortalLiberator {
1616
override fun canSolve(response: Response): Boolean {
17-
return response.requestUrl.host == "binarynoise.de" && response.requestUrl.port == 8000
17+
return response.requestUrl.host == "portal.binarynoise.de" && response.requestUrl.port == 8001
1818
}
1919

2020
override fun solve(client: OkHttpClient, response: Response, cookies: Set<Cookie>) {

portalProxy/README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# PortalProxy
2+
3+
A HTTP/HTTPS proxy server with captive portal functionality.
4+
5+
## Overview
6+
7+
PortalProxy provides:
8+
9+
- HTTP/HTTPS proxy server with configurable port
10+
- Captive portal for network authentication
11+
- IP-based access control and allowlisting
12+
- CONNECT tunneling for HTTPS traffic
13+
- Real-time IP tracking and login/logout functionality
14+
15+
## Environment Variables
16+
17+
### Server Configuration
18+
19+
| Variable | Default | Description |
20+
|---------------|---------|---------------------------------------------|
21+
| `PROXY_PORT` | `8000` | Port for the proxy server |
22+
| `PORTAL_PORT` | `8001` | Port for the captive portal web interface |
23+
| `PORTAL_HOST` | `null` | Friendly hostname for the portal (optional) |
24+
25+
### Access Control
26+
27+
| Variable | Default | Description |
28+
|--------------------------|---------|--------------------------------------------------------------|
29+
| `PROXY_ALLOWLIST_DOMAIN` | `null` | Comma-separated list of allowed domains for CONNECT requests |
30+
| `PROXY_ALLOWLIST_PORT` | `null` | Comma-separated list of allowed ports for CONNECT requests |
31+
32+
Providing empty values for `PROXY_ALLOWLIST_DOMAIN` or `PROXY_ALLOWLIST_PORT` disables allowlisting.
33+
34+
## Usage
35+
36+
### Basic Setup
37+
38+
```bash
39+
./gradlew :portalProxy:run
40+
```
41+
42+
### Portal Interface
43+
44+
Access the captive portal at `http://localhost:8001/` (or your configured `PORTAL_PORT`).
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package de.binarynoise.captiveportalautologin.portalproxy.portal
2+
import java.net.Inet4Address
3+
import java.net.Inet6Address
4+
import java.net.InetAddress
5+
import java.net.UnknownHostException
6+
import io.vertx.core.http.HttpServerRequest
7+
8+
fun HttpServerRequest.getRealRemoteIP(): String {
9+
val ip = remoteAddress().host()
10+
11+
val headers = headers()
12+
if (isLanIp(ip) && "X-Real-IP" in headers) {
13+
val realIp = headers["X-Real-IP"]
14+
if (realIp != null) return realIp.split(",").first().trim()
15+
}
16+
return ip
17+
}
18+
19+
fun isLanIp(ipString: String): Boolean {
20+
val addr = parseIp(ipString) ?: return false
21+
22+
return when (addr) {
23+
is Inet4Address -> isPrivateIPv4(addr)
24+
is Inet6Address -> isLocalIPv6(addr)
25+
else -> false
26+
}
27+
}
28+
29+
fun parseIp(ipString: String): InetAddress? = try {
30+
InetAddress.getByName(ipString.trim())
31+
} catch (_: UnknownHostException) {
32+
null
33+
} catch (_: SecurityException) {
34+
null
35+
}
36+
37+
38+
val loopbackV6 = ByteArray(16) { if (it == 15) 1 else 0 }
39+
fun isLocalIPv6(addr: Inet6Address): Boolean {
40+
val bytes = addr.address
41+
val b0 = bytes[0].toInt()
42+
val b1 = bytes[1].toInt()
43+
44+
// fc00::/7 (1111 110x)
45+
val isUniqueLocal = (b0 and 0xFE) == 0xFC
46+
// fe80::/10 (1111 1110 10xx xxxx)
47+
val isLinkLocal = (b0 == 0xFE) && ((b1 and 0xC0) == 0x80)
48+
val isLoopback = bytes.contentEquals(loopbackV6)
49+
50+
return isUniqueLocal || isLinkLocal || isLoopback
51+
}
52+
53+
fun isPrivateIPv4(addr: Inet4Address): Boolean {
54+
val bytes = addr.address
55+
val b0 = bytes[0].toInt() and 0xFF
56+
val b1 = bytes[1].toInt() and 0xFF
57+
58+
return when (b0) {
59+
// 10.0.0.0/8
60+
10 -> true
61+
// 127.0.0.1/8
62+
127 -> true
63+
// 172.16.0.0/12
64+
172 if (b1 in 16..31) -> true
65+
// 192.168.0.0/16
66+
192 if b1 == 168 -> true
67+
else -> false
68+
}
69+
}

portalProxy/src/main/kotlin/Main.kt

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
package de.binarynoise.captiveportalautologin.portalproxy
22

3-
import kotlin.coroutines.EmptyCoroutineContext
3+
import kotlinx.coroutines.Dispatchers
44
import kotlinx.coroutines.launch
5-
import de.binarynoise.captiveportalautologin.portalproxy.portal.portalHost
5+
import de.binarynoise.captiveportalautologin.portalproxy.portal.portalPort
66
import de.binarynoise.captiveportalautologin.portalproxy.portal.portalRouter
77
import de.binarynoise.captiveportalautologin.portalproxy.proxy.forward
88
import de.binarynoise.captiveportalautologin.portalproxy.proxy.forwardConnect
9+
import de.binarynoise.captiveportalautologin.portalproxy.proxy.proxyPort
910
import de.binarynoise.logger.Logger
1011
import de.binarynoise.logger.Logger.log
1112
import io.vertx.core.Vertx
@@ -14,26 +15,31 @@ import io.vertx.core.http.HttpServerRequest
1415
import io.vertx.ext.web.Router
1516
import io.vertx.kotlin.coroutines.CoroutineVerticle
1617
import io.vertx.kotlin.coroutines.coAwait
17-
import io.vertx.kotlin.coroutines.dispatcher
1818

1919
class MainVerticle : CoroutineVerticle() {
2020
override suspend fun start() {
21-
val router = Router.router(vertx)
2221

2322
// Portal routes
24-
router.route().virtualHost(portalHost).handler { ctx ->
23+
val portalRouter = Router.router(vertx)
24+
portalRouter.route().handler { ctx ->
2525
log("route portal")
2626
ctx.next()
2727
}.subRouter(portalRouter(vertx))
2828

2929
// Proxy routes
30-
router.route().handler { ctx ->
30+
val proxyRouter = Router.router(vertx)
31+
proxyRouter.route().handler { ctx ->
3132
log("route /http")
32-
forward(ctx.request())
33+
val request = ctx.request()
34+
if (request.authority().port() == portalPort) {
35+
portalRouter.handle(request)
36+
} else {
37+
forward(request)
38+
}
3339
}
3440

35-
val requestHandler: (HttpServerRequest) -> Unit = { request ->
36-
launch(vertx.dispatcher() + EmptyCoroutineContext) {
41+
val proxyRequestHandler: (HttpServerRequest) -> Unit = { request ->
42+
launch(Dispatchers.IO) {
3743
log(buildString {
3844
append("< ")
3945
append(request.method())
@@ -42,9 +48,9 @@ class MainVerticle : CoroutineVerticle() {
4248
append(" -> ")
4349
append(request.scheme())
4450
append(" ")
45-
append(request.authority().host())
51+
append(request.authority()?.host())
4652
append(" : ")
47-
append(request.authority().port())
53+
append(request.authority()?.port())
4854
append(" ")
4955
append(request.path())
5056
append(" from ")
@@ -56,7 +62,7 @@ class MainVerticle : CoroutineVerticle() {
5662
if (request.method() == HttpMethod.CONNECT) {
5763
forwardConnect(request, vertx)
5864
} else {
59-
router.handle(request)
65+
proxyRouter.handle(request)
6066
}
6167

6268
log(buildString {
@@ -71,26 +77,36 @@ class MainVerticle : CoroutineVerticle() {
7177
}
7278
}
7379

80+
val portalRequestHandler: (HttpServerRequest) -> Unit = { request ->
81+
portalRouter.handle(request)
82+
}
83+
7484
val exceptionHandler = { t: Throwable ->
7585
log("Unhandled exception during connection", t)
7686
}
77-
val invalidRequestHandler = { r: HttpServerRequest ->
78-
log("Invalid request: $r")
79-
}
8087

81-
val server = vertx.createHttpServer()
82-
.requestHandler(requestHandler)
88+
val proxyServer = vertx.createHttpServer()
89+
.requestHandler(proxyRequestHandler)
8390
.exceptionHandler(exceptionHandler)
84-
.invalidRequestHandler(invalidRequestHandler)
85-
.listen(8000, "::")
91+
.listen(proxyPort, "0.0.0.0")
8692
.coAwait()
87-
log("Started server on port " + server.actualPort())
93+
log("Started proxy server on port " + proxyServer.actualPort())
94+
95+
val portalServer = vertx.createHttpServer()
96+
.requestHandler(portalRequestHandler)
97+
.exceptionHandler(exceptionHandler)
98+
.listen(portalPort, "0.0.0.0")
99+
.coAwait()
100+
log("Started portal server on port " + portalServer.actualPort())
88101
}
89102
}
90103

91104
fun main() {
92105
Logger.Config.debugDump = true
93106

107+
// disable ipv6 so entries in the portal database are deterministic for dual stack clients
108+
System.setProperty("java.net.preferIPv4Stack", "true")
109+
94110
val vertx = Vertx.builder()/*.withTracer { o -> DebugTracer() }*/.build()
95111
vertx.exceptionHandler { e ->
96112
log("Unhandled exception", e)

portalProxy/src/main/kotlin/Portal.kt

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package de.binarynoise.captiveportalautologin.portalproxy.portal
22

3-
import java.util.concurrent.ConcurrentHashMap
3+
import java.util.concurrent.*
44
import kotlinx.coroutines.CoroutineScope
55
import kotlinx.html.*
66
import kotlinx.html.stream.*
@@ -10,8 +10,8 @@ import io.vertx.core.http.HttpServerRequest
1010
import io.vertx.ext.web.Router
1111
import io.vertx.kotlin.coroutines.coroutineRouter
1212

13-
const val portalHost = "binarynoise.de"
14-
const val portalPort = 8000
13+
val portalPort = System.getenv("PORTAL_PORT")?.toInt() ?: 8001
14+
val friendlyHost: String? = System.getenv("PORTAL_HOST")
1515

1616
private val database = ConcurrentHashMap<String, Boolean>()
1717

@@ -26,15 +26,17 @@ fun CoroutineScope.portalRouter(vertx: Vertx): Router {
2626

2727
// Login route
2828
router.route("/login").handler { ctx ->
29-
val ip = ctx.request().remoteAddress().host()
29+
val ip = ctx.request().getRealRemoteIP()
30+
3031
database[ip] = false
3132
log("logged in $ip")
3233
ctx.response().putHeader("Location", "/").setStatusCode(302).end()
3334
}
3435

3536
// Logout route
3637
router.route("/logout").handler { ctx ->
37-
val ip = ctx.request().remoteAddress().host()
38+
val ip = ctx.request().getRealRemoteIP()
39+
3840
database[ip] = true
3941
log("logged out $ip")
4042
redirect(ctx.request())
@@ -49,12 +51,17 @@ fun CoroutineScope.portalRouter(vertx: Vertx): Router {
4951
return router
5052
}
5153

54+
fun getPortalHost(request: HttpServerRequest): String {
55+
return friendlyHost ?: request.getHeader("Host")!!.substringBefore(":")
56+
}
57+
5258
fun redirect(request: HttpServerRequest) {
53-
request.response().putHeader("Location", "http://$portalHost:$portalPort/").setStatusCode(303).end()
59+
val host = getPortalHost(request)
60+
request.response().putHeader("Location", "http://$host:$portalPort/").setStatusCode(303).end()
5461
}
5562

5663
fun checkCaptured(request: HttpServerRequest): Boolean {
57-
val ip = request.remoteAddress().host()
64+
val ip = request.getRealRemoteIP()
5865
return database[ip] ?: true
5966
}
6067

@@ -89,6 +96,10 @@ private fun servePortalPage(request: HttpServerRequest) {
8996
body {
9097
h1 { +"Captive Portal" }
9198
p { +"You are currently ${if (captured) "captured" else "not captured"}" }
99+
p {
100+
+"Your IP is "
101+
code { +request.getRealRemoteIP() }
102+
}
92103

93104
form("/login") {
94105
p {
@@ -105,7 +116,7 @@ private fun servePortalPage(request: HttpServerRequest) {
105116
p {
106117
+"This page can be opened again at"
107118
br()
108-
val href = "http://$portalHost:$portalPort/"
119+
val href = "http://${getPortalHost(request)}:$portalPort/"
109120
a(href = href) { +href }
110121
}
111122
}

portalProxy/src/main/kotlin/Proxy.kt

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import kotlin.concurrent.atomics.AtomicBoolean
66
import kotlin.concurrent.atomics.ExperimentalAtomicApi
77
import kotlinx.coroutines.CancellationException
88
import de.binarynoise.captiveportalautologin.portalproxy.portal.checkCaptured
9+
import de.binarynoise.captiveportalautologin.portalproxy.portal.friendlyHost
910
import de.binarynoise.captiveportalautologin.portalproxy.portal.redirect
1011
import de.binarynoise.logger.Logger.log
1112
import io.netty.handler.codec.http.HttpResponseStatus
@@ -15,11 +16,12 @@ import io.vertx.core.http.HttpServerRequest
1516
import io.vertx.core.net.NetSocket
1617
import io.vertx.kotlin.coroutines.coAwait
1718

18-
private val allowlistDomain = listOf("am-i-captured.binarynoise.de", "www.google.com")
19-
private val allowlistPort = listOf("80", "443")
19+
private val allowlistDomain = System.getenv("PROXY_ALLOWLIST_DOMAIN")?.split(",")?.map { it.trim() }
20+
private val allowlistPort = System.getenv("PROXY_ALLOWLIST_PORT")?.split(",")?.map { it.trim() }
21+
val proxyPort = System.getenv("PROXY_PORT")?.toInt() ?: 8000
2022

2123
fun forward(request: HttpServerRequest) {
22-
if (checkCaptured(request)) {
24+
if ((friendlyHost != null && request.authority()?.host() == friendlyHost) || checkCaptured(request)) {
2325
redirect(request)
2426
return
2527
}
@@ -52,7 +54,7 @@ suspend fun forwardConnect(request: HttpServerRequest, vertx: Vertx) {
5254

5355
val (host, port) = hostPort
5456

55-
if (host !in allowlistDomain || port !in allowlistPort) {
57+
if ((allowlistDomain != null && host !in allowlistDomain) || (allowlistPort != null && port !in allowlistPort)) {
5658
request.response()
5759
.setStatusCode(HttpResponseStatus.FORBIDDEN.code())
5860
.end("Access to $host:$port is not allowed")

systemd/portal-proxy.service

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ User=root
88
StateDirectory=captive-portal-proxy
99
WorkingDirectory=/var/lib/captive-portal-proxy
1010
ExecStart=/usr/bin/java -jar /opt/CaptivePortalAutoLogin/portalProxy/build/libs/portalProxy-shadow.jar
11+
Environment=PROXY_ALLOWLIST_DOMAIN=am-i-captured.binarynoise.de,www.google.com
12+
Environment=PROXY_ALLOWLIST_PORT=80,443
13+
Environment=PORTAL_HOST=portal.binarynoise.de
1114
Restart=on-failure
1215
RestartSec=10
1316

0 commit comments

Comments
 (0)