diff --git a/router_refresh_ip.py b/router_refresh_ip.py new file mode 100755 index 0000000..50a9d9a --- /dev/null +++ b/router_refresh_ip.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 + +import argparse +import base64 +import json +import logging +import ssl +import time +import urllib.error +import urllib.request + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s") +logger = logging.getLogger(__name__) + +ROUTER_HOST = "192.168.1.1" +ROUTER_USERNAME = "abdus" +ROUTER_PASSWORD = "xAsametk50" +PUBLIC_IP_URL = "https://api.ipify.org" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Drop and re-establish the router's PPPoE session to request a new CGNAT public IP, without rebooting", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser.add_argument("--host", default=ROUTER_HOST) + parser.add_argument("--timeout", type=float, default=15.0, help="Router request timeout in seconds") + parser.add_argument("--wait-after-disconnect", type=float, default=5.0, help="Seconds to wait before reconnecting") + parser.add_argument("--max-wait", type=float, default=60.0, help="Seconds to wait for the session to come back up") + parser.add_argument("--skip-public-ip-check", action="store_true", help="Do not call an external service to compare public IPs") + parser.add_argument("--dry-run", action="store_true", help="Log in and find the active PPPoE session, but do not disconnect it") + return parser.parse_args() + + +class RouterSession: + """Logs in to the router on enter and always logs out on exit, so a crash never leaks a session. + + The router has a limit on concurrent logins and returns "Maxium number of login account has + reached" once it's hit; the only way out of that is waiting for idle sessions to time out. + """ + + def __init__(self, host: str, timeout: float) -> None: + self.base_url = f"https://{host}" + self.timeout = timeout + self.session_key = "" + # The router uses a self-signed certificate. Over HTTPS the UI skips its RSA/AES login layer. + context = ssl.create_default_context() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + self.opener = urllib.request.build_opener( + urllib.request.HTTPSHandler(context=context), + urllib.request.HTTPCookieProcessor(), + ) + + def request(self, path: str, body: dict | None, method: str) -> dict: + separator = "&" if "?" in path else "?" + suffix = f"{separator}sessionkey={self.session_key}" if self.session_key else "" + headers = {"Content-Type": "application/json"} + if self.session_key and method != "GET": + # Since the ABSB.5 firmware, state-changing requests (POST/PUT/DELETE) are only + # accepted with the session key repeated in this header; the ?sessionkey= query + # param alone (still required for GETs) gets a bare 403 on everything else. + headers["CSRFToken"] = self.session_key + request = urllib.request.Request( + url=f"{self.base_url}{path}{suffix}", + data=json.dumps(body).encode() if body is not None else None, + headers=headers, + method=method, + ) + with self.opener.open(request, timeout=self.timeout) as response: + raw = response.read().decode() + return json.loads(raw) if raw.strip() else {} + + def login(self) -> None: + body = self.request( + path="/UserLogin", + body={ + "Input_Account": ROUTER_USERNAME, + "Input_Passwd": base64.b64encode(ROUTER_PASSWORD.encode()).decode(), + "currLang": "en", + "RememberPassword": 0, + "SHA512_password": False, + }, + method="POST", + ) + if body.get("result") != "ZCFG_SUCCESS": + raise RuntimeError(f"Login rejected: {body}") + self.session_key = body.get("sessionkey", "") + logger.info("Logged in as %s", ROUTER_USERNAME) + + def logout(self) -> None: + if not self.session_key: + return + try: + self.request(path="/cgi-bin/UserLogout", body={}, method="POST") + logger.info("Logged out, session released") + except Exception as exc: + logger.warning("Logout failed, the router may hold this session until it times out: %s", exc) + finally: + self.session_key = "" + + def get_status(self) -> dict: + return self.request(path="/cgi-bin/DAL?oid=status", body=None, method="GET") + + def wan_action(self, wanppp_idx: int, action: str) -> None: + # Disconnect/Connect tear down or rebuild the PPPoE link, so the router often doesn't get + # an HTTP response back in time (or at all, if the link it was answering over just dropped). + # That doesn't mean the action failed: treat a timeout as "sent, outcome unknown" and let + # the caller confirm the real state by polling, instead of raising and giving up. + try: + reply = self.request(path="/cgi-bin/DAL?oid=status", body={"action": action, "wanpppIdx": wanppp_idx}, method="PUT") + except (TimeoutError, urllib.error.URLError, ConnectionError) as exc: + logger.info("%s request did not get a response (%s), checking the actual state instead", action, exc) + return + if reply.get("result") not in (None, "ZCFG_SUCCESS"): + raise RuntimeError(f"Router rejected {action}: {reply}") + + def __enter__(self) -> "RouterSession": + self.login() + return self + + def __exit__(self, exc_type: object, exc_value: object, traceback: object) -> None: + self.logout() + + +def find_active_pppoe(status: dict) -> dict: + candidates = [ + entry + for entry in status["Object"][0]["WanLanInfo"] + if entry.get("Encapsulation") == "PPPoE" and entry.get("Status") == "Up" + ] + if not candidates: + raise RuntimeError("No active PPPoE WAN session found to refresh") + if len(candidates) > 1: + logger.warning("Multiple active PPPoE sessions found, using the first: %s", candidates[0].get("Name")) + return candidates[0] + + +def wan_ip_of(entry: dict) -> str | None: + return next(iter(entry.get("IPv4Address") or []), {}).get("IPAddress") + + +def find_wan_entry(status: dict, wanppp_idx: int) -> dict | None: + return next( + (e for e in status["Object"][0]["WanLanInfo"] if e.get("wanpppIdx") == wanppp_idx and e.get("Encapsulation") == "PPPoE"), + None, + ) + + +def poll_until_up(session: RouterSession, wanppp_idx: int, timeout: float) -> dict | None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + entry = find_wan_entry(status=session.get_status(), wanppp_idx=wanppp_idx) + if entry and entry.get("Status") == "Up" and wan_ip_of(entry=entry): + return entry + except (urllib.error.URLError, json.JSONDecodeError, ConnectionError, KeyError, IndexError) as exc: + logger.info("Still reconnecting: %s", exc) + time.sleep(2.0) + return None + + +def ensure_reconnected(session: RouterSession, wanppp_idx: int, max_wait: float, connect_retries: int = 2) -> dict: + """Poll for the link to come back; if it's stuck at N/A (the router's own auto-reconnect can + fail to kick in), nudge it with another Connect call instead of leaving it down.""" + per_attempt = max(max_wait / (connect_retries + 1), 15.0) + for attempt in range(connect_retries + 1): + entry = poll_until_up(session=session, wanppp_idx=wanppp_idx, timeout=per_attempt) + if entry: + return entry + if attempt < connect_retries: + logger.warning("Not back up yet, retrying Connect (%d/%d)...", attempt + 1, connect_retries) + session.wan_action(wanppp_idx=wanppp_idx, action="Connect") + raise TimeoutError(f"WAN session did not come back up within {max_wait:.0f}s, even after retrying Connect") + + +def fetch_public_ip(timeout: float) -> str | None: + try: + with urllib.request.urlopen(PUBLIC_IP_URL, timeout=timeout) as response: + return response.read().decode().strip() + except Exception as exc: + logger.info("Could not fetch public IP from %s: %s", PUBLIC_IP_URL, exc) + return None + + +def main() -> None: + args = parse_args() + public_ip_before = None if args.skip_public_ip_check else fetch_public_ip(timeout=args.timeout) + if public_ip_before: + logger.info("Public IP before: %s", public_ip_before) + + with RouterSession(host=args.host, timeout=args.timeout) as session: + wan = find_active_pppoe(status=session.get_status()) + wanppp_idx = wan["wanpppIdx"] + logger.info("Active PPPoE session: %s (wanpppIdx=%s, current WAN IP=%s)", wan.get("Name"), wanppp_idx, wan_ip_of(entry=wan)) + + if args.dry_run: + logger.info("Dry run, not disconnecting") + return + + logger.info("Disconnecting PPPoE session...") + session.wan_action(wanppp_idx=wanppp_idx, action="Disconnect") + + time.sleep(args.wait_after_disconnect) + + logger.info("Reconnecting PPPoE session...") + session.wan_action(wanppp_idx=wanppp_idx, action="Connect") + + entry = ensure_reconnected(session=session, wanppp_idx=wanppp_idx, max_wait=args.max_wait) + logger.info("Reconnected. WAN IP: %s", wan_ip_of(entry=entry)) + + if public_ip_before: + public_ip_after = fetch_public_ip(timeout=args.timeout) + if public_ip_after: + logger.info("Public IP after: %s", public_ip_after) + if public_ip_after == public_ip_before: + logger.warning("Public IP did not change (ISP returned the same CGNAT address)") + else: + logger.info("Public IP changed: %s -> %s", public_ip_before, public_ip_after) + + +if __name__ == "__main__": + main()