Files
playground/router_refresh_ip.py
T
abdus a443eeb042 feat(router): Add router_refresh_ip.py to get a new CGNAT public IP
Standalone script that drops and re-establishes the router's PPPoE session
without a full reboot. Mirrors router_ui.py's RouterSession pattern: a
context manager that always logs out on exit, the CSRFToken header required
for writes since ABSB.5, and tolerant handling of the Disconnect/Connect
calls timing out with a Connect retry if the link doesn't come back up.

Confirmed working live: WAN IP and public IP (checked via api.ipify.org)
both changed after a run.
2026-10-19 00:33:45 +03:00

224 lines
9.6 KiB
Python
Executable File

#!/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()