gigafibre-fsm/services/legacy-bridge/ops_airos.php
louispaulb db03b0cdab feat(ops): page-load wireless status + daily 03h RADIUS MAC reconcile
Fast page-load status (no slow status.cgi): ops_airos.php gains a `mode=status`
path that returns RADIUS-only state (online, since, current MAC, AP IP) in ~ms.
Hub airosStatus() + GET /collab/airos-status; EquipmentDetail loads it on mount for
wireless → "● En ligne depuis 9h · IP …" badge (with MAC auto-sync on this call too).

Daily park-wide reconcile (03h ET): ops_airos.php `bulk=1` returns every wireless
service's current radacct MAC in one call (2 queries + merge); hub syncWirelessMacs()
indexes Service Equipment by mgmt IP and updates only changed MACs. Scheduled via a
/30-min tick that fires once in the 03h ET window (WIRELESS_MAC_SYNC=off to disable),
started lazily on first request (not at require → no timer in CLI/tests). Manual
trigger: POST /collab/airos-mac-sync.

Fixed erp.update success detection (returns {ok:false}, never throws) in autoSyncMac
+ syncWirelessMacs — counts are now accurate. Verified: reconcile bulk 351 / checked
333 / updated / errs=18 = pre-existing broken service_location links (flagged
separately, non-blocking). Deployed hub+F+SPA (index.5444d59b); leak 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:12:06 -04:00

219 lines
13 KiB
PHP
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.

