gigafibre-fsm/services/legacy-bridge/ops_airos.php
louispaulb 80a059f50c feat(ops): wireless on the same status cards + DHCP leases (F parity)
Unify the wireless (airOS) device view onto the shared statCards grid — signal
(colored + meter), signal côté borne, CCQ, débit lien TX/RX, capacité airMAX,
SSID, fréquence, uptime, gestion, point d'accès (link) — same glanceable cards as
fiber, replacing the old chip row.

"See what's happening" — the three things F shows, now in OPS:
- actual traffic: live eth0 sparkline (already present)
- DHCP leases = the devices behind the CPE: ops_airos.php mode=leases SSHes the CPE
  (port 2194) -> cat /tmp/dhcpd.leases -> {mac, ip, host, vendor(OUI), remaining};
  hub airosLeases + GET /collab/airos-leases; UI renders an on-demand "Appareils du
  client (DHCP)" table with refresh.
- wifi/wired clients = those leases.

Verified: leases for Bryson CPE -> Cisco-Linksys 172.31.1.19 (OUI vendor resolved),
through the hub too. Build clean, leak 0; deployed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 15:17:34 -04:00

242 lines
14 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
// MODE LEASES (?mode=leases) : baux DHCP = les APPAREILS derrière le CPE (façon F). SSH sur le CPE (port 2194) →
// cat /tmp/dhcpd.leases → {mac, ip, host, vendor (OUI), remaining_s}. Le CPE = serveur DHCP du client.
if (($_REQUEST['mode'] ?? '') === 'leases') {
if (!function_exists('ssh2_connect')) out(['ok' => false, 'error' => 'ssh2 indisponible sur le pont']);
$conn = @ssh2_connect($mip, 2194);
if (!$conn || !@ssh2_auth_password($conn, $user, $pwd)) out(['ok' => false, 'error' => 'SSH refusé (auth CPE)']);
$stream = @ssh2_exec($conn, 'cat /tmp/dhcpd.leases');
if (!$stream) out(['ok' => false, 'error' => 'lecture baux impossible']);
stream_set_blocking($stream, true); $raw = stream_get_contents($stream);
$now = time(); $leases = array();
foreach (preg_split('/\r?\n/', (string) $raw) as $line) {
$line = trim($line); if ($line === '') continue;
$d = preg_split('/\s+/', $line); if (count($d) < 3) continue;
$exp = is_numeric($d[0]) ? ((int) $d[0] - $now) : 0;
$mac = $d[1] ?? ''; $ip = $d[2] ?? ''; $host = $d[3] ?? '';
$oui = strtoupper(preg_replace('/[^0-9A-Fa-f]/', '', substr($mac, 0, 8)));
$vendor = null;
if (strlen($oui) >= 6) { $v = @shell_exec('grep -i ' . escapeshellarg(substr($oui, 0, 6)) . " /targo/facturation/oui.txt | awk -F '\\t' '{print \$3}' | head -1"); $vendor = ($v && trim($v) !== '') ? trim($v) : null; }
$leases[] = array('mac' => $mac, 'ip' => $ip, 'host' => (($host && $host !== '*') ? $host : null), 'vendor' => $vendor, 'remaining_s' => ($exp > 0 ? $exp : 0));
}
out(['ok' => true, 'count' => count($leases), 'leases' => $leases]);
}
// 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),
));