diff --git a/.gitignore b/.gitignore
index a83a2110e0..94eb3de26a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,4 +9,3 @@ test/node_modules
docker/dev/dnsrouter-config.json.tmp
docker/dev/resolv.conf
.claude
-
diff --git a/backend/internal/agent-client.js b/backend/internal/agent-client.js
new file mode 100644
index 0000000000..507618da64
--- /dev/null
+++ b/backend/internal/agent-client.js
@@ -0,0 +1,140 @@
+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,
+ }),
+ });
+
+ 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 a308ea6179..c93d2cbddb 100644
--- a/backend/routes/main.js
+++ b/backend/routes/main.js
@@ -4,6 +4,7 @@ import errs from "../lib/error.js";
import logRequest from "../lib/express/log-request.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 ciRoutes from "./ci.js";
import accessListsRoutes from "./nginx/access_lists.js";
@@ -49,6 +50,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/index.html b/frontend/index.html
index 08c760964f..f70e667ed0 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -5,6 +5,10 @@
Nginx Proxy Manager
+
+
+
+
-
+
diff --git a/frontend/public/images/favicon/site.webmanifest b/frontend/public/images/favicon/site.webmanifest
index 99d1016eb5..20dab70f04 100644
--- a/frontend/public/images/favicon/site.webmanifest
+++ b/frontend/public/images/favicon/site.webmanifest
@@ -1,19 +1,25 @@
{
- "name": "",
- "short_name": "",
- "icons": [
- {
- "src": "/images/favicons/android-chrome-192x192.png",
- "sizes": "192x192",
- "type": "image/png"
- },
- {
- "src": "/images/favicons/android-chrome-512x512.png",
- "sizes": "512x512",
- "type": "image/png"
- }
- ],
- "theme_color": "#ffffff",
- "background_color": "#ffffff",
- "display": "standalone"
+ "name": "Nginx Proxy Manager",
+ "short_name": "NPM",
+ "description": "Manage Nginx hosts with a simple, powerful interface",
+ "start_url": "/",
+ "scope": "/",
+ "display": "standalone",
+ "orientation": "any",
+ "theme_color": "#206bc4",
+ "background_color": "#ffffff",
+ "icons": [
+ {
+ "src": "/images/favicon/android-chrome-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "any maskable"
+ },
+ {
+ "src": "/images/favicon/android-chrome-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "any maskable"
+ }
+ ]
}
diff --git a/frontend/public/offline.html b/frontend/public/offline.html
new file mode 100644
index 0000000000..75d311b990
--- /dev/null
+++ b/frontend/public/offline.html
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+ Nginx Proxy Manager is offline
+
+
+
+
+
+ Nginx Proxy Manager is offline
+ The app shell is installed, but management actions require a connection to the NPM server.
+
+
+
+
diff --git a/frontend/public/sw.js b/frontend/public/sw.js
new file mode 100644
index 0000000000..a6fd1b4a93
--- /dev/null
+++ b/frontend/public/sw.js
@@ -0,0 +1,72 @@
+const CACHE_VERSION = "npm-pwa-v1";
+const APP_SHELL = [
+ "/",
+ "/index.html",
+ "/offline.html",
+ "/images/logo-no-text.svg",
+ "/images/favicon/android-chrome-192x192.png",
+ "/images/favicon/android-chrome-512x512.png",
+];
+
+self.addEventListener("install", (event) => {
+ event.waitUntil(
+ caches.open(CACHE_VERSION).then((cache) => cache.addAll(APP_SHELL)),
+ );
+});
+
+self.addEventListener("activate", (event) => {
+ event.waitUntil(
+ caches
+ .keys()
+ .then((keys) => Promise.all(keys.filter((key) => key !== CACHE_VERSION).map((key) => caches.delete(key))))
+ .then(() => self.clients.claim()),
+ );
+});
+
+self.addEventListener("fetch", (event) => {
+ const { request } = event;
+
+ if (request.method !== "GET") {
+ return;
+ }
+
+ const url = new URL(request.url);
+ if (url.origin !== self.location.origin || url.pathname.startsWith("/api/")) {
+ return;
+ }
+
+ if (request.mode === "navigate") {
+ event.respondWith(
+ fetch(request)
+ .then((response) => {
+ const copy = response.clone();
+ caches.open(CACHE_VERSION).then((cache) => cache.put("/index.html", copy));
+ return response;
+ })
+ .catch(async () => (await caches.match("/index.html")) || caches.match("/offline.html")),
+ );
+ return;
+ }
+
+ event.respondWith(
+ caches.match(request).then((cached) => {
+ if (cached) {
+ return cached;
+ }
+
+ return fetch(request).then((response) => {
+ if (response.ok) {
+ const copy = response.clone();
+ caches.open(CACHE_VERSION).then((cache) => cache.put(request, copy));
+ }
+ return response;
+ });
+ }),
+ );
+});
+
+self.addEventListener("message", (event) => {
+ if (event.data?.type === "SKIP_WAITING") {
+ self.skipWaiting();
+ }
+});
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index b6f0bba70d..df98622267 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,16 +1,34 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { useEffect } from "react";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import EasyModal from "ez-modal-react";
import { RawIntlProvider } from "react-intl";
-import { ToastContainer } from "react-toastify";
+import { toast, ToastContainer } from "react-toastify";
import { AuthProvider, LocaleProvider, ThemeProvider } from "src/context";
import { intl } from "src/locale";
import Router from "src/Router.tsx";
+import { registerPwa } from "src/modules/Pwa";
// Create a client
const queryClient = new QueryClient();
function App() {
+ useEffect(() => {
+ registerPwa({
+ onOfflineReady: () => {
+ toast.info("Nginx Proxy Manager is ready for offline launch.");
+ },
+ onUpdateReady: (activateUpdate) => {
+ toast.info(
+ ,
+ { autoClose: false },
+ );
+ },
+ });
+ }, []);
+
return (
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..dfb359ba7e 100644
--- a/frontend/src/locale/src/bg.json
+++ b/frontend/src/locale/src/bg.json
@@ -74,6 +74,9 @@
"action.view-details": {
"defaultMessage": "Преглед на детайли"
},
+ "agents": {
+ "defaultMessage": "Agents"
+ },
"auditlogs": {
"defaultMessage": "Журнали за одит"
},
diff --git a/frontend/src/locale/src/cs.json b/frontend/src/locale/src/cs.json
index cd86b678dc..eaff3825e9 100644
--- a/frontend/src/locale/src/cs.json
+++ b/frontend/src/locale/src/cs.json
@@ -131,6 +131,9 @@
"action.view-details": {
"defaultMessage": "Zobrazit podrobnosti"
},
+ "agents": {
+ "defaultMessage": "Agents"
+ },
"auditlogs": {
"defaultMessage": "Záznamy auditu"
},
diff --git a/frontend/src/locale/src/de.json b/frontend/src/locale/src/de.json
index f654e10858..09d664cdbf 100644
--- a/frontend/src/locale/src/de.json
+++ b/frontend/src/locale/src/de.json
@@ -65,6 +65,9 @@
"action.view-details": {
"defaultMessage": "Details anzeigen"
},
+ "agents": {
+ "defaultMessage": "Agents"
+ },
"auditlogs": {
"defaultMessage": "Protokolle"
},
diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json
index bb00ac3322..4705df4379 100644
--- a/frontend/src/locale/src/en.json
+++ b/frontend/src/locale/src/en.json
@@ -131,6 +131,9 @@
"action.view-details": {
"defaultMessage": "View Details"
},
+ "agents": {
+ "defaultMessage": "Agents"
+ },
"auditlogs": {
"defaultMessage": "Audit Logs"
},
diff --git a/frontend/src/locale/src/es.json b/frontend/src/locale/src/es.json
index c8b1edb075..59890a8411 100644
--- a/frontend/src/locale/src/es.json
+++ b/frontend/src/locale/src/es.json
@@ -74,6 +74,9 @@
"action.view-details": {
"defaultMessage": "Ver Detalles"
},
+ "agents": {
+ "defaultMessage": "Agents"
+ },
"auditlogs": {
"defaultMessage": "Registros de Auditoría"
},
diff --git a/frontend/src/locale/src/et.json b/frontend/src/locale/src/et.json
index bb00ac3322..4705df4379 100644
--- a/frontend/src/locale/src/et.json
+++ b/frontend/src/locale/src/et.json
@@ -131,6 +131,9 @@
"action.view-details": {
"defaultMessage": "View Details"
},
+ "agents": {
+ "defaultMessage": "Agents"
+ },
"auditlogs": {
"defaultMessage": "Audit Logs"
},
diff --git a/frontend/src/locale/src/fr.json b/frontend/src/locale/src/fr.json
index 0911eedc39..8e21c34899 100644
--- a/frontend/src/locale/src/fr.json
+++ b/frontend/src/locale/src/fr.json
@@ -131,6 +131,9 @@
"action.view-details": {
"defaultMessage": "Voir les Détails"
},
+ "agents": {
+ "defaultMessage": "Agents"
+ },
"auditlogs": {
"defaultMessage": "Journaux d'audit"
},
diff --git a/frontend/src/locale/src/ga.json b/frontend/src/locale/src/ga.json
index 719b863bf0..e042ea7597 100644
--- a/frontend/src/locale/src/ga.json
+++ b/frontend/src/locale/src/ga.json
@@ -74,6 +74,9 @@
"action.view-details": {
"defaultMessage": "Féach Sonraí"
},
+ "agents": {
+ "defaultMessage": "Agents"
+ },
"auditlogs": {
"defaultMessage": "Logaí Iniúchta"
},
diff --git a/frontend/src/locale/src/hu.json b/frontend/src/locale/src/hu.json
index 4caf058344..4d0d8ece04 100644
--- a/frontend/src/locale/src/hu.json
+++ b/frontend/src/locale/src/hu.json
@@ -131,6 +131,9 @@
"action.view-details": {
"defaultMessage": "Részletek megtekintése"
},
+ "agents": {
+ "defaultMessage": "Agents"
+ },
"auditlogs": {
"defaultMessage": "Audit naplók"
},
diff --git a/frontend/src/locale/src/id.json b/frontend/src/locale/src/id.json
index cb498f0d88..d146012061 100644
--- a/frontend/src/locale/src/id.json
+++ b/frontend/src/locale/src/id.json
@@ -74,6 +74,9 @@
"action.view-details": {
"defaultMessage": "Lihat Detail"
},
+ "agents": {
+ "defaultMessage": "Agents"
+ },
"auditlogs": {
"defaultMessage": "Log Audit"
},
diff --git a/frontend/src/locale/src/it.json b/frontend/src/locale/src/it.json
index 7e5ca77113..da53ce9e88 100644
--- a/frontend/src/locale/src/it.json
+++ b/frontend/src/locale/src/it.json
@@ -65,6 +65,9 @@
"action.view-details": {
"defaultMessage": "Visualizza Dettagli"
},
+ "agents": {
+ "defaultMessage": "Agents"
+ },
"auditlogs": {
"defaultMessage": "Log di Audit"
},
diff --git a/frontend/src/locale/src/ja.json b/frontend/src/locale/src/ja.json
index 438dc218d3..8c28d0fc96 100644
--- a/frontend/src/locale/src/ja.json
+++ b/frontend/src/locale/src/ja.json
@@ -65,6 +65,9 @@
"action.view-details": {
"defaultMessage": "詳細"
},
+ "agents": {
+ "defaultMessage": "Agents"
+ },
"auditlogs": {
"defaultMessage": "監査ログ"
},
diff --git a/frontend/src/locale/src/ko.json b/frontend/src/locale/src/ko.json
index 9c0093591b..55baf10830 100644
--- a/frontend/src/locale/src/ko.json
+++ b/frontend/src/locale/src/ko.json
@@ -74,6 +74,9 @@
"action.view-details": {
"defaultMessage": "자세히 보기"
},
+ "agents": {
+ "defaultMessage": "Agents"
+ },
"auditlogs": {
"defaultMessage": "감사 로그"
},
diff --git a/frontend/src/locale/src/nl.json b/frontend/src/locale/src/nl.json
index 86d49d95e2..f61777c651 100644
--- a/frontend/src/locale/src/nl.json
+++ b/frontend/src/locale/src/nl.json
@@ -131,6 +131,9 @@
"action.view-details": {
"defaultMessage": "Details weergeven"
},
+ "agents": {
+ "defaultMessage": "Agents"
+ },
"auditlogs": {
"defaultMessage": "Logboeken"
},
diff --git a/frontend/src/locale/src/no.json b/frontend/src/locale/src/no.json
index f14ea54b11..18209ba674 100644
--- a/frontend/src/locale/src/no.json
+++ b/frontend/src/locale/src/no.json
@@ -131,6 +131,9 @@
"action.view-details": {
"defaultMessage": "Vis detaljer"
},
+ "agents": {
+ "defaultMessage": "Agents"
+ },
"auditlogs": {
"defaultMessage": "Revisjonslogger"
},
diff --git a/frontend/src/locale/src/pl.json b/frontend/src/locale/src/pl.json
index a5fb2ad0be..960f515fa6 100644
--- a/frontend/src/locale/src/pl.json
+++ b/frontend/src/locale/src/pl.json
@@ -71,6 +71,9 @@
"action.view-details": {
"defaultMessage": "Pokaż szczegóły"
},
+ "agents": {
+ "defaultMessage": "Agents"
+ },
"auditlogs": {
"defaultMessage": "Logi"
},
diff --git a/frontend/src/locale/src/pt.json b/frontend/src/locale/src/pt.json
index 0a789f484e..286d2b0878 100644
--- a/frontend/src/locale/src/pt.json
+++ b/frontend/src/locale/src/pt.json
@@ -74,6 +74,9 @@
"action.view-details": {
"defaultMessage": "Ver Detalhes"
},
+ "agents": {
+ "defaultMessage": "Agents"
+ },
"auditlogs": {
"defaultMessage": "Registos de Auditoria"
},
diff --git a/frontend/src/locale/src/ru.json b/frontend/src/locale/src/ru.json
index c18be998f9..da3bcce986 100644
--- a/frontend/src/locale/src/ru.json
+++ b/frontend/src/locale/src/ru.json
@@ -131,6 +131,9 @@
"action.view-details": {
"defaultMessage": "Просмотреть сведения"
},
+ "agents": {
+ "defaultMessage": "Agents"
+ },
"auditlogs": {
"defaultMessage": "Журнал аудита"
},
diff --git a/frontend/src/locale/src/sk.json b/frontend/src/locale/src/sk.json
index 8d48cf811e..3d12141150 100644
--- a/frontend/src/locale/src/sk.json
+++ b/frontend/src/locale/src/sk.json
@@ -131,6 +131,9 @@
"action.view-details": {
"defaultMessage": "Zobraziť podrobnosti"
},
+ "agents": {
+ "defaultMessage": "Agents"
+ },
"auditlogs": {
"defaultMessage": "Záznamy auditu"
},
diff --git a/frontend/src/locale/src/tr.json b/frontend/src/locale/src/tr.json
index 972fa895ec..e4460c7054 100644
--- a/frontend/src/locale/src/tr.json
+++ b/frontend/src/locale/src/tr.json
@@ -74,6 +74,9 @@
"action.view-details": {
"defaultMessage": "Detayları Görüntüle"
},
+ "agents": {
+ "defaultMessage": "Agents"
+ },
"auditlogs": {
"defaultMessage": "Denetim Kayıtları"
},
diff --git a/frontend/src/locale/src/vi.json b/frontend/src/locale/src/vi.json
index 32d26d5590..058ee36c33 100644
--- a/frontend/src/locale/src/vi.json
+++ b/frontend/src/locale/src/vi.json
@@ -65,6 +65,9 @@
"action.view-details": {
"defaultMessage": "Xem Chi tiết"
},
+ "agents": {
+ "defaultMessage": "Agents"
+ },
"auditlogs": {
"defaultMessage": "Nhật ký kiểm tra"
},
diff --git a/frontend/src/locale/src/zh.json b/frontend/src/locale/src/zh.json
index 72494bb64f..7b5541a994 100644
--- a/frontend/src/locale/src/zh.json
+++ b/frontend/src/locale/src/zh.json
@@ -65,6 +65,9 @@
"action.view-details": {
"defaultMessage": "查看详情"
},
+ "agents": {
+ "defaultMessage": "Agents"
+ },
"auditlogs": {
"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/modules/Pwa.ts b/frontend/src/modules/Pwa.ts
new file mode 100644
index 0000000000..003f7c3343
--- /dev/null
+++ b/frontend/src/modules/Pwa.ts
@@ -0,0 +1,63 @@
+type RegisterPwaOptions = {
+ onOfflineReady?: () => void;
+ onUpdateReady?: (activateUpdate: () => void) => void;
+};
+
+function listenForWaitingWorker(registration: ServiceWorkerRegistration, onUpdateReady?: (activateUpdate: () => void) => void) {
+ const notify = (worker?: ServiceWorker | null) => {
+ if (!worker || !onUpdateReady) {
+ return;
+ }
+
+ onUpdateReady(() => worker.postMessage({ type: "SKIP_WAITING" }));
+ };
+
+ if (registration.waiting) {
+ notify(registration.waiting);
+ }
+
+ registration.addEventListener("updatefound", () => {
+ const worker = registration.installing;
+ worker?.addEventListener("statechange", () => {
+ if (worker.state === "installed" && navigator.serviceWorker.controller) {
+ notify(worker);
+ }
+ });
+ });
+}
+
+export function registerPwa({ onOfflineReady, onUpdateReady }: RegisterPwaOptions = {}) {
+ if (!import.meta.env.PROD || !("serviceWorker" in navigator)) {
+ return;
+ }
+
+ window.addEventListener("load", () => {
+ navigator.serviceWorker
+ .register("/sw.js")
+ .then((registration) => {
+ listenForWaitingWorker(registration, onUpdateReady);
+ if (!navigator.serviceWorker.controller) {
+ registration.addEventListener("updatefound", () => {
+ const worker = registration.installing;
+ worker?.addEventListener("statechange", () => {
+ if (worker.state === "installed") {
+ onOfflineReady?.();
+ }
+ });
+ });
+ }
+ })
+ .catch((error) => {
+ console.error("Failed to register service worker", error);
+ });
+ });
+
+ let refreshing = false;
+ navigator.serviceWorker.addEventListener("controllerchange", () => {
+ if (refreshing) {
+ return;
+ }
+ refreshing = true;
+ window.location.reload();
+ });
+}
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.
+
+
+
+
+ | Name | URL | Login | Status | Actions |
+
+ {data?.map((agent) => (
+
+ | {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)}
/>