From fbdf5f777c54f86e57ed66a7234ab0f2ea1d96b7 Mon Sep 17 00:00:00 2001 From: NightWatcher Date: Thu, 21 May 2026 02:35:44 +0800 Subject: [PATCH 1/2] Add multi-agent manager support --- backend/internal/agent-client.js | 141 ++++++++++++++++++ backend/internal/agent.js | 107 +++++++++++++ backend/lib/express/agent-forward.js | 18 +++ .../migrations/20260520160000_agent_table.js | 33 ++++ backend/models/agent.js | 49 ++++++ backend/routes/agents.js | 98 ++++++++++++ backend/routes/main.js | 2 + backend/routes/nginx/access_lists.js | 3 + backend/routes/nginx/certificates.js | 9 ++ backend/routes/nginx/dead_hosts.js | 5 + backend/routes/nginx/proxy_hosts.js | 5 + backend/routes/nginx/redirection_hosts.js | 5 + backend/routes/nginx/streams.js | 9 +- frontend/src/Router.tsx | 2 + frontend/src/api/backend/createAgent.ts | 6 + frontend/src/api/backend/createProxyHost.ts | 6 +- frontend/src/api/backend/deleteAgent.ts | 5 + frontend/src/api/backend/deleteProxyHost.ts | 3 +- frontend/src/api/backend/getAgents.ts | 6 + frontend/src/api/backend/index.ts | 6 + frontend/src/api/backend/models.ts | 12 ++ frontend/src/api/backend/testAgent.ts | 5 + frontend/src/api/backend/toggleProxyHost.ts | 3 +- frontend/src/api/backend/updateAgent.ts | 7 + frontend/src/api/backend/updateProxyHost.ts | 5 +- frontend/src/components/Form/AccessField.tsx | 5 +- .../components/Form/SSLCertificateField.tsx | 4 +- frontend/src/components/SiteMenu.tsx | 7 + frontend/src/hooks/index.ts | 2 + frontend/src/hooks/useAccessLists.ts | 12 +- frontend/src/hooks/useAgents.ts | 15 ++ frontend/src/hooks/useCertificates.ts | 12 +- frontend/src/hooks/useProxyHost.ts | 27 ++-- frontend/src/hooks/useProxyHosts.ts | 10 +- frontend/src/locale/src/bg.json | 3 + frontend/src/locale/src/cs.json | 3 + frontend/src/locale/src/de.json | 3 + frontend/src/locale/src/en.json | 3 + frontend/src/locale/src/es.json | 3 + frontend/src/locale/src/et.json | 3 + frontend/src/locale/src/fr.json | 3 + frontend/src/locale/src/ga.json | 3 + frontend/src/locale/src/hu.json | 3 + frontend/src/locale/src/id.json | 3 + frontend/src/locale/src/it.json | 3 + frontend/src/locale/src/ja.json | 3 + frontend/src/locale/src/ko.json | 3 + frontend/src/locale/src/nl.json | 3 + frontend/src/locale/src/no.json | 3 + frontend/src/locale/src/pl.json | 3 + frontend/src/locale/src/pt.json | 3 + frontend/src/locale/src/ru.json | 3 + frontend/src/locale/src/sk.json | 3 + frontend/src/locale/src/tr.json | 3 + frontend/src/locale/src/vi.json | 3 + frontend/src/locale/src/zh.json | 3 + frontend/src/modals/ProxyHostModal.tsx | 14 +- frontend/src/pages/Agents/index.tsx | 100 +++++++++++++ .../pages/Nginx/ProxyHosts/TableWrapper.tsx | 31 +++- 59 files changed, 805 insertions(+), 50 deletions(-) create mode 100644 backend/internal/agent-client.js create mode 100644 backend/internal/agent.js create mode 100644 backend/lib/express/agent-forward.js create mode 100644 backend/migrations/20260520160000_agent_table.js create mode 100644 backend/models/agent.js create mode 100644 backend/routes/agents.js create mode 100644 frontend/src/api/backend/createAgent.ts create mode 100644 frontend/src/api/backend/deleteAgent.ts create mode 100644 frontend/src/api/backend/getAgents.ts create mode 100644 frontend/src/api/backend/testAgent.ts create mode 100644 frontend/src/api/backend/updateAgent.ts create mode 100644 frontend/src/hooks/useAgents.ts create mode 100644 frontend/src/pages/Agents/index.tsx diff --git a/backend/internal/agent-client.js b/backend/internal/agent-client.js new file mode 100644 index 0000000000..48101f5e61 --- /dev/null +++ b/backend/internal/agent-client.js @@ -0,0 +1,141 @@ +import errs from "../lib/error.js"; +import agentModel from "../models/agent.js"; + +const tokenCache = new Map(); + +function publicAgent(agent) { + return `${agent.name || agent.id} (${agent.url})`; +} + +function trimBaseUrl(url) { + return String(url || "").replace(/\/$/, ""); +} + +async function parsePayload(response) { + const contentType = response.headers.get("content-type") || ""; + if (contentType.includes("application/json")) { + return await response.json(); + } + return await response.text(); +} + +async function request(agent, path, options = {}) { + const url = `${trimBaseUrl(agent.url)}${path}`; + const response = await fetch(url, options); + const payload = await parsePayload(response); + if (!response.ok) { + const message = payload?.error?.message || payload?.message || payload || `HTTP ${response.status}`; + const err = new errs.ValidationError(`Agent ${publicAgent(agent)} request failed: ${message}`); + err.status = response.status; + throw err; + } + return { response, payload }; +} + +async function getToken(agent, force = false) { + const cached = tokenCache.get(agent.id || agent.url); + if (!force && cached?.token && new Date(cached.expires).getTime() > Date.now() + 60000) { + return cached.token; + } + + const { payload } = await request(agent, "/api/tokens", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + identity: agent.identity, + secret: agent.secret, + expiry: "1d", + }), + }); + + if (payload?.requires_2fa) { + throw new errs.ValidationError(`Agent ${publicAgent(agent)} requires 2FA; use a non-2FA service account`); + } + if (!payload?.token) { + throw new errs.ValidationError(`Agent ${publicAgent(agent)} did not return a token`); + } + tokenCache.set(agent.id || agent.url, payload); + return payload.token; +} + +function buildForwardPath(req) { + const query = new URLSearchParams(); + for (const [key, value] of Object.entries(req.query || {})) { + if (["agent_id", "agent", "node"].includes(key)) { + continue; + } + if (Array.isArray(value)) { + value.forEach((item) => { + query.append(key, item); + }); + } else if (typeof value !== "undefined" && value !== null) { + query.append(key, value); + } + } + const qs = query.toString(); + return `/api${req.baseUrl}${req.path}${qs ? `?${qs}` : ""}`; +} + +const internalAgentClient = { + findRequestedAgentId: (req) => req.query.agent_id || req.query.agent || req.query.node, + + shouldForward: (req) => { + const agentId = internalAgentClient.findRequestedAgentId(req); + return agentId && agentId !== "local" && agentId !== "0"; + }, + + getAgent: async (id) => { + const agent = await agentModel.query().where("id", Number.parseInt(id, 10)).andWhere("is_deleted", 0).first(); + if (!agent?.id || !agent.enabled) { + throw new errs.ItemNotFoundError(`agent ${id}`); + } + return agent; + }, + + health: async (agent) => { + const { payload } = await request(agent, "/api", { method: "GET" }); + await getToken(agent, true); + return { + ok: true, + version: payload.version, + setup: payload.setup, + checked_on: new Date().toISOString(), + }; + }, + + forward: async (req, res) => { + const agent = await internalAgentClient.getAgent(internalAgentClient.findRequestedAgentId(req)); + let token = await getToken(agent); + const headers = { + Authorization: `Bearer ${token}`, + }; + let body; + if (!["GET", "HEAD"].includes(req.method)) { + headers["Content-Type"] = "application/json"; + body = JSON.stringify(req.body || {}); + } + const path = buildForwardPath(req); + let response = await fetch(`${trimBaseUrl(agent.url)}${path}`, { + method: req.method, + headers, + body, + }); + if (response.status === 401) { + token = await getToken(agent, true); + response = await fetch(`${trimBaseUrl(agent.url)}${path}`, { + method: req.method, + headers: { ...headers, Authorization: `Bearer ${token}` }, + body, + }); + } + const payload = await parsePayload(response); + res.status(response.status); + if (typeof payload === "string") { + res.send(payload); + } else { + res.send(payload); + } + }, +}; + +export default internalAgentClient; diff --git a/backend/internal/agent.js b/backend/internal/agent.js new file mode 100644 index 0000000000..ee17147698 --- /dev/null +++ b/backend/internal/agent.js @@ -0,0 +1,107 @@ +import errs from "../lib/error.js"; +import utils from "../lib/utils.js"; +import agentModel from "../models/agent.js"; +import internalAgentClient from "./agent-client.js"; + +const omissions = () => ["is_deleted", "secret"]; + +function normalizeUrl(url) { + try { + const parsed = new URL(url); + parsed.pathname = parsed.pathname.replace(/\/$/, ""); + parsed.search = ""; + parsed.hash = ""; + return parsed.toString().replace(/\/$/, ""); + } catch { + throw new errs.ValidationError("Invalid agent URL"); + } +} + +const internalAgent = { + getAll: async (access) => { + await access.can("users:list"); + return agentModel + .query() + .where("is_deleted", 0) + .orderBy("name", "ASC") + .then(utils.omitRows(omissions())); + }, + + get: async (access, data) => { + await access.can("users:list"); + const row = await agentModel.query().where("id", data.id).andWhere("is_deleted", 0).first(); + if (!row?.id) { + throw new errs.ItemNotFoundError(data.id); + } + return utils.omitRow(omissions())(row); + }, + + create: async (access, data) => { + await access.can("users:list"); + const row = await agentModel.query().insertAndFetch({ + name: data.name, + url: normalizeUrl(data.url), + identity: data.identity, + secret: data.secret, + enabled: typeof data.enabled === "undefined" ? true : data.enabled, + meta: {}, + }); + return utils.omitRow(omissions())(row); + }, + + update: async (access, data) => { + await access.can("users:list"); + const existing = await agentModel.query().where("id", data.id).andWhere("is_deleted", 0).first(); + if (!existing?.id) { + throw new errs.ItemNotFoundError(data.id); + } + const patch = {}; + ["name", "identity", "enabled"].forEach((key) => { + if (typeof data[key] !== "undefined") { + patch[key] = data[key]; + } + }); + if (typeof data.url !== "undefined") { + patch.url = normalizeUrl(data.url); + } + if (typeof data.secret === "string" && data.secret.length) { + patch.secret = data.secret; + } + await agentModel.query().where("id", data.id).patch(patch); + return internalAgent.get(access, { id: data.id }); + }, + + delete: async (access, data) => { + await access.can("users:list"); + const existing = await agentModel.query().where("id", data.id).andWhere("is_deleted", 0).first(); + if (!existing?.id) { + throw new errs.ItemNotFoundError(data.id); + } + await agentModel.query().where("id", data.id).patch({ is_deleted: 1 }); + return true; + }, + + test: async (access, data) => { + await access.can("users:list"); + let agent; + if (data.id) { + agent = await agentModel.query().where("id", data.id).andWhere("is_deleted", 0).first(); + } else { + agent = { + url: normalizeUrl(data.url), + identity: data.identity, + secret: data.secret, + }; + } + if (!agent) { + throw new errs.ItemNotFoundError(data.id); + } + const result = await internalAgentClient.health(agent); + if (data.id) { + await agentModel.query().where("id", data.id).patch({ meta: { last_test: result } }); + } + return result; + }, +}; + +export default internalAgent; diff --git a/backend/lib/express/agent-forward.js b/backend/lib/express/agent-forward.js new file mode 100644 index 0000000000..85ad4fbbd5 --- /dev/null +++ b/backend/lib/express/agent-forward.js @@ -0,0 +1,18 @@ +import internalAgentClient from "../../internal/agent-client.js"; +import { debug, express as logger } from "../../logger.js"; + +export default function () { + return async (req, res, next) => { + if (!internalAgentClient.shouldForward(req)) { + next(); + return; + } + try { + await res.locals.access.can("users:list"); + await internalAgentClient.forward(req, res); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.originalUrl}: ${err}`); + next(err); + } + }; +} diff --git a/backend/migrations/20260520160000_agent_table.js b/backend/migrations/20260520160000_agent_table.js new file mode 100644 index 0000000000..7383315478 --- /dev/null +++ b/backend/migrations/20260520160000_agent_table.js @@ -0,0 +1,33 @@ +import { migrate as logger } from "../logger.js"; + +const migrateName = "agent-table"; + +const up = (knex) => { + logger.info(`[${migrateName}] Migrating Up...`); + return knex.schema.hasTable("agent").then((exists) => { + if (exists) { + logger.info(`[${migrateName}] agent Table already exists`); + return; + } + return knex.schema.createTable("agent", (table) => { + table.increments().primary(); + table.dateTime("created_on").notNull(); + table.dateTime("modified_on").notNull(); + table.integer("is_deleted").notNull().unsigned().defaultTo(0); + table.integer("enabled").notNull().unsigned().defaultTo(1); + table.string("name").notNull(); + table.string("url").notNull(); + table.string("identity").notNull(); + table.text("secret").notNull(); + table.json("meta").notNull(); + table.unique("url"); + }); + }); +}; + +const down = (knex) => { + logger.warn(`[${migrateName}] Migrating Down...`); + return knex.schema.dropTableIfExists("agent"); +}; + +export { up, down }; diff --git a/backend/models/agent.js b/backend/models/agent.js new file mode 100644 index 0000000000..86eb239ff2 --- /dev/null +++ b/backend/models/agent.js @@ -0,0 +1,49 @@ +import { Model } from "objection"; +import db from "../db.js"; +import { convertBoolFieldsToInt, convertIntFieldsToBool } from "../lib/helpers.js"; +import now from "./now_helper.js"; + +Model.knex(db()); + +const boolFields = ["is_deleted", "enabled"]; + +class Agent extends Model { + $beforeInsert() { + this.created_on = now(); + this.modified_on = now(); + if (typeof this.enabled === "undefined") { + this.enabled = true; + } + if (typeof this.meta === "undefined") { + this.meta = {}; + } + } + + $beforeUpdate() { + this.modified_on = now(); + } + + $parseDatabaseJson(json) { + const thisJson = super.$parseDatabaseJson(json); + return convertIntFieldsToBool(thisJson, boolFields); + } + + $formatDatabaseJson(json) { + const thisJson = convertBoolFieldsToInt(json, boolFields); + return super.$formatDatabaseJson(thisJson); + } + + static get name() { + return "Agent"; + } + + static get tableName() { + return "agent"; + } + + static get jsonAttributes() { + return ["meta"]; + } +} + +export default Agent; diff --git a/backend/routes/agents.js b/backend/routes/agents.js new file mode 100644 index 0000000000..819ebb4888 --- /dev/null +++ b/backend/routes/agents.js @@ -0,0 +1,98 @@ +import express from "express"; +import internalAgent from "../internal/agent.js"; +import jwtdecode from "../lib/express/jwt-decode.js"; +import { debug, express as logger } from "../logger.js"; + +const router = express.Router({ + caseSensitive: true, + strict: true, + mergeParams: true, +}); + +router + .route("/") + .options((_, res) => res.sendStatus(204)) + .all(jwtdecode()) + .get(async (req, res, next) => { + try { + const rows = await internalAgent.getAll(res.locals.access); + res.status(200).send(rows); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }) + .post(async (req, res, next) => { + try { + const result = await internalAgent.create(res.locals.access, req.body || {}); + res.status(201).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +router + .route("/test") + .options((_, res) => res.sendStatus(204)) + .all(jwtdecode()) + .post(async (req, res, next) => { + try { + const result = await internalAgent.test(res.locals.access, req.body || {}); + res.status(200).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +router + .route("/:agent_id") + .options((_, res) => res.sendStatus(204)) + .all(jwtdecode()) + .get(async (req, res, next) => { + try { + const row = await internalAgent.get(res.locals.access, { id: Number.parseInt(req.params.agent_id, 10) }); + res.status(200).send(row); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }) + .put(async (req, res, next) => { + try { + const result = await internalAgent.update(res.locals.access, { + ...(req.body || {}), + id: Number.parseInt(req.params.agent_id, 10), + }); + res.status(200).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }) + .delete(async (req, res, next) => { + try { + const result = await internalAgent.delete(res.locals.access, { id: Number.parseInt(req.params.agent_id, 10) }); + res.status(200).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +router + .route("/:agent_id/test") + .options((_, res) => res.sendStatus(204)) + .all(jwtdecode()) + .post(async (req, res, next) => { + try { + const result = await internalAgent.test(res.locals.access, { id: Number.parseInt(req.params.agent_id, 10) }); + res.status(200).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +export default router; diff --git a/backend/routes/main.js b/backend/routes/main.js index 94682cfba4..f0a769a001 100644 --- a/backend/routes/main.js +++ b/backend/routes/main.js @@ -2,6 +2,7 @@ import express from "express"; import errs from "../lib/error.js"; import pjson from "../package.json" with { type: "json" }; import { isSetup } from "../setup.js"; +import agentsRoutes from "./agents.js"; import auditLogRoutes from "./audit-log.js"; import accessListsRoutes from "./nginx/access_lists.js"; import certificatesHostsRoutes from "./nginx/certificates.js"; @@ -44,6 +45,7 @@ router.get("/", async (_, res /*, next*/) => { router.use("/schema", schemaRoutes); router.use("/tokens", tokensRoutes); router.use("/users", usersRoutes); +router.use("/agents", agentsRoutes); router.use("/audit-log", auditLogRoutes); router.use("/reports", reportsRoutes); router.use("/settings", settingsRoutes); diff --git a/backend/routes/nginx/access_lists.js b/backend/routes/nginx/access_lists.js index 9dfcf7ec31..0a2228e1b2 100644 --- a/backend/routes/nginx/access_lists.js +++ b/backend/routes/nginx/access_lists.js @@ -1,5 +1,6 @@ import express from "express"; import internalAccessList from "../../internal/access-list.js"; +import agentForward from "../../lib/express/agent-forward.js"; import jwtdecode from "../../lib/express/jwt-decode.js"; import apiValidator from "../../lib/validator/api.js"; import validator from "../../lib/validator/index.js"; @@ -21,6 +22,7 @@ router res.sendStatus(204); }) .all(jwtdecode()) + .all(agentForward()) /** * GET /api/nginx/access-lists @@ -81,6 +83,7 @@ router res.sendStatus(204); }) .all(jwtdecode()) + .all(agentForward()) /** * GET /api/nginx/access-lists/123 diff --git a/backend/routes/nginx/certificates.js b/backend/routes/nginx/certificates.js index 99f429b446..a3e503b1a6 100644 --- a/backend/routes/nginx/certificates.js +++ b/backend/routes/nginx/certificates.js @@ -2,6 +2,7 @@ import express from "express"; import dnsPlugins from "../../certbot/dns-plugins.json" with { type: "json" }; import internalCertificate from "../../internal/certificate.js"; import errs from "../../lib/error.js"; +import agentForward from "../../lib/express/agent-forward.js"; import jwtdecode from "../../lib/express/jwt-decode.js"; import apiValidator from "../../lib/validator/api.js"; import validator from "../../lib/validator/index.js"; @@ -23,6 +24,7 @@ router res.sendStatus(204); }) .all(jwtdecode()) + .all(agentForward()) /** * GET /api/nginx/certificates @@ -95,6 +97,7 @@ router res.sendStatus(204); }) .all(jwtdecode()) + .all(agentForward()) /** * GET /api/nginx/certificates/dns-providers @@ -131,6 +134,7 @@ router res.sendStatus(204); }) .all(jwtdecode()) + .all(agentForward()) /** * POST /api/nginx/certificates/test-http @@ -167,6 +171,7 @@ router res.sendStatus(204); }) .all(jwtdecode()) + .all(agentForward()) /** * POST /api/nginx/certificates/validate @@ -201,6 +206,7 @@ router res.sendStatus(204); }) .all(jwtdecode()) + .all(agentForward()) /** * GET /api/nginx/certificates/123 @@ -269,6 +275,7 @@ router res.sendStatus(204); }) .all(jwtdecode()) + .all(agentForward()) /** * POST /api/nginx/certificates/123/upload @@ -304,6 +311,7 @@ router res.sendStatus(204); }) .all(jwtdecode()) + .all(agentForward()) /** * POST /api/nginx/certificates/123/renew @@ -334,6 +342,7 @@ router res.sendStatus(204); }) .all(jwtdecode()) + .all(agentForward()) /** * GET /api/nginx/certificates/123/download diff --git a/backend/routes/nginx/dead_hosts.js b/backend/routes/nginx/dead_hosts.js index 31f7043635..c631996e0c 100644 --- a/backend/routes/nginx/dead_hosts.js +++ b/backend/routes/nginx/dead_hosts.js @@ -1,5 +1,6 @@ import express from "express"; import internalDeadHost from "../../internal/dead-host.js"; +import agentForward from "../../lib/express/agent-forward.js"; import jwtdecode from "../../lib/express/jwt-decode.js"; import apiValidator from "../../lib/validator/api.js"; import validator from "../../lib/validator/index.js"; @@ -21,6 +22,7 @@ router res.sendStatus(204); }) .all(jwtdecode()) + .all(agentForward()) /** * GET /api/nginx/dead-hosts @@ -81,6 +83,7 @@ router res.sendStatus(204); }) .all(jwtdecode()) + .all(agentForward()) /** * GET /api/nginx/dead-hosts/123 @@ -163,6 +166,7 @@ router res.sendStatus(204); }) .all(jwtdecode()) + .all(agentForward()) /** * POST /api/nginx/dead-hosts/123/enable @@ -190,6 +194,7 @@ router res.sendStatus(204); }) .all(jwtdecode()) + .all(agentForward()) /** * POST /api/nginx/dead-hosts/123/disable diff --git a/backend/routes/nginx/proxy_hosts.js b/backend/routes/nginx/proxy_hosts.js index 7045a195cc..a173b0dfb3 100644 --- a/backend/routes/nginx/proxy_hosts.js +++ b/backend/routes/nginx/proxy_hosts.js @@ -1,5 +1,6 @@ import express from "express"; import internalProxyHost from "../../internal/proxy-host.js"; +import agentForward from "../../lib/express/agent-forward.js"; import jwtdecode from "../../lib/express/jwt-decode.js"; import apiValidator from "../../lib/validator/api.js"; import validator from "../../lib/validator/index.js"; @@ -21,6 +22,7 @@ router res.sendStatus(204); }) .all(jwtdecode()) + .all(agentForward()) /** * GET /api/nginx/proxy-hosts @@ -81,6 +83,7 @@ router res.sendStatus(204); }) .all(jwtdecode()) + .all(agentForward()) /** * GET /api/nginx/proxy-hosts/123 @@ -163,6 +166,7 @@ router res.sendStatus(204); }) .all(jwtdecode()) + .all(agentForward()) /** * POST /api/nginx/proxy-hosts/123/enable @@ -190,6 +194,7 @@ router res.sendStatus(204); }) .all(jwtdecode()) + .all(agentForward()) /** * POST /api/nginx/proxy-hosts/123/disable diff --git a/backend/routes/nginx/redirection_hosts.js b/backend/routes/nginx/redirection_hosts.js index 9b5b5b374d..b06680c356 100644 --- a/backend/routes/nginx/redirection_hosts.js +++ b/backend/routes/nginx/redirection_hosts.js @@ -1,5 +1,6 @@ import express from "express"; import internalRedirectionHost from "../../internal/redirection-host.js"; +import agentForward from "../../lib/express/agent-forward.js"; import jwtdecode from "../../lib/express/jwt-decode.js"; import apiValidator from "../../lib/validator/api.js"; import validator from "../../lib/validator/index.js"; @@ -21,6 +22,7 @@ router res.sendStatus(204); }) .all(jwtdecode()) + .all(agentForward()) /** * GET /api/nginx/redirection-hosts @@ -81,6 +83,7 @@ router res.sendStatus(204); }) .all(jwtdecode()) + .all(agentForward()) /** * GET /api/nginx/redirection-hosts/123 @@ -166,6 +169,7 @@ router res.sendStatus(204); }) .all(jwtdecode()) + .all(agentForward()) /** * POST /api/nginx/redirection-hosts/123/enable @@ -193,6 +197,7 @@ router res.sendStatus(204); }) .all(jwtdecode()) + .all(agentForward()) /** * POST /api/nginx/redirection-hosts/123/disable diff --git a/backend/routes/nginx/streams.js b/backend/routes/nginx/streams.js index dec2e1a13f..b1cb660470 100644 --- a/backend/routes/nginx/streams.js +++ b/backend/routes/nginx/streams.js @@ -1,5 +1,6 @@ import express from "express"; import internalStream from "../../internal/stream.js"; +import agentForward from "../../lib/express/agent-forward.js"; import jwtdecode from "../../lib/express/jwt-decode.js"; import apiValidator from "../../lib/validator/api.js"; import validator from "../../lib/validator/index.js"; @@ -20,7 +21,8 @@ router .options((_, res) => { res.sendStatus(204); }) - .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes + .all(jwtdecode()) + .all(agentForward()) // preferred so it doesn't apply to nonexistent routes /** * GET /api/nginx/streams @@ -80,7 +82,8 @@ router .options((_, res) => { res.sendStatus(204); }) - .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes + .all(jwtdecode()) + .all(agentForward()) // preferred so it doesn't apply to nonexistent routes /** * GET /api/nginx/streams/123 @@ -163,6 +166,7 @@ router res.sendStatus(204); }) .all(jwtdecode()) + .all(agentForward()) /** * POST /api/nginx/streams/123/enable @@ -190,6 +194,7 @@ router res.sendStatus(204); }) .all(jwtdecode()) + .all(agentForward()) /** * POST /api/nginx/streams/123/disable diff --git a/frontend/src/Router.tsx b/frontend/src/Router.tsx index 6aa8f0894f..1eac82402b 100644 --- a/frontend/src/Router.tsx +++ b/frontend/src/Router.tsx @@ -17,6 +17,7 @@ const Setup = lazy(() => import("src/pages/Setup")); const Login = lazy(() => import("src/pages/Login")); const Dashboard = lazy(() => import("src/pages/Dashboard")); const Settings = lazy(() => import("src/pages/Settings")); +const Agents = lazy(() => import("src/pages/Agents")); const Certificates = lazy(() => import("src/pages/Certificates")); const Access = lazy(() => import("src/pages/Access")); const AuditLog = lazy(() => import("src/pages/AuditLog")); @@ -65,6 +66,7 @@ function Router() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/backend/createAgent.ts b/frontend/src/api/backend/createAgent.ts new file mode 100644 index 0000000000..6a03170ce7 --- /dev/null +++ b/frontend/src/api/backend/createAgent.ts @@ -0,0 +1,6 @@ +import * as api from "./base"; +import type { Agent } from "./models"; + +export async function createAgent(item: Partial & { secret: string }): Promise { + return await api.post({ url: "/agents", data: item }); +} diff --git a/frontend/src/api/backend/createProxyHost.ts b/frontend/src/api/backend/createProxyHost.ts index fcde7cd602..6e880c05bf 100644 --- a/frontend/src/api/backend/createProxyHost.ts +++ b/frontend/src/api/backend/createProxyHost.ts @@ -1,9 +1,11 @@ import * as api from "./base"; import type { ProxyHost } from "./models"; -export async function createProxyHost(item: ProxyHost): Promise { +export async function createProxyHost(item: ProxyHost & { agentId?: string }): Promise { + const { agentId, ...data } = item; return await api.post({ url: "/nginx/proxy-hosts", - data: item, + params: agentId && agentId !== "local" ? { agent_id: agentId } : undefined, + data, }); } diff --git a/frontend/src/api/backend/deleteAgent.ts b/frontend/src/api/backend/deleteAgent.ts new file mode 100644 index 0000000000..cca761a3ad --- /dev/null +++ b/frontend/src/api/backend/deleteAgent.ts @@ -0,0 +1,5 @@ +import * as api from "./base"; + +export async function deleteAgent(id: number): Promise { + return await api.del({ url: `/agents/${id}` }); +} diff --git a/frontend/src/api/backend/deleteProxyHost.ts b/frontend/src/api/backend/deleteProxyHost.ts index 7b7f2d8277..5ae6167123 100644 --- a/frontend/src/api/backend/deleteProxyHost.ts +++ b/frontend/src/api/backend/deleteProxyHost.ts @@ -1,7 +1,8 @@ import * as api from "./base"; -export async function deleteProxyHost(id: number): Promise { +export async function deleteProxyHost(id: number, agentId?: string): Promise { return await api.del({ url: `/nginx/proxy-hosts/${id}`, + params: agentId && agentId !== "local" ? { agent_id: agentId } : undefined, }); } diff --git a/frontend/src/api/backend/getAgents.ts b/frontend/src/api/backend/getAgents.ts new file mode 100644 index 0000000000..92faa69ee0 --- /dev/null +++ b/frontend/src/api/backend/getAgents.ts @@ -0,0 +1,6 @@ +import * as api from "./base"; +import type { Agent } from "./models"; + +export async function getAgents(): Promise { + return await api.get({ url: "/agents" }); +} diff --git a/frontend/src/api/backend/index.ts b/frontend/src/api/backend/index.ts index 40cb4142fc..4261667971 100644 --- a/frontend/src/api/backend/index.ts +++ b/frontend/src/api/backend/index.ts @@ -61,3 +61,9 @@ export * from "./updateUser"; export * from "./uploadCertificate"; export * from "./validateCertificate"; export * from "./twoFactor"; + +export * from "./createAgent"; +export * from "./deleteAgent"; +export * from "./getAgents"; +export * from "./testAgent"; +export * from "./updateAgent"; diff --git a/frontend/src/api/backend/models.ts b/frontend/src/api/backend/models.ts index 2ae0b08348..7968db4bb2 100644 --- a/frontend/src/api/backend/models.ts +++ b/frontend/src/api/backend/models.ts @@ -1,3 +1,15 @@ + +export interface Agent { + id: number; + createdOn: string; + modifiedOn: string; + enabled: boolean; + name: string; + url: string; + identity: string; + meta?: Record; +} + export interface AppVersion { major: number; minor: number; diff --git a/frontend/src/api/backend/testAgent.ts b/frontend/src/api/backend/testAgent.ts new file mode 100644 index 0000000000..a2850dfc6f --- /dev/null +++ b/frontend/src/api/backend/testAgent.ts @@ -0,0 +1,5 @@ +import * as api from "./base"; + +export async function testAgent(id: number): Promise<{ ok: boolean }> { + return await api.post({ url: `/agents/${id}/test` }); +} diff --git a/frontend/src/api/backend/toggleProxyHost.ts b/frontend/src/api/backend/toggleProxyHost.ts index 376e788186..e21fab5324 100644 --- a/frontend/src/api/backend/toggleProxyHost.ts +++ b/frontend/src/api/backend/toggleProxyHost.ts @@ -1,7 +1,8 @@ import * as api from "./base"; -export async function toggleProxyHost(id: number, enabled: boolean): Promise { +export async function toggleProxyHost(id: number, enabled: boolean, agentId?: string): Promise { return await api.post({ url: `/nginx/proxy-hosts/${id}/${enabled ? "enable" : "disable"}`, + params: agentId && agentId !== "local" ? { agent_id: agentId } : undefined, }); } diff --git a/frontend/src/api/backend/updateAgent.ts b/frontend/src/api/backend/updateAgent.ts new file mode 100644 index 0000000000..6977c6643f --- /dev/null +++ b/frontend/src/api/backend/updateAgent.ts @@ -0,0 +1,7 @@ +import * as api from "./base"; +import type { Agent } from "./models"; + +export async function updateAgent(item: Partial & { id: number; secret?: string }): Promise { + const { id, ...data } = item; + return await api.put({ url: `/agents/${id}`, data }); +} diff --git a/frontend/src/api/backend/updateProxyHost.ts b/frontend/src/api/backend/updateProxyHost.ts index e7ee3d9064..5608739691 100644 --- a/frontend/src/api/backend/updateProxyHost.ts +++ b/frontend/src/api/backend/updateProxyHost.ts @@ -1,12 +1,13 @@ import * as api from "./base"; import type { ProxyHost } from "./models"; -export async function updateProxyHost(item: ProxyHost): Promise { +export async function updateProxyHost(item: ProxyHost & { agentId?: string }): Promise { // Remove readonly fields - const { id, createdOn: _, modifiedOn: __, ...data } = item; + const { id, createdOn: _, modifiedOn: __, agentId, ...data } = item; return await api.put({ url: `/nginx/proxy-hosts/${id}`, + params: agentId && agentId !== "local" ? { agent_id: agentId } : undefined, data: data, }); } diff --git a/frontend/src/components/Form/AccessField.tsx b/frontend/src/components/Form/AccessField.tsx index afcbd0cf7d..0beb0a20a4 100644 --- a/frontend/src/components/Form/AccessField.tsx +++ b/frontend/src/components/Form/AccessField.tsx @@ -31,10 +31,11 @@ interface Props { id?: string; name?: string; label?: string; + agentId?: string; } -export function AccessField({ name = "accessListId", label = "access-list", id = "accessListId" }: Props) { +export function AccessField({ name = "accessListId", label = "access-list", id = "accessListId", agentId }: Props) { const { locale } = useLocaleState(); - const { isLoading, isError, error, data } = useAccessLists(["owner", "items", "clients"]); + const { isLoading, isError, error, data } = useAccessLists(["owner", "items", "clients"], {}, agentId); const { setFieldValue } = useFormikContext(); const handleChange = (newValue: any, _actionMeta: ActionMeta) => { diff --git a/frontend/src/components/Form/SSLCertificateField.tsx b/frontend/src/components/Form/SSLCertificateField.tsx index 6ab3ea92c1..fc92662d38 100644 --- a/frontend/src/components/Form/SSLCertificateField.tsx +++ b/frontend/src/components/Form/SSLCertificateField.tsx @@ -33,6 +33,7 @@ interface Props { required?: boolean; allowNew?: boolean; forHttp?: boolean; // the sslForced, http2Support, hstsEnabled, hstsSubdomains fields + agentId?: string; } export function SSLCertificateField({ name = "certificateId", @@ -41,9 +42,10 @@ export function SSLCertificateField({ required, allowNew, forHttp = true, + agentId, }: Props) { const { locale } = useLocaleState(); - const { isLoading, isError, error, data } = useCertificates(); + const { isLoading, isError, error, data } = useCertificates(undefined, {}, agentId); const { values, setFieldValue } = useFormikContext(); const v: any = values || {}; diff --git a/frontend/src/components/SiteMenu.tsx b/frontend/src/components/SiteMenu.tsx index 265150bb54..c4181f8579 100644 --- a/frontend/src/components/SiteMenu.tsx +++ b/frontend/src/components/SiteMenu.tsx @@ -1,6 +1,7 @@ import { IconBook, IconDeviceDesktop, + IconNetwork, IconHome, IconLock, IconSettings, @@ -95,6 +96,12 @@ const menuItems: MenuItem[] = [ label: "auditlogs", permissionSection: ADMIN, }, + { + to: "/agents", + icon: IconNetwork, + label: "agents", + permissionSection: ADMIN, + }, { to: "/settings", icon: IconSettings, diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index 744190ade1..a5c620dce9 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -20,3 +20,5 @@ export * from "./useStreams"; export * from "./useTheme"; export * from "./useUser"; export * from "./useUsers"; + +export * from "./useAgents"; diff --git a/frontend/src/hooks/useAccessLists.ts b/frontend/src/hooks/useAccessLists.ts index cb052f68ce..f9d92b76a2 100644 --- a/frontend/src/hooks/useAccessLists.ts +++ b/frontend/src/hooks/useAccessLists.ts @@ -1,14 +1,16 @@ import { useQuery } from "@tanstack/react-query"; import { type AccessList, type AccessListExpansion, getAccessLists } from "src/api/backend"; -const fetchAccessLists = (expand?: AccessListExpansion[]) => { - return getAccessLists(expand); +const paramsForAgent = (agentId?: string) => (agentId && agentId !== "local" ? { agent_id: agentId } : {}); + +const fetchAccessLists = (expand?: AccessListExpansion[], agentId?: string) => { + return getAccessLists(expand, paramsForAgent(agentId)); }; -const useAccessLists = (expand?: AccessListExpansion[], options = {}) => { +const useAccessLists = (expand?: AccessListExpansion[], options: any = {}, agentId?: string) => { return useQuery({ - queryKey: ["access-lists", { expand }], - queryFn: () => fetchAccessLists(expand), + queryKey: ["access-lists", { expand, agentId }], + queryFn: () => fetchAccessLists(expand, agentId), staleTime: 60 * 1000, ...options, }); diff --git a/frontend/src/hooks/useAgents.ts b/frontend/src/hooks/useAgents.ts new file mode 100644 index 0000000000..5ca5cc1533 --- /dev/null +++ b/frontend/src/hooks/useAgents.ts @@ -0,0 +1,15 @@ +import { useQuery } from "@tanstack/react-query"; +import { getAgents, type Agent } from "src/api/backend"; + +const fetchAgents = () => getAgents(); + +const useAgents = (options = {}) => { + return useQuery({ + queryKey: ["agents"], + queryFn: fetchAgents, + staleTime: 60 * 1000, + ...options, + }); +}; + +export { fetchAgents, useAgents }; diff --git a/frontend/src/hooks/useCertificates.ts b/frontend/src/hooks/useCertificates.ts index 261c79d819..cd60c24598 100644 --- a/frontend/src/hooks/useCertificates.ts +++ b/frontend/src/hooks/useCertificates.ts @@ -1,14 +1,16 @@ import { useQuery } from "@tanstack/react-query"; import { type Certificate, type CertificateExpansion, getCertificates } from "src/api/backend"; -const fetchCertificates = (expand?: CertificateExpansion[]) => { - return getCertificates(expand); +const paramsForAgent = (agentId?: string) => (agentId && agentId !== "local" ? { agent_id: agentId } : {}); + +const fetchCertificates = (expand?: CertificateExpansion[], agentId?: string) => { + return getCertificates(expand, paramsForAgent(agentId)); }; -const useCertificates = (expand?: CertificateExpansion[], options = {}) => { +const useCertificates = (expand?: CertificateExpansion[], options: any = {}, agentId?: string) => { return useQuery({ - queryKey: ["certificates", { expand }], - queryFn: () => fetchCertificates(expand), + queryKey: ["certificates", { expand, agentId }], + queryFn: () => fetchCertificates(expand, agentId), staleTime: 60 * 1000, ...options, }); diff --git a/frontend/src/hooks/useProxyHost.ts b/frontend/src/hooks/useProxyHost.ts index 24e7f4fae2..4795923ff5 100644 --- a/frontend/src/hooks/useProxyHost.ts +++ b/frontend/src/hooks/useProxyHost.ts @@ -1,7 +1,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { createProxyHost, getProxyHost, type ProxyHost, updateProxyHost } from "src/api/backend"; -const fetchProxyHost = (id: number | "new") => { +const paramsForAgent = (agentId?: string) => (agentId && agentId !== "local" ? { agent_id: agentId } : {}); + +const fetchProxyHost = (id: number | "new", agentId?: string) => { if (id === "new") { return Promise.resolve({ id: 0, @@ -27,32 +29,35 @@ const fetchProxyHost = (id: number | "new") => { trustForwardedProto: false, } as ProxyHost); } - return getProxyHost(id, ["owner"]); + return getProxyHost(id, ["owner"], paramsForAgent(agentId)); }; -const useProxyHost = (id: number | "new", options = {}) => { +const useProxyHost = (id: number | "new", options: any = {}, agentId?: string) => { return useQuery({ - queryKey: ["proxy-host", id], - queryFn: () => fetchProxyHost(id), - staleTime: 60 * 1000, // 1 minute + queryKey: ["proxy-host", id, { agentId }], + queryFn: () => fetchProxyHost(id, agentId), + staleTime: 60 * 1000, ...options, }); }; -const useSetProxyHost = () => { +const useSetProxyHost = (agentId?: string) => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (values: ProxyHost) => (values.id ? updateProxyHost(values) : createProxyHost(values)), + mutationFn: (values: ProxyHost) => { + const payload = { ...values, agentId } as ProxyHost & { agentId?: string }; + return values.id ? updateProxyHost(payload) : createProxyHost(payload); + }, onMutate: (values: ProxyHost) => { if (!values.id) { return; } - const previousObject = queryClient.getQueryData(["proxy-host", values.id]); - queryClient.setQueryData(["proxy-host", values.id], (old: ProxyHost) => ({ + const previousObject = queryClient.getQueryData(["proxy-host", values.id, { agentId }]); + queryClient.setQueryData(["proxy-host", values.id, { agentId }], (old: ProxyHost) => ({ ...old, ...values, })); - return () => queryClient.setQueryData(["proxy-host", values.id], previousObject); + return () => queryClient.setQueryData(["proxy-host", values.id, { agentId }], previousObject); }, onError: (_, __, rollback: any) => rollback(), onSuccess: async ({ id }: ProxyHost) => { diff --git a/frontend/src/hooks/useProxyHosts.ts b/frontend/src/hooks/useProxyHosts.ts index 86366fef7d..11f45020e1 100644 --- a/frontend/src/hooks/useProxyHosts.ts +++ b/frontend/src/hooks/useProxyHosts.ts @@ -1,14 +1,14 @@ import { useQuery } from "@tanstack/react-query"; import { getProxyHosts, type ProxyHost, type ProxyHostExpansion } from "src/api/backend"; -const fetchProxyHosts = (expand?: ProxyHostExpansion[]) => { - return getProxyHosts(expand); +const fetchProxyHosts = (expand?: ProxyHostExpansion[], agentId?: string) => { + return getProxyHosts(expand, agentId && agentId !== "local" ? { agent_id: agentId } : {}); }; -const useProxyHosts = (expand?: ProxyHostExpansion[], options = {}) => { +const useProxyHosts = (expand?: ProxyHostExpansion[], options: any = {}, agentId?: string) => { return useQuery({ - queryKey: ["proxy-hosts", { expand }], - queryFn: () => fetchProxyHosts(expand), + queryKey: ["proxy-hosts", { expand, agentId }], + queryFn: () => fetchProxyHosts(expand, agentId), staleTime: 60 * 1000, ...options, }); diff --git a/frontend/src/locale/src/bg.json b/frontend/src/locale/src/bg.json index 5183fe315b..8355688348 100644 --- a/frontend/src/locale/src/bg.json +++ b/frontend/src/locale/src/bg.json @@ -77,6 +77,9 @@ "auditlogs": { "defaultMessage": "Журнали за одит" }, + "agents": { + "defaultMessage": "Agents" + }, "auto": { "defaultMessage": "Автоматично" }, diff --git a/frontend/src/locale/src/cs.json b/frontend/src/locale/src/cs.json index cd86b678dc..c5096dfef5 100644 --- a/frontend/src/locale/src/cs.json +++ b/frontend/src/locale/src/cs.json @@ -134,6 +134,9 @@ "auditlogs": { "defaultMessage": "Záznamy auditu" }, + "agents": { + "defaultMessage": "Agents" + }, "auto": { "defaultMessage": "Automaticky" }, diff --git a/frontend/src/locale/src/de.json b/frontend/src/locale/src/de.json index f654e10858..1c57b20e08 100644 --- a/frontend/src/locale/src/de.json +++ b/frontend/src/locale/src/de.json @@ -68,6 +68,9 @@ "auditlogs": { "defaultMessage": "Protokolle" }, + "agents": { + "defaultMessage": "Agents" + }, "cancel": { "defaultMessage": "Abbrechen" }, diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json index bb00ac3322..d4a2dcb864 100644 --- a/frontend/src/locale/src/en.json +++ b/frontend/src/locale/src/en.json @@ -134,6 +134,9 @@ "auditlogs": { "defaultMessage": "Audit Logs" }, + "agents": { + "defaultMessage": "Agents" + }, "auto": { "defaultMessage": "Auto" }, diff --git a/frontend/src/locale/src/es.json b/frontend/src/locale/src/es.json index c8b1edb075..18797b3556 100644 --- a/frontend/src/locale/src/es.json +++ b/frontend/src/locale/src/es.json @@ -77,6 +77,9 @@ "auditlogs": { "defaultMessage": "Registros de Auditoría" }, + "agents": { + "defaultMessage": "Agents" + }, "auto": { "defaultMessage": "Auto" }, diff --git a/frontend/src/locale/src/et.json b/frontend/src/locale/src/et.json index bb00ac3322..d4a2dcb864 100644 --- a/frontend/src/locale/src/et.json +++ b/frontend/src/locale/src/et.json @@ -134,6 +134,9 @@ "auditlogs": { "defaultMessage": "Audit Logs" }, + "agents": { + "defaultMessage": "Agents" + }, "auto": { "defaultMessage": "Auto" }, diff --git a/frontend/src/locale/src/fr.json b/frontend/src/locale/src/fr.json index 0911eedc39..27be15b98f 100644 --- a/frontend/src/locale/src/fr.json +++ b/frontend/src/locale/src/fr.json @@ -134,6 +134,9 @@ "auditlogs": { "defaultMessage": "Journaux d'audit" }, + "agents": { + "defaultMessage": "Agents" + }, "auto": { "defaultMessage": "Auto" }, diff --git a/frontend/src/locale/src/ga.json b/frontend/src/locale/src/ga.json index 719b863bf0..965e048b0e 100644 --- a/frontend/src/locale/src/ga.json +++ b/frontend/src/locale/src/ga.json @@ -77,6 +77,9 @@ "auditlogs": { "defaultMessage": "Logaí Iniúchta" }, + "agents": { + "defaultMessage": "Agents" + }, "auto": { "defaultMessage": "Uath" }, diff --git a/frontend/src/locale/src/hu.json b/frontend/src/locale/src/hu.json index 4caf058344..de8daa80e7 100644 --- a/frontend/src/locale/src/hu.json +++ b/frontend/src/locale/src/hu.json @@ -134,6 +134,9 @@ "auditlogs": { "defaultMessage": "Audit naplók" }, + "agents": { + "defaultMessage": "Agents" + }, "auto": { "defaultMessage": "Automatikus" }, diff --git a/frontend/src/locale/src/id.json b/frontend/src/locale/src/id.json index cb498f0d88..4e784ad0ea 100644 --- a/frontend/src/locale/src/id.json +++ b/frontend/src/locale/src/id.json @@ -77,6 +77,9 @@ "auditlogs": { "defaultMessage": "Log Audit" }, + "agents": { + "defaultMessage": "Agents" + }, "auto": { "defaultMessage": "Otomatis" }, diff --git a/frontend/src/locale/src/it.json b/frontend/src/locale/src/it.json index 7e5ca77113..8451fec4cf 100644 --- a/frontend/src/locale/src/it.json +++ b/frontend/src/locale/src/it.json @@ -68,6 +68,9 @@ "auditlogs": { "defaultMessage": "Log di Audit" }, + "agents": { + "defaultMessage": "Agents" + }, "cancel": { "defaultMessage": "Annulla" }, diff --git a/frontend/src/locale/src/ja.json b/frontend/src/locale/src/ja.json index 438dc218d3..d6f0c2f87e 100644 --- a/frontend/src/locale/src/ja.json +++ b/frontend/src/locale/src/ja.json @@ -68,6 +68,9 @@ "auditlogs": { "defaultMessage": "監査ログ" }, + "agents": { + "defaultMessage": "Agents" + }, "cancel": { "defaultMessage": "キャンセル" }, diff --git a/frontend/src/locale/src/ko.json b/frontend/src/locale/src/ko.json index 9c0093591b..29ed9511ec 100644 --- a/frontend/src/locale/src/ko.json +++ b/frontend/src/locale/src/ko.json @@ -77,6 +77,9 @@ "auditlogs": { "defaultMessage": "감사 로그" }, + "agents": { + "defaultMessage": "Agents" + }, "auto": { "defaultMessage": "자동" }, diff --git a/frontend/src/locale/src/nl.json b/frontend/src/locale/src/nl.json index 86d49d95e2..d51818f741 100644 --- a/frontend/src/locale/src/nl.json +++ b/frontend/src/locale/src/nl.json @@ -134,6 +134,9 @@ "auditlogs": { "defaultMessage": "Logboeken" }, + "agents": { + "defaultMessage": "Agents" + }, "auto": { "defaultMessage": "Autom." }, diff --git a/frontend/src/locale/src/no.json b/frontend/src/locale/src/no.json index f14ea54b11..807f8eeea1 100644 --- a/frontend/src/locale/src/no.json +++ b/frontend/src/locale/src/no.json @@ -134,6 +134,9 @@ "auditlogs": { "defaultMessage": "Revisjonslogger" }, + "agents": { + "defaultMessage": "Agents" + }, "auto": { "defaultMessage": "Auto" }, diff --git a/frontend/src/locale/src/pl.json b/frontend/src/locale/src/pl.json index a5fb2ad0be..f88d1f066c 100644 --- a/frontend/src/locale/src/pl.json +++ b/frontend/src/locale/src/pl.json @@ -74,6 +74,9 @@ "auditlogs": { "defaultMessage": "Logi" }, + "agents": { + "defaultMessage": "Agents" + }, "cancel": { "defaultMessage": "Anuluj" }, diff --git a/frontend/src/locale/src/pt.json b/frontend/src/locale/src/pt.json index 0a789f484e..50e650bb22 100644 --- a/frontend/src/locale/src/pt.json +++ b/frontend/src/locale/src/pt.json @@ -77,6 +77,9 @@ "auditlogs": { "defaultMessage": "Registos de Auditoria" }, + "agents": { + "defaultMessage": "Agents" + }, "auto": { "defaultMessage": "Automático" }, diff --git a/frontend/src/locale/src/ru.json b/frontend/src/locale/src/ru.json index c18be998f9..e1db6a98ec 100644 --- a/frontend/src/locale/src/ru.json +++ b/frontend/src/locale/src/ru.json @@ -134,6 +134,9 @@ "auditlogs": { "defaultMessage": "Журнал аудита" }, + "agents": { + "defaultMessage": "Agents" + }, "auto": { "defaultMessage": "Авто" }, diff --git a/frontend/src/locale/src/sk.json b/frontend/src/locale/src/sk.json index 8d48cf811e..982fe27033 100644 --- a/frontend/src/locale/src/sk.json +++ b/frontend/src/locale/src/sk.json @@ -134,6 +134,9 @@ "auditlogs": { "defaultMessage": "Záznamy auditu" }, + "agents": { + "defaultMessage": "Agents" + }, "auto": { "defaultMessage": "Automaticky" }, diff --git a/frontend/src/locale/src/tr.json b/frontend/src/locale/src/tr.json index 972fa895ec..ca95a6a38b 100644 --- a/frontend/src/locale/src/tr.json +++ b/frontend/src/locale/src/tr.json @@ -77,6 +77,9 @@ "auditlogs": { "defaultMessage": "Denetim Kayıtları" }, + "agents": { + "defaultMessage": "Agents" + }, "auto": { "defaultMessage": "Otomatik" }, diff --git a/frontend/src/locale/src/vi.json b/frontend/src/locale/src/vi.json index 32d26d5590..826a449e0a 100644 --- a/frontend/src/locale/src/vi.json +++ b/frontend/src/locale/src/vi.json @@ -68,6 +68,9 @@ "auditlogs": { "defaultMessage": "Nhật ký kiểm tra" }, + "agents": { + "defaultMessage": "Agents" + }, "cancel": { "defaultMessage": "Hủy" }, diff --git a/frontend/src/locale/src/zh.json b/frontend/src/locale/src/zh.json index 72494bb64f..343b751ce1 100644 --- a/frontend/src/locale/src/zh.json +++ b/frontend/src/locale/src/zh.json @@ -68,6 +68,9 @@ "auditlogs": { "defaultMessage": "审计日志" }, + "agents": { + "defaultMessage": "Agents" + }, "cancel": { "defaultMessage": "取消" }, diff --git a/frontend/src/modals/ProxyHostModal.tsx b/frontend/src/modals/ProxyHostModal.tsx index 3227be51bb..58369058fa 100644 --- a/frontend/src/modals/ProxyHostModal.tsx +++ b/frontend/src/modals/ProxyHostModal.tsx @@ -22,17 +22,18 @@ import { MANAGE, PROXY_HOSTS } from "src/modules/Permissions"; import { validateNumber, validateString } from "src/modules/Validations"; import { showObjectSuccess } from "src/notifications"; -const showProxyHostModal = (id: number | "new") => { - EasyModal.show(ProxyHostModal, { id }); +const showProxyHostModal = (id: number | "new", agentId?: string) => { + EasyModal.show(ProxyHostModal as any, { id, agentId }); }; interface Props extends InnerModalProps { id: number | "new"; + agentId?: string; } -const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => { +const ProxyHostModal = EasyModal.create(({ id, agentId, visible, remove }: Props) => { const { data: currentUser, isLoading: userIsLoading, error: userError } = useUser("me"); - const { data, isLoading, error } = useProxyHost(id); - const { mutate: setProxyHost } = useSetProxyHost(); + const { data, isLoading, error } = useProxyHost(id, {}, agentId); + const { mutate: setProxyHost } = useSetProxyHost(agentId); const [errorMsg, setErrorMsg] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); @@ -253,7 +254,7 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => { - +

@@ -339,6 +340,7 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => { name="certificateId" label="ssl-certificate" allowNew + agentId={agentId} />

diff --git a/frontend/src/pages/Agents/index.tsx b/frontend/src/pages/Agents/index.tsx new file mode 100644 index 0000000000..9ed500b6b1 --- /dev/null +++ b/frontend/src/pages/Agents/index.tsx @@ -0,0 +1,100 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import Alert from "react-bootstrap/Alert"; +import { createAgent, deleteAgent, testAgent } from "src/api/backend"; +import { Button, LoadingPage } from "src/components"; +import { useAgents } from "src/hooks"; + +export default function Agents() { + const queryClient = useQueryClient(); + const { data, isLoading, isError, error } = useAgents(); + const [form, setForm] = useState({ name: "", url: "", identity: "", secret: "" }); + const [message, setMessage] = useState(null); + const [errorMsg, setErrorMsg] = useState(null); + + if (isLoading) return ; + if (isError) return {error?.message || "Unknown error"}; + + const refresh = () => queryClient.invalidateQueries({ queryKey: ["agents"] }); + const setValue = (key: string, value: string) => setForm((old) => ({ ...old, [key]: value })); + + const addAgent = async () => { + setMessage(null); + setErrorMsg(null); + try { + await createAgent(form); + setForm({ name: "", url: "", identity: "", secret: "" }); + setMessage("Agent added"); + refresh(); + } catch (err: any) { + setErrorMsg(err.message || `${err}`); + } + }; + + const test = async (id: number) => { + setMessage(null); + setErrorMsg(null); + try { + await testAgent(id); + setMessage("Agent test succeeded"); + refresh(); + } catch (err: any) { + setErrorMsg(err.message || `${err}`); + } + }; + + const remove = async (id: number) => { + setMessage(null); + setErrorMsg(null); + try { + await deleteAgent(id); + setMessage("Agent deleted"); + refresh(); + } catch (err: any) { + setErrorMsg(err.message || `${err}`); + } + }; + + return ( +
+
+
+

Agents

+
+
+ {message ? {message} : null} + {errorMsg ? {errorMsg} : null} +

+ Add remote Nginx Proxy Manager instances. The proxy host page can then switch nodes and forward + operations to the selected instance. +

+
+
setValue("name", e.target.value)} />
+
setValue("url", e.target.value)} />
+
setValue("identity", e.target.value)} />
+
setValue("secret", e.target.value)} />
+
+
+
+ + + + {data?.map((agent) => ( + + + + + + + + ))} + +
NameURLLoginStatusActions
{agent.name}{agent.url}{agent.identity}{agent.meta?.lastTest?.ok ? "online" : agent.enabled ? "unknown" : "disabled"} + + +
+
+
+
+ ); +} diff --git a/frontend/src/pages/Nginx/ProxyHosts/TableWrapper.tsx b/frontend/src/pages/Nginx/ProxyHosts/TableWrapper.tsx index 5d6602e2db..e61c39584c 100644 --- a/frontend/src/pages/Nginx/ProxyHosts/TableWrapper.tsx +++ b/frontend/src/pages/Nginx/ProxyHosts/TableWrapper.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import Alert from "react-bootstrap/Alert"; import { deleteProxyHost, toggleProxyHost } from "src/api/backend"; import { Button, HasPermission, LoadingPage } from "src/components"; -import { useProxyHosts } from "src/hooks"; +import { useAgents, useProxyHosts } from "src/hooks"; import { T } from "src/locale"; import { showDeleteConfirmModal, showHelpModal, showProxyHostModal } from "src/modals"; import { MANAGE, PROXY_HOSTS } from "src/modules/Permissions"; @@ -14,7 +14,9 @@ import Table from "./Table"; export default function TableWrapper() { const queryClient = useQueryClient(); const [search, setSearch] = useState(""); - const { isFetching, isLoading, isError, error, data } = useProxyHosts(["owner", "access_list", "certificate"]); + const [agentId, setAgentId] = useState("local"); + const { data: agents } = useAgents(); + const { isFetching, isLoading, isError, error, data } = useProxyHosts(["owner", "access_list", "certificate"], {}, agentId); if (isLoading) { return ; @@ -25,12 +27,12 @@ export default function TableWrapper() { } const handleDelete = async (id: number) => { - await deleteProxyHost(id); + await deleteProxyHost(id, agentId); showObjectSuccess("proxy-host", "deleted"); }; const handleDisableToggle = async (id: number, enabled: boolean) => { - await toggleProxyHost(id, enabled); + await toggleProxyHost(id, enabled, agentId); queryClient.invalidateQueries({ queryKey: ["proxy-hosts"] }); queryClient.invalidateQueries({ queryKey: ["proxy-host", id] }); showObjectSuccess("proxy-host", enabled ? "enabled" : "disabled"); @@ -62,6 +64,21 @@ export default function TableWrapper() {
+ {data?.length ? (
@@ -84,7 +101,7 @@ export default function TableWrapper() { @@ -98,7 +115,7 @@ export default function TableWrapper() { data={filtered ?? data ?? []} isFiltered={!!search} isFetching={isFetching} - onEdit={(id: number) => showProxyHostModal(id)} + onEdit={(id: number) => showProxyHostModal(id, agentId)} onDelete={(id: number) => { const host = data?.find((h) => h.id === id); showDeleteConfirmModal({ @@ -121,7 +138,7 @@ export default function TableWrapper() { }); }} onDisableToggle={handleDisableToggle} - onNew={() => showProxyHostModal("new")} + onNew={() => showProxyHostModal("new", agentId)} />
From c6235dcfec91fa92e5a6d2da3f93b31e2c67dd37 Mon Sep 17 00:00:00 2001 From: NightWatcher Date: Thu, 21 May 2026 05:15:55 +0800 Subject: [PATCH 2/2] Fix agent token login payload --- backend/internal/agent-client.js | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/internal/agent-client.js b/backend/internal/agent-client.js index 48101f5e61..507618da64 100644 --- a/backend/internal/agent-client.js +++ b/backend/internal/agent-client.js @@ -44,7 +44,6 @@ async function getToken(agent, force = false) { body: JSON.stringify({ identity: agent.identity, secret: agent.secret, - expiry: "1d", }), });