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.
224 lines
9.6 KiB
Python
Executable File
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()
|