From b748b21fbd1406f82a0f9c4e35667ff5c23b93e5 Mon Sep 17 00:00:00 2001 From: GenticFlow Labs Date: Tue, 17 Mar 2026 21:46:02 +0000 Subject: [PATCH 1/6] Introduced support for load balancing through upstream hosts and customizable Real IP header source --- backend/internal/ip_ranges.js | 28 +- backend/internal/nginx.js | 2 + backend/internal/proxy-host.js | 6 +- backend/internal/report.js | 3 + backend/internal/setting.js | 5 + backend/internal/upstream-host.js | 320 ++++++++++ backend/internal/user.js | 1 + backend/lib/access.js | 1 + backend/lib/access/upstream_hosts-create.json | 23 + backend/lib/access/upstream_hosts-delete.json | 23 + backend/lib/access/upstream_hosts-get.json | 23 + backend/lib/access/upstream_hosts-list.json | 23 + backend/lib/access/upstream_hosts-update.json | 23 + backend/logger.js | 3 +- .../20260315120000_upstream_hosts.js | 96 +++ backend/models/proxy_host.js | 16 +- backend/models/upstream_host.js | 99 +++ backend/models/upstream_host_server.js | 53 ++ backend/routes/main.js | 2 + backend/routes/nginx/upstream_hosts.js | 145 +++++ .../schema/components/proxy-host-object.json | 14 + .../schema/components/upstream-host-list.json | 7 + .../components/upstream-host-object.json | 41 ++ .../paths/nginx/proxy-hosts/hostID/put.json | 3 + .../schema/paths/nginx/proxy-hosts/post.json | 3 + .../paths/nginx/upstream-hosts/get.json | 26 + .../paths/nginx/upstream-hosts/post.json | 74 +++ .../upstream-hosts/upstreamID/delete.json | 27 + .../nginx/upstream-hosts/upstreamID/get.json | 33 + .../nginx/upstream-hosts/upstreamID/put.json | 83 +++ .../schema/paths/settings/settingID/put.json | 7 +- backend/schema/swagger.json | 23 + backend/scripts/regenerate-config | 10 +- backend/setup.js | 86 ++- backend/templates/_assets.conf | 39 +- backend/templates/_location.conf | 4 + backend/templates/ip_ranges.conf | 3 +- backend/templates/proxy_host.conf | 5 + backend/templates/upstream_host.conf | 14 + .../etc/nginx/conf.d/include/assets.conf | 1 + .../etc/nginx/conf.d/include/proxy.conf | 1 - docker/rootfs/etc/nginx/nginx.conf | 3 +- .../s6-overlay/s6-rc.d/prepare/20-paths.sh | 1 + frontend/src/Router.tsx | 2 + .../src/api/backend/createUpstreamHost.ts | 9 + .../src/api/backend/deleteUpstreamHost.ts | 7 + frontend/src/api/backend/expansions.ts | 3 +- frontend/src/api/backend/getUpstreamHost.ts | 13 + frontend/src/api/backend/getUpstreamHosts.ts | 13 + frontend/src/api/backend/index.ts | 5 + frontend/src/api/backend/models.ts | 39 ++ .../src/api/backend/updateUpstreamHost.ts | 12 + .../components/Form/LoadBalancingFields.tsx | 155 +++++ .../src/components/Form/LocationsFields.tsx | 101 ++- .../src/components/Form/UpstreamHostField.tsx | 92 +++ .../components/Form/UpstreamHostSelect.tsx | 70 +++ frontend/src/components/Form/index.ts | 3 + frontend/src/components/SiteMenu.tsx | 9 + frontend/src/hooks/index.ts | 2 + frontend/src/hooks/useProxyHost.ts | 1 + frontend/src/hooks/useUpstreamHost.ts | 61 ++ frontend/src/hooks/useUpstreamHosts.ts | 17 + frontend/src/locale/src/bg.json | 174 +++++- frontend/src/locale/src/cs.json | 125 +++- frontend/src/locale/src/de.json | 201 ++++++ frontend/src/locale/src/en.json | 81 +++ frontend/src/locale/src/es.json | 171 ++++- frontend/src/locale/src/et.json | 583 ++++++++++-------- frontend/src/locale/src/fr.json | 212 ++++++- frontend/src/locale/src/ga.json | 174 ++++++ frontend/src/locale/src/hu.json | 93 ++- frontend/src/locale/src/id.json | 182 +++++- frontend/src/locale/src/it.json | 200 +++++- frontend/src/locale/src/ja.json | 204 ++++++ frontend/src/locale/src/ko.json | 176 +++++- frontend/src/locale/src/nl.json | 200 +++++- frontend/src/locale/src/no.json | 89 ++- frontend/src/locale/src/pl.json | 197 +++++- frontend/src/locale/src/pt.json | 190 +++++- frontend/src/locale/src/ru.json | 201 ++++++ frontend/src/locale/src/sk.json | 155 ++++- frontend/src/locale/src/tr.json | 184 +++++- frontend/src/locale/src/vi.json | 200 +++++- frontend/src/locale/src/zh.json | 199 +++++- frontend/src/modals/PermissionsModal.tsx | 9 + frontend/src/modals/ProxyHostModal.tsx | 255 +++++--- frontend/src/modals/UpstreamHostModal.tsx | 215 +++++++ frontend/src/modals/index.ts | 1 + frontend/src/modules/Permissions.ts | 4 +- frontend/src/pages/Nginx/ProxyHosts/Table.tsx | 15 + .../pages/Nginx/ProxyHosts/TableWrapper.tsx | 2 +- frontend/src/pages/Settings/Layout.tsx | 24 +- frontend/src/pages/Settings/RealIpHeader.tsx | 163 +++++ frontend/src/pages/UpstreamHosts/Table.tsx | 150 +++++ .../src/pages/UpstreamHosts/TableWrapper.tsx | 100 +++ frontend/src/pages/UpstreamHosts/index.tsx | 13 + 96 files changed, 6681 insertions(+), 478 deletions(-) create mode 100644 backend/internal/upstream-host.js create mode 100644 backend/lib/access/upstream_hosts-create.json create mode 100644 backend/lib/access/upstream_hosts-delete.json create mode 100644 backend/lib/access/upstream_hosts-get.json create mode 100644 backend/lib/access/upstream_hosts-list.json create mode 100644 backend/lib/access/upstream_hosts-update.json create mode 100644 backend/migrations/20260315120000_upstream_hosts.js create mode 100644 backend/models/upstream_host.js create mode 100644 backend/models/upstream_host_server.js create mode 100644 backend/routes/nginx/upstream_hosts.js create mode 100644 backend/schema/components/upstream-host-list.json create mode 100644 backend/schema/components/upstream-host-object.json create mode 100644 backend/schema/paths/nginx/upstream-hosts/get.json create mode 100644 backend/schema/paths/nginx/upstream-hosts/post.json create mode 100644 backend/schema/paths/nginx/upstream-hosts/upstreamID/delete.json create mode 100644 backend/schema/paths/nginx/upstream-hosts/upstreamID/get.json create mode 100644 backend/schema/paths/nginx/upstream-hosts/upstreamID/put.json create mode 100644 backend/templates/upstream_host.conf create mode 100644 frontend/src/api/backend/createUpstreamHost.ts create mode 100644 frontend/src/api/backend/deleteUpstreamHost.ts create mode 100644 frontend/src/api/backend/getUpstreamHost.ts create mode 100644 frontend/src/api/backend/getUpstreamHosts.ts create mode 100644 frontend/src/api/backend/updateUpstreamHost.ts create mode 100644 frontend/src/components/Form/LoadBalancingFields.tsx create mode 100644 frontend/src/components/Form/UpstreamHostField.tsx create mode 100644 frontend/src/components/Form/UpstreamHostSelect.tsx create mode 100644 frontend/src/hooks/useUpstreamHost.ts create mode 100644 frontend/src/hooks/useUpstreamHosts.ts create mode 100644 frontend/src/modals/UpstreamHostModal.tsx create mode 100644 frontend/src/pages/Settings/RealIpHeader.tsx create mode 100644 frontend/src/pages/UpstreamHosts/Table.tsx create mode 100644 frontend/src/pages/UpstreamHosts/TableWrapper.tsx create mode 100644 frontend/src/pages/UpstreamHosts/index.tsx diff --git a/backend/internal/ip_ranges.js b/backend/internal/ip_ranges.js index 6aa2b88a98..c7a47ae09d 100644 --- a/backend/internal/ip_ranges.js +++ b/backend/internal/ip_ranges.js @@ -6,6 +6,7 @@ import { ProxyAgent } from "proxy-agent"; import errs from "../lib/error.js"; import utils from "../lib/utils.js"; import { ipRanges as logger } from "../logger.js"; +import settingModel from "../models/setting.js"; import internalNginx from "./nginx.js"; const __filename = fileURLToPath(import.meta.url); @@ -23,6 +24,7 @@ const internalIpRanges = { interval: null, interval_processing: false, iteration_count: 0, + last_ip_ranges: [], initTimer: () => { logger.info("IP Ranges Renewal Timer initialized"); @@ -107,6 +109,8 @@ const internalIpRanges = { return true; }); + internalIpRanges.last_ip_ranges = clean_ip_ranges; + return internalIpRanges.generateConfig(clean_ip_ranges).then(() => { if (internalIpRanges.iteration_count) { // Reload nginx @@ -129,7 +133,17 @@ const internalIpRanges = { * @param {Array} ip_ranges * @returns {Promise} */ - generateConfig: (ip_ranges) => { + generateConfig: async (ip_ranges) => { + let realIpHeader = "X-Real-IP"; + try { + const setting = await settingModel.query().where("id", "real-ip-header").first(); + if (setting?.value) { + realIpHeader = setting.value === "custom" && setting.meta?.custom + ? setting.meta.custom + : setting.value; + } + } catch (_) {} + const renderEngine = utils.getRenderEngine(); return new Promise((resolve, reject) => { let template = null; @@ -142,7 +156,7 @@ const internalIpRanges = { } renderEngine - .parseAndRender(template, { ip_ranges: ip_ranges }) + .parseAndRender(template, { ip_ranges: ip_ranges, real_ip_header: realIpHeader }) .then((config_text) => { fs.writeFileSync(filename, config_text, { encoding: "utf8" }); resolve(true); @@ -153,6 +167,16 @@ const internalIpRanges = { }); }); }, + + /** + * Regenerate ip_ranges.conf with cached ranges and reload nginx. + * Called when the real-ip-header setting changes. + * @returns {Promise} + */ + regenerate: async () => { + await internalIpRanges.generateConfig(internalIpRanges.last_ip_ranges); + await internalNginx.reload(); + }, }; export default internalIpRanges; diff --git a/backend/internal/nginx.js b/backend/internal/nginx.js index fe84607f96..5ece76e14a 100644 --- a/backend/internal/nginx.js +++ b/backend/internal/nginx.js @@ -162,6 +162,8 @@ const internalNginx = { { hsts_subdomains: host.hsts_subdomains }, { access_list: host.access_list }, { certificate: host.certificate }, + { upstream_host_id: 0 }, + { upstream_host_forward_scheme: "http" }, host.locations[i], ); diff --git a/backend/internal/proxy-host.js b/backend/internal/proxy-host.js index 34475c99f7..7d8b570a90 100644 --- a/backend/internal/proxy-host.js +++ b/backend/internal/proxy-host.js @@ -80,7 +80,7 @@ const internalProxyHost = { // re-fetch with cert return internalProxyHost.get(access, { id: row.id, - expand: ["certificate", "owner", "access_list.[clients,items]"], + expand: ["certificate", "owner", "access_list.[clients,items]", "upstream_host.[servers]"], }); }) .then((row) => { @@ -206,7 +206,7 @@ const internalProxyHost = { return internalProxyHost .get(access, { id: thisData.id, - expand: ["owner", "certificate", "access_list.[clients,items]"], + expand: ["owner", "certificate", "access_list.[clients,items]", "upstream_host.[servers]"], }) .then((row) => { if (!row.enabled) { @@ -323,7 +323,7 @@ const internalProxyHost = { .then(() => { return internalProxyHost.get(access, { id: data.id, - expand: ["certificate", "owner", "access_list"], + expand: ["certificate", "owner", "access_list", "upstream_host.[servers]"], }); }) .then((row) => { diff --git a/backend/internal/report.js b/backend/internal/report.js index 59f13fe695..e9fbfd34a1 100644 --- a/backend/internal/report.js +++ b/backend/internal/report.js @@ -2,6 +2,7 @@ import internalDeadHost from "./dead-host.js"; import internalProxyHost from "./proxy-host.js"; import internalRedirectionHost from "./redirection-host.js"; import internalStream from "./stream.js"; +import internalUpstreamHost from "./upstream-host.js"; const internalReport = { /** @@ -19,6 +20,7 @@ const internalReport = { internalRedirectionHost.getCount(userId, access_data.permission_visibility), internalStream.getCount(userId, access_data.permission_visibility), internalDeadHost.getCount(userId, access_data.permission_visibility), + internalUpstreamHost.getCount(userId, access_data.permission_visibility), ]; return Promise.all(promises); @@ -29,6 +31,7 @@ const internalReport = { redirection: counts.shift(), stream: counts.shift(), dead: counts.shift(), + upstream: counts.shift(), }; }); }, diff --git a/backend/internal/setting.js b/backend/internal/setting.js index f8fc711454..63a1ceca99 100644 --- a/backend/internal/setting.js +++ b/backend/internal/setting.js @@ -1,6 +1,7 @@ import fs from "node:fs"; import errs from "../lib/error.js"; import settingModel from "../models/setting.js"; +import internalIpRanges from "./ip_ranges.js"; import internalNginx from "./nginx.js"; const internalSetting = { @@ -32,6 +33,10 @@ const internalSetting = { }); }) .then((row) => { + if (row.id === "real-ip-header") { + return internalIpRanges.regenerate().then(() => row); + } + if (row.id === "default-site") { // write the html if we need to if (row.value === "html") { diff --git a/backend/internal/upstream-host.js b/backend/internal/upstream-host.js new file mode 100644 index 0000000000..9920d5026e --- /dev/null +++ b/backend/internal/upstream-host.js @@ -0,0 +1,320 @@ +import _ from "lodash"; +import errs from "../lib/error.js"; +import utils from "../lib/utils.js"; +import { upstreamHosts as logger } from "../logger.js"; +import proxyHostModel from "../models/proxy_host.js"; +import upstreamHostModel from "../models/upstream_host.js"; +import upstreamHostServerModel from "../models/upstream_host_server.js"; +import internalAuditLog from "./audit-log.js"; +import internalNginx from "./nginx.js"; + +const omissions = () => { + return ["is_deleted"]; +}; + +const internalUpstreamHost = { + /** + * @param {Access} access + * @param {Object} data + * @returns {Promise} + */ + create: async (access, data) => { + await access.can("upstream_hosts:create", data); + const row = await upstreamHostModel + .query() + .insertAndFetch({ + name: data.name, + forward_scheme: data.forward_scheme || "http", + method: data.method || "round_robin", + owner_user_id: access.token.getUserId(1), + }) + .then(utils.omitRow(omissions())); + + data.id = row.id; + + // Insert servers + const promises = []; + if (data.servers && data.servers.length) { + data.servers.map((server) => { + promises.push( + upstreamHostServerModel.query().insert({ + upstream_host_id: row.id, + host: server.host, + port: server.port, + weight: server.weight || null, + }), + ); + return true; + }); + } + + await Promise.all(promises); + + // re-fetch with expansions + const freshRow = await internalUpstreamHost.get(access, { + id: data.id, + expand: ["owner", "servers"], + }); + + // Configure nginx + await internalNginx.configure(upstreamHostModel, "upstream_host", freshRow); + + // Add to audit log + await internalAuditLog.add(access, { + action: "created", + object_type: "upstream-host", + object_id: freshRow.id, + meta: data, + }); + + return freshRow; + }, + + /** + * @param {Access} access + * @param {Object} data + * @param {Integer} data.id + * @return {Promise} + */ + update: async (access, data) => { + await access.can("upstream_hosts:update", data.id); + const row = await internalUpstreamHost.get(access, { id: data.id }); + if (row.id !== data.id) { + throw new errs.InternalValidationError( + `Upstream Host could not be updated, IDs do not match: ${row.id} !== ${data.id}`, + ); + } + + // Patch fields + const patchData = {}; + if (typeof data.name !== "undefined") patchData.name = data.name; + if (typeof data.forward_scheme !== "undefined") patchData.forward_scheme = data.forward_scheme; + if (typeof data.method !== "undefined") patchData.method = data.method; + + if (Object.keys(patchData).length) { + await upstreamHostModel.query().where({ id: data.id }).patch(patchData); + } + + // Handle servers: delete + re-insert + if (typeof data.servers !== "undefined" && data.servers) { + await upstreamHostServerModel.query().delete().where("upstream_host_id", data.id); + + const serverPromises = []; + data.servers.map((server) => { + if (server.host) { + serverPromises.push( + upstreamHostServerModel.query().insert({ + upstream_host_id: data.id, + host: server.host, + port: server.port, + weight: server.weight || null, + }), + ); + } + return true; + }); + + if (serverPromises.length) { + await Promise.all(serverPromises); + } + } + + // Add to audit log + await internalAuditLog.add(access, { + action: "updated", + object_type: "upstream-host", + object_id: data.id, + meta: data, + }); + + // re-fetch with expansions + const freshRow = await internalUpstreamHost.get(access, { + id: data.id, + expand: ["owner", "servers", "proxy_hosts.[certificate,access_list.[clients,items],upstream_host.[servers]]"], + }); + + // Regenerate upstream config + await internalNginx.configure(upstreamHostModel, "upstream_host", freshRow); + + // Bulk regenerate all referencing proxy host configs + if (Number.parseInt(freshRow.proxy_host_count, 10)) { + await internalNginx.bulkGenerateConfigs("proxy_host", freshRow.proxy_hosts); + } + await internalNginx.reload(); + + return freshRow; + }, + + /** + * @param {Access} access + * @param {Object} data + * @param {Integer} data.id + * @param {Array} [data.expand] + * @param {Array} [data.omit] + * @return {Promise} + */ + get: async (access, data) => { + const thisData = data || {}; + const accessData = await access.can("upstream_hosts:get", thisData.id); + + const query = upstreamHostModel + .query() + .select("upstream_host.*", upstreamHostModel.raw("COUNT(proxy_host.id) as proxy_host_count")) + .leftJoin("proxy_host", function () { + this.on("proxy_host.upstream_host_id", "=", "upstream_host.id").andOn( + "proxy_host.is_deleted", + "=", + 0, + ); + }) + .where("upstream_host.is_deleted", 0) + .andWhere("upstream_host.id", thisData.id) + .groupBy("upstream_host.id") + .allowGraph("[owner,servers,proxy_hosts.[certificate,access_list.[clients,items],upstream_host.[servers]]]") + .first(); + + if (accessData.permission_visibility !== "all") { + query.andWhere("upstream_host.owner_user_id", access.token.getUserId(1)); + } + + if (typeof thisData.expand !== "undefined" && thisData.expand !== null) { + query.withGraphFetched(`[${thisData.expand.join(", ")}]`); + } + + const row = await query.then(utils.omitRow(omissions())); + + if (!row || !row.id) { + throw new errs.ItemNotFoundError(thisData.id); + } + + // Custom omissions + if (typeof data.omit !== "undefined" && data.omit !== null) { + return _.omit(row, data.omit); + } + + return row; + }, + + /** + * @param {Access} access + * @param {Object} data + * @param {Integer} data.id + * @returns {Promise} + */ + delete: async (access, data) => { + await access.can("upstream_hosts:delete", data.id); + const row = await internalUpstreamHost.get(access, { + id: data.id, + expand: ["proxy_hosts.[certificate,access_list.[clients,items]]", "servers"], + }); + + if (!row || !row.id) { + throw new errs.ItemNotFoundError(data.id); + } + + // 1. soft-delete the upstream host + await upstreamHostModel + .query() + .where("id", row.id) + .patch({ + is_deleted: 1, + }); + + // 2. clear upstream_host_id on referencing proxy hosts + if (row.proxy_hosts) { + await proxyHostModel + .query() + .where("upstream_host_id", "=", row.id) + .patch({ upstream_host_id: 0 }); + + // update in-memory so config regen uses 0 + row.proxy_hosts.map((_val, idx) => { + row.proxy_hosts[idx].upstream_host_id = 0; + return true; + }); + + await internalNginx.bulkGenerateConfigs("proxy_host", row.proxy_hosts); + } + + // 3. delete upstream config file + await internalNginx.deleteConfig("upstream_host", row, true); + await internalNginx.reload(); + + // 4. audit log + await internalAuditLog.add(access, { + action: "deleted", + object_type: "upstream-host", + object_id: row.id, + meta: _.omit(row, ["is_deleted", "proxy_hosts"]), + }); + + return true; + }, + + /** + * All upstream hosts + * + * @param {Access} access + * @param {Array} [expand] + * @param {String} [searchQuery] + * @returns {Promise} + */ + getAll: async (access, expand, searchQuery) => { + const accessData = await access.can("upstream_hosts:list"); + + const query = upstreamHostModel + .query() + .select("upstream_host.*", upstreamHostModel.raw("COUNT(proxy_host.id) as proxy_host_count")) + .leftJoin("proxy_host", function () { + this.on("proxy_host.upstream_host_id", "=", "upstream_host.id").andOn( + "proxy_host.is_deleted", + "=", + 0, + ); + }) + .where("upstream_host.is_deleted", 0) + .groupBy("upstream_host.id") + .allowGraph("[owner,servers]") + .orderBy("upstream_host.name", "ASC"); + + if (accessData.permission_visibility !== "all") { + query.andWhere("upstream_host.owner_user_id", access.token.getUserId(1)); + } + + // Query is used for searching + if (typeof searchQuery === "string") { + query.where(function () { + this.where("upstream_host.name", "like", `%${searchQuery}%`); + }); + } + + if (typeof expand !== "undefined" && expand !== null) { + query.withGraphFetched(`[${expand.join(", ")}]`); + } + + return query.then(utils.omitRows(omissions())); + }, + + /** + * Count is used in reports + * + * @param {Integer} userId + * @param {String} visibility + * @returns {Promise} + */ + getCount: async (userId, visibility) => { + const query = upstreamHostModel + .query() + .count("id as count") + .where("is_deleted", 0); + + if (visibility !== "all") { + query.andWhere("owner_user_id", userId); + } + + const row = await query.first(); + return Number.parseInt(row.count, 10); + }, +}; + +export default internalUpstreamHost; diff --git a/backend/internal/user.js b/backend/internal/user.js index d13931d54a..b8de0ff895 100644 --- a/backend/internal/user.js +++ b/backend/internal/user.js @@ -59,6 +59,7 @@ const internalUser = { streams: "manage", access_lists: "manage", certificates: "manage", + upstream_hosts: "manage", }); user = await internalUser.get(access, { id: user.id, expand: ["permissions"] }); diff --git a/backend/lib/access.js b/backend/lib/access.js index a4dec5c4dd..8fafa35c49 100644 --- a/backend/lib/access.js +++ b/backend/lib/access.js @@ -241,6 +241,7 @@ export default function (tokenString) { permission_streams: permissions.streams, permission_access_lists: permissions.access_lists, permission_certificates: permissions.certificates, + permission_upstream_hosts: permissions.upstream_hosts, }, }; diff --git a/backend/lib/access/upstream_hosts-create.json b/backend/lib/access/upstream_hosts-create.json new file mode 100644 index 0000000000..1c153758c9 --- /dev/null +++ b/backend/lib/access/upstream_hosts-create.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_upstream_hosts", "roles"], + "properties": { + "permission_upstream_hosts": { + "$ref": "perms#/definitions/manage" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/backend/lib/access/upstream_hosts-delete.json b/backend/lib/access/upstream_hosts-delete.json new file mode 100644 index 0000000000..1c153758c9 --- /dev/null +++ b/backend/lib/access/upstream_hosts-delete.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_upstream_hosts", "roles"], + "properties": { + "permission_upstream_hosts": { + "$ref": "perms#/definitions/manage" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/backend/lib/access/upstream_hosts-get.json b/backend/lib/access/upstream_hosts-get.json new file mode 100644 index 0000000000..a9378778e6 --- /dev/null +++ b/backend/lib/access/upstream_hosts-get.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_upstream_hosts", "roles"], + "properties": { + "permission_upstream_hosts": { + "$ref": "perms#/definitions/view" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/backend/lib/access/upstream_hosts-list.json b/backend/lib/access/upstream_hosts-list.json new file mode 100644 index 0000000000..a9378778e6 --- /dev/null +++ b/backend/lib/access/upstream_hosts-list.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_upstream_hosts", "roles"], + "properties": { + "permission_upstream_hosts": { + "$ref": "perms#/definitions/view" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/backend/lib/access/upstream_hosts-update.json b/backend/lib/access/upstream_hosts-update.json new file mode 100644 index 0000000000..1c153758c9 --- /dev/null +++ b/backend/lib/access/upstream_hosts-update.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_upstream_hosts", "roles"], + "properties": { + "permission_upstream_hosts": { + "$ref": "perms#/definitions/manage" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/backend/logger.js b/backend/logger.js index 2b60dbff7b..889c9b3a10 100644 --- a/backend/logger.js +++ b/backend/logger.js @@ -16,6 +16,7 @@ const importer = new signale.Signale({ scope: "Importer ", ...opts }); const setup = new signale.Signale({ scope: "Setup ", ...opts }); const ipRanges = new signale.Signale({ scope: "IP Ranges", ...opts }); const remoteVersion = new signale.Signale({ scope: "Remote Version", ...opts }); +const upstreamHosts = new signale.Signale({ scope: "Upstream ", ...opts }); const debug = (logger, ...args) => { if (isDebugMode()) { @@ -23,4 +24,4 @@ const debug = (logger, ...args) => { } }; -export { debug, global, migrate, express, access, nginx, ssl, certbot, importer, setup, ipRanges, remoteVersion }; +export { debug, global, migrate, express, access, nginx, ssl, certbot, importer, setup, ipRanges, remoteVersion, upstreamHosts }; diff --git a/backend/migrations/20260315120000_upstream_hosts.js b/backend/migrations/20260315120000_upstream_hosts.js new file mode 100644 index 0000000000..6d2b15aece --- /dev/null +++ b/backend/migrations/20260315120000_upstream_hosts.js @@ -0,0 +1,96 @@ +import { migrate as logger } from "../logger.js"; + +const migrateName = "upstream_hosts"; + +/** + * Migrate + * + * @see http://knexjs.org/#Schema + * + * @param {Object} knex + * @returns {Promise} + */ +const up = (knex) => { + logger.info(`[${migrateName}] Migrating Up...`); + + return knex.schema + .createTable("upstream_host", (table) => { + table.increments().primary(); + table.dateTime("created_on").notNull(); + table.dateTime("modified_on").notNull(); + table.integer("owner_user_id").notNull().unsigned(); + table.integer("is_deleted").notNull().unsigned().defaultTo(0); + table.string("name", 255).notNull(); + table.string("forward_scheme", 10).notNull().defaultTo("http"); + table.string("method", 32).notNull().defaultTo("round_robin"); + table.json("meta").notNull(); + }) + .then(() => { + logger.info(`[${migrateName}] upstream_host Table created`); + + return knex.schema.createTable("upstream_host_server", (table) => { + table.increments().primary(); + table.dateTime("created_on").notNull(); + table.dateTime("modified_on").notNull(); + table.integer("upstream_host_id").notNull().unsigned(); + table.string("host", 255).notNull(); + table.integer("port").notNull().unsigned(); + table.integer("weight").nullable().unsigned(); + table.json("meta").notNull(); + }); + }) + .then(() => { + logger.info(`[${migrateName}] upstream_host_server Table created`); + + return knex.schema.table("proxy_host", (proxy_host) => { + proxy_host.integer("upstream_host_id").notNull().unsigned().defaultTo(0); + }); + }) + .then(() => { + logger.info(`[${migrateName}] proxy_host Table altered`); + + return knex.schema.table("user_permission", (user_permission) => { + user_permission.string("upstream_hosts").notNull().defaultTo("manage"); + }); + }) + .then(() => { + logger.info(`[${migrateName}] user_permission Table altered`); + }); +}; + +/** + * Undo Migrate + * + * @param {Object} knex + * @returns {Promise} + */ +const down = (knex) => { + logger.info(`[${migrateName}] Migrating Down...`); + + return knex.schema + .dropTable("upstream_host_server") + .then(() => { + logger.info(`[${migrateName}] upstream_host_server Table dropped`); + + return knex.schema.dropTable("upstream_host"); + }) + .then(() => { + logger.info(`[${migrateName}] upstream_host Table dropped`); + + return knex.schema.table("proxy_host", (proxy_host) => { + proxy_host.dropColumn("upstream_host_id"); + }); + }) + .then(() => { + logger.info(`[${migrateName}] proxy_host Table reverted`); + + return knex.schema.table("user_permission", (user_permission) => { + user_permission.dropColumn("upstream_hosts"); + }); + }) + .then(() => { + logger.info(`[${migrateName}] user_permission Table reverted`); + }); +}; + +export { up, down }; diff --git a/backend/models/proxy_host.js b/backend/models/proxy_host.js index acb8da9358..1bc3a44c74 100644 --- a/backend/models/proxy_host.js +++ b/backend/models/proxy_host.js @@ -8,6 +8,7 @@ import AccessList from "./access_list.js"; import Certificate from "./certificate.js"; import now from "./now_helper.js"; import User from "./user.js"; +import UpstreamHost from "./upstream_host.js"; Model.knex(db()); @@ -74,11 +75,11 @@ class ProxyHost extends Model { } static get defaultAllowGraph() { - return "[owner,access_list.[clients,items],certificate]"; + return "[owner,access_list.[clients,items],certificate,upstream_host.[servers]]"; } static get defaultExpand() { - return ["owner", "certificate", "access_list.[clients,items]"]; + return ["owner", "certificate", "access_list.[clients,items]", "upstream_host.[servers]"]; } static get defaultOrder() { @@ -120,6 +121,17 @@ class ProxyHost extends Model { qb.where("certificate.is_deleted", 0); }, }, + upstream_host: { + relation: Model.HasOneRelation, + modelClass: UpstreamHost, + join: { + from: "proxy_host.upstream_host_id", + to: "upstream_host.id", + }, + modify: (qb) => { + qb.where("upstream_host.is_deleted", 0); + }, + }, }; } } diff --git a/backend/models/upstream_host.js b/backend/models/upstream_host.js new file mode 100644 index 0000000000..0a074cc999 --- /dev/null +++ b/backend/models/upstream_host.js @@ -0,0 +1,99 @@ +// Objection Docs: +// http://vincit.github.io/objection.js/ + +import { Model } from "objection"; +import db from "../db.js"; +import { convertBoolFieldsToInt, convertIntFieldsToBool } from "../lib/helpers.js"; +import now from "./now_helper.js"; +import UpstreamHostServer from "./upstream_host_server.js"; +import ProxyHostModel from "./proxy_host.js"; +import User from "./user.js"; + +Model.knex(db()); + +const boolFields = ["is_deleted"]; + +class UpstreamHost extends Model { + $beforeInsert() { + this.created_on = now(); + this.modified_on = now(); + 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 "UpstreamHost"; + } + + static get tableName() { + return "upstream_host"; + } + + static get jsonAttributes() { + return ["meta"]; + } + + static get defaultAllowGraph() { + return "[owner,servers,proxy_hosts]"; + } + + static get defaultExpand() { + return ["owner", "servers"]; + } + + static get defaultOrder() { + return ["name", "ASC"]; + } + + static get relationMappings() { + return { + owner: { + relation: Model.HasOneRelation, + modelClass: User, + join: { + from: "upstream_host.owner_user_id", + to: "user.id", + }, + modify: (qb) => { + qb.where("user.is_deleted", 0); + }, + }, + servers: { + relation: Model.HasManyRelation, + modelClass: UpstreamHostServer, + join: { + from: "upstream_host.id", + to: "upstream_host_server.upstream_host_id", + }, + }, + proxy_hosts: { + relation: Model.HasManyRelation, + modelClass: ProxyHostModel, + join: { + from: "upstream_host.id", + to: "proxy_host.upstream_host_id", + }, + modify: (qb) => { + qb.where("proxy_host.is_deleted", 0); + }, + }, + }; + } +} + +export default UpstreamHost; diff --git a/backend/models/upstream_host_server.js b/backend/models/upstream_host_server.js new file mode 100644 index 0000000000..5a90bf1c45 --- /dev/null +++ b/backend/models/upstream_host_server.js @@ -0,0 +1,53 @@ +// Objection Docs: +// http://vincit.github.io/objection.js/ + +import { Model } from "objection"; +import db from "../db.js"; +import upstreamHostModel from "./upstream_host.js"; +import now from "./now_helper.js"; + +Model.knex(db()); + +class UpstreamHostServer extends Model { + $beforeInsert() { + this.created_on = now(); + this.modified_on = now(); + if (typeof this.meta === "undefined") { + this.meta = {}; + } + } + + $beforeUpdate() { + this.modified_on = now(); + } + + static get name() { + return "UpstreamHostServer"; + } + + static get tableName() { + return "upstream_host_server"; + } + + static get jsonAttributes() { + return ["meta"]; + } + + static get relationMappings() { + return { + upstream_host: { + relation: Model.HasOneRelation, + modelClass: upstreamHostModel, + join: { + from: "upstream_host_server.upstream_host_id", + to: "upstream_host.id", + }, + modify: (qb) => { + qb.where("upstream_host.is_deleted", 0); + }, + }, + }; + } +} + +export default UpstreamHostServer; diff --git a/backend/routes/main.js b/backend/routes/main.js index 94682cfba4..db878e9960 100644 --- a/backend/routes/main.js +++ b/backend/routes/main.js @@ -9,6 +9,7 @@ import deadHostsRoutes from "./nginx/dead_hosts.js"; import proxyHostsRoutes from "./nginx/proxy_hosts.js"; import redirectionHostsRoutes from "./nginx/redirection_hosts.js"; import streamsRoutes from "./nginx/streams.js"; +import upstreamHostsRoutes from "./nginx/upstream_hosts.js"; import reportsRoutes from "./reports.js"; import schemaRoutes from "./schema.js"; import settingsRoutes from "./settings.js"; @@ -53,6 +54,7 @@ router.use("/nginx/redirection-hosts", redirectionHostsRoutes); router.use("/nginx/dead-hosts", deadHostsRoutes); router.use("/nginx/streams", streamsRoutes); router.use("/nginx/access-lists", accessListsRoutes); +router.use("/nginx/upstream-hosts", upstreamHostsRoutes); router.use("/nginx/certificates", certificatesHostsRoutes); /** diff --git a/backend/routes/nginx/upstream_hosts.js b/backend/routes/nginx/upstream_hosts.js new file mode 100644 index 0000000000..19e69cd6b5 --- /dev/null +++ b/backend/routes/nginx/upstream_hosts.js @@ -0,0 +1,145 @@ +import express from "express"; +import internalUpstreamHost from "../../internal/upstream-host.js"; +import jwtdecode from "../../lib/express/jwt-decode.js"; +import apiValidator from "../../lib/validator/api.js"; +import validator from "../../lib/validator/index.js"; +import { debug, express as logger } from "../../logger.js"; +import { getValidationSchema } from "../../schema/index.js"; + +const router = express.Router({ + caseSensitive: true, + strict: true, + mergeParams: true, +}); + +/** + * /api/nginx/upstream-hosts + */ +router + .route("/") + .options((_, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + + /** + * GET /api/nginx/upstream-hosts + */ + .get(async (req, res, next) => { + try { + const data = await validator( + { + additionalProperties: false, + properties: { + expand: { + $ref: "common#/properties/expand", + }, + query: { + $ref: "common#/properties/query", + }, + }, + }, + { + expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null, + query: typeof req.query.query === "string" ? req.query.query : null, + }, + ); + const rows = await internalUpstreamHost.getAll(res.locals.access, data.expand, data.query); + res.status(200).send(rows); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }) + + /** + * POST /api/nginx/upstream-hosts + */ + .post(async (req, res, next) => { + try { + const payload = await apiValidator(getValidationSchema("/nginx/upstream-hosts", "post"), req.body); + const result = await internalUpstreamHost.create(res.locals.access, payload); + res.status(201).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +/** + * Specific upstream host + * + * /api/nginx/upstream-hosts/123 + */ +router + .route("/:upstream_id") + .options((_, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + + /** + * GET /api/nginx/upstream-hosts/123 + */ + .get(async (req, res, next) => { + try { + const data = await validator( + { + required: ["upstream_id"], + additionalProperties: false, + properties: { + upstream_id: { + $ref: "common#/properties/id", + }, + expand: { + $ref: "common#/properties/expand", + }, + }, + }, + { + upstream_id: req.params.upstream_id, + expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null, + }, + ); + const row = await internalUpstreamHost.get(res.locals.access, { + id: Number.parseInt(data.upstream_id, 10), + expand: data.expand, + }); + res.status(200).send(row); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }) + + /** + * PUT /api/nginx/upstream-hosts/123 + */ + .put(async (req, res, next) => { + try { + const payload = await apiValidator(getValidationSchema("/nginx/upstream-hosts/{upstreamID}", "put"), req.body); + payload.id = Number.parseInt(req.params.upstream_id, 10); + const result = await internalUpstreamHost.update(res.locals.access, payload); + res.status(200).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }) + + /** + * DELETE /api/nginx/upstream-hosts/123 + */ + .delete(async (req, res, next) => { + try { + const result = await internalUpstreamHost.delete(res.locals.access, { + id: Number.parseInt(req.params.upstream_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/schema/components/proxy-host-object.json b/backend/schema/components/proxy-host-object.json index 3ac6462136..e9b3f0663d 100644 --- a/backend/schema/components/proxy-host-object.json +++ b/backend/schema/components/proxy-host-object.json @@ -124,6 +124,14 @@ }, "advanced_config": { "type": "string" + }, + "upstream_host_id": { + "type": "integer", + "minimum": 0 + }, + "upstream_host_forward_scheme": { + "type": "string", + "enum": ["http", "https"] } } }, @@ -142,6 +150,12 @@ "hsts_subdomains": { "$ref": "../common.json#/properties/hsts_subdomains" }, + "upstream_host_id": { + "description": "Upstream Host ID", + "type": "integer", + "minimum": 0, + "example": 0 + }, "trust_forwarded_proto":{ "type": "boolean", "description": "Trust the forwarded headers", diff --git a/backend/schema/components/upstream-host-list.json b/backend/schema/components/upstream-host-list.json new file mode 100644 index 0000000000..dbc66cebff --- /dev/null +++ b/backend/schema/components/upstream-host-list.json @@ -0,0 +1,7 @@ +{ + "type": "array", + "description": "Upstream Hosts list", + "items": { + "$ref": "./upstream-host-object.json" + } +} diff --git a/backend/schema/components/upstream-host-object.json b/backend/schema/components/upstream-host-object.json new file mode 100644 index 0000000000..84ea1ccf30 --- /dev/null +++ b/backend/schema/components/upstream-host-object.json @@ -0,0 +1,41 @@ +{ + "type": "object", + "description": "Upstream Host object", + "required": ["id", "created_on", "modified_on", "owner_user_id", "name", "forward_scheme", "method", "meta"], + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "minimum": 1 + }, + "created_on": { + "type": "string" + }, + "modified_on": { + "type": "string" + }, + "owner_user_id": { + "type": "integer", + "minimum": 1 + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "forward_scheme": { + "type": "string", + "enum": ["http", "https"] + }, + "method": { + "type": "string", + "enum": ["round_robin", "least_conn", "ip_hash"] + }, + "meta": { + "type": "object" + }, + "proxy_host_count": { + "type": "integer" + } + } +} diff --git a/backend/schema/paths/nginx/proxy-hosts/hostID/put.json b/backend/schema/paths/nginx/proxy-hosts/hostID/put.json index fc3198456b..969f8850c2 100644 --- a/backend/schema/paths/nginx/proxy-hosts/hostID/put.json +++ b/backend/schema/paths/nginx/proxy-hosts/hostID/put.json @@ -83,6 +83,9 @@ "meta": { "$ref": "../../../../components/proxy-host-object.json#/properties/meta" }, + "upstream_host_id": { + "$ref": "../../../../components/proxy-host-object.json#/properties/upstream_host_id" + }, "locations": { "$ref": "../../../../components/proxy-host-object.json#/properties/locations" } diff --git a/backend/schema/paths/nginx/proxy-hosts/post.json b/backend/schema/paths/nginx/proxy-hosts/post.json index 28ddad8fc2..c8c76677f8 100644 --- a/backend/schema/paths/nginx/proxy-hosts/post.json +++ b/backend/schema/paths/nginx/proxy-hosts/post.json @@ -75,6 +75,9 @@ "meta": { "$ref": "../../../components/proxy-host-object.json#/properties/meta" }, + "upstream_host_id": { + "$ref": "../../../components/proxy-host-object.json#/properties/upstream_host_id" + }, "locations": { "$ref": "../../../components/proxy-host-object.json#/properties/locations" } diff --git a/backend/schema/paths/nginx/upstream-hosts/get.json b/backend/schema/paths/nginx/upstream-hosts/get.json new file mode 100644 index 0000000000..8b74310d56 --- /dev/null +++ b/backend/schema/paths/nginx/upstream-hosts/get.json @@ -0,0 +1,26 @@ +{ + "operationId": "getUpstreamHosts", + "summary": "Get all upstream hosts", + "tags": ["upstream-hosts"], + "security": [{ "bearerAuth": [] }], + "parameters": [ + { + "in": "query", + "name": "expand", + "description": "Expansions", + "schema": { "type": "string", "enum": ["owner", "servers"] } + } + ], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "../../../components/upstream-host-list.json" + } + } + } + } + } +} diff --git a/backend/schema/paths/nginx/upstream-hosts/post.json b/backend/schema/paths/nginx/upstream-hosts/post.json new file mode 100644 index 0000000000..bd0bc68ca9 --- /dev/null +++ b/backend/schema/paths/nginx/upstream-hosts/post.json @@ -0,0 +1,74 @@ +{ + "operationId": "createUpstreamHost", + "summary": "Create an Upstream Host", + "tags": ["upstream-hosts"], + "security": [{ "bearerAuth": [] }], + "requestBody": { + "description": "Upstream Host Payload", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["name", "servers"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "forward_scheme": { + "type": "string", + "enum": ["http", "https"] + }, + "method": { + "type": "string", + "enum": ["round_robin", "least_conn", "ip_hash"] + }, + "servers": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["host", "port"], + "properties": { + "host": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "weight": { + "type": "integer", + "minimum": 1, + "maximum": 100 + } + } + } + }, + "meta": { + "type": "object" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "201 response", + "content": { + "application/json": { + "schema": { + "$ref": "../../../components/upstream-host-object.json" + } + } + } + } + } +} diff --git a/backend/schema/paths/nginx/upstream-hosts/upstreamID/delete.json b/backend/schema/paths/nginx/upstream-hosts/upstreamID/delete.json new file mode 100644 index 0000000000..a3083489e6 --- /dev/null +++ b/backend/schema/paths/nginx/upstream-hosts/upstreamID/delete.json @@ -0,0 +1,27 @@ +{ + "operationId": "deleteUpstreamHost", + "summary": "Delete an Upstream Host", + "tags": ["upstream-hosts"], + "security": [{ "bearerAuth": [] }], + "parameters": [ + { + "in": "path", + "name": "upstreamID", + "schema": { "type": "integer", "minimum": 1 }, + "required": true, + "example": 1 + } + ], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + } + } +} diff --git a/backend/schema/paths/nginx/upstream-hosts/upstreamID/get.json b/backend/schema/paths/nginx/upstream-hosts/upstreamID/get.json new file mode 100644 index 0000000000..d318f59c23 --- /dev/null +++ b/backend/schema/paths/nginx/upstream-hosts/upstreamID/get.json @@ -0,0 +1,33 @@ +{ + "operationId": "getUpstreamHost", + "summary": "Get an Upstream Host", + "tags": ["upstream-hosts"], + "security": [{ "bearerAuth": [] }], + "parameters": [ + { + "in": "path", + "name": "upstreamID", + "schema": { "type": "integer", "minimum": 1 }, + "required": true, + "example": 1 + }, + { + "in": "query", + "name": "expand", + "description": "Expansions", + "schema": { "type": "string", "enum": ["owner", "servers"] } + } + ], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "../../../../components/upstream-host-object.json" + } + } + } + } + } +} diff --git a/backend/schema/paths/nginx/upstream-hosts/upstreamID/put.json b/backend/schema/paths/nginx/upstream-hosts/upstreamID/put.json new file mode 100644 index 0000000000..f9efc0e831 --- /dev/null +++ b/backend/schema/paths/nginx/upstream-hosts/upstreamID/put.json @@ -0,0 +1,83 @@ +{ + "operationId": "updateUpstreamHost", + "summary": "Update an Upstream Host", + "tags": ["upstream-hosts"], + "security": [{ "bearerAuth": [] }], + "parameters": [ + { + "in": "path", + "name": "upstreamID", + "schema": { "type": "integer", "minimum": 1 }, + "required": true, + "example": 1 + } + ], + "requestBody": { + "description": "Upstream Host Payload", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "minProperties": 1, + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "forward_scheme": { + "type": "string", + "enum": ["http", "https"] + }, + "method": { + "type": "string", + "enum": ["round_robin", "least_conn", "ip_hash"] + }, + "servers": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["host", "port"], + "properties": { + "host": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "weight": { + "type": "integer", + "minimum": 1, + "maximum": 100 + } + } + } + }, + "meta": { + "type": "object" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "../../../../components/upstream-host-object.json" + } + } + } + } + } +} diff --git a/backend/schema/paths/settings/settingID/put.json b/backend/schema/paths/settings/settingID/put.json index 050ad44125..0b81f15965 100644 --- a/backend/schema/paths/settings/settingID/put.json +++ b/backend/schema/paths/settings/settingID/put.json @@ -14,7 +14,7 @@ "schema": { "type": "string", "minLength": 1, - "enum": ["default-site"] + "enum": ["default-site", "real-ip-header"] }, "required": true, "description": "Setting ID", @@ -34,7 +34,7 @@ "value": { "type": "string", "minLength": 1, - "enum": ["congratulations", "404", "444", "redirect", "html"], + "enum": ["congratulations", "404", "444", "redirect", "html", "X-Real-IP", "CF-Connecting-IP", "X-Forwarded-For", "custom"], "example": "html" }, "meta": { @@ -46,6 +46,9 @@ }, "html": { "type": "string" + }, + "custom": { + "type": "string" } }, "example": { diff --git a/backend/schema/swagger.json b/backend/schema/swagger.json index 4222f19ddd..dd134f8613 100644 --- a/backend/schema/swagger.json +++ b/backend/schema/swagger.json @@ -48,6 +48,10 @@ "name": "streams", "description": "Endpoints related to Streams" }, + { + "name": "upstream-hosts", + "description": "Endpoints related to Upstream Hosts" + }, { "name": "reports", "description": "Endpoints for viewing reports" @@ -262,6 +266,25 @@ "$ref": "./paths/nginx/streams/streamID/disable/post.json" } }, + "/nginx/upstream-hosts": { + "get": { + "$ref": "./paths/nginx/upstream-hosts/get.json" + }, + "post": { + "$ref": "./paths/nginx/upstream-hosts/post.json" + } + }, + "/nginx/upstream-hosts/{upstreamID}": { + "get": { + "$ref": "./paths/nginx/upstream-hosts/upstreamID/get.json" + }, + "put": { + "$ref": "./paths/nginx/upstream-hosts/upstreamID/put.json" + }, + "delete": { + "$ref": "./paths/nginx/upstream-hosts/upstreamID/delete.json" + } + }, "/reports/hosts": { "get": { "$ref": "./paths/reports/hosts/get.json" diff --git a/backend/scripts/regenerate-config b/backend/scripts/regenerate-config index 00f8411310..2c84100819 100755 --- a/backend/scripts/regenerate-config +++ b/backend/scripts/regenerate-config @@ -60,17 +60,17 @@ const processItems = async (model, type) => { for (const row of rows) { if (!DRY_RUN) { logIt(`[${type}] Regenerating config #${row.id}: ${row.domain_names ? row.domain_names.join(", ") : 'port ' + row.incoming_port}`); - await internalNginx.configure(proxyHostModel, "proxy_host", row); + await internalNginx.configure(model, type, row); } else { logIt(`[${type}] Skipping generation of config #${row.id}: ${row.domain_names ? row.domain_names.join(", ") : 'port ' + row.incoming_port}`); } } }; -await processItems(proxyHostModel, "Proxy Host"); -await processItems(redirectionHostModel, "Redirection Host"); -await processItems(deadHostModel, "404 Host"); -await processItems(streamModel, "Stream"); +await processItems(proxyHostModel, "proxy_host"); +await processItems(redirectionHostModel, "redirection_host"); +await processItems(deadHostModel, "dead_host"); +await processItems(streamModel, "stream"); logIt("Completed", "success"); process.exit(0); diff --git a/backend/setup.js b/backend/setup.js index 84f42793ea..6062ce25cd 100644 --- a/backend/setup.js +++ b/backend/setup.js @@ -1,9 +1,16 @@ +import fs from "node:fs"; import { installPlugins } from "./lib/certbot.js"; import utils from "./lib/utils.js"; +import internalNginx from "./internal/nginx.js"; import { setup as logger } from "./logger.js"; import authModel from "./models/auth.js"; import certificateModel from "./models/certificate.js"; +import deadHostModel from "./models/dead_host.js"; +import proxyHostModel from "./models/proxy_host.js"; +import redirectionHostModel from "./models/redirection_host.js"; import settingModel from "./models/setting.js"; +import streamModel from "./models/stream.js"; +import upstreamHostModel from "./models/upstream_host.js"; import userModel from "./models/user.js"; import userPermissionModel from "./models/user_permission.js"; @@ -66,6 +73,7 @@ const setupDefaultUser = async () => { streams: "manage", access_lists: "manage", certificates: "manage", + upstream_hosts: "manage", }); logger.info("Initial admin setup completed"); } @@ -95,6 +103,18 @@ const setupDefaultSettings = async () => { }); logger.info("Default settings added"); } + + const ipRow = await settingModel.query().select("id").where({ id: "real-ip-header" }).first(); + if (!ipRow?.id) { + await settingModel.query().insert({ + id: "real-ip-header", + name: "Real IP Header", + description: "HTTP header used to determine the real client IP address", + value: "X-Real-IP", + meta: {}, + }); + logger.info("Default real-ip-header setting added"); + } }; /** @@ -141,6 +161,70 @@ const setupCertbotPlugins = async () => { } }; +/** + * Deletes all generated nginx host config files and regenerates them + * from current templates. This ensures configs on disk always match + * the current template version after an upgrade. + * + * @returns {Promise} + */ +const setupNginxConfigs = async () => { + const hostTypes = [ + { model: upstreamHostModel, type: "upstream_host", noEnabledFilter: true }, + { model: proxyHostModel, type: "proxy_host" }, + { model: redirectionHostModel, type: "redirection_host" }, + { model: deadHostModel, type: "dead_host" }, + { model: streamModel, type: "stream" }, + ]; + + // Delete all existing host config files so stale configs don't + // block nginx from starting (e.g. after a template change) + for (const { type } of hostTypes) { + const dir = `/data/nginx/${type}`; + if (fs.existsSync(dir)) { + for (const file of fs.readdirSync(dir)) { + if (file.endsWith(".conf") || file.endsWith(".conf.err")) { + fs.unlinkSync(`${dir}/${file}`); + } + } + } + } + + // Regenerate configs for all enabled hosts + for (const { model, type, noEnabledFilter } of hostTypes) { + const query = model + .query() + .where("is_deleted", 0); + if (!noEnabledFilter) { + query.andWhere("enabled", 1); + } + const rows = await query + .groupBy("id") + .allowGraph(model.defaultAllowGraph) + .withGraphFetched(`[${model.defaultExpand.join(", ")}]`) + .orderBy(...model.defaultOrder); + + for (const row of rows) { + try { + await internalNginx.generateConfig(type, row); + } catch (err) { + logger.warn(`Failed to generate ${type} config #${row.id}: ${err.message}`); + } + } + + if (rows.length) { + logger.info(`Regenerated ${rows.length} ${type} config(s)`); + } + } + + // Reload nginx to pick up the fresh configs + try { + await internalNginx.reload(); + } catch (err) { + logger.warn(`Nginx reload after config regeneration failed: ${err.message}`); + } +}; + /** * Starts a timer to call run the logrotation binary every two days * @returns {Promise} @@ -163,4 +247,4 @@ const setupLogrotation = () => { return runLogrotate(); }; -export default () => setupDefaultUser().then(setupDefaultSettings).then(setupCertbotPlugins).then(setupLogrotation); +export default () => setupDefaultUser().then(setupDefaultSettings).then(setupCertbotPlugins).then(setupNginxConfigs).then(setupLogrotation); diff --git a/backend/templates/_assets.conf b/backend/templates/_assets.conf index dcb183c555..76ed745e4e 100644 --- a/backend/templates/_assets.conf +++ b/backend/templates/_assets.conf @@ -1,4 +1,37 @@ -{% if caching_enabled == 1 or caching_enabled == true -%} +{% if caching_enabled == 1 or caching_enabled == true %} +{% unless path %} # Asset Caching - include conf.d/include/assets.conf; -{% endif %} \ No newline at end of file + location ~* ^.*\.(css|js|jpe?g|gif|png|webp|woff|woff2|eot|ttf|svg|ico|css\.map|js\.map)$ { + if_modified_since off; + + # use the public cache + proxy_cache public-cache; + proxy_cache_key $host$request_uri; + + # ignore these headers for media + proxy_ignore_headers Set-Cookie Cache-Control Expires X-Accel-Expires; + + # cache 200s and also 404s (not ideal but there are a few 404 images for some reason) + proxy_cache_valid any 30m; + proxy_cache_valid 404 1m; + + # strip this header to avoid If-Modified-Since requests + proxy_hide_header Last-Modified; + proxy_hide_header Cache-Control; + proxy_hide_header Vary; + + proxy_cache_bypass 0; + proxy_no_cache 0; + + proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504 http_404; + proxy_connect_timeout 5s; + proxy_read_timeout 45s; + + expires @30m; + access_log off; + + include conf.d/include/proxy.conf; + proxy_pass $forward_scheme://$server:$port; + } +{% endunless %} +{% endif %} diff --git a/backend/templates/_location.conf b/backend/templates/_location.conf index a2ecb166d6..fdd3221fbb 100644 --- a/backend/templates/_location.conf +++ b/backend/templates/_location.conf @@ -7,7 +7,11 @@ proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Real-IP $remote_addr; + {% if upstream_host_id > 0 %} + proxy_pass {{ upstream_host_forward_scheme }}://upstream_host_{{ upstream_host_id }}; + {% else %} proxy_pass {{ forward_scheme }}://{{ forward_host }}:{{ forward_port }}{{ forward_path }}; + {% endif %} {% include "_access.conf" %} {% include "_assets.conf" %} diff --git a/backend/templates/ip_ranges.conf b/backend/templates/ip_ranges.conf index 8ede2bd992..2c8598133a 100644 --- a/backend/templates/ip_ranges.conf +++ b/backend/templates/ip_ranges.conf @@ -1,3 +1,4 @@ {% for range in ip_ranges %} set_real_ip_from {{ range }}; -{% endfor %} \ No newline at end of file +{% endfor %} +real_ip_header {{ real_ip_header }}; diff --git a/backend/templates/proxy_host.conf b/backend/templates/proxy_host.conf index d23ca46fa2..ba9dc2a860 100644 --- a/backend/templates/proxy_host.conf +++ b/backend/templates/proxy_host.conf @@ -44,6 +44,11 @@ proxy_http_version 1.1; # Proxy! include conf.d/include/proxy.conf; + {% if upstream_host_id > 0 %} + proxy_pass {{ upstream_host.forward_scheme }}://upstream_host_{{ upstream_host_id }}; + {% else %} + proxy_pass $forward_scheme://$server:$port; + {% endif %} } {% endif %} diff --git a/backend/templates/upstream_host.conf b/backend/templates/upstream_host.conf new file mode 100644 index 0000000000..b65b424a45 --- /dev/null +++ b/backend/templates/upstream_host.conf @@ -0,0 +1,14 @@ +# ------------------------------------------------------------ +# {{ name }} +# ------------------------------------------------------------ + +upstream upstream_host_{{ id }} { +{% if method == "least_conn" %} + least_conn; +{% elsif method == "ip_hash" %} + ip_hash; +{% endif %} +{% for server in servers %} + server {{ server.host }}:{{ server.port }}{% if server.weight %} weight={{ server.weight }}{% endif %}; +{% endfor %} +} diff --git a/docker/rootfs/etc/nginx/conf.d/include/assets.conf b/docker/rootfs/etc/nginx/conf.d/include/assets.conf index 5a90beb8ae..c1bc8c215b 100644 --- a/docker/rootfs/etc/nginx/conf.d/include/assets.conf +++ b/docker/rootfs/etc/nginx/conf.d/include/assets.conf @@ -28,4 +28,5 @@ location ~* ^.*\.(css|js|jpe?g|gif|png|webp|woff|woff2|eot|ttf|svg|ico|css\.map| access_log off; include conf.d/include/proxy.conf; + proxy_pass $forward_scheme://$server:$port$request_uri; } diff --git a/docker/rootfs/etc/nginx/conf.d/include/proxy.conf b/docker/rootfs/etc/nginx/conf.d/include/proxy.conf index fe2c2f2132..99d8a7d216 100644 --- a/docker/rootfs/etc/nginx/conf.d/include/proxy.conf +++ b/docker/rootfs/etc/nginx/conf.d/include/proxy.conf @@ -4,5 +4,4 @@ proxy_set_header X-Forwarded-Scheme $x_forwarded_scheme; proxy_set_header X-Forwarded-Proto $x_forwarded_proto; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Real-IP $remote_addr; -proxy_pass $forward_scheme://$server:$port$request_uri; diff --git a/docker/rootfs/etc/nginx/nginx.conf b/docker/rootfs/etc/nginx/nginx.conf index bdba3b3055..4c5ff12b73 100644 --- a/docker/rootfs/etc/nginx/nginx.conf +++ b/docker/rootfs/etc/nginx/nginx.conf @@ -78,7 +78,7 @@ http { # NPM generated CDN ip ranges: include conf.d/include/ip_ranges[.]conf; # always put the following 2 lines after ip subnets: - real_ip_header X-Real-IP; + # real_ip_header is now set dynamically in ip_ranges.conf real_ip_recursive on; # Custom @@ -87,6 +87,7 @@ http { # Files generated by NPM include /etc/nginx/conf.d/*.conf; include /data/nginx/default_host/*.conf; + include /data/nginx/upstream_host/*.conf; include /data/nginx/proxy_host/*.conf; include /data/nginx/redirection_host/*.conf; include /data/nginx/dead_host/*.conf; diff --git a/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/20-paths.sh b/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/20-paths.sh index 2f59ef41ac..ea24437760 100755 --- a/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/20-paths.sh +++ b/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/20-paths.sh @@ -23,6 +23,7 @@ mkdir -p \ /data/nginx/default_host \ /data/nginx/default_www \ /data/nginx/proxy_host \ + /data/nginx/upstream_host \ /data/nginx/redirection_host \ /data/nginx/stream \ /data/nginx/dead_host \ diff --git a/frontend/src/Router.tsx b/frontend/src/Router.tsx index 6aa8f0894f..14ae5c8bc4 100644 --- a/frontend/src/Router.tsx +++ b/frontend/src/Router.tsx @@ -25,6 +25,7 @@ const ProxyHosts = lazy(() => import("src/pages/Nginx/ProxyHosts")); const RedirectionHosts = lazy(() => import("src/pages/Nginx/RedirectionHosts")); const DeadHosts = lazy(() => import("src/pages/Nginx/DeadHosts")); const Streams = lazy(() => import("src/pages/Nginx/Streams")); +const UpstreamHosts = lazy(() => import("src/pages/UpstreamHosts")); function Router() { const health = useHealth(); @@ -70,6 +71,7 @@ function Router() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/api/backend/createUpstreamHost.ts b/frontend/src/api/backend/createUpstreamHost.ts new file mode 100644 index 0000000000..43bd7c28ba --- /dev/null +++ b/frontend/src/api/backend/createUpstreamHost.ts @@ -0,0 +1,9 @@ +import * as api from "./base"; +import type { UpstreamHost } from "./models"; + +export async function createUpstreamHost(item: UpstreamHost): Promise { + return await api.post({ + url: "/nginx/upstream-hosts", + data: item, + }); +} diff --git a/frontend/src/api/backend/deleteUpstreamHost.ts b/frontend/src/api/backend/deleteUpstreamHost.ts new file mode 100644 index 0000000000..628c4d426c --- /dev/null +++ b/frontend/src/api/backend/deleteUpstreamHost.ts @@ -0,0 +1,7 @@ +import * as api from "./base"; + +export async function deleteUpstreamHost(id: number): Promise { + return await api.del({ + url: `/nginx/upstream-hosts/${id}`, + }); +} diff --git a/frontend/src/api/backend/expansions.ts b/frontend/src/api/backend/expansions.ts index e098a49000..e0bf7aaec1 100644 --- a/frontend/src/api/backend/expansions.ts +++ b/frontend/src/api/backend/expansions.ts @@ -2,5 +2,6 @@ export type AccessListExpansion = "owner" | "items" | "clients"; export type AuditLogExpansion = "user"; export type CertificateExpansion = "owner" | "proxy_hosts" | "redirection_hosts" | "dead_hosts" | "streams"; export type HostExpansion = "owner" | "certificate"; -export type ProxyHostExpansion = "owner" | "access_list" | "certificate"; +export type ProxyHostExpansion = "owner" | "access_list" | "certificate" | "upstream_host"; +export type UpstreamHostExpansion = "owner" | "servers"; export type UserExpansion = "permissions"; diff --git a/frontend/src/api/backend/getUpstreamHost.ts b/frontend/src/api/backend/getUpstreamHost.ts new file mode 100644 index 0000000000..546ac63a02 --- /dev/null +++ b/frontend/src/api/backend/getUpstreamHost.ts @@ -0,0 +1,13 @@ +import * as api from "./base"; +import type { UpstreamHostExpansion } from "./expansions"; +import type { UpstreamHost } from "./models"; + +export async function getUpstreamHost(id: number, expand?: UpstreamHostExpansion[], params = {}): Promise { + return await api.get({ + url: `/nginx/upstream-hosts/${id}`, + params: { + expand: expand?.join(","), + ...params, + }, + }); +} diff --git a/frontend/src/api/backend/getUpstreamHosts.ts b/frontend/src/api/backend/getUpstreamHosts.ts new file mode 100644 index 0000000000..89ba10cf69 --- /dev/null +++ b/frontend/src/api/backend/getUpstreamHosts.ts @@ -0,0 +1,13 @@ +import * as api from "./base"; +import type { UpstreamHostExpansion } from "./expansions"; +import type { UpstreamHost } from "./models"; + +export async function getUpstreamHosts(expand?: UpstreamHostExpansion[], params = {}): Promise { + return await api.get({ + url: "/nginx/upstream-hosts", + params: { + expand: expand?.join(","), + ...params, + }, + }); +} diff --git a/frontend/src/api/backend/index.ts b/frontend/src/api/backend/index.ts index 40cb4142fc..c11ea5bcb1 100644 --- a/frontend/src/api/backend/index.ts +++ b/frontend/src/api/backend/index.ts @@ -6,6 +6,7 @@ export * from "./createProxyHost"; export * from "./createRedirectionHost"; export * from "./createStream"; export * from "./createUser"; +export * from "./createUpstreamHost"; export * from "./deleteAccessList"; export * from "./deleteCertificate"; export * from "./deleteDeadHost"; @@ -13,6 +14,7 @@ export * from "./deleteProxyHost"; export * from "./deleteRedirectionHost"; export * from "./deleteStream"; export * from "./deleteUser"; +export * from "./deleteUpstreamHost"; export * from "./downloadCertificate"; export * from "./expansions"; export * from "./getAccessList"; @@ -37,6 +39,8 @@ export * from "./getStreams"; export * from "./getToken"; export * from "./getUser"; export * from "./getUsers"; +export * from "./getUpstreamHost"; +export * from "./getUpstreamHosts"; export * from "./helpers"; export * from "./loginAsUser"; export * from "./models"; @@ -58,6 +62,7 @@ export * from "./updateRedirectionHost"; export * from "./updateSetting"; export * from "./updateStream"; export * from "./updateUser"; +export * from "./updateUpstreamHost"; export * from "./uploadCertificate"; export * from "./validateCertificate"; export * from "./twoFactor"; diff --git a/frontend/src/api/backend/models.ts b/frontend/src/api/backend/models.ts index 2ae0b08348..c0b19cec37 100644 --- a/frontend/src/api/backend/models.ts +++ b/frontend/src/api/backend/models.ts @@ -16,6 +16,7 @@ export interface UserPermissions { streams: string; accessLists: string; certificates: string; + upstreamHosts: string; } export interface User { @@ -97,14 +98,50 @@ export interface Certificate { redirectionHosts?: RedirectionHost[]; } +export interface UpstreamHostServer { + id?: number; + createdOn?: string; + modifiedOn?: string; + upstreamHostId?: number; + host: string; + port: number; + weight?: number; + meta?: Record; +} + +export interface UpstreamHost { + id?: number; + createdOn?: string; + modifiedOn?: string; + ownerUserId: number; + name: string; + forwardScheme: string; + method: LoadBalancingMethod; + meta: Record; + proxyHostCount?: number; + // Expansions: + owner?: User; + servers?: UpstreamHostServer[]; +} + export interface ProxyLocation { path: string; advancedConfig: string; forwardScheme: string; forwardHost: string; forwardPort: number; + upstreamHostId?: number; + upstreamHostForwardScheme?: string; } +export interface LoadBalancingServer { + host: string; + port: number; + weight?: number; +} + +export type LoadBalancingMethod = "round_robin" | "least_conn" | "ip_hash"; + export interface ProxyHost { id: number; createdOn: string; @@ -128,10 +165,12 @@ export interface ProxyHost { hstsEnabled: boolean; hstsSubdomains: boolean; trustForwardedProto: boolean; + upstreamHostId: number; // Expansions: owner?: User; accessList?: AccessList; certificate?: Certificate; + upstreamHost?: UpstreamHost; } export interface DeadHost { diff --git a/frontend/src/api/backend/updateUpstreamHost.ts b/frontend/src/api/backend/updateUpstreamHost.ts new file mode 100644 index 0000000000..ce2c87826f --- /dev/null +++ b/frontend/src/api/backend/updateUpstreamHost.ts @@ -0,0 +1,12 @@ +import * as api from "./base"; +import type { UpstreamHost } from "./models"; + +export async function updateUpstreamHost(item: UpstreamHost): Promise { + // Remove readonly fields + const { id, createdOn: _, modifiedOn: __, ...data } = item; + + return await api.put({ + url: `/nginx/upstream-hosts/${id}`, + data: data, + }); +} diff --git a/frontend/src/components/Form/LoadBalancingFields.tsx b/frontend/src/components/Form/LoadBalancingFields.tsx new file mode 100644 index 0000000000..0aee260b83 --- /dev/null +++ b/frontend/src/components/Form/LoadBalancingFields.tsx @@ -0,0 +1,155 @@ +import { IconX } from "@tabler/icons-react"; +import { useFormikContext } from "formik"; +import { useState } from "react"; +import type { LoadBalancingServer } from "src/api/backend"; +import { T } from "src/locale"; + +interface Props { + initialValues: LoadBalancingServer[]; + name?: string; +} + +export function LoadBalancingFields({ initialValues, name = "loadBalancingServers" }: Props) { + const [values, setValues] = useState(initialValues || []); + const { setFieldValue } = useFormikContext(); + + const blankItem: LoadBalancingServer = { + host: "", + port: 80, + }; + + if (values.length === 0) { + setValues([blankItem]); + } + + const setFormField = (newValues: LoadBalancingServer[]) => { + const filtered = newValues + .map((item) => { + const host = typeof item.host === "string" ? item.host.trim() : ""; + const port = Number.parseInt(String(item.port || ""), 10); + const weight = Number.parseInt(String(item.weight || ""), 10); + const normalized: LoadBalancingServer = { + host, + port: Number.isFinite(port) ? port : 0, + }; + + if (Number.isFinite(weight) && weight > 0) { + normalized.weight = weight; + } + + return normalized; + }) + .filter((item) => item.host !== "" && item.port > 0); + + setFieldValue(name, filtered); + }; + + const handleAdd = () => { + setValues([...values, blankItem]); + }; + + const handleRemove = (idx: number) => { + const newValues = values.filter((_: LoadBalancingServer, i: number) => i !== idx); + if (newValues.length === 0) { + newValues.push(blankItem); + } + setValues(newValues); + setFormField(newValues); + }; + + const handleChange = (idx: number, field: string, fieldValue: string) => { + const newValues = values.map((value: LoadBalancingServer, i: number) => { + if (i !== idx) { + return value; + } + + if (field === "port" || field === "weight") { + const parsed = Number.parseInt(fieldValue, 10); + return { + ...value, + [field]: Number.isFinite(parsed) ? parsed : 0, + }; + } + + return { ...value, [field]: fieldValue }; + }); + + setValues(newValues); + setFormField(newValues); + }; + + return ( + <> + {values.map((item: LoadBalancingServer, idx: number) => ( +
+
+
+ + handleChange(idx, "host", e.target.value)} + /> +
+
+
+
+ + handleChange(idx, "port", e.target.value)} + /> +
+
+
+
+ + handleChange(idx, "weight", e.target.value)} + /> +
+
+ +
+ ))} +
+ +
+ + ); +} diff --git a/frontend/src/components/Form/LocationsFields.tsx b/frontend/src/components/Form/LocationsFields.tsx index 4240b1f986..f8713860a6 100644 --- a/frontend/src/components/Form/LocationsFields.tsx +++ b/frontend/src/components/Form/LocationsFields.tsx @@ -6,6 +6,7 @@ import { useState } from "react"; import type { ProxyLocation } from "src/api/backend"; import { intl, T } from "src/locale"; import styles from "./LocationsFields.module.css"; +import { UpstreamHostSelect } from "./UpstreamHostSelect"; interface Props { initialValues: ProxyLocation[]; @@ -38,12 +39,39 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) { setFormField(newValues); }; - const handleChange = (idx: number, field: string, fieldValue: string) => { + const handleChange = (idx: number, field: string, fieldValue: string | number) => { const newValues = values.map((v: ProxyLocation, i: number) => (i === idx ? { ...v, [field]: fieldValue } : v)); setValues(newValues); setFormField(newValues); }; + const handleUpstreamChange = (idx: number, upstreamHostId: number) => { + const newValues = values.map((v: ProxyLocation, i: number) => + i === idx + ? { + ...v, + upstreamHostId, + upstreamHostForwardScheme: v.upstreamHostForwardScheme || "http", + } + : v, + ); + setValues(newValues); + setFormField(newValues); + }; + + const handleTargetTypeChange = (idx: number, targetType: "direct" | "upstream") => { + const newValues = values.map((v: ProxyLocation, i: number) => + i === idx + ? { + ...v, + upstreamHostId: targetType === "direct" ? 0 : (v.upstreamHostId || 0), + } + : v, + ); + setValues(newValues); + setFormField(newValues); + }; + const setFormField = (newValues: ProxyLocation[]) => { const filtered = newValues.filter((v: ProxyLocation) => v?.path?.trim() !== ""); setFieldValue(name, filtered); @@ -61,7 +89,9 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) { return ( <> - {values.map((item: ProxyLocation, idx: number) => ( + {values.map((item: ProxyLocation, idx: number) => { + const targetType = item.upstreamHostId && item.upstreamHostId > 0 ? "upstream" : "direct"; + return (
@@ -89,14 +119,65 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
+
+ + +
+ {targetType === "upstream" && ( +
+ handleUpstreamChange(idx, id)} + /> +
+ )} + {targetType === "direct" && (
-
- ))} + ); + })}
- - )} + + ); + }} )} diff --git a/frontend/src/modals/UpstreamHostModal.tsx b/frontend/src/modals/UpstreamHostModal.tsx new file mode 100644 index 0000000000..0edeb5e653 --- /dev/null +++ b/frontend/src/modals/UpstreamHostModal.tsx @@ -0,0 +1,215 @@ +import EasyModal, { type InnerModalProps } from "ez-modal-react"; +import { Field, Form, Formik } from "formik"; +import { type ReactNode, useState } from "react"; +import { Alert } from "react-bootstrap"; +import Modal from "react-bootstrap/Modal"; +import { Button, LoadBalancingFields, Loading } from "src/components"; +import { useSetUpstreamHost, useUpstreamHost } from "src/hooks"; +import { T } from "src/locale"; +import { validateString } from "src/modules/Validations"; +import { showObjectSuccess } from "src/notifications"; + +const showUpstreamHostModal = (id: number | "new") => { + EasyModal.show(UpstreamHostModal, { id }); +}; + +interface Props extends InnerModalProps { + id: number | "new"; +} +const UpstreamHostModal = EasyModal.create(({ id, visible, remove }: Props) => { + const { data, isLoading, error } = useUpstreamHost(id, ["servers"]); + const { mutate: setUpstreamHost } = useSetUpstreamHost(); + const [errorMsg, setErrorMsg] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const onSubmit = async (values: any, { setSubmitting }: any) => { + if (isSubmitting) return; + setIsSubmitting(true); + setErrorMsg(null); + + const payload = { + id: id === "new" ? undefined : id, + ...values, + }; + + setUpstreamHost(payload, { + onError: (err: any) => setErrorMsg(), + onSuccess: () => { + showObjectSuccess("upstream-host", "saved"); + remove(); + }, + onSettled: () => { + setIsSubmitting(false); + setSubmitting(false); + }, + }); + }; + + return ( + + {!isLoading && error && ( + + {error?.message || "Unknown error"} + + )} + {isLoading && } + {!isLoading && data && ( + + {() => ( +
+ + + + + + + setErrorMsg(null)} dismissible> + {errorMsg} + +
+
+ +
+
+
+
+ + {({ field, form }: any) => ( +
+ + + {form.errors.name ? ( +
+ {form.errors.name && form.touched.name + ? form.errors.name + : null} +
+ ) : null} +
+ )} +
+
+
+ + {({ field }: any) => ( +
+ + +
+ )} +
+
+
+ + {({ field }: any) => ( +
+ + +
+ )} +
+
+
+
+
+ +
+
+
+
+
+ + + + +
+ )} +
+ )} +
+ ); +}); + +export { showUpstreamHostModal }; diff --git a/frontend/src/modals/index.ts b/frontend/src/modals/index.ts index a06a0c0d71..a9e5bf5292 100644 --- a/frontend/src/modals/index.ts +++ b/frontend/src/modals/index.ts @@ -14,4 +14,5 @@ export * from "./RenewCertificateModal"; export * from "./SetPasswordModal"; export * from "./StreamModal"; export * from "./TwoFactorModal"; +export * from "./UpstreamHostModal"; export * from "./UserModal"; diff --git a/frontend/src/modules/Permissions.ts b/frontend/src/modules/Permissions.ts index 2d784213e4..0fdd825517 100644 --- a/frontend/src/modules/Permissions.ts +++ b/frontend/src/modules/Permissions.ts @@ -8,6 +8,7 @@ export const DEAD_HOSTS = "deadHosts"; export const STREAMS = "streams"; export const CERTIFICATES = "certificates"; export const ACCESS_LISTS = "accessLists"; +export const UPSTREAM_HOSTS = "upstreamHosts"; export const MANAGE = "manage"; export const VIEW = "view"; @@ -24,7 +25,8 @@ export type Section = | typeof DEAD_HOSTS | typeof STREAMS | typeof CERTIFICATES - | typeof ACCESS_LISTS; + | typeof ACCESS_LISTS + | typeof UPSTREAM_HOSTS; export type Permission = typeof MANAGE | typeof VIEW; diff --git a/frontend/src/pages/Nginx/ProxyHosts/Table.tsx b/frontend/src/pages/Nginx/ProxyHosts/Table.tsx index 9d58b26acd..d9003ac01a 100644 --- a/frontend/src/pages/Nginx/ProxyHosts/Table.tsx +++ b/frontend/src/pages/Nginx/ProxyHosts/Table.tsx @@ -13,6 +13,7 @@ import { } from "src/components"; import { TableLayout } from "src/components/Table/TableLayout"; import { intl, T } from "src/locale"; +import { showUpstreamHostModal } from "src/modals"; import { MANAGE, PROXY_HOSTS } from "src/modules/Permissions"; interface Props { @@ -51,6 +52,20 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog header: intl.formatMessage({ id: "column.destination" }), cell: (info: any) => { const value = info.getValue(); + if (value.upstreamHostId > 0 && value.upstreamHost) { + return ( + + ); + } return `${value.forwardScheme}://${value.forwardHost}:${value.forwardPort}`; }, }), diff --git a/frontend/src/pages/Nginx/ProxyHosts/TableWrapper.tsx b/frontend/src/pages/Nginx/ProxyHosts/TableWrapper.tsx index 68af43e10a..759c1f9b9b 100644 --- a/frontend/src/pages/Nginx/ProxyHosts/TableWrapper.tsx +++ b/frontend/src/pages/Nginx/ProxyHosts/TableWrapper.tsx @@ -14,7 +14,7 @@ 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 { isFetching, isLoading, isError, error, data } = useProxyHosts(["owner", "access_list", "certificate", "upstream_host"]); if (isLoading) { return ; diff --git a/frontend/src/pages/Settings/Layout.tsx b/frontend/src/pages/Settings/Layout.tsx index a0a77db29e..68688f3c20 100644 --- a/frontend/src/pages/Settings/Layout.tsx +++ b/frontend/src/pages/Settings/Layout.tsx @@ -1,10 +1,14 @@ +import { useState } from "react"; import { T } from "src/locale"; import DefaultSite from "./DefaultSite"; +import RealIpHeader from "./RealIpHeader"; export default function Layout() { // Taken from https://preview.tabler.io/settings.html // Refer to that when updating this content + const [activeTab, setActiveTab] = useState<"default-site" | "real-ip-header">("default-site"); + return (
- + {activeTab === "default-site" && } + {activeTab === "real-ip-header" && }
diff --git a/frontend/src/pages/Settings/RealIpHeader.tsx b/frontend/src/pages/Settings/RealIpHeader.tsx new file mode 100644 index 0000000000..3df8277721 --- /dev/null +++ b/frontend/src/pages/Settings/RealIpHeader.tsx @@ -0,0 +1,163 @@ +import { Field, Form, Formik } from "formik"; +import { type ReactNode, useState } from "react"; +import { Alert } from "react-bootstrap"; +import { Button, Loading } from "src/components"; +import { useSetSetting, useSetting } from "src/hooks"; +import { intl, T } from "src/locale"; +import { showObjectSuccess } from "src/notifications"; + +const HEADER_OPTIONS = [ + { value: "X-Real-IP", localeId: "settings.real-ip-header.x-real-ip", descId: "settings.real-ip-header.x-real-ip.description" }, + { value: "CF-Connecting-IP", localeId: "settings.real-ip-header.cf-connecting-ip", descId: "settings.real-ip-header.cf-connecting-ip.description" }, + { value: "X-Forwarded-For", localeId: "settings.real-ip-header.x-forwarded-for", descId: "settings.real-ip-header.x-forwarded-for.description" }, + { value: "custom", localeId: "settings.real-ip-header.custom", descId: null }, +]; + +export default function RealIpHeader() { + const { data, isLoading, error } = useSetting("real-ip-header"); + const { mutate: setSetting } = useSetSetting(); + const [errorMsg, setErrorMsg] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const onSubmit = async (values: any, { setSubmitting }: any) => { + if (isSubmitting) return; + setIsSubmitting(true); + setErrorMsg(null); + + const payload = { + id: "real-ip-header", + value: values.value, + meta: { + custom: values.custom, + }, + }; + + setSetting(payload, { + onError: (err: any) => setErrorMsg(), + onSuccess: () => { + showObjectSuccess("setting", "saved"); + }, + onSettled: () => { + setIsSubmitting(false); + setSubmitting(false); + }, + }); + }; + + if (!isLoading && error) { + return ( +
+
+ + {error.message} + +
+
+ ); + } + + if (isLoading) { + return ( +
+
+ +
+
+ ); + } + + return ( + + {({ values }) => ( +
+
+ setErrorMsg(null)} dismissible> + {errorMsg} + + + {({ field, form }: any) => ( +
+ +
+ {HEADER_OPTIONS.map((opt) => ( + + ))} +
+
+ )} +
+ {values.value === "custom" && ( + + {({ field }: any) => ( +
+ +
+ +
+
+ )} +
+ )} +
+
+
+ +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/UpstreamHosts/Table.tsx b/frontend/src/pages/UpstreamHosts/Table.tsx new file mode 100644 index 0000000000..6971aa48f6 --- /dev/null +++ b/frontend/src/pages/UpstreamHosts/Table.tsx @@ -0,0 +1,150 @@ +import { IconDotsVertical, IconEdit, IconTrash } from "@tabler/icons-react"; +import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table"; +import { useMemo } from "react"; +import type { UpstreamHost } from "src/api/backend"; +import { EmptyData, GravatarFormatter, HasPermission, ValueWithDateFormatter } from "src/components"; +import { TableLayout } from "src/components/Table/TableLayout"; +import { intl, T } from "src/locale"; +import { MANAGE, UPSTREAM_HOSTS } from "src/modules/Permissions"; + +interface Props { + data: UpstreamHost[]; + isFiltered?: boolean; + isFetching?: boolean; + onEdit?: (id: number) => void; + onDelete?: (id: number) => void; + onNew?: () => void; +} +export default function Table({ data, isFetching, isFiltered, onEdit, onDelete, onNew }: Props) { + const columnHelper = createColumnHelper(); + const columns = useMemo( + () => [ + columnHelper.accessor((row: any) => row.owner, { + id: "owner", + cell: (info: any) => { + const value = info.getValue(); + return ; + }, + meta: { + className: "w-1", + }, + }), + columnHelper.accessor((row: any) => row, { + id: "name", + header: intl.formatMessage({ id: "column.name" }), + cell: (info: any) => ( + + ), + }), + columnHelper.accessor((row: any) => row.forwardScheme, { + id: "forwardScheme", + header: intl.formatMessage({ id: "column.scheme" }), + cell: (info: any) => info.getValue(), + }), + columnHelper.accessor((row: any) => row.method, { + id: "method", + header: intl.formatMessage({ id: "upstream-host.method" }), + cell: (info: any) => { + const method = info.getValue(); + return method?.replace(/_/g, " ") || ""; + }, + }), + columnHelper.accessor((row: any) => row.servers, { + id: "servers", + header: intl.formatMessage({ id: "upstream-host.servers" }), + cell: (info: any) => { + const servers = info.getValue(); + return servers?.length || 0; + }, + }), + columnHelper.accessor((row: any) => row.proxyHostCount, { + id: "proxyHostCount", + header: intl.formatMessage({ id: "proxy-hosts" }), + cell: (info: any) => , + }), + columnHelper.display({ + id: "id", + cell: (info: any) => { + return ( + + +
+ + + + { + e.preventDefault(); + onEdit?.(info.row.original.id); + }} + > + + + + + + + ); + }, + meta: { + className: "text-end w-1", + }, + }), + ], + [columnHelper, onEdit, onDelete], + ); + + const tableInstance = useReactTable({ + columns, + data, + getCoreRowModel: getCoreRowModel(), + rowCount: data.length, + meta: { + isFetching, + }, + enableSortingRemoval: false, + }); + + return ( + + } + /> + ); +} diff --git a/frontend/src/pages/UpstreamHosts/TableWrapper.tsx b/frontend/src/pages/UpstreamHosts/TableWrapper.tsx new file mode 100644 index 0000000000..0bb41d2111 --- /dev/null +++ b/frontend/src/pages/UpstreamHosts/TableWrapper.tsx @@ -0,0 +1,100 @@ +import { IconSearch } from "@tabler/icons-react"; +import { useState } from "react"; +import Alert from "react-bootstrap/Alert"; +import { deleteUpstreamHost } from "src/api/backend"; +import { Button, HasPermission, LoadingPage } from "src/components"; +import { useUpstreamHosts } from "src/hooks"; +import { T } from "src/locale"; +import { showDeleteConfirmModal, showUpstreamHostModal } from "src/modals"; +import { MANAGE, UPSTREAM_HOSTS } from "src/modules/Permissions"; +import { showObjectSuccess } from "src/notifications"; +import Table from "./Table"; + +export default function TableWrapper() { + const [search, setSearch] = useState(""); + const { isFetching, isLoading, isError, error, data } = useUpstreamHosts(["owner", "servers"]); + + if (isLoading) { + return ; + } + + if (isError) { + return {error?.message || "Unknown error"}; + } + + const handleDelete = async (id: number) => { + await deleteUpstreamHost(id); + showObjectSuccess("upstream-host", "deleted"); + }; + + let filtered = null; + if (search && data) { + filtered = data?.filter((item) => { + return item.name.toLowerCase().includes(search); + }); + } else if (search !== "") { + setSearch(""); + } + + return ( +
+
+
+
+
+
+

+ +

+
+ +
+
+ {data?.length ? ( +
+ + + + setSearch(e.target.value.toLowerCase().trim())} + /> +
+ ) : null} + + {data?.length ? ( + + ) : null} + +
+
+
+
+ showUpstreamHostModal(id)} + onDelete={(id: number) => + showDeleteConfirmModal({ + title: , + onConfirm: () => handleDelete(id), + invalidations: [["upstream-hosts"], ["upstream-host", id]], + children: , + }) + } + onNew={() => showUpstreamHostModal("new")} + /> + + + ); +} diff --git a/frontend/src/pages/UpstreamHosts/index.tsx b/frontend/src/pages/UpstreamHosts/index.tsx new file mode 100644 index 0000000000..1d7439c29e --- /dev/null +++ b/frontend/src/pages/UpstreamHosts/index.tsx @@ -0,0 +1,13 @@ +import { HasPermission } from "src/components"; +import { UPSTREAM_HOSTS, VIEW } from "src/modules/Permissions"; +import TableWrapper from "./TableWrapper"; + +const UpstreamHosts = () => { + return ( + + + + ); +}; + +export default UpstreamHosts; From f42e35df0f692f6e3599d1cee1a2ced94ee501fb Mon Sep 17 00:00:00 2001 From: GenticFlow Labs Date: Tue, 17 Mar 2026 22:02:16 +0000 Subject: [PATCH 2/6] Fixed lint errors on RealIpHeader --- frontend/src/pages/Settings/RealIpHeader.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/Settings/RealIpHeader.tsx b/frontend/src/pages/Settings/RealIpHeader.tsx index 3df8277721..2dcabb2226 100644 --- a/frontend/src/pages/Settings/RealIpHeader.tsx +++ b/frontend/src/pages/Settings/RealIpHeader.tsx @@ -85,10 +85,10 @@ export default function RealIpHeader() { {({ field, form }: any) => (
-