gigafibre-fsm/services/targo-hub/lib/olt-ops.js
louispaulb 218bef8c5a refactor(olt-ops): dédup plomberie n8n + effets pilotés par le spec ACTIONS
- extrait n8nRequest(verb,url,body,timeout) partagé par dostuff() et wifiClients() (fin de la double implémentation https-GET-with-body)
- effets déplacés dans ACTIONS[action].effect → supprime l'échelle de if dans plan()
Comportement préservé (vérifié live : plan tech-3 suspend identique, gardes run inchangées). Déployé prod.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 07:38:48 -04:00

248 lines
18 KiB
JavaScript
Raw Permalink 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.

'use strict'
// ══════════════════════════════════════════════════════════════════════════════
// olt-ops.js — ÉCRITURES cycle-de-vie ONU (activate / replace / speed / suspend /
// remove) pour la fibre TP-Link tech-3 (XGS-PON), en enveloppant les MÊMES verbes
// n8n que F (`/webhook/dostuff`, le VERBE HTTP = l'action). C'est le port G2 du
// plan « Do Stuff » : OPS lisait déjà tout (olt-snmp) mais n'écrivait RIEN à l'OLT.
//
// SÉCURITÉ (leçons PPA + F sans idempotence) :
// • plan() = LECTURE SEULE — résout le contexte (OLT/slot/port/profil depuis
// Service Equipment), retourne le payload EXACT + effets + avertissements. 0 réseau.
// • run() = fire le verbe n8n PUIS miroir ERPNext. Exige confirm:true + une
// idempotency-key ; re-jouer la MÊME clé ne re-tire PAS (cache /app/data/olt_ops.json).
// • tech-2 (Raisecom, RCMG) = SNMP direct côté F → n8n renvoie « OLT non reconnue » ;
// plan() le signale (écriture SNMP native = G3, pas encore branchée) et refuse run().
// ══════════════════════════════════════════════════════════════════════════════
const fs = require('fs')
const erp = require('./erp')
const { log } = require('./helpers')
const N8N_DOSTUFF = 'https://n8napi.targo.ca/webhook/dostuff'
const STORE = '/app/data/olt_ops.json'
const IDEMP_WINDOW_MS = 10 * 60 * 1000 // rejouer la même clé dans 10 min = même résultat (pas de 2e tir)
// Communauté SNMP EN ÉCRITURE (Raisecom tech-2). Secret → env seulement, JAMAIS bundlé. Absente ⇒ run() tech-2 refuse.
const OLT_RW_COMMUNITY = process.env.OLT_RW_COMMUNITY || ''
const OLT_RO_COMMUNITY = process.env.OLT_RO_COMMUNITY || 'targosnmp'
// OIDs Raisecom (MIB 8886) — EXTRAITS DU DRIVER F raisecom_rcmg.php (vérifiés à la source, pas reconstruits) :
// reboot 1.3.6.1.4.1.8886.18.3.6.1.1.1.23.{olt_id}.4 = i 1 (olt_id = slot*1e7+port*1e5+ontid)
// vlan 1.3.6.1.4.1.8886.18.3.6.6.1.1.7.{olt_id}.4 = i 40|666 (40=actif, 666=suspendu)
// profile 1.3.6.1.4.1.8886.18.3.1.3.1.1.6.{onu_id} = i profil (onu_id = (slot+32)*8388608+port*65536+ontid)
// save 1.3.6.1.4.1.8886.1.2.1.1.0 = i 2 (APRÈS chaque set)
const RC = {
REBOOT: (oltId) => `1.3.6.1.4.1.8886.18.3.6.1.1.1.23.${oltId}.4`,
VLAN: (oltId) => `1.3.6.1.4.1.8886.18.3.6.6.1.1.7.${oltId}.4`,
PROFILE: (onuId) => `1.3.6.1.4.1.8886.18.3.1.3.1.1.6.${onuId}`,
SAVE: '1.3.6.1.4.1.8886.1.2.1.1.0',
VLAN_ACTIVE: 40, VLAN_SUSPEND: 666,
}
const rcOltId = (s, p, o) => Number(s) * 10000000 + Number(p) * 100000 + Number(o)
const rcOnuId = (s, p, o) => (Number(s) + 32) * 8388608 + Number(p) * 65536 + Number(o)
// action → verbe n8n (tech-3), champs requis, techs supportées, effet (affiché dans le plan).
// tech-3 = n8n ; tech-2 = SNMP natif (G3).
const ACTIONS = {
activate: { verb: 'POST', label: 'Activer / provisionner lONU', needs: ['sn', 'olt', 'profileid'], techs: [3], effect: 'Provisionne lONU sur lOLT + met Service Equipment « Actif ».' },
replace: { verb: 'PUT', label: 'Remplacer lONU (échange de série)', needs: ['old_sn', 'new_sn', 'olt'], techs: [3], effect: 'Ré-authentifie la nouvelle série sur lOLT + met à jour le numéro de série de Service Equipment.' },
speed: { verb: 'PATCH', label: 'Changer le forfait / la vitesse', needs: ['sn', 'olt', 'profileid'], extra: { trigger: 'speedchange' }, techs: [3, 2], effect: 'Change le profil de ligne (lONU redémarre).' },
suspend: { verb: 'PATCH', label: 'Suspendre linternet (VLAN)', needs: ['sn', 'olt'], extra: { profileid: '', trigger: 'suspend' }, techs: [3, 2], effect: 'Bascule le VLAN internet → coupe laccès (facturation inchangée).' },
unsuspend: { verb: 'PATCH', label: 'Rétablir linternet (VLAN)', needs: ['sn', 'olt'], extra: { profileid: '', trigger: 'unsuspend' }, techs: [3, 2], effect: 'Rétablit le VLAN internet.' },
remove: { verb: 'DELETE', label: 'Retirer / désactiver lONU', needs: ['sn', 'olt'], techs: [3], effect: 'Retire lONU de lOLT + délie Service Equipment.' },
reboot: { verb: null, label: 'Redémarrer lONU', needs: ['sn', 'olt'], techs: [2], effect: 'Redémarre lONU.' }, // tech-3 : utiliser le reboot TR-069
}
// SNMP SET Raisecom (tech-2) : applique l'action + SAVE, via net-snmp. Renvoie {ok,error}. LECTURE : voir olt-snmp.
function snmpRaisecom (action, { host, slot, port, ontid, profileid } = {}) {
return new Promise((resolve) => {
if (!OLT_RW_COMMUNITY) return resolve({ ok: false, error: 'OLT_RW_COMMUNITY non configurée (env) — écriture SNMP tech-2 désactivée' })
if (host == null || slot == null || port == null || ontid == null) return resolve({ ok: false, error: 'slot/port/ontid/olt requis' })
let snmp; try { snmp = require('net-snmp') } catch (e) { return resolve({ ok: false, error: 'net-snmp indisponible' }) }
const oltId = rcOltId(slot, port, ontid); const onuId = rcOnuId(slot, port, ontid)
let vb
if (action === 'suspend') vb = { oid: RC.VLAN(oltId), type: snmp.ObjectType.Integer, value: RC.VLAN_SUSPEND }
else if (action === 'unsuspend') vb = { oid: RC.VLAN(oltId), type: snmp.ObjectType.Integer, value: RC.VLAN_ACTIVE }
else if (action === 'reboot') vb = { oid: RC.REBOOT(oltId), type: snmp.ObjectType.Integer, value: 1 }
else if (action === 'speed') { const pr = parseInt(profileid, 10); if (!pr) return resolve({ ok: false, error: 'profileid (n° profil de ligne) requis pour le changement de vitesse' }); vb = { oid: RC.PROFILE(onuId), type: snmp.ObjectType.Integer, value: pr } }
else return resolve({ ok: false, error: 'action SNMP tech-2 non supportée: ' + action })
const session = snmp.createSession(String(host), OLT_RW_COMMUNITY, { timeout: 6000, retries: 1, version: snmp.Version2c })
session.set([vb], (err) => {
if (err) { try { session.close() } catch (e) {} return resolve({ ok: false, error: 'SNMP set: ' + err.message }) }
// SAVE obligatoire après un set (sinon revert silencieux) — sauf reboot (F ne sauve pas après un reboot).
if (action === 'reboot') { try { session.close() } catch (e) {} return resolve({ ok: true, oid: vb.oid }) }
session.set([{ oid: RC.SAVE, type: snmp.ObjectType.Integer, value: 2 }], (err2) => {
try { session.close() } catch (e) {}
if (err2) return resolve({ ok: false, error: 'SNMP save: ' + err2.message, warn: 'set appliqué mais save échoué — peut se réverter' })
resolve({ ok: true, oid: vb.oid, saved: true })
})
})
})
}
function readStore () { try { return JSON.parse(fs.readFileSync(STORE, 'utf8')) } catch (e) { return {} } }
function writeStore (o) { try { fs.writeFileSync(STORE, JSON.stringify(o)) } catch (e) { log('olt-ops store write: ' + e.message) } }
// tech-3 (TP-Link XGS-PON) = n8n ; tech-2 (Raisecom) = SNMP direct côté F (pas n8n).
function techOf (serial, equip) {
const s = String(serial || (equip && equip.serial_number) || '').toUpperCase()
if (equip && equip.fibre_tech) return Number(equip.fibre_tech)
if (s.startsWith('TPLG')) return 3
if (s.startsWith('RCMG')) return 2
return null // inconnu
}
// Résout le contexte réseau d'un ONU depuis Service Equipment (miroir de la table `fibre` de F).
async function resolveEquip (serial) {
// ⚠️ Ne PAS demander fibre_line_profile/fibre_service_profile ici : ce ne sont PAS des DocFields v16
// (erp.list les strippe → bruit + latence). Le profil (activate/speed) est fourni par l'appelant
// (l'UI le collecte) ; suspend/unsuspend/remove n'en ont pas besoin.
const rows = await erp.list('Service Equipment', {
filters: [['serial_number', '=', String(serial)]],
fields: ['name', 'serial_number', 'mac_address', 'olt_ip', 'olt_slot', 'olt_port', 'olt_ontid', 'customer', 'service_location', 'status', 'equipment_type'],
limit: 1,
}).catch(() => [])
const equip = (rows && rows[0]) || null
// Repli coordonnées ONU depuis le poller SNMP live (olt-snmp) : les coords Raisecom (tech-2) ne sont PAS mirorées
// dans Service Equipment (elles vivent dans la table `fibre` de F) → sans ça, l'écriture SNMP tech-2 est impossible.
if (!equip || equip.olt_ip == null || equip.olt_slot == null || equip.olt_ontid == null) {
try {
const onu = require('./olt-snmp').getOnuBySerial(String(serial))
if (onu) {
const merged = equip || { serial_number: String(serial) }
if (merged.olt_ip == null) merged.olt_ip = onu.oltHost || null
if (merged.olt_slot == null) merged.olt_slot = (onu.slot != null ? onu.slot : null)
if (merged.olt_port == null) merged.olt_port = (onu.port != null ? onu.port : null)
if (merged.olt_ontid == null) merged.olt_ontid = (onu.ontId != null ? onu.ontId : (onu.ontid != null ? onu.ontid : null))
merged._coords_from_snmp = true
return merged
}
} catch (e) { /* poller indispo → on garde equip tel quel */ }
}
return equip
}
// Requête n8n : VERBE HTTP arbitraire + body JSON via le module https (fetch refuse un body en GET). Renvoie le
// JSON brut. Base commune de dostuff() et wifiClients() (F fait pareil).
function n8nRequest (verb, targetUrl, body, timeoutMs) {
return new Promise((resolve) => {
let https; try { https = require('https') } catch (e) { return resolve({ ok: false, error: 'https indisponible' }) }
const payload = JSON.stringify(body)
const req = https.request(targetUrl, { method: verb, headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }, timeout: timeoutMs }, (res) => {
let d = ''; res.on('data', c => { d += c }); res.on('end', () => {
let parsed; try { parsed = JSON.parse(d) } catch (e) { parsed = d }
resolve({ ok: res.statusCode < 400, status: res.statusCode, data: parsed })
})
})
req.on('error', e => resolve({ ok: false, error: e.message }))
req.on('timeout', () => { req.destroy(); resolve({ ok: false, error: 'délai n8n dépassé (~' + Math.round(timeoutMs / 1000) + ' s)' }) })
req.write(payload); req.end()
})
}
// dostuff : verbe = action ; « OLT non reconnue » (ErrorCode) = tech ≠ 3 → non reconnu.
async function dostuff (verb, payload, timeoutMs = 60000) {
const res = await n8nRequest(verb, N8N_DOSTUFF, payload, timeoutMs)
if (res.error) return { ok: false, error: res.error }
const r = Array.isArray(res.data) ? res.data[0] : res.data
const recognized = !(r && r.ErrorCode)
return { ok: recognized && res.ok, status: res.status, recognized, error: (r && r.ErrorCode) || null, data: r }
}
// Construit le payload n8n EXACT pour une action, à partir du contexte résolu + overrides.
function buildPayload (action, equip, opts = {}) {
const spec = ACTIONS[action]
const olt = opts.olt || (equip && equip.olt_ip) || ''
const sn = opts.sn || (equip && equip.serial_number) || ''
const profileid = opts.profileid != null ? opts.profileid : (equip && equip.fibre_line_profile) || ''
const base = { user: opts.user || 'ops', tel: opts.tel || '' }
let p
if (action === 'replace') p = { old_sn: opts.old_sn || sn, new_sn: opts.new_sn || '', olt, ...base }
else p = { sn, olt, ...base }
if (spec.needs.includes('profileid') || action === 'speed') p.profileid = profileid
if (action === 'activate') { p.profileid = profileid; p.clientid = opts.clientid || (equip && equip.customer) || '' }
if (spec.extra) Object.assign(p, spec.extra)
return p
}
// PLAN — LECTURE SEULE. Résout le contexte, retourne le payload + effets + avertissements. Aucun appel réseau.
async function plan ({ serial, action, ...opts } = {}) {
const spec = ACTIONS[action]
if (!spec) return { ok: false, error: 'action inconnue: ' + action + ' (activate|replace|speed|suspend|unsuspend|remove)' }
const equip = await resolveEquip(opts.old_sn || serial)
const tech = techOf(opts.old_sn || serial, equip)
const payload = buildPayload(action, equip, { ...opts, sn: serial, old_sn: opts.old_sn || serial })
const method = tech === 2 ? 'snmp' : 'n8n' // tech-2 Raisecom = SNMP natif ; tech-3 TP-Link = n8n
const supportsTech = tech != null && Array.isArray(spec.techs) && spec.techs.includes(tech)
const warnings = []
if (!equip) warnings.push('Aucun Service Equipment pour ce numéro de série — lOLT/profil doivent être fournis manuellement.')
if (tech == null) warnings.push('Technologie ONU inconnue (préfixe série non reconnu) — vérifier avant dexécuter.')
else if (!supportsTech) warnings.push('« ' + spec.label + ' » nest pas supporté pour tech-' + tech + (tech === 3 ? ' via ce module (reboot = utiliser TR-069).' : ' (activate/replace/remove Raisecom = script CLI, non branché).'))
if (!payload.olt) warnings.push('IP OLT manquante — requise.')
// tech-2 : besoin des coordonnées ONU (slot/port/ontid) résolues + communauté RW configurée
let snmpReady = true
if (method === 'snmp') {
const haveCoords = equip && equip.olt_slot != null && equip.olt_port != null && equip.olt_ontid != null
if (!haveCoords) { warnings.push('Coordonnées ONU (slot/port/ontid) introuvables sur Service Equipment — requises pour lécriture SNMP.'); snmpReady = false }
if (!OLT_RW_COMMUNITY) { warnings.push('Communauté SNMP en écriture non configurée (env OLT_RW_COMMUNITY) → exécution SNMP désactivée.'); snmpReady = false }
if (action === 'speed' && !(opts.profileid || (equip && equip.fibre_line_profile))) { warnings.push('Profil de ligne (profileid) requis pour le changement de vitesse.'); snmpReady = false }
}
const missing = method === 'n8n' ? spec.needs.filter(k => !payload[k] && payload[k] !== 0) : []
if (missing.length) warnings.push('Champs manquants : ' + missing.join(', '))
const canRun = supportsTech && !!payload.olt && (method === 'n8n' ? (tech === 3 && !missing.length) : snmpReady)
const effects = spec.effect ? [spec.effect] : []
return {
ok: canRun,
action, label: spec.label, verb: spec.verb, tech, method,
target: { serial: serial || payload.sn, olt: payload.olt, slot: equip && equip.olt_slot, port: equip && equip.olt_port, ontid: equip && equip.olt_ontid, profile: payload.profileid || (equip && equip.fibre_line_profile) || '' },
equipment: equip ? equip.name : null, customer: equip && equip.customer, service_location: equip && equip.service_location,
payload: method === 'n8n' ? payload : { olt: payload.olt, action, snmp: 'MIB 8886 (voir olt-ops.js)' }, effects, warnings,
can_run: canRun,
}
}
// RUN — fire le verbe n8n PUIS miroir ERPNext. Idempotent par clé. confirm:true obligatoire.
async function run ({ serial, action, confirm, idempotencyKey, actor, ...opts } = {}) {
const spec = ACTIONS[action]
if (!spec) return { ok: false, error: 'action inconnue' }
if (!confirm) return { ok: false, error: 'confirm:true requis (action réseau)' }
const pl = await plan({ serial, action, ...opts })
if (!pl.can_run) return { ok: false, error: 'plan non exécutable', plan: pl }
// Idempotence : même clé rejouée dans la fenêtre → renvoyer le résultat mémorisé (NE PAS re-tirer).
const key = String(idempotencyKey || '')
if (!key) return { ok: false, error: 'idempotencyKey requis' }
const store = readStore()
const prev = store[key]
if (prev && (Date.now() - prev.at) < IDEMP_WINDOW_MS) return { ok: true, idempotent: true, ...prev.result }
// FIRE — tech-3 = n8n dostuff ; tech-2 = SNMP set Raisecom (+ save).
let net
if (pl.method === 'snmp') {
net = await snmpRaisecom(action, { host: pl.target.olt, slot: pl.target.slot, port: pl.target.port, ontid: pl.target.ontid, profileid: opts.profileid || (pl.target.profile) })
} else {
net = await dostuff(spec.verb, pl.payload)
}
const result = { action, serial: pl.target.serial, olt: pl.target.olt, method: pl.method, verb: spec.verb, net_ok: !!net.ok, net_error: net.error || null, net_status: net.status, mirrored: [] }
if (net.ok && pl.equipment) {
// Miroir ERPNext (fill-only, best-effort) — reflète l'effet sur Service Equipment.
try {
if (action === 'activate' || action === 'unsuspend') { await erp.update('Service Equipment', pl.equipment, { status: 'Actif' }); result.mirrored.push('status=Actif') }
else if (action === 'suspend') { await erp.update('Service Equipment', pl.equipment, { status: 'Suspendu' }); result.mirrored.push('status=Suspendu') }
else if (action === 'remove') { await erp.update('Service Equipment', pl.equipment, { status: 'Retiré' }); result.mirrored.push('status=Retiré') }
else if (action === 'replace' && opts.new_sn) { await erp.update('Service Equipment', pl.equipment, { serial_number: opts.new_sn }); result.mirrored.push('serial=' + opts.new_sn) }
} catch (e) { result.mirror_error = e.message }
}
store[key] = { at: Date.now(), action, actor: actor || '', result }
writeStore(store)
log('[olt-ops] ' + action + ' ' + pl.target.serial + ' → net_ok=' + result.net_ok + (result.net_error ? ' (' + result.net_error + ')' : '') + ' actor=' + (actor || '?'))
return { ok: !!net.ok, ...result }
}
// G1 reste : clients WiFi connectés d'un ONU (n8n get_wifi_info) — LECTURE.
async function wifiClients ({ sn } = {}) {
if (!sn) return { ok: false, error: 'sn requis' }
const res = await n8nRequest('GET', 'https://n8napi.targo.ca/webhook/get_wifi_info', { sn: String(sn) }, 20000)
if (res.error) return { ok: false, error: res.error }
const j = res.data
return { ok: !!j, clients: Array.isArray(j) ? j : (j && j.clients) || [], raw: j }
}
module.exports = { plan, run, wifiClients, ACTIONS }