| 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, |
| }; |
|
|