The hub can't reach the CPE network, so wireless signal now flows through a
new read-only F endpoint that CAN (F sits on that network + already has the
airOS code + device creds):
- services/legacy-bridge/ops_airos.php — token-gated (X-Ops-Token, same secret
as ops_reassign.php), SSRF-safe (only curls a mgmt IP present in F's `device`
table). Resolves device by serial/mac/ip, logs into the CPE (airOS >=8.5
/api/auth or <8.5 /login.cgi), reads status.cgi, returns normalized signal /
signal-AP / CCQ / TX-RX rate / airMAX capacity / model / uptime. v8 gets full
data; v6 falls back to top-level wireless.signal; CCQ clamped to 0-100.
- hub: airosSignal now calls the F bridge FIRST (OPS_LEGACY_URL host +
/app/data/ops_legacy.token), n8n webhook demoted to post-migration fallback.
Shared normalizeAiros() for both paths.
Verified end-to-end (hub -> F -> CPE): NanoBeam 5AC signal -53 / airMAX
140/143 Mbps; v6 NanoStation signal -47; offline CPE -> clean {ok:false}.
Auth reject + SSRF guard verified. docs updated (F bridge primary).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
133 lines
6.1 KiB
Markdown
133 lines
6.1 KiB
Markdown
# Wireless Do Stuff — data source (F bridge now, n8n post-migration)
|
||
|
||
> **STATUS (2026-07-20): LIVE via the F bridge.** Wireless signal now flows through
|
||
> `services/legacy-bridge/ops_airos.php` (deployed on `facturation.targo.ca`), which F can
|
||
> reach on the CPE network and which already has the airOS code + device creds. The hub
|
||
> (`ticket-collab.airosSignal`) calls it **first** (`OPS_LEGACY_URL` host + `X-Ops-Token`),
|
||
> and only falls back to the n8n webhook below if the F bridge is unreachable. **The n8n
|
||
> webhook is the post-F-migration path** — build it before F is sunset (~Sept), then the
|
||
> hub fallback picks it up automatically. Verified end-to-end: NanoBeam 5AC → signal −53,
|
||
> airMAX ↑140/↓143 Mbps; offline CPE → clean `{ok:false}`.
|
||
|
||
The rest of this doc specifies the n8n webhook (the future path). Everything on the OPS
|
||
side is built and deployed:
|
||
|
||
- Hub reader/endpoint: `GET /collab/airos-signal?serial=<sn>[&ip=&port=&user=]`
|
||
(`ticket-collab.airosSignal`). Resolves the CPE mgmt IP + login from
|
||
`Service Equipment` and calls `https://n8napi.targo.ca/webhook/get_signal_airos`.
|
||
- UI: `EquipmentDetail.vue` shows **"Do Stuff en direct (sans-fil)"** for wireless
|
||
equipment and renders the fields below. Until this webhook exists it degrades
|
||
gracefully ("signal sans-fil en attente : webhook get_signal_airos à configurer").
|
||
|
||
**Why n8n and not the hub directly?** Verified empirically: the hub cannot route to
|
||
the CPE management network (`100.x` CGNAT + `10.150/10.151.x`) — every probe timed
|
||
out. n8n sits where it can reach the field gear (same reason fiber TP-Link Do Stuff
|
||
uses `/webhook/dostuff`). Keeping the fetch in n8n also keeps the AP password out of
|
||
OPS/the hub.
|
||
|
||
This is a straight port of F's `device_ajax/airos_ac_ajax.php` (`statuscgi` branch).
|
||
|
||
---
|
||
|
||
## Contract
|
||
|
||
**Request** — `GET /webhook/get_signal_airos`, JSON body:
|
||
|
||
```json
|
||
{ "sn": "<serial>", "ip": "<mgmt ip>", "port": 2196, "user": "admin" }
|
||
```
|
||
|
||
`ip`/`port`/`user` are passed by the hub (resolved from `Service Equipment.ip_address`
|
||
/ `login_user`). The **password** must live in n8n (credential/env) — F's fallback is
|
||
`admin` / `N0HAk4u$`.
|
||
|
||
**Response** — JSON object (or 1-element array). The hub reads these keys (snake_case
|
||
as F emits them; camelCase also accepted):
|
||
|
||
| key | source in `status.cgi` |
|
||
|---|---|
|
||
| `device_model` | `host.devmodel` |
|
||
| `device_name` | `host.hostname` |
|
||
| `version` | `host.fwversion` |
|
||
| `ssid` | `wireless.essid` |
|
||
| `frequency` | `wireless.frequency` |
|
||
| `tx_power` | `wireless.txpower` |
|
||
| `mac_ap` | `wireless.apmac` |
|
||
| `signal` | `wireless.sta[0].signal` (dBm) |
|
||
| `signal_ap` | `wireless.sta[0].remote.signal` (dBm) |
|
||
| `ccq` | `wireless.sta[0].ccq` (or `wireless.ccq`) |
|
||
| `tx_rate` | `idx[wireless.tx_idx]` |
|
||
| `rx_rate` | `idx[wireless.rx_idx]` |
|
||
| `airmax_capacity_uplink` | `round(wireless.sta[0].airmax.uplink_capacity / 1024, 2)` (Mbps) |
|
||
| `airmax_capacity_downlink` | `round(wireless.sta[0].airmax.downlink_capacity / 1024, 2)` (Mbps) |
|
||
| `connection_time` | `secondsToTime(host.uptime)` |
|
||
| `if_speed_lan0` / `if_speed_lan1` | `interfaces[ifname=eth0/eth1].status.speed + duplex` |
|
||
|
||
where `idx = ['1x','2x','2x','4x','4x','6x','6x','8x','8x']` (airMAX MCS multiplier).
|
||
|
||
On failure return `{ "error": "<reason>" }` (the hub surfaces it in the panel).
|
||
|
||
---
|
||
|
||
## Workflow (4 nodes)
|
||
|
||
1. **Webhook** — method `GET`, path `get_signal_airos`, "Respond: Using Respond to
|
||
Webhook node".
|
||
|
||
2. **HTTP Request — airOS auth** (airOS ≥ 8.5, fallback < 8.5)
|
||
- `POST https://{{$json.body.ip}}:{{$json.body.port}}/api/auth`
|
||
- body (form-urlencoded): `username={{user}}`, `password={{$credentials}}`
|
||
- Options: **ignore SSL issues = on**, **full response = on** (need response
|
||
headers to capture `X-CSRF-ID` and the session cookie).
|
||
- If status ≠ 200 → fall back to `POST /login.cgi` (same fields).
|
||
|
||
3. **HTTP Request — status.cgi**
|
||
- `GET https://{{ip}}:{{port}}/status.cgi`
|
||
- headers: `Cookie` from node 2, plus `X-CSRF-ID: {{...}}` when the ≥8.5 path was used.
|
||
- ignore SSL issues = on. Response format = JSON.
|
||
|
||
4. **Function — normalize** → then **Respond to Webhook**:
|
||
|
||
```js
|
||
const s = items[0].json; // status.cgi
|
||
const idx = ['1x','2x','2x','4x','4x','6x','6x','8x','8x'];
|
||
const sec2t = (n) => { if(!n||n<=0) return ''; const d=Math.floor(n/86400),h=Math.floor(n%86400/3600),m=Math.floor(n%3600/60),ss=Math.floor(n%60); return `${d}d ${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(ss).padStart(2,'0')}`; };
|
||
const w = s.wireless||{}, sta = (w.sta&&w.sta[0])||{}, host = s.host||{};
|
||
const iface = (n) => (s.interfaces||[]).find(i=>i.ifname===n)||{};
|
||
const eth0 = iface('eth0').status||{}, eth1 = iface('eth1').status||{};
|
||
return [{ json: {
|
||
device_model: host.devmodel, device_name: host.hostname, version: host.fwversion,
|
||
ssid: w.essid, frequency: w.frequency, tx_power: w.txpower, mac_ap: w.apmac,
|
||
signal: sta.signal, signal_ap: (sta.remote||{}).signal, ccq: sta.ccq ?? w.ccq,
|
||
tx_rate: idx[w.tx_idx], rx_rate: idx[w.rx_idx],
|
||
airmax_capacity_uplink: Math.round(((sta.airmax||{}).uplink_capacity||0)/1024*100)/100,
|
||
airmax_capacity_downlink: Math.round(((sta.airmax||{}).downlink_capacity||0)/1024*100)/100,
|
||
connection_time: sec2t(host.uptime),
|
||
if_speed_lan0: eth0.plugged ? `${eth0.speed} Mbps` : 'unplugged',
|
||
if_speed_lan1: eth1.plugged ? `${eth1.speed} Mbps` : 'unplugged',
|
||
}}];
|
||
```
|
||
|
||
(Optional) call `GET /logout.cgi` (with `X-AIROS-LUA: 1` + `X-CSRF-ID`) after node 3
|
||
to release the session, as F does.
|
||
|
||
---
|
||
|
||
## Cambium
|
||
|
||
Cambium CPEs use a different API (`device/cambium.php` in F). Handle as a **branch**
|
||
in the same workflow keyed on the caller's device brand/category (add a `brand`/
|
||
`category` field to the hub call), or a separate `get_signal_cambium` webhook. Not
|
||
required for the airOS/airMAX park (the majority).
|
||
|
||
## Test
|
||
|
||
```bash
|
||
curl -s -X GET https://n8napi.targo.ca/webhook/get_signal_airos \
|
||
-H 'Content-Type: application/json' \
|
||
-d '{"ip":"<a-reachable-CPE-ip>","port":2196,"user":"admin"}'
|
||
```
|
||
|
||
Then in OPS: open a wireless device → **Do Stuff en direct (sans-fil)** → signal /
|
||
CCQ / TX-RX / airMAX capacity render. No further OPS changes needed.
|