Compare commits

...

2 Commits

Author SHA1 Message Date
shamoon
b48d283dc2 Retry Omada login on HTML response; preserve cookies 2026-03-04 11:34:44 -08:00
shamoon
d313e0a124 Add some debug logging for omada 2026-03-02 09:22:39 -08:00
2 changed files with 211 additions and 8 deletions

View File

@@ -6,6 +6,40 @@ const proxyName = "omadaProxyHandler";
const logger = createLogger(proxyName);
function parseOmadaJson(data, { step, status, contentType, url }) {
const body = Buffer.isBuffer(data) ? data.toString() : String(data ?? "");
try {
return JSON.parse(body);
} catch (error) {
logger.debug(
"Failed parsing Omada %s response as JSON (HTTP %d, content-type: %s, url: %s). Body: %s",
step,
status,
contentType ?? "unknown",
url,
body,
);
throw error;
}
}
function isLikelyHtmlResponse(contentType, data) {
const body = Buffer.isBuffer(data) ? data.toString() : String(data ?? "");
return contentType?.includes("text/html") || body.startsWith("<!DOCTYPE") || body.startsWith("<html");
}
function extractCookieHeader(responseHeaders) {
const setCookieHeader = responseHeaders?.["set-cookie"];
if (!setCookieHeader) return undefined;
if (Array.isArray(setCookieHeader)) {
return setCookieHeader.map((cookie) => cookie.split(";")[0]).join("; ");
}
return String(setCookieHeader).split(";")[0];
}
async function login(loginUrl, username, password, controllerVersionMajor) {
const params = {
username,
@@ -20,15 +54,17 @@ async function login(loginUrl, username, password, controllerVersionMajor) {
};
}
const [status, contentType, data] = await httpProxy(loginUrl, {
const [status, contentType, data, responseHeaders] = await httpProxy(loginUrl, {
method: "POST",
cookieHeader: "X-Bypass-Cookie",
body: JSON.stringify(params),
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
});
return [status, JSON.parse(data.toString())];
return [status, contentType, data, extractCookieHeader(responseHeaders)];
}
export default async function omadaProxyHandler(req, res) {
@@ -86,12 +122,18 @@ export default async function omadaProxyHandler(req, res) {
break;
}
const [loginStatus, loginResponseData] = await login(
const [loginStatus, loginContentType, loginData, loginCookieHeader] = await login(
loginUrl,
widget.username,
widget.password,
controllerVersionMajor,
);
const loginResponseData = parseOmadaJson(loginData, {
step: "login",
status: loginStatus,
contentType: loginContentType,
url: loginUrl,
});
if (loginStatus !== 200 || loginResponseData.errorCode > 0) {
return res
@@ -100,11 +142,13 @@ export default async function omadaProxyHandler(req, res) {
}
const { token } = loginResponseData.result;
let omadaCookieHeader = loginCookieHeader;
let sitesUrl;
let body = {};
let params = { token };
let headers = { "Csrf-Token": token };
if (omadaCookieHeader) headers.Cookie = omadaCookieHeader;
let method = "GET";
switch (controllerVersionMajor) {
@@ -134,9 +178,72 @@ export default async function omadaProxyHandler(req, res) {
params,
body: JSON.stringify(body),
headers,
cookieHeader: "X-Bypass-Cookie",
});
const sitesResponseData = JSON.parse(data);
let sitesResponseData;
try {
sitesResponseData = parseOmadaJson(data, {
step: "sites list",
status,
contentType,
url: sitesUrl,
});
} catch (parseError) {
if (!isLikelyHtmlResponse(contentType, data)) {
throw parseError;
}
logger.debug("Received HTML response for Omada sites list; retrying with a fresh login.");
const [retryLoginStatus, retryLoginContentType, retryLoginData, retryLoginCookieHeader] = await login(
loginUrl,
widget.username,
widget.password,
controllerVersionMajor,
);
const retryLoginResponseData = parseOmadaJson(retryLoginData, {
step: "login (retry)",
status: retryLoginStatus,
contentType: retryLoginContentType,
url: loginUrl,
});
if (retryLoginStatus !== 200 || retryLoginResponseData.errorCode > 0) {
return res.status(retryLoginStatus).json({
error: {
message: "Error re-authenticating to Omada controller",
url: loginUrl,
data: retryLoginResponseData,
},
});
}
const retryToken = retryLoginResponseData.result?.token;
omadaCookieHeader = retryLoginCookieHeader;
const retrySitesUrlObj = new URL(sitesUrl);
retrySitesUrlObj.searchParams.set("token", retryToken);
const retrySitesUrl = retrySitesUrlObj.toString();
[status, contentType, data] = await httpProxy(retrySitesUrl, {
method,
params: { token: retryToken },
body: JSON.stringify(body),
headers: {
...headers,
"Csrf-Token": retryToken,
...(omadaCookieHeader ? { Cookie: omadaCookieHeader } : {}),
},
cookieHeader: "X-Bypass-Cookie",
});
sitesResponseData = parseOmadaJson(data, {
step: "sites list (retry)",
status,
contentType,
url: retrySitesUrl,
});
}
if (status !== 200 || sitesResponseData.errorCode > 0) {
logger.debug(`HTTP ${status} getting sites list: ${sitesResponseData.msg}`);
@@ -174,6 +281,7 @@ export default async function omadaProxyHandler(req, res) {
},
};
headers = { "Content-Type": "application/json" };
if (omadaCookieHeader) headers.Cookie = omadaCookieHeader;
params = { token };
[status, contentType, data] = await httpProxy(switchUrl, {
@@ -181,9 +289,15 @@ export default async function omadaProxyHandler(req, res) {
params,
body: JSON.stringify(body),
headers,
cookieHeader: "X-Bypass-Cookie",
});
const switchResponseData = JSON.parse(data);
const switchResponseData = parseOmadaJson(data, {
step: "switch site",
status,
contentType,
url: switchUrl,
});
if (status !== 200 || switchResponseData.errorCode > 0) {
logger.error(`HTTP ${status} getting sites list: ${data}`);
return res.status(status).json({ error: { message: "Error switching site", url: switchUrl, data } });
@@ -197,9 +311,15 @@ export default async function omadaProxyHandler(req, res) {
method: "getGlobalStat",
}),
headers,
cookieHeader: "X-Bypass-Cookie",
});
siteResponseData = JSON.parse(data);
siteResponseData = parseOmadaJson(data, {
step: "global stats",
status,
contentType,
url: statsUrl,
});
if (status !== 200 || siteResponseData.errorCode > 0) {
return res.status(status).json({ error: { message: "Error getting stats", url: statsUrl, data } });
@@ -218,10 +338,17 @@ export default async function omadaProxyHandler(req, res) {
[status, contentType, data] = await httpProxy(siteStatsUrl, {
headers: {
"Csrf-Token": token,
...(omadaCookieHeader ? { Cookie: omadaCookieHeader } : {}),
},
cookieHeader: "X-Bypass-Cookie",
});
siteResponseData = JSON.parse(data);
siteResponseData = parseOmadaJson(data, {
step: "overview stats",
status,
contentType,
url: siteStatsUrl,
});
if (status !== 200 || siteResponseData.errorCode > 0) {
logger.debug(`HTTP ${status} getting stats for site ${widget.site} with message ${siteResponseData.msg}`);
@@ -242,9 +369,16 @@ export default async function omadaProxyHandler(req, res) {
[status, contentType, data] = await httpProxy(alertUrl, {
headers: {
"Csrf-Token": token,
...(omadaCookieHeader ? { Cookie: omadaCookieHeader } : {}),
},
cookieHeader: "X-Bypass-Cookie",
});
const alertResponseData = parseOmadaJson(data, {
step: "alerts",
status,
contentType,
url: alertUrl,
});
const alertResponseData = JSON.parse(data);
activeUser = siteResponseData.result.totalClientNum;
connectedAp = siteResponseData.result.connectedApNum;

View File

@@ -324,4 +324,73 @@ describe("widgets/omada/proxy", () => {
},
});
});
it("retries login when sites list returns HTML", async () => {
getServiceWidget.mockResolvedValue({ url: "http://omada", username: "u", password: "p", site: "Default" });
httpProxy
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({ result: { omadacId: "cid", controllerVer: "5.0.0" } }),
])
// initial login
.mockResolvedValueOnce([
200,
"application/json",
Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t1" } })),
])
// sites list unexpectedly returns HTML
.mockResolvedValueOnce([200, "text/html;charset=utf-8", "<!DOCTYPE html><html><body>login</body></html>"])
// retry login
.mockResolvedValueOnce([
200,
"application/json",
Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t2" } })),
])
// retry sites list works
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({ errorCode: 0, result: { data: [{ name: "Default", id: "siteid" }] } }),
])
// overview works
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({
errorCode: 0,
result: {
totalClientNum: 11,
connectedApNum: 3,
connectedGatewayNum: 1,
connectedSwitchNum: 2,
},
}),
])
// alerts works
.mockResolvedValueOnce([200, "application/json", JSON.stringify({ errorCode: 0, result: { alertNum: 5 } })]);
const req = { query: { group: "g", service: "svc", index: "0" } };
const res = createMockRes();
await omadaProxyHandler(req, res);
expect(logger.debug).toHaveBeenCalledWith(
"Received HTML response for Omada sites list; retrying with a fresh login.",
);
expect(httpProxy.mock.calls[1][1].cookieHeader).toBe("X-Bypass-Cookie");
expect(httpProxy.mock.calls[2][1].cookieHeader).toBe("X-Bypass-Cookie");
expect(httpProxy.mock.calls[3][1].cookieHeader).toBe("X-Bypass-Cookie");
expect(httpProxy.mock.calls[4][1].cookieHeader).toBe("X-Bypass-Cookie");
expect(res.body).toBe(
JSON.stringify({
connectedAp: 3,
activeUser: 11,
alerts: 5,
connectedGateways: 1,
connectedSwitches: 2,
}),
);
});
});