~/potatohd.orgRU
← All posts
Aug 13, 2026·8 min read

An SMS gateway on a router: sending codes without a carrier contract

Goreverse engineeringOTPsystem design

Any product with phone login needs verification codes, and a contract with an SMS aggregator means a legal entity, an approved sender name and a minimum spend. Meanwhile a TP-Link Deco X20-4G sits on the desk with an ordinary SIM in it, and its app can send SMS. The hardware is already there; what's missing is an API.

There isn't a documented one. The popular reverse-engineered TP-Link router projects implement SMS for the older MR models, not for Deco. Here's what that turned into.

#One way in, several ways out

Start with the result, because the shape of the gateway is dictated entirely by what turned up along the way.

Diagram: Novu, ZITADEL and my own services send SMS through one gateway that tries the router first, then the MTS Exolve carrier API, then SMS.ru
Three callers, one send path, and as many backends underneath it as needed

Three callers: the Novu notification centre through its built-in SMS webhook, ZITADEL as the SMS provider for OTP, and other services directly. All three get one HTTP endpoint and one internal contract; how the message actually leaves is none of their business.

Under that endpoint sits a chain of backends. The primary is tried first; if it fails, or is already marked down from an earlier failure, the send goes out through the fallback instead. The caller sees a success rather than an outage. One detail there saves real money: while the primary is marked down a background probe keeps trying to bring it back, and for the carrier API that probe is a free read-only call listing sender names — not a test SMS. Which backend actually delivered each message is recorded in the ClickHouse history, because a month later "are we even still sending through the router?" is otherwise unanswerable.

#Logging into the router: the crypto is solvable, the controller isn't

The Deco's local web interface speaks luci — requests shaped like /cgi-bin/luci/;stok=<stok>/admin/... — and modern firmware encrypts the login itself, RSA over AES. That part turned out to be tractable: the logic is cross-checked against two independent reverse-engineered implementations, and once logged in the session happily reads things like WAN and LTE status.

Then it gets interesting. Nothing documents where SMS sending actually lives. The answer came out of the extracted firmware — the exact build running on the live device: a controller at admin/mobile_app/sms, with forms send_sms, read_sms_list, get_unread_count, parameters number and content (the text base64-encoded), backed by SQLite on the router itself.

Diagram: the web admin session logs in and reads ordinary luci forms but gets a 404 on admin/mobile_app/sms, while the same controller answers the Deco app's session
The address is known exactly and still closed: the controller serves a different session than the one knocking

And here's the instructive part. The whole admin/mobile_app/* tree returns 404 to the web-admin session. Not 401, not 403: as far as that session is concerned the controller does not exist — while it sits in the firmware and works. It is served only to the Deco app's context.

That 404 is a debugging trap. It reads as "you guessed the path wrong", and it's easy to lose a day permuting form names. The path was right on the first try; the session was wrong.

Hence a second route: doing the same thing through TP-Link's cloud — the protocol the app itself uses (REST plus a binary tunnel), recovered by capturing the app's traffic. It works and sends real messages. The price is a short-lived access token: its expiry once took delivery down silently, so the gateway now renews it on its own, and when it can't, it says so by name instead of surfacing a bare 502.

#Why production still uses the carrier API for OTP

Sense beats sport here. The router is consumer hardware on a home connection: it reboots, it updates firmware, it drops the link. For a verification code someone is waiting on right now, that's a poor primary. So OTP in production goes through the carrier API (MTS Exolve), while the router and the cloud path stay as working alternatives and fallbacks.

A small trap from the same corner, which cost some time: in the Exolve request body the number field is the sender, and the recipient is destination. Reading it as "the number we're sending to" is natural and wrong.

#The bug that stopped every code

The most expensive breakage wasn't in the reverse engineering. It was in my own API schema.

The gateway takes one request body in two dialects: my services send {to, text}, Novu sends {to, content}. That worked fine while the body was decoded by hand. Moving to generated schemas, fields without omitempty became required — so the validator started demanding text and content in the same request.

The result: every phone login attempt got a 422 before the handler ran at all. In the app it looked like "couldn't send the code", while the gateway itself was perfectly healthy — /healthz green, backends alive, and not one send error in the logs, because no send was attempted. The breaking change didn't arrive with a feature; it arrived with a migration to a different way of describing the same body.

The fix is small: omitempty on both fields, with "at least one of the two" enforced in code. A schema can say "this field is required"; it cannot say "one of these two" — and expressing the second as the first quietly breaks the contract.

#Only the panel faces the world

Diagram: Traefik publishes only the /admin path, while /v1/sms/* is reachable exclusively from the internal network, where Novu and ZITADEL call it by container name
The send endpoint has no public route at all — not "protected by a token", but absent from the outside

A publicly reachable send-SMS endpoint is someone else's bill and this SIM's reputation. So only the admin panel is published: /v1/* simply has no route in Traefik. Novu and ZITADEL reach the gateway by container name over shared docker networks. The token and the signature are still there — but the first line of defence is a missing route, not a header check.

On top of that sit the ordinary guards against my own mistakes: a minimum interval between messages to the same number, and a daily cap on total sends. A loop in someone else's code shouldn't turn into a bill from a carrier.

#What to take from this

  • A 404 doesn't always mean "wrong path". Sometimes it means "wrong session" — a different diagnosis with a different cost.
  • If the primary path is consumer hardware, the fallback is needed on day one, and checking whether the primary recovered shouldn't cost money per attempt.
  • A schema generator describes fields, not relationships between them. Anything that sounds like "one of" belongs in code.
  • The most reliable way not to expose a dangerous endpoint is to never give it a route.

// available for hire

Need a website or setup, done right?