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
5 changes: 5 additions & 0 deletions config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,5 +95,10 @@
"example.com",
"example.org"
]
},
"captive_portal": {
"enabled": true,
"open_browser": false,
"check_interval": 30
}
}
27 changes: 27 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@ def parse_args():
action="store_true",
help="Skip the certificate installation check on startup.",
)
parser.add_argument(
"--skip-captive-check",
action="store_true",
help="Skip the captive portal detection probe on startup.",
)
parser.add_argument(
"--scan",
action="store_true",
Expand Down Expand Up @@ -192,6 +197,8 @@ def main():
elif os.environ.get("DFT_LOG_LEVEL"):
config["log_level"] = os.environ["DFT_LOG_LEVEL"]

config["_skip_captive_check"] = args.skip_captive_check

for key in ("auth_key",):
if key not in config:
print(f"Missing required config key: {key}")
Expand Down Expand Up @@ -305,10 +312,30 @@ async def _run(config):
loop = asyncio.get_running_loop()
_log = logging.getLogger("asyncio")
loop.set_exception_handler(_make_exception_handler(_log))

# ── Captive portal check ───────────────────────────────────────
skip_captive = config.get("_skip_captive_check", False)
cp_cfg = config.get("captive_portal") or {}
captive_enabled = cp_cfg.get("enabled", True)

if not skip_captive and captive_enabled:
from core.captive_portal import startup_check, monitor as captive_monitor
await startup_check(config)
# ──────────────────────────────────────────────────────────────

server = ProxyServer(config)
monitor_task = None
try:
if not skip_captive and captive_enabled:
monitor_task = asyncio.create_task(captive_monitor(config))
await server.start()
finally:
if monitor_task is not None:
monitor_task.cancel()
try:
await monitor_task
except asyncio.CancelledError:
pass
await server.stop()
# Cancel any tasks that leaked through (e.g. fire-and-forget pool tasks).
stray = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
Expand Down
177 changes: 177 additions & 0 deletions src/core/captive_portal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
"""
Captive portal detection.

Probes a well-known HTTP endpoint that returns 204 No Content on a clean network.
Any other response (redirect, HTML page, wrong status) indicates a captive portal
is intercepting traffic — the relay will not work until the user authenticates.

Probe endpoint: http://connectivitycheck.gstatic.com/generate_204
Same endpoint used by Android and ChromeOS for connectivity checks.
"""

import asyncio
import logging
import webbrowser

log = logging.getLogger("CaptivePortal")

_PROBE_HOST = "connectivitycheck.gstatic.com"
_PROBE_PATH = "/generate_204"
_PROBE_PORT = 80
_PROBE_TIMEOUT = 5.0


async def probe() -> tuple[bool, str | None]:
"""Probe for a captive portal.

Opens a raw HTTP/1.1 connection (no redirects) to the probe endpoint.
Returns (is_captive, portal_url):
- (False, None) → network is clean
- (True, "http://...") → captive portal detected, redirect URL known
- (True, None) → captive portal detected, no redirect URL
On any network error the function returns (False, None) so a transient
failure never blocks startup.
"""
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(_PROBE_HOST, _PROBE_PORT),
timeout=_PROBE_TIMEOUT,
)
except Exception as exc:
log.debug("Probe connect failed: %s", exc)
return False, None

try:
writer.write((
f"GET {_PROBE_PATH} HTTP/1.1\r\n"
f"Host: {_PROBE_HOST}\r\n"
f"Connection: close\r\n"
f"\r\n"
).encode())
await writer.drain()

status_line = await asyncio.wait_for(
reader.readline(), timeout=_PROBE_TIMEOUT
)
parts = status_line.decode(errors="replace").split()
if len(parts) < 2:
return False, None

try:
status_code = int(parts[1])
except ValueError:
return False, None

if status_code == 204:
return False, None # clean network

# Read headers to find Location redirect
portal_url: str | None = None
while True:
line = await asyncio.wait_for(
reader.readline(), timeout=_PROBE_TIMEOUT
)
decoded = line.decode(errors="replace").strip()
if not decoded:
break
lower = decoded.lower()
if lower.startswith("location:"):
portal_url = decoded.split(":", 1)[1].strip()

return True, portal_url

except asyncio.TimeoutError:
log.debug("Probe timed out")
return False, None
except Exception as exc:
log.debug("Probe error: %s", exc)
return False, None
finally:
try:
writer.close()
except Exception:
pass


async def startup_check(config: dict) -> None:
"""Run a single probe at startup and warn if a captive portal is found.

When captive_portal.open_browser is true and a redirect URL is available,
the portal page is opened in the default browser automatically.
"""
cp_cfg = config.get("captive_portal") or {}
log.info("Checking for captive portal…")
is_captive, portal_url = await probe()

if not is_captive:
log.info("Network is clean — no captive portal detected.")
return

if portal_url:
log.warning(
"Captive portal detected! All traffic is being intercepted.\n"
" Portal URL : %s\n"
" The relay will not work until you authenticate on the portal.\n"
" Tip: open the URL above in a browser, sign in, then retry.",
portal_url,
)
else:
log.warning(
"Captive portal detected! The connectivity probe returned an "
"unexpected response (not HTTP 204). The relay may not work until "
"you authenticate on the network's captive portal."
)

if cp_cfg.get("open_browser", False) and portal_url:
log.info("Opening captive portal in default browser: %s", portal_url)
try:
webbrowser.open(portal_url)
except Exception as exc:
log.debug("Failed to open browser: %s", exc)


async def monitor(config: dict) -> None:
"""Background task: re-probe periodically and log state transitions.

Logs a warning when the portal becomes active mid-session (e.g. the
network lease expired and the ISP requires re-authentication), and an
info message when the portal clears and the network is clean again.
"""
cp_cfg = config.get("captive_portal") or {}
interval = max(10.0, float(cp_cfg.get("check_interval", 30)))

last_captive: bool | None = None

while True:
try:
await asyncio.sleep(interval)
is_captive, portal_url = await probe()

if is_captive and last_captive is not True:
if portal_url:
log.warning(
"Captive portal became active — relay traffic is being "
"intercepted. Portal URL: %s",
portal_url,
)
else:
log.warning(
"Captive portal became active — relay traffic is being "
"intercepted."
)
last_captive = True

elif not is_captive and last_captive is True:
log.info(
"Captive portal cleared — network is clean again. "
"Relay should resume normally."
)
last_captive = False

else:
last_captive = is_captive

except asyncio.CancelledError:
break
except Exception as exc:
log.debug("Monitor error: %s", exc)