| const axios = require("axios"); |
| const fs = require("fs"); |
| const https = require("https"); |
| const path = require("path"); |
| const { OmadaClient } = require("./omadaClient"); |
|
|
| |
| const httpsAgent = new https.Agent({ rejectUnauthorized: false }); |
|
|
| let controllerId = null; |
| let csrfToken = null; |
| let sessionCookies = ""; |
|
|
| const client = axios.create({ |
| httpsAgent, |
| timeout: 20000, |
| }); |
|
|
| let v2Client = null; |
|
|
| function isOpenApiConfigured() { |
| return Boolean( |
| process.env.OMADA_URL && |
| process.env.OMADA_OPENAPI_OMADAC_ID && |
| process.env.OMADA_OPENAPI_CLIENT_ID && |
| process.env.OMADA_OPENAPI_CLIENT_SECRET, |
| ); |
| } |
|
|
| function getV2Client() { |
| if (!v2Client) { |
| v2Client = new OmadaClient({ |
| controllerUrl: process.env.OMADA_URL, |
| omadacId: process.env.OMADA_OPENAPI_OMADAC_ID, |
| clientId: process.env.OMADA_OPENAPI_CLIENT_ID, |
| clientSecret: process.env.OMADA_OPENAPI_CLIENT_SECRET, |
| controllerUsername: process.env.OMADA_USER, |
| controllerPassword: process.env.OMADA_PASS, |
| rejectUnauthorized: process.env.OMADA_REJECT_UNAUTHORIZED === "true", |
| }); |
| } |
| return v2Client; |
| } |
|
|
| function unwrapRows(result) { |
| const rows = result?.result?.data || result?.result || []; |
| return Array.isArray(rows) ? rows : []; |
| } |
|
|
| function unwrapControllerRows(result) { |
| const rows = result?.result?.data || result?.result || []; |
| return Array.isArray(rows) ? rows : []; |
| } |
|
|
| function omadaResultId(result) { |
| if (!result) return null; |
| if (typeof result === "string") return result; |
| return result.id || result.fileId || result.pageId || result.importedPortalPageId || null; |
| } |
|
|
| function findPortalById(portals, portalId) { |
| return portals.find((p) => (p.id ?? p.portalId) === portalId) || null; |
| } |
|
|
| function toOmadaMbps(value, unit) { |
| const n = Number(value || 0); |
| if (!Number.isFinite(n) || n <= 0) return 0; |
| return Number(unit || 2) === 1 ? n / 1024 : n; |
| } |
|
|
| function durationToVoucher(durationSeconds) { |
| const seconds = Number(durationSeconds || 0); |
| if (seconds > 0 && seconds % 86400 === 0) { |
| return { duration: seconds / 86400, durationUnit: 3 }; |
| } |
| if (seconds > 0 && seconds % 3600 === 0) { |
| return { duration: seconds / 3600, durationUnit: 2 }; |
| } |
| return { duration: Math.max(1, Math.ceil(seconds / 60)), durationUnit: 1 }; |
| } |
|
|
| function stripNullValues(value) { |
| return Object.fromEntries( |
| Object.entries(value).filter(([, entry]) => entry !== null && entry !== undefined), |
| ); |
| } |
|
|
| |
|
|
| client.interceptors.request.use( |
| (config) => config, |
| (error) => { |
| console.error("[Omada] Request Setup Error:", error.message); |
| return Promise.reject(error); |
| }, |
| ); |
|
|
| client.interceptors.response.use( |
| (response) => response, |
| (error) => { |
| |
| if (error.response) { |
| console.error( |
| `[Omada] ${error.config.method.toUpperCase()} ${error.config.url} → ${error.response.status}`, |
| JSON.stringify(error.response.data), |
| ); |
| } else { |
| console.error("[Omada] Network error:", error.message); |
| } |
| return Promise.reject(error); |
| }, |
| ); |
|
|
| |
|
|
| async function initOmada() { |
| if (isOpenApiConfigured()) { |
| const openApi = getV2Client(); |
| await openApi.authorize(); |
| console.log(`[Omada] Ready via OpenAPI. omadacId=${process.env.OMADA_OPENAPI_OMADAC_ID}`); |
| return; |
| } |
|
|
| |
| const infoRes = await client.get(`${process.env.OMADA_URL}/api/info`); |
| controllerId = infoRes.data.result.omadacId; |
|
|
| |
| await omadaLogin(); |
|
|
| |
| setInterval(keepAlive, 25 * 60 * 1000); |
|
|
| console.log(`[Omada] Ready. controllerId=${controllerId}`); |
| } |
|
|
| async function omadaLogin() { |
| const res = await client.post( |
| `${process.env.OMADA_URL}/${controllerId}/api/v2/login`, |
| { username: process.env.OMADA_USER, password: process.env.OMADA_PASS }, |
| ); |
| csrfToken = res.data.result.token; |
| const setCookie = res.headers["set-cookie"] || []; |
| sessionCookies = setCookie.map((c) => c.split(";")[0]).join("; "); |
| } |
|
|
| async function keepAlive() { |
| try { |
| await omadaRequest("GET", "/current/users"); |
| } catch (err) { |
| console.error("[Omada] Keepalive failed:", err.message); |
| } |
| } |
|
|
| |
|
|
| async function omadaRequest(method, path, data = null, retried = false) { |
| if (!controllerId) await initOmada(); |
| if (!csrfToken) await omadaLogin(); |
|
|
| try { |
| const res = await client({ |
| method, |
| url: `${process.env.OMADA_URL}/${controllerId}/api/v2${path}`, |
| headers: { |
| "Csrf-Token": csrfToken, |
| Cookie: sessionCookies, |
| "Content-Type": "application/json", |
| }, |
| data: data ?? undefined, |
| }); |
|
|
| |
| if (res.data.errorCode === -1000 && !retried) { |
| await omadaLogin(); |
| return omadaRequest(method, path, data, true); |
| } |
|
|
| |
| |
|
|
| if (res.data.errorCode !== 0) { |
| const err = new Error( |
| res.data.msg || `Omada error ${res.data.errorCode}`, |
| ); |
| err.errorCode = res.data.errorCode; |
| err.result = res.data.result; |
| throw err; |
| } |
|
|
| return res.data; |
| } catch (err) { |
| if (err.response?.status === 401 && !retried) { |
| await omadaLogin(); |
| return omadaRequest(method, path, data, true); |
| } |
| throw err; |
| } |
| } |
|
|
| async function authenticatePortalVoucher(payload) { |
| const url = `${String(process.env.OMADA_URL || "").replace(/\/+$/, "")}/portal/auth`; |
| const startedAt = Date.now(); |
| const safePayload = { |
| ...payload, |
| voucherCode: payload?.voucherCode ? "***" : undefined, |
| }; |
|
|
| try { |
| const res = await client.post(url, payload, { |
| headers: { "Content-Type": "application/json" }, |
| }); |
| console.log( |
| `[Omada Portal HTTP] POST /portal/auth -> ${res.status} errorCode=${res.data?.errorCode} msg=${res.data?.msg || ""} ms=${Date.now() - startedAt}`, |
| safePayload, |
| ); |
|
|
| if (res.data?.errorCode !== 0) { |
| const err = new Error(res.data?.msg || `Omada portal auth error ${res.data?.errorCode}`); |
| err.errorCode = res.data?.errorCode; |
| err.result = res.data?.result; |
| err.responseData = res.data; |
| throw err; |
| } |
|
|
| return res.data; |
| } catch (err) { |
| if (err.response) { |
| console.error( |
| `[Omada Portal HTTP] POST /portal/auth -> ${err.response.status} errorCode=${err.response.data?.errorCode} msg=${err.response.data?.msg || ""} ms=${Date.now() - startedAt}`, |
| safePayload, |
| ); |
| err.errorCode = err.response.data?.errorCode; |
| err.result = err.response.data?.result; |
| err.responseData = err.response.data; |
| } else { |
| console.error(`[Omada Portal HTTP] POST /portal/auth network error: ${err.message}`, safePayload); |
| } |
| throw err; |
| } |
| } |
|
|
| |
|
|
| async function createSite(name, scenario = "Hotel") { |
| if (isOpenApiConfigured()) { |
| const res = await getV2Client().openApi("POST", "/sites", { |
| body: { |
| name, |
| scenario, |
| region: process.env.OMADA_SITE_REGION || "Tanzania", |
| timeZone: process.env.OMADA_SITE_TIMEZONE || "Africa/Dar_es_Salaam", |
| deviceAccountSetting: { |
| username: process.env.OMADA_DEVICE_DEFAULT_USER || "admin", |
| password: process.env.OMADA_DEVICE_DEFAULT_PASS || "Admin@12345", |
| }, |
| tagIds: [], |
| supportES: false, |
| supportL2: true, |
| }, |
| }); |
| return res.result?.siteId ?? res.result?.id ?? res.result; |
| } |
|
|
| const res = await omadaRequest("POST", "/sites", { |
| name, |
| scenario, |
| region: "Tanzania", |
| timeZone: "Africa/Dar_es_Salaam", |
| }); |
| |
| return res.result?.siteId ?? res.result; |
| } |
|
|
| async function getSites() { |
| if (isOpenApiConfigured()) { |
| const res = await getV2Client().openApi("GET", "/sites", { |
| query: { page: 1, pageSize: 200 }, |
| }); |
| return unwrapRows(res); |
| } |
|
|
| const res = await omadaRequest( |
| "GET", |
| "/sites?currentPageSize=200¤tPage=1", |
| ); |
| return res.result?.data || []; |
| } |
|
|
| async function getSiteById(siteId) { |
| const sites = await getSites(); |
| return sites.find((s) => (s.id ?? s.siteId) === siteId) || null; |
| } |
|
|
| |
|
|
| async function createPortal(siteId, portalName, ssidList = []) { |
| if (isOpenApiConfigured()) { |
| const ssids = ssidList.filter(Boolean); |
| const res = await getV2Client().openApi("POST", `/sites/${siteId}/portal`, { |
| body: { |
| name: portalName || "Voucher Portal", |
| enable: true, |
| ssidList: ssids, |
| networkList: [], |
| authType: 11, |
| authTimeout: { |
| customTimeout: Number(process.env.OMADA_PORTAL_AUTH_TIMEOUT || 1440), |
| customTimeoutUnit: 1, |
| }, |
| httpsRedirectEnable: true, |
| landingPage: 1, |
| hotspot: { enabledTypes: [3] }, |
| pageType: 2, |
| portalCustomize: { |
| defaultLanguage: 1, |
| logoDisplay: false, |
| welcomeEnable: false, |
| termsOfServiceEnable: false, |
| copyrightEnable: false, |
| redirectionCountDownEnable: false, |
| buttonText: "Log In", |
| }, |
| }, |
| }); |
| return res.result?.portalId ?? res.result?.id ?? res.result; |
| } |
|
|
| const scheme = process.env.PORTAL_SCHEME || "http"; |
| let serverUrl = `${process.env.PORTAL_HOST}/portal/${siteId}`; |
| if (!serverUrl.endsWith("?")) serverUrl += ""; |
|
|
| |
| serverUrl = serverUrl.replace(/^https?:\/\//, ""); |
|
|
| const res = await omadaRequest("POST", `/sites/${siteId}/setting/portals`, { |
| name: portalName, |
| enable: true, |
| httpsRedirectEnable: true, |
| landingPage: 1, |
| authType: 4, |
| externalPortal: { |
| hostType: 2, |
| serverUrlScheme: scheme, |
| serverUrl, |
| logout: false, |
| }, |
| ssidList, |
| networkList: [], |
| pageType: 1, |
| }); |
| return res.result; |
| } |
|
|
| |
|
|
| async function getWlans(siteId) { |
| if (isOpenApiConfigured()) { |
| const res = await getV2Client().openApi("GET", `/sites/${siteId}/wireless-network/wlans`, { |
| query: { page: 1, pageSize: 50 }, |
| }); |
| return unwrapRows(res); |
| } |
|
|
| const res = await omadaRequest( |
| "GET", |
| `/sites/${siteId}/setting/wlans?currentPage=1¤tPageSize=10`, |
| ); |
| const data = res.result?.data || res.result || []; |
| |
| return Array.isArray(data) ? data : []; |
| } |
|
|
| async function getOrCreateWlan(siteId) { |
| const wlans = await getWlans(siteId); |
| if (wlans.length > 0) { |
| const id = wlans[0].id ?? wlans[0].wlanId ?? wlans[0].groupId; |
| return id; |
| } |
|
|
| if (isOpenApiConfigured()) { |
| const res = await getV2Client().openApi("POST", `/sites/${siteId}/wireless-network/wlans`, { |
| body: { name: "Default" }, |
| }); |
| return res.result?.wlanId ?? res.result?.id ?? res.result; |
| } |
|
|
| |
| const res = await omadaRequest("POST", `/sites/${siteId}/setting/wlans`, { |
| name: "Default", |
| }); |
| const id = res.result?.id ?? res.result; |
| return id; |
| } |
|
|
| async function getSSIDs(siteId, wlanId) { |
| if (isOpenApiConfigured()) { |
| const res = await getV2Client().openApi("GET", `/sites/${siteId}/wireless-network/wlans/${wlanId}/ssids`, { |
| query: { page: 1, pageSize: 100 }, |
| }); |
| return unwrapRows(res); |
| } |
|
|
| const res = await omadaRequest( |
| "GET", |
| `/sites/${siteId}/setting/wlans/${wlanId}/ssids?currentPage=1¤tPageSize=10`, |
| ); |
| const data = res.result?.data || res.result || []; |
| return Array.isArray(data) ? data : []; |
| } |
|
|
| async function getRateLimits(siteId) { |
| const paths = [ |
| `/sites/${siteId}/setting/profiles/rateLimits`, |
| `/sites/${siteId}/setting/profiles/ratelimit`, |
| `/sites/${siteId}/setting/profiles/bandwidth`, |
| `/sites/${siteId}/setting/profiles/transmission`, |
| ]; |
| for (const path of paths) { |
| try { |
| const res = await omadaRequest( |
| "GET", |
| `${path}?currentPage=1¤tPageSize=50`, |
| ); |
| |
| const data = Array.isArray(res.result) |
| ? res.result |
| : res.result?.data || []; |
| if (data.length) return data; |
| } catch { |
| |
| } |
| } |
| return []; |
| } |
|
|
| async function resolveRateLimitId(siteId) { |
| |
| const profiles = await getRateLimits(siteId); |
| if (profiles.length) |
| return profiles[0].id ?? profiles[0].profileId ?? profiles[0].rateLimitId; |
|
|
| |
| try { |
| const wlans = await getWlans(siteId); |
| for (const w of wlans) { |
| const wid = w.id ?? w.wlanId ?? w.groupId; |
| const ssids = await getSSIDs(siteId, wid); |
| for (const s of ssids) { |
| const id = s.rateLimit?.rateLimitId ?? s.ssidRateLimit?.rateLimitId; |
| if (id) return id; |
| } |
|
|
| |
| try { |
| const res = await omadaRequest( |
| "GET", |
| `/sites/${siteId}/setting/wlans/${wid}`, |
| ); |
| const wlan = res.result; |
| |
| const id = |
| wlan?.rateLimit?.rateLimitId ?? |
| wlan?.defaultRateLimitId ?? |
| wlan?.rateLimitId; |
| if (id) return id; |
| } catch { |
| |
| } |
| } |
| } catch { |
| |
| } |
|
|
| |
| try { |
| const res = await omadaRequest("GET", `/sites/${siteId}/setting/wireless`); |
| |
| const id = |
| res.result?.defaultRateLimitId ?? res.result?.rateLimit?.rateLimitId; |
| if (id) return id; |
| } catch { |
| |
| } |
|
|
| console.warn( |
| `[omada] resolveRateLimitId site=${siteId} — could not find any rateLimitId`, |
| ); |
| return null; |
| } |
|
|
| async function createSSID(siteId, wlanId, { ssid, password }) { |
| if (!wlanId) throw new Error(`Cannot create SSID — wlanId is ${wlanId}`); |
|
|
| |
| const existing = await getSSIDs(siteId, wlanId); |
| if (existing.length > 0) { |
| const id = existing[0].id ?? existing[0].ssidId; |
| |
| return id; |
| } |
|
|
| if (isOpenApiConfigured()) { |
| const body = { |
| name: ssid, |
| deviceType: 1, |
| band: 1, |
| guestNetEnable: false, |
| security: password ? 3 : 0, |
| broadcast: true, |
| vlanEnable: false, |
| mloEnable: false, |
| pmfMode: 3, |
| enable11r: false, |
| hidePwd: false, |
| greEnable: false, |
| vlanSetting: { mode: 0 }, |
| prohibitWifiShare: false, |
| }; |
| if (password) { |
| body.pskSetting = { |
| securityKey: password, |
| versionPsk: 2, |
| encryptionPsk: 3, |
| gikRekeyPskEnable: false, |
| }; |
| } else { |
| body.oweEnable = false; |
| } |
| const res = await getV2Client().openApi( |
| "POST", |
| `/sites/${siteId}/wireless-network/wlans/${wlanId}/ssids`, |
| { body }, |
| ); |
| return res.result?.ssidId ?? res.result?.id ?? res.result; |
| } |
|
|
| const rateLimitId = await resolveRateLimitId(siteId); |
|
|
| const security = password ? { mode: 3, encryptAlg: 2, password } : 0; |
|
|
| const payload = { |
| name: ssid, |
| band: 1, |
| guestNetEnable: false, |
| security, |
| broadcast: true, |
| vlanSetting: { mode: 0 }, |
| ...(rateLimitId |
| ? { rateLimit: { rateLimitId }, ssidRateLimit: { rateLimitId } } |
| : {}), |
| wlanScheduleEnable: false, |
| macFilterEnable: false, |
| wlanId: "", |
| enable11r: false, |
| oweEnable: false, |
| deviceType: 1, |
| mloEnable: false, |
| rateAndBeaconCtrl: { |
| rate2gCtrlEnable: false, |
| rate5gCtrlEnable: false, |
| rate6gCtrlEnable: false, |
| }, |
| multiCastSetting: { |
| multiCastEnable: true, |
| arpCastEnable: true, |
| filterEnable: false, |
| ipv6CastEnable: true, |
| channelUtil: 100, |
| }, |
| }; |
| const res = await omadaRequest( |
| "POST", |
| `/sites/${siteId}/setting/wlans/${wlanId}/ssids`, |
| payload, |
| ); |
| |
| const id = res.result?.ssidId ?? res.result?.id ?? res.result; |
| return id; |
| } |
|
|
| async function getPortals(siteId) { |
| if (isOpenApiConfigured()) { |
| const res = await getV2Client().openApi("GET", `/sites/${siteId}/portal`, { |
| query: { page: 1, pageSize: 50 }, |
| }); |
| return unwrapRows(res); |
| } |
|
|
| const res = await omadaRequest( |
| "GET", |
| `/sites/${siteId}/setting/portals?currentPage=1¤tPageSize=10`, |
| ); |
| return Array.isArray(res.result) ? res.result : res.result?.data || []; |
| } |
|
|
| async function getControllerPortals(siteId) { |
| const pathPart = `/sites/${siteId}/setting/portals?currentPage=1¤tPageSize=50`; |
| if (isOpenApiConfigured()) { |
| const res = await getV2Client().controllerRequest("GET", pathPart); |
| return unwrapControllerRows(res); |
| } |
|
|
| const res = await omadaRequest("GET", pathPart); |
| return unwrapControllerRows(res); |
| } |
|
|
| async function updatePortalSSID(siteId, portalId, ssidList) { |
| |
| const portals = await getPortals(siteId); |
| const current = portals.find((p) => (p.id ?? p.portalId) === portalId); |
| if (!current) |
| throw new Error(`Portal ${portalId} not found in site ${siteId}`); |
|
|
| console.log("[omada] current portal before PATCH:", current); |
| if (isOpenApiConfigured()) { |
| return getV2Client().controllerRequest("PATCH", `/sites/${siteId}/setting/portals/${portalId}`, { |
| ...current, |
| id: portalId, |
| enable: true, |
| authType: 11, |
| hotspot: { ...(current.hotspot || {}), enabledTypes: [3] }, |
| pageType: current.pageType || 2, |
| ssidList, |
| networkList: current.networkList || [], |
| }); |
| } |
|
|
| return omadaRequest("PATCH", `/sites/${siteId}/setting/portals/${portalId}`, { |
| ...current, |
| pageType: 1, |
| ssidList, |
| }); |
| } |
|
|
| async function updatePortalUrl(siteId, portalId) { |
| const scheme = process.env.PORTAL_SCHEME || "http"; |
| let serverUrl = `${process.env.PORTAL_HOST}/portal/${siteId}`; |
|
|
| |
| const portals = await getPortals(siteId); |
| const current = portals.find((p) => (p.id ?? p.portalId) === portalId); |
| if (!current) |
| throw new Error(`Portal ${portalId} not found in site ${siteId}`); |
|
|
| console.log("[omada] current portal before PATCH:", current); |
|
|
| if (isOpenApiConfigured()) { |
| return getV2Client().controllerRequest("PATCH", `/sites/${siteId}/setting/portals/${portalId}`, { |
| ...current, |
| id: portalId, |
| enable: true, |
| pageType: current.pageType || 2, |
| httpsRedirectEnable: true, |
| landingPage: 1, |
| authType: 11, |
| hotspot: { ...(current.hotspot || {}), enabledTypes: [3] }, |
| portalCustomize: current.portalCustomize || { |
| defaultLanguage: 1, |
| logoDisplay: false, |
| welcomeEnable: false, |
| termsOfServiceEnable: false, |
| copyrightEnable: false, |
| redirectionCountDownEnable: false, |
| buttonText: "Log In", |
| }, |
| ssidList: current.ssidList || [], |
| networkList: current.networkList || [], |
| }); |
| } |
|
|
| serverUrl = serverUrl.replace(/^https?:\/\//, ""); |
| return omadaRequest("PATCH", `/sites/${siteId}/setting/portals/${portalId}`, { |
| ...current, |
| enable: true, |
| pageType: 1, |
| httpsRedirectEnable: true, |
| landingPage: 1, |
| portalCustomize: current.portalCustomize, |
| authType: 4, |
| externalPortal: { |
| hostType: 2, |
| serverUrlScheme: scheme, |
| serverUrl, |
| logout: false, |
| }, |
| }); |
| } |
|
|
| |
|
|
| async function getSiteDevices(siteId) { |
| if (isOpenApiConfigured()) { |
| const client = getV2Client(); |
| try { |
| const res = await client.controllerRequest( |
| "GET", |
| `/sites/${siteId}/grid/devices?currentPage=1¤tPageSize=100`, |
| ); |
| const rows = unwrapControllerRows(res); |
| if (rows.length > 0) return rows; |
| } catch (err) { |
| console.warn(`[Omada] controller grid devices failed for site=${siteId}:`, err.message); |
| } |
|
|
| const candidates = [ |
| `/sites/${siteId}/devices`, |
| `/sites/${siteId}/devices/adopted`, |
| `/sites/${siteId}/devices/known-devices`, |
| ]; |
| let lastError = null; |
| for (const candidate of candidates) { |
| try { |
| const res = await client.openApi("GET", candidate, { |
| query: { page: 1, pageSize: 100 }, |
| }); |
| return unwrapRows(res); |
| } catch (err) { |
| lastError = err; |
| } |
| } |
| throw lastError; |
| } |
|
|
| const res = await omadaRequest( |
| "GET", |
| `/sites/${siteId}/grid/devices?currentPage=1¤tPageSize=50`, |
| ); |
| return res.result?.data || []; |
| } |
|
|
| async function getAllAdoptedDevices() { |
| if (isOpenApiConfigured()) { |
| const res = await getV2Client().openApi("GET", "/devices/known-devices", { |
| query: { page: 1, pageSize: 200 }, |
| }); |
| return unwrapRows(res); |
| } |
|
|
| const res = await omadaRequest( |
| "GET", |
| "/grid/devices/adopted?currentPage=1¤tPageSize=200", |
| ); |
| return res.result?.data || []; |
| } |
|
|
| async function adoptDevice(siteId, mac, username, password) { |
| if (isOpenApiConfigured()) { |
| return getV2Client().openApi("POST", `/sites/${siteId}/devices/${mac}/start-adopt`, { |
| body: { username, password }, |
| }); |
| } |
|
|
| return omadaRequest("POST", `/sites/${siteId}/cmd/devices/adopt`, { |
| mac, |
| username, |
| password, |
| }); |
| } |
|
|
| async function rebootDevice(siteId, mac) { |
| if (isOpenApiConfigured()) { |
| return getV2Client().controllerRequest("POST", `/sites/${siteId}/cmd/devices/${mac}/reboot`, { mac }); |
| } |
|
|
| return omadaRequest("POST", `/sites/${siteId}/cmd/devices/${mac}/reboot`, { |
| mac, |
| }); |
| } |
|
|
| async function forgetDevice(siteId, mac) { |
| if (isOpenApiConfigured()) { |
| return getV2Client().controllerRequest("POST", `/sites/${siteId}/cmd/devices/${mac}/forget`, { mac }); |
| } |
|
|
| return omadaRequest("POST", `/sites/${siteId}/cmd/devices/${mac}/forget`, { |
| mac, |
| }); |
| } |
|
|
| async function updateSSID(siteId, wlanId, ssidId, { ssid, password }) { |
| const existing = await getSSIDs(siteId, wlanId); |
| const current = existing.find((s) => (s.id ?? s.ssidId) === ssidId); |
| if (!current) throw new Error(`SSID ${ssidId} not found in wlan ${wlanId}`); |
| console.log("[omada] current SSID before update:", current); |
|
|
| if (isOpenApiConfigured()) { |
| const body = { |
| name: ssid, |
| band: Number(current.band || 1), |
| guestNetEnable: current.guestNetEnable ?? false, |
| security: password ? 3 : 0, |
| broadcast: current.broadcast ?? true, |
| vlanEnable: current.vlanEnable ?? false, |
| mloEnable: false, |
| pmfMode: current.pmfMode ?? 3, |
| enable11r: current.enable11r ?? false, |
| hidePwd: current.hidePwd ?? false, |
| greEnable: current.greEnable ?? false, |
| vlanSetting: current.vlanSetting || { mode: 0 }, |
| prohibitWifiShare: current.prohibitWifiShare ?? false, |
| }; |
| if (password) { |
| body.pskSetting = { |
| securityKey: password, |
| versionPsk: 2, |
| encryptionPsk: 3, |
| gikRekeyPskEnable: false, |
| }; |
| } else { |
| body.oweEnable = false; |
| } |
| return getV2Client().openApi( |
| "PATCH", |
| `/sites/${siteId}/wireless-network/wlans/${wlanId}/ssids/${ssidId}/update-basic-config`, |
| { body }, |
| ); |
| } |
|
|
| const body = { |
| name: ssid, |
| band: current.band, |
| guestNetEnable: current.guestNetEnable ?? false, |
| security: password ? 3 : 0, |
| broadcast: current.broadcast ?? true, |
| vlanSetting: { mode: 0 }, |
| ...(password |
| ? { |
| pskSetting: { |
| securityKey: password, |
| encryptionPsk: 3, |
| versionPsk: 2, |
| gikRekeyPskEnable: false, |
| }, |
| } |
| : {}), |
| rateLimit: { rateLimitId: current.rateLimit?.rateLimitId ?? "" }, |
| ssidRateLimit: { rateLimitId: current.ssidRateLimit?.rateLimitId ?? "" }, |
| wlanScheduleEnable: false, |
| macFilterEnable: false, |
| wlanId: "", |
| enable11r: false, |
| pmfMode: current.pmfMode ?? 3, |
| multiCastSetting: { |
| multiCastEnable: true, |
| arpCastEnable: true, |
| filterEnable: false, |
| ipv6CastEnable: true, |
| channelUtil: 100, |
| }, |
| mloEnable: false, |
| rateAndBeaconCtrl: { |
| rate2gCtrlEnable: false, |
| rate5gCtrlEnable: false, |
| rate6gCtrlEnable: false, |
| }, |
| }; |
|
|
| return omadaRequest( |
| "PATCH", |
| `/sites/${siteId}/setting/wlans/${wlanId}/ssids/${ssidId}`, |
| body, |
| ); |
| } |
|
|
| async function deleteSSID(siteId, wlanId, ssidId) { |
| if (isOpenApiConfigured()) { |
| return getV2Client().openApi( |
| "DELETE", |
| `/sites/${siteId}/wireless-network/wlans/${wlanId}/ssids/${ssidId}`, |
| ); |
| } |
|
|
| return omadaRequest( |
| "DELETE", |
| `/sites/${siteId}/setting/wlans/${wlanId}/ssids/${ssidId}`, |
| ); |
| } |
|
|
| |
|
|
| async function getClient(siteId, mac) { |
| if (isOpenApiConfigured()) { |
| const res = await getV2Client().controllerRequest("GET", `/sites/${siteId}/clients/${mac}`); |
| return res.result; |
| } |
|
|
| const res = await omadaRequest("GET", `/sites/${siteId}/clients/${mac}`); |
| return res.result; |
| } |
|
|
| async function getSiteClients(siteId) { |
| if (isOpenApiConfigured()) { |
| const res = await getV2Client().controllerRequest( |
| "GET", |
| `/sites/${siteId}/clients?currentPage=1¤tPageSize=200&filters.active=true`, |
| ); |
| return res.result?.data || []; |
| } |
|
|
| const res = await omadaRequest( |
| "GET", |
| `/sites/${siteId}/clients?currentPage=1¤tPageSize=200&filters.active=true`, |
| ); |
| return res.result?.data || []; |
| } |
|
|
| async function getHotspotClients(siteId, { page = 1, size = 200 } = {}) { |
| if (!isOpenApiConfigured()) { |
| throw new Error("Omada OpenAPI config is required for hotspot clients"); |
| } |
|
|
| const query = new URLSearchParams({ |
| currentPage: String(page), |
| currentPageSize: String(size), |
| _: String(Date.now()), |
| }); |
| const res = await getV2Client().controllerRequest( |
| "GET", |
| `/hotspot/sites/${siteId}/clients?${query}`, |
| ); |
| return res.result?.data || []; |
| } |
|
|
| async function authorizeClient(siteId, mac) { |
| if (isOpenApiConfigured()) { |
| return getV2Client().controllerRequest("POST", `/sites/${siteId}/cmd/clients/${mac}/auth`, {}); |
| } |
|
|
| return omadaRequest("POST", `/sites/${siteId}/cmd/clients/${mac}/auth`, {}); |
| } |
|
|
| async function deauthorizeClient(siteId, mac) { |
| if (isOpenApiConfigured()) { |
| return getV2Client().controllerRequest("POST", `/sites/${siteId}/cmd/clients/${mac}/unauth`, {}); |
| } |
|
|
| return omadaRequest( |
| "POST", |
| `/sites/${siteId}/cmd/clients/${mac}/unauth`, |
| {}, |
| ); |
| } |
|
|
| async function applyRateLimit( |
| siteId, |
| mac, |
| { downLimit, downUnit, upLimit, upUnit, name = "Guest" }, |
| ) { |
| if (isOpenApiConfigured()) { |
| return getV2Client().controllerRequest("PATCH", `/sites/${siteId}/clients/${mac}`, { |
| name, |
| rateLimit: { |
| enable: true, |
| downEnable: true, |
| downLimit, |
| downUnit, |
| upEnable: true, |
| upLimit, |
| upUnit, |
| }, |
| clientLockToApSetting: { enable: false }, |
| ipSetting: { useFixedAddr: false }, |
| }); |
| } |
|
|
| return omadaRequest("PATCH", `/sites/${siteId}/clients/${mac}`, { |
| name, |
| rateLimit: { |
| enable: true, |
| downEnable: true, |
| downLimit, |
| downUnit, |
| upEnable: true, |
| upLimit, |
| upUnit, |
| }, |
| clientLockToApSetting: { enable: false }, |
| ipSetting: { useFixedAddr: false }, |
| }); |
| } |
|
|
| |
|
|
| async function getSiteAlerts(siteId) { |
| const now = Date.now(); |
| const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000; |
| if (isOpenApiConfigured()) { |
| const res = await getV2Client().controllerRequest( |
| "GET", |
| `/sites/${siteId}/logs/alerts` + |
| `?filters.timeStart=${thirtyDaysAgo}&filters.timeEnd=${now}` + |
| `&filters.resolved=false¤tPage=1¤tPageSize=50`, |
| ); |
| return res.result?.data || []; |
| } |
|
|
| const res = await omadaRequest( |
| "GET", |
| `/sites/${siteId}/logs/alerts` + |
| `?filters.timeStart=${thirtyDaysAgo}&filters.timeEnd=${now}` + |
| `&filters.resolved=false¤tPage=1¤tPageSize=50`, |
| ); |
| return res.result?.data || []; |
| } |
|
|
| function voucherGroupBodyFromPlan(plan, overrides = {}) { |
| const duration = durationToVoucher(plan.duration_seconds); |
| const amount = Number(overrides.amount || process.env.OMADA_VOUCHER_BATCH_SIZE || 100); |
| const durationMinutes = |
| duration.durationUnit === 1 ? duration.duration : |
| duration.durationUnit === 2 ? duration.duration * 60 : |
| duration.duration * 1440; |
|
|
| return stripNullValues({ |
| codeLength: Number(overrides.codeLength || process.env.OMADA_VOUCHER_CODE_LENGTH || 6), |
| codeForm: [0, 1], |
| amount, |
| name: overrides.name || `${plan.name} (${plan.id})`, |
| type: 0, |
| logout: true, |
| downLimitEnable: false, |
| downLimit: null, |
| downLimitUnit: null, |
| upLimitEnable: false, |
| upLimit: null, |
| upLimitUnit: null, |
| trafficLimitEnable: false, |
| trafficLimit: null, |
| voucherValidityEnable: false, |
| upTimeLimitEnable: false, |
| durationType: 1, |
| duration: durationMinutes, |
| description: overrides.description || `WiFiBiz plan ${plan.id}`, |
| maxUsers: 1, |
| applyToAllPortals: true, |
| printComments: null, |
| }); |
| } |
|
|
| |
| |
| |
| const OMADA_VOUCHER_NAME_MAX = 32; |
| function clampVoucherGroupName(name) { |
| const chars = Array.from(String(name || "")); |
| if (chars.length <= OMADA_VOUCHER_NAME_MAX) return chars.join(""); |
| return chars.slice(0, OMADA_VOUCHER_NAME_MAX).join(""); |
| } |
|
|
| function defaultVoucherGroupName(plan) { |
| return clampVoucherGroupName(`${plan.name} (${plan.id})`); |
| } |
|
|
| function correctedVoucherGroupName(plan, existingGroups = []) { |
| const base = `${defaultVoucherGroupName(plan)} - single-user`; |
| const existingNames = new Set(existingGroups.map((group) => String(group?.name || ""))); |
| const clampedBase = clampVoucherGroupName(base); |
| if (!existingNames.has(clampedBase)) return clampedBase; |
|
|
| for (let i = 2; i <= 50; i += 1) { |
| const candidate = clampVoucherGroupName(`${base} ${i}`); |
| if (!existingNames.has(candidate)) return candidate; |
| } |
|
|
| return clampVoucherGroupName(`${base} ${Date.now()}`); |
| } |
|
|
| function expectedVoucherGroupSettings(plan) { |
| const body = voucherGroupBodyFromPlan(plan, { amount: 1 }); |
| return { |
| type: body.type, |
| maxUsers: body.maxUsers, |
| durationType: body.durationType, |
| duration: body.duration, |
| }; |
| } |
|
|
| function voucherGroupIdFromResult(res) { |
| return res.result?.id || res.result?.voucherGroupId || res.result?.groupId || res.result; |
| } |
|
|
| function voucherRowsFromResult(res) { |
| const rows = res.result?.data || res.result || res.data || []; |
| return Array.isArray(rows) ? rows : []; |
| } |
|
|
| function voucherGroupId(group) { |
| return group?.id || group?.voucherGroupId || group?.groupId || null; |
| } |
|
|
| function isVoucherGroupUnused(group) { |
| if (!group) return false; |
| const hasUsageCounters = ["usedCount", "inUseCount", "expiredCount", "unusedCount", "totalCount", "totalRows"] |
| .some((key) => Object.prototype.hasOwnProperty.call(group, key)); |
| if (!hasUsageCounters) return false; |
| const used = Number(group.usedCount || 0); |
| const inUse = Number(group.inUseCount || 0); |
| const expired = Number(group.expiredCount || 0); |
| const unused = Number(group.unusedCount || 0); |
| const total = Number(group.totalCount || group.totalRows || 0); |
| if (used > 0 || inUse > 0 || expired > 0) return false; |
| return total === 0 || unused === 0 || unused === total; |
| } |
|
|
| function isTruthyOmadaFlag(value) { |
| return value === true || value === 1 || value === "1" || value === "true"; |
| } |
|
|
| function voucherGroupHasNoSpeedLimit(group) { |
| if (!group) return false; |
| return !isTruthyOmadaFlag(group.downLimitEnable) && !isTruthyOmadaFlag(group.upLimitEnable); |
| } |
|
|
| function hasOwnValue(object, key) { |
| return Object.prototype.hasOwnProperty.call(object || {}, key) && |
| object[key] !== null && |
| object[key] !== undefined && |
| object[key] !== ""; |
| } |
|
|
| function voucherGroupCompatibility(group, plan) { |
| const expected = expectedVoucherGroupSettings(plan); |
| const reasons = []; |
|
|
| for (const key of ["type", "maxUsers", "durationType", "duration"]) { |
| if (!hasOwnValue(group, key)) { |
| reasons.push(`missing_${key}`); |
| continue; |
| } |
| if (Number(group[key]) !== Number(expected[key])) { |
| reasons.push(`${key}_expected_${expected[key]}_got_${group[key]}`); |
| } |
| } |
|
|
| if (!voucherGroupHasNoSpeedLimit(group)) { |
| reasons.push("speed_limit_enabled"); |
| } |
|
|
| return { |
| compatible: reasons.length === 0, |
| reasons, |
| expected, |
| }; |
| } |
|
|
| async function getVoucherGroup(siteId, groupId) { |
| const { groups } = await listVoucherGroups(siteId, { page: 1, size: 200 }); |
| return groups.find((item) => String(voucherGroupId(item)) === String(groupId)) || null; |
| } |
|
|
| function findCompatibleVoucherGroup(groups, plan) { |
| const base = defaultVoucherGroupName(plan); |
| const candidates = groups.filter((group) => { |
| const name = String(group?.name || ""); |
| return name === base || name.startsWith(`${base} - single-user`); |
| }); |
| return candidates.find((group) => voucherGroupCompatibility(group, plan).compatible) || null; |
| } |
|
|
| async function createVoucherGroup(siteId, plan, overrides = {}) { |
| if (!isOpenApiConfigured()) { |
| throw new Error("Omada OpenAPI config is required for voucher groups"); |
| } |
| const client = getV2Client(); |
| const controllerBody = voucherGroupBodyFromPlan(plan, overrides); |
| const res = await client.controllerRequest( |
| "POST", |
| `/hotspot/sites/${siteId}/voucherGroups`, |
| controllerBody, |
| ); |
| return { |
| voucherGroupId: voucherGroupIdFromResult(res), |
| request: controllerBody, |
| response: res, |
| path: `/hotspot/sites/${siteId}/voucherGroups`, |
| }; |
| } |
|
|
| async function deleteVoucherGroup(siteId, groupId) { |
| if (!isOpenApiConfigured()) { |
| throw new Error("Omada OpenAPI config is required for voucher groups"); |
| } |
| if (!siteId || !groupId) { |
| throw new Error("siteId and groupId are required to delete an Omada voucher group"); |
| } |
| const client = getV2Client(); |
| return client.controllerRequest("DELETE", `/hotspot/sites/${siteId}/voucherGroups/${groupId}`); |
| } |
|
|
| async function deleteVoucher(siteId, voucherId) { |
| if (!isOpenApiConfigured()) { |
| throw new Error("Omada OpenAPI config is required for vouchers"); |
| } |
| if (!siteId || !voucherId) { |
| throw new Error("siteId and voucherId are required to delete an Omada voucher"); |
| } |
| const client = getV2Client(); |
| return client.controllerRequest("DELETE", `/hotspot/sites/${siteId}/vouchers/${voucherId}`); |
| } |
|
|
| async function extendVoucherClient(siteId, clientId, periodMs) { |
| if (!isOpenApiConfigured()) { |
| throw new Error("Omada OpenAPI config is required for voucher extension"); |
| } |
| if (!siteId || !clientId || !periodMs) { |
| throw new Error("siteId, clientId, and periodMs are required to extend an Omada voucher client"); |
| } |
| const client = getV2Client(); |
| return client.controllerRequest( |
| "POST", |
| `/hotspot/sites/${siteId}/cmd/clients/${clientId}/extend`, |
| { period: Number(periodMs) }, |
| ); |
| } |
|
|
| async function disconnectHotspotClient(siteId, clientId) { |
| if (!isOpenApiConfigured()) { |
| throw new Error("Omada OpenAPI config is required for hotspot client disconnect"); |
| } |
| if (!siteId || !clientId) { |
| throw new Error("siteId and clientId are required to disconnect an Omada hotspot client"); |
| } |
| const client = getV2Client(); |
| return client.controllerRequest( |
| "POST", |
| `/hotspot/sites/${siteId}/cmd/clients/${clientId}/disconnect`, |
| {}, |
| ); |
| } |
|
|
| async function reconcileVoucherGroup(siteId, plan) { |
| const groupId = plan.omada_voucher_group_id; |
| const { groups } = await listVoucherGroups(siteId, { page: 1, size: 200 }); |
|
|
| if (!groupId) { |
| const compatible = findCompatibleVoucherGroup(groups, plan); |
| if (compatible) { |
| return { |
| action: "reused_compatible", |
| voucherGroupId: voucherGroupId(compatible), |
| group: compatible, |
| compatibility: voucherGroupCompatibility(compatible, plan), |
| }; |
| } |
|
|
| const name = groups.some((group) => String(group?.name || "") === defaultVoucherGroupName(plan)) |
| ? correctedVoucherGroupName(plan, groups) |
| : undefined; |
| const created = await createVoucherGroup(siteId, plan, { name }); |
| return { action: "created", ...created }; |
| } |
|
|
| const group = groups.find((item) => String(voucherGroupId(item)) === String(groupId)) || null; |
| if (!group) { |
| const compatible = findCompatibleVoucherGroup(groups, plan); |
| if (compatible) { |
| return { |
| action: "reused_compatible", |
| oldVoucherGroupId: groupId, |
| voucherGroupId: voucherGroupId(compatible), |
| group: compatible, |
| compatibility: voucherGroupCompatibility(compatible, plan), |
| }; |
| } |
|
|
| const name = groups.some((item) => String(item?.name || "") === defaultVoucherGroupName(plan)) |
| ? correctedVoucherGroupName(plan, groups) |
| : undefined; |
| const created = await createVoucherGroup(siteId, plan, { name }); |
| return { action: "created_missing", oldVoucherGroupId: groupId, ...created }; |
| } |
|
|
| const compatibility = voucherGroupCompatibility(group, plan); |
| if (compatibility.compatible) { |
| return { action: "unchanged", voucherGroupId: groupId, group, compatibility }; |
| } |
|
|
| const compatible = findCompatibleVoucherGroup(groups, plan); |
| if (compatible) { |
| return { |
| action: "reused_compatible", |
| oldVoucherGroupId: groupId, |
| voucherGroupId: voucherGroupId(compatible), |
| group: compatible, |
| oldGroup: group, |
| compatibility, |
| }; |
| } |
|
|
| const created = await createVoucherGroup(siteId, { |
| ...plan, |
| omada_voucher_group_id: null, |
| }, { |
| name: correctedVoucherGroupName(plan, groups), |
| }); |
| return { |
| action: "replaced_incompatible", |
| oldVoucherGroupId: groupId, |
| group, |
| compatibility, |
| ...created, |
| }; |
| } |
|
|
| async function listVoucherGroups(siteId, { page = 1, size = 50, name } = {}) { |
| if (!isOpenApiConfigured()) { |
| throw new Error("Omada OpenAPI config is required for voucher groups"); |
| } |
| const client = getV2Client(); |
| const query = `currentPage=${page}¤tPageSize=${size}&searchField=name%2Ccode&_=${Date.now()}`; |
| const response = await client.controllerRequest("GET", `/hotspot/sites/${siteId}/voucherGroups?${query}`); |
| const groups = voucherRowsFromResult(response); |
|
|
| return { |
| response, |
| groups, |
| matched: name ? groups.find((item) => item.name === name) : groups[0], |
| }; |
| } |
|
|
| async function listVouchers(siteId, groupId, { page = 1, size = 100 } = {}) { |
| if (!isOpenApiConfigured()) { |
| throw new Error("Omada OpenAPI config is required for vouchers"); |
| } |
| const client = getV2Client(); |
| const query = `currentPage=${page}¤tPageSize=${size}&_=${Date.now()}`; |
| const res = await client.controllerRequest( |
| "GET", |
| `/hotspot/sites/${siteId}/voucherGroups/${groupId}?${query}`, |
| ); |
| const vouchers = voucherRowsFromResult(res); |
| return { response: res, vouchers }; |
| } |
|
|
| async function issueVoucherFromGroup(siteId, groupId, { excludeCodes = [], excludeVoucherIds = [] } = {}) { |
| const excludedCodes = new Set(excludeCodes.filter(Boolean).map((code) => String(code).toUpperCase())); |
| const excludedIds = new Set(excludeVoucherIds.filter(Boolean).map(String)); |
| const result = await listVouchers(siteId, groupId, { page: 1, size: 200 }); |
| const voucher = result.vouchers.find((item) => { |
| const status = Number(item.status); |
| const code = String(item.code || "").toUpperCase(); |
| const id = String(item.id || item.voucherId || ""); |
| return status === 0 && code && !excludedCodes.has(code) && !excludedIds.has(id); |
| }); |
|
|
| if (!voucher) { |
| const err = new Error("No unused vouchers available for this group"); |
| err.code = "NO_UNUSED_OMADA_VOUCHERS"; |
| throw err; |
| } |
|
|
| return { |
| voucher, |
| voucherId: voucher.id || voucher.voucherId || null, |
| code: voucher.code, |
| stats: result.response?.result || null, |
| }; |
| } |
|
|
| async function findVoucherByCode(siteId, code, { pageSize = 200 } = {}) { |
| const normalizedCode = String(code || "").trim().toUpperCase(); |
| if (!normalizedCode) return null; |
|
|
| const { groups } = await listVoucherGroups(siteId, { page: 1, size: pageSize }); |
| for (const group of groups) { |
| const groupId = voucherGroupId(group); |
| if (!groupId) continue; |
|
|
| const { vouchers } = await listVouchers(siteId, groupId, { page: 1, size: pageSize }); |
| const voucher = vouchers.find((item) => String(item.code || "").trim().toUpperCase() === normalizedCode); |
| if (voucher) { |
| return { |
| group, |
| groupId, |
| voucher, |
| voucherId: voucher.id || voucher.voucherId || null, |
| code: voucher.code, |
| }; |
| } |
| } |
|
|
| return null; |
| } |
|
|
| async function importAndAttachPortalPage(siteId, portalId, ssidId, filePath = null) { |
| if (!isOpenApiConfigured()) { |
| throw new Error("Omada OpenAPI config is required for portal page import"); |
| } |
| const client = getV2Client(); |
| const resolvedPath = path.resolve( |
| filePath || process.env.OMADA_PORTAL_TEMPLATE_PATH || path.join(__dirname, "..", "..", "v2 Test", "portal-redirect.html"), |
| ); |
| if (!fs.existsSync(resolvedPath)) { |
| throw new Error(`Portal template not found: ${resolvedPath}`); |
| } |
|
|
| const portalBase = `${process.env.PORTAL_SCHEME || "http"}://${String(process.env.PORTAL_HOST || "").replace(/^https?:\/\//, "").replace(/\/+$/, "")}`; |
| const templateHtml = fs.readFileSync(resolvedPath, "utf8") |
| .replace(/__WIFIBIZ_SITE_ID__/g, siteId) |
| .replace(/__WIFIBIZ_PORTAL_BASE__/g, portalBase); |
| const preparedPath = path.join("/tmp", `wifibiz-portal-${siteId}-${Date.now()}.html`); |
| fs.writeFileSync(preparedPath, templateHtml); |
|
|
| const md5 = client.fileMd5(preparedPath); |
| console.log("[portal-template] prepared upload:", { |
| siteId, |
| portalId, |
| ssidId, |
| portalBase, |
| fileName: "portal-redirect.html", |
| md5, |
| containsSiteId: templateHtml.includes(siteId), |
| }); |
| const upload = await client.controllerUploadFileWithData( |
| `/files/sites/${siteId}/portal/page`, |
| preparedPath, |
| { md5 }, |
| "portal-redirect.html", |
| ); |
| const uploadedPortalPageId = omadaResultId(upload.result); |
| console.log("[portal-template] upload result:", { |
| siteId, |
| portalId, |
| ssidId, |
| uploadedPortalPageId, |
| result: upload.result || null, |
| }); |
|
|
| const portals = await getControllerPortals(siteId); |
| const current = findPortalById(portals, portalId) || {}; |
| const importedPortalPageId = uploadedPortalPageId || current.importedPortalPage?.id || null; |
| const saveBody = { |
| id: portalId, |
| name: current.name || "Voucher Portal", |
| enable: true, |
| httpsRedirectEnable: true, |
| landingPage: 1, |
| authType: 11, |
| hotspot: { enabledTypes: [3] }, |
| ...(importedPortalPageId ? { importedPortalPage: { id: importedPortalPageId } } : {}), |
| resource: 0, |
| ssidList: ssidId ? [ssidId] : (current.ssidList || []), |
| networkList: current.networkList || [], |
| pageType: 2, |
| }; |
| console.log(`[portal-template] attaching imported page site=${siteId} portal=${portalId} page=${importedPortalPageId || 'existing/unknown'} ssid=${ssidId || 'none'} name=${saveBody.name}`); |
| let saved; |
| try { |
| saved = await client.controllerRequest("PATCH", `/sites/${siteId}/setting/portals/${portalId}`, saveBody); |
| } catch (err) { |
| console.error("[portal-template] attach failed:", { |
| siteId, |
| portalId, |
| ssidId, |
| importedPortalPageId, |
| uploadedFileName: upload.result?.fileName || null, |
| errorCode: err.errorCode ?? null, |
| message: err.message, |
| result: err.result ?? null, |
| request: saveBody, |
| }); |
| throw err; |
| } |
| const verifiedPortals = await getControllerPortals(siteId); |
| const verified = findPortalById(verifiedPortals, portalId); |
| const expectedServerUrl = `${String(process.env.PORTAL_HOST || "").replace(/^https?:\/\//, "").replace(/\/+$/, "")}/portal/${siteId}`; |
| const verification = { |
| siteId, |
| portalId, |
| expectedServerUrl, |
| pageType: verified?.pageType ?? null, |
| importedPortalPageId: verified?.importedPortalPage?.id || null, |
| importedPortalPageFileName: verified?.importedPortalPage?.fileName || null, |
| serverUrlScheme: verified?.externalPortal?.serverUrlScheme || verified?.landingUrlScheme || null, |
| serverUrl: verified?.externalPortal?.serverUrl || null, |
| ssidList: verified?.ssidList || [], |
| hotspotEnabledTypes: verified?.hotspot?.enabledTypes || [], |
| }; |
| const mismatches = []; |
| if (!verified) mismatches.push("portal_missing_after_patch"); |
| if (verified && verified.pageType !== 2) mismatches.push(`pageType=${verified.pageType}`); |
| if (verified && !verified.importedPortalPage?.id) mismatches.push("importedPortalPage.id_missing"); |
| if (verified && verified.importedPortalPage?.fileName !== "portal-redirect.html") { |
| mismatches.push(`importedPortalPage.fileName=${verified.importedPortalPage?.fileName || "missing"}`); |
| } |
| if (verified && verified.externalPortal?.serverUrlScheme !== "http") { |
| mismatches.push(`externalPortal.serverUrlScheme=${verified.externalPortal?.serverUrlScheme || "missing"}`); |
| } |
| if (verified && verified.externalPortal?.serverUrl !== expectedServerUrl) { |
| mismatches.push(`externalPortal.serverUrl=${verified.externalPortal?.serverUrl || "missing"}`); |
| } |
| if (verified && ssidId && !(verified.ssidList || []).includes(ssidId)) { |
| mismatches.push(`ssidList_missing_${ssidId}`); |
| } |
| if (verified && !(verified.hotspot?.enabledTypes || []).includes(3)) { |
| mismatches.push("hotspot.enabledTypes_missing_3"); |
| } |
|
|
| if (mismatches.length) { |
| console.warn("[portal-template] verification failed:", { |
| ...verification, |
| mismatches, |
| }); |
| } else { |
| console.log("[portal-template] verification ok:", verification); |
| } |
| try { |
| fs.unlinkSync(preparedPath); |
| } catch (_) {} |
| return { |
| importedPortalPageId: verification.importedPortalPageId, |
| filePath: resolvedPath, |
| preparedPath, |
| md5, |
| upload, |
| saved, |
| verification, |
| verificationMismatches: mismatches, |
| }; |
| } |
|
|
| async function getPortalId(siteId, ssidId = null) { |
| const portals = await getPortals(siteId); |
| const matched = ssidId |
| ? portals.find((portal) => (portal.ssidList || []).includes(ssidId)) |
| : portals[0]; |
| return matched ? (matched.id ?? matched.portalId) : null; |
| } |
|
|
| module.exports = { |
| initOmada, |
| createSite, |
| getSites, |
| getSiteById, |
| createPortal, |
| getPortals, |
| getPortalId, |
| updatePortalUrl, |
| importAndAttachPortalPage, |
| getWlans, |
| getOrCreateWlan, |
| getSSIDs, |
| getRateLimits, |
| resolveRateLimitId, |
| createSSID, |
| updatePortalSSID, |
| getSiteDevices, |
| getAllAdoptedDevices, |
| adoptDevice, |
| rebootDevice, |
| forgetDevice, |
| updateSSID, |
| deleteSSID, |
| getClient, |
| getSiteClients, |
| getHotspotClients, |
| authenticatePortalVoucher, |
| authorizeClient, |
| deauthorizeClient, |
| applyRateLimit, |
| getSiteAlerts, |
| isOpenApiConfigured, |
| createVoucherGroup, |
| deleteVoucherGroup, |
| deleteVoucher, |
| extendVoucherClient, |
| disconnectHotspotClient, |
| reconcileVoucherGroup, |
| isVoucherGroupUnused, |
| voucherGroupHasNoSpeedLimit, |
| voucherGroupCompatibility, |
| listVoucherGroups, |
| listVouchers, |
| issueVoucherFromGroup, |
| findVoucherByCode, |
| }; |
|
|