diff --git a/config.example.json b/config.example.json index 3539dec..dbcc0de 100644 --- a/config.example.json +++ b/config.example.json @@ -95,5 +95,10 @@ "example.com", "example.org" ] + }, + "captive_portal": { + "enabled": true, + "open_browser": false, + "check_interval": 30 } } diff --git a/main.py b/main.py index 61712d0..be02c1e 100644 --- a/main.py +++ b/main.py @@ -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", @@ -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}") @@ -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()] diff --git a/src/core/captive_portal.py b/src/core/captive_portal.py new file mode 100644 index 0000000..f7e7f46 --- /dev/null +++ b/src/core/captive_portal.py @@ -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)