From 9ce66eec9eef319806e144bff98843ba2e0accad Mon Sep 17 00:00:00 2001 From: louispaulb Date: Mon, 20 Jul 2026 15:02:58 -0400 Subject: [PATCH] feat(ops): wireless Do Stuff LIVE via F bridge (ops_airos.php) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/n8n-get-signal-airos.md | 15 ++- services/legacy-bridge/ops_airos.php | 150 ++++++++++++++++++++++++ services/targo-hub/lib/ticket-collab.js | 93 ++++++++++----- 3 files changed, 226 insertions(+), 32 deletions(-) create mode 100644 services/legacy-bridge/ops_airos.php diff --git a/docs/n8n-get-signal-airos.md b/docs/n8n-get-signal-airos.md index 3e42adb..558ee10 100644 --- a/docs/n8n-get-signal-airos.md +++ b/docs/n8n-get-signal-airos.md @@ -1,7 +1,16 @@ -# n8n webhook: `get_signal_airos` (wireless Do Stuff) +# Wireless Do Stuff — data source (F bridge now, n8n post-migration) -This is the **one remaining hop** to light up the wireless (airOS / Cambium) "Do Stuff" -panel in OPS. Everything on the OPS side is built and deployed: +> **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=[&ip=&port=&user=]` (`ticket-collab.airosSignal`). Resolves the CPE mgmt IP + login from diff --git a/services/legacy-bridge/ops_airos.php b/services/legacy-bridge/ops_airos.php new file mode 100644 index 0000000..9f656f0 --- /dev/null +++ b/services/legacy-bridge/ops_airos.php @@ -0,0 +1,150 @@ + | ?mac= | ?ip= | ?device_id= [&port=] + * header X-Ops-Token: + * → { ok, source:"f", device_model, device_name, version, ssid, frequency, tx_power, mac_ap, + * signal, signal_ap, ccq, tx_rate, rx_rate, airmax_capacity_uplink, airmax_capacity_downlink, + * connection_time, if_speed_lan0, if_speed_lan1 } + * + * Anti-SSRF : on ne curl QUE l'IP `manage` d'une ligne `device` réellement provisionnée (jamais une IP brute + * fournie par l'appelant sans correspondance en base). + */ +header('Content-Type: application/json; charset=utf-8'); +require __DIR__ . '/ops_secret.php'; // $OPS_TOKEN, $DB_USER, $DB_PASS +@include_once __DIR__ . '/facturation/lib/mcrypt.php'; // mcrypt('decrypt', ...) — déchiffre device.pass (repli défaut usine si absent) + +$DB_HOST = '10.100.80.100'; +$DB_NAME = 'gestionclient'; + +function out($a, $c = 200) { http_response_code($c); echo json_encode($a, JSON_UNESCAPED_UNICODE); exit; } + +// ─── auth (mêmes secret + mécanisme que ops_reassign.php / ops_placemarks.php) ─── +$tok = $_SERVER['HTTP_X_OPS_TOKEN'] ?? ($_REQUEST['token'] ?? ''); +if (!is_string($tok) || !hash_equals($OPS_TOKEN, $tok)) out(['ok' => false, 'error' => 'forbidden'], 403); + +// ─── airOS status fetch (copié à l'identique de F device_ajax/airos_ac_ajax.php, + timeouts) ─── +function airOS_getFile_HTTPS($username, $password, $file, $address) { + $ch = curl_init(); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); + curl_setopt($ch, CURLOPT_COOKIEJAR, null); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 6); + curl_setopt($ch, CURLOPT_TIMEOUT, 15); + curl_setopt($ch, CURLOPT_HTTPHEADER, array('Expect: ')); + // airOS >= 8.5 : POST /api/auth (récupère X-CSRF-ID) ; sinon repli /login.cgi + curl_setopt($ch, CURLOPT_URL, "https://$address/api/auth"); + curl_setopt($ch, CURLOPT_POST, 1); + curl_setopt($ch, CURLOPT_POSTFIELDS, array('username' => $username, 'password' => $password)); + curl_setopt($ch, CURLOPT_HEADER, 1); + $response = curl_exec($ch); + curl_setopt($ch, CURLOPT_HEADER, 0); + if (curl_getinfo($ch, CURLINFO_HTTP_CODE) == 200) { + preg_match('/X-CSRF-ID: .*/', substr($response, 0, curl_getinfo($ch, CURLINFO_HEADER_SIZE)), $XCSRFID); + curl_setopt($ch, CURLOPT_URL, "https://$address/$file"); + curl_setopt($ch, CURLOPT_POST, 0); + $retfile = curl_exec($ch); + curl_setopt($ch, CURLOPT_URL, "https://$address/logout.cgi"); + curl_setopt($ch, CURLOPT_HTTPHEADER, array(trim($XCSRFID[0] ?? ''), 'X-AIROS-LUA: 1')); + curl_setopt($ch, CURLOPT_POST, 1); + curl_setopt($ch, CURLOPT_POSTFIELDS, array()); + curl_exec($ch); + } else { + curl_setopt($ch, CURLOPT_URL, "https://$address/login.cgi"); + curl_setopt($ch, CURLOPT_POST, 1); + curl_setopt($ch, CURLOPT_POSTFIELDS, array('username' => $username, 'password' => $password)); + curl_exec($ch); + curl_setopt($ch, CURLOPT_URL, "https://$address/$file"); + curl_setopt($ch, CURLOPT_POST, 0); + $retfile = curl_exec($ch); + } + curl_close($ch); + return $retfile; +} + +function sec2t($n) { + if (!is_numeric($n) || $n <= 0) return ''; + $n = (int)$n; + return sprintf('%dd %02d:%02d:%02d', intdiv($n, 86400), intdiv($n % 86400, 3600), intdiv($n % 3600, 60), $n % 60); +} + +// ─── résout la ligne device (creds + IP de gestion) : par sn, puis mac, puis manage=ip, puis id ─── +$serial = trim($_REQUEST['serial'] ?? $_REQUEST['sn'] ?? ''); +$mac = trim($_REQUEST['mac'] ?? ''); +$ip = trim($_REQUEST['ip'] ?? ''); +$device_id = (int)($_REQUEST['device_id'] ?? 0); + +$db = @new mysqli($DB_HOST, $DB_USER, $DB_PASS, $DB_NAME); +if ($db->connect_errno) out(['ok' => false, 'error' => 'db_connect'], 500); +$db->set_charset('utf8'); + +$row = null; +$fetch1 = function ($sql, $type, $val) use ($db) { + $st = $db->prepare($sql); if (!$st) return null; + $st->bind_param($type, $val); $st->execute(); + return $st->get_result()->fetch_assoc() ?: null; +}; +$cols = 'id, category, manage, port, user, pass, sn, mac, model'; +if ($device_id) $row = $fetch1("SELECT $cols FROM device WHERE id=? LIMIT 1", 'i', $device_id); +if (!$row && $serial !== '') $row = $fetch1("SELECT $cols FROM device WHERE sn=? AND manage<>'' LIMIT 1", 's', $serial); +if (!$row && $mac !== '') $row = $fetch1("SELECT $cols FROM device WHERE mac=? AND manage<>'' LIMIT 1", 's', $mac); +if (!$row && $ip !== '') $row = $fetch1("SELECT $cols FROM device WHERE manage=? LIMIT 1", 's', $ip); +if (!$row) out(['ok' => false, 'error' => 'device introuvable (serial/mac/ip/device_id)'], 404); + +if (stripos((string)$row['category'], 'cambium') !== false) + out(['ok' => false, 'error' => 'Cambium non supporté par ce pont (API différente)'], 200); + +$mip = trim((string)$row['manage']); +if ($mip === '') out(['ok' => false, 'error' => 'aucune IP de gestion pour cet appareil'], 200); +$port = (int)($_REQUEST['port'] ?? 0); if ($port <= 0) $port = (int)$row['port']; if ($port <= 0) $port = 2196; +$addr = $mip . ':' . $port; + +$user = trim((string)$row['user']); +$pwd = ''; +if (trim((string)$row['pass']) !== '' && function_exists('mcrypt')) { $pwd = @mcrypt('decrypt', $row['pass']); } +if ($user === '' || $pwd === '' || $pwd === false) { $user = 'admin'; $pwd = 'N0HAk4u$'; } // repli = défaut usine F + +$j = airOS_getFile_HTTPS($user, $pwd, 'status.cgi', $addr); +$s = $j ? json_decode($j, true) : null; +if (!is_array($s) || !isset($s['host'])) out(['ok' => false, 'error' => 'CPE injoignable ou réponse invalide', 'addr' => $addr], 200); + +$idx = array('1x', '2x', '2x', '4x', '4x', '6x', '6x', '8x', '8x'); +$w = $s['wireless'] ?? array(); +$sta = $w['sta'][0] ?? array(); +$host = $s['host'] ?? array(); +$eth0 = null; $eth1 = null; +foreach (($s['interfaces'] ?? array()) as $if) { + if (($if['ifname'] ?? '') === 'eth0') $eth0 = $if['status'] ?? array(); + if (($if['ifname'] ?? '') === 'eth1') $eth1 = $if['status'] ?? array(); +} +$lan = function ($st) { return ($st && !empty($st['plugged'])) ? (($st['speed'] ?? '?') . ' Mbps' . (!empty($st['duplex']) ? ' [Full]' : ' [Half]')) : 'unplugged'; }; +// signal : v8 = wireless.sta[0].signal ; v6 (NanoStation) = wireless.signal (top-niveau). +$signal = $sta['signal'] ?? ($w['signal'] ?? null); +// CCQ (Transmit) = 0-100 % ; certains firmwares exposent un champ hors échelle → on masque l'aberrant. +$ccq = $sta['ccq'] ?? ($w['ccq'] ?? null); +if ($ccq !== null && (!is_numeric($ccq) || $ccq < 0 || $ccq > 100)) $ccq = null; + +out(array( + 'ok' => true, 'source' => 'f', + 'device_model' => $host['devmodel'] ?? null, + 'device_name' => $host['hostname'] ?? null, + 'version' => $host['fwversion'] ?? null, + 'ssid' => $w['essid'] ?? null, + 'frequency' => $w['frequency'] ?? null, + 'tx_power' => $w['txpower'] ?? null, + 'mac_ap' => $w['apmac'] ?? null, + 'signal' => $signal, + 'signal_ap' => $sta['remote']['signal'] ?? null, + 'ccq' => $ccq, + 'tx_rate' => $idx[$w['tx_idx'] ?? -1] ?? null, + 'rx_rate' => $idx[$w['rx_idx'] ?? -1] ?? null, + 'airmax_capacity_uplink' => isset($sta['airmax']['uplink_capacity']) ? round($sta['airmax']['uplink_capacity'] / 1024, 2) : null, + 'airmax_capacity_downlink' => isset($sta['airmax']['downlink_capacity']) ? round($sta['airmax']['downlink_capacity'] / 1024, 2) : null, + 'connection_time' => sec2t($host['uptime'] ?? 0), + 'if_speed_lan0' => $lan($eth0), + 'if_speed_lan1' => $lan($eth1), +)); diff --git a/services/targo-hub/lib/ticket-collab.js b/services/targo-hub/lib/ticket-collab.js index e662e41..b2e7b3b 100644 --- a/services/targo-hub/lib/ticket-collab.js +++ b/services/targo-hub/lib/ticket-collab.js @@ -802,43 +802,78 @@ function dostuffStatus ({ sn, olt } = {}) { }) } -// ── airOS/Cambium (sans-fil fixe) : signal LIVE d'un CPE via un webhook n8n `get_signal_airos` (À CRÉER — voir la -// fiche de mise en place n8n). Miroir de F `device_ajax/airos_ac_ajax.php` : n8n se logue sur le CPE (POST /api/auth -// airOS ≥ 8.5, sinon /login.cgi), lit `status.cgi`, et renvoie signal/CCQ/débit/capacité airMAX. Le hub NE PEUT PAS -// joindre les CPE directement (réseau terrain non routable — vérifié : timeouts) → d'où le pont n8n, exactement -// comme la fibre TP-Link (/webhook/dostuff). 404 = webhook pas encore configuré → on dégrade proprement. -async function airosSignal ({ sn, ip, port, user } = {}) { - // Résout l'IP de gestion + le login depuis Service Equipment si non fournis (source unique du parc). - if ((!ip || !user) && sn) { - try { - const rows = await erp.list('Service Equipment', { filters: [['serial_number', '=', String(sn)]], fields: ['ip_address', 'login_user'], limit: 1 }) - const e = rows && rows[0] - if (e) { if (!ip) ip = e.ip_address || ''; if (!user) user = e.login_user || '' } - } catch (e) { /* résolution best-effort */ } +// ── airOS/Cambium (sans-fil fixe) : signal LIVE d'un CPE. Le hub NE JOINT PAS les CPE (réseau terrain non routable — +// vérifié : timeouts). PRIMAIRE = pont F `ops_airos.php` sur facturation (F joint les CPE + a le code airOS + les +// creds ; même auth X-Ops-Token que ops_reassign.php). REPLI = webhook n8n `get_signal_airos` (chemin post-migration +// F ; 404 tant qu'il n'existe pas). Miroir de F `device_ajax/airos_ac_ajax.php` (status.cgi). +function opsBridgeToken () { try { return (process.env.OPS_LEGACY_TOKEN || require('fs').readFileSync('/app/data/ops_legacy.token', 'utf8') || '').trim() } catch (e) { return (process.env.OPS_LEGACY_TOKEN || '').trim() } } + +function normalizeAiros (r) { + const clean = (v) => { const s = String(v == null ? '' : v).replace(/^"+|"+$/g, '').trim(); return s || null } + const num = (v) => { const n = parseFloat(clean(v)); return isNaN(n) ? null : n } + return { + ok: true, source: r.source || 'airos', + model: clean(r.device_model || r.model), name: clean(r.device_name || r.name), version: clean(r.version), + ssid: clean(r.ssid), frequency: clean(r.frequency), txPower: clean(r.tx_power || r.txPower), apMac: clean(r.mac_ap || r.apMac), + signal: num(r.signal), signalAp: num(r.signal_ap || r.signalAp), ccq: num(r.ccq), + txRate: clean(r.tx_rate || r.txRate), rxRate: clean(r.rx_rate || r.rxRate), + capUp: num(r.airmax_capacity_uplink || r.capUp), capDown: num(r.airmax_capacity_downlink || r.capDown), + uptime: clean(r.connection_time || r.uptime), lan0: clean(r.if_speed_lan0 || r.lan0), lan1: clean(r.if_speed_lan1 || r.lan1), } - if (!ip) return { ok: false, error: 'IP de gestion introuvable pour cet appareil (Service Equipment.ip_address vide)' } +} + +function httpsGetJson (targetUrl, headers, timeoutMs) { + return new Promise((resolve) => { + let https; try { https = require('https') } catch (e) { return resolve({ _err: 'https indisponible' }) } + const req = https.request(targetUrl, { method: 'GET', headers: headers || {}, timeout: timeoutMs || 20000 }, (res) => { + let d = ''; res.on('data', c => { d += c }); res.on('end', () => { + let j; try { j = JSON.parse(d) } catch (e) { return resolve({ _err: 'réponse illisible', _status: res.statusCode }) } + if (j && typeof j === 'object') j._status = res.statusCode + resolve(j) + }) + }) + req.on('error', e => resolve({ _err: e.message })) + req.on('timeout', () => { req.destroy(); resolve({ _err: 'délai dépassé' }) }) + req.end() + }) +} + +async function airosSignal ({ sn, ip, port, user, mac } = {}) { + // Résout ip/mac depuis Service Equipment (source unique du parc) si non fournis. + if ((!ip || !mac) && sn) { + try { + const rows = await erp.list('Service Equipment', { filters: [['serial_number', '=', String(sn)]], fields: ['ip_address', 'mac_address'], limit: 1 }) + const e = rows && rows[0] + if (e) { if (!ip) ip = e.ip_address || ''; if (!mac) mac = e.mac_address || '' } + } catch (e) { /* best-effort */ } + } + if (!sn && !ip && !mac) return { ok: false, error: 'serial, ip ou mac requis' } + // PRIMAIRE : pont F ops_airos.php (même hôte + token que ops_reassign.php). + const token = opsBridgeToken() + const base = (process.env.OPS_LEGACY_URL || 'https://facturation.targo.ca/ops_reassign.php').replace(/ops_reassign\.php.*$/, 'ops_airos.php') + if (token && base) { + const q = new URLSearchParams() + if (sn) q.set('serial', sn); if (ip) q.set('ip', ip); if (mac) q.set('mac', mac); if (port) q.set('port', String(port)) + const r = await httpsGetJson(base + '?' + q.toString(), { 'X-Ops-Token': token }, 25000) + if (r && r.ok === true) return normalizeAiros(r) + if (r && r.ok === false && r.error && !r._err) return { ok: false, error: r.error } // réponse F propre (device introuvable / CPE injoignable) + // transport KO (r._err) → tenter n8n en repli + } + // REPLI : webhook n8n get_signal_airos (post-migration F ; 404 = pas encore créé). + return await airosViaN8n({ sn, ip, port, user }) +} + +function airosViaN8n ({ sn, ip, port, user } = {}) { return new Promise((resolve) => { let https; try { https = require('https') } catch (e) { return resolve({ ok: false, error: 'https indisponible' }) } - const body = JSON.stringify({ sn: String(sn || ''), ip: String(ip), port: Number(port) || 2196, user: String(user || 'admin') }) + const body = JSON.stringify({ sn: String(sn || ''), ip: String(ip || ''), port: Number(port) || 2196, user: String(user || 'admin') }) const req = https.request('https://n8napi.targo.ca/webhook/get_signal_airos', { method: 'GET', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, timeout: 25000 }, (res) => { let d = ''; res.on('data', c => { d += c }); res.on('end', () => { - if (res.statusCode === 404) return resolve({ ok: false, registered: false, error: 'Webhook n8n « get_signal_airos » non configuré' }) + if (res.statusCode === 404) return resolve({ ok: false, registered: false, error: 'Signal sans-fil indisponible (pont F injoignable + webhook n8n « get_signal_airos » absent)' }) let arr; try { arr = JSON.parse(d) } catch (e) { return resolve({ ok: false, error: 'réponse n8n illisible' }) } const r = Array.isArray(arr) ? arr[0] : arr if (!r || r.ErrorCode || r.error) return resolve({ ok: false, error: (r && (r.ErrorCode || r.error)) || 'réponse vide (CPE injoignable ?)' }) - const clean = (v) => String(v == null ? '' : v).replace(/^"+|"+$/g, '').trim() - const num = (v) => { const n = parseFloat(clean(v)); return isNaN(n) ? null : n } - resolve({ - ok: true, source: 'airos', - model: clean(r.device_model || r.model) || null, name: clean(r.device_name || r.name) || null, - version: clean(r.version) || null, ssid: clean(r.ssid) || null, - frequency: clean(r.frequency) || null, txPower: clean(r.tx_power || r.txPower) || null, apMac: clean(r.mac_ap || r.apMac) || null, - signal: num(r.signal), signalAp: num(r.signal_ap || r.signalAp), ccq: num(r.ccq), - txRate: clean(r.tx_rate || r.txRate) || null, rxRate: clean(r.rx_rate || r.rxRate) || null, - capUp: num(r.airmax_capacity_uplink || r.capUp), capDown: num(r.airmax_capacity_downlink || r.capDown), - uptime: clean(r.connection_time || r.uptime) || null, - lan0: clean(r.if_speed_lan0 || r.lan0) || null, lan1: clean(r.if_speed_lan1 || r.lan1) || null, - }) + resolve(normalizeAiros(r)) }) }) req.on('error', e => resolve({ ok: false, error: e.message }))