gigafibre-fsm/docs/n8n-get-signal-airos.md
louispaulb 9ce66eec9e feat(ops): wireless Do Stuff LIVE via F bridge (ops_airos.php)
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>
2026-07-20 15:02:58 -04:00

133 lines
6.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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