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.
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:
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.pkg/mikrotik/rest/) for the new client.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.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).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.https://raw.githubusercontent.com/tomtana/python-glinet/main/pyglinet/api/api_description.json (43 modules with request and response examples).root and the admin password. Use it only when the JSON-RPC API lacks a method.github.com/GehirnInc/crypt covers md5-crypt, sha256-crypt, and sha512-crypt.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.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.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}.router.Versioner: Version() from system get_info.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).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.pkg/mikrotik/rest/client.go:60.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.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.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.ResolveRouterPassword: env HYDRANECK_SECRET_<KEY>, then the secrets store (~/.hydraneck/secrets.yaml, key <venue>/<router-name>), then the deprecated inline field.config.example.yaml (address, username, tier, access, password via secrets store).CLAUDE.md (the "Omada + MikroTik" line), docs/runbooks/neck-lifecycle.md tier table, site-types.md, new-venue.md, partner-network-design.md.internal/cli/router_entry_test.go and internal/cli/config_test.go green. Extend them for the glinet type.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.
https://hydraneck.experiencenet.com/admin/secrets under key <venue>/<router-name>./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.v<X.Y.Z>, push the tag, GitHub Actions releases to releases.experiencenet.com/hydraneck/, the server auto-updates. Never deploy manually.awaiting-credentials then live, the Clients list fills, node correlation works, the Version column shows the firmware version.hydraneck scan output for the venue on the CLI.Venue side (ad6, district bxl1):
omada, see hydraguard omada-venue.md). Decide first whether AD6 gets a GL.iNet replacement or goes pure-consumer.Hydraneck server, on or around the physical decommission day:
omada-ad6 entry from /root/.hydraneck/config.yaml. Check for other type: omada entries. Restart the service.DELETE /admin/api/secrets/ad6/omada-ad6. Verify with hydraneck secrets list. Unset any HYDRANECK_SECRET_AD6_OMADA_AD6 env override.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):
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).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.pkg/secrets/store_test.go. Update the "prosumer = Omada-class" comments in pkg/store/store.go (line 43) and internal/cli/config.go.make test && make vet. Tag and release.Docs:
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.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.
router.WireGuardManager on day one or can defer it.prosumer tier keep its name, or does the mobile use case need a new tier label? The tier only drives dashboard color-coding today.RouterOSv7Plus (pkg/scanner/version.go)? Firmware 4.8 changes the login hash method, but a client that defaults to md5 handles both.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?system module has reboot. Confirm the no-remote-reboot rule for bodies does not apply to necks before we expose any reboot operation.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.
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":"..."}}.algis a NUMBER (5 = sha256-crypt).hash-methodIS present, value "sha256" (firmware 4.8+ as expected). Salt is constant across challenges.Step 3 — login: cipher = openssl passwd -5 (full
$5$salt$hashcrypt string), digest = sha256 hex ofroot:<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 deniederror 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,rxall 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/*_initare inconsistently typed — JSON strings on some entries and number 0 on others;namecan be "".tx/rxare plain numbers.Step 5 — wg-client read surface: module name
wg-client(hyphen) works.get_group_listresult:{"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_statusreturns{"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
hydratestcreated (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_keyalso 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. Notepeer_idcomes back as a JSON STRING, whileremove_configaccepted 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_statusdoes 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.