<?php
/**
* ops_airos.php — Pont LECTURE Ops → signal LIVE d'un CPE sans-fil (airOS/Cambium) via status.cgi.
* Déployé SUR facturation.targo.ca (seul hôte qui joint le réseau des CPE). Gaté X-Ops-Token (= ops_reassign.php).
* Read-only : ne fait qu'interroger status.cgi du CPE. Port du device_ajax/airos_ac_ajax.php de F.
*
* GET/POST ?serial=<sn> | ?mac=<mac> | ?ip=<manage> | ?device_id=<id> [&port=]
* header X-Ops-Token: <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';
// RADIUS (WPA2) = source AUTORITAIRE de la MAC courante du CPE (auto-MàJ au remplacement, via callingstationid) +
// IP de l'AP (nasipaddress). Mêmes creds que F airos_ac.php (le hub OPS n'a PAS le GRANT → passe par ce pont).
$RADIUS_HOST = '10.5.2.25';
$RADIUS_DB = 'radiusdb';
$RADIUS_PASS = 'N0HAk4u$';
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');
// ─── helpers RADIUS ───
function norm_mac($m) { $x = strtoupper(trim(str_replace('-', ':', (string) $m))); return preg_match('/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/', $x) ? $x : null; }
function radacct_latest($rdb, $username) {
$st = $rdb->prepare("SELECT callingstationid, acctstoptime, framedipaddress, nasipaddress, acctstarttime FROM radacct WHERE username=? ORDER BY radacctid DESC LIMIT 1");
if (!$st) return null; $st->bind_param('s', $username); $st->execute(); return $st->get_result()->fetch_assoc() ?: null;
}
// ─── MODE BULK (?bulk=1) : réconciliation MAC park-wide (job quotidien 03h). MAC RADIUS courante par appareil sans-fil. ───
if (($_REQUEST['bulk'] ?? '') === '1') {
$rows = array(); $users = array();
$rs = $db->query("SELECT d.id device_id, d.manage, d.mac, s.radius_user FROM device d JOIN service s ON s.device_id=d.id AND s.radius_user<>'' AND s.status=1 WHERE d.category IN ('airos_ac','airosm','cambium') AND d.manage<>''");
if ($rs) while ($r = $rs->fetch_assoc()) { $rows[] = $r; $users[$r['radius_user']] = true; }
$users = array_keys($users);
$macByUser = array();
$rdb = @new mysqli($RADIUS_HOST, $DB_USER, $RADIUS_PASS, $RADIUS_DB);
if ($rdb && !$rdb->connect_errno) {
for ($i = 0; $i < count($users); $i += 400) {
$batch = array_slice($users, $i, 400); if (!$batch) break;
$in = implode(',', array_fill(0, count($batch), '?'));
$st = $rdb->prepare("SELECT r.username, r.callingstationid, r.acctstoptime FROM radacct r INNER JOIN (SELECT username, MAX(radacctid) mx FROM radacct WHERE username IN ($in) GROUP BY username) g ON r.radacctid = g.mx");
if ($st) { $st->bind_param(str_repeat('s', count($batch)), ...$batch); $st->execute(); $res = $st->get_result(); while ($x = $res->fetch_assoc()) $macByUser[$x['username']] = $x; }
}
$rdb->close();
}
$devs = array();
foreach ($rows as $r) {
$ra = $macByUser[$r['radius_user']] ?? null; if (!$ra) continue;
$cm = norm_mac($ra['callingstationid']); if (!$cm) continue;
$devs[] = array('device_id' => (int) $r['device_id'], 'radius_user' => $r['radius_user'], 'manage' => $r['manage'], 'device_mac' => $r['mac'], 'radius_mac' => $cm, 'online' => ($ra['acctstoptime'] === null || $ra['acctstoptime'] === ''));
}
out(array('ok' => true, 'count' => count($devs), 'devices' => $devs));
}
$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
// RADIUS (WPA2) : radius_user (service) → dernière session radacct → MAC courante + online + IPs (nasip = AP fiable).
$radius_user = null; $radius_mac = null; $ap_ip = null; $r_online = null; $framed_ip = null; $r_since = null;
$stR = $db->prepare("SELECT radius_user FROM service WHERE device_id=? AND radius_user<>'' ORDER BY (status=1) DESC, id DESC LIMIT 1");
if ($stR) { $stR->bind_param('i', $row['id']); $stR->execute(); $rr = $stR->get_result()->fetch_assoc(); if ($rr) $radius_user = $rr['radius_user']; }
if ($radius_user) {
$rdb = @new mysqli($RADIUS_HOST, $DB_USER, $RADIUS_PASS, $RADIUS_DB);
if ($rdb && !$rdb->connect_errno) {
$ra = radacct_latest($rdb, $radius_user);
if ($ra) { $radius_mac = norm_mac($ra['callingstationid']); $nip = trim((string) $ra['nasipaddress']); if ($nip !== '' && $nip !== '0.0.0.0') $ap_ip = $nip; $r_online = ($ra['acctstoptime'] === null || $ra['acctstoptime'] === ''); $framed_ip = trim((string) $ra['framedipaddress']) ?: null; $r_since = $ra['acctstarttime'] ?: null; }
$rdb->close();
}
}
// MODE STATUS (?mode=status) : état RAPIDE via RADIUS seul (aucun status.cgi) — pour l'affichage au chargement de la page.
if (($_REQUEST['mode'] ?? '') === 'status') {
out(array('ok' => true, 'source' => 'radius', 'kind' => 'sans-fil', 'model' => ($row['model'] ?: null),
'online' => $r_online, 'since' => $r_since, 'radius_user' => $radius_user, 'radius_mac' => $radius_mac, 'framed_ip' => $framed_ip, 'ap_ip' => $ap_ip));
}
$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;
// Repli AP IP si RADIUS muet : MAC radio de l'AP → ligne device (best-effort).
if ($ap_ip === null) { $apmac = $w['apmac'] ?? ''; if ($apmac !== '') { $st = $db->prepare("SELECT manage FROM device WHERE (mac=? OR mac=?) AND manage<>'' LIMIT 1"); if ($st) { $macNC = str_replace(array(':', '-'), '', $apmac); $st->bind_param('ss', $apmac, $macNC); $st->execute(); $r2 = $st->get_result()->fetch_assoc(); if ($r2) $ap_ip = $r2['manage']; } } }
$eth0b = $eth0 ?: array();
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,
'ap_ip' => $ap_ip,
'radius_user' => $radius_user,
'radius_mac' => $radius_mac,
'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),
// Compteurs d'octets eth0 (LAN client) → débit LIVE calculé côté OPS (Δoctets/Δs × 8). eth0 rx = upload, tx = download.
'eth_rx_bytes' => isset($eth0b['rx_bytes']) ? (float) $eth0b['rx_bytes'] : null,
'eth_tx_bytes' => isset($eth0b['tx_bytes']) ? (float) $eth0b['tx_bytes'] : null,
'ts' => round(microtime(true) * 1000),
));