My home fibre drops. Not constantly — but regularly enough that I’ve developed a habit of opening the router admin page when Netflix stalls, just to confirm what I already suspect.
The TP-Link ER605 I use is a decent small-business router: dual WAN, PPPoE on WAN1 for fibre, DHCP on WAN2 for a mobile backup. When the fibre goes down the router fails over silently. The backup connection works. Everything sort of continues — browsing is slower, calls drop, but nothing catastrophically fails.
The problem is I don’t know it happened until much later, and I can’t easily tell how long it was down for. The only real evidence is a log line buried in the router’s syslog:
2 2026-02-23 12:35:14 PPPoE Client WARNING [7C-F1-7E-74-E5-52]: PPPoE <account>@isp.co failed
to connect to the server because sending PADI times out.
I wanted to fix this. I wanted an email the moment the fibre dropped, hourly reminders while it was still down, and a “connection restored” email with total downtime included. Could I write a script that polled the router?
This is the story of using Claude Code to figure out how — starting from nothing and ending, 35 minutes later, with working code.
There is no API
The ER605 is part of TP-Link’s Omada ecosystem — the same lineup as the EAP access points and Omada SDN Controller software. If you run an Omada Controller, you get a proper REST API and alerting options. I’m not running a controller. The ER605 sits on its own in standalone mode, and in standalone mode you get a web UI and nothing else.
TP-Link publishes no API documentation for the standalone web UI. There is a Python library on PyPI — tplinkrouterc6u — that claims to support TP-Link routers. We tried it first.
First attempt: just use the library
Claude Code installed tplinkrouterc6u and wrote a quick test:
from tplinkrouterc6u import TplinkRouterProvider
router = TplinkRouterProvider.get_client('https://192.168.1.1', 'admin', '<password>')
router.authorize()
The traceback:
ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed:
EE certificate key too weak (_ssl.c:1000)
The phrase “key too weak” is the interesting part. A normal self-signed cert failure — expired cert, wrong hostname, untrusted CA — produces “self-signed certificate.” You can bypass that with verify=False. “Key too weak” is different: modern OpenSSL, at its default security level 2
(standard in Ubuntu 22.04+), refuses to complete an SSL handshake with any endpoint using less than a 2048-bit RSA key. The ER605’s self-signed cert uses a 1024-bit key. verify=False won’t help — the handshake won’t even start.
Claude’s read at the time: “The SSL issue is that the cert key is ’too weak’ — not just untrusted. Setting verify=False won’t fix this; we need to lower the cipher security level.”
And even past SSL, the library was a dead end. tplinkrouterc6u targets consumer TP-Link routers (Archer, Deco series) whose auth flow is completely different from the ER605’s. So we had two problems: fix SSL, then figure out the auth from scratch.
Breaking through the SSL wall
The fix is a custom HTTPAdapter for requests that builds its own SSL context with SECLEVEL=0, dropping the key strength requirement entirely:
class WeakSSLAdapter(HTTPAdapter):
"""Router uses a 1024-bit RSA cert which modern Python rejects."""
def init_poolmanager(self, *args, **kwargs):
ctx = create_urllib3_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
ctx.set_ciphers("DEFAULT:@SECLEVEL=0")
kwargs["ssl_context"] = ctx
return super().init_poolmanager(*args, **kwargs)
Mount this adapter on a requests.Session and Python will talk to 1024-bit cert endpoints again. It’s not great for general internet traffic, but for a local LAN connection to a known device it’s fine.
Probing the web UI
With SSL working, the next step was understanding the auth flow. I suggested Claude try the router’s login page directly: https://192.168.1.1/webpages/login.html. The HTML loaded. The
JS bundles it referenced — /webpages/js/su/data/proxy.js and others — came back as:
<h1>Not Found</h1>
Strange. The HTML itself loaded fine, but its static dependencies 404’d. The fix turned out to be a User-Agent header. The router’s embedded web server serves static files to browsers but 404s anything that doesn’t look like one. Adding User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit\537.36 to all requests unlocked the JS files immediately. It’s presumably some kind of anti-scraping heuristic baked into the Luci config, though it’s a flimsy one.
Once the JS was accessible, Claude downloaded password.js to a temp file and started reading.
The password widget reveals the auth scheme
Inside password.js, which handles the login form’s password field:
if ($.type(encrypt) == "function" && check){
value = encrypt(value + (obj.withTimestamp ? '_' + $.su.locale.uptime : ''), param);
};
Two discoveries in eight lines of code:
First: the password isn’t sent in plaintext. It’s passed to $.su.encrypt with a parameter called param — which, looking higher up in the file, was ["n", "e"]. RSA encryption using public key components fetched from the server.
Second: when withTimestamp is set, the plaintext being encrypted isn’t just the password — it’s password_<uptime>. The uptime value comes from $.su.locale.uptime. So somewhere there must be a /locale endpoint that returns the router’s current uptime, and the encrypted payload changes every second because the uptime changes every second. A replay attack won’t work.
Finding the uptime endpoint once we knew to look for it: POST /cgi-bin/luci/;stok=/locale?form=lang.
Which uncovered the next complication.
Two request formats, no documentation
Almost every endpoint in the ER605’s API takes a data field containing a JSON blob:
resp = session.post(url, data={"data": json.dumps({"method": "get", "params": {...}})})
The locale endpoint is the exception. It takes raw form-encoded data:
resp = session.post(url,
data="operation=read",
headers={"Content-Type": "application/x-www-form-urlencoded"})
We hit this because JSON-formatted requests to the locale endpoint returned errors. Switching to raw form data worked. The two formats ended up as separate helpers in the client class, _post_json and _post_form, called where appropriate:
def _post_json(self, path, data):
"""POST with JSON-wrapped data payload (most endpoints)."""
resp = self.session.post(self._url(path), data={"data": json.dumps(data)})
resp.raise_for_status()
return resp.json()
def _post_form(self, path, data):
"""POST with form-encoded data (locale endpoint)."""
resp = self.session.post(
self._url(path),
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
resp.raise_for_status()
return resp.json()
The RSA encryption: no padding
The next find was in encrypt.js. When you see “RSA encryption,” the default assumption is PKCS#1 v1.5 padding — the format Python’s pycryptodome implements in Crypto.Cipher.PKCS1_v1_5. The ER605 doesn’t use it.
The router’s encrypt.js implements a custom nopadding() function. It UTF-8 encodes the plaintext, zero-pads the byte array to the key length, converts that to a big integer, and runs raw modular exponentiation: c = pow(m, e, n). No randomness, no padding scheme. Just textbook
RSA applied directly to the zero-padded plaintext.
Using PKCS1_v1_5 would produce a ciphertext the router would silently reject as a wrong password, with no indication that the crypto format was the problem. Instead, Claude replicated the custom scheme directly in Python:
def _rsa_encrypt(self, plaintext, n_hex, e_hex):
"""RSA encrypt with no-padding (matches router's encrypt.js)."""
n = int(n_hex, 16)
e = int(e_hex, 16)
key_len = (n.bit_length() + 7) >> 3
# UTF-8 encode then zero-pad to key length
ba = []
for ch in plaintext:
c = ord(ch)
if c < 128:
ba.append(c)
elif c < 2048:
ba.append((c & 63) | 128)
ba.append((c >> 6) | 192)
else:
ba.append((c & 63) | 128)
ba.append(((c >> 6) & 63) | 128)
ba.append((c >> 12) | 224)
while len(ba) < key_len:
ba.append(0)
m = int.from_bytes(ba, byteorder="big")
c = pow(m, e, n)
return format(c, "x").zfill(256)
It’s not much code. The entirety of the crypto lives in pow(m, e, n). Why would a router vendor write a custom no-padding RSA scheme? Most likely: someone needed to encrypt a short string in a browser context without adding a crypto dependency, saw that textbook RSA could be written in a dozen lines of JavaScript, and never thought about the consequences of deterministic encryption. The 1024-bit key is probably the same decision — small enough to generate in the
browser, before anyone was paying attention to key sizes.
Incidentally, pycryptodome shows up in the project’s dependencies (as a transitive requirement from tplinkrouterc6u) but is never used in our code. We didn’t need it.
Finding the status endpoint
With a working login that returned a session token (stok), the next problem was finding an endpoint that returned WAN interface data.
The obvious guesses produced a consistent response:
{"id": 1, "error_code": "1014"}
1014 is the router’s “no such endpoint” code. We tried variations: /admin/wan?form=status, /admin/interface?form=status, /admin/network?form=wan_status — all returned 1014.
The solution was to look at the web UI pages that display WAN status in a browser. Each page under /webpages/pages/userrpm/ contains inline JavaScript with calls shaped like $.su.url("/admin/...") that reveal the actual API paths the page uses. Fetching interface_wan.html with a browser User-Agent and grepping for those calls produced:
/admin/interface?form=status2
Note the trailing 2. There’s presumably an older v1 endpoint the firmware kept around for compatibility, and this is the current one. The generalizable lesson here: when reversing a web-app-style admin UI, the endpoint you want is almost always referenced in the page that
displays the data. Read the source of that page before guessing endpoint names.
The login payload: a Lua error helps
One more footgun before everything worked together. The login POST body must be structured as:
{
"method": "login",
"type": "default",
"params": {
"username": "admin",
"password": "<encrypted>"
}
}
The params wrapper around username and password is not optional. Send those fields at the top level and the router returns:
attempt to index field 'params' (a nil value)
That’s a raw Lua traceback leaking through the JSON API. The router’s backend is OpenWrt’s Luci framework with Lua handler scripts, and the handler was doing request.params.username without a nil check. Our malformed JSON exposed the internals. In hindsight this is a useful diagnostic: a Lua error in a JSON response tells you exactly what you’re talking to, and roughly how the request parsing works on the other side.
The first successful run
After about 35 minutes of work — most of it reading JavaScript files — Claude ran the first real query. The output:
Interface Type Status IP Address Gateway
----------------------------------------------------------------------
WAN (WAN1) pppoe DOWN - -
WAN/LAN1 (WAN2) dhcp UP 192.168.2.101 192.168.2.1
LAN (LAN1) static UP 192.168.1.1 -
The PPPoE connection was actually down at the moment we ran the script for the first time. The monitor worked, and it confirmed the original complaint, in the same instant. The fibre had been dropping. The script could see it.
Building the monitor
The actual monitoring logic is straightforward once you can query the router. monitor.py runs in a loop, polling every five minutes:
- Login to the router, query
/admin/interface?form=status2, logout. - Write each interface’s status to a daily CSV file (
logs/YYYY-MM-DD.csv). - Track PPPoE state transitions: send an immediate email when it goes DOWN, hourly reminders while it stays down, and a “restored” email when it comes back — including how long it was out.
- Persist state to
state.jsonso the script survives a restart mid-outage.
The state persistence led to one useful addition: a recover_state_from_logs() function that scans the CSV history to reconstruct the last known PPPoE state if state.json is missing. If
the process is restarted and we have no state, we need to know whether the current “DOWN” just started or has been going on for six hours.
The script runs as a systemd user service. One non-obvious detail: the service needs PYTHONUNBUFFERED=1 in its environment. Without it, Python’s output buffering swallows print() calls and nothing appears in journalctl. The symptom — an empty log when the service is
clearly running — is confusing until you know what to look for.
Email goes through Gmail SMTP with an app password. The down and reminder emails include the ISP’s support contact details, because that’s the information you actually need during an outage and you want it in the same notification.
What I learned doing this with Claude Code
A few things about this project worth generalizing:
I contributed two hints. The reverse engineering required two pieces of information from me: the URL to start from (https://192.168.1.1/webpages/login.html), and the router password. Everything else — the SSL issue and its fix, the User-Agent requirement, the password.js discovery, the timestamp scheme, the RSA no-padding, the two request formats, the params wrapper, the correct endpoint — Claude figured out by reading the device’s own code. I was more navigator than pilot.
LLMs are surprisingly good at “read this and tell me what it does.” The bottleneck in
reversing an admin API like this isn’t network probing — it’s understanding the auth flow. The ER605 made this tractable because it ships its crypto and form logic as readable browser JavaScript. Claude read password.js, recognised the RSA pattern, fetched encrypt.js, saw the custom no-padding scheme, and replicated it in Python without me having to understand the underlying math. This generalizes: any time your reverse engineering target is a web app that ships its JS unminified, an LLM can do a lot of the reading work.
Failures were diagnostic, not destructive. An LLM trying wrong endpoint names and getting 1014 errors isn’t wasted effort — it’s narrowing the search space. The Lua traceback from the malformed login payload told us exactly what to fix. The router couldn’t be broken from our probing; we just got error codes back. This is a better failure mode than, say, reversing a binary where a wrong guess means a segfault.
Document at the end of the session. At the end of the 35-minute session, Claude wrote a CLAUDE.md file in the project directory summarising all the API gotchas: the no-padding RSA, the params wrapper, the SECLEVEL issue, how to find new endpoints by reading the UI pages. Three months later, that file was the source of truth for this blog post. If you’re using Claude Code for exploration or reverse engineering, have it write down what it found before you close the session. The knowledge is perishable if it only lives in a terminal.
One honest limit: this worked because the router ships readable JavaScript. A device that compiled its admin UI to WebAssembly, or that handled auth in a native binary outside the browser, would be significantly harder. The TP-Link engineering team did us a favour by writing their crypto in plain JS.
Three months later
The script has been running on an Intel NUC on the same LAN since February. It’s produced 56 daily CSV logs and sent alerts for half a dozen outages — some brief, some long enough that the ISP contact details in the reminder email actually got used.
As I write this, state.json shows:
{
"pppoe_up": false,
"down_since": "2026-05-14T14:00:49",
"last_down_email": "2026-05-16T14:33:19"
}
The fibre has been down for two days. The monitor has been emailing me about it every hour. The ISP is presumably working on it.