HydraIssues

Add a GL.iNet backend to hydraneck and decommission the Omada backend
closed feature Project: hydraneck Reporter: cederik 19 Aug 2026 20:11

Description

Goal

Add a new router backend glinet to hydraneck. Give it the same first-class support the MikroTik backend has (issue #115 is the pattern). Then remove the TP-Link Omada backend from the codebase and from the live configuration.

Background

Hydraneck talks to venue routers through the router.Router abstraction in pkg/router/router.go. Backends are selected by a type switch in internal/cli/config.go:buildRouter() (line 300). Necks have three tiers: partner, prosumer, and owned (RouterConfig.Tier).

What changes:

  • The prosumer tier moves from TP-Link Omada gear to GL.iNet gear. We bought a GL.iNet Slate 7 Pro travel router as the first device.
  • The Omada backend (pkg/omada/) becomes dead code. AD6 is the only Omada neck (see docs/runbooks/neck-lifecycle.md). Note: issue #125 (AD6 pure-consumer) was closed as obsolete on 2026-04-29 with the decision that AD6 stays prosumer on Omada. This issue reverses that decision: the prosumer tier now standardizes on GL.iNet.
  • MikroTik stays the partner tier backend. Follow its structure (pkg/mikrotik/rest/) for the new client.

Device facts

  • Model: GL.iNet Slate 7 Pro = GL-BE10000. Tri-band Wi-Fi 7, 2x 2.5GbE, WireGuard throughput up to 1100 Mbps.
  • Firmware: GL.iNet 4.x on OpenWrt 21.02.
  • API: one JSON-RPC 2.0 endpoint at POST http(s)://<address>/rpc. The stock web UI is a client of this API. It is on by default on the LAN side. It is not exposed on the WAN side.
  • Auth: challenge-response. Call challenge, compute a unix crypt(3) hash of the password with the returned alg and salt (alg 1 = md5-crypt, 5 = sha256-crypt, 6 = sha512-crypt), then hash username:crypt_hash:nonce (md5 hex by default, firmware 4.8+ can return a hash-method). Call login to get a sid. Send the sid inside the params of each call, not in a header. Keep the session alive with the alive method. Module names use hyphens on the wire (wg-client, wg-server).
  • Useful modules: clients (get_list returns mac, ip, name, online, rx/tx bytes), network (get_dhcp_leases), system (get_status, get_info), wg-server (get_peer_list, add_peer, remove_peer, get_config, set_config, start/stop), wg-client, vpn_policy, logread.
  • Documentation caveat: dev.gl-inet.com is offline. Use the archived API description from python-glinet as the schema reference: https://raw.githubusercontent.com/tomtana/python-glinet/main/pyglinet/api/api_description.json (43 modules with request and response examples).
  • TLS: the default https certificate is self-signed. The client needs an insecure-TLS option or plain http on the LAN.
  • Fallback surface: dropbear SSH on port 22 with root and the admin password. Use it only when the JSON-RPC API lacks a method.
  • Crypto dependency: a Go crypt(3) library such as github.com/GehirnInc/crypt covers md5-crypt, sha256-crypt, and sha512-crypt.

Phase A: GL.iNet backend

  • Create pkg/glinet/client.go. Use package glinet. Add a Config struct with Name, Address, Username, Password. Make New(cfg) do no network I/O. The constructor runs at startup for every venue.
  • Implement a thin JSON-RPC layer: challenge, login, alive, logout, and call. Fetch a fresh challenge immediately before each login, because nonces expire. Map error -32000 to an auth error and -32601 to a missing method.
  • Implement router.Router: Name(), Type() returns "glinet", Ping() (login as the cheap authenticated check), Clients() from the clients get_list module mapped to router.Client{MAC, IP, Name, Online}.
  • Implement router.Versioner: Version() from system get_info.
  • Implement router.BandwidthMonitor: Bandwidth() returns *router.BandwidthResult{TxBps, RxBps} in bits per second. Return (nil, nil) when the data is not available. Note: the scanner prefers hydraguard WireGuard deltas and uses this only as a fallback (pkg/scanner/scanner.go:193).
  • Implement router.WireGuardManager on top of the wg-server module: CreateInterface, AddPeer, RemovePeer, ListPeers. Keep the MikroTik idempotency semantics from pkg/mikrotik/rest/wireguard.go: AddPeer upserts by public key, RemovePeer returns an error when the peer is absent.
  • Add compile-time interface checks like pkg/mikrotik/rest/client.go:60.
  • Add pkg/glinet/client_test.go with an httptest server, in the style of pkg/mikrotik/rest/client_test.go. Add test seams for the base URL and the HTTP client.
  • Wire the backend into internal/cli/config.go:buildRouter(): add case "glinet":. The generic address-plus-password lifecycle in buildRouterEntry() (declared, awaiting-credentials, live, unreachable) already covers glinet. No new lifecycle code is necessary.
  • Extend internal/cli/wg.go. Lines 43 and 124 hard-filter on rc.Type == "mikrotik". Refactor to build via buildRouter and type-assert router.WireGuardManager. Keep the rc.HasWriteAccess() gate. The read-only fail-closed default stays mandatory.
  • Secrets: no code change. The password resolves through the existing chain in ResolveRouterPassword: env HYDRANECK_SECRET_<KEY>, then the secrets store (~/.hydraneck/secrets.yaml, key <venue>/<router-name>), then the deprecated inline field.
  • Add a documented glinet example to config.example.yaml (address, username, tier, access, password via secrets store).
  • Update docs: CLAUDE.md (the "Omada + MikroTik" line), docs/runbooks/neck-lifecycle.md tier table, site-types.md, new-venue.md, partner-network-design.md.
  • Keep internal/cli/router_entry_test.go and internal/cli/config_test.go green. Extend them for the glinet type.
  • Optional, deferred: live debug streams (SSE bandwidth, log tail). The plumbing is MikroTik-shaped (api.MikrotikDebugConfig, buildMikrotikDebugConfigs in internal/cli/config.go:226, handlers in pkg/api/handlers_debug.go). Omada skipped this. See the open questions.

Progress 2026-08-19: Phase A shipped in v0.12.0 (commit eb347ea). New pkg/glinet implements Router, Versioner, BandwidthMonitor, WireGuardManager, and the new router.WireGuardToggler. WireGuard maps onto the wg-client module: the hub peer is a tunnel config in the wg-hydra group. New commands hydraneck wg on <venue> and hydraneck wg off <venue> switch the tunnel, behind the access: write gate. The wg CLI now builds all backends through buildRouter instead of a mikrotik-only filter. 13 new tests pass against a fake device with the full challenge-response login. Runbook: docs/runbooks/glinet.md. Not yet verified against the physical device (it is on a desk LAN the hydraneck server cannot reach); Phase B does that. Device-side API verification is issue #517: a step-by-step task for an agent with LAN access to the router. Verification DONE 2026-08-19 (findings in the comment below): all assumptions confirmed except wg-client get_status, which does not exist on firmware 4.8.4. Fixed in v0.12.1: WireGuardUp now reads the wgclient service state from system get_status, EnableWireGuard falls back to the first configured tunnel, inconsistently typed numeric fields decode via flexInt, and empty-array results no longer fail. Phone home: the device is enrolled in the hydraguard mesh as venue peer slate7-kit (10.10.5.1/32, LAN 10.0.5.0/24, guard type gateway, applied on the hub). Tunnel bring-up on the device is issue #522 (LAN agent task). After #522, Phase B uses address: "10.10.5.1" in the hydraneck config. #522 DONE and closed 2026-08-19: tunnel up, phones home, reboot-safe (agent-installed /usr/bin/hydra-wg-up.sh on the device). Phase B EXECUTED: venue sint-niklaas-tourism-office created in HydraVenues (bxl1, visit-flanders), hydraguard peer renamed to match, router entry glinet-sintniklaas (type glinet, tier prosumer, address 10.10.5.1, access write) live on the hydraneck server, server updated to v0.12.1. Scan shows the venue with the router in awaiting-credentials plus the three ipad-head-map nodes. (1) DONE 2026-08-19: secret set, router scans live with "4.8.4 (GL.iNet GL-BE10000)" and 9 clients — glinet backend verified end to end in production. Remaining: (2) NEW from #522 findings: firmware 4.8.4 removed wg-client start/stop/get_status from the JSON-RPC API and the vpn-client replacement is the global-proxy engine (unusable for selective routing) — rework glinet WireGuardToggler and wg on|off to drive SSH (dropbear, ifup/ifdown wgclient1 + address/routes, see the hydra-wg-up.sh mechanism). Also system get_status has NO wgclient service entry on 4.8.4, so WireGuardUp reports down even when the tunnel is up — fold into the SSH rework. (3) iPad coexistence glance (#522 step 4c) still unperformed.

Phase B: wire in the physical device

  • Assign the Slate 7 Pro to a venue in HydraVenues. Venues come from the HydraVenues API, not from local config.
  • Set the admin password on the device. Store it in the secrets store via https://hydraneck.experiencenet.com/admin/secrets under key <venue>/<router-name>.
  • Add the router entry to /root/.hydraneck/config.yaml on the hydraneck server (46.225.8.28): type: glinet, tier: prosumer, access: read initially. Restart the service: systemctl restart hydraneck. Config is built once at startup.
  • Ship the new binary through the release flow: tag v<X.Y.Z>, push the tag, GitHub Actions releases to releases.experiencenet.com/hydraneck/, the server auto-updates. Never deploy manually.
  • Verify on the dashboard: router state goes awaiting-credentials then live, the Clients list fills, node correlation works, the Version column shows the firmware version.
  • Verify hydraneck scan output for the venue on the CLI.

Phase C: decommission Omada

Venue side (ad6, district bxl1):

  • Remove the AD6 Omada WireGuard mesh peer in hydraguard (guard type omada, see hydraguard omada-venue.md). Decide first whether AD6 gets a GL.iNet replacement or goes pure-consumer.
  • Factory-reset the TP-Link gear before disposal. It holds the WG private key and the admin password.

Hydraneck server, on or around the physical decommission day:

  • Delete the omada-ad6 entry from /root/.hydraneck/config.yaml. Check for other type: omada entries. Restart the service.
  • Delete the secret: DELETE /admin/api/secrets/ad6/omada-ad6. Verify with hydraneck secrets list. Unset any HYDRANECK_SECRET_AD6_OMADA_AD6 env override.
  • Scan data self-cleans: SetResult replaces the venue row in /root/.hydraneck/scans.yaml on the next 5-minute scan. /root/.hydraneck/bandwidth/ad6.yaml is backend-agnostic and can stay.

Code removal (any time after the config removal; an unconfigured backend is dead code, not a hazard):

  • Delete pkg/omada/ (one file, client.go).
  • internal/cli/config.go: remove the omada import (line 14) and the case "omada": block (lines 302-309). Update the comments at lines 91 and 205. Remove the omada-only Site field (line 55).
  • Run go mod tidy to drop github.com/dougbw/go-omada v0.6.2.
  • config.example.yaml: remove the omada-cloud7 and omada-ad6 examples and the "(mikrotik, omada)" comment at line 56.
  • Cosmetic: rename the omada fixture keys in pkg/secrets/store_test.go. Update the "prosumer = Omada-class" comments in pkg/store/store.go (line 43) and internal/cli/config.go.
  • Run make test && make vet. Tag and release.

Docs:

  • Purge omada from the runbooks: runbook.md (lines 133, 173, 176), neck-lifecycle.md (tier table), site-types.md, new-venue.md, mikrotik-rest-migration.md (line 57), mesh-participation.md, address-ranges.md, overview.md, partner-network-design.md, secrets-management.md.
  • In the hydraguard repo: retire docs/runbooks/omada-venue.md and the --guard omada venue type. Add a glinet equivalent if GL.iNet necks join the mesh through hydraguard.

Dashboard: no structural change. pkg/api and the templates render Type and Tier as generic strings.

Open questions

  1. Mesh role: does the Slate 7 Pro join the hydraguard WireGuard mesh as a peer, like the mobilekit hydraneck pattern for iPad and kiosk heads? This decides whether Phase A must ship router.WireGuardManager on day one or can defer it.
  2. Tier naming: does the prosumer tier keep its name, or does the mobile use case need a new tier label? The tier only drives dashboard color-coding today.
  3. Firmware floor: do we need a minimum-firmware flag like RouterOSv7Plus (pkg/scanner/version.go)? Firmware 4.8 changes the login hash method, but a client that defaults to md5 handles both.
  4. Live debug streams: is a pkg/glinet/debug package (the logread module can serve log tail) worth generalizing MikrotikDebugConfig into a type-tagged debug config, or do we skip SSE debug like Omada did?
  5. TLS posture: plain http on the LAN, or https with an insecure-TLS flag? The mesh path may make plain http acceptable.
  6. Reboot method: the system module has reboot. Confirm the no-remote-reboot rule for bodies does not apply to necks before we expose any reboot operation.

Comments (2)

api 19 Aug 2026 21:31

Verification of the GL.iNet Slate 7 Pro (GL-BE10000) API on the LAN, per issue #517. Device: firmware_version 4.8.4, model "be10000", board_info.model "GL.iNet GL-BE10000" (kernel 5.4.281, OpenWrt 21.02-SNAPSHOT). All steps run against http://192.168.8.1/rpc. Password and sid redacted.

Step 1 — reachability: POST /rpc with {} returns HTTP 200 with JSON-RPC error body {"error":{"message":"Invalid Request","code":-32600}}. Matches expectation.

Step 2 — challenge (raw, nonce varies): {"id":1,"jsonrpc":"2.0","result":{"hash-method":"sha256","salt":"o/GVW.rRdc8wdzQi","alg":5,"nonce":"..."}}. alg is a NUMBER (5 = sha256-crypt). hash-method IS present, value "sha256" (firmware 4.8+ as expected). Salt is constant across challenges.

Step 3 — login: cipher = openssl passwd -5 (full $5$salt$hash crypt string), digest = sha256 hex of root:<cipher>:<nonce> per hash-method. Accepted. Result shape: {"result":{"username":"root","sid":"<32-char alnum>"}}. The md5 digest was not tested with the correct password (only sha256, which the device prescribes and accepts). Note: repeated failed logins trigger a lockout window of several minutes with the same -32000 Access denied error as a wrong password — indistinguishable from the client side.

Step 4 — system get_info: firmware_version: "4.8.4", model: "be10000", board_info.model: "GL.iNet GL-BE10000". hydraneck would render "4.8.4 (GL.iNet GL-BE10000)". Correct.

Step 5 — clients get_list: field names confirmed: mac, ip, name, online, tx, rx all present. One raw entry: {"limit_tx":0,"ip":"192.168.8.136","total_rx":"311998227","limit_rx":0,"total_tx_init":0,"total_tx":"10851534","mac":"8C:29:37:E7:BF:CE","ipv6":[],"last_update_rate":1787173999,"remote":true,"iface":"2.4G","tx":15416,"online":true,"name":"macbookair2013","blocked":false,"total_rx_init":0,"type":0,"online_time":1787173999,"rx":580978}. DECODE WARNING: total_tx/total_rx/*_init are inconsistently typed — JSON strings on some entries and number 0 on others; name can be "". tx/rx are plain numbers.

Step 5 — wg-client read surface: module name wg-client (hyphen) works. get_group_list result: {"groups":[{"username":"","password":"","procedure":0,"group_name":"AzireVPN","peer_count":0,"auth_type":1,"group_type":1,"group_id":10000}, ...]} (10 built-in provider groups, ids 10000–10009). CONTRADICTION: wg-client get_status returns {"error":{"message":"Method not found","code":-32601}} on 4.8.4. If pkg/glinet calls wg-client get_status, that call fails on this firmware and needs a different method or graceful handling.

Step 6 — add_config write format (VERDICT): scratch group hydratest created (add_group returns an EMPTY result [] — no group_id; had to re-list to find it: group_id 8736, a plain number, not in the 10000+ range). wg-server generate_key also returns empty [] on this firmware (no keys) — keys were generated locally instead. add_config with "allowed_ips":"10.99.0.0/16" as a CSV STRING (hydraneck's format) was ACCEPTED: result {"peer_id":"9921"}. The array form was not needed. Note peer_id comes back as a JSON STRING, while remove_config accepted it as a number.

Cleanup: remove_config and remove_group both returned []; get_group_list afterwards shows only the 10 built-in groups — scratch group confirmed removed. Tunnel was never started. Logged out (result:null).

Summary of pkg/glinet impact: (1) allowed_ips CSV string: confirmed correct. (2) challenge/login/system/clients assumptions: confirmed. (3) BUG: wg-client get_status does not exist on firmware 4.8.4. (4) Hardening: tolerate string-typed peer_id and string/number-mixed client byte counters; add_group and generate_key return empty results.

cederik 20 Aug 2026 09:06

Closing as implemented. The glinet backend shipped in v0.12.0/v0.12.1 and the Slate 7 Pro is live in hydraneck at venue sint-niklaas-tourism-office (mesh 10.10.5.1, firmware 4.8.4, 9 clients), phone home verified and reboot-safe. Full device picture in hydraneck/docs/runbooks/glinet.md. The remaining work (SSH-based wg on|off rework, iPad coexistence glance, Omada decommission Phase C, deferred SSE debug) moved to #533.