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}`; } 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, }); } 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 res = await this.http.post('/openapi/authorize/token?grant_type=client_credentials', 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.http.request({ method, url: 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 controllerLogin() { if (!this.controllerId) { const info = await this.http.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 res = await this.http.post(`/${this.controllerId}/api/v2/login`, { 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 res = await this.http.request({ method, url: `/${this.controllerId}/api/v2${path}`, 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 res = await this.http.request({ method: 'POST', url: `/${this.controllerId}/api/v2${path}`, 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; } } 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); } } module.exports = { OmadaClient, formatOmadaError, };