WifiBiz / src /services /omadaClient.js
Mbonea's picture
Migrate production app to v2 Omada workflow
9b8d6f0
Raw
History Blame Contribute Delete
8.38 kB
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,
};