From d783ed4c3a55865b63a3aebdb01aff9b188ceda0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Paulo=20Ros?= Date: Mon, 18 May 2026 23:59:35 -0700 Subject: [PATCH 1/3] Rebased PR Client certificate support #2956 from @wrouesnel --- backend/internal/access-list.js | 68 +++++++++++++-- backend/internal/certificate.js | 49 ++++++++--- backend/internal/nginx.js | 6 ++ backend/internal/proxy-host.js | 6 +- ...0526062132_add_clientcas_to_accesslists.js | 52 ++++++++++++ ...411_add_drop_unauthorized_to_proxyhosts.js | 41 +++++++++ backend/models/access_list.js | 9 ++ backend/models/access_list_clientcas.js | 61 +++++++++++++ backend/models/proxy_host.js | 5 +- backend/schema/common.json | 12 ++- .../schema/components/access-list-object.json | 32 +++++++ .../schema/components/proxy-host-object.json | 6 ++ .../paths/nginx/access-lists/listID/put.json | 6 +- .../schema/paths/nginx/access-lists/post.json | 6 +- .../certificates/certID/upload/post.json | 2 +- .../nginx/certificates/validate/post.json | 2 +- .../paths/nginx/proxy-hosts/hostID/put.json | 3 + .../schema/paths/nginx/proxy-hosts/post.json | 3 + backend/templates/_access.conf | 54 +++++++----- backend/templates/_certificates.conf | 8 +- backend/templates/access.conf | 12 +++ docker/docker-compose.dev-user.yml | 70 +++++++++++++++ docker/rootfs/etc/nginx/nginx.conf | 1 + .../s6-overlay/s6-rc.d/prepare/20-paths.sh | 2 + frontend/src/api/backend/expansions.ts | 2 +- frontend/src/api/backend/models.ts | 12 +++ frontend/src/components/Form/AccessField.tsx | 3 +- frontend/src/hooks/useProxyHost.ts | 1 + frontend/src/locale/src/en.json | 22 ++++- frontend/src/locale/src/et.json | 2 +- frontend/src/modals/AccessListModal.tsx | 65 ++++++++++++-- .../src/modals/CustomCertificateModal.tsx | 85 +++++++++++-------- frontend/src/modals/ProxyHostModal.tsx | 24 ++++++ frontend/src/pages/Access/Table.tsx | 5 ++ frontend/src/pages/Access/TableWrapper.tsx | 7 +- frontend/src/pages/Certificates/Table.tsx | 10 +++ .../src/pages/Certificates/TableWrapper.tsx | 10 +++ scripts/.common.sh | 11 +++ scripts/buildx | 8 +- scripts/wait-healthy | 2 +- test/cypress/e2e/api/ProxyHosts.cy.js | 1 + 41 files changed, 681 insertions(+), 105 deletions(-) create mode 100644 backend/migrations/20230526062132_add_clientcas_to_accesslists.js create mode 100644 backend/migrations/20230529030411_add_drop_unauthorized_to_proxyhosts.js create mode 100644 backend/models/access_list_clientcas.js create mode 100644 backend/templates/access.conf create mode 100644 docker/docker-compose.dev-user.yml diff --git a/backend/internal/access-list.js b/backend/internal/access-list.js index 88bf9df523..9b5f6b9256 100644 --- a/backend/internal/access-list.js +++ b/backend/internal/access-list.js @@ -6,6 +6,7 @@ import utils from "../lib/utils.js"; import { access as logger } from "../logger.js"; import accessListModel from "../models/access_list.js"; import accessListAuthModel from "../models/access_list_auth.js"; +import accessListClientCAsModel from "../models/access_list_clientcas.js"; import accessListClientModel from "../models/access_list_client.js"; import proxyHostModel from "../models/proxy_host.js"; import internalAuditLog from "./audit-log.js"; @@ -58,13 +59,25 @@ const internalAccessList = { directive: client.directive, }); } + for (const certificateId of data.clientcas ?? []) { + await accessListClientCAsModel.query().insert({ + access_list_id: row.id, + certificate_id: certificateId, + }); + } // re-fetch with expansions const freshRow = await internalAccessList.get( access, { id: data.id, - expand: ["owner", "items", "clients", "proxy_hosts.access_list.[clients,items]"], + expand: [ + "owner", + "items", + "clients", + "clientcas.certificate", + "proxy_hosts.access_list.[clientcas.certificate,clients,items]", + ], }, true // skip masking ); @@ -164,6 +177,16 @@ const internalAccessList = { } } } + if (typeof data.clientcas !== "undefined" && data.clientcas) { + await accessListClientCAsModel.query().delete().where("access_list_id", data.id); + + for (const certificateId of data.clientcas) { + await accessListClientCAsModel.query().insert({ + access_list_id: data.id, + certificate_id: certificateId, + }); + } + } // Add to audit log await internalAuditLog.add(access, { @@ -178,7 +201,13 @@ const internalAccessList = { access, { id: data.id, - expand: ["owner", "items", "clients", "proxy_hosts.[certificate,access_list.[clients,items]]"], + expand: [ + "owner", + "items", + "clients", + "clientcas.certificate", + "proxy_hosts.[certificate,access_list.[clientcas.certificate,clients,items]]", + ], }, true // skip masking ); @@ -217,7 +246,7 @@ const internalAccessList = { .where("access_list.is_deleted", 0) .andWhere("access_list.id", thisData.id) .groupBy("access_list.id") - .allowGraph("[owner,items,clients,proxy_hosts.[certificate,access_list.[clients,items]]]") + .allowGraph("[owner,items,clients,clientcas.certificate,proxy_hosts.[certificate,access_list.[clientcas.certificate,clients,items]]]") .first(); if (accessData.permission_visibility !== "all") { @@ -254,7 +283,7 @@ const internalAccessList = { await access.can("access_lists:delete", data.id); const row = await internalAccessList.get(access, { id: data.id, - expand: ["proxy_hosts", "items", "clients"], + expand: ["proxy_hosts", "items", "clients", "clientcas.certificate"], }); if (!row?.id) { @@ -333,7 +362,7 @@ const internalAccessList = { }) .where("access_list.is_deleted", 0) .groupBy("access_list.id") - .allowGraph("[owner,items,clients]") + .allowGraph("[owner,items,clients,clientcas.certificate]") .orderBy("access_list.name", "ASC"); if (accessData.permission_visibility !== "all") { @@ -404,6 +433,14 @@ const internalAccessList = { return true; }); } + if (list && typeof list.clientcas !== "undefined") { + list.clientcas.map((val, idx) => { + if (val.certificate?.meta) { + list.clientcas[idx].certificate.meta = {}; + } + return true; + }); + } return list; }, @@ -427,6 +464,7 @@ const internalAccessList = { logger.info(`Building Access file #${list.id} for: ${list.name}`); const htpasswdFile = internalAccessList.getFilename(list); + const clientCaFile = internalAccessList.getClientCaFilename(list); // 1. remove any existing access file try { @@ -434,6 +472,11 @@ const internalAccessList = { } catch (_err) { // do nothing } + try { + fs.unlinkSync(clientCaFile); + } catch (_err) { + // do nothing + } // 2. create empty access file fs.writeFileSync(htpasswdFile, '', {encoding: 'utf8'}); @@ -471,7 +514,18 @@ const internalAccessList = { }); }); } - } -} + + fs.writeFileSync(clientCaFile, "", { encoding: "utf8" }); + for (const clientca of list.clientcas ?? []) { + if (clientca.certificate?.meta?.certificate) { + fs.appendFileSync(clientCaFile, `${clientca.certificate.meta.certificate}\n`, { encoding: "utf8" }); + } + } + }, + + getClientCaFilename: (list) => { + return `/data/clientca/${list.id}`; + }, +}; export default internalAccessList; diff --git a/backend/internal/certificate.js b/backend/internal/certificate.js index 6498422c61..fca7a4186f 100644 --- a/backend/internal/certificate.js +++ b/backend/internal/certificate.js @@ -518,6 +518,12 @@ const internalCertificate = { }); }).then(() => { return new Promise((resolve, reject) => { + if (certificate.provider === "clientca") { + // Client CAs have no private key associated, so just succeed. + resolve(); + return; + } + fs.writeFile(`${dir}/privkey.pem`, certificate.meta.certificate_key, (err) => { if (err) { reject(err); @@ -596,7 +602,7 @@ const internalCertificate = { */ upload: async (access, data) => { const row = await internalCertificate.get(access, { id: data.id }); - if (row.provider !== "other") { + if (row.provider !== "other" && row.provider !== "clientca") { throw new error.ValidationError("Cannot upload certificates for this type of provider"); } @@ -671,6 +677,26 @@ const internalCertificate = { } }, + /** + * Parse the X509 subject line as returned by the OpenSSL command when + * invoked with openssl x509 -in -subject -noout + * + * @param {String} line emitted from the openssl command + * @param {String} prefix expected to be removed + * @return {Object} object containing the parsed fields from the subject line + */ + parseX509Output: (line, prefix) => { + const subjectValue = line.trim().slice(prefix.length); + + return subjectValue + .split(/,(?=(?:(?:[^"]*"){2})*[^"]*$)/) + .map((e) => e.trim().split(/\s*=\s*/, 2)) + .reduce((obj, [key, value]) => { + obj[key] = value.replace(/^"/, "").replace(/"$/, ""); + return obj; + }, {}); + }, + /** * Uses the openssl command to both validate and get info out of the certificate. * It will save the file to disk first, then run commands on it, then delete the file. @@ -685,25 +711,22 @@ const internalCertificate = { const result = await utils.execFile("openssl", ["x509", "-in", certificateFile, "-subject", "-noout"]); // Examples: - // subject=CN = *.jc21.com // subject=CN = something.example.com - // subject=CN=*.jc21.com - const regex = /(?:subject=)?[^=]+=\s*(\S+)/gim; - const match = regex.exec(result); - if (match && typeof match[1] !== "undefined") { - certData.cn = match[1].trim(); + // subject=C = NoCountry, O = NoOrg, OU = NoOrgUnit, CN = Some Value With Spaces + const subjectParams = internalCertificate.parseX509Output(result, "subject="); + if (typeof subjectParams.CN === "undefined") { + throw new error.ValidationError(`Could not determine subject from certificate: ${result}`); } + certData.cn = subjectParams.CN; const result2 = await utils.execFile("openssl", ["x509", "-in", certificateFile, "-issuer", "-noout"]); // Examples: // issuer=C = US, O = Let's Encrypt, CN = Let's Encrypt Authority X3 - // issuer=C = US, O = Let's Encrypt, CN = E5 - // issuer=O = NginxProxyManager, CN = NginxProxyManager Intermediate CA","O = NginxProxyManager, CN = NginxProxyManager Intermediate CA - const regex2 = /^(?:issuer=)?(.*)$/gim; - const match2 = regex2.exec(result2); - if (match2 && typeof match2[1] !== "undefined") { - certData.issuer = match2[1]; + const issuerParams = internalCertificate.parseX509Output(result2, "issuer="); + if (typeof issuerParams.CN === "undefined") { + throw new error.ValidationError(`Could not determine issuer from certificate: ${result2}`); } + certData.issuer = issuerParams.CN; const result3 = await utils.execFile("openssl", ["x509", "-in", certificateFile, "-dates", "-noout"]); // notBefore=Jul 14 04:04:29 2018 GMT diff --git a/backend/internal/nginx.js b/backend/internal/nginx.js index fe84607f96..d212875cde 100644 --- a/backend/internal/nginx.js +++ b/backend/internal/nginx.js @@ -156,6 +156,7 @@ const internalNginx = { { ssl_forced: host.ssl_forced }, { caching_enabled: host.caching_enabled }, { block_exploits: host.block_exploits }, + { drop_unauthorized: host.drop_unauthorized }, { allow_websocket_upgrade: host.allow_websocket_upgrade }, { http2_support: host.http2_support }, { hsts_enabled: host.hsts_enabled }, @@ -209,6 +210,11 @@ const internalNginx = { let origLocations; // Manipulate the data a bit before sending it to the template + if (typeof host.drop_unauthorized === "undefined") { + // Only proxy hosts expose this field, but hosts share templates. + host.drop_unauthorized = 0; + } + if (nice_host_type !== "default") { host.use_default_location = true; if (typeof host.advanced_config !== "undefined" && host.advanced_config) { diff --git a/backend/internal/proxy-host.js b/backend/internal/proxy-host.js index 2c159d48ad..ff492e0954 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.[clientcas.certificate,clients,items]"], }); }) .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.[clientcas.certificate,clients,items]"], }) .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.[clientcas.certificate,clients,items]"], }); }) .then((row) => { diff --git a/backend/migrations/20230526062132_add_clientcas_to_accesslists.js b/backend/migrations/20230526062132_add_clientcas_to_accesslists.js new file mode 100644 index 0000000000..e8be558f7f --- /dev/null +++ b/backend/migrations/20230526062132_add_clientcas_to_accesslists.js @@ -0,0 +1,52 @@ +import { migrate as logger } from "../logger.js"; + +const migrateName = "client_certificates"; + +/** + * Migrate + * + * @see http://knexjs.org/#Schema + * + * @param {Object} knex + * @param {Promise} Promise + * @returns {Promise} + */ +const up = (knex) => { + logger.info(`[${migrateName}] Migrating Up...`); + + return knex.schema.createTable("access_list_clientcas", (table) => { + table.increments().primary(); + table.dateTime("created_on").notNull(); + table.dateTime("modified_on").notNull(); + table.integer("access_list_id").notNull().unsigned(); + table.integer("certificate_id").notNull().unsigned(); + table.json("meta").notNull(); + }) + .then(() => { + logger.info(`[${migrateName}] access_list_clientcas Table created`); + }) + .then(() => { + logger.info(`[${migrateName}] Migrating Up Complete`); + }); +}; + +/** + * Undo Migrate + * + * @param {Object} knex + * @param {Promise} Promise + * @returns {Promise} + */ +const down = (knex) => { + logger.info(`[${migrateName}] Migrating Down...`); + + return knex.schema.dropTable("access_list_clientcas") + .then(() => { + logger.info(`[${migrateName}] access_list_clientcas Table dropped`); + }) + .then(() => { + logger.info(`[${migrateName}] Migrating Down Complete`); + }); +}; + +export { up, down }; diff --git a/backend/migrations/20230529030411_add_drop_unauthorized_to_proxyhosts.js b/backend/migrations/20230529030411_add_drop_unauthorized_to_proxyhosts.js new file mode 100644 index 0000000000..1773d40e1e --- /dev/null +++ b/backend/migrations/20230529030411_add_drop_unauthorized_to_proxyhosts.js @@ -0,0 +1,41 @@ +import { migrate as logger } from "../logger.js"; + +const migrateName = "drop_unauthorized"; + +/** + * Migrate + * + * @see http://knexjs.org/#Schema + * + * @param {Object} knex + * @param {Promise} Promise + * @returns {Promise} + */ +const up = (knex) => { + logger.info(`[${migrateName}] Migrating Up...`); + + return knex.schema.table("proxy_host", (table) => { + table.integer("drop_unauthorized").notNull().unsigned().defaultTo(0); + }).then(() => { + logger.info(`[${migrateName}] Migrating Up Complete`); + }); +}; + +/** + * Undo Migrate + * + * @param {Object} knex + * @param {Promise} Promise + * @returns {Promise} + */ +const down = (knex) => { + logger.info(`[${migrateName}] Migrating Down...`); + + return knex.schema.table("proxy_host", (table) => { + table.dropColumn("drop_unauthorized"); + }).then(() => { + logger.info(`[${migrateName}] Migrating Down Complete`); + }); +}; + +export { up, down }; diff --git a/backend/models/access_list.js b/backend/models/access_list.js index 427d447d62..6cf058eb70 100644 --- a/backend/models/access_list.js +++ b/backend/models/access_list.js @@ -6,6 +6,7 @@ import db from "../db.js"; import { convertBoolFieldsToInt, convertIntFieldsToBool } from "../lib/helpers.js"; import AccessListAuth from "./access_list_auth.js"; import AccessListClient from "./access_list_client.js"; +import AccessListClientCAs from "./access_list_clientcas.js"; import now from "./now_helper.js"; import ProxyHostModel from "./proxy_host.js"; import User from "./user.js"; @@ -80,6 +81,14 @@ class AccessList extends Model { to: "access_list_client.access_list_id", }, }, + clientcas: { + relation: Model.HasManyRelation, + modelClass: AccessListClientCAs, + join: { + from: "access_list.id", + to: "access_list_clientcas.access_list_id", + }, + }, proxy_hosts: { relation: Model.HasManyRelation, modelClass: ProxyHostModel, diff --git a/backend/models/access_list_clientcas.js b/backend/models/access_list_clientcas.js new file mode 100644 index 0000000000..045e8157f9 --- /dev/null +++ b/backend/models/access_list_clientcas.js @@ -0,0 +1,61 @@ +import { Model } from "objection"; +import db from "../db.js"; +import AccessList from "./access_list.js"; +import Certificate from "./certificate.js"; +import now from "./now_helper.js"; + +Model.knex(db()); + +class AccessListClientCAs extends Model { + $beforeInsert() { + this.created_on = now(); + this.modified_on = now(); + + // Default for meta + if (typeof this.meta === "undefined") { + this.meta = {}; + } + } + + $beforeUpdate() { + this.modified_on = now(); + } + + static get name() { + return "AccessListClientCAs"; + } + + static get tableName() { + return "access_list_clientcas"; + } + + static get jsonAttributes() { + return ["meta"]; + } + + static get relationMappings() { + return { + access_list: { + relation: Model.HasOneRelation, + modelClass: AccessList, + join: { + from: "access_list_clientcas.access_list_id", + to: "access_list.id", + }, + modify: (qb) => { + qb.where("access_list.is_deleted", 0); + }, + }, + certificate: { + relation: Model.HasOneRelation, + modelClass: Certificate, + join: { + from: "access_list_clientcas.certificate_id", + to: "certificate.id", + }, + }, + }; + } +} + +export default AccessListClientCAs; diff --git a/backend/models/proxy_host.js b/backend/models/proxy_host.js index acb8da9358..4eeac64585 100644 --- a/backend/models/proxy_host.js +++ b/backend/models/proxy_host.js @@ -22,6 +22,7 @@ const boolFields = [ "hsts_enabled", "hsts_subdomains", "trust_forwarded_proto", + "drop_unauthorized", ]; class ProxyHost extends Model { @@ -74,11 +75,11 @@ class ProxyHost extends Model { } static get defaultAllowGraph() { - return "[owner,access_list.[clients,items],certificate]"; + return "[owner,access_list.[clientcas.[certificate],clients,items],certificate]"; } static get defaultExpand() { - return ["owner", "certificate", "access_list.[clients,items]"]; + return ["owner", "certificate", "access_list.[clientcas.[certificate],clients,items]"]; } static get defaultOrder() { diff --git a/backend/schema/common.json b/backend/schema/common.json index e56c9f70a0..9f6e70cda6 100644 --- a/backend/schema/common.json +++ b/backend/schema/common.json @@ -108,7 +108,7 @@ }, "ssl_provider": { "type": "string", - "pattern": "^(letsencrypt|other)$", + "pattern": "^(letsencrypt|other|clientca)$", "example": "letsencrypt" }, "http2_support": { @@ -205,6 +205,14 @@ } ] }, + "access_clientcas": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1 + }, + "example": [1, 2] + }, "certificate_files": { "description": "Certificate Files", "content": { @@ -212,7 +220,7 @@ "schema": { "type": "object", "additionalProperties": false, - "required": ["certificate", "certificate_key"], + "required": ["certificate"], "properties": { "certificate": { "type": "string", diff --git a/backend/schema/components/access-list-object.json b/backend/schema/components/access-list-object.json index d80eb06d8f..5fec77c0ac 100644 --- a/backend/schema/components/access-list-object.json +++ b/backend/schema/components/access-list-object.json @@ -36,6 +36,38 @@ "type": "integer", "minimum": 0, "example": 3 + }, + "clientcas": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "$ref": "../common.json#/properties/id" + }, + "created_on": { + "$ref": "../common.json#/properties/created_on" + }, + "modified_on": { + "$ref": "../common.json#/properties/modified_on" + }, + "access_list_id": { + "$ref": "../common.json#/properties/access_list_id" + }, + "certificate_id": { + "type": "integer", + "minimum": 1 + }, + "certificate": { + "$ref": "./certificate-object.json" + }, + "meta": { + "type": "object", + "example": {} + } + } + } } } } diff --git a/backend/schema/components/proxy-host-object.json b/backend/schema/components/proxy-host-object.json index 3ac6462136..8173d2a423 100644 --- a/backend/schema/components/proxy-host-object.json +++ b/backend/schema/components/proxy-host-object.json @@ -14,6 +14,7 @@ "ssl_forced", "caching_enabled", "block_exploits", + "drop_unauthorized", "advanced_config", "meta", "allow_websocket_upgrade", @@ -68,6 +69,11 @@ "block_exploits": { "$ref": "../common.json#/properties/block_exploits" }, + "drop_unauthorized": { + "description": "Drop unauthorized requests without responding", + "type": "boolean", + "example": false + }, "advanced_config": { "type": "string", "example": "" diff --git a/backend/schema/paths/nginx/access-lists/listID/put.json b/backend/schema/paths/nginx/access-lists/listID/put.json index 61e8044013..f90a69645c 100644 --- a/backend/schema/paths/nginx/access-lists/listID/put.json +++ b/backend/schema/paths/nginx/access-lists/listID/put.json @@ -44,6 +44,9 @@ }, "clients": { "$ref": "../../../../common.json#/properties/access_clients" + }, + "clientcas": { + "$ref": "../../../../common.json#/properties/access_clientcas" } } }, @@ -62,7 +65,8 @@ "directive": "allow", "address": "192.168.0.0/24" } - ] + ], + "clientcas": [1] } } } diff --git a/backend/schema/paths/nginx/access-lists/post.json b/backend/schema/paths/nginx/access-lists/post.json index 38b7003a1e..5aa32a0cf9 100644 --- a/backend/schema/paths/nginx/access-lists/post.json +++ b/backend/schema/paths/nginx/access-lists/post.json @@ -35,6 +35,9 @@ }, "clients": { "$ref": "../../../common.json#/properties/access_clients" + }, + "clientcas": { + "$ref": "../../../common.json#/properties/access_clientcas" } } }, @@ -53,7 +56,8 @@ "directive": "allow", "address": "192.168.0.0/24" } - ] + ], + "clientcas": [1] } } } diff --git a/backend/schema/paths/nginx/certificates/certID/upload/post.json b/backend/schema/paths/nginx/certificates/certID/upload/post.json index 2b1ba3e610..c817d2dd65 100644 --- a/backend/schema/paths/nginx/certificates/certID/upload/post.json +++ b/backend/schema/paths/nginx/certificates/certID/upload/post.json @@ -39,7 +39,7 @@ "schema": { "type": "object", "additionalProperties": false, - "required": ["certificate", "certificate_key"], + "required": ["certificate"], "properties": { "certificate": { "type": "string", diff --git a/backend/schema/paths/nginx/certificates/validate/post.json b/backend/schema/paths/nginx/certificates/validate/post.json index 9fa2bd126d..fffaced062 100644 --- a/backend/schema/paths/nginx/certificates/validate/post.json +++ b/backend/schema/paths/nginx/certificates/validate/post.json @@ -33,7 +33,7 @@ "schema": { "type": "object", "additionalProperties": false, - "required": ["certificate", "certificate_key"], + "required": ["certificate"], "properties": { "certificate": { "type": "object", diff --git a/backend/schema/paths/nginx/proxy-hosts/hostID/put.json b/backend/schema/paths/nginx/proxy-hosts/hostID/put.json index fc3198456b..7fd63760b3 100644 --- a/backend/schema/paths/nginx/proxy-hosts/hostID/put.json +++ b/backend/schema/paths/nginx/proxy-hosts/hostID/put.json @@ -65,6 +65,9 @@ "block_exploits": { "$ref": "../../../../components/proxy-host-object.json#/properties/block_exploits" }, + "drop_unauthorized": { + "$ref": "../../../../components/proxy-host-object.json#/properties/drop_unauthorized" + }, "caching_enabled": { "$ref": "../../../../components/proxy-host-object.json#/properties/caching_enabled" }, diff --git a/backend/schema/paths/nginx/proxy-hosts/post.json b/backend/schema/paths/nginx/proxy-hosts/post.json index 28ddad8fc2..118da18403 100644 --- a/backend/schema/paths/nginx/proxy-hosts/post.json +++ b/backend/schema/paths/nginx/proxy-hosts/post.json @@ -57,6 +57,9 @@ "block_exploits": { "$ref": "../../../components/proxy-host-object.json#/properties/block_exploits" }, + "drop_unauthorized": { + "$ref": "../../../components/proxy-host-object.json#/properties/drop_unauthorized" + }, "caching_enabled": { "$ref": "../../../components/proxy-host-object.json#/properties/caching_enabled" }, diff --git a/backend/templates/_access.conf b/backend/templates/_access.conf index 81983f3a3c..ebb1944c14 100644 --- a/backend/templates/_access.conf +++ b/backend/templates/_access.conf @@ -1,27 +1,43 @@ {% if access_list_id > 0 %} + set $auth_basic "Authorization required"; + {% if access_list.satisfy_any == 1 or access_list.satisfy_any == true %} + # Satisfy Any - any check can succeed. + {% if access_list.clients.size != 0 %} + if ( $access_list_{{ access_list_id }} = 1) { + set $auth_basic off; + } + {% endif %} + {% if access_list.clientcas.size != 0 %} + if ( $ssl_client_verify = "SUCCESS" ) { + set $auth_basic off; + } + {% endif %} + {% else %} + # Satisfy All - all checks must succeed. + {% if access_list.clients.size != 0 %} + if ( $access_list_{{ access_list_id }} = 0) { + return {% if drop_unauthorized == 1 or drop_unauthorized == true %}444{% else %}403{% endif %}; + } + {% endif %} + {% if access_list.clientcas.size != 0 %} + if ( $ssl_client_verify != "SUCCESS" ) { + return {% if drop_unauthorized == 1 or drop_unauthorized == true %}444{% else %}403{% endif %}; + } + {% endif %} + {% endif %} + {% if access_list.items.length > 0 %} # Authorization - auth_basic "Authorization required"; + auth_basic $auth_basic; auth_basic_user_file /data/access/{{ access_list_id }}; - - {% if access_list.pass_auth == 0 or access_list.pass_auth == false %} + {% if access_list.pass_auth == 0 or access_list.pass_auth == false %} proxy_set_header Authorization ""; - {% endif %} - - {% endif %} - - {% if access_list.clients.length > 0 %} - # Access Rules: {{ access_list.clients | size }} total - {% for client in access_list.clients %} - {{client | nginxAccessRule}} - {% endfor %} - deny all; - {% endif %} - - # Access checks must... - {% if access_list.satisfy_any == 1 or access_list.satisfy_any == true %} - satisfy any; + {% endif %} {% else %} - satisfy all; + {% if access_list.satisfy_any == 1 or access_list.satisfy_any == true %} + if ( $auth_basic != "off" ) { + return {% if drop_unauthorized == 1 or drop_unauthorized == true %}444{% else %}403{% endif %}; + } + {% endif %} {% endif %} {% endif %} diff --git a/backend/templates/_certificates.conf b/backend/templates/_certificates.conf index efcca5cd5d..f931877d1b 100644 --- a/backend/templates/_certificates.conf +++ b/backend/templates/_certificates.conf @@ -12,4 +12,10 @@ ssl_certificate_key /data/custom_ssl/npm-{{ certificate_id }}/privkey.pem; {% endif %} {% endif %} - +{% if access_list_id > 0 -%} +{% if access_list.clientcas.size > 0 %} + # Client Certificate Authorization ({{access_list.clientcas.size}} CAs) + ssl_client_certificate /data/clientca/{{ access_list_id }}; + ssl_verify_client optional; +{% endif %} +{% endif %} \ No newline at end of file diff --git a/backend/templates/access.conf b/backend/templates/access.conf new file mode 100644 index 0000000000..7d2d663d89 --- /dev/null +++ b/backend/templates/access.conf @@ -0,0 +1,12 @@ +# Access List Clients for {{ access_list.id }} - {{ access_list.name }} +geo $realip_remote_addr $access_list_{{ access_list.id }} { + default 0; +{% for client in access_list.clients %} +{% if client.directive == "allow" %} + {{client.address}} 1; +{% endif %} +{% if client.directive == "deny" %} + {{client.address}} 0; +{% endif %} +{% endfor %} +} diff --git a/docker/docker-compose.dev-user.yml b/docker/docker-compose.dev-user.yml new file mode 100644 index 0000000000..661805cf6c --- /dev/null +++ b/docker/docker-compose.dev-user.yml @@ -0,0 +1,70 @@ +# WARNING: This is a DEVELOPMENT docker-compose file, it should not be used for production. +# Important: this version is designed to work with user-namespaces, which allows running +# under podman. +version: '3.8' +services: + + npm: + image: nginxproxymanager:dev + container_name: npm_core + build: + context: ./ + dockerfile: ./dev/Dockerfile + ports: + - 3080:80 + - 3081:81 + - 3443:443 + networks: + - nginx_proxy_manager + environment: +# PUID: 1000 +# PGID: 1000 + FORCE_COLOR: 1 + # specifically for dev: + DEBUG: 'true' + DEVELOPMENT: 'true' + LE_STAGING: 'true' + # db: + DB_MYSQL_HOST: 'db' + DB_MYSQL_PORT: '3306' + DB_MYSQL_USER: 'npm' + DB_MYSQL_PASSWORD: 'npm' + DB_MYSQL_NAME: 'npm' + # DB_SQLITE_FILE: "/data/database.sqlite" + # DISABLE_IPV6: "true" + volumes: + - npm_data:/data + - le_data:/etc/letsencrypt + - ../backend:/app + - ../frontend:/app/frontend + - ../global:/app/global + depends_on: + - db + working_dir: /app + + db: + image: jc21/mariadb-aria + container_name: npm_db + ports: + - 33306:3306 + networks: + - nginx_proxy_manager + environment: + MYSQL_ROOT_PASSWORD: 'npm' + MYSQL_DATABASE: 'npm' + MYSQL_USER: 'npm' + MYSQL_PASSWORD: 'npm' + volumes: + - db_data:/var/lib/mysql + +volumes: + npm_data: + name: npm_core_data + le_data: + name: npm_le_data + db_data: + name: npm_db_data + +networks: + nginx_proxy_manager: + name: npm_network diff --git a/docker/rootfs/etc/nginx/nginx.conf b/docker/rootfs/etc/nginx/nginx.conf index bdba3b3055..53c6a79a13 100644 --- a/docker/rootfs/etc/nginx/nginx.conf +++ b/docker/rootfs/etc/nginx/nginx.conf @@ -86,6 +86,7 @@ http { # Files generated by NPM include /etc/nginx/conf.d/*.conf; + include /data/nginx/client/*.conf; include /data/nginx/default_host/*.conf; include /data/nginx/proxy_host/*.conf; include /data/nginx/redirection_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..5e1b8f95d7 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 @@ -20,6 +20,8 @@ mkdir -p \ /data/custom_ssl \ /data/logs \ /data/access \ + /data/clientca \ + /data/nginx/client \ /data/nginx/default_host \ /data/nginx/default_www \ /data/nginx/proxy_host \ diff --git a/frontend/src/api/backend/expansions.ts b/frontend/src/api/backend/expansions.ts index e098a49000..9f6711010a 100644 --- a/frontend/src/api/backend/expansions.ts +++ b/frontend/src/api/backend/expansions.ts @@ -1,4 +1,4 @@ -export type AccessListExpansion = "owner" | "items" | "clients"; +export type AccessListExpansion = "owner" | "items" | "clients" | "clientcas.certificate"; export type AuditLogExpansion = "user"; export type CertificateExpansion = "owner" | "proxy_hosts" | "redirection_hosts" | "dead_hosts" | "streams"; export type HostExpansion = "owner" | "certificate"; diff --git a/frontend/src/api/backend/models.ts b/frontend/src/api/backend/models.ts index 2ae0b08348..cd111cd542 100644 --- a/frontend/src/api/backend/models.ts +++ b/frontend/src/api/backend/models.ts @@ -58,6 +58,7 @@ export interface AccessList { owner?: User; items?: AccessListItem[]; clients?: AccessListClient[]; + clientcas?: AccessListClientCA[]; } export interface AccessListItem { @@ -81,6 +82,16 @@ export type AccessListClient = { meta?: Record; }; +export type AccessListClientCA = { + id?: number; + createdOn?: string; + modifiedOn?: string; + accessListId?: number; + certificateId: number; + certificate?: Certificate; + meta?: Record; +}; + export interface Certificate { id: number; createdOn: string; @@ -116,6 +127,7 @@ export interface ProxyHost { forwardPort: number; accessListId: number; certificateId: number; + dropUnauthorized: boolean; sslForced: boolean; cachingEnabled: boolean; blockExploits: boolean; diff --git a/frontend/src/components/Form/AccessField.tsx b/frontend/src/components/Form/AccessField.tsx index afcbd0cf7d..4f2e7c43a4 100644 --- a/frontend/src/components/Form/AccessField.tsx +++ b/frontend/src/components/Form/AccessField.tsx @@ -34,7 +34,7 @@ interface Props { } export function AccessField({ name = "accessListId", label = "access-list", id = "accessListId" }: Props) { const { locale } = useLocaleState(); - const { isLoading, isError, error, data } = useAccessLists(["owner", "items", "clients"]); + const { isLoading, isError, error, data } = useAccessLists(["owner", "items", "clients", "clientcas.certificate"]); const { setFieldValue } = useFormikContext(); const handleChange = (newValue: any, _actionMeta: ActionMeta) => { @@ -50,6 +50,7 @@ export function AccessField({ name = "accessListId", label = "access-list", id = { users: item?.items?.length, rules: item?.clients?.length, + clientcas: item?.clientcas?.length, date: item?.createdOn ? formatDateTime(item?.createdOn, locale) : "N/A", }, ), diff --git a/frontend/src/hooks/useProxyHost.ts b/frontend/src/hooks/useProxyHost.ts index 24e7f4fae2..c58a4af640 100644 --- a/frontend/src/hooks/useProxyHost.ts +++ b/frontend/src/hooks/useProxyHost.ts @@ -13,6 +13,7 @@ const fetchProxyHost = (id: number | "new") => { forwardPort: 0, accessListId: 0, certificateId: 0, + dropUnauthorized: false, sslForced: false, cachingEnabled: false, blockExploits: false, diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json index bb00ac3322..564f654ab7 100644 --- a/frontend/src/locale/src/en.json +++ b/frontend/src/locale/src/en.json @@ -65,6 +65,12 @@ "access-list.auth-count": { "defaultMessage": "{count} {count, plural, one {User} other {Users}}" }, + "access-list.client-cas": { + "defaultMessage": "Client Certificate Authorities" + }, + "access-list.clientca-count": { + "defaultMessage": "{count} Client {count, plural, one {CA} other {CAs}}" + }, "access-list.help-rules-last": { "defaultMessage": "When at least 1 rule exists, this deny all rule will be added last" }, @@ -87,7 +93,7 @@ "defaultMessage": "Satisfy Any" }, "access-list.subtitle": { - "defaultMessage": "{users} {users, plural, one {User} other {Users}}, {rules} {rules, plural, one {Rule} other {Rules}} - Created: {date}" + "defaultMessage": "{users} {users, plural, one {User} other {Users}}, {rules} {rules, plural, one {Rule} other {Rules}}, {clientcas} Client {clientcas, plural, one {CA} other {CAs}} - Created: {date}" }, "access-lists": { "defaultMessage": "Access Lists" @@ -173,6 +179,9 @@ "certificates": { "defaultMessage": "Certificates" }, + "certificates.clientca": { + "defaultMessage": "Client Certificate Authority" + }, "certificates.custom": { "defaultMessage": "Custom Certificate" }, @@ -245,6 +254,9 @@ "certificates.request.title": { "defaultMessage": "Request a new Certificate" }, + "clientca": { + "defaultMessage": "Client Certificate Authority" + }, "column.access": { "defaultMessage": "Access" }, @@ -254,6 +266,9 @@ "column.authorizations": { "defaultMessage": "Authorizations" }, + "column.client-cas": { + "defaultMessage": "Client CAs" + }, "column.custom-locations": { "defaultMessage": "Custom Locations" }, @@ -381,7 +396,7 @@ "defaultMessage": "Enabled" }, "error.access.at-least-one": { - "defaultMessage": "Either one Authorization or one Access Rule is required" + "defaultMessage": "Either one Authorization, one Access Rule or one Client Certificate Authority is required" }, "error.access.duplicate-usernames": { "defaultMessage": "Authorization Usernames must be unique" @@ -428,6 +443,9 @@ "host.flags.cache-assets": { "defaultMessage": "Cache Assets" }, + "host.flags.drop-unauthorized": { + "defaultMessage": "Drop Unauthorized Requests" + }, "host.flags.preserve-path": { "defaultMessage": "Preserve Path" }, diff --git a/frontend/src/locale/src/et.json b/frontend/src/locale/src/et.json index bb00ac3322..d1183a7594 100644 --- a/frontend/src/locale/src/et.json +++ b/frontend/src/locale/src/et.json @@ -381,7 +381,7 @@ "defaultMessage": "Enabled" }, "error.access.at-least-one": { - "defaultMessage": "Either one Authorization or one Access Rule is required" + "defaultMessage": "Either one Authorization, one Access Rule or one Client Certificate Authority is required" }, "error.access.duplicate-usernames": { "defaultMessage": "Authorization Usernames must be unique" diff --git a/frontend/src/modals/AccessListModal.tsx b/frontend/src/modals/AccessListModal.tsx index 79537f5cd5..6f4225dd1f 100644 --- a/frontend/src/modals/AccessListModal.tsx +++ b/frontend/src/modals/AccessListModal.tsx @@ -4,9 +4,9 @@ import { Field, Form, Formik } from "formik"; import { type ReactNode, useState } from "react"; import { Alert } from "react-bootstrap"; import Modal from "react-bootstrap/Modal"; -import type { AccessList, AccessListClient, AccessListItem } from "src/api/backend"; +import type { AccessList, AccessListClient, AccessListClientCA, AccessListItem, Certificate } from "src/api/backend"; import { AccessClientFields, BasicAuthFields, Button, Loading } from "src/components"; -import { useAccessList, useSetAccessList } from "src/hooks"; +import { useAccessList, useCertificates, useSetAccessList } from "src/hooks"; import { intl, T } from "src/locale"; import { validateString } from "src/modules/Validations"; import { showObjectSuccess } from "src/notifications"; @@ -19,14 +19,15 @@ interface Props extends InnerModalProps { id: number | "new"; } const AccessListModal = EasyModal.create(({ id, visible, remove }: Props) => { - const { data, isLoading, error } = useAccessList(id, ["items", "clients"]); + const { data, isLoading, error } = useAccessList(id, ["items", "clients", "clientcas.certificate"]); + const { data: certificates, isLoading: certificatesLoading } = useCertificates(); const { mutate: setAccessList } = useSetAccessList(); const [errorMsg, setErrorMsg] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const validate = (values: any): string | null => { // either Auths or Clients must be defined - if (values.items?.length === 0 && values.clients?.length === 0) { + if (values.items?.length === 0 && values.clients?.length === 0 && values.clientcas?.length === 0) { return intl.formatMessage({ id: "error.access.at-least-one" }); } @@ -68,6 +69,9 @@ const AccessListModal = EasyModal.create(({ id, visible, remove }: Props) => { directive: i.directive, address: i.address, })); + payload.clientcas = (values.clientcas || []).map((i: AccessListClientCA | number | string) => + typeof i === "object" ? i.certificateId : Number(i), + ); setAccessList(payload, { onError: (err: any) => setErrorMsg(), @@ -92,8 +96,8 @@ const AccessListModal = EasyModal.create(({ id, visible, remove }: Props) => { {error?.message || "Unknown error"} )} - {isLoading && } - {!isLoading && data && ( + {(isLoading || certificatesLoading) && } + {!isLoading && !certificatesLoading && data && ( { passAuth: data?.passAuth, items: data?.items || [], clients: data?.clients || [], + clientcas: data?.clientcas || [], } as AccessList } onSubmit={onSubmit} @@ -155,6 +160,18 @@ const AccessListModal = EasyModal.create(({ id, visible, remove }: Props) => { +
  • + + + +
  • @@ -262,6 +279,42 @@ const AccessListModal = EasyModal.create(({ id, visible, remove }: Props) => {
    +
    + + {({ field, form }: any) => ( +
    + + +
    + )} +
    +
    diff --git a/frontend/src/modals/CustomCertificateModal.tsx b/frontend/src/modals/CustomCertificateModal.tsx index deab1c5231..cc73aab945 100644 --- a/frontend/src/modals/CustomCertificateModal.tsx +++ b/frontend/src/modals/CustomCertificateModal.tsx @@ -11,11 +11,15 @@ import { T } from "src/locale"; import { validateString } from "src/modules/Validations"; import { showObjectSuccess } from "src/notifications"; -const showCustomCertificateModal = () => { - EasyModal.show(CustomCertificateModal); +const showCustomCertificateModal = (provider: "other" | "clientca" = "other") => { + EasyModal.show(CustomCertificateModal, { provider }); }; -const CustomCertificateModal = EasyModal.create(({ visible, remove }: InnerModalProps) => { +interface Props extends InnerModalProps { + provider?: "other" | "clientca"; +} + +const CustomCertificateModal = EasyModal.create(({ visible, remove, provider = "other" }: Props) => { const queryClient = useQueryClient(); const [errorMsg, setErrorMsg] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); @@ -30,7 +34,9 @@ const CustomCertificateModal = EasyModal.create(({ visible, remove }: InnerModal const formData = new FormData(); formData.append("certificate", certificate); - formData.append("certificate_key", certificateKey); + if (provider === "other") { + formData.append("certificate_key", certificateKey); + } if (intermediateCertificate !== null) { formData.append("intermediate_certificate", intermediateCertificate); } @@ -62,7 +68,7 @@ const CustomCertificateModal = EasyModal.create(({ visible, remove }: InnerModal initialValues={ { niceName: "", - provider: "other", + provider, certificate: null, certificateKey: null, intermediateCertificate: null, @@ -74,7 +80,10 @@ const CustomCertificateModal = EasyModal.create(({ visible, remove }: InnerModal
    - + @@ -111,37 +120,39 @@ const CustomCertificateModal = EasyModal.create(({ visible, remove }: InnerModal )} - - {({ field, form }: any) => ( -
    - - { - form.setFieldValue( - field.name, - event.currentTarget.files?.length - ? event.currentTarget.files[0] - : null, - ); - }} - /> - {form.errors.certificateKey ? ( -
    - {form.errors.certificateKey && form.touched.certificateKey - ? form.errors.certificateKey - : null} -
    - ) : null} -
    - )} -
    + {provider === "other" ? ( + + {({ field, form }: any) => ( +
    + + { + form.setFieldValue( + field.name, + event.currentTarget.files?.length + ? event.currentTarget.files[0] + : null, + ); + }} + /> + {form.errors.certificateKey ? ( +
    + {form.errors.certificateKey && form.touched.certificateKey + ? form.errors.certificateKey + : null} +
    + ) : null} +
    + )} +
    + ) : null} {({ field, form }: any) => (
    diff --git a/frontend/src/modals/ProxyHostModal.tsx b/frontend/src/modals/ProxyHostModal.tsx index 3227be51bb..80978489f7 100644 --- a/frontend/src/modals/ProxyHostModal.tsx +++ b/frontend/src/modals/ProxyHostModal.tsx @@ -79,6 +79,7 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => { accessListId: data?.accessListId || 0, cachingEnabled: data?.cachingEnabled || false, blockExploits: data?.blockExploits || false, + dropUnauthorized: data?.dropUnauthorized || false, allowWebsocketUpgrade: data?.allowWebsocketUpgrade || false, // Locations tab locations: data?.locations || [], @@ -305,6 +306,29 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
    +
    + +
    ); diff --git a/frontend/src/pages/Certificates/TableWrapper.tsx b/frontend/src/pages/Certificates/TableWrapper.tsx index 14dfc417fd..bc473d9a00 100644 --- a/frontend/src/pages/Certificates/TableWrapper.tsx +++ b/frontend/src/pages/Certificates/TableWrapper.tsx @@ -132,6 +132,16 @@ export default function TableWrapper() { > + { + e.preventDefault(); + showCustomCertificateModal("clientca"); + }} + > + + ) : null} diff --git a/scripts/.common.sh b/scripts/.common.sh index 2b59e6ed5a..32354a4973 100644 --- a/scripts/.common.sh +++ b/scripts/.common.sh @@ -10,6 +10,17 @@ YELLOW='\E[1;33m' export BLUE CYAN GREEN RED RESET YELLOW +# Identify docker-like command +# Ensure docker exists +if command -v docker 1>/dev/null 2>&1; then + export docker=docker +elif command -v podman 1>/dev/null 2>&1; then + export docker=podman +else + echo -e "${RED}❯ docker or podman command is not available${RESET}" + exit 1 +fi + # Docker Compose COMPOSE_PROJECT_NAME="npm2dev" COMPOSE_FILE="docker/docker-compose.dev.yml" diff --git a/scripts/buildx b/scripts/buildx index 8838290af4..b9702b6e41 100755 --- a/scripts/buildx +++ b/scripts/buildx @@ -13,10 +13,10 @@ if [ "$BUILD_COMMIT" == "" ]; then fi # Buildx Builder -docker buildx create --name "${BUILDX_NAME:-npm}" || echo -docker buildx use "${BUILDX_NAME:-npm}" +$docker buildx create --name "${BUILDX_NAME:-npm}" || echo +$docker buildx use "${BUILDX_NAME:-npm}" -docker buildx build \ +$docker buildx build \ --build-arg BUILD_VERSION="${BUILD_VERSION:-dev}" \ --build-arg BUILD_COMMIT="${BUILD_COMMIT:-notset}" \ --build-arg BUILD_DATE="$(date '+%Y-%m-%d %T %Z')" \ @@ -30,6 +30,6 @@ docker buildx build \ . rc=$? -docker buildx rm "${BUILDX_NAME:-npm}" +$docker buildx rm "${BUILDX_NAME:-npm}" echo -e "${BLUE}❯ ${GREEN}Multiarch build Complete${RESET}" exit $rc diff --git a/scripts/wait-healthy b/scripts/wait-healthy index 503d6a908e..fdd6e735cf 100755 --- a/scripts/wait-healthy +++ b/scripts/wait-healthy @@ -19,7 +19,7 @@ echo -e "${BLUE}❯ ${CYAN}Waiting for healthy: ${YELLOW}${SERVICE}${RESET}" until [ "${HEALTHY}" = "healthy" ]; do echo -n "." sleep 1 - HEALTHY="$(docker inspect -f '{{.State.Health.Status}}' $SERVICE)" + HEALTHY="$($docker inspect -f '{{.State.Health.Status}}' $SERVICE)" ((LOOPCOUNT++)) if [ "$LOOPCOUNT" == "$LIMIT" ]; then diff --git a/test/cypress/e2e/api/ProxyHosts.cy.js b/test/cypress/e2e/api/ProxyHosts.cy.js index 5f437cf950..c1bc60bbaa 100644 --- a/test/cypress/e2e/api/ProxyHosts.cy.js +++ b/test/cypress/e2e/api/ProxyHosts.cy.js @@ -27,6 +27,7 @@ describe('Proxy Hosts endpoints', () => { advanced_config: '', locations: [], block_exploits: false, + drop_unauthorized: false, caching_enabled: false, allow_websocket_upgrade: false, http2_support: false, From 98fcb7702171d34a9a1465a0b4a2ddf73c4c13da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Paulo=20Ros?= Date: Tue, 19 May 2026 08:20:23 -0700 Subject: [PATCH 2/3] Fix Locale --- frontend/src/locale/src/en.json | 8 ++++---- frontend/src/locale/src/et.json | 2 +- frontend/src/locale/src/pt.json | 8 +++++++- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json index 564f654ab7..c4dd3d9034 100644 --- a/frontend/src/locale/src/en.json +++ b/frontend/src/locale/src/en.json @@ -66,7 +66,7 @@ "defaultMessage": "{count} {count, plural, one {User} other {Users}}" }, "access-list.client-cas": { - "defaultMessage": "Client Certificate Authorities" + "defaultMessage": "Client Certificate" }, "access-list.clientca-count": { "defaultMessage": "{count} Client {count, plural, one {CA} other {CAs}}" @@ -180,7 +180,7 @@ "defaultMessage": "Certificates" }, "certificates.clientca": { - "defaultMessage": "Client Certificate Authority" + "defaultMessage": "Client Certificate" }, "certificates.custom": { "defaultMessage": "Custom Certificate" @@ -255,7 +255,7 @@ "defaultMessage": "Request a new Certificate" }, "clientca": { - "defaultMessage": "Client Certificate Authority" + "defaultMessage": "Client Certificate" }, "column.access": { "defaultMessage": "Access" @@ -396,7 +396,7 @@ "defaultMessage": "Enabled" }, "error.access.at-least-one": { - "defaultMessage": "Either one Authorization, one Access Rule or one Client Certificate Authority is required" + "defaultMessage": "Either one Authorization, one Access Rule or one Client Certificate is required" }, "error.access.duplicate-usernames": { "defaultMessage": "Authorization Usernames must be unique" diff --git a/frontend/src/locale/src/et.json b/frontend/src/locale/src/et.json index d1183a7594..9f51acf0b7 100644 --- a/frontend/src/locale/src/et.json +++ b/frontend/src/locale/src/et.json @@ -381,7 +381,7 @@ "defaultMessage": "Enabled" }, "error.access.at-least-one": { - "defaultMessage": "Either one Authorization, one Access Rule or one Client Certificate Authority is required" + "defaultMessage": "Either one Authorization, one Access Rule or one Client Certificate is required" }, "error.access.duplicate-usernames": { "defaultMessage": "Authorization Usernames must be unique" diff --git a/frontend/src/locale/src/pt.json b/frontend/src/locale/src/pt.json index 0a789f484e..d537ddb3ea 100644 --- a/frontend/src/locale/src/pt.json +++ b/frontend/src/locale/src/pt.json @@ -8,6 +8,12 @@ "access-list.auth-count": { "defaultMessage": "{count} {count, plural, one {Utilizador} other {Utilizadores}}" }, + "access-list.client-cas": { + "defaultMessage": "Certificado de Cliente" + }, + "access-list.clientca-count": { + "defaultMessage": "{count} {count, plural, one {Certificado} other {Certificados}} de Cliente" + }, "access-list.help-rules-last": { "defaultMessage": "Quando existir pelo menos 1 regra, esta regra de negação geral será aplicada em último lugar" }, @@ -306,7 +312,7 @@ "defaultMessage": "Ativado" }, "error.access.at-least-one": { - "defaultMessage": "É necessária pelo menos uma Autorização ou uma Regra de Acesso" + "defaultMessage": "É necessária pelo menos uma Autorização, uma Regra de Acesso ou um Certificado de Cliente" }, "error.access.duplicate-usernames": { "defaultMessage": "Os nomes de utilizador de autorização devem ser únicos" From 4b59ea0185e437d16753c105c528fd00ab642c95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joa=CC=83o=20Paulo=20Ros?= Date: Tue, 19 May 2026 08:40:25 -0700 Subject: [PATCH 3/3] Fix Access List Modal client certificate selection --- frontend/src/locale/src/en.json | 9 ++ frontend/src/locale/src/pt.json | 9 ++ frontend/src/modals/AccessListModal.tsx | 202 +++++++++++++++++++----- 3 files changed, 184 insertions(+), 36 deletions(-) diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json index c4dd3d9034..5d4b1cfd6f 100644 --- a/frontend/src/locale/src/en.json +++ b/frontend/src/locale/src/en.json @@ -65,6 +65,12 @@ "access-list.auth-count": { "defaultMessage": "{count} {count, plural, one {User} other {Users}}" }, + "access-list.authorized-client-cas": { + "defaultMessage": "Authorized Client Certificate" + }, + "access-list.client-ca-search-placeholder": { + "defaultMessage": "Client Certificate" + }, "access-list.client-cas": { "defaultMessage": "Client Certificate" }, @@ -77,6 +83,9 @@ "access-list.help.rules-order": { "defaultMessage": "Note that the allow and deny directives will be applied in the order they are defined." }, + "access-list.no-client-cas": { + "defaultMessage": "No Client Certificate added." + }, "access-list.pass-auth": { "defaultMessage": "Pass Auth to Upstream" }, diff --git a/frontend/src/locale/src/pt.json b/frontend/src/locale/src/pt.json index d537ddb3ea..e5af0b1cc2 100644 --- a/frontend/src/locale/src/pt.json +++ b/frontend/src/locale/src/pt.json @@ -8,6 +8,12 @@ "access-list.auth-count": { "defaultMessage": "{count} {count, plural, one {Utilizador} other {Utilizadores}}" }, + "access-list.authorized-client-cas": { + "defaultMessage": "Certificado de Cliente Autorizado" + }, + "access-list.client-ca-search-placeholder": { + "defaultMessage": "Certificado de Cliente" + }, "access-list.client-cas": { "defaultMessage": "Certificado de Cliente" }, @@ -20,6 +26,9 @@ "access-list.help.rules-order": { "defaultMessage": "Nota: as diretivas allow e deny são aplicadas pela ordem em que forem definidas." }, + "access-list.no-client-cas": { + "defaultMessage": "Nenhum Certificado de Cliente adicionado." + }, "access-list.pass-auth": { "defaultMessage": "Passar Autenticação para o Upstream" }, diff --git a/frontend/src/modals/AccessListModal.tsx b/frontend/src/modals/AccessListModal.tsx index 6f4225dd1f..61d9278df5 100644 --- a/frontend/src/modals/AccessListModal.tsx +++ b/frontend/src/modals/AccessListModal.tsx @@ -1,13 +1,16 @@ +import { IconShield, IconTrash } from "@tabler/icons-react"; import cn from "classnames"; import EasyModal, { type InnerModalProps } from "ez-modal-react"; -import { Field, Form, Formik } from "formik"; +import { Field, Form, Formik, useFormikContext } from "formik"; import { type ReactNode, useState } from "react"; import { Alert } from "react-bootstrap"; import Modal from "react-bootstrap/Modal"; +import Select, { type ActionMeta, components, type MultiValue, type OptionProps, type SingleValue } from "react-select"; import type { AccessList, AccessListClient, AccessListClientCA, AccessListItem, Certificate } from "src/api/backend"; import { AccessClientFields, BasicAuthFields, Button, Loading } from "src/components"; +import { useLocaleState } from "src/context"; import { useAccessList, useCertificates, useSetAccessList } from "src/hooks"; -import { intl, T } from "src/locale"; +import { formatDateTime, intl, T } from "src/locale"; import { validateString } from "src/modules/Validations"; import { showObjectSuccess } from "src/notifications"; @@ -18,6 +21,166 @@ const showAccessListModal = (id: number | "new") => { interface Props extends InnerModalProps { id: number | "new"; } + +interface ClientCAOption { + readonly value: number; + readonly label: string; + readonly subLabel: string; + readonly certificate: Certificate; + readonly icon: React.ReactNode; +} + +const ClientCAOption = (props: OptionProps) => ( + +
    +
    + {props.data.icon} {props.data.label} +
    +
    {props.data.subLabel}
    +
    +
    +); + +const getClientCAId = (value: AccessListClientCA | number | string) => + typeof value === "object" ? value.certificateId : Number(value); + +const ClientCAFields = ({ certificates = [] }: { certificates?: Certificate[] }) => { + const { locale } = useLocaleState(); + const { setFieldValue } = useFormikContext(); + const [selectedOption, setSelectedOption] = useState(null); + + return ( + + {({ field }: any) => { + const selectedValues = field.value || []; + const selectedIds = new Set(selectedValues.map(getClientCAId)); + const clientCACertificates = certificates.filter((cert: Certificate) => cert.provider === "clientca"); + const options: ClientCAOption[] = clientCACertificates + .filter((cert: Certificate) => !selectedIds.has(cert.id)) + .map((cert: Certificate) => ({ + value: cert.id, + label: cert.niceName, + subLabel: intl.formatMessage( + { id: "expires.on" }, + { date: cert.expiresOn ? formatDateTime(cert.expiresOn, locale) : "N/A" }, + ), + certificate: cert, + icon: , + })); + const selectedCertificates = selectedValues.map((value: AccessListClientCA | number | string) => { + const certificateId = getClientCAId(value); + const expandedCertificate = typeof value === "object" ? value.certificate : undefined; + return expandedCertificate || certificates.find((cert: Certificate) => cert.id === certificateId); + }); + + const handleAdd = () => { + if (!selectedOption) { + return; + } + setFieldValue(field.name, [...selectedValues, selectedOption.value]); + setSelectedOption(null); + }; + + const handleRemove = (certificateId: number) => { + setFieldValue( + field.name, + selectedValues.filter((value: AccessListClientCA | number | string) => getClientCAId(value) !== certificateId), + ); + }; + + return ( +
    + +
    +
    + - typeof value === "object" - ? String(value.certificateId) - : String(value), - )} - onChange={(event) => { - const selected = Array.from(event.currentTarget.selectedOptions).map( - (option) => Number(option.value), - ); - form.setFieldValue(field.name, selected); - }} - > - {(certificates || []) - .filter((cert: Certificate) => cert.provider === "clientca") - .map((cert: Certificate) => ( - - ))} - -
    - )} - +