gigafibre-fsm/services/targo-hub/server.js
louispaulb bfffed2b41 feat: ONT diagnostics — grouped mesh topology, signal RSSI, management link
- EquipmentDetail: collapsible node groups (clients grouped by mesh node)
- Signal strength as RSSI % (0-255 per 802.11-2020) with 10-tone color scale
- Management IP clickable link to device web GUI (/superadmin/)
- Fibre status compact top bar (status + Rx/Tx power when available)
- targo-hub: WAN IP detection across all VLAN interfaces
- targo-hub: full WiFi client count (direct + EasyMesh mesh repeaters)
- targo-hub: /devices/:id/hosts endpoint with client-to-node mapping
- ClientsPage: start empty, load only on search (no auto-load all)
- nginx: dynamic ollama resolver (won't crash if ollama is down)
- Cleanup: remove unused BillingKPIs.vue and TagInput.vue
- New docs and migration scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 21:26:14 -04:00

2198 lines
89 KiB
JavaScript

/**
* targo-hub — Lightweight SSE relay for real-time Communication events.
*
* Endpoints:
* GET /sse?topics=customer:C-LPB4,customer:C-114796350603272
* → SSE stream, authenticated via X-Authentik-Email header (Traefik injects it)
*
* POST /broadcast
* → Push an event to all SSE clients subscribed to matching topics
* → Auth: Bearer token (HUB_INTERNAL_TOKEN)
* → Body: { topic, event, data }
*
* POST /webhook/twilio/sms-incoming
* → Receive Twilio inbound SMS, log to ERPNext, broadcast SSE
*
* POST /webhook/twilio/sms-status
* → Receive Twilio delivery status updates
*
* GET /health
* → Health check
*
* 3CX Call Log Poller:
* → Polls 3CX xAPI every 30s for new completed calls
* → Logs to ERPNext Communication, broadcasts SSE
*
* GET|POST|DELETE /devices/*
* → GenieACS NBI proxy for CPE/ONT device management
* → /devices/summary — fleet stats, /devices/lookup?serial=X — find device
* → /devices/:id/tasks — send reboot, getParameterValues, etc.
*/
const http = require('http')
const https = require('https')
const { URL } = require('url')
// ── Config ──
const PORT = parseInt(process.env.PORT || '3300', 10)
const INTERNAL_TOKEN = process.env.HUB_INTERNAL_TOKEN || ''
const ERP_URL = process.env.ERP_URL || 'http://erpnext-backend:8000'
const ERP_TOKEN = process.env.ERP_TOKEN || ''
const TWILIO_ACCOUNT_SID = process.env.TWILIO_ACCOUNT_SID || ''
const TWILIO_AUTH_TOKEN = process.env.TWILIO_AUTH_TOKEN || ''
const TWILIO_FROM = process.env.TWILIO_FROM || ''
// 3CX Config (DISABLED by default — set PBX_ENABLED=1 to re-enable)
const PBX_ENABLED = process.env.PBX_ENABLED === '1'
const PBX_URL = process.env.PBX_URL || 'https://targopbx.3cx.ca'
const PBX_USER = process.env.PBX_USER || ''
const PBX_PASS = process.env.PBX_PASS || ''
const PBX_POLL_INTERVAL = parseInt(process.env.PBX_POLL_INTERVAL || '30000', 10) // 30s
// Twilio Voice Config
const TWILIO_API_KEY = process.env.TWILIO_API_KEY || ''
const TWILIO_API_SECRET = process.env.TWILIO_API_SECRET || ''
const TWILIO_TWIML_APP_SID = process.env.TWILIO_TWIML_APP_SID || ''
// Fonoster/Routr DB Config (direct PostgreSQL access)
const ROUTR_DB_URL = process.env.ROUTR_DB_URL || ''
const FNIDENTITY_DB_URL = process.env.FNIDENTITY_DB_URL || ''
// GenieACS NBI Config
const GENIEACS_NBI_URL = process.env.GENIEACS_NBI_URL || 'http://10.5.2.115:7557'
// ── SSE Client Registry ──
// Map<topic, Set<{ res, email }>>
const subscribers = new Map()
let clientIdSeq = 0
function addClient (topics, res, email) {
const id = ++clientIdSeq
const client = { id, res, email, topics }
for (const t of topics) {
if (!subscribers.has(t)) subscribers.set(t, new Set())
subscribers.get(t).add(client)
}
// Remove on disconnect
res.on('close', () => {
for (const t of topics) {
const set = subscribers.get(t)
if (set) {
set.delete(client)
if (set.size === 0) subscribers.delete(t)
}
}
log(`SSE client #${id} disconnected (${email})`)
})
log(`SSE client #${id} connected (${email}) topics=[${topics.join(',')}]`)
return id
}
function broadcast (topic, event, data) {
const set = subscribers.get(topic)
if (!set || set.size === 0) return 0
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
let count = 0
for (const client of set) {
try {
client.res.write(payload)
count++
} catch {
// Client gone, will be cleaned up on close
}
}
return count
}
function broadcastAll (event, data) {
// Broadcast to ALL connected clients regardless of topic
const sent = new Set()
let count = 0
for (const [, set] of subscribers) {
for (const client of set) {
if (!sent.has(client.id)) {
sent.add(client.id)
try {
client.res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
count++
} catch {}
}
}
}
return count
}
// ── ERPNext helpers ──
const ERP_SITE = process.env.ERP_SITE || 'erp.gigafibre.ca'
function erpFetch (path, opts = {}) {
const url = ERP_URL + path
return new Promise((resolve, reject) => {
const parsed = new URL(url)
const reqOpts = {
hostname: parsed.hostname,
port: parsed.port || 8000,
path: parsed.pathname + parsed.search,
method: opts.method || 'GET',
headers: {
Host: ERP_SITE, // Required: Frappe multi-tenant routing needs the site name
Authorization: 'token ' + ERP_TOKEN,
'Content-Type': 'application/json',
...opts.headers,
},
}
const req = http.request(reqOpts, (res) => {
let body = ''
res.on('data', c => body += c)
res.on('end', () => {
try { resolve({ status: res.statusCode, data: JSON.parse(body) }) }
catch { resolve({ status: res.statusCode, data: body }) }
})
})
req.on('error', reject)
if (opts.body) req.write(typeof opts.body === 'string' ? opts.body : JSON.stringify(opts.body))
req.end()
})
}
// Convenience wrapper: erpRequest(method, path, body?) → { status, data }
async function erpRequest (method, path, body) {
const opts = { method }
if (body) opts.body = body
return erpFetch(path, opts)
}
async function lookupCustomerByPhone (phone) {
// Normalize to last 10 digits
const digits = phone.replace(/\D/g, '').slice(-10)
const fields = JSON.stringify(['name', 'customer_name', 'cell_phone', 'tel_home', 'tel_office'])
// Search across all phone fields: cell_phone, tel_home, tel_office
for (const field of ['cell_phone', 'tel_home', 'tel_office']) {
const filters = JSON.stringify([[field, 'like', '%' + digits]])
const path = `/api/resource/Customer?filters=${encodeURIComponent(filters)}&fields=${encodeURIComponent(fields)}&limit_page_length=1`
try {
const res = await erpFetch(path)
if (res.status === 200 && res.data.data && res.data.data.length > 0) {
return res.data.data[0]
}
} catch (e) {
log('lookupCustomerByPhone error on ' + field + ':', e.message)
}
}
return null
}
async function createCommunication (fields) {
return erpFetch('/api/resource/Communication', {
method: 'POST',
body: JSON.stringify(fields),
})
}
// ── Request body parser ──
function parseBody (req) {
return new Promise((resolve, reject) => {
const chunks = []
req.on('data', c => chunks.push(c))
req.on('end', () => {
const raw = Buffer.concat(chunks).toString()
const ct = (req.headers['content-type'] || '').toLowerCase()
if (ct.includes('application/json')) {
try { resolve(JSON.parse(raw)) } catch { resolve({}) }
} else if (ct.includes('urlencoded')) {
// Twilio sends application/x-www-form-urlencoded
const params = new URLSearchParams(raw)
const obj = {}
for (const [k, v] of params) obj[k] = v
resolve(obj)
} else {
resolve(raw)
}
})
req.on('error', reject)
})
}
// ── Logging ──
function log (...args) {
console.log(`[${new Date().toISOString().slice(11, 19)}]`, ...args)
}
// ── HTTP Server ──
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://localhost:${PORT}`)
const path = url.pathname
const method = req.method
// CORS headers (for browser SSE + POST from ops/client apps)
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Authentik-Email, X-Authentik-Groups')
if (method === 'OPTIONS') {
res.writeHead(204)
return res.end()
}
try {
// ─── Health ───
if (path === '/health') {
const clientCount = new Set()
for (const [, set] of subscribers) for (const c of set) clientCount.add(c.id)
return json(res, 200, {
ok: true,
clients: clientCount.size,
topics: subscribers.size,
uptime: Math.floor(process.uptime()),
})
}
// ─── SSE endpoint ───
if (path === '/sse' && method === 'GET') {
const email = req.headers['x-authentik-email'] || 'anonymous'
const topicsParam = url.searchParams.get('topics') || ''
const topics = topicsParam.split(',').map(t => t.trim()).filter(Boolean)
if (!topics.length) {
return json(res, 400, { error: 'Missing topics parameter' })
}
// Set SSE headers
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no', // Disable nginx/traefik buffering
})
// Send initial comment to establish connection
res.write(': connected\n\n')
const clientId = addClient(topics, res, email)
// Keepalive ping every 25s
const keepalive = setInterval(() => {
try { res.write(': ping\n\n') } catch { clearInterval(keepalive) }
}, 25000)
res.on('close', () => clearInterval(keepalive))
return
}
// ─── Broadcast (internal) ───
if (path === '/broadcast' && method === 'POST') {
// Auth check
const auth = req.headers.authorization || ''
if (INTERNAL_TOKEN && auth !== 'Bearer ' + INTERNAL_TOKEN) {
return json(res, 401, { error: 'Unauthorized' })
}
const body = await parseBody(req)
const { topic, event, data } = body
if (!topic || !event) {
return json(res, 400, { error: 'Missing topic or event' })
}
const count = broadcast(topic, event || 'message', data || {})
return json(res, 200, { ok: true, delivered: count })
}
// ─── Twilio SMS Incoming ───
if (path === '/webhook/twilio/sms-incoming' && method === 'POST') {
const body = await parseBody(req)
const from = body.From || ''
const to = body.To || ''
const text = body.Body || ''
const sid = body.MessageSid || ''
log(`SMS IN: ${from}${to}: ${text.substring(0, 50)}...`)
// Respond to Twilio immediately (they expect TwiML or 200)
res.writeHead(200, { 'Content-Type': 'text/xml' })
res.end('<Response></Response>')
// Process async: lookup customer, log, broadcast
setImmediate(async () => {
try {
const customer = await lookupCustomerByPhone(from)
const customerName = customer ? customer.name : null
const customerLabel = customer ? customer.customer_name : 'Inconnu'
// Log Communication in ERPNext
if (customerName) {
await createCommunication({
communication_type: 'Communication',
communication_medium: 'SMS',
sent_or_received: 'Received',
sender: 'sms@gigafibre.ca',
sender_full_name: customerLabel,
phone_no: from,
content: text,
subject: 'SMS from ' + from,
reference_doctype: 'Customer',
reference_name: customerName,
message_id: sid,
status: 'Open',
})
}
// Broadcast SSE event
const eventData = {
type: 'sms',
direction: 'in',
customer: customerName,
customer_name: customerLabel,
phone: from,
text,
sid,
ts: new Date().toISOString(),
}
let n = 0
if (customerName) {
n = broadcast('customer:' + customerName, 'message', eventData)
}
// Also broadcast to global topic for dispatch/monitoring
broadcastAll('sms-incoming', eventData)
log(`SMS logged: ${from}${customerName || 'UNKNOWN'} (${sid}) broadcast=${n}`)
} catch (e) {
log('SMS processing error:', e.message)
}
})
return
}
// ─── Twilio SMS Status ───
if (path === '/webhook/twilio/sms-status' && method === 'POST') {
const body = await parseBody(req)
const sid = body.MessageSid || body.SmsSid || ''
const status = body.MessageStatus || body.SmsStatus || ''
log(`SMS STATUS: ${sid}${status}`)
// Respond immediately
res.writeHead(200, { 'Content-Type': 'text/xml' })
res.end('<Response></Response>')
// Broadcast status update
setImmediate(() => {
broadcastAll('sms-status', { sid, status, ts: new Date().toISOString() })
})
return
}
// ─── Send SMS (outbound, called from ops/client apps) ───
if (path === '/send/sms' && method === 'POST') {
const body = await parseBody(req)
const { phone, message, customer } = body
if (!phone || !message) {
return json(res, 400, { error: 'Missing phone or message' })
}
// Send via Twilio
const twilioData = new URLSearchParams({
To: phone.startsWith('+') ? phone : '+' + phone,
From: TWILIO_FROM,
Body: message,
StatusCallback: (process.env.HUB_PUBLIC_URL || 'https://msg.gigafibre.ca') + '/webhook/twilio/sms-status',
})
const twilioRes = await new Promise((resolve, reject) => {
const authStr = Buffer.from(TWILIO_ACCOUNT_SID + ':' + TWILIO_AUTH_TOKEN).toString('base64')
const twilioReq = require('https').request({
hostname: 'api.twilio.com',
path: `/2010-04-01/Accounts/${TWILIO_ACCOUNT_SID}/Messages.json`,
method: 'POST',
headers: {
Authorization: 'Basic ' + authStr,
'Content-Type': 'application/x-www-form-urlencoded',
},
}, (res) => {
let body = ''
res.on('data', c => body += c)
res.on('end', () => {
try { resolve({ status: res.statusCode, data: JSON.parse(body) }) }
catch { resolve({ status: res.statusCode, data: body }) }
})
})
twilioReq.on('error', reject)
twilioReq.write(twilioData.toString())
twilioReq.end()
})
if (twilioRes.status >= 400) {
log('Twilio send error:', twilioRes.data)
return json(res, 502, { ok: false, error: 'Twilio error', details: twilioRes.data })
}
const sid = twilioRes.data.sid || ''
log(`SMS OUT: → ${phone}: ${message.substring(0, 50)}... (${sid})`)
// Log Communication in ERPNext
if (customer) {
await createCommunication({
communication_type: 'Communication',
communication_medium: 'SMS',
sent_or_received: 'Sent',
sender: 'sms@gigafibre.ca',
sender_full_name: 'Targo Ops',
phone_no: phone,
content: message,
subject: 'SMS to ' + phone,
reference_doctype: 'Customer',
reference_name: customer,
message_id: sid,
status: 'Linked',
})
// Broadcast SSE
broadcast('customer:' + customer, 'message', {
type: 'sms',
direction: 'out',
customer,
phone,
text: message,
sid,
ts: new Date().toISOString(),
})
}
return json(res, 200, { ok: true, sid })
}
// ─── Twilio Voice: Generate Access Token ───
if (path === '/voice/token' && method === 'GET') {
if (!TWILIO_API_KEY || !TWILIO_API_SECRET || !TWILIO_TWIML_APP_SID) {
return json(res, 503, { error: 'Twilio Voice not configured' })
}
const identity = url.searchParams.get('identity') || req.headers['x-authentik-email'] || 'ops-agent'
try {
const twilio = require('twilio')
const AccessToken = twilio.jwt.AccessToken
const VoiceGrant = AccessToken.VoiceGrant
const token = new AccessToken(
TWILIO_ACCOUNT_SID,
TWILIO_API_KEY,
TWILIO_API_SECRET,
{ identity, ttl: 3600 }
)
const grant = new VoiceGrant({
outgoingApplicationSid: TWILIO_TWIML_APP_SID,
incomingAllow: true,
})
token.addGrant(grant)
log(`Voice token generated for ${identity}`)
return json(res, 200, { token: token.toJwt(), identity })
} catch (e) {
log('Voice token error:', e.message)
return json(res, 500, { error: 'Token generation failed: ' + e.message })
}
}
// ─── Fonoster SIP: Get SIP config for WebRTC client ───
if (path === '/voice/sip-config' && method === 'GET') {
// Return SIP credentials for the browser client
// These are configured via env vars or fetched from Fonoster API
const sipConfig = {
wssUrl: process.env.SIP_WSS_URL || 'wss://voice.gigafibre.ca:5063',
domain: process.env.SIP_DOMAIN || 'voice.gigafibre.ca',
extension: process.env.SIP_EXTENSION || '1001',
authId: process.env.SIP_AUTH_ID || '1001',
authPassword: process.env.SIP_AUTH_PASSWORD || '',
displayName: process.env.SIP_DISPLAY_NAME || 'Targo Ops',
identity: process.env.SIP_EXTENSION || '1001',
}
return json(res, 200, sipConfig)
}
// ─── Twilio Voice: TwiML for outbound calls ───
if (path === '/voice/twiml' && method === 'POST') {
const body = await parseBody(req)
const to = body.To || body.phone || ''
log(`Voice TwiML: dialing ${to}`)
// Return TwiML to dial the number
const callerId = TWILIO_FROM || '+14382313838'
const twiml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Dial callerId="${callerId}" answerOnBridge="true" timeout="30">
<Number statusCallbackEvent="initiated ringing answered completed"
statusCallback="${(process.env.HUB_PUBLIC_URL || 'https://msg.gigafibre.ca')}/voice/status"
statusCallbackMethod="POST">${to}</Number>
</Dial>
</Response>`
res.writeHead(200, { 'Content-Type': 'text/xml' })
return res.end(twiml)
}
// ─── Twilio Voice: Call status callback ───
if (path === '/voice/status' && method === 'POST') {
const body = await parseBody(req)
const callSid = body.CallSid || ''
const callStatus = body.CallStatus || ''
const to = body.To || ''
const from = body.From || ''
const duration = parseInt(body.CallDuration || '0', 10)
log(`Voice STATUS: ${callSid} ${from}${to} status=${callStatus} dur=${duration}s`)
res.writeHead(200, { 'Content-Type': 'text/xml' })
res.end('<Response></Response>')
// On completed, log to ERPNext and broadcast
if (callStatus === 'completed' && duration > 0) {
setImmediate(async () => {
try {
const phone = to.startsWith('client:') ? from : to
const customer = await lookupCustomerByPhone(phone)
const customerName = customer ? customer.name : null
if (customerName) {
const durationMin = Math.floor(duration / 60)
const durationSec = duration % 60
const durationStr = `${durationMin}m${durationSec.toString().padStart(2, '0')}s`
await createCommunication({
communication_type: 'Communication',
communication_medium: 'Phone',
sent_or_received: 'Sent',
sender: 'sms@gigafibre.ca',
sender_full_name: 'Targo Ops',
phone_no: phone,
content: `Appel vers ${phone} — Duree: ${durationStr}`,
subject: `Appel vers ${phone}`,
reference_doctype: 'Customer',
reference_name: customerName,
status: 'Linked',
})
broadcast('customer:' + customerName, 'call-event', {
type: 'call', event: 'completed', direction: 'out',
customer: customerName, phone, duration,
call_id: callSid, ts: new Date().toISOString(),
})
log(`Voice logged: ${phone}${customerName} (${durationStr})`)
}
} catch (e) {
log('Voice status processing error:', e.message)
}
})
}
return
}
// ─── 3CX Call Event Webhook (CRM Integration) ───
if (path === '/webhook/3cx/call-event' && method === 'POST') {
const body = await parseBody(req)
// 3CX CRM Integration sends: event_type, call_id, direction, caller, callee, ext, duration, status, etc.
const eventType = body.event_type || body.EventType || body.type || ''
const callId = body.call_id || body.CallId || body.id || ''
const direction = (body.direction || body.Direction || '').toLowerCase() // inbound / outbound
const caller = body.caller || body.Caller || body.from || ''
const callee = body.callee || body.Callee || body.to || ''
const ext = body.ext || body.Extension || body.extension || ''
const duration = parseInt(body.duration || body.Duration || '0', 10)
const status = body.status || body.Status || eventType
// Determine the remote phone number (not the extension)
const isOutbound = direction === 'outbound' || direction === 'out'
const remotePhone = isOutbound ? callee : caller
log(`3CX ${eventType}: ${caller}${callee} ext=${ext} dir=${direction} dur=${duration}s status=${status} (${callId})`)
// Respond immediately
json(res, 200, { ok: true })
// Process async: lookup customer, log communication, broadcast SSE
setImmediate(async () => {
try {
// Only log completed calls (not ringing/answered events) to avoid duplicates
const isCallEnd = ['ended', 'completed', 'hangup', 'Notified', 'missed'].some(
s => eventType.toLowerCase().includes(s.toLowerCase()) || status.toLowerCase().includes(s.toLowerCase())
)
const customer = remotePhone ? await lookupCustomerByPhone(remotePhone) : null
const customerName = customer ? customer.name : null
const customerLabel = customer ? customer.customer_name : 'Inconnu'
// Log Communication in ERPNext for ended/completed calls
if (isCallEnd && customerName && duration > 0) {
const durationMin = Math.floor(duration / 60)
const durationSec = duration % 60
const durationStr = `${durationMin}m${durationSec.toString().padStart(2, '0')}s`
await createCommunication({
communication_type: 'Communication',
communication_medium: 'Phone',
sent_or_received: isOutbound ? 'Sent' : 'Received',
sender: 'sms@gigafibre.ca',
sender_full_name: isOutbound ? 'Targo Ops' : customerLabel,
phone_no: remotePhone,
content: `Appel ${isOutbound ? 'sortant vers' : 'entrant de'} ${remotePhone} — Duree: ${durationStr}`,
subject: `Appel ${isOutbound ? 'vers' : 'de'} ${remotePhone}`,
reference_doctype: 'Customer',
reference_name: customerName,
status: 'Linked',
})
}
// Broadcast SSE event for ALL call events (ringing, answered, ended)
const eventData = {
type: 'call',
event: eventType,
direction: isOutbound ? 'out' : 'in',
customer: customerName,
customer_name: customerLabel,
phone: remotePhone,
extension: ext,
duration,
status,
call_id: callId,
ts: new Date().toISOString(),
}
let n = 0
if (customerName) {
n = broadcast('customer:' + customerName, 'call-event', eventData)
}
// Also broadcast globally for dispatch monitoring
broadcastAll('call-event', eventData)
log(`3CX logged: ${remotePhone}${customerName || 'UNKNOWN'} (${callId}) broadcast=${n}`)
} catch (e) {
log('3CX call processing error:', e.message)
}
})
return
}
// ─── Fonoster Telephony Management API ───
// All /telephony/* endpoints manage the Fonoster CPaaS (trunks, agents, credentials, numbers, domains)
if (path.startsWith('/telephony/')) {
return handleTelephony(req, res, method, path, url)
}
// ─── GenieACS Device Management API ───
// Proxy to GenieACS NBI for CPE/ONT management
if (path.startsWith('/devices')) {
return handleGenieACS(req, res, method, path, url)
}
// ─── GenieACS Config Export (provisions, presets, virtual params, files) ───
if (path.startsWith('/acs/')) {
return handleACSConfig(req, res, method, path, url)
}
// ─── Installation / Provisioning API ───
// OLT pre-authorization, equipment activation, barcode-triggered workflows
if (path.startsWith('/provision/')) {
return handleProvisioning(req, res, method, path, url)
}
// ─── 404 ───
json(res, 404, { error: 'Not found' })
} catch (e) {
log('ERROR:', e.message)
json(res, 500, { error: 'Internal error' })
}
})
function json (res, status, data) {
res.writeHead(status, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(data))
}
// ── Fonoster Telephony Management (Direct DB) ──
// Access Routr + Fonoster identity PostgreSQL databases directly
let routrPool = null
let identityPool = null
function getRoutrPool () {
if (!routrPool) {
const { Pool } = require('pg')
routrPool = new Pool({ connectionString: ROUTR_DB_URL, max: 3 })
routrPool.on('error', e => log('Routr DB pool error:', e.message))
}
return routrPool
}
function getIdentityPool () {
if (!identityPool) {
const { Pool } = require('pg')
identityPool = new Pool({ connectionString: FNIDENTITY_DB_URL, max: 3 })
identityPool.on('error', e => log('Identity DB pool error:', e.message))
}
return identityPool
}
// Routr resource tables
const ROUTR_TABLES = {
trunks: 'trunks',
agents: 'agents',
credentials: 'credentials',
numbers: 'numbers',
domains: 'domains',
acls: 'access_control_lists',
peers: 'peers',
}
// Identity resource tables
const IDENTITY_TABLES = {
workspaces: 'workspaces',
users: 'users',
}
async function handleTelephony (req, res, method, path, url) {
try {
const parts = path.replace('/telephony/', '').split('/').filter(Boolean)
const resource = parts[0]
const ref = parts[1] || null
// GET /telephony/overview — Dashboard summary
if (resource === 'overview' && method === 'GET') {
const rPool = getRoutrPool()
const iPool = getIdentityPool()
const [trunks, agents, creds, numbers, domains, peers, workspaces] = await Promise.all([
rPool.query('SELECT COUNT(*) FROM trunks'),
rPool.query('SELECT COUNT(*) FROM agents'),
rPool.query('SELECT COUNT(*) FROM credentials'),
rPool.query('SELECT COUNT(*) FROM numbers'),
rPool.query('SELECT COUNT(*) FROM domains'),
rPool.query('SELECT COUNT(*) FROM peers'),
iPool.query('SELECT COUNT(*) FROM workspaces'),
])
return json(res, 200, {
trunks: parseInt(trunks.rows[0].count),
agents: parseInt(agents.rows[0].count),
credentials: parseInt(creds.rows[0].count),
numbers: parseInt(numbers.rows[0].count),
domains: parseInt(domains.rows[0].count),
peers: parseInt(peers.rows[0].count),
workspaces: parseInt(workspaces.rows[0].count),
})
}
// Determine which database
const isIdentity = IDENTITY_TABLES[resource]
const tableName = IDENTITY_TABLES[resource] || ROUTR_TABLES[resource]
if (!tableName) {
return json(res, 404, { error: `Unknown resource: ${resource}. Available: overview, ${[...Object.keys(ROUTR_TABLES), ...Object.keys(IDENTITY_TABLES)].join(', ')}` })
}
const pool = isIdentity ? getIdentityPool() : getRoutrPool()
// GET /telephony/{resource} — List all
if (method === 'GET' && !ref) {
const limit = parseInt(url.searchParams.get('limit') || '100', 10)
const offset = parseInt(url.searchParams.get('offset') || '0', 10)
const result = await pool.query(
`SELECT * FROM ${tableName} ORDER BY created_at DESC LIMIT $1 OFFSET $2`,
[limit, offset]
)
const count = await pool.query(`SELECT COUNT(*) FROM ${tableName}`)
return json(res, 200, { items: result.rows, total: parseInt(count.rows[0].count, 10) })
}
// GET /telephony/{resource}/{ref} — Get by ref
if (method === 'GET' && ref) {
const result = await pool.query(`SELECT * FROM ${tableName} WHERE ref = $1`, [ref])
if (result.rows.length === 0) return json(res, 404, { error: 'Not found' })
return json(res, 200, result.rows[0])
}
// POST /telephony/{resource} — Create
if (method === 'POST' && !ref) {
const body = await parseBody(req)
// Generate ref if not provided
if (!body.ref) {
const crypto = require('crypto')
body.ref = crypto.randomUUID()
}
if (!body.api_version) body.api_version = 'v2'
if (!body.created_at) body.created_at = new Date()
if (!body.updated_at) body.updated_at = new Date()
const keys = Object.keys(body)
const values = Object.values(body)
const placeholders = keys.map((_, i) => `$${i + 1}`)
const query = `INSERT INTO ${tableName} (${keys.join(', ')}) VALUES (${placeholders.join(', ')}) RETURNING *`
const result = await pool.query(query, values)
log(`Telephony: created ${resource}/${result.rows[0].ref}`)
return json(res, 201, result.rows[0])
}
// PUT /telephony/{resource}/{ref} — Update
if (method === 'PUT' && ref) {
const body = await parseBody(req)
body.updated_at = new Date()
delete body.ref
delete body.created_at
const keys = Object.keys(body)
const values = Object.values(body)
const setClauses = keys.map((k, i) => `${k} = $${i + 1}`)
values.push(ref)
const query = `UPDATE ${tableName} SET ${setClauses.join(', ')} WHERE ref = $${values.length} RETURNING *`
const result = await pool.query(query, values)
if (result.rows.length === 0) return json(res, 404, { error: 'Not found' })
log(`Telephony: updated ${resource}/${ref}`)
return json(res, 200, result.rows[0])
}
// DELETE /telephony/{resource}/{ref} — Delete
if (method === 'DELETE' && ref) {
const result = await pool.query(`DELETE FROM ${tableName} WHERE ref = $1 RETURNING ref`, [ref])
if (result.rows.length === 0) return json(res, 404, { error: 'Not found' })
log(`Telephony: deleted ${resource}/${ref}`)
return json(res, 200, { ok: true, deleted: ref })
}
return json(res, 405, { error: 'Method not allowed' })
} catch (e) {
log('Telephony error:', e.message)
return json(res, 500, { error: e.message })
}
}
// ── 3CX Call Log Poller ──
// Polls 3CX xAPI for recently completed calls, logs new ones to ERPNext + SSE
let pbxToken = null
let pbxTokenExpiry = 0
const processedCallIds = new Set() // Track already-processed call IDs (in memory, survives poll cycles)
let pbxPollTimer = null
function httpsFetch (url, opts = {}) {
return new Promise((resolve, reject) => {
const parsed = new URL(url)
const reqOpts = {
hostname: parsed.hostname,
port: parsed.port || 443,
path: parsed.pathname + parsed.search,
method: opts.method || 'GET',
headers: opts.headers || {},
}
const req = https.request(reqOpts, (res) => {
let body = ''
res.on('data', c => body += c)
res.on('end', () => {
try { resolve({ status: res.statusCode, data: JSON.parse(body) }) }
catch { resolve({ status: res.statusCode, data: body }) }
})
})
req.on('error', reject)
if (opts.body) req.write(typeof opts.body === 'string' ? opts.body : JSON.stringify(opts.body))
req.end()
})
}
async function get3cxToken () {
if (pbxToken && Date.now() < pbxTokenExpiry - 60000) return pbxToken // 1min buffer
if (!PBX_USER || !PBX_PASS) return null
try {
const res = await httpsFetch(PBX_URL + '/webclient/api/Login/GetAccessToken', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ Username: PBX_USER, Password: PBX_PASS }),
})
if (res.data.Status === 'AuthSuccess') {
pbxToken = res.data.Token.access_token
pbxTokenExpiry = Date.now() + (res.data.Token.expires_in * 1000)
return pbxToken
}
log('3CX auth failed:', res.data.Status)
} catch (e) {
log('3CX auth error:', e.message)
}
return null
}
async function poll3cxCallLog () {
const token = await get3cxToken()
if (!token) return
try {
// Fetch last 20 calls from the last 5 minutes
const since = new Date(Date.now() - 5 * 60 * 1000).toISOString()
const url = `${PBX_URL}/xapi/v1/ReportCallLogData?$top=20&$orderby=StartTime%20desc&$filter=StartTime%20gt%20${encodeURIComponent(since)}`
const res = await httpsFetch(url, {
headers: { Authorization: 'Bearer ' + token },
})
if (res.status !== 200 || !res.data.value) return
for (const call of res.data.value) {
const callId = call.CdrId || call.CallHistoryId || ''
if (!callId || processedCallIds.has(callId)) continue
// Only process answered/completed calls (not segments still in progress)
if (!call.TalkingDuration && !call.Answered) continue
processedCallIds.add(callId)
// Parse call data
const direction = (call.Direction || '').toLowerCase().includes('inbound') ? 'in' : 'out'
const isOutbound = direction === 'out'
const sourcePhone = call.SourceCallerId || call.SourceDn || ''
const destPhone = call.DestinationCallerId || call.DestinationDn || ''
const remotePhone = isOutbound ? destPhone : sourcePhone
const ext = isOutbound ? call.SourceDn : call.DestinationDn
// Parse duration from ISO 8601 duration (PT1M30S) or seconds
let durationSec = 0
const td = call.TalkingDuration || ''
if (typeof td === 'string' && td.startsWith('PT')) {
const minMatch = td.match(/(\d+)M/)
const secMatch = td.match(/(\d+)S/)
const hrMatch = td.match(/(\d+)H/)
durationSec = (hrMatch ? parseInt(hrMatch[1]) * 3600 : 0) +
(minMatch ? parseInt(minMatch[1]) * 60 : 0) +
(secMatch ? parseInt(secMatch[1]) : 0)
} else if (typeof td === 'number') {
durationSec = td
}
// Skip very short/empty calls
if (durationSec < 3 && !call.Answered) continue
log(`3CX POLL: ${sourcePhone}${destPhone} dir=${direction} dur=${durationSec}s answered=${call.Answered} (${callId})`)
// Lookup customer and log
try {
const customer = remotePhone ? await lookupCustomerByPhone(remotePhone) : null
const customerName = customer ? customer.name : null
const customerLabel = customer ? customer.customer_name : 'Inconnu'
// Log Communication in ERPNext
if (customerName) {
const durationMin = Math.floor(durationSec / 60)
const durationS = durationSec % 60
const durationStr = `${durationMin}m${durationS.toString().padStart(2, '0')}s`
await createCommunication({
communication_type: 'Communication',
communication_medium: 'Phone',
sent_or_received: isOutbound ? 'Sent' : 'Received',
sender: 'sms@gigafibre.ca',
sender_full_name: isOutbound ? (call.SourceDisplayName || 'Targo Ops') : customerLabel,
phone_no: remotePhone,
content: `Appel ${isOutbound ? 'sortant vers' : 'entrant de'} ${remotePhone} — Duree: ${durationStr}${call.Answered ? '' : ' (manque)'}`,
subject: `Appel ${isOutbound ? 'vers' : 'de'} ${remotePhone}`,
reference_doctype: 'Customer',
reference_name: customerName,
status: 'Linked',
})
log(`3CX logged to ERPNext: ${remotePhone}${customerName} (${durationStr})`)
}
// Broadcast SSE
const eventData = {
type: 'call',
event: 'completed',
direction,
customer: customerName,
customer_name: customerLabel,
phone: remotePhone,
extension: ext,
duration: durationSec,
answered: call.Answered,
call_id: callId,
ts: call.StartTime || new Date().toISOString(),
}
if (customerName) {
broadcast('customer:' + customerName, 'call-event', eventData)
}
broadcastAll('call-event', eventData)
} catch (e) {
log('3CX poll processing error:', e.message)
}
}
// Prune old processed IDs (keep last 500)
if (processedCallIds.size > 500) {
const arr = [...processedCallIds]
arr.splice(0, arr.length - 500)
processedCallIds.clear()
arr.forEach(id => processedCallIds.add(id))
}
} catch (e) {
log('3CX poll error:', e.message)
}
}
function start3cxPoller () {
if (!PBX_ENABLED) {
log('3CX poller: DISABLED (PBX_ENABLED != 1, using Twilio instead)')
return
}
if (!PBX_USER || !PBX_PASS) {
log('3CX poller: DISABLED (no PBX_USER/PBX_PASS)')
return
}
log(`3CX poller: ENABLED (every ${PBX_POLL_INTERVAL / 1000}s) → ${PBX_URL}`)
// Initial poll after 5s
setTimeout(poll3cxCallLog, 5000)
pbxPollTimer = setInterval(poll3cxCallLog, PBX_POLL_INTERVAL)
}
// ── GenieACS NBI API Proxy ──
// Provides /devices endpoints for CPE/ONT management via GenieACS NBI (port 7557)
function nbiRequest (nbiPath, method = 'GET', body = null, extraHeaders = {}) {
return new Promise((resolve, reject) => {
const u = new URL(nbiPath, GENIEACS_NBI_URL)
const opts = {
hostname: u.hostname,
port: u.port || 7557,
path: u.pathname + u.search,
method,
headers: { 'Content-Type': 'application/json', ...extraHeaders },
timeout: 10000,
}
const transport = u.protocol === 'https:' ? https : http
const req = transport.request(opts, (resp) => {
let data = ''
resp.on('data', c => { data += c })
resp.on('end', () => {
try {
resolve({ status: resp.statusCode, data: data ? JSON.parse(data) : null })
} catch {
resolve({ status: resp.statusCode, data: data })
}
})
})
req.on('error', reject)
req.on('timeout', () => { req.destroy(); reject(new Error('NBI timeout')) })
if (body) req.write(typeof body === 'string' ? body : JSON.stringify(body))
req.end()
})
}
async function handleGenieACS (req, res, method, path, url) {
try {
if (!GENIEACS_NBI_URL) {
return json(res, 503, { error: 'GenieACS NBI not configured' })
}
const parts = path.replace('/devices', '').split('/').filter(Boolean)
// parts: [] = list, [serial] = device detail, [serial, action] = action
// GET /devices — List all devices (with optional query filter)
if (parts.length === 0 && method === 'GET') {
const q = url.searchParams.get('q') || ''
const projection = url.searchParams.get('projection') || ''
const limit = url.searchParams.get('limit') || '50'
const skip = url.searchParams.get('skip') || '0'
let nbiPath = `/devices/?limit=${limit}&skip=${skip}`
if (q) nbiPath += '&query=' + encodeURIComponent(q)
if (projection) nbiPath += '&projection=' + encodeURIComponent(projection)
const r = await nbiRequest(nbiPath)
return json(res, r.status, r.data)
}
// GET /devices/summary — Aggregated device stats
if (parts[0] === 'summary' && method === 'GET') {
// Get all devices with minimal projection for counting
const r = await nbiRequest('/devices/?projection=_deviceId,_tags&limit=10000')
if (r.status !== 200 || !Array.isArray(r.data)) {
return json(res, r.status, r.data || { error: 'Failed to fetch devices' })
}
const devices = r.data
const stats = {
total: devices.length,
byManufacturer: {},
byProductClass: {},
byTag: {},
}
for (const d of devices) {
const did = d._deviceId || {}
const mfr = did._Manufacturer || 'Unknown'
const pc = did._ProductClass || 'Unknown'
stats.byManufacturer[mfr] = (stats.byManufacturer[mfr] || 0) + 1
stats.byProductClass[pc] = (stats.byProductClass[pc] || 0) + 1
if (d._tags && Array.isArray(d._tags)) {
for (const t of d._tags) {
stats.byTag[t] = (stats.byTag[t] || 0) + 1
}
}
}
return json(res, 200, stats)
}
// GET /devices/:id — Single device detail
if (parts.length === 1 && method === 'GET') {
const deviceId = decodeURIComponent(parts[0])
const q = JSON.stringify({ _id: deviceId })
const r = await nbiRequest('/devices/?query=' + encodeURIComponent(q))
if (r.status !== 200) return json(res, r.status, r.data)
const devices = Array.isArray(r.data) ? r.data : []
if (!devices.length) return json(res, 404, { error: 'Device not found' })
return json(res, 200, devices[0])
}
// POST /devices/:id/reboot — Reboot a CPE
if (parts.length === 2 && parts[1] === 'reboot' && method === 'POST') {
const deviceId = decodeURIComponent(parts[0])
const task = { name: 'reboot' }
const r = await nbiRequest(
'/devices/' + encodeURIComponent(deviceId) + '/tasks?connection_request',
'POST', task
)
return json(res, r.status, r.data)
}
// POST /devices/:id/refresh — Force parameter refresh
if (parts.length === 2 && parts[1] === 'refresh' && method === 'POST') {
const deviceId = decodeURIComponent(parts[0])
const task = {
name: 'getParameterValues',
parameterNames: [
'InternetGatewayDevice.',
'Device.',
],
}
const r = await nbiRequest(
'/devices/' + encodeURIComponent(deviceId) + '/tasks?connection_request',
'POST', task
)
return json(res, r.status, r.data)
}
// POST /devices/:id/set — Set parameter values
if (parts.length === 2 && parts[1] === 'set' && method === 'POST') {
const deviceId = decodeURIComponent(parts[0])
const body = await parseBody(req)
const parsed = JSON.parse(body)
// Expected: { parameterValues: [["path", "value", "type"], ...] }
if (!parsed.parameterValues) {
return json(res, 400, { error: 'Missing parameterValues array' })
}
const task = {
name: 'setParameterValues',
parameterValues: parsed.parameterValues,
}
const r = await nbiRequest(
'/devices/' + encodeURIComponent(deviceId) + '/tasks?connection_request',
'POST', task
)
return json(res, r.status, r.data)
}
// DELETE /devices/:id/tasks/:taskId — Cancel a pending task
if (parts.length === 3 && parts[1] === 'tasks' && method === 'DELETE') {
const taskId = parts[2]
const r = await nbiRequest('/tasks/' + taskId, 'DELETE')
return json(res, r.status, r.data)
}
// GET /devices/:id/tasks — List tasks for a device
if (parts.length === 2 && parts[1] === 'tasks' && method === 'GET') {
const deviceId = decodeURIComponent(parts[0])
const q = JSON.stringify({ device: deviceId })
const r = await nbiRequest('/tasks/?query=' + encodeURIComponent(q))
return json(res, r.status, r.data)
}
// GET /devices/:id/faults — List faults for a device
if (parts.length === 2 && parts[1] === 'faults' && method === 'GET') {
const deviceId = decodeURIComponent(parts[0])
const q = JSON.stringify({ device: deviceId })
const r = await nbiRequest('/faults/?query=' + encodeURIComponent(q))
return json(res, r.status, r.data)
}
// POST /devices/:id/tag — Add a tag
if (parts.length === 2 && parts[1] === 'tag' && method === 'POST') {
const deviceId = decodeURIComponent(parts[0])
const body = await parseBody(req)
const { tag } = JSON.parse(body)
if (!tag) return json(res, 400, { error: 'Missing tag' })
const r = await nbiRequest(
'/devices/' + encodeURIComponent(deviceId) + '/tags/' + encodeURIComponent(tag),
'POST'
)
return json(res, r.status, r.data)
}
// DELETE /devices/:id/tag/:tag — Remove a tag
if (parts.length === 3 && parts[1] === 'tag' && method === 'DELETE') {
const deviceId = decodeURIComponent(parts[0])
const tag = decodeURIComponent(parts[2])
const r = await nbiRequest(
'/devices/' + encodeURIComponent(deviceId) + '/tags/' + encodeURIComponent(tag),
'DELETE'
)
return json(res, r.status, r.data)
}
// GET /devices/faults — List all faults
if (parts[0] === 'faults' && parts.length === 1 && method === 'GET') {
const r = await nbiRequest('/faults/')
return json(res, r.status, r.data)
}
return json(res, 404, { error: 'Unknown device endpoint' })
} catch (e) {
log('GenieACS error:', e.message)
return json(res, 502, { error: 'GenieACS NBI error: ' + e.message })
}
}
// ── GenieACS Config Export ──
// Extract provisions, presets, virtual parameters, files metadata from GenieACS NBI
// Used for migration to Oktopus TR-369 and as backup of all ACS logic
async function handleACSConfig (req, res, method, path, url) {
if (!GENIEACS_NBI_URL) return json(res, 503, { error: 'GenieACS NBI not configured' })
try {
const parts = path.replace('/acs/', '').split('/').filter(Boolean)
const resource = parts[0]
// GET /acs/provisions — list all provision scripts
if (resource === 'provisions' && method === 'GET') {
const r = await genieRequest('GET', '/provisions/')
return json(res, r.status, r.data)
}
// GET /acs/provisions/:id — get single provision script
if (resource === 'provisions' && parts[1] && method === 'GET') {
const id = decodeURIComponent(parts[1])
const r = await genieRequest('GET', '/provisions/' + encodeURIComponent(id))
return json(res, r.status, r.data)
}
// GET /acs/presets — list all presets (trigger rules)
if (resource === 'presets' && method === 'GET') {
const r = await genieRequest('GET', '/presets/')
return json(res, r.status, r.data)
}
// GET /acs/virtual-parameters — list all virtual parameters
if (resource === 'virtual-parameters' && method === 'GET') {
const r = await genieRequest('GET', '/virtual-parameters/')
return json(res, r.status, r.data)
}
// GET /acs/files — list all files (firmware, configs)
if (resource === 'files' && method === 'GET') {
const r = await genieRequest('GET', '/files/')
return json(res, r.status, r.data)
}
// GET /acs/faults — list all active faults
if (resource === 'faults' && method === 'GET') {
const r = await genieRequest('GET', '/faults/')
return json(res, r.status, r.data)
}
// GET /acs/export — full export of all config (provisions + presets + virtual params + files metadata)
if (resource === 'export' && method === 'GET') {
const [provisions, presets, virtualParams, files, faults] = await Promise.all([
genieRequest('GET', '/provisions/').then(r => r.data).catch(() => []),
genieRequest('GET', '/presets/').then(r => r.data).catch(() => []),
genieRequest('GET', '/virtual-parameters/').then(r => r.data).catch(() => []),
genieRequest('GET', '/files/').then(r => r.data).catch(() => []),
genieRequest('GET', '/faults/').then(r => r.data).catch(() => []),
])
const exportData = {
exportedAt: new Date().toISOString(),
source: GENIEACS_NBI_URL,
provisions: Array.isArray(provisions) ? provisions : [],
presets: Array.isArray(presets) ? presets : [],
virtualParameters: Array.isArray(virtualParams) ? virtualParams : [],
files: Array.isArray(files) ? files.map(f => ({
_id: f._id,
metadata: f.metadata || {},
filename: f.filename,
length: f.length,
uploadDate: f.uploadDate,
})) : [],
faultCount: Array.isArray(faults) ? faults.length : 0,
summary: {
provisionCount: Array.isArray(provisions) ? provisions.length : 0,
presetCount: Array.isArray(presets) ? presets.length : 0,
virtualParamCount: Array.isArray(virtualParams) ? virtualParams.length : 0,
fileCount: Array.isArray(files) ? files.length : 0,
},
}
return json(res, 200, exportData)
}
return json(res, 404, { error: 'Unknown ACS config endpoint' })
} catch (e) {
log('ACS config error:', e.message)
return json(res, 502, { error: 'GenieACS config error: ' + e.message })
}
}
// ── GenieACS NBI Proxy ──
// Proxies requests to the GenieACS NBI API for CPE/ONT device management
// Endpoints:
// GET /devices — List all devices (with optional query/projection)
// GET /devices/:id — Get single device by _id
// GET /devices/:id/tasks — Get pending tasks for device
// POST /devices/:id/tasks?connection_request — Push task (reboot, getParameterValues, etc.)
// DELETE /devices/:id — Delete device from ACS
// GET /devices/summary — Aggregate device stats (online/offline counts by model)
function genieRequest (method, path, body) {
return new Promise((resolve, reject) => {
const urlObj = new URL(path, GENIEACS_NBI_URL)
const opts = {
hostname: urlObj.hostname,
port: urlObj.port,
path: urlObj.pathname + urlObj.search,
method,
headers: { 'Content-Type': 'application/json' },
timeout: 15000,
}
const proto = urlObj.protocol === 'https:' ? https : http
const req = proto.request(opts, (resp) => {
let data = ''
resp.on('data', chunk => { data += chunk })
resp.on('end', () => {
try {
resolve({ status: resp.statusCode, data: data ? JSON.parse(data) : null })
} catch {
resolve({ status: resp.statusCode, data: data })
}
})
})
req.on('error', reject)
req.on('timeout', () => { req.destroy(); reject(new Error('GenieACS NBI timeout')) })
if (body) req.write(JSON.stringify(body))
req.end()
})
}
// Find the WAN (public) IP by scanning all IP interfaces for a non-RFC1918 address
function findWanIp (d) {
const ifaces = extractAllInterfaces(d)
const pub = ifaces.find(i => i.role === 'internet')
return pub ? pub.ip : ''
}
// Extract all active IP interfaces with role classification
function extractAllInterfaces (d) {
const ipIfaces = d.Device && d.Device.IP && d.Device.IP.Interface
if (!ipIfaces) return []
const results = []
for (const ifKey of Object.keys(ipIfaces)) {
if (ifKey.startsWith('_')) continue
const iface = ipIfaces[ifKey]
if (!iface || !iface.IPv4Address) continue
const name = iface.Name && iface.Name._value || ''
for (const addrKey of Object.keys(iface.IPv4Address)) {
if (addrKey.startsWith('_')) continue
const addr = iface.IPv4Address[addrKey]
if (!addr || !addr.IPAddress || !addr.IPAddress._value) continue
const ip = addr.IPAddress._value
const status = addr.Status && addr.Status._value
if (!ip || ip === '0.0.0.0' || (status && status !== 'Enabled')) continue
const mask = addr.SubnetMask && addr.SubnetMask._value || ''
const addrType = addr.AddressingType && addr.AddressingType._value || ''
// Classify by IP range
let role = 'unknown'
if (ip.startsWith('192.168.') || (ip.startsWith('10.') && name === 'br0')) role = 'lan'
else if (!ip.startsWith('10.') && !ip.startsWith('172.') && !ip.startsWith('192.168.') && !ip.startsWith('169.254.')) role = 'internet'
else if (ip.startsWith('172.17.') || ip.startsWith('172.16.') || name.includes('_10')) role = 'management'
else role = 'service'
results.push({ iface: ifKey, name, ip, mask, addrType, role })
}
}
return results
}
// Sum all WiFi AccessPoint associated device counts (controller only, flat AP tree)
function countAllWifiClients (d) {
const aps = d.Device && d.Device.WiFi && d.Device.WiFi.AccessPoint
if (!aps) return { direct: 0, mesh: 0, total: 0, perAp: [] }
let direct = 0, mesh = 0
const perAp = []
for (const k of Object.keys(aps)) {
if (k.startsWith('_') || !aps[k]) continue
const cnt = aps[k].AssociatedDeviceNumberOfEntries
if (!cnt || cnt._value === undefined) continue
const n = Number(cnt._value) || 0
const ssidRef = aps[k].SSIDReference && aps[k].SSIDReference._value || ''
const ssidIdx = ssidRef.match(/SSID\.(\d+)/)
const idx = ssidIdx ? parseInt(ssidIdx[1]) : parseInt(k)
if (idx <= 8) { direct += n } else { mesh += n }
if (n > 0) perAp.push({ ap: k, ssid: ssidRef, clients: n })
}
return { direct, mesh, total: direct + mesh, perAp }
}
// Extract EasyMesh topology from MultiAP tree — returns mesh nodes + true client totals
function extractMeshTopology (d) {
const apDevices = d.Device && d.Device.WiFi && d.Device.WiFi.MultiAP && d.Device.WiFi.MultiAP.APDevice
if (!apDevices) return null
const nodes = []
let totalClients = 0
for (const dk of Object.keys(apDevices)) {
if (dk.startsWith('_')) continue
const dev = apDevices[dk]
if (!dev || !dev._object) continue
const name = dev.X_TP_HostName && dev.X_TP_HostName._value || ''
const active = dev.X_TP_Active && dev.X_TP_Active._value
const mac = dev.MACAddress && dev.MACAddress._value || ''
const ip = dev.X_TP_IPAddress && dev.X_TP_IPAddress._value || ''
// Sum clients across all radios and APs for this node
let nodeClients = 0
const radios = dev.Radio || {}
for (const rk of Object.keys(radios)) {
if (rk.startsWith('_')) continue
const radio = radios[rk]
if (!radio || !radio.AP) continue
for (const ak of Object.keys(radio.AP)) {
if (ak.startsWith('_')) continue
const ap = radio.AP[ak]
if (!ap) continue
const cnt = ap.AssociatedDeviceNumberOfEntries
if (cnt && cnt._value !== undefined) nodeClients += Number(cnt._value) || 0
}
}
totalClients += nodeClients
if (name || mac) {
nodes.push({ id: dk, name, active: active === true, mac, ip, clients: nodeClients })
}
}
return nodes.length > 0 ? { nodes, totalClients } : null
}
// Extract key parameters from a GenieACS device object into a flat summary
function summarizeDevice (d) {
const get = (path) => {
const node = path.split('.').reduce((obj, k) => obj && obj[k], d)
return node && node._value !== undefined ? node._value : (node && node._object !== undefined ? undefined : null)
}
const getStr = (path) => {
const v = get(path)
return v != null ? String(v) : ''
}
return {
_id: d._id || d['_id'],
serial: getStr('DeviceID.SerialNumber') || (d.DeviceID && d.DeviceID.SerialNumber && d.DeviceID.SerialNumber._value) || '',
manufacturer: getStr('DeviceID.Manufacturer') || (d.DeviceID && d.DeviceID.Manufacturer && d.DeviceID.Manufacturer._value) || '',
model: getStr('DeviceID.ProductClass') || (d.DeviceID && d.DeviceID.ProductClass && d.DeviceID.ProductClass._value) || '',
oui: getStr('DeviceID.OUI') || (d.DeviceID && d.DeviceID.OUI && d.DeviceID.OUI._value) || '',
firmware: getStr('InternetGatewayDevice.DeviceInfo.SoftwareVersion') || getStr('Device.DeviceInfo.SoftwareVersion') || '',
uptime: get('InternetGatewayDevice.DeviceInfo.UpTime') || get('Device.DeviceInfo.UpTime') || null,
lastInform: d['_lastInform'] || null,
lastBoot: d['_lastBootstrap'] || null,
registered: d['_registered'] || null,
interfaces: extractAllInterfaces(d),
ip: getStr('InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress')
|| findWanIp(d) || '',
rxPower: get('InternetGatewayDevice.WANDevice.1.X_GponInterafceConfig.RXPower')
|| get('InternetGatewayDevice.WANDevice.1.WANCommonInterfaceConfig.RXPower')
|| get('Device.Optical.Interface.1.Stats.SignalRxPower')
|| get('Device.Optical.Interface.1.OpticalSignalLevel') || null,
txPower: get('InternetGatewayDevice.WANDevice.1.X_GponInterafceConfig.TXPower')
|| get('InternetGatewayDevice.WANDevice.1.WANCommonInterfaceConfig.TXPower')
|| get('Device.Optical.Interface.1.Stats.SignalTxPower')
|| get('Device.Optical.Interface.1.TransmitOpticalLevel') || null,
pppoeUser: getStr('InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.Username')
|| getStr('Device.PPP.Interface.1.Username') || '',
ssid: getStr('InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID')
|| getStr('Device.WiFi.SSID.1.SSID') || '',
macAddress: getStr('InternetGatewayDevice.WANDevice.1.WANEthernetInterfaceConfig.MACAddress')
|| getStr('Device.Ethernet.Interface.1.MACAddress') || '',
tags: d['_tags'] || [],
// ── Extended diagnostics (XX230v / TR-181 devices) ──
opticalStatus: getStr('Device.Optical.Interface.1.Status') || null,
opticalErrors: {
sent: get('Device.Optical.Interface.1.Stats.ErrorsSent') || 0,
received: get('Device.Optical.Interface.1.Stats.ErrorsReceived') || 0,
},
wifi: (() => {
const counts = countAllWifiClients(d)
const mesh = extractMeshTopology(d)
return {
radio1: {
status: getStr('Device.WiFi.Radio.1.Status') || null,
channel: get('Device.WiFi.Radio.1.Channel') || null,
bandwidth: getStr('Device.WiFi.Radio.1.CurrentOperatingChannelBandwidth') || null,
noise: get('Device.WiFi.Radio.1.Stats.Noise') || null,
clients: get('Device.WiFi.AccessPoint.1.AssociatedDeviceNumberOfEntries') || 0,
},
radio2: {
status: getStr('Device.WiFi.Radio.2.Status') || null,
channel: get('Device.WiFi.Radio.2.Channel') || null,
bandwidth: getStr('Device.WiFi.Radio.2.CurrentOperatingChannelBandwidth') || null,
noise: get('Device.WiFi.Radio.2.Stats.Noise') || null,
clients: get('Device.WiFi.AccessPoint.2.AssociatedDeviceNumberOfEntries') || 0,
},
radio3: {
status: getStr('Device.WiFi.Radio.3.Status') || null,
channel: get('Device.WiFi.Radio.3.Channel') || null,
clients: get('Device.WiFi.AccessPoint.3.AssociatedDeviceNumberOfEntries') || 0,
},
// Use mesh totals if available (more accurate), fall back to flat AP counts
totalClients: mesh ? mesh.totalClients : counts.total,
directClients: counts.direct,
meshClients: mesh ? (mesh.totalClients - counts.direct) : counts.mesh,
}
})(),
// EasyMesh topology — each node with name, IP, client count
mesh: (() => {
const mesh = extractMeshTopology(d)
return mesh ? mesh.nodes : null
})(),
hostsCount: get('Device.Hosts.HostNumberOfEntries') || null,
ethernet: {
port1: { status: getStr('Device.Ethernet.Interface.1.Status') || null, speed: get('Device.Ethernet.Interface.1.MaxBitRate') || null },
port2: { status: getStr('Device.Ethernet.Interface.2.Status') || null, speed: get('Device.Ethernet.Interface.2.MaxBitRate') || null },
},
}
}
async function handleGenieACS (req, res, method, path, url) {
if (!GENIEACS_NBI_URL) {
return json(res, 503, { error: 'GenieACS NBI not configured' })
}
try {
const parts = path.replace('/devices', '').split('/').filter(Boolean)
// GET /devices/summary — aggregate stats
if (parts[0] === 'summary' && method === 'GET') {
const now = Date.now()
const fiveMinAgo = new Date(now - 5 * 60 * 1000).toISOString()
const result = await genieRequest('GET',
`/devices/?projection=DeviceID,_lastInform,_tags&limit=10000`)
const devices = Array.isArray(result.data) ? result.data : []
const stats = { total: devices.length, online: 0, offline: 0, models: {} }
for (const d of devices) {
const model = (d.DeviceID && d.DeviceID.ProductClass && d.DeviceID.ProductClass._value) || 'Unknown'
const lastInform = d._lastInform
const isOnline = lastInform && new Date(lastInform).toISOString() > fiveMinAgo
if (isOnline) stats.online++; else stats.offline++
if (!stats.models[model]) stats.models[model] = { total: 0, online: 0 }
stats.models[model].total++
if (isOnline) stats.models[model].online++
}
return json(res, 200, stats)
}
// GET /devices/lookup?serial=XXX — Find device by serial number
if (parts[0] === 'lookup' && method === 'GET') {
const serial = url.searchParams.get('serial')
const mac = url.searchParams.get('mac')
if (!serial && !mac) return json(res, 400, { error: 'Provide serial or mac parameter' })
const projection = 'DeviceID,InternetGatewayDevice,Device,_lastInform,_lastBootstrap,_registered,_tags'
let devices = []
if (serial) {
// Try 1: exact SerialNumber match (works for Raisecom HT803G etc.)
let query = JSON.stringify({ 'DeviceID.SerialNumber._value': serial })
let result = await genieRequest('GET', `/devices/?query=${encodeURIComponent(query)}&projection=${projection}`)
devices = Array.isArray(result.data) ? result.data : []
// Try 2: GPON serial match (XX230v stores TPLG + last 8 hex of GponSn as serial in ERPNext)
if (!devices.length && serial.startsWith('TPLG')) {
const gponSuffix = serial.slice(4).toUpperCase()
query = JSON.stringify({ 'Device.Optical.Interface.1.GponAuth.GponSn._value': { '$regex': gponSuffix + '$' } })
result = await genieRequest('GET', `/devices/?query=${encodeURIComponent(query)}&projection=${projection}`)
devices = Array.isArray(result.data) ? result.data : []
}
// Try 3: _id contains the serial (fallback)
if (!devices.length) {
query = JSON.stringify({ '_id': { '$regex': serial } })
result = await genieRequest('GET', `/devices/?query=${encodeURIComponent(query)}&projection=${projection}`)
devices = Array.isArray(result.data) ? result.data : []
}
} else if (mac) {
const cleanMac = mac.replace(/[:-]/g, '').toUpperCase()
const query = JSON.stringify({ '$or': [
{ 'InternetGatewayDevice.WANDevice.1.WANEthernetInterfaceConfig.MACAddress._value': mac },
{ 'Device.Ethernet.Interface.1.MACAddress._value': mac },
{ 'Device.DeviceInfo.X_TP_MACAddress._value': { '$regex': cleanMac.slice(-6) } },
]})
const result = await genieRequest('GET', `/devices/?query=${encodeURIComponent(query)}&projection=${projection}`)
devices = Array.isArray(result.data) ? result.data : []
}
return json(res, 200, devices.map(summarizeDevice))
}
// GET /devices — list devices (pass through query params)
if (!parts.length && method === 'GET') {
const limit = url.searchParams.get('limit') || '50'
const skip = url.searchParams.get('skip') || '0'
const query = url.searchParams.get('query') || ''
const projection = url.searchParams.get('projection') || 'DeviceID,_lastInform,_tags'
const sort = url.searchParams.get('sort') || '{"_lastInform":-1}'
let nbiPath = `/devices/?projection=${encodeURIComponent(projection)}&limit=${limit}&skip=${skip}&sort=${encodeURIComponent(sort)}`
if (query) nbiPath += `&query=${encodeURIComponent(query)}`
const result = await genieRequest('GET', nbiPath)
const devices = Array.isArray(result.data) ? result.data : []
// If projection is minimal, return raw; otherwise summarize
if (projection === 'DeviceID,_lastInform,_tags') {
return json(res, 200, devices.map(d => ({
_id: d._id,
serial: (d.DeviceID && d.DeviceID.SerialNumber && d.DeviceID.SerialNumber._value) || '',
manufacturer: (d.DeviceID && d.DeviceID.Manufacturer && d.DeviceID.Manufacturer._value) || '',
model: (d.DeviceID && d.DeviceID.ProductClass && d.DeviceID.ProductClass._value) || '',
oui: (d.DeviceID && d.DeviceID.OUI && d.DeviceID.OUI._value) || '',
lastInform: d._lastInform || null,
tags: d._tags || [],
})))
}
return json(res, 200, devices.map(summarizeDevice))
}
// Decode device ID (URL-encoded, e.g., "202BC1-BM632w-ZTEGC8B042%2F02211")
const deviceId = decodeURIComponent(parts[0])
const subResource = parts[1] || null
// GET /devices/:id — single device detail
if (!subResource && method === 'GET') {
const result = await genieRequest('GET',
`/devices/?query=${encodeURIComponent(`_id = "${deviceId}"`)}&projection=DeviceID,InternetGatewayDevice,Device,_lastInform,_lastBootstrap,_registered,_tags`)
const devices = Array.isArray(result.data) ? result.data : []
if (!devices.length) return json(res, 404, { error: 'Device not found' })
return json(res, 200, summarizeDevice(devices[0]))
}
// POST /devices/:id/tasks — send task to device
if (subResource === 'tasks' && method === 'POST') {
const body = await parseBody(req)
const connReq = url.searchParams.get('connection_request') !== null
let nbiPath = `/devices/${encodeURIComponent(deviceId)}/tasks`
if (connReq) nbiPath += '?connection_request'
const timeout = url.searchParams.get('timeout')
if (timeout) nbiPath += (connReq ? '&' : '?') + `timeout=${timeout}`
const result = await genieRequest('POST', nbiPath, body)
return json(res, result.status, result.data)
}
// GET /devices/:id/tasks — get pending tasks
if (subResource === 'tasks' && method === 'GET') {
const result = await genieRequest('GET',
`/tasks/?query=${encodeURIComponent(`device = "${deviceId}"`)}`)
return json(res, result.status, result.data)
}
// GET /devices/:id/hosts — get connected clients (LAN hosts + DHCP leases)
if (subResource === 'hosts' && method === 'GET') {
const encId = encodeURIComponent(deviceId)
// Step 1: Send getParameterValues for hosts — triggers connection_request + provision
const hostParams = []
for (let i = 1; i <= 20; i++) {
for (const f of ['HostName', 'IPAddress', 'PhysAddress', 'Active', 'AddressSource',
'LeaseTimeRemaining', 'Layer1Interface']) {
hostParams.push(`Device.Hosts.Host.${i}.${f}`)
}
}
hostParams.push('Device.Hosts.HostNumberOfEntries')
try {
await genieRequest('POST', `/devices/${encId}/tasks?connection_request&timeout=15000`,
{ name: 'getParameterValues', parameterNames: hostParams })
} catch (e) { /* timeout ok */ }
// Step 2: Now send a SECOND task for MultiAP associated device MACs
// The device session is still open from step 1, so this runs immediately
// This reads which client MAC is connected to which mesh node AP
const multiApParams = []
for (let d2 = 1; d2 <= 3; d2++) { // up to 3 mesh nodes
for (let r = 1; r <= 2; r++) { // 2 radios per node
for (let a = 1; a <= 4; a++) { // up to 4 APs per radio
multiApParams.push(`Device.WiFi.MultiAP.APDevice.${d2}.Radio.${r}.AP.${a}.AssociatedDeviceNumberOfEntries`)
for (let c = 1; c <= 8; c++) { // up to 8 clients per AP
multiApParams.push(`Device.WiFi.MultiAP.APDevice.${d2}.Radio.${r}.AP.${a}.AssociatedDevice.${c}.MACAddress`)
multiApParams.push(`Device.WiFi.MultiAP.APDevice.${d2}.Radio.${r}.AP.${a}.AssociatedDevice.${c}.SignalStrength`)
multiApParams.push(`Device.WiFi.MultiAP.APDevice.${d2}.Radio.${r}.AP.${a}.AssociatedDevice.${c}.X_TP_NegotiationSpeed`)
}
}
}
// Also fetch node info
multiApParams.push(`Device.WiFi.MultiAP.APDevice.${d2}.X_TP_HostName`)
multiApParams.push(`Device.WiFi.MultiAP.APDevice.${d2}.MACAddress`)
multiApParams.push(`Device.WiFi.MultiAP.APDevice.${d2}.X_TP_Active`)
multiApParams.push(`Device.WiFi.MultiAP.APDevice.${d2}.Radio.1.OperatingFrequencyBand`)
multiApParams.push(`Device.WiFi.MultiAP.APDevice.${d2}.Radio.2.OperatingFrequencyBand`)
}
try {
await genieRequest('POST', `/devices/${encId}/tasks?timeout=10000`,
{ name: 'getParameterValues', parameterNames: multiApParams })
} catch (e) { /* timeout ok */ }
// Step 3: Read the now-cached data from GenieACS
const result = await genieRequest('GET',
`/devices/?query=${encodeURIComponent(JSON.stringify({ _id: deviceId }))}&projection=Device.Hosts,Device.WiFi.MultiAP`)
const devices = Array.isArray(result.data) ? result.data : []
if (!devices.length) return json(res, 404, { error: 'Device not found' })
const dd = devices[0]
const hostTree = dd.Device && dd.Device.Hosts && dd.Device.Hosts.Host || {}
const hostCount = dd.Device && dd.Device.Hosts && dd.Device.Hosts.HostNumberOfEntries
&& dd.Device.Hosts.HostNumberOfEntries._value || 0
// Step 4: Build client MAC → { nodeName, band, signal, speed } from MultiAP
const clientNodeMap = new Map() // client MAC (upper) → { nodeName, band, signal, speed }
const meshNodes = new Map() // node MAC (upper) → { name, ip }
const apDevices = dd.Device && dd.Device.WiFi && dd.Device.WiFi.MultiAP && dd.Device.WiFi.MultiAP.APDevice || {}
for (const dk of Object.keys(apDevices)) {
if (dk.startsWith('_')) continue
const dev = apDevices[dk]
const nodeMac = dev.MACAddress && dev.MACAddress._value || ''
const nodeName = dev.X_TP_HostName && dev.X_TP_HostName._value || ''
const nodeIp = dev.X_TP_IPAddress && dev.X_TP_IPAddress._value || ''
if (nodeMac) meshNodes.set(nodeMac.toUpperCase(), { name: nodeName, ip: nodeIp })
// Walk radios → APs → AssociatedDevices
const radios = dev.Radio || {}
for (const rk of Object.keys(radios)) {
if (rk.startsWith('_')) continue
const radio = radios[rk]
if (!radio) continue
const freqBand = radio.OperatingFrequencyBand && radio.OperatingFrequencyBand._value || ''
const band = freqBand.includes('5') ? '5GHz' : freqBand.includes('2.4') ? '2.4GHz' : freqBand || ''
const aps = radio.AP || {}
for (const ak of Object.keys(aps)) {
if (ak.startsWith('_')) continue
const ap = aps[ak]
if (!ap) continue
const assocDevs = ap.AssociatedDevice || {}
for (const ck of Object.keys(assocDevs)) {
if (ck.startsWith('_')) continue
const client = assocDevs[ck]
if (!client) continue
const cMac = client.MACAddress && client.MACAddress._value
if (!cMac) continue
const signal = client.SignalStrength && client.SignalStrength._value
const speed = client.X_TP_NegotiationSpeed && client.X_TP_NegotiationSpeed._value
clientNodeMap.set(cMac.toUpperCase(), {
nodeName, nodeMac: nodeMac.toUpperCase(), band,
signal: signal != null ? Number(signal) : null,
speed: speed != null ? String(speed) : '',
})
}
}
}
}
// Step 5: Build host list, enriched with node mapping
const hosts = []
for (const k of Object.keys(hostTree)) {
if (k.startsWith('_')) continue
const h = hostTree[k]
if (!h || h._object === undefined) continue
const gv = (key) => h[key] && h[key]._value !== undefined ? h[key]._value : null
const name = gv('HostName')
const ip = gv('IPAddress')
const mac = gv('PhysAddress')
if (!name && !ip && !mac) continue
const active = gv('Active')
const source = gv('AddressSource')
const lease = gv('LeaseTimeRemaining')
const isMeshNode = mac && meshNodes.has(mac.toUpperCase())
// Look up client → node mapping from MultiAP
const nodeInfo = mac ? clientNodeMap.get(mac.toUpperCase()) : null
const attachedNode = nodeInfo ? nodeInfo.nodeName : ''
const band = nodeInfo ? nodeInfo.band : ''
const signal = nodeInfo ? nodeInfo.signal : null
const linkRate = nodeInfo ? nodeInfo.speed : ''
let connType = 'wifi'
if (isMeshNode) connType = 'mesh-node'
else if (!nodeInfo && !active) connType = 'unknown'
hosts.push({
id: k, name: name || '', ip: ip || '', mac: mac || '',
active: active === true,
addressSource: source || '',
leaseRemaining: lease != null ? Number(lease) : null,
connType, band, signal, linkRate, attachedNode,
attachedMac: nodeInfo ? nodeInfo.nodeMac : '',
isMeshNode,
})
}
hosts.sort((a, b) => {
if (a.isMeshNode !== b.isMeshNode) return a.isMeshNode ? 1 : -1
if (a.active !== b.active) return a.active ? -1 : 1
return (a.name || a.ip).localeCompare(b.name || b.ip)
})
return json(res, 200, { total: hostCount, hosts })
}
// GET /devices/:id/faults — get device faults
if (subResource === 'faults' && method === 'GET') {
const result = await genieRequest('GET',
`/faults/?query=${encodeURIComponent(`device = "${deviceId}"`)}`)
return json(res, result.status, result.data)
}
// DELETE /devices/:id/tasks/:taskId — cancel a task
if (subResource === 'tasks' && parts[2] && method === 'DELETE') {
const result = await genieRequest('DELETE', `/tasks/${parts[2]}`)
return json(res, result.status, result.data)
}
// DELETE /devices/:id — remove device from ACS
if (!subResource && method === 'DELETE') {
const result = await genieRequest('DELETE', `/devices/${encodeURIComponent(deviceId)}`)
return json(res, result.status, result.data)
}
return json(res, 400, { error: 'Unknown device endpoint' })
} catch (e) {
log('GenieACS error:', e.message)
return json(res, 502, { error: 'GenieACS NBI error: ' + e.message })
}
}
// ═══════════════════════════════════════════════════════════════════════
// Provisioning API — OLT pre-auth, equipment activation, swap
// ═══════════════════════════════════════════════════════════════════════
//
// These endpoints are called by:
// - n8n webhooks (on task open/close)
// - Field tech app (on barcode scan)
// - Ops app (manual activation)
//
// OLT pre-authorization flow:
// When we know the address→OLT→slot→port mapping AND the ONT GPON serial
// (scanned in warehouse or on-site), we can pre-register the ONT on the OLT
// so it auto-activates the moment the tech plugs the fibre in.
//
// For XX230v: GPON serial = "TPLG" + last 8 chars of Device.Optical.Interface.1.GponAuth.GponSn
// For Raisecom: GPON serial = RCMG sticker serial
//
// Equipment swap flow:
// Tech reports defective device → scans new replacement device → system:
// 1. Marks old equipment as "Défectueux"
// 2. Creates new Service Equipment with scanned serial
// 3. Transfers all provisioning data (WiFi, VoIP, OLT port) to new device
// 4. If OLT-connected: de-registers old ONT, registers new one
// 5. ACS pushes config to new device on bootstrap
//
async function handleProvisioning (req, res, method, path, url) {
try {
const parts = path.replace('/provision/', '').split('/').filter(Boolean)
const action = parts[0]
// ─── POST /provision/pre-authorize ───
// Pre-register an ONT on the OLT before the tech goes on site
// Body: { gpon_serial, olt_ip, frame, slot, port, ont_id, vlans, equipment_name }
if (action === 'pre-authorize' && method === 'POST') {
const body = await parseBody(req)
const { gpon_serial, olt_ip, frame, slot, port, ont_id, equipment_name } = body
const vlans = body.vlans || {}
if (!gpon_serial || !olt_ip || frame === undefined || slot === undefined || port === undefined) {
return json(res, 400, { error: 'Missing required fields: gpon_serial, olt_ip, frame, slot, port' })
}
// Build OLT CLI commands for Huawei/ZTE-style OLTs
// These would be executed via SSH in a real implementation (or via n8n SSH node)
const commands = [
`# OLT: ${olt_ip} — Pre-authorize ONT`,
`interface gpon ${frame}/${slot}`,
`ont add ${port} sn-auth "${gpon_serial}" omci ont-lineprofile-id ${body.line_profile || 10} ont-srvprofile-id ${body.service_profile || 10} desc "pre-auth-${equipment_name || 'unknown'}"`,
`ont confirm ${port} ont ${ont_id}`,
`quit`,
`# Service ports (VLANs)`,
vlans.internet ? `service-port auto vlan ${vlans.internet} gemport 1 gpon ${frame}/${slot}/${port} ont ${ont_id} transparent` : '# no internet vlan',
vlans.manage ? `service-port auto vlan ${vlans.manage} gemport 2 gpon ${frame}/${slot}/${port} ont ${ont_id} transparent` : '# no manage vlan',
vlans.telephone ? `service-port auto vlan ${vlans.telephone} gemport 3 gpon ${frame}/${slot}/${port} ont ${ont_id} transparent` : '# no telephone vlan',
vlans.tv ? `service-port auto vlan ${vlans.tv} gemport 4 gpon ${frame}/${slot}/${port} ont ${ont_id} transparent` : '# no tv vlan',
]
// Update ERPNext Service Equipment with GPON serial
if (equipment_name) {
try {
await erpRequest('PUT', `/api/resource/Service Equipment/${encodeURIComponent(equipment_name)}`, {
gpon_serial: gpon_serial,
status: 'Actif',
})
} catch (e) {
log('ERPNext update error:', e.message)
}
}
// In production, this would SSH to the OLT and execute commands.
// For now, return the commands for n8n to execute via SSH node.
log(`[provision] Pre-authorize ${gpon_serial} on ${olt_ip} ${frame}/${slot}/${port} ONT:${ont_id}`)
return json(res, 200, {
status: 'commands_generated',
gpon_serial,
olt_ip,
port: `${frame}/${slot}/${port}`,
ont_id,
commands,
note: 'Execute via n8n SSH node or manual SSH to OLT',
})
}
// ─── POST /provision/on-scan ───
// Called when tech scans a barcode in the field app
// Triggers: equipment update + OLT pre-auth if address→OLT mapping exists
// Body: { serial, mac, equipment_type, equipment_name, customer, service_location, job_name }
if (action === 'on-scan' && method === 'POST') {
const body = await parseBody(req)
const { serial, equipment_name, service_location } = body
if (!serial) {
return json(res, 400, { error: 'Missing serial' })
}
const result = { serial, actions: [] }
// 1. Update equipment serial if placeholder
if (equipment_name) {
try {
await erpRequest('PUT', `/api/resource/Service Equipment/${encodeURIComponent(equipment_name)}`, {
serial_number: serial,
mac_address: body.mac || null,
status: 'Actif',
})
result.actions.push({ action: 'equipment_updated', equipment_name, serial })
} catch (e) {
result.actions.push({ action: 'equipment_update_failed', error: e.message })
}
}
// 2. Check if we should trigger OLT pre-auth
// (if this is an ONT and we have the OLT port mapping from the Service Location)
if (service_location && (body.equipment_type === 'ONT' || serial.startsWith('TPLG') || serial.startsWith('RCMG'))) {
try {
const locRes = await erpRequest('GET',
`/api/resource/Service Location/${encodeURIComponent(service_location)}?fields=["olt_ip","olt_port","ont_id","vlan_internet","vlan_manage","vlan_telephone","vlan_tv"]`)
const loc = locRes.data || {}
if (loc.olt_ip && loc.olt_port) {
// Parse olt_port: "0/3/2 ONT:12" → frame=0, slot=3, port=2
const portMatch = (loc.olt_port || '').match(/(\d+)\/(\d+)\/(\d+)/)
if (portMatch) {
// Derive GPON serial from scanned serial
let gpon_serial = serial
// XX230v: physical serial IS the GPON serial (TPLGXXXXXXXX)
// Raisecom: physical serial IS the GPON serial (RCMGXXXXXXXX)
result.olt_pre_auth = {
gpon_serial,
olt_ip: loc.olt_ip,
frame: parseInt(portMatch[1]),
slot: parseInt(portMatch[2]),
port: parseInt(portMatch[3]),
ont_id: loc.ont_id || 0,
vlans: {
internet: loc.vlan_internet,
manage: loc.vlan_manage,
telephone: loc.vlan_telephone,
tv: loc.vlan_tv,
},
}
result.actions.push({ action: 'olt_pre_auth_ready', message: 'OLT commands generated, send to n8n for SSH execution' })
}
}
} catch (e) {
result.actions.push({ action: 'location_lookup_failed', error: e.message })
}
}
// 3. Broadcast SSE event so Ops sees the scan in real-time
if (body.customer) {
broadcast('customer:' + body.customer, 'equipment-scanned', {
serial, equipment_type: body.equipment_type, job: body.job_name,
})
}
log(`[provision] Scan: ${serial}${result.actions.length} actions`)
return json(res, 200, result)
}
// ─── POST /provision/swap ───
// Equipment swap: defective device → replacement OR diagnostic swap
// Body: { old_equipment_name, new_serial, new_mac, equipment_type, swap_type, reason, customer, service_location, job_name }
// swap_type: "permanent" (default) | "diagnostic" | "upgrade"
// - permanent: old → Défectueux, confirmed dead
// - diagnostic: old → En diagnostic, testing if it's the cause
// - upgrade: old → Retourné, replaced with newer model
if (action === 'swap' && method === 'POST') {
const body = await parseBody(req)
const { old_equipment_name, new_serial, equipment_type, customer, service_location } = body
const swapType = body.swap_type || 'permanent'
if (!old_equipment_name || !new_serial) {
return json(res, 400, { error: 'Missing old_equipment_name and new_serial' })
}
const result = { old_equipment: old_equipment_name, new_serial, swap_type: swapType, actions: [] }
// Status mapping based on swap type
const oldStatusMap = {
permanent: 'Défectueux',
diagnostic: 'En diagnostic',
upgrade: 'Retourné',
}
const oldStatus = oldStatusMap[swapType] || 'Défectueux'
// 1. Get old equipment data (to transfer provisioning info)
let oldEquip = {}
try {
const r = await erpRequest('GET', `/api/resource/Service Equipment/${encodeURIComponent(old_equipment_name)}`)
oldEquip = (r.data && r.data.data) || r.data || {}
} catch (e) {
return json(res, 404, { error: 'Old equipment not found: ' + old_equipment_name })
}
// 2. Mark old equipment with appropriate status
try {
await erpRequest('PUT', `/api/resource/Service Equipment/${encodeURIComponent(old_equipment_name)}`, {
status: oldStatus,
notes: `${oldEquip.notes || ''}\n[${new Date().toISOString()}] ${swapType === 'diagnostic' ? 'Swap diagnostic' : 'Remplacé'} par ${new_serial}. Raison: ${body.reason || swapType}`.trim(),
})
result.actions.push({ action: 'old_status_updated', name: old_equipment_name, status: oldStatus })
} catch (e) {
result.actions.push({ action: 'status_update_failed', error: e.message })
}
// 3. Create new equipment with transferred provisioning data
const newName = 'EQ-SWAP-' + new_serial.substring(0, 10).replace(/[^A-Za-z0-9]/g, '')
try {
await erpRequest('POST', '/api/resource/Service Equipment', {
name: newName,
equipment_type: equipment_type || oldEquip.equipment_type,
serial_number: new_serial,
mac_address: body.new_mac || null,
brand: oldEquip.brand,
model: oldEquip.model,
customer: customer || oldEquip.customer,
service_location: service_location || oldEquip.service_location,
subscription: oldEquip.subscription,
status: 'Actif',
ownership: 'Gigafibre',
// Transfer provisioning data
wifi_ssid: oldEquip.wifi_ssid,
wifi_password: oldEquip.wifi_password,
sip_username: oldEquip.sip_username,
sip_password: oldEquip.sip_password,
fibre_line_profile: oldEquip.fibre_line_profile,
fibre_service_profile: oldEquip.fibre_service_profile,
parent_device: oldEquip.parent_device,
notes: `Remplacement de ${old_equipment_name} (${oldEquip.serial_number || 'N/A'}). Raison: ${body.reason || 'défectueux'}`,
})
result.actions.push({ action: 'new_equipment_created', name: newName, serial: new_serial })
} catch (e) {
result.actions.push({ action: 'create_new_failed', error: e.message })
}
// 4. If ONT, prepare OLT swap commands (de-register old, register new)
if ((equipment_type || oldEquip.equipment_type) === 'ONT' && service_location) {
try {
const locRes = await erpRequest('GET',
`/api/resource/Service Location/${encodeURIComponent(service_location || oldEquip.service_location)}?fields=["olt_ip","olt_port","ont_id","vlan_internet","vlan_manage","vlan_telephone","vlan_tv"]`)
const loc = locRes.data || {}
const portMatch = (loc.olt_port || '').match(/(\d+)\/(\d+)\/(\d+)/)
if (loc.olt_ip && portMatch) {
result.olt_swap_commands = [
`# OLT: ${loc.olt_ip} — Swap ONT on ${portMatch[0]}`,
`interface gpon ${portMatch[1]}/${portMatch[2]}`,
`# Remove old ONT`,
`ont delete ${portMatch[3]} ${loc.ont_id || 0}`,
`# Register new ONT`,
`ont add ${portMatch[3]} sn-auth "${new_serial}" omci ont-lineprofile-id ${oldEquip.fibre_line_profile || 10} ont-srvprofile-id ${oldEquip.fibre_service_profile || 10} desc "swap-${newName}"`,
`ont confirm ${portMatch[3]} ont ${loc.ont_id || 0}`,
`quit`,
`# Re-create service ports (same VLANs as before)`,
loc.vlan_internet ? `service-port auto vlan ${loc.vlan_internet} gemport 1 gpon ${portMatch[1]}/${portMatch[2]}/${portMatch[3]} ont ${loc.ont_id || 0} transparent` : '',
loc.vlan_manage ? `service-port auto vlan ${loc.vlan_manage} gemport 2 gpon ${portMatch[1]}/${portMatch[2]}/${portMatch[3]} ont ${loc.ont_id || 0} transparent` : '',
].filter(Boolean)
result.actions.push({ action: 'olt_swap_commands_ready', message: 'Execute via n8n SSH node' })
}
} catch (e) {
result.actions.push({ action: 'olt_swap_lookup_failed', error: e.message })
}
}
// 5. Broadcast SSE event
if (customer || oldEquip.customer) {
broadcast('customer:' + (customer || oldEquip.customer), 'equipment-swapped', {
old: old_equipment_name, new_serial, reason: body.reason,
})
}
log(`[provision] Swap: ${old_equipment_name}${new_serial} (${result.actions.length} actions)`)
return json(res, 200, result)
}
// ─── GET /provision/equipment/:serial ───
// Lookup equipment by serial for provisioning data (called by ACS on bootstrap)
if (parts.length >= 2 && parts[0] === 'equipment' && method === 'GET') {
const serial = decodeURIComponent(parts[1])
try {
const r = await erpRequest('GET',
`/api/resource/Service Equipment?filters=[["serial_number","=","${serial}"]]&fields=["name","serial_number","mac_address","wifi_ssid","wifi_password","sip_username","sip_password","fibre_line_profile","fibre_service_profile","customer","service_location","equipment_type"]&limit_page_length=1`)
const equips = (r.data || [])
if (!equips.length) {
return json(res, 404, { error: 'Equipment not found for serial: ' + serial })
}
return json(res, 200, equips[0])
} catch (e) {
return json(res, 502, { error: 'ERPNext lookup failed: ' + e.message })
}
}
return json(res, 400, { error: 'Unknown provision endpoint: ' + action })
} catch (e) {
log('Provisioning error:', e.message)
return json(res, 500, { error: 'Provisioning error: ' + e.message })
}
}
server.listen(PORT, '0.0.0.0', () => {
log(`targo-hub listening on :${PORT}`)
log(` SSE: GET /sse?topics=customer:C-LPB4`)
log(` Broadcast: POST /broadcast`)
log(` SMS In: POST /webhook/twilio/sms-incoming`)
log(` SMS Out: POST /send/sms`)
log(` 3CX Call: POST /webhook/3cx/call-event`)
log(` Voice Tkn: GET /voice/token`)
log(` Voice TML: POST /voice/twiml`)
log(` Voice Sts: POST /voice/status`)
log(` Health: GET /health`)
log(` Telephony: GET|POST|PUT|DELETE /telephony/{trunks,agents,credentials,numbers,domains,acls,peers,workspaces,users}/{ref?}`)
log(` Telephony: GET /telephony/overview`)
log(` Devices: GET /devices, /devices/summary, /devices/lookup?serial=X`)
log(` Devices: GET|POST|DELETE /devices/:id/tasks`)
log(` Provision: POST /provision/pre-authorize, /provision/on-scan, /provision/swap`)
log(` Provision: GET /provision/equipment/:serial`)
log(` GenieACS: ${GENIEACS_NBI_URL}`)
log(` Routr DB: ${ROUTR_DB_URL.replace(/:[^:@]+@/, ':***@')}`)
// Start 3CX poller
start3cxPoller()
})