A 25-gigabit QNAP switch, a sluggish web GUI, no SSH, and not a page of documentation. So I read the JavaScript, found the hidden REST API it was hiding behind, and wrote my own tool to drive it.
I have a QNAP QSW-M5216-1T — sixteen ports of 25-gigabit fiber plus a 10-gig copper uplink, sitting right at the center of my network. The hardware is excellent. The management story is not: the only supported way to configure it is a slow single-page web app. Add a VLAN? Click through the GUI. Tag a port? GUI. Every single time.
There is no SSH. No Telnet. No published API. QNAP's answer to "how do I automate this" is, essentially, you don't.
But here's the thing about a web UI: it is a program, and that program is talking to the switch over the network. If I could work out what it was saying, I could say the same things myself — from a script, a cron job, a config file checked into git. So I went looking.
Before anything clever, the boring question: what is this thing even listening on?
$ for p in 22 23 80 161 443; do nc -zv -w2 192.0.2.10 $p; done
22/tcp timed out # no SSH
23/tcp refused # no Telnet
80/tcp open # HTTP — the web UI
443/tcp open # HTTPS
161/udp open # SNMP (read-only; no VLAN writes)
No shell access of any kind, so this switch can't be driven like a Cisco or Arista box. SNMP is monitoring-oriented — great for graphs, useless for creating VLANs. That left exactly one real avenue: whatever the web UI on port 80 was doing. Which is what I was hoping for.
Fetching the root page returned a near-empty HTML shell — a single <div id="app">
and three webpack bundles. That's a single-page app: an empty container that
JavaScript fills in at runtime. And crucially, every backend call the UI makes lives inside
those bundles, in plain sight.
I downloaded app.js and vendor.js — minified, but not obfuscated.
Minification mangles variable names; it doesn't hide string literals. And a REST client is made
of string literals. Searching the bundle for URL-shaped strings turned up the base immediately:
/api/v1.
The endpoints themselves are built dynamically — the code uses one axios instance (minified to
Hi) and one base constant (Fi = "/api/v1"), so every call looks like
Hi.get(Fi + "/vlan"). That is a pattern. And a pattern is a regex.
# every backend call in the bundle has the same shape:
# Hi.get(Fi + "/vlan") Hi.post(Fi + "/lacp/group") ...
$ grep -oE 'Hi\.(get|post|put|delete)\(Fi\+"[^"]+"' app.js | sort -u
→ 126 endpoints # vlans, ports, lacp, acl, qos, rstp, poe, snmp, lldp…
Better still, the service functions were named things like getVlan,
setVlan, delVlan, and addLagStatus. Minification kept
those names. So the purpose of each endpoint was legible straight from the code, before I ever
sent a byte.
Reading the postLogin action gave the whole handshake. The UI POSTs a username and
a password to /api/v1/users/login — but the password is run through a function
called ezEncode first. That looked like it might be a custom cipher. It wasn't.
I pulled ezEncode's implementation out of the bundle and found it building output
from this alphabet: ABC…xyz0123456789+/. That's the standard Base64 alphabet.
ezEncode is just a hand-rolled Base64 encoder. The "encoding" is
base64(password) — no secret, no salt, no hash.
$ curl -s -X POST http://192.0.2.10/api/v1/users/login \
-H 'Content-Type: application/json' \
-d '{"username":"<user>","password":"'$(printf %s 'hunter2' | base64)'"}'
{"error_code":200,"error_message":"OK","result":"eyJhbGciOiJSUzI1NiI…"}
That result is a JWT. Decode its payload and the switch tells you
exactly who it thinks you are:
{"Ip":"192.0.2.20","Privilege":15,"UserName":"<user>","iat":1783229194}
From there, every other call carries Authorization: Bearer <jwt>. There's no
exp claim — the session is tracked server-side — so a stale token just starts
returning 401, at which point you log in again and retry. That single rule ("on a
401, re-authenticate once") is the entire session-management story.
Almost every response is wrapped the same way:
{"error_code":200,"error_message":"OK","result":…}. Collections come back as arrays
of {key, val}. Simple enough to read.
Writing is where it got interesting. Different endpoints expect different body
shapes, and — this is the part that cost me an hour — sending the wrong shape doesn't
error. It returns 200 OK with result: null and silently changes
nothing.
I hit this trying to set a port's MTU. My request looked right, the switch said OK, and the MTU
didn't budge. The fix was to stop guessing and read the exact object the UI builds for that
action — which turned out to be {idx, data}, not the {key, val} that
the matching GET returns. Here's the map I ended up with:
| Endpoint | Verb | Body shape |
|---|---|---|
| /vlan | POST · PUT | {"data":[{"key","val":[…]}]} |
| /vlan | DELETE | {"idx":["100"]} |
| /portlist | PUT | [{"idx","data":{…}}] ← not key/val! |
| /lacp/group | POST | {"idx","data":{PortMembers,AggrMode}} |
| /system/save | PUT | {} — persists running→startup |
| /system/config | POST | multipart form-data, field "conf" |
200 is not proof a write took effect. On an undocumented API, always read the
value back — the switch will happily accept a request it has no intention of honoring.
With reads and writes both working, the switch's actual model came into focus — including several things the web UI never shows you. VLANs, for instance, are a pure 802.1Q membership table: a list of ports, each tagged or untagged. There is no port "mode," no PVID… and no place to put a name. I tried anyway:
$ # create VLAN 3990 and ask it to remember a name…
$ curl … -d '{"data":[{"key":"3990","name":"STORAGE","val":[…]}]}'
{"error_code":200,"error_message":"OK"}
$ # …now read it back:
{"key":"3990","val":[{"Port":"16","Tagged":true}]} # the name is gone.
So the firmware simply has no concept of VLAN names — which is why my tool stores them locally and paints them back on. That was one of six behaviors that only surfaced through live testing. Every one of them is now baked into the tool as code, so nobody has to rediscover it:
| Behavior | What actually happens |
|---|---|
| Silent no-op writes | The wrong body shape returns 200 and changes nothing. Read the UI's exact payload; always verify. |
| VLAN names don't exist | Sent, accepted, silently discarded. The GUI has no name field either. Labels have to live client-side. |
| MTU is hidden but real | Not in the GUI at all, yet fully settable per interface. Empirically the switch clamps it at 10000; default is 9016. |
| There is no "trunk mode" | No PVID, no port mode — a trunk is literally "tagged member of every VLAN." A VLAN made later won't include a port until you re-add it. |
| Bonding wipes VLANs | Creating a link-aggregation group resets its member ports' VLAN config. Bond first, then assign VLANs to the LAG interface. |
| Running vs. startup | Writes take effect immediately but vanish on reboot until you PUT /system/save with an empty body. |
Knowing the API is one thing; living with it is another. I wrapped everything in a single
Python file, qsw.py, in two layers. Underneath sits a client class that handles the
unglamorous parts — Base64 login, JWT caching, unwrapping the response envelope, re-authenticating
on a 401, and knowing each endpoint's quirky body shape. On top sits a CLI that speaks in terms a
network admin actually thinks in.
That second layer is where the real value is. The switch models VLANs as per-VLAN membership tables; I want to say "make port 17 a trunk" or "reconcile the whole switch to this file." So the tool translates intent into the switch's model — and does it idempotently, with a dry-run and an automatic backup before anything is written.
$ qsw vlans # the whole VLAN table, with labels
$ qsw trunk 17 --native mgmt # tag port 17 on every VLAN
$ qsw find a89c6c # which port is this host on?
A8:9C:6C:88:10:2D -> port 11, VLAN 30
$ qsw apply switch.yaml --dry-run # reconcile from a declarative file
Nothing here is specific to QNAP. Any appliance with a modern web UI and no official API is a candidate, and the workflow is the same every time:
curl.Every write in this project was tested on unused ports and disposable VLAN IDs and then reverted; the switch went back to its exact original state after each experiment. The reward for a couple of evenings of reading minified JavaScript is a switch I now manage from a text file instead of a spinner — and a small map of an API that officially doesn't exist.