A Go client for MikroTik SwOS-managed switches (both the "Lite" and
"classic" protocol dialects), generated directly from each device's own
engine.js web-UI script — no hand-authored intermediate schema.
SwOS devices don't expose a documented HTTP API. Their web UI ships a
minified engine.js that itself contains a complete, authoritative
description of every field the device supports (labels, types, enums,
read-only flags, etc.). Rather than hand-transcribing that into a schema
(which drifts from reality and is error-prone), this repo executes the
device's actual engine.js in a Node vm sandbox and introspects its
live field registry to generate an OpenAPI 3.0 document, which is then fed
to oapi-codegen to produce
a readable Go client.
Two supported protocol dialects, each a separate generator because closure-compiler minification assigns arbitrary, per-build different identifiers to the same concepts. Within a dialect, each BOARD also gets its own generated openapi.json and Go client package (kept fully separate, never overwriting one another) since it isn't yet confirmed that the schema/minified identifiers are identical across boards of the same dialect:
| Dialect | Board | Generator | Client package |
|---|---|---|---|
| Lite | CSS610PI | boards/css610pi/engine-to-openapi-css610pi.js |
boards/css610pi |
| Lite | CSS610G | boards/css610g/engine-to-openapi-css610g.js |
boards/css610g |
| Classic | CSS106P (also covers RB260GS) | boards/css106p/engine-to-openapi-css106p.js |
boards/css106p |
| Classic | CSS318G | boards/css318g/engine-to-openapi-css318g.js |
boards/css318g |
| Classic | CSS328P | boards/css328p/engine-to-openapi-css328p.js |
boards/css328p |
| Classic | CSS354 | boards/css354/engine-to-openapi-css354.js |
boards/css354 |
CSS318G and CSS328P ship a genuinely different closure-compiler build than
CSS106P (different minified identifiers and object property keys
throughout, though confirmed identical to EACH OTHER except one
type-constructor letter), so each gets its own dedicated generator
(engine-to-openapi-css318g.js / engine-to-openapi-css328p.js)
rather than reusing engine-to-openapi-css106p.js - see each
script's header comment for the full rationale and the specific caveats
(each board's fixed port layout is hardcoded since it's normally
hardware-detected at runtime; fields/pages gated on PoE/fan/
temperature-sensor capability come from each board's own engine.js
build).
CSS354 looks like the same "CSS3xx family" structurally (same page
registry shape, same V()/W()/Tb()/Ub() entrypoints, same stable
field-object-literal keys) but turned out to be a genuinely DIFFERENT
closure-compiler build than CSS318G/CSS328P: nearly every single-letter
identifier is shifted by one further down the alphabet (verified by
direct inspection), so it gets its own dedicated generator,
engine-to-openapi-css354.js, rather than reusing
engine-to-openapi-css318g.js / engine-to-openapi-css328p.js
- see that script's header comment for the full letter-mapping. CSS354's port layout (28 total, 24 SFP+) is inferred from its known hardware spec (CSS354-4G-24S+2Q+RM: 4x GbE combo + 24x SFP+ + 2x QSFP+ uplinks), not independently confirmed against a live device.
CSS610G looked like it might share CSS610PI's exact build (both Lite,
both expose the same wire ids, e.g. i01/i0a/...) but turned out to be
a genuinely different closure-compiler build too: it inserts one extra
type-constructor function (used only by a "Cable Pairs" field CSS610PI's
build doesn't have at all), which shifts several subsequent single-letter
type/property identifiers - confirmed by direct diff of the two
engine.js files. It gets its own dedicated generator,
boards/css610g/engine-to-openapi-css610g.js, rather than reusing
boards/css610pi/engine-to-openapi-css610pi.js - see that script's header comment for the full
letter-mapping. Since the wire protocol itself (ids, endpoints) is
identical to CSS610PI, its Go-name override table is reused as-is.
boards/<board>/— one directory per board, each containing the capturedengine.jsfile(s) (per firmware version) used as ground truth input to that board's dedicatedengine-to-openapi*.jsgenerator, plus the generated output - the OpenAPI 3.0 document (swos-<board>.openapi.json) and the Go client (swos_client.gen.go, package name = the board slug). Do not hand-edit the generated files; re-runboards/generate-clients.shinstead. See "Extracting engine.js" below for how theengine.jsfiles are (re)produced.boards/generate-clients.sh— single script that regenerates every board's OpenAPI document and Go client in one run. See "Regenerating the clients" below.boards/extract-engine-from-firmware.sh— a single script that extracts any board'sengine.jsfrom its firmware.binimage (the board name is derived from the firmware filename itself).boards/build/— shared build tooling used by the generators and scripts above, kept out of any single board's folder: extraction helpers used byboards/extract-engine-from-firmware.sh(find-gzip-offsets.sh,gunzip-at-offset.sh,extract-inline-script.js), the sharedwire-kinds-go.jshelper used by every board'sengine-to-openapi*.jsgenerator to emit that board'swire_kinds_*.gen.gofile, andoapi-codegen-config.yaml, the single shared oapi-codegen config used to generate every board's client (both dialects). The generated per-board OpenAPI documents themselves live inboards/<board>/.boards/common/digestauth/— a minimal HTTP Digest Authentication (RFC 2617/7616)http.RoundTripper, since SwOS devices require Digest auth.boards/common/swoswire/— anhttp.RoundTripper(Transport) that bridges SwOS's non-standard wire format and the generated JSON-based client:- GET responses aren't JSON: the device returns
Content-Type: application/x-javascriptwith a JS object-literal body (unquoted keys, single-quoted strings, hex-literal numbers).Transportrewrites this into standard JSON before the generated client parses it. - Certain fields (MAC addresses, hex-encoded ASCII strings, packed IPv4
addresses) are further decoded into human-usable Go values (e.g.
"AA:BB:CC:DD:EE:FF"instead of a raw hex blob), driven by a generated per-boardKindsmap living alongside that board's client package (e.g.boards/css610pi/wire_kinds_css610pi.gen.goexposescss610pi.Css610piWireKinds). - Writes are symmetric: outgoing JSON request bodies (from generated
Put*client calls) are re-encoded back into the device's proprietarytext/plainwire format.
- GET responses aren't JSON: the device returns
boards/<board>/test/main.go— a standalone smoke-test program per board that connects to a real device and dumps every endpoint's data. See "Testing against a real device" below.boards/common/apitest/— a small reflection-based helper (apitest.DumpAll) shared by everyboards/<board>/test/main.go, which calls everyGet*WithResponsemethod the board's generated client exposes and prints its status code and JSON body, without hand-listing each board's endpoints.
httpClient := &http.Client{
Transport: &swoswire.Transport{
Base: &digestauth.Transport{
Username: "admin",
Password: "qw",
},
Kinds: css610pi.Css610piWireKinds,
},
}
client, err := css610pi.NewClientWithResponses("http://192.168.40.22", css610pi.WithHTTPClient(httpClient))
if err != nil {
panic(err)
}
sys, err := client.GetSysWithResponse(context.Background())
if err != nil {
panic(err)
}
fmt.Println("Identity:", *sys.JSON200.Identity)
// Writes are full-page replaces (the device has no partial-update
// semantics) - start from a GET result and mutate it.
newIdentity := "My Switch"
sys.JSON200.Identity = &newIdentity
if _, err := client.PutSysWithResponse(context.Background(), *sys.JSON200); err != nil {
panic(err)
}See boards/css610pi/test/main.go for a runnable version.
Each board has its own standalone smoke-test program at
boards/<board>/test/main.go, which connects to a real device and dumps
every endpoint's data (via boards/common/apitest's reflection-based helper, so
it stays in sync automatically as a board's schema changes):
go run ./boards/css610pi/test -host 192.168.88.1 -username admin -password ''All three flags default as shown above (admin/blank password is SwOS's
factory default). Substitute the board's directory (css610g, css106p,
css318g, css328p, css354) to test a different board.
A single script regenerates every board's OpenAPI document and Go client:
boards/generate-clients.shSet CSS106P_DEVICE_BOARD=RB260GS (defaults to "CSS106-1G-4P-1S", the
PoE variant) to switch the CSS106P board variant passed into the sandbox.
For each board, the script parses that board's engine.js directly into
an OpenAPI document (boards/<board>/*.openapi.json), emits a
boards/<board>/wire_kinds_*.gen.go map of fields needing semantic
decoding, and runs oapi-codegen; go mod tidy runs once at the end.
Requires node (to execute engine.js in a sandbox) and go (oapi-codegen
itself is fetched on demand via go run).
A single script extracts any board's engine.js from its firmware
.bin image - the board name is derived automatically from the
firmware filename (swos-<slug>-<version>.bin, with a couple of known
naming exceptions handled internally, e.g. css106 -> CSS106P):
boards/extract-engine-from-firmware.sh swos-css106-2.18.bin # -> boards/css106p/2.18-CSS106P.js
boards/extract-engine-from-firmware.sh swos-css318g-2.18.bin # -> boards/css318g/2.18-CSS318G.js
boards/extract-engine-from-firmware.sh swos-css328p-2.18.bin # -> boards/css328p/2.18-CSS328P.js
boards/extract-engine-from-firmware.sh swos-css354-2.17.bin # -> boards/css354/2.17-CSS354.js
boards/extract-engine-from-firmware.sh swos-css610g-2.21.bin # -> boards/css610g/2.21-CSS610G.js
boards/extract-engine-from-firmware.sh swos-css610pi-2.21.bin # -> boards/css610pi/2.21-CSS610PI.jsOutput path is always boards/<board, lowercased>/<version>-<BOARD>.js,
with <version> parsed from the firmware filename's trailing X.Y.
binwalk locates the offset of the gzip blob(s) embedded in the
firmware .bin, and the last one found is decompressed. Most firmwares
wrap engine.js in a full index.html with a single inline
<script>...</script> tag, which is unwrapped automatically; some (e.g.
CSS610G, CSS610PI) embed engine.js directly as their own gzip member
with no HTML wrapper at all - the script detects this and skips the
unwrap step accordingly.
Note that a newly extracted engine.js is not automatically
generator-ready - see the caveat below.
- Each generator's sandbox driver hardcodes exact minified identifier
names (entrypoint function, type-constructor function letters, etc.)
from ONE specific
engine.jsbuild. A different closure-compiler build - even for the same board family/dialect - reassigns those single/double-letter names, so a newly capturedengine.jsis not guaranteed to work with either generator out of the box; it may need the hardcoded names re-derived for that build (or, for some newer firmware, the sandbox may need to emulate live device XHR responses used for runtime hardware-capability detection, which neither generator currently supports). Always run the generator against a newengine.jsand check for errors before assuming it's supported - don't assume "it's inboards/" implies "the generator understands it". - Property names in the generated structs are the device's own wire keys
(
i01,i02, ...); each has anx-go-namegiving it a readable Go field name, but the JSON tag itself stays as the wire key. - Only tested against the specific firmware/board combinations captured
under
boards/. A different firmware version could map fields differently - always regenerate against your own device'sengine.jsif in doubt. - Some
boards/*/*.jsfiles were extracted from firmware images without access to the physical device, so their exact board name (as reported by the device itself) isn't independently confirmed - the filename reflects either a literal internal fallback string found in the JS (e.g.CSS318G/CSS328P, fromn="css318g"/n="css328p"fallback assignments) or the firmware image's own filename suffix (CSS354,CSS610G- these boards report their name via runtime hardware detection with no matching string literal in the JS itself).