const axios = require('axios'); const crypto = require('crypto'); const fs = require('fs'); const https = require('https'); function trimSlash(value) { return String(value || '').replace(/\/+$/, ''); } function encodeQuery(query = {}) { const params = new URLSearchParams(); for (const [key, value] of Object.entries(query || {})) { if (value !== undefined && value !== null && value !== '') { params.set(key, String(value)); } } const text = params.toString(); return text ? `?${text}` : ''; } function formatOmadaError(error, context = '') { if (error.response) { const data = error.response.data; const body = typeof data === 'string' ? data : JSON.stringify(data); return `${context}${error.response.status} ${error.response.statusText}: ${body}`; } if (error.omada) { const detail = error.result ? ` ${JSON.stringify(error.result)}` : ''; return `${context}Omada error ${error.errorCode}: ${error.message}${detail}`; } return `${context}${error.message}`; } function summarizeOmadaResponse(data) { if (!data || typeof data !== 'object') { return ''; } const parts = []; if ('errorCode' in data) parts.push(`errorCode=${data.errorCode}`); if (data.msg || data.message) parts.push(`message=${data.msg || data.message}`); if (data.result && data.errorCode && data.errorCode !== 0) { parts.push(`result=${JSON.stringify(data.result)}`); } return parts.length ? ` ${parts.join(' ')}` : ''; } function logHttpResult(method, url, res) { const status = res?.status || 'NO_STATUS'; console.log(`[Omada HTTP] ${String(method).toUpperCase()} ${url} -> ${status}${summarizeOmadaResponse(res?.data)}`); } class OmadaClient { constructor(config) { this.config = config || {}; this.controllerUrl = trimSlash(config.controllerUrl); this.omadacId = config.omadacId; this.clientId = config.clientId; this.clientSecret = config.clientSecret; this.accessToken = config.accessToken || null; this.authHeaderStyle = config.authHeaderStyle || 'access-token'; this.controllerId = config.controllerId || config.omadacId || null; this.csrfToken = null; this.sessionCookies = ''; this.http = axios.create({ baseURL: this.controllerUrl, timeout: Number(config.timeoutMs || 30000), httpsAgent: new https.Agent({ rejectUnauthorized: config.rejectUnauthorized === true, }), validateStatus: () => true, }); } async requestWithLog(method, url, options = {}) { try { const res = await this.http.request({ method, url, ...options, }); logHttpResult(method, url, res); return res; } catch (err) { console.warn(`[Omada HTTP] ${String(method).toUpperCase()} ${url} -> ERROR ${err.message}`); throw err; } } requireOpenApiConfig() { const missing = []; if (!this.controllerUrl) missing.push('OMADA_URL'); if (!this.omadacId) missing.push('OMADA_OPENAPI_OMADAC_ID'); if (!this.clientId) missing.push('OMADA_OPENAPI_CLIENT_ID'); if (!this.clientSecret) missing.push('OMADA_OPENAPI_CLIENT_SECRET'); if (missing.length) { throw new Error(`Missing required Omada OpenAPI config: ${missing.join(', ')}`); } } async authorize() { this.requireOpenApiConfig(); const body = { omadacId: this.omadacId, client_id: this.clientId, client_secret: this.clientSecret, }; const url = '/openapi/authorize/token?grant_type=client_credentials'; const res = await this.requestWithLog('POST', url, { data: body }); const data = this.unwrap(res, 'authorize'); const token = data.result?.accessToken || data.result?.token || data.accessToken || data.token; if (!token) { throw new Error(`Authorize succeeded but no access token was found: ${JSON.stringify(data)}`); } this.accessToken = token; return data; } authHeaders(style = this.authHeaderStyle) { if (!this.accessToken) return {}; if (style === 'bearer-access-token') { return { Authorization: `Bearer AccessToken=${this.accessToken}` }; } if (style === 'bearer') { return { Authorization: `Bearer ${this.accessToken}` }; } return { Authorization: `AccessToken=${this.accessToken}` }; } openApiPath(pathPart) { const suffix = String(pathPart || '').startsWith('/') ? pathPart : `/${pathPart}`; return `/openapi/v1/${this.omadacId}${suffix}`; } async request(method, url, { body = null, query = null, headers = {}, retry = true } = {}) { if (!this.accessToken) { await this.authorize(); } const fullUrl = `${url}${encodeQuery(query)}`; const styles = [this.authHeaderStyle, 'bearer-access-token', 'bearer'] .filter((style, index, arr) => arr.indexOf(style) === index); let lastError = null; for (const style of styles) { const res = await this.requestWithLog(method, fullUrl, { headers: { ...this.authHeaders(style), ...headers, }, data: body ?? undefined, }); try { const data = this.unwrap(res, `${method} ${fullUrl}`); this.authHeaderStyle = style; return data; } catch (err) { lastError = err; if (retry && this.isExpiredToken(err)) { this.accessToken = null; await this.authorize(); return this.request(method, url, { body, query, headers, retry: false }); } if (!this.isAuthHeaderCandidate(err)) { throw err; } } } throw lastError; } async openApi(method, pathPart, options = {}) { return this.request(method, this.openApiPath(pathPart), options); } async tryOpenApiCandidates(method, pathParts, options = {}) { const errors = []; for (const pathPart of pathParts) { try { const data = await this.openApi(method, pathPart, options); return { path: this.openApiPath(pathPart), data }; } catch (err) { errors.push({ path: this.openApiPath(pathPart), error: err }); if (!this.isCandidateRetryable(err)) { throw err; } } } const lines = errors.map((item) => `- ${item.path}: ${item.error.message}`).join('\n'); const err = new Error(`All OpenAPI voucher endpoint candidates failed:\n${lines}`); err.candidates = errors; throw err; } async controllerLogin() { if (!this.controllerId) { const info = await this.requestWithLog('GET', '/api/info'); const data = this.unwrap(info, 'GET /api/info'); this.controllerId = data.result?.omadacId; if (!this.controllerId) { throw new Error(`Controller ID not found in /api/info: ${JSON.stringify(data)}`); } } const username = this.config.controllerUsername || process.env.OMADA_USER; const password = this.config.controllerPassword || process.env.OMADA_PASS; if (!username || !password) { throw new Error('Missing OMADA_USER/OMADA_PASS for Omada controller fallback'); } const loginUrl = `/${this.controllerId}/api/v2/login`; const res = await this.requestWithLog('POST', loginUrl, { data: { username, password } }); const data = this.unwrap(res, 'controller login'); this.csrfToken = data.result?.token; this.sessionCookies = (res.headers['set-cookie'] || []).map((item) => item.split(';')[0]).join('; '); return data; } async controllerRequest(method, pathPart, body = null, retried = false) { if (!this.controllerId || !this.csrfToken) { await this.controllerLogin(); } const path = String(pathPart || '').startsWith('/') ? pathPart : `/${pathPart}`; const url = `/${this.controllerId}/api/v2${path}`; const res = await this.requestWithLog(method, url, { headers: { 'Csrf-Token': this.csrfToken, Cookie: this.sessionCookies, 'Content-Type': 'application/json', }, data: body ?? undefined, }); try { return this.unwrap(res, `${method} controller ${path}`); } catch (err) { if (!retried && this.isExpiredToken(err)) { await this.controllerLogin(); return this.controllerRequest(method, pathPart, body, true); } throw err; } } async controllerUploadDataField(pathPart, data, retried = false) { if (!this.controllerId || !this.csrfToken) { await this.controllerLogin(); } const path = String(pathPart || '').startsWith('/') ? pathPart : `/${pathPart}`; const form = new FormData(); form.append('data', JSON.stringify(data)); const url = `/${this.controllerId}/api/v2${path}`; const res = await this.requestWithLog('POST', url, { headers: { 'Csrf-Token': this.csrfToken, Cookie: this.sessionCookies, 'X-Requested-With': 'XMLHttpRequest', refresh: 'manual', }, data: form, }); try { return this.unwrap(res, `POST controller ${path}`); } catch (err) { if (!retried && this.isExpiredToken(err)) { await this.controllerLogin(); return this.controllerUploadDataField(pathPart, data, true); } throw err; } } async controllerUploadFileWithData(pathPart, filePath, data = {}, fileName = null, retried = false) { if (!this.controllerId || !this.csrfToken) { await this.controllerLogin(); } const path = String(pathPart || '').startsWith('/') ? pathPart : `/${pathPart}`; const form = new FormData(); form.append('data', JSON.stringify(data)); const bytes = fs.readFileSync(filePath); const blob = new Blob([bytes], { type: 'text/html' }); form.append('file', blob, fileName || filePath.split(/[\\/]/).pop() || 'portal-redirect.html'); const url = `/${this.controllerId}/api/v2${path}`; const res = await this.requestWithLog('POST', url, { headers: { 'Csrf-Token': this.csrfToken, Cookie: this.sessionCookies, 'X-Requested-With': 'XMLHttpRequest', refresh: 'manual', }, data: form, }); try { return this.unwrap(res, `POST controller ${path}`); } catch (err) { if (!retried && this.isExpiredToken(err)) { await this.controllerLogin(); return this.controllerUploadFileWithData(pathPart, filePath, data, fileName, true); } throw err; } } fileMd5(filePath) { return crypto.createHash('md5').update(fs.readFileSync(filePath)).digest('hex'); } unwrap(res, label) { const data = res.data; if (res.status < 200 || res.status >= 300) { const err = new Error(`${label} failed with HTTP ${res.status}: ${typeof data === 'string' ? data : JSON.stringify(data)}`); err.response = res; throw err; } if (data && typeof data === 'object' && 'errorCode' in data && data.errorCode !== 0) { const err = new Error(data.msg || data.message || `${label} failed`); err.omada = true; err.errorCode = data.errorCode; err.result = data.result; throw err; } return data; } isExpiredToken(err) { return err.response?.status === 401 || [-1000, -44106, -44112].includes(err.errorCode); } isAuthHeaderCandidate(err) { return err.response?.status === 401 || [-44106, -44112, -44113].includes(err.errorCode); } isNotFound(err) { return err.response?.status === 404 || err.errorCode === -404 || /not found/i.test(err.message); } isCandidateRetryable(err) { return this.isNotFound(err) || err.errorCode === -1; } } module.exports = { OmadaClient, formatOmadaError, };