'use strict' /** * Oktopus TR-369/USP Integration * * Handles device pre-authorization and service provisioning via the * Oktopus controller API at oss.gigafibre.ca. * * Device identity in Oktopus = MAC address (no colons, uppercase). * ERPNext Service Equipment stores mac_address (with colons) and serial_number. * * Flow: * 1. On equipment scan (field app) or bulk sync, create device auth in Oktopus * 2. Attach a "registration" service instance so the CPE auto-provisions on BOOTSTRAP * 3. Optionally push OLT/fibre config variables for ZTP */ const { log, httpRequest, erpFetch } = require('./helpers') const cfg = require('./config') // Internal Docker network URL (bypasses Authentik SSO proxy) // Internal Docker network URL (bypasses Authentik SSO proxy) const OKTOPUS_URL = cfg.OKTOPUS_URL || 'http://oktopus-controller-1:8000' const OKTOPUS_USER = cfg.OKTOPUS_USER || '' const OKTOPUS_PASS = cfg.OKTOPUS_PASS || '' const OKTOPUS_MONGO_URL = cfg.OKTOPUS_MONGO_URL || 'mongodb://oktopus-mongo-1:27017' let authToken = null let tokenExpiry = 0 let mongoClient = null let mongoDb = null /* ── Auth ─────────────────────────────────────────────────────────── */ async function authenticate () { if (authToken && Date.now() < tokenExpiry) return authToken try { const res = await httpRequest(OKTOPUS_URL, '/api/auth/login', { method: 'PUT', body: { email: OKTOPUS_USER, password: OKTOPUS_PASS }, }) // Oktopus returns JWT as a plain JSON string (with quotes) const token = typeof res.data === 'string' ? res.data.replace(/^"|"$/g, '') : res.data?.token if (res.status === 200 && token) { authToken = token // Refresh 5 min before expiry (assume 1h token) tokenExpiry = Date.now() + 55 * 60 * 1000 log('[oktopus] Authenticated') return authToken } log('[oktopus] Auth failed:', res.status, JSON.stringify(res.data)) return null } catch (e) { log('[oktopus] Auth error:', e.message) return null } } async function oktopusRequest (method, path, body = null) { const token = await authenticate() if (!token) throw new Error('Oktopus authentication failed') return httpRequest(OKTOPUS_URL, path, { method, body, // Oktopus expects raw JWT token (no "Bearer " prefix) headers: { Authorization: token }, timeout: 15000, }) } /* ── MongoDB (Oktopus adapter DB — device records) ────────────────── */ async function getMongoDb () { if (mongoDb) return mongoDb try { const { MongoClient } = require('mongodb') mongoClient = new MongoClient(OKTOPUS_MONGO_URL) await mongoClient.connect() mongoDb = mongoClient.db('adapter') log('[oktopus] Connected to Oktopus MongoDB') return mongoDb } catch (e) { log('[oktopus] MongoDB connect error:', e.message) return null } } /** * Ensure a device record exists in Oktopus MongoDB (adapter.devices). * The adapter requires the device to exist before it can process status updates. */ async function ensureDeviceRecord (endpointId, opts = {}) { const db = await getMongoDb() if (!db) return { ok: false, error: 'MongoDB unavailable' } try { const col = db.collection('devices') const existing = await col.findOne({ sn: endpointId }) if (existing) { // Update if we have new info if (opts.vendor || opts.model || opts.customer || opts.alias) { const update = {} if (opts.vendor) update.vendor = opts.vendor if (opts.model) update.model = opts.model if (opts.customer) update.customer = opts.customer if (opts.alias) update.alias = opts.alias await col.updateOne({ sn: endpointId }, { $set: update }) } return { ok: true, existing: true } } await col.insertOne({ sn: endpointId, model: opts.model || '', customer: opts.customer || '', vendor: opts.vendor || '', version: '', product_class: opts.product_class || 'ONT', alias: opts.alias || '', status: 0, mqtt: true, stomp: false, websockets: false, cwmp: false, }) log(`[oktopus] Device record created: ${endpointId}`) return { ok: true, created: true } } catch (e) { log(`[oktopus] MongoDB error for ${endpointId}:`, e.message) return { ok: false, error: e.message } } } /* ── MAC helpers ──────────────────────────────────────────────────── */ /** Normalize MAC to uppercase hex without separators: E4FAC4160688 */ function normalizeMac (mac) { if (!mac) return null return mac.replace(/[:\-. ]/g, '').toUpperCase() } /** Format as USP Agent Endpoint ID: ops::OUI-ProductClass-SerialNumber (TR-369 ops authority) */ function generateEndpointId (mac, serial, productClass = 'Device2') { const cleanMac = normalizeMac(mac) if (!cleanMac) return null const oui = cleanMac.substring(0, 6) if (serial) { return `ops::${oui}-${productClass}-${serial}` } // Fallback if serial is missing return `USP::${cleanMac}` } /** Generate a random password (alphanumeric, 16 chars) */ function generatePassword (len = 16) { const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789' let result = '' for (let i = 0; i < len; i++) result += chars[Math.floor(Math.random() * chars.length)] return result } /* ── Device Auth (pre-authorization) ──────────────────────────────── */ /** * Create device credentials in Oktopus so the CPE can authenticate via MQTT. * @param {string} mac - MAC address (any format) * @returns {{ ok: boolean, deviceId: string, password?: string, error?: string }} */ async function preAuthorizeDevice (mac) { const deviceId = normalizeMac(mac) if (!deviceId) return { ok: false, error: 'Invalid MAC address' } const password = generatePassword() try { const res = await oktopusRequest('POST', '/api/device/auth', { id: deviceId, password, }) if (res.status === 200 || res.status === 201) { log(`[oktopus] Device pre-authorized: ${deviceId}`) return { ok: true, deviceId, password } } if (res.status === 409) { // Already exists — that's fine log(`[oktopus] Device already registered: ${deviceId}`) return { ok: true, deviceId, password: null, existing: true } } log(`[oktopus] Pre-auth failed: ${res.status}`, JSON.stringify(res.data)) return { ok: false, deviceId, error: `HTTP ${res.status}: ${JSON.stringify(res.data)}` } } catch (e) { log(`[oktopus] Pre-auth error for ${deviceId}:`, e.message) return { ok: false, deviceId, error: e.message } } } /* ── Full provisioning: pre-auth + fibre context ─────────────────── */ /** * Pre-authorize a device and enrich with fibre/OLT context from ERPNext. * Called during field scan (on-scan) or bulk sync. * * @param {object} opts * @param {string} opts.mac - MAC address * @param {string} opts.serial - ERPNext serial number * @param {string} [opts.service_location] - ERPNext Service Location name * @param {string} [opts.equipment_name] - ERPNext Service Equipment name * @param {string} [opts.customer] - ERPNext Customer name * @returns {object} Result with pre-auth status and fibre context */ async function provisionDevice (opts) { const { mac, serial, service_location, equipment_name, customer } = opts const result = { serial, mac, deviceId: normalizeMac(mac), endpointId: generateEndpointId(mac, serial), actions: [], } // Step 1: Pre-authorize in Oktopus const authResult = await preAuthorizeDevice(mac) result.oktopus_auth = authResult if (authResult.ok) { result.actions.push({ action: authResult.existing ? 'oktopus_already_registered' : 'oktopus_pre_authorized', deviceId: authResult.deviceId, }) } else { result.actions.push({ action: 'oktopus_auth_failed', error: authResult.error }) } // Step 1b: Ensure device record exists in Oktopus MongoDB if (result.endpointId) { const mongoResult = await ensureDeviceRecord(result.endpointId, { vendor: opts.vendor || 'TP-Link', model: opts.model || '', customer: customer || '', alias: opts.alias || serial || '', product_class: opts.equipment_type || 'ONT', }) if (mongoResult.ok) { result.actions.push({ action: mongoResult.created ? 'device_record_created' : 'device_record_exists', endpointId: result.endpointId, }) } else { result.actions.push({ action: 'device_record_failed', error: mongoResult.error }) } } // Step 2: Store Oktopus credentials back in ERPNext (if new) if (authResult.ok && authResult.password && equipment_name) { try { await erpFetch(`/api/resource/Service Equipment/${encodeURIComponent(equipment_name)}`, { method: 'PUT', body: JSON.stringify({ oktopus_device_id: authResult.deviceId, oktopus_password: authResult.password, }), }) result.actions.push({ action: 'erp_credentials_stored', equipment: equipment_name }) } catch (e) { result.actions.push({ action: 'erp_update_failed', error: e.message }) } } // Step 3: Fetch fibre/OLT context from Service Location if (service_location) { try { const locRes = await erpFetch( `/api/resource/Service Location/${encodeURIComponent(service_location)}` + '?fields=["name","address_line1","city","olt_ip","olt_port","olt_name","ont_id","ont_serial",' + '"vlan_internet","vlan_manage","vlan_telephone","vlan_tv","connection_type","network_id"]' ) const loc = locRes.data?.data || locRes.data || {} result.fibre_context = { location: loc.name, address: `${loc.address_line1 || ''}, ${loc.city || ''}`.trim().replace(/^,\s*/, ''), olt_ip: loc.olt_ip, olt_port: loc.olt_port, olt_name: loc.olt_name, ont_id: loc.ont_id, ont_serial: loc.ont_serial, vlans: { internet: loc.vlan_internet, manage: loc.vlan_manage, telephone: loc.vlan_telephone, tv: loc.vlan_tv, }, connection_type: loc.connection_type, network_id: loc.network_id, } result.actions.push({ action: 'fibre_context_loaded', location: loc.name }) } catch (e) { result.actions.push({ action: 'fibre_context_failed', error: e.message }) } } // Step 4: Generate TR-369 MQTT config summary for the CPE if (result.deviceId) { result.tr369_config = { connection_type: 'MQTT', mqtt_server: 'oss.gigafibre.ca', mqtt_port: 1883, agent_endpoint_id: result.endpointId, agent_mqtt_topic: `oktopus/usp/v1/agent/${result.deviceId}`, controller_endpoint_id: 'oktopusController', controller_mqtt_topic: `oktopus/usp/v1/controller/${result.deviceId}`, } } log(`[oktopus] Provision: ${serial} (${result.deviceId}) → ${result.actions.length} actions`) return result } /* ── Bulk sync: ERPNext → Oktopus ─────────────────────────────────── */ /** * Sync all active ONT equipment from ERPNext into Oktopus. * Fetches Service Equipment where equipment_type=ONT and mac_address is set, * then pre-authorizes each in Oktopus. * * @param {object} [opts] * @param {string} [opts.status] - Filter by status (default: 'Actif') * @param {number} [opts.limit] - Max devices to sync (default: all) * @returns {{ total: number, authorized: number, existing: number, failed: number, errors: string[] }} */ async function bulkSync (opts = {}) { const status = opts.status || 'Actif' const limit = opts.limit || 0 const PAGE = 500 let offset = 0 let allEquip = [] log(`[oktopus] Bulk sync starting (status=${status}, limit=${limit || 'all'})`) // Fetch all ONT equipment with MAC addresses from ERPNext while (true) { const filters = JSON.stringify([ ['equipment_type', '=', 'ONT'], ['mac_address', 'is', 'set'], ['status', '=', status], ]) const fields = JSON.stringify(['name', 'serial_number', 'mac_address', 'service_location', 'customer']) const path = `/api/resource/Service Equipment?filters=${encodeURIComponent(filters)}&fields=${encodeURIComponent(fields)}&limit_page_length=${PAGE}&limit_start=${offset}&order_by=name asc` const res = await erpFetch(path) const rows = res.data?.data || [] allEquip.push(...rows) if (rows.length < PAGE) break offset += PAGE if (limit && allEquip.length >= limit) break } if (limit) allEquip = allEquip.slice(0, limit) log(`[oktopus] Found ${allEquip.length} ONT devices to sync`) const result = { total: allEquip.length, authorized: 0, existing: 0, failed: 0, errors: [] } for (const equip of allEquip) { const authRes = await preAuthorizeDevice(equip.mac_address) if (authRes.ok) { // Ensure MongoDB record exists const endpointId = generateEndpointId(equip.mac_address, equip.serial_number) await ensureDeviceRecord(endpointId, { vendor: equip.brand || '', model: equip.model || '', customer: equip.customer || '', alias: equip.serial_number || '', }) if (authRes.existing) { result.existing++ } else { result.authorized++ // Store credentials in ERPNext try { await erpFetch(`/api/resource/Service Equipment/${encodeURIComponent(equip.name)}`, { method: 'PUT', body: JSON.stringify({ oktopus_device_id: authRes.deviceId, oktopus_password: authRes.password, }), }) } catch (e) { log(`[oktopus] Failed to store creds for ${equip.name}:`, e.message) } } } else { result.failed++ result.errors.push(`${equip.serial_number}: ${authRes.error}`) } // Small delay to avoid hammering Oktopus API if (result.total > 50) await new Promise(r => setTimeout(r, 50)) } log(`[oktopus] Bulk sync done: ${result.authorized} new, ${result.existing} existing, ${result.failed} failed`) return result } /* ── HTTP handler for /oktopus/* routes ───────────────────────────── */ async function handle (req, res, method, path) { const { json: sendJson, parseBody: parse } = require('./helpers') try { const action = path.replace('/oktopus/', '').split('/').filter(Boolean)[0] // POST /oktopus/provision — provision single device if (action === 'provision' && method === 'POST') { const body = await parse(req) if (!body.mac) return sendJson(res, 400, { error: 'Missing mac address' }) const result = await provisionDevice(body) return sendJson(res, 200, result) } // POST /oktopus/pre-authorize — just create device auth if (action === 'pre-authorize' && method === 'POST') { const body = await parse(req) if (!body.mac) return sendJson(res, 400, { error: 'Missing mac address' }) const result = await preAuthorizeDevice(body.mac) return sendJson(res, 200, result) } // POST /oktopus/bulk-sync — sync all ONT devices from ERPNext if (action === 'bulk-sync' && method === 'POST') { const body = await parse(req) const result = await bulkSync(body) return sendJson(res, 200, result) } // GET /oktopus/status — check Oktopus connectivity if (action === 'status' && method === 'GET') { try { const token = await authenticate() return sendJson(res, 200, { ok: !!token, url: OKTOPUS_URL, authenticated: !!token, }) } catch (e) { return sendJson(res, 200, { ok: false, url: OKTOPUS_URL, error: e.message }) } } // PUT /oktopus/walk — USP GetSupportedParams (like snmpwalk) if (action === 'walk' && method === 'PUT') { const body = await parse(req) const sn = body.sn || body.serial const rootPath = body.path || 'Device.' if (!sn) return sendJson(res, 400, { error: 'Missing sn (e.g. USP::E4FAC4160688)' }) try { const result = await oktopusRequest('PUT', `/api/device/${encodeURIComponent(sn)}/mqtt/parameters`, { paths: [rootPath], }) return sendJson(res, result.status, result.data) } catch (e) { return sendJson(res, 504, { error: e.message }) } } // PUT /oktopus/get — USP Get (read parameter values) if (action === 'get' && method === 'PUT') { const body = await parse(req) const sn = body.sn || body.serial const paths = body.paths || [body.path || 'Device.'] if (!sn) return sendJson(res, 400, { error: 'Missing sn' }) try { const result = await oktopusRequest('PUT', `/api/device/${encodeURIComponent(sn)}/mqtt/get`, { paths }) return sendJson(res, result.status, result.data) } catch (e) { return sendJson(res, 504, { error: e.message }) } } // PUT /oktopus/set — USP Set (write parameter values) if (action === 'set' && method === 'PUT') { const body = await parse(req) const sn = body.sn || body.serial if (!sn || !body.params) return sendJson(res, 400, { error: 'Missing sn and params' }) try { const result = await oktopusRequest('PUT', `/api/device/${encodeURIComponent(sn)}/mqtt/set`, body.params) return sendJson(res, result.status, result.data) } catch (e) { return sendJson(res, 504, { error: e.message }) } } // GET /oktopus/mqtt-status — MQTT monitor status if (action === 'mqtt-status' && method === 'GET') { try { const mqttMon = require('./oktopus-mqtt') return sendJson(res, 200, mqttMon.getStatus()) } catch (e) { return sendJson(res, 200, { ok: false, error: 'MQTT monitor not loaded' }) } } // POST /oktopus/mark-online — manually mark device online if (action === 'mark-online' && method === 'POST') { const body = await parse(req) const sn = body.sn || body.endpointId if (!sn) return sendJson(res, 400, { error: 'Missing sn (e.g. USP::E4FAC4160688)' }) try { const mqttMon = require('./oktopus-mqtt') const result = await mqttMon.markOnline(sn) return sendJson(res, 200, result) } catch (e) { return sendJson(res, 500, { error: e.message }) } } // POST /oktopus/probe — probe device to check if online if (action === 'probe' && method === 'POST') { const body = await parse(req) const sn = body.sn || body.endpointId if (!sn) return sendJson(res, 400, { error: 'Missing sn' }) try { const mqttMon = require('./oktopus-mqtt') const result = await mqttMon.probeDevice(sn) return sendJson(res, 200, result) } catch (e) { return sendJson(res, 504, { error: e.message }) } } // GET /oktopus/devices — list all devices from Oktopus if (action === 'devices' && method === 'GET') { try { const result = await oktopusRequest('GET', '/api/device') return sendJson(res, result.status, result.data) } catch (e) { return sendJson(res, 502, { error: e.message }) } } return sendJson(res, 404, { error: 'Unknown oktopus endpoint: ' + action }) } catch (e) { log('[oktopus] Handler error:', e.message) return require('./helpers').json(res, 500, { error: 'Oktopus error: ' + e.message }) } } module.exports = { handle, preAuthorizeDevice, provisionDevice, bulkSync, normalizeMac, generateEndpointId, }