A lovely box wrapped around one frustrating limitation

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.

Knock on every port first

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.

The app is just JavaScript, and JavaScript can be read

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…
One regex, zero requests

A single regular expression mapped the entire API — 126 endpoints — without making one call to the switch.

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.

Getting in

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.

How the API talks — and the trap in it

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:

EndpointVerbBody shape
/vlanPOST · PUT{“data”:[{“key”,“val”:[…]}]}
/vlanDELETE{“idx”:[“100”]}
/portlistPUT[{“idx”,“data”:{…}}] ← not key/val!
/lacp/groupPOST{“idx”,“data”:{PortMembers,AggrMode}}
/system/savePUT{} — persists running→startup
/system/configPOSTmultipart form-data, field “conf”
The lesson

A 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.

What the data told me

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:

BehaviorWhat actually happens
Silent no-op writesThe wrong body shape returns 200 and changes nothing. Read the UI’s exact payload; always verify.
VLAN names don’t existSent, accepted, silently discarded. The GUI has no name field either. Labels have to live client-side.
MTU is hidden but realNot 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 VLANsCreating a link-aggregation group resets its member ports’ VLAN config. Bond first, then assign VLANs to the LAG interface.
Running vs. startupWrites take effect immediately but vanish on reboot until you PUT /system/save with an empty body.

Turning it into a tool

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
Guiding principle

The switch’s own web UI is the reference implementation. Whenever a body shape or a field value was unclear, the answer was never a guess — it was sitting right there in how the UI built that exact request.

If you want to do this to your own gear

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:

  1. Scan first to find the management surface and rule out a real CLI.
  2. Spot the SPA and pull its JavaScript bundles.
  3. Regex the bundle for the API base and the request pattern — that maps the whole surface for free.
  4. Lift the auth flow straight from the login code and reproduce it with curl.
  5. Confirm live: reads first, then careful writes on something you can afford to break — a link-down port, a throwaway VLAN — always reading back, always reverting.

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.