Chore: make unifi proxy more generic (#6469)

This commit is contained in:
shamoon
2026-03-27 14:39:27 -07:00
committed by GitHub
parent b37645b8d0
commit ff4eaa2cd9
3 changed files with 197 additions and 99 deletions

View File

@@ -0,0 +1,116 @@
import cache from "memory-cache";
import createLogger from "utils/logger";
import { formatApiCall } from "utils/proxy/api-helpers";
import { addCookieToJar, setCookieHeader } from "utils/proxy/cookie-jar";
import { httpProxy } from "utils/proxy/http";
import widgets from "widgets/widgets";
function isSuccessfulLoginResponse(data) {
const json = JSON.parse(data.toString());
return json?.meta?.rc === "ok" || json?.login_time || json?.update_time;
}
async function login({ widget, api, endpoint, csrfToken }) {
const loginUrl = new URL(formatApiCall(api.replace("{prefix}", ""), { endpoint, ...widget }));
const headers = { "Content-Type": "application/json" };
if (csrfToken) {
headers["X-CSRF-TOKEN"] = csrfToken;
}
return httpProxy(loginUrl, {
method: "POST",
body: JSON.stringify({ username: widget.username, password: widget.password, remember: true, rememberMe: true }),
headers,
});
}
export default function createUnifiProxyHandler({
proxyName,
resolveWidget,
resolveRequestContext,
getLoginEndpoint = () => "auth/login",
shouldAttemptLogin = ({ widget }) => !widget.key,
}) {
const prefixCacheKey = `${proxyName}__prefix`;
const logger = createLogger(proxyName);
return async function unifiProxyHandler(req, res) {
const widget = await resolveWidget(req, logger);
const { service, endpoint } = req.query;
if (!widget) {
return res.status(400).json({ error: "Invalid proxy service type" });
}
const api = widgets?.[widget.type]?.api;
if (!api) {
return res.status(403).json({ error: "Service does not support API calls" });
}
const cachedPrefix = cache.get(`${prefixCacheKey}.${service}`);
const {
prefix,
headers = {},
csrfToken: initialCsrfToken,
} = await resolveRequestContext({
cachedPrefix,
logger,
req,
service,
widget,
});
let csrfToken = initialCsrfToken;
cache.put(`${prefixCacheKey}.${service}`, prefix);
widget.prefix = prefix;
const url = new URL(formatApiCall(api, { endpoint, ...widget }));
const params = { method: "GET", headers };
setCookieHeader(url, params);
let [status, contentType, data, responseHeaders] = await httpProxy(url, params);
if (status === 401 && shouldAttemptLogin({ widget, req, responseHeaders })) {
logger.debug("UniFi request was rejected, attempting login.");
if (responseHeaders?.["x-csrf-token"]) {
csrfToken = responseHeaders["x-csrf-token"];
}
[status, contentType, data, responseHeaders] = await login({
api,
csrfToken,
endpoint: getLoginEndpoint({ prefix, req, widget }),
widget,
});
if (status !== 200) {
logger.error("HTTP %d logging in to UniFi. Data: %s", status, data);
return res.status(status).json({ error: { message: `HTTP Error ${status}`, url, data } });
}
if (!isSuccessfulLoginResponse(data)) {
logger.error("Error logging in to UniFi: Data: %s", data);
return res.status(401).end(data);
}
addCookieToJar(url, responseHeaders);
setCookieHeader(url, params);
[status, contentType, data, responseHeaders] = await httpProxy(url, params);
}
if (status !== 200) {
logger.error("HTTP %d getting data from UniFi endpoint %s. Data: %s", status, url.href, data);
return res.status(status).json({ error: { message: `HTTP Error ${status}`, url, data } });
}
if (contentType) {
res.setHeader("Content-Type", contentType);
}
return res.status(status).send(data);
};
}

View File

@@ -1,19 +1,11 @@
import cache from "memory-cache";
import getServiceWidget from "utils/config/service-helpers";
import { getPrivateWidgetOptions } from "utils/config/widget-helpers";
import createLogger from "utils/logger";
import { formatApiCall } from "utils/proxy/api-helpers";
import { addCookieToJar, setCookieHeader } from "utils/proxy/cookie-jar";
import createUnifiProxyHandler from "utils/proxy/handlers/unifi";
import { httpProxy } from "utils/proxy/http";
import widgets from "widgets/widgets";
const udmpPrefix = "/proxy/network";
const proxyName = "unifiProxyHandler";
const prefixCacheKey = `${proxyName}__prefix`;
const logger = createLogger(proxyName);
async function getWidget(req) {
async function getWidget(req, logger) {
const { group, service, index } = req.query;
let widget = null;
@@ -43,100 +35,36 @@ async function getWidget(req) {
return widget;
}
async function login(widget, csrfToken) {
const endpoint = widget.prefix === udmpPrefix ? "auth/login" : "login";
const api = widgets?.[widget.type]?.api?.replace("{prefix}", ""); // no prefix for login url
const loginUrl = new URL(formatApiCall(api, { endpoint, ...widget }));
const loginBody = { username: widget.username, password: widget.password, remember: true, rememberMe: true };
const headers = { "Content-Type": "application/json" };
if (csrfToken) {
headers["X-CSRF-TOKEN"] = csrfToken;
}
const [status, contentType, data, responseHeaders] = await httpProxy(loginUrl, {
method: "POST",
body: JSON.stringify(loginBody),
headers,
});
return [status, contentType, data, responseHeaders];
}
export default async function unifiProxyHandler(req, res) {
const widget = await getWidget(req);
const { service } = req.query;
if (!widget) {
return res.status(400).json({ error: "Invalid proxy service type" });
}
const api = widgets?.[widget.type]?.api;
if (!api) {
return res.status(403).json({ error: "Service does not support API calls" });
}
let [status, contentType, data, responseHeaders] = [];
let prefix = cache.get(`${prefixCacheKey}.${service}`);
let csrfToken;
async function resolveRequestContext({ cachedPrefix, widget }) {
const headers = {};
if (widget.key) {
prefix = udmpPrefix;
headers["X-API-KEY"] = widget.key;
headers["Accept"] = "application/json";
} else if (prefix === null) {
// auto detect if we're talking to a UDM Pro or Network API device, and cache the result
// so that we don't make two requests each time data from Unifi is required
[status, contentType, data, responseHeaders] = await httpProxy(widget.url);
prefix = "";
if (responseHeaders?.["x-csrf-token"]) {
// Unifi OS < 3.2.5 passes & requires csrf-token
prefix = udmpPrefix;
csrfToken = responseHeaders["x-csrf-token"];
} else if (
responseHeaders?.["access-control-expose-headers"] ||
responseHeaders?.["Access-Control-Expose-Headers"]
) {
// Unifi OS ≥ 3.2.5 doesnt pass csrf token but still uses different endpoint, same with Network API
prefix = udmpPrefix;
}
}
cache.put(`${prefixCacheKey}.${service}`, prefix);
widget.prefix = prefix;
const { endpoint } = req.query;
const url = new URL(formatApiCall(api, { endpoint, ...widget }));
const params = { method: "GET", headers };
setCookieHeader(url, params);
[status, contentType, data, responseHeaders] = await httpProxy(url, params);
if (status === 401 && !widget.key) {
logger.debug("Unifi isn't logged in or rejected the reqeust, attempting login.");
if (responseHeaders?.["x-csrf-token"]) {
csrfToken = responseHeaders["x-csrf-token"];
}
[status, contentType, data, responseHeaders] = await login(widget, csrfToken);
if (status !== 200) {
logger.error("HTTP %d logging in to Unifi. Data: %s", status, data);
return res.status(status).json({ error: { message: `HTTP Error ${status}`, url, data } });
}
const json = JSON.parse(data.toString());
if (!(json?.meta?.rc === "ok" || json?.login_time || json?.update_time)) {
logger.error("Error logging in to Unifi: Data: %s", data);
return res.status(401).end(data);
}
addCookieToJar(url, responseHeaders);
setCookieHeader(url, params);
logger.debug("Retrying Unifi request after login.");
[status, contentType, data, responseHeaders] = await httpProxy(url, params);
headers.Accept = "application/json";
return { headers, prefix: udmpPrefix };
}
if (status !== 200) {
logger.error("HTTP %d getting data from Unifi endpoint %s. Data: %s", status, url.href, data);
return res.status(status).json({ error: { message: `HTTP Error ${status}`, url, data } });
if (cachedPrefix !== null) {
return { headers, prefix: cachedPrefix };
}
if (contentType) res.setHeader("Content-Type", contentType);
return res.status(status).send(data);
const [, , , responseHeaders] = await httpProxy(widget.url);
let prefix = "";
let csrfToken;
if (responseHeaders?.["x-csrf-token"]) {
prefix = udmpPrefix;
csrfToken = responseHeaders["x-csrf-token"];
} else if (responseHeaders?.["access-control-expose-headers"] || responseHeaders?.["Access-Control-Expose-Headers"]) {
prefix = udmpPrefix;
}
return { csrfToken, headers, prefix };
}
export default createUnifiProxyHandler({
proxyName: "unifiProxyHandler",
resolveWidget: getWidget,
resolveRequestContext,
getLoginEndpoint: ({ prefix }) => (prefix === udmpPrefix ? "auth/login" : "login"),
});

View File

@@ -89,4 +89,58 @@ describe("widgets/unifi/proxy", () => {
expect(res.statusCode).toBe(200);
expect(res.body).toEqual(Buffer.from("data"));
});
it("uses unifi_console private widget config for the info widget path", async () => {
getPrivateWidgetOptions.mockResolvedValue({
url: "http://console",
username: "u",
password: "p",
});
httpProxy
.mockResolvedValueOnce([200, "text/html", Buffer.from(""), { "x-csrf-token": "csrf" }])
.mockResolvedValueOnce([200, "application/json", Buffer.from("data"), {}]);
const req = {
query: {
group: "unifi_console",
service: "unifi_console",
endpoint: "self",
query: JSON.stringify({ index: 2 }),
},
};
const res = createMockRes();
await unifiProxyHandler(req, res);
expect(getPrivateWidgetOptions).toHaveBeenCalledWith("unifi_console", 2);
expect(httpProxy.mock.calls[1][0].toString()).toContain("/proxy/network/api/self");
expect(res.statusCode).toBe(200);
});
it("uses the API key flow without attempting login", async () => {
getServiceWidget.mockResolvedValue({
type: "unifi",
key: "secret",
url: "http://unifi",
});
httpProxy.mockResolvedValueOnce([200, "application/json", Buffer.from("data"), {}]);
const req = { query: { group: "g", service: "svc", endpoint: "self", index: "0" } };
const res = createMockRes();
await unifiProxyHandler(req, res);
expect(httpProxy).toHaveBeenCalledTimes(1);
expect(httpProxy.mock.calls[0][0].toString()).toContain("/proxy/network/api/self");
expect(httpProxy.mock.calls[0][1]).toMatchObject({
headers: {
Accept: "application/json",
"X-API-KEY": "secret",
},
method: "GET",
});
expect(res.statusCode).toBe(200);
});
});