From 6acbe047edcd585edb265ba3f45c5d0545999f1b Mon Sep 17 00:00:00 2001 From: Steven Alexson Date: Wed, 3 Jun 2026 11:04:12 -0400 Subject: [PATCH 01/67] Add credential vault, external stores, and automation API Store encrypted DNS credentials on /data with optional Vault/AWS/Azure/ Infisical/HTTP providers via OIDC. Add API keys, async certificate jobs, signed webhooks, certificate PUT, UI, docs, and Cypress coverage. Co-authored-by: Cursor --- README.md | 4 + backend/internal/api-key.js | 120 +++++++ backend/internal/audit-log.js | 3 +- backend/internal/certificate.js | 75 ++++- backend/internal/credential-provider.js | 132 ++++++++ backend/internal/credential.js | 200 +++++++++++ backend/internal/job.js | 114 +++++++ backend/internal/proxy-host.js | 12 + backend/internal/user.js | 1 + backend/internal/webhook.js | 112 +++++++ backend/lib/access.js | 9 +- backend/lib/access/api_keys-create.json | 7 + backend/lib/access/api_keys-delete.json | 7 + backend/lib/access/api_keys-list.json | 7 + .../access/credential_providers-create.json | 3 + .../access/credential_providers-delete.json | 3 + .../lib/access/credential_providers-get.json | 3 + .../lib/access/credential_providers-list.json | 3 + .../access/credential_providers-update.json | 3 + backend/lib/access/credentials-create.json | 23 ++ backend/lib/access/credentials-delete.json | 23 ++ backend/lib/access/credentials-get.json | 23 ++ backend/lib/access/credentials-list.json | 23 ++ backend/lib/access/credentials-update.json | 23 ++ backend/lib/access/webhooks-create.json | 7 + backend/lib/access/webhooks-delete.json | 7 + backend/lib/access/webhooks-list.json | 7 + backend/lib/express/jwt-decode.js | 33 +- backend/lib/express/jwt.js | 6 +- backend/lib/secrets/crypto.js | 98 ++++++ backend/lib/secrets/format.js | 26 ++ backend/lib/secrets/oidc.js | 74 +++++ backend/lib/secrets/provider-storage.js | 28 ++ backend/lib/secrets/resolve.js | 69 ++++ backend/lib/secrets/resolvers/aws.js | 152 +++++++++ backend/lib/secrets/resolvers/azure.js | 35 ++ backend/lib/secrets/resolvers/external.js | 19 ++ backend/lib/secrets/resolvers/http.js | 40 +++ backend/lib/secrets/resolvers/index.js | 61 ++++ backend/lib/secrets/resolvers/infisical.js | 38 +++ backend/lib/secrets/resolvers/vault.js | 47 +++ backend/lib/secrets/scrub.js | 46 +++ backend/lib/secrets/storage.js | 61 ++++ backend/lib/utils.js | 23 +- .../migrations/20260603120000_credentials.js | 41 +++ .../migrations/20260603120100_automation.js | 65 ++++ .../20260603120200_credential_providers.js | 27 ++ backend/models/api_key.js | 30 ++ backend/models/credential.js | 26 ++ backend/models/credential_provider.js | 33 ++ backend/models/job.js | 30 ++ backend/models/webhook_endpoint.js | 30 ++ backend/routes/api-keys.js | 68 ++++ backend/routes/credential-providers.js | 135 ++++++++ backend/routes/credentials.js | 122 +++++++ backend/routes/jobs.js | 45 +++ backend/routes/main.js | 10 + backend/routes/nginx/certificates.js | 55 +++- backend/routes/webhooks.js | 70 ++++ .../schema/components/certificate-object.json | 24 ++ .../schema/components/credential-object.json | 35 ++ .../schema/components/permission-object.json | 6 + backend/schema/components/user-object.json | 9 +- .../paths/api-keys/apiKeyID/delete.json | 9 + backend/schema/paths/api-keys/get.json | 16 + backend/schema/paths/api-keys/post.json | 25 ++ .../paths/credential-providers/get.json | 1 + .../paths/credential-providers/post.json | 1 + .../providerID/delete.json | 1 + .../credential-providers/providerID/get.json | 1 + .../credential-providers/providerID/put.json | 1 + .../providerID/test-resolve/post.json | 1 + .../providerID/test/post.json | 1 + .../credentials/credentialID/delete.json | 16 + .../paths/credentials/credentialID/get.json | 16 + .../paths/credentials/credentialID/put.json | 32 ++ .../credentials/credentialID/test/post.json | 23 ++ backend/schema/paths/credentials/get.json | 19 ++ .../credentials/migrate-legacy/post.json | 40 +++ backend/schema/paths/credentials/post.json | 37 +++ backend/schema/paths/jobs/get.json | 6 + backend/schema/paths/jobs/jobID/get.json | 9 + .../paths/nginx/certificates/certID/put.json | 35 ++ backend/schema/paths/webhooks/get.json | 9 + backend/schema/paths/webhooks/post.json | 27 ++ .../paths/webhooks/webhookID/delete.json | 9 + backend/schema/swagger.json | 117 +++++++ backend/setup.js | 31 +- .../s6-overlay/s6-rc.d/prepare/20-paths.sh | 2 + docs/src/advanced/automation-api.md | 139 ++++++++ frontend/src/Router.tsx | 2 + frontend/src/api/backend/createApiKey.ts | 10 + frontend/src/api/backend/createCredential.ts | 10 + .../api/backend/createCredentialProvider.ts | 6 + frontend/src/api/backend/createWebhook.ts | 12 + frontend/src/api/backend/deleteApiKey.ts | 5 + frontend/src/api/backend/deleteCredential.ts | 5 + .../api/backend/deleteCredentialProvider.ts | 5 + frontend/src/api/backend/deleteWebhook.ts | 5 + frontend/src/api/backend/getApiKeys.ts | 15 + .../src/api/backend/getCredentialProviders.ts | 17 + frontend/src/api/backend/getCredentials.ts | 14 + frontend/src/api/backend/getWebhooks.ts | 13 + frontend/src/api/backend/index.ts | 16 + .../api/backend/migrateLegacyCredentials.ts | 5 + frontend/src/api/backend/models.ts | 1 + .../src/api/backend/testCredentialProvider.ts | 9 + frontend/src/api/backend/updateCredential.ts | 9 + .../api/backend/updateCredentialProvider.ts | 9 + .../src/components/Form/DNSProviderFields.tsx | 116 ++++++- frontend/src/components/SiteMenu.tsx | 9 + frontend/src/hooks/index.ts | 2 + frontend/src/hooks/useCredentialProviders.ts | 9 + frontend/src/hooks/useCredentials.ts | 9 + .../src/locale/src/HelpDoc/en/Credentials.md | 21 ++ frontend/src/locale/src/HelpDoc/en/index.ts | 1 + frontend/src/locale/src/en.json | 78 +++++ frontend/src/modals/CredentialModal.tsx | 121 +++++++ frontend/src/modals/PermissionsModal.tsx | 9 + frontend/src/modals/index.ts | 1 + frontend/src/modules/Permissions.ts | 4 +- frontend/src/pages/Credentials/Table.tsx | 85 +++++ .../src/pages/Credentials/TableWrapper.tsx | 114 +++++++ frontend/src/pages/Credentials/index.tsx | 11 + frontend/src/pages/Settings/ApiKeys.tsx | 127 +++++++ .../pages/Settings/CredentialProviders.tsx | 311 ++++++++++++++++++ frontend/src/pages/Settings/Layout.tsx | 44 ++- frontend/src/pages/Settings/Webhooks.tsx | 133 ++++++++ test/cypress/e2e/api/ApiKeys.cy.js | 51 +++ test/cypress/e2e/api/Credentials.cy.js | 60 ++++ 130 files changed, 4731 insertions(+), 48 deletions(-) create mode 100644 backend/internal/api-key.js create mode 100644 backend/internal/credential-provider.js create mode 100644 backend/internal/credential.js create mode 100644 backend/internal/job.js create mode 100644 backend/internal/webhook.js create mode 100644 backend/lib/access/api_keys-create.json create mode 100644 backend/lib/access/api_keys-delete.json create mode 100644 backend/lib/access/api_keys-list.json create mode 100644 backend/lib/access/credential_providers-create.json create mode 100644 backend/lib/access/credential_providers-delete.json create mode 100644 backend/lib/access/credential_providers-get.json create mode 100644 backend/lib/access/credential_providers-list.json create mode 100644 backend/lib/access/credential_providers-update.json create mode 100644 backend/lib/access/credentials-create.json create mode 100644 backend/lib/access/credentials-delete.json create mode 100644 backend/lib/access/credentials-get.json create mode 100644 backend/lib/access/credentials-list.json create mode 100644 backend/lib/access/credentials-update.json create mode 100644 backend/lib/access/webhooks-create.json create mode 100644 backend/lib/access/webhooks-delete.json create mode 100644 backend/lib/access/webhooks-list.json create mode 100644 backend/lib/secrets/crypto.js create mode 100644 backend/lib/secrets/format.js create mode 100644 backend/lib/secrets/oidc.js create mode 100644 backend/lib/secrets/provider-storage.js create mode 100644 backend/lib/secrets/resolve.js create mode 100644 backend/lib/secrets/resolvers/aws.js create mode 100644 backend/lib/secrets/resolvers/azure.js create mode 100644 backend/lib/secrets/resolvers/external.js create mode 100644 backend/lib/secrets/resolvers/http.js create mode 100644 backend/lib/secrets/resolvers/index.js create mode 100644 backend/lib/secrets/resolvers/infisical.js create mode 100644 backend/lib/secrets/resolvers/vault.js create mode 100644 backend/lib/secrets/scrub.js create mode 100644 backend/lib/secrets/storage.js create mode 100644 backend/migrations/20260603120000_credentials.js create mode 100644 backend/migrations/20260603120100_automation.js create mode 100644 backend/migrations/20260603120200_credential_providers.js create mode 100644 backend/models/api_key.js create mode 100644 backend/models/credential.js create mode 100644 backend/models/credential_provider.js create mode 100644 backend/models/job.js create mode 100644 backend/models/webhook_endpoint.js create mode 100644 backend/routes/api-keys.js create mode 100644 backend/routes/credential-providers.js create mode 100644 backend/routes/credentials.js create mode 100644 backend/routes/jobs.js create mode 100644 backend/routes/webhooks.js create mode 100644 backend/schema/components/credential-object.json create mode 100644 backend/schema/paths/api-keys/apiKeyID/delete.json create mode 100644 backend/schema/paths/api-keys/get.json create mode 100644 backend/schema/paths/api-keys/post.json create mode 100644 backend/schema/paths/credential-providers/get.json create mode 100644 backend/schema/paths/credential-providers/post.json create mode 100644 backend/schema/paths/credential-providers/providerID/delete.json create mode 100644 backend/schema/paths/credential-providers/providerID/get.json create mode 100644 backend/schema/paths/credential-providers/providerID/put.json create mode 100644 backend/schema/paths/credential-providers/providerID/test-resolve/post.json create mode 100644 backend/schema/paths/credential-providers/providerID/test/post.json create mode 100644 backend/schema/paths/credentials/credentialID/delete.json create mode 100644 backend/schema/paths/credentials/credentialID/get.json create mode 100644 backend/schema/paths/credentials/credentialID/put.json create mode 100644 backend/schema/paths/credentials/credentialID/test/post.json create mode 100644 backend/schema/paths/credentials/get.json create mode 100644 backend/schema/paths/credentials/migrate-legacy/post.json create mode 100644 backend/schema/paths/credentials/post.json create mode 100644 backend/schema/paths/jobs/get.json create mode 100644 backend/schema/paths/jobs/jobID/get.json create mode 100644 backend/schema/paths/nginx/certificates/certID/put.json create mode 100644 backend/schema/paths/webhooks/get.json create mode 100644 backend/schema/paths/webhooks/post.json create mode 100644 backend/schema/paths/webhooks/webhookID/delete.json create mode 100644 docs/src/advanced/automation-api.md create mode 100644 frontend/src/api/backend/createApiKey.ts create mode 100644 frontend/src/api/backend/createCredential.ts create mode 100644 frontend/src/api/backend/createCredentialProvider.ts create mode 100644 frontend/src/api/backend/createWebhook.ts create mode 100644 frontend/src/api/backend/deleteApiKey.ts create mode 100644 frontend/src/api/backend/deleteCredential.ts create mode 100644 frontend/src/api/backend/deleteCredentialProvider.ts create mode 100644 frontend/src/api/backend/deleteWebhook.ts create mode 100644 frontend/src/api/backend/getApiKeys.ts create mode 100644 frontend/src/api/backend/getCredentialProviders.ts create mode 100644 frontend/src/api/backend/getCredentials.ts create mode 100644 frontend/src/api/backend/getWebhooks.ts create mode 100644 frontend/src/api/backend/migrateLegacyCredentials.ts create mode 100644 frontend/src/api/backend/testCredentialProvider.ts create mode 100644 frontend/src/api/backend/updateCredential.ts create mode 100644 frontend/src/api/backend/updateCredentialProvider.ts create mode 100644 frontend/src/hooks/useCredentialProviders.ts create mode 100644 frontend/src/hooks/useCredentials.ts create mode 100644 frontend/src/locale/src/HelpDoc/en/Credentials.md create mode 100644 frontend/src/modals/CredentialModal.tsx create mode 100644 frontend/src/pages/Credentials/Table.tsx create mode 100644 frontend/src/pages/Credentials/TableWrapper.tsx create mode 100644 frontend/src/pages/Credentials/index.tsx create mode 100644 frontend/src/pages/Settings/ApiKeys.tsx create mode 100644 frontend/src/pages/Settings/CredentialProviders.tsx create mode 100644 frontend/src/pages/Settings/Webhooks.tsx create mode 100644 test/cypress/e2e/api/ApiKeys.cy.js create mode 100644 test/cypress/e2e/api/Credentials.cy.js diff --git a/README.md b/README.md index 5cd9be1c96..ce3300f9f8 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ so that the barrier to entry here is low. - Access Lists and basic HTTP Authentication for your hosts - Advanced Nginx configuration available for super users - User management, permissions, and audit log +- **Credential vault** on the `/data` volume (encrypted DNS API tokens, optional external stores via OIDC) +- **Automation API** (API keys, async certificate jobs, signed webhooks) — see [docs](docs/src/advanced/automation-api.md) ::: warning `armv7` is no longer supported in version 2.14+. This is due to Nodejs dropping support for armhf. Please @@ -74,6 +76,8 @@ services: This is the bare minimum configuration required. See the [documentation](https://nginxproxymanager.com/setup/) for more. +**Important:** Mount `./data:/data` so the credential vault, encryption keys, and automation settings persist across container restarts. Optional: set `NPM_SECRETS_ENCRYPTION_KEY` (32+ bytes, base64) to control encryption instead of the auto-generated key under `/data/keys/`. + 3. Bring up your stack by running ```bash diff --git a/backend/internal/api-key.js b/backend/internal/api-key.js new file mode 100644 index 0000000000..ba6b2954fb --- /dev/null +++ b/backend/internal/api-key.js @@ -0,0 +1,120 @@ +import crypto from "node:crypto"; +import bcrypt from "bcrypt"; +import apiKeyModel from "../models/api_key.js"; +import now from "../models/now_helper.js"; +import errs from "../lib/error.js"; +import utils from "../lib/utils.js"; +import internalAuditLog from "./audit-log.js"; + +const omissions = () => ["is_deleted", "key_hash", "key_prefix"]; + +const hashApiKey = (rawKey) => bcrypt.hash(rawKey, 13); +const verifyApiKey = (rawKey, hash) => bcrypt.compare(rawKey, hash); + +const internalApiKey = { + create: async (access, data) => { + await access.can("api_keys:create", data); + + const prefix = crypto.randomBytes(4).toString("hex"); + const secret = crypto.randomBytes(24).toString("base64url"); + const rawKey = `npmak_${prefix}_${secret}`; + const keyHash = await hashApiKey(rawKey); + + const row = await apiKeyModel.query().insertAndFetch({ + name: data.name, + key_prefix: prefix, + key_hash: keyHash, + owner_user_id: access.token.getUserId(1), + permissions: data.permissions || {}, + expires_on: data.expires_on || null, + }); + + const result = utils.omitRow(omissions())(row); + result.key = rawKey; + + await internalAuditLog.add(access, { + action: "created", + object_type: "api-key", + object_id: row.id, + meta: { name: row.name }, + }); + + return result; + }, + + getAll: async (access) => { + await access.can("api_keys:list"); + return apiKeyModel + .query() + .where("is_deleted", 0) + .orderBy("name", "ASC") + .then(utils.omitRows(omissions())); + }, + + delete: async (access, data) => { + await access.can("api_keys:delete", data.id); + const row = await apiKeyModel + .query() + .where("id", data.id) + .andWhere("is_deleted", 0) + .first(); + + if (!row) { + throw new errs.ItemNotFoundError(data.id); + } + + await apiKeyModel.query().patchAndFetchById(row.id, { is_revoked: 1, is_deleted: 1 }); + + await internalAuditLog.add(access, { + action: "deleted", + object_type: "api-key", + object_id: row.id, + meta: { name: row.name }, + }); + + return true; + }, + + /** + * @param {string} rawKey + */ + authenticate: async (rawKey) => { + if (!rawKey?.startsWith("npmak_")) { + throw new errs.AuthError("Invalid API key"); + } + + const parts = rawKey.split("_"); + if (parts.length < 3) { + throw new errs.AuthError("Invalid API key"); + } + + const prefix = parts[1]; + const row = await apiKeyModel + .query() + .where("key_prefix", prefix) + .andWhere("is_deleted", 0) + .andWhere("is_revoked", 0) + .first(); + + if (!row) { + throw new errs.AuthError("Invalid API key"); + } + + if (row.expires_on && new Date(row.expires_on) < new Date()) { + throw new errs.AuthError("API key expired"); + } + + const valid = await verifyApiKey(rawKey, row.key_hash); + if (!valid) { + throw new errs.AuthError("Invalid API key"); + } + + await apiKeyModel.query().patchAndFetchById(row.id, { + last_used_at: now(), + }); + + return row; + }, +}; + +export default internalApiKey; diff --git a/backend/internal/audit-log.js b/backend/internal/audit-log.js index 02700dc5da..ef8171d787 100644 --- a/backend/internal/audit-log.js +++ b/backend/internal/audit-log.js @@ -1,4 +1,5 @@ import errs from "../lib/error.js"; +import { scrubAuditMeta } from "../lib/secrets/scrub.js"; import { castJsonIfNeed } from "../lib/helpers.js"; import auditLogModel from "../models/audit-log.js"; @@ -94,7 +95,7 @@ const internalAuditLog = { action: data.action, object_type: data.object_type || "", object_id: data.object_id || 0, - meta: data.meta || {}, + meta: scrubAuditMeta(data.meta || {}), }); }, }; diff --git a/backend/internal/certificate.js b/backend/internal/certificate.js index 6498422c61..542e77d7c1 100644 --- a/backend/internal/certificate.js +++ b/backend/internal/certificate.js @@ -18,15 +18,18 @@ import userModel from "../models/user.js"; import internalAuditLog from "./audit-log.js"; import internalHost from "./host.js"; import internalNginx from "./nginx.js"; +import internalWebhook from "./webhook.js"; +import { materializeCertbotCredentials } from "../lib/secrets/resolve.js"; const letsencryptConfig = "/etc/letsencrypt.ini"; const certbotCommand = "certbot"; const certbotLogsDir = "/data/logs"; const certbotWorkDir = "/tmp/letsencrypt-lib"; -const omissions = () => { - return ["is_deleted", "owner.is_deleted", "meta.dns_provider_credentials"]; -}; +const omissions = () => ["is_deleted", "owner.is_deleted"]; +const metaOmissions = () => ["dns_provider_credentials"]; + +const omitCertificate = () => utils.omitRow(omissions(), metaOmissions()); const internalCertificate = { allowedSslFiles: ["certificate", "certificate_key", "intermediate_certificate"], @@ -114,6 +117,20 @@ const internalCertificate = { * @param {Object} data * @returns {Promise} */ + prepareMetaForStorage: (meta) => { + if (!meta || typeof meta !== "object") { + return meta; + } + const prepared = { ...meta }; + if ( + (prepared.credential_ref?.type === "internal" && prepared.credential_ref.id) || + (prepared.credential_ref?.type === "external" && prepared.credential_ref.provider_id) + ) { + delete prepared.dns_provider_credentials; + } + return prepared; + }, + create: async (access, data) => { await access.can("certificates:create", data); data.owner_user_id = access.token.getUserId(1); @@ -122,6 +139,10 @@ const internalCertificate = { data.nice_name = data.domain_names.join(", "); } + if (data.meta) { + data.meta = internalCertificate.prepareMetaForStorage(data.meta); + } + // this command really should clean up and delete the cert if it can't fully succeed const certificate = await certificateModel.query().insertAndFetch(data); @@ -197,7 +218,7 @@ const internalCertificate = { .patchAndFetchById(certificate.id, { expires_on: moment(certInfo.dates.to, "X").format("YYYY-MM-DD HH:mm:ss"), }) - .then(utils.omitRow(omissions())); + .then(omitCertificate()); // Add cert data for audit log savedRow.meta = _.assign({}, savedRow.meta, { @@ -210,30 +231,44 @@ const internalCertificate = { } catch (err) { // Delete the certificate from the database if it was not created successfully await certificateModel.query().deleteById(certificate.id); + void internalWebhook.dispatch("certificate.failed", { + id: certificate.id, + domain_names: certificate.domain_names, + error: err.message, + }); throw err; } } } catch (err) { // Delete the certificate here. This is a hard delete, since it never existed properly - await certificateModel.query().deleteById(certificate.id); + if (certificate?.id) { + await certificateModel.query().deleteById(certificate.id); + void internalWebhook.dispatch("certificate.failed", { + id: certificate.id, + domain_names: data.domain_names, + error: err.message, + }); + } throw err; } data.meta = _.assign({}, data.meta || {}, certificate.meta); // Add to audit log - await internalCertificate.addCreatedAuditLog(access, certificate.id, utils.omitRow(omissions())(data)); + await internalCertificate.addCreatedAuditLog(access, certificate.id, omitCertificate()(data)); - return utils.omitRow(omissions())(certificate); + return omitCertificate()(certificate); }, addCreatedAuditLog: async (access, certificate_id, meta) => { + const sanitized = omitCertificate()(meta); await internalAuditLog.add(access, { action: "created", object_type: "certificate", object_id: certificate_id, - meta: meta, + meta: sanitized, }); + void internalWebhook.dispatch("certificate.created", { id: certificate_id }); }, /** @@ -255,10 +290,14 @@ const internalCertificate = { ); } + if (data.meta) { + data.meta = internalCertificate.prepareMetaForStorage(data.meta); + } + const savedRow = await certificateModel .query() .patchAndFetchById(row.id, data) - .then(utils.omitRow(omissions())); + .then(omitCertificate()); savedRow.meta = internalCertificate.cleanMeta(savedRow.meta); data.meta = internalCertificate.cleanMeta(data.meta); @@ -275,6 +314,7 @@ const internalCertificate = { object_id: row.id, meta: _.omit(data, ["expires_on"]), // this prevents json circular reference because expires_on might be raw }); + void internalWebhook.dispatch("certificate.updated", { id: savedRow.id }); return savedRow; }, @@ -304,7 +344,7 @@ const internalCertificate = { query.withGraphFetched(`[${data.expand.join(", ")}]`); } - const row = await query.then(utils.omitRow(omissions())); + const row = await query.then(omitCertificate()); if (!row?.id) { throw new error.ItemNotFoundError(data.id); } @@ -412,8 +452,9 @@ const internalCertificate = { action: "deleted", object_type: "certificate", object_id: row.id, - meta: _.omit(row, omissions()), + meta: omitCertificate()(row), }); + void internalWebhook.dispatch("certificate.deleted", { id: row.id }); if (row.provider === "letsencrypt") { // Revoke the cert @@ -755,6 +796,9 @@ const internalCertificate = { * @returns {Object} */ cleanMeta: (meta, remove) => { + if (meta && typeof meta === "object") { + delete meta.dns_provider_credentials; + } internalCertificate.allowedSslFiles.map((key) => { if (typeof meta[key] !== "undefined" && meta[key]) { if (remove) { @@ -828,9 +872,7 @@ const internalCertificate = { `Requesting LetsEncrypt certificates via ${dnsPlugin.name} for Cert #${certificate.id}: ${certificate.domain_names.join(", ")}`, ); - const credentialsLocation = `/etc/letsencrypt/credentials/credentials-${certificate.id}`; - fs.mkdirSync("/etc/letsencrypt/credentials", { recursive: true }); - fs.writeFileSync(credentialsLocation, certificate.meta.dns_provider_credentials, { mode: 0o600 }); + const credentialsLocation = await materializeCertbotCredentials(certificate); // Whether the plugin has a ---credentials argument const hasConfigArg = certificate.meta.dns_provider !== "route53"; @@ -917,8 +959,9 @@ const internalCertificate = { action: "renewed", object_type: "certificate", object_id: updatedCertificate.id, - meta: updatedCertificate, + meta: omitCertificate()(updatedCertificate), }); + void internalWebhook.dispatch("certificate.renewed", { id: updatedCertificate.id }); return updatedCertificate; } @@ -977,6 +1020,8 @@ const internalCertificate = { throw Error(`Unknown DNS provider '${certificate.meta.dns_provider}'`); } + await materializeCertbotCredentials(certificate); + logger.info( `Renewing LetsEncrypt certificates via ${dnsPlugin.name} for Cert #${certificate.id}: ${certificate.domain_names.join(", ")}`, ); diff --git a/backend/internal/credential-provider.js b/backend/internal/credential-provider.js new file mode 100644 index 0000000000..82667d5a24 --- /dev/null +++ b/backend/internal/credential-provider.js @@ -0,0 +1,132 @@ +import errs from "../lib/error.js"; +import { getProviderAccessToken, loadProvider, resolveFromProvider } from "../lib/secrets/resolvers/index.js"; +import { + deleteProviderSecret, + readProviderSecret, + writeProviderSecret, +} from "../lib/secrets/provider-storage.js"; +import utils from "../lib/utils.js"; +import credentialProviderModel from "../models/credential_provider.js"; +import internalAuditLog from "./audit-log.js"; + +const omissions = () => ["is_deleted", "oidc_client_secret_path"]; + +const PROVIDER_TYPES = ["vault", "aws", "azure", "infisical", "http"]; + +const internalCredentialProvider = { + create: async (access, data) => { + await access.can("credential_providers:create", data); + + if (!PROVIDER_TYPES.includes(data.type)) { + throw new errs.ValidationError(`Invalid provider type. Must be one of: ${PROVIDER_TYPES.join(", ")}`); + } + + const row = await credentialProviderModel.query().insertAndFetch({ + name: data.name, + type: data.type, + owner_user_id: access.token.getUserId(1), + oidc_issuer: data.oidc_issuer || null, + oidc_client_id: data.oidc_client_id || null, + oidc_audience: data.oidc_audience || null, + oidc_scope: data.oidc_scope || null, + oidc_client_secret_path: "pending.enc", + meta: data.meta || {}, + }); + + if (data.oidc_client_secret) { + const secretPath = writeProviderSecret(row.id, data.oidc_client_secret); + await credentialProviderModel.query().patchAndFetchById(row.id, { + oidc_client_secret_path: secretPath, + }); + } + + const saved = await internalCredentialProvider.get(access, { id: row.id }); + + await internalAuditLog.add(access, { + action: "created", + object_type: "credential-provider", + object_id: saved.id, + meta: { name: saved.name, type: saved.type }, + }); + + return saved; + }, + + update: async (access, data) => { + await access.can("credential_providers:update", data.id); + const row = await loadProvider(data.id); + + const patch = {}; + if (typeof data.name !== "undefined") patch.name = data.name; + if (typeof data.oidc_issuer !== "undefined") patch.oidc_issuer = data.oidc_issuer; + if (typeof data.oidc_client_id !== "undefined") patch.oidc_client_id = data.oidc_client_id; + if (typeof data.oidc_audience !== "undefined") patch.oidc_audience = data.oidc_audience; + if (typeof data.oidc_scope !== "undefined") patch.oidc_scope = data.oidc_scope; + if (typeof data.meta !== "undefined") patch.meta = data.meta; + + if (typeof data.oidc_client_secret !== "undefined" && data.oidc_client_secret) { + const secretPath = writeProviderSecret(row.id, data.oidc_client_secret); + patch.oidc_client_secret_path = secretPath; + } + + await credentialProviderModel.query().patchAndFetchById(row.id, patch); + return internalCredentialProvider.get(access, { id: row.id }); + }, + + get: async (access, data) => { + await access.can("credential_providers:get", data.id); + const row = await credentialProviderModel + .query() + .where("id", data.id) + .andWhere("is_deleted", 0) + .first() + .then(utils.omitRow(omissions())); + + if (!row) { + throw new errs.ItemNotFoundError(data.id); + } + row.has_oidc_secret = !!readProviderSecret(row.id); + return row; + }, + + getAll: async (access) => { + await access.can("credential_providers:list"); + const rows = await credentialProviderModel + .query() + .where("is_deleted", 0) + .orderBy("name", "ASC") + .then(utils.omitRows(omissions())); + + return rows.map((row) => ({ + ...row, + has_oidc_secret: !!readProviderSecret(row.id), + })); + }, + + delete: async (access, data) => { + await access.can("credential_providers:delete", data.id); + const row = await loadProvider(data.id); + await credentialProviderModel.query().patchAndFetchById(row.id, { is_deleted: 1 }); + deleteProviderSecret(row.id); + return true; + }, + + test: async (access, data) => { + await access.can("credential_providers:get", data.id); + const provider = await loadProvider(data.id); + await getProviderAccessToken(provider); + return { ok: true, type: provider.type, name: provider.name }; + }, + + testResolve: async (access, data) => { + await access.can("credential_providers:get", data.id); + const provider = await loadProvider(data.id); + const ini = await resolveFromProvider(provider, { + path: data.path, + field: data.field, + }); + return { ok: true, bytes: ini.length }; + }, +}; + +export default internalCredentialProvider; diff --git a/backend/internal/credential.js b/backend/internal/credential.js new file mode 100644 index 0000000000..99ca5be505 --- /dev/null +++ b/backend/internal/credential.js @@ -0,0 +1,200 @@ +import dnsPlugins from "../certbot/dns-plugins.json" with { type: "json" }; +import errs from "../lib/error.js"; +import { validateDnsCredentialsFormat } from "../lib/secrets/resolve.js"; +import { deleteCredentialFile, readCredentialFile, writeCredentialFile } from "../lib/secrets/storage.js"; +import utils from "../lib/utils.js"; +import certificateModel from "../models/certificate.js"; +import credentialModel from "../models/credential.js"; +import internalAuditLog from "./audit-log.js"; + +const omissions = () => ["is_deleted", "storage_path", "encryption_key_id"]; + +const internalCredential = { + create: async (access, data) => { + await access.can("credentials:create", data); + validateDnsCredentialsFormat(data.dns_provider, data.credentials); + + const row = await credentialModel.query().insertAndFetch({ + name: data.name, + dns_provider: data.dns_provider, + owner_user_id: access.token.getUserId(1), + storage_path: "pending.enc", + encryption_key_id: "v1", + }); + + const { storagePath, keyId } = writeCredentialFile(row.id, data.credentials); + const saved = await credentialModel + .query() + .patchAndFetchById(row.id, { + storage_path: storagePath, + encryption_key_id: keyId, + }) + .then(utils.omitRow(omissions())); + + await internalAuditLog.add(access, { + action: "created", + object_type: "credential", + object_id: saved.id, + meta: { name: saved.name, dns_provider: saved.dns_provider }, + }); + + return saved; + }, + + update: async (access, data) => { + await access.can("credentials:update", data.id); + const row = await internalCredential.get(access, { id: data.id }); + + const patch = {}; + if (typeof data.name !== "undefined") { + patch.name = data.name; + } + if (typeof data.dns_provider !== "undefined") { + patch.dns_provider = data.dns_provider; + } + + if (typeof data.credentials !== "undefined") { + const provider = data.dns_provider || row.dns_provider; + validateDnsCredentialsFormat(provider, data.credentials); + const { storagePath, keyId } = writeCredentialFile(row.id, data.credentials); + patch.storage_path = storagePath; + patch.encryption_key_id = keyId; + } + + const saved = await credentialModel.query().patchAndFetchById(row.id, patch).then(utils.omitRow(omissions())); + + await internalAuditLog.add(access, { + action: "updated", + object_type: "credential", + object_id: saved.id, + meta: { name: saved.name, dns_provider: saved.dns_provider }, + }); + + return saved; + }, + + get: async (access, data) => { + await access.can("credentials:get", data.id); + const row = await credentialModel + .query() + .where("id", data.id) + .andWhere("is_deleted", 0) + .first() + .then(utils.omitRow(omissions())); + + if (!row) { + throw new errs.ItemNotFoundError(data.id); + } + return row; + }, + + getAll: async (access) => { + await access.can("credentials:list"); + return credentialModel + .query() + .where("is_deleted", 0) + .orderBy("name", "ASC") + .then(utils.omitRows(omissions())); + }, + + delete: async (access, data) => { + await access.can("credentials:delete", data.id); + const row = await internalCredential.get(access, { id: data.id }); + + await credentialModel.query().patchAndFetchById(row.id, { is_deleted: 1 }); + deleteCredentialFile(row.id); + + await internalAuditLog.add(access, { + action: "deleted", + object_type: "credential", + object_id: row.id, + meta: { name: row.name, dns_provider: row.dns_provider }, + }); + + return true; + }, + + test: async (access, data) => { + await access.can("credentials:get", data.id); + const row = await credentialModel + .query() + .where("id", data.id) + .andWhere("is_deleted", 0) + .first(); + + if (!row) { + throw new errs.ItemNotFoundError(data.id); + } + + readCredentialFile(row.id); + const plugin = dnsPlugins[row.dns_provider]; + return { + ok: true, + dns_provider: row.dns_provider, + plugin_name: plugin?.name || row.dns_provider, + }; + }, + + /** + * Import plaintext DNS credentials from certificate.meta into the vault. + * @param {Access} access + * @param {Object} [data] + * @param {boolean} [data.dry_run] + */ + migrateLegacy: async (access, data = {}) => { + await access.can("credentials:create", {}); + + const certs = await certificateModel + .query() + .where("is_deleted", 0) + .andWhere("provider", "letsencrypt"); + + const results = []; + for (const cert of certs) { + const meta = cert.meta || {}; + if (!meta.dns_challenge || typeof meta.dns_provider_credentials !== "string") { + continue; + } + if (meta.credential_ref?.type) { + continue; + } + + const entry = { + certificate_id: cert.id, + domain_names: cert.domain_names, + dns_provider: meta.dns_provider, + status: "skipped", + }; + + if (!data.dry_run) { + const cred = await internalCredential.create(access, { + name: `Migrated cert #${cert.id}`, + dns_provider: meta.dns_provider, + credentials: meta.dns_provider_credentials, + }); + + const newMeta = { + ...meta, + credential_ref: { type: "internal", id: cred.id }, + }; + delete newMeta.dns_provider_credentials; + + await certificateModel.query().patchAndFetchById(cert.id, { meta: newMeta }); + entry.credential_id = cred.id; + entry.status = "migrated"; + } else { + entry.status = "would_migrate"; + } + + results.push(entry); + } + + return { + dry_run: !!data.dry_run, + count: results.length, + results, + }; + }, +}; + +export default internalCredential; diff --git a/backend/internal/job.js b/backend/internal/job.js new file mode 100644 index 0000000000..ec7f2d30ba --- /dev/null +++ b/backend/internal/job.js @@ -0,0 +1,114 @@ +import internalCertificate from "./certificate.js"; +import internalWebhook from "./webhook.js"; +import jobModel from "../models/job.js"; +import now from "../models/now_helper.js"; +import errs from "../lib/error.js"; +import utils from "../lib/utils.js"; + +const jobQueue = []; +let jobProcessing = false; + +const omissions = () => []; + +const processQueue = async () => { + if (jobProcessing || !jobQueue.length) { + return; + } + jobProcessing = true; + const { jobId, access, handler, onFail } = jobQueue.shift(); + try { + await jobModel.query().patchAndFetchById(jobId, { status: "running" }); + const result = await handler(); + await jobModel.query().patchAndFetchById(jobId, { + status: "completed", + result, + finished_on: now(), + }); + } catch (err) { + await jobModel.query().patchAndFetchById(jobId, { + status: "failed", + error: err.message, + finished_on: now(), + }); + if (typeof onFail === "function") { + try { + await onFail(err); + } catch { + // ignore webhook errors + } + } + } finally { + jobProcessing = false; + setImmediate(processQueue); + } +}; + +const internalJob = { + enqueue: async (access, type, payload, handler, onFail) => { + const row = await jobModel.query().insertAndFetch({ + owner_user_id: access.token.getUserId(1), + type, + status: "pending", + payload, + }); + + jobQueue.push({ jobId: row.id, access, handler, onFail }); + setImmediate(processQueue); + + return utils.omitRow(omissions())(row); + }, + + getAll: async (access, data = {}) => { + const query = jobModel.query().orderBy("id", "DESC").limit(data.limit || 50); + + if (!access.isAdmin()) { + query.where("owner_user_id", access.token.getUserId(1)); + } + + return query.then(utils.omitRows(omissions())); + }, + + get: async (access, data) => { + const row = await jobModel.query().where("id", data.id).first(); + if (!row) { + throw new errs.ItemNotFoundError(data.id); + } + if (row.owner_user_id !== access.token.getUserId(1) && !access.isAdmin()) { + throw new errs.PermissionError("Permission Denied"); + } + return utils.omitRow(omissions())(row); + }, + + runCertificateCreate: (access, payload) => { + return internalJob.enqueue( + access, + "certificate.create", + payload, + () => internalCertificate.create(access, payload), + async (err) => { + void internalWebhook.dispatch("certificate.failed", { + error: err.message, + domain_names: payload.domain_names, + }); + }, + ); + }, + + runCertificateRenew: (access, payload) => { + const certId = payload.id; + return internalJob.enqueue( + access, + "certificate.renew", + payload, + () => internalCertificate.renew(access, payload), + async (err) => { + void internalWebhook.dispatch("certificate.failed", { + id: certId, + error: err.message, + }); + }, + ); + }, +}; + +export default internalJob; diff --git a/backend/internal/proxy-host.js b/backend/internal/proxy-host.js index 2c159d48ad..e2917e18e0 100644 --- a/backend/internal/proxy-host.js +++ b/backend/internal/proxy-host.js @@ -7,6 +7,7 @@ import internalAuditLog from "./audit-log.js"; import internalCertificate from "./certificate.js"; import internalHost from "./host.js"; import internalNginx from "./nginx.js"; +import internalWebhook from "./webhook.js"; const omissions = () => { return ["is_deleted", "owner.is_deleted"]; @@ -102,6 +103,7 @@ const internalProxyHost = { meta: thisData, }) .then(() => { + void internalWebhook.dispatch("proxy_host.created", utils.omitRow(omissions())(row)); return row; }); }); @@ -198,6 +200,7 @@ const internalProxyHost = { meta: thisData, }) .then(() => { + void internalWebhook.dispatch("proxy_host.updated", saved_row); return saved_row; }); }); @@ -303,6 +306,9 @@ const internalProxyHost = { object_id: row.id, meta: _.omit(row, omissions()), }); + }) + .then(() => { + void internalWebhook.dispatch("proxy_host.deleted", { id: row.id }); }); }) .then(() => { @@ -354,6 +360,9 @@ const internalProxyHost = { object_id: row.id, meta: _.omit(row, omissions()), }); + }) + .then(() => { + void internalWebhook.dispatch("proxy_host.enabled", { id: row.id }); }); }) .then(() => { @@ -404,6 +413,9 @@ const internalProxyHost = { object_id: row.id, meta: _.omit(row, omissions()), }); + }) + .then(() => { + void internalWebhook.dispatch("proxy_host.disabled", { id: row.id }); }); }) .then(() => { diff --git a/backend/internal/user.js b/backend/internal/user.js index 56a5ea8598..be65c1f523 100644 --- a/backend/internal/user.js +++ b/backend/internal/user.js @@ -59,6 +59,7 @@ const internalUser = { streams: "manage", access_lists: "manage", certificates: "manage", + credentials: "manage", }); user = await internalUser.get(access, { id: user.id, expand: ["permissions"] }); diff --git a/backend/internal/webhook.js b/backend/internal/webhook.js new file mode 100644 index 0000000000..4c18fb82cc --- /dev/null +++ b/backend/internal/webhook.js @@ -0,0 +1,112 @@ +import crypto from "node:crypto"; +import { encrypt, decrypt } from "../lib/secrets/crypto.js"; +import { ensureCredentialDirs } from "../lib/secrets/storage.js"; +import fs from "node:fs"; +import path from "node:path"; +import webhookEndpointModel from "../models/webhook_endpoint.js"; +import utils from "../lib/utils.js"; +import { debug, express as logger } from "../logger.js"; + +const WEBHOOK_SECRETS_DIR = "/data/credentials/webhooks"; + +const omissions = () => ["is_deleted", "secret_path"]; + +const writeWebhookSecret = (id, secret) => { + ensureCredentialDirs(); + fs.mkdirSync(WEBHOOK_SECRETS_DIR, { recursive: true, mode: 0o700 }); + const { buffer } = encrypt(secret); + const filePath = path.join(WEBHOOK_SECRETS_DIR, `${id}.enc`); + fs.writeFileSync(filePath, buffer, { mode: 0o600 }); + return `${id}.enc`; +}; + +const readWebhookSecret = (id) => { + const filePath = path.join(WEBHOOK_SECRETS_DIR, `${id}.enc`); + if (!fs.existsSync(filePath)) { + return ""; + } + return decrypt(fs.readFileSync(filePath)); +}; + +const internalWebhook = { + create: async (access, data) => { + await access.can("webhooks:create", data); + const signingSecret = data.secret || crypto.randomBytes(32).toString("hex"); + + const row = await webhookEndpointModel.query().insertAndFetch({ + name: data.name, + url: data.url, + events: data.events, + owner_user_id: access.token.getUserId(1), + secret_path: "pending.enc", + is_enabled: data.is_enabled !== false ? 1 : 0, + }); + + const secretPath = writeWebhookSecret(row.id, signingSecret); + const saved = await webhookEndpointModel + .query() + .patchAndFetchById(row.id, { secret_path: secretPath }) + .then(utils.omitRow(omissions())); + + if (data.secret) { + delete saved.secret; + } else { + saved.secret = signingSecret; + } + + return saved; + }, + + getAll: async (access) => { + await access.can("webhooks:list"); + return webhookEndpointModel + .query() + .where("is_deleted", 0) + .orderBy("name", "ASC") + .then(utils.omitRows(omissions())); + }, + + delete: async (access, data) => { + await access.can("webhooks:delete", data.id); + await webhookEndpointModel.query().patchAndFetchById(data.id, { is_deleted: 1 }); + return true; + }, + + dispatch: async (event, payload) => { + const endpoints = await webhookEndpointModel + .query() + .where("is_deleted", 0) + .andWhere("is_enabled", 1); + + for (const endpoint of endpoints) { + if (!endpoint.events?.includes(event) && !endpoint.events?.includes("*")) { + continue; + } + + try { + const secret = readWebhookSecret(endpoint.id); + const body = JSON.stringify({ event, payload, timestamp: new Date().toISOString() }); + const signature = crypto.createHmac("sha256", secret).update(body).digest("hex"); + + const response = await fetch(endpoint.url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-NPM-Event": event, + "X-NPM-Signature": `sha256=${signature}`, + }, + body, + signal: AbortSignal.timeout(10000), + }); + + if (!response.ok) { + debug(logger, `Webhook ${endpoint.id} returned ${response.status}`); + } + } catch (err) { + debug(logger, `Webhook ${endpoint.id} failed: ${err.message}`); + } + } + }, +}; + +export default internalWebhook; diff --git a/backend/lib/access.js b/backend/lib/access.js index a4dec5c4dd..4e041da31f 100644 --- a/backend/lib/access.js +++ b/backend/lib/access.js @@ -22,7 +22,7 @@ import errs from "./error.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -export default function (tokenString) { +export default function (tokenString, permissionsOverride = null) { const Token = TokenModel(); let tokenData = null; let initialised = false; @@ -30,6 +30,7 @@ export default function (tokenString) { let allowInternalAccess = false; let userRoles = []; let permissions = {}; + const overridePermissions = permissionsOverride; /** * Loads the Token object from the token string @@ -83,6 +84,9 @@ export default function (tokenString) { initialised = true; userRoles = user.roles; permissions = user.permissions; + if (overridePermissions && typeof overridePermissions === "object") { + permissions = _.assign({}, permissions, overridePermissions); + } } else { throw new errs.AuthError("User cannot be loaded for Token"); } @@ -199,6 +203,8 @@ export default function (tokenString) { return { token: Token, + isAdmin: () => _.indexOf(userRoles, "admin") !== -1, + /** * * @param {Boolean} [allowInternal] @@ -241,6 +247,7 @@ export default function (tokenString) { permission_streams: permissions.streams, permission_access_lists: permissions.access_lists, permission_certificates: permissions.certificates, + permission_credentials: permissions.credentials, }, }; diff --git a/backend/lib/access/api_keys-create.json b/backend/lib/access/api_keys-create.json new file mode 100644 index 0000000000..aeadc94ba9 --- /dev/null +++ b/backend/lib/access/api_keys-create.json @@ -0,0 +1,7 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + } + ] +} diff --git a/backend/lib/access/api_keys-delete.json b/backend/lib/access/api_keys-delete.json new file mode 100644 index 0000000000..aeadc94ba9 --- /dev/null +++ b/backend/lib/access/api_keys-delete.json @@ -0,0 +1,7 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + } + ] +} diff --git a/backend/lib/access/api_keys-list.json b/backend/lib/access/api_keys-list.json new file mode 100644 index 0000000000..aeadc94ba9 --- /dev/null +++ b/backend/lib/access/api_keys-list.json @@ -0,0 +1,7 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + } + ] +} diff --git a/backend/lib/access/credential_providers-create.json b/backend/lib/access/credential_providers-create.json new file mode 100644 index 0000000000..3206784491 --- /dev/null +++ b/backend/lib/access/credential_providers-create.json @@ -0,0 +1,3 @@ +{ + "anyOf": [{ "$ref": "roles#/definitions/admin" }] +} diff --git a/backend/lib/access/credential_providers-delete.json b/backend/lib/access/credential_providers-delete.json new file mode 100644 index 0000000000..3206784491 --- /dev/null +++ b/backend/lib/access/credential_providers-delete.json @@ -0,0 +1,3 @@ +{ + "anyOf": [{ "$ref": "roles#/definitions/admin" }] +} diff --git a/backend/lib/access/credential_providers-get.json b/backend/lib/access/credential_providers-get.json new file mode 100644 index 0000000000..3206784491 --- /dev/null +++ b/backend/lib/access/credential_providers-get.json @@ -0,0 +1,3 @@ +{ + "anyOf": [{ "$ref": "roles#/definitions/admin" }] +} diff --git a/backend/lib/access/credential_providers-list.json b/backend/lib/access/credential_providers-list.json new file mode 100644 index 0000000000..3206784491 --- /dev/null +++ b/backend/lib/access/credential_providers-list.json @@ -0,0 +1,3 @@ +{ + "anyOf": [{ "$ref": "roles#/definitions/admin" }] +} diff --git a/backend/lib/access/credential_providers-update.json b/backend/lib/access/credential_providers-update.json new file mode 100644 index 0000000000..3206784491 --- /dev/null +++ b/backend/lib/access/credential_providers-update.json @@ -0,0 +1,3 @@ +{ + "anyOf": [{ "$ref": "roles#/definitions/admin" }] +} diff --git a/backend/lib/access/credentials-create.json b/backend/lib/access/credentials-create.json new file mode 100644 index 0000000000..08da91b034 --- /dev/null +++ b/backend/lib/access/credentials-create.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_credentials", "roles"], + "properties": { + "permission_credentials": { + "$ref": "perms#/definitions/manage" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/backend/lib/access/credentials-delete.json b/backend/lib/access/credentials-delete.json new file mode 100644 index 0000000000..08da91b034 --- /dev/null +++ b/backend/lib/access/credentials-delete.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_credentials", "roles"], + "properties": { + "permission_credentials": { + "$ref": "perms#/definitions/manage" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/backend/lib/access/credentials-get.json b/backend/lib/access/credentials-get.json new file mode 100644 index 0000000000..b96d4944a4 --- /dev/null +++ b/backend/lib/access/credentials-get.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_credentials", "roles"], + "properties": { + "permission_credentials": { + "$ref": "perms#/definitions/view" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/backend/lib/access/credentials-list.json b/backend/lib/access/credentials-list.json new file mode 100644 index 0000000000..b96d4944a4 --- /dev/null +++ b/backend/lib/access/credentials-list.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_credentials", "roles"], + "properties": { + "permission_credentials": { + "$ref": "perms#/definitions/view" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/backend/lib/access/credentials-update.json b/backend/lib/access/credentials-update.json new file mode 100644 index 0000000000..08da91b034 --- /dev/null +++ b/backend/lib/access/credentials-update.json @@ -0,0 +1,23 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": ["permission_credentials", "roles"], + "properties": { + "permission_credentials": { + "$ref": "perms#/definitions/manage" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user"] + } + } + } + } + ] +} diff --git a/backend/lib/access/webhooks-create.json b/backend/lib/access/webhooks-create.json new file mode 100644 index 0000000000..aeadc94ba9 --- /dev/null +++ b/backend/lib/access/webhooks-create.json @@ -0,0 +1,7 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + } + ] +} diff --git a/backend/lib/access/webhooks-delete.json b/backend/lib/access/webhooks-delete.json new file mode 100644 index 0000000000..aeadc94ba9 --- /dev/null +++ b/backend/lib/access/webhooks-delete.json @@ -0,0 +1,7 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + } + ] +} diff --git a/backend/lib/access/webhooks-list.json b/backend/lib/access/webhooks-list.json new file mode 100644 index 0000000000..aeadc94ba9 --- /dev/null +++ b/backend/lib/access/webhooks-list.json @@ -0,0 +1,7 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + } + ] +} diff --git a/backend/lib/express/jwt-decode.js b/backend/lib/express/jwt-decode.js index 90fe241e46..19afbfcb9f 100644 --- a/backend/lib/express/jwt-decode.js +++ b/backend/lib/express/jwt-decode.js @@ -1,10 +1,41 @@ import Access from "../access.js"; +import errs from "../error.js"; +import internalApiKey from "../../internal/api-key.js"; +import userModel from "../../models/user.js"; +import TokenModel from "../../models/token.js"; export default () => { return async (_, res, next) => { try { res.locals.access = null; - const access = new Access(res.locals.token || null); + let token = res.locals.token || null; + let permissionsOverride = null; + + if (res.locals.apiKey) { + const apiKeyRow = await internalApiKey.authenticate(res.locals.apiKey); + const user = await userModel + .query() + .where("id", apiKeyRow.owner_user_id) + .andWhere("is_deleted", 0) + .andWhere("is_disabled", 0) + .first(); + + if (!user) { + throw new errs.AuthError("API key owner not found"); + } + + const Token = TokenModel(); + const signed = await Token.create({ + iss: "api-key", + scope: ["user"], + attrs: { id: user.id }, + expiresIn: "1h", + }); + token = signed.token; + permissionsOverride = apiKeyRow.permissions; + } + + const access = new Access(token, permissionsOverride); await access.load(); res.locals.access = access; next(); diff --git a/backend/lib/express/jwt.js b/backend/lib/express/jwt.js index ce907b6de9..dc2de1cd20 100644 --- a/backend/lib/express/jwt.js +++ b/backend/lib/express/jwt.js @@ -4,7 +4,11 @@ export default function () { const parts = req.headers.authorization.split(" "); if (parts && parts[0] === "Bearer" && parts[1]) { - res.locals.token = parts[1]; + if (parts[1].startsWith("npmak_")) { + res.locals.apiKey = parts[1]; + } else { + res.locals.token = parts[1]; + } } } diff --git a/backend/lib/secrets/crypto.js b/backend/lib/secrets/crypto.js new file mode 100644 index 0000000000..376cb5148b --- /dev/null +++ b/backend/lib/secrets/crypto.js @@ -0,0 +1,98 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +const ALGORITHM = "aes-256-gcm"; +const IV_LENGTH = 12; +const KEY_LENGTH = 32; +const CURRENT_KEY_ID = "v1"; + +const secretsKeyFile = "/data/keys/secrets.json"; + +const getMasterKey = () => { + const envKey = process.env.NPM_SECRETS_ENCRYPTION_KEY; + if (envKey) { + const buf = Buffer.from(envKey, "base64"); + if (buf.length !== KEY_LENGTH) { + throw new Error("NPM_SECRETS_ENCRYPTION_KEY must be 32 bytes (base64-encoded)"); + } + return { key: buf, keyId: CURRENT_KEY_ID }; + } + + if (fs.existsSync(secretsKeyFile)) { + const data = JSON.parse(fs.readFileSync(secretsKeyFile, "utf8")); + const key = Buffer.from(data.key, "base64"); + if (key.length !== KEY_LENGTH) { + throw new Error("Invalid master key in secrets.json"); + } + return { key, keyId: data.keyId || CURRENT_KEY_ID }; + } + + fs.mkdirSync(path.dirname(secretsKeyFile), { recursive: true, mode: 0o700 }); + const key = crypto.randomBytes(KEY_LENGTH); + const payload = { + keyId: CURRENT_KEY_ID, + key: key.toString("base64"), + created: new Date().toISOString(), + }; + fs.writeFileSync(secretsKeyFile, JSON.stringify(payload, null, 2), { mode: 0o600 }); + return { key, keyId: CURRENT_KEY_ID }; +}; + +/** + * @param {string} plaintext + * @returns {{ buffer: Buffer, keyId: string }} + */ +export const encrypt = (plaintext) => { + const { key, keyId } = getMasterKey(); + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + const buffer = Buffer.concat([ + Buffer.from([1]), + Buffer.from(keyId, "utf8"), + Buffer.from([0]), + iv, + tag, + encrypted, + ]); + return { buffer, keyId }; +}; + +/** + * @param {Buffer} buffer + * @returns {string} + */ +export const decrypt = (buffer) => { + if (buffer[0] !== 1) { + throw new Error("Unsupported credential encryption format"); + } + const keyIdEnd = buffer.indexOf(0, 1); + const keyId = buffer.subarray(1, keyIdEnd).toString("utf8"); + const ivStart = keyIdEnd + 1; + const iv = buffer.subarray(ivStart, ivStart + IV_LENGTH); + const tag = buffer.subarray(ivStart + IV_LENGTH, ivStart + IV_LENGTH + 16); + const encrypted = buffer.subarray(ivStart + IV_LENGTH + 16); + + const envKey = process.env.NPM_SECRETS_ENCRYPTION_KEY; + let key; + if (envKey) { + key = Buffer.from(envKey, "base64"); + } else if (fs.existsSync(secretsKeyFile)) { + const data = JSON.parse(fs.readFileSync(secretsKeyFile, "utf8")); + if (data.keyId !== keyId) { + throw new Error(`Unknown encryption key id: ${keyId}`); + } + key = Buffer.from(data.key, "base64"); + } else { + throw new Error("No master encryption key available"); + } + + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8"); +}; + +export const CREDENTIALS_DIR = "/data/credentials"; +export const PROVIDERS_DIR = "/data/credentials/providers"; diff --git a/backend/lib/secrets/format.js b/backend/lib/secrets/format.js new file mode 100644 index 0000000000..8950ac37c4 --- /dev/null +++ b/backend/lib/secrets/format.js @@ -0,0 +1,26 @@ +/** + * Convert fetched secret payload to certbot INI format. + * @param {*} payload - string or object from external store + * @param {string} [field] - optional key when payload is JSON object + */ +export const toCertbotIni = (payload, field) => { + if (typeof payload === "string") { + return payload.trim(); + } + + if (payload && typeof payload === "object") { + if (field && typeof payload[field] === "string") { + return payload[field].trim(); + } + if (field && payload[field] !== undefined) { + return String(payload[field]); + } + // Flat object -> ini lines + return Object.entries(payload) + .filter(([, v]) => v !== null && v !== undefined && String(v).length) + .map(([k, v]) => `${k} = ${v}`) + .join("\n"); + } + + throw new Error("Unsupported secret payload format"); +}; diff --git a/backend/lib/secrets/oidc.js b/backend/lib/secrets/oidc.js new file mode 100644 index 0000000000..8bf9e7e1a6 --- /dev/null +++ b/backend/lib/secrets/oidc.js @@ -0,0 +1,74 @@ +import errs from "../error.js"; + +/** + * OAuth2 client credentials token fetch. + * @param {Object} config + * @param {string} config.issuer + * @param {string} config.clientId + * @param {string} config.clientSecret + * @param {string} [config.audience] + * @param {string} [config.scope] + */ +export const fetchClientCredentialsToken = async (config) => { + const tokenUrl = config.tokenUrl || `${config.issuer.replace(/\/$/, "")}/oauth/token`; + + const body = new URLSearchParams({ + grant_type: "client_credentials", + client_id: config.clientId, + client_secret: config.clientSecret, + }); + + if (config.audience) { + body.set("audience", config.audience); + } + if (config.scope) { + body.set("scope", config.scope); + } + + const response = await fetch(tokenUrl, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + signal: AbortSignal.timeout(15000), + }); + + if (!response.ok) { + const text = await response.text(); + throw new errs.ValidationError(`OIDC token request failed (${response.status}): ${text.slice(0, 200)}`); + } + + const data = await response.json(); + if (!data.access_token) { + throw new errs.ValidationError("OIDC response missing access_token"); + } + + return data.access_token; +}; + +/** + * Azure AD client credentials. + */ +export const fetchAzureAdToken = async ({ tenantId, clientId, clientSecret, scope }) => { + const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`; + const body = new URLSearchParams({ + grant_type: "client_credentials", + client_id: clientId, + client_secret: clientSecret, + scope: scope || "https://vault.azure.net/.default", + }); + + const response = await fetch(tokenUrl, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + signal: AbortSignal.timeout(15000), + }); + + if (!response.ok) { + const text = await response.text(); + throw new errs.ValidationError(`Azure AD token failed (${response.status}): ${text.slice(0, 200)}`); + } + + const data = await response.json(); + return data.access_token; +}; diff --git a/backend/lib/secrets/provider-storage.js b/backend/lib/secrets/provider-storage.js new file mode 100644 index 0000000000..adc1ecea69 --- /dev/null +++ b/backend/lib/secrets/provider-storage.js @@ -0,0 +1,28 @@ +import fs from "node:fs"; +import path from "node:path"; +import { PROVIDERS_DIR, decrypt, encrypt } from "./crypto.js"; + +export const writeProviderSecret = (providerId, plaintext) => { + fs.mkdirSync(PROVIDERS_DIR, { recursive: true, mode: 0o700 }); + const { buffer } = encrypt(plaintext); + const target = path.join(PROVIDERS_DIR, `${providerId}.enc`); + const temp = `${target}.tmp`; + fs.writeFileSync(temp, buffer, { mode: 0o600 }); + fs.renameSync(temp, target); + return `${providerId}.enc`; +}; + +export const readProviderSecret = (providerId) => { + const filePath = path.join(PROVIDERS_DIR, `${providerId}.enc`); + if (!fs.existsSync(filePath)) { + return null; + } + return decrypt(fs.readFileSync(filePath)); +}; + +export const deleteProviderSecret = (providerId) => { + const filePath = path.join(PROVIDERS_DIR, `${providerId}.enc`); + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } +}; diff --git a/backend/lib/secrets/resolve.js b/backend/lib/secrets/resolve.js new file mode 100644 index 0000000000..7c2b346369 --- /dev/null +++ b/backend/lib/secrets/resolve.js @@ -0,0 +1,69 @@ +import dnsPlugins from "../../certbot/dns-plugins.json" with { type: "json" }; +import errs from "../error.js"; +import credentialModel from "../../models/credential.js"; +import now from "../../models/now_helper.js"; +import { resolveExternalCredential } from "./resolvers/external.js"; +import { readCredentialFile, writeCertbotCredentialsFile } from "./storage.js"; + +/** + * Resolve DNS certbot credentials plaintext for a certificate. + * @param {Object} certificate + * @returns {Promise} + */ +export const resolveDnsCredentials = async (certificate) => { + const meta = certificate.meta || {}; + + if (meta.credential_ref?.type === "external") { + return resolveExternalCredential(meta.credential_ref); + } + + if (meta.credential_ref?.type === "internal" && meta.credential_ref.id) { + const credential = await credentialModel + .query() + .where("id", meta.credential_ref.id) + .andWhere("is_deleted", 0) + .first(); + + if (!credential) { + throw new errs.ValidationError("Referenced credential not found"); + } + + if (credential.dns_provider !== meta.dns_provider) { + throw new errs.ValidationError("Credential DNS provider does not match certificate"); + } + + const plaintext = readCredentialFile(credential.id); + await credentialModel.query().patchAndFetchById(credential.id, { + last_used_at: now(), + }); + return plaintext; + } + + if (typeof meta.dns_provider_credentials === "string" && meta.dns_provider_credentials.length) { + return meta.dns_provider_credentials; + } + + throw new errs.ValidationError("DNS credentials are required (credential_ref or dns_provider_credentials)"); +}; + +/** + * @param {Object} certificate + * @returns {Promise} + */ +export const materializeCertbotCredentials = async (certificate) => { + const plaintext = await resolveDnsCredentials(certificate); + return writeCertbotCredentialsFile(certificate.id, plaintext); +}; + +/** + * @param {string} provider + * @param {string} credentialsIni + */ +export const validateDnsCredentialsFormat = (provider, credentialsIni) => { + if (!dnsPlugins[provider]) { + throw new errs.ValidationError(`Unknown DNS provider: ${provider}`); + } + if (!credentialsIni || typeof credentialsIni !== "string" || !credentialsIni.trim()) { + throw new errs.ValidationError("Credentials cannot be empty"); + } +}; diff --git a/backend/lib/secrets/resolvers/aws.js b/backend/lib/secrets/resolvers/aws.js new file mode 100644 index 0000000000..d32b9e5814 --- /dev/null +++ b/backend/lib/secrets/resolvers/aws.js @@ -0,0 +1,152 @@ +import crypto from "node:crypto"; +import errs from "../../error.js"; +import { toCertbotIni } from "../format.js"; + +/** + * AWS Secrets Manager via OIDC web identity + STS + GetSecretValue (REST, no SDK). + * meta: { region, role_arn } + * secretRef.path: secret id or ARN + */ +export const assumeRoleWithWebIdentity = async ({ region, roleArn, webIdentityToken }) => { + const params = new URLSearchParams({ + Action: "AssumeRoleWithWebIdentity", + Version: "2011-06-15", + RoleArn: roleArn, + RoleSessionName: "npm-credential-resolver", + WebIdentityToken: webIdentityToken, + DurationSeconds: "900", + }); + + const response = await fetch(`https://sts.${region}.amazonaws.com/`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: params.toString(), + signal: AbortSignal.timeout(15000), + }); + + const text = await response.text(); + if (!response.ok) { + throw new errs.ValidationError(`AWS STS failed (${response.status})`); + } + + const key = (name) => { + const match = text.match(new RegExp(`<${name}>([^<]+)`)); + return match?.[1]; + }; + + return { + accessKeyId: key("AccessKeyId"), + secretAccessKey: key("SecretAccessKey"), + sessionToken: key("SessionToken"), + }; +}; + +const sha256 = async (message) => { + const data = new TextEncoder().encode(message); + const hash = await crypto.subtle.digest("SHA-256", data); + return Buffer.from(hash).toString("hex"); +}; + +const hmac = async (key, message) => { + const cryptoKey = await crypto.subtle.importKey( + "raw", + typeof key === "string" ? new TextEncoder().encode(key) : key, + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const sig = await crypto.subtle.sign("HMAC", cryptoKey, new TextEncoder().encode(message)); + return Buffer.from(sig); +}; + +const getSignatureKey = async (key, dateStamp, regionName, serviceName) => { + const kDate = await hmac(`AWS4${key}`, dateStamp); + const kRegion = await hmac(kDate, regionName); + const kService = await hmac(kRegion, serviceName); + return hmac(kService, "aws4_request"); +}; + +const signRequest = async ({ method, url, headers, body, credentials, region, service }) => { + const urlObj = new URL(url); + const amzDate = headers["x-amz-date"]; + const dateStamp = amzDate.slice(0, 8); + const canonicalHeaders = `${Object.keys(headers) + .sort() + .map((k) => `${k.toLowerCase()}:${headers[k].trim()}\n`) + .join("")}host:${urlObj.host}\n`; + const signedHeaders = `${Object.keys(headers) + .sort() + .map((k) => k.toLowerCase()) + .join(";")};host`; + const payloadHash = await sha256(body || ""); + const canonicalRequest = [method, urlObj.pathname + urlObj.search, "", canonicalHeaders, signedHeaders, payloadHash].join( + "\n", + ); + const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`; + const stringToSign = ["AWS4-HMAC-SHA256", amzDate, credentialScope, await sha256(canonicalRequest)].join("\n"); + const signingKey = await getSignatureKey(credentials.secretAccessKey, dateStamp, region, service); + const signature = (await hmac(signingKey, stringToSign)).toString("hex"); + + return `AWS4-HMAC-SHA256 Credential=${credentials.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`; +}; + +export const resolveAws = async (provider, secretRef, oidcToken) => { + const { region = "us-east-1", role_arn } = provider.meta || {}; + if (!role_arn) { + throw new errs.ValidationError("AWS provider requires role_arn in meta"); + } + + const credentials = await assumeRoleWithWebIdentity({ + region, + roleArn: role_arn, + webIdentityToken: oidcToken, + }); + + const secretId = secretRef.path?.replace(/^\//, ""); + const host = `secretsmanager.${region}.amazonaws.com`; + const url = `https://${host}/`; + const body = JSON.stringify({ SecretId: secretId }); + const now = new Date(); + const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, ""); + const headers = { + "Content-Type": "application/x-amz-json-1.1", + "X-Amz-Target": "secretsmanager.GetSecretValue", + "X-Amz-Date": amzDate, + "X-Amz-Security-Token": credentials.sessionToken, + Host: host, + }; + + headers.Authorization = await signRequest({ + method: "POST", + url, + headers, + body, + credentials, + region, + service: "secretsmanager", + }); + + const response = await fetch(url, { + method: "POST", + headers, + body, + signal: AbortSignal.timeout(15000), + }); + + if (!response.ok) { + const text = await response.text(); + throw new errs.ValidationError(`AWS Secrets Manager failed (${response.status}): ${text.slice(0, 200)}`); + } + + const data = await response.json(); + let payload = data.SecretString; + if (!payload && data.SecretBinary) { + payload = Buffer.from(data.SecretBinary, "base64").toString("utf8"); + } + try { + payload = JSON.parse(payload); + } catch { + // keep string + } + return toCertbotIni(payload, secretRef.field); +}; diff --git a/backend/lib/secrets/resolvers/azure.js b/backend/lib/secrets/resolvers/azure.js new file mode 100644 index 0000000000..a0596be119 --- /dev/null +++ b/backend/lib/secrets/resolvers/azure.js @@ -0,0 +1,35 @@ +import errs from "../../error.js"; +import { fetchAzureAdToken } from "../oidc.js"; +import { toCertbotIni } from "../format.js"; + +/** + * Azure Key Vault secret. + * meta: { tenant_id, vault_url } — vault_url e.g. https://myvault.vault.azure.net + */ +export const resolveAzure = async (provider, secretRef, clientSecret) => { + const { tenant_id, vault_url } = provider.meta || {}; + if (!tenant_id || !vault_url) { + throw new errs.ValidationError("Azure provider requires tenant_id and vault_url in meta"); + } + + const token = await fetchAzureAdToken({ + tenantId: tenant_id, + clientId: provider.oidc_client_id, + clientSecret, + }); + + const secretName = secretRef.path?.replace(/^\//, ""); + const url = `${vault_url.replace(/\/$/, "")}/secrets/${secretName}?api-version=7.4`; + + const response = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(15000), + }); + + if (!response.ok) { + throw new errs.ValidationError(`Azure Key Vault read failed (${response.status})`); + } + + const data = await response.json(); + return toCertbotIni(data.value, secretRef.field); +}; diff --git a/backend/lib/secrets/resolvers/external.js b/backend/lib/secrets/resolvers/external.js new file mode 100644 index 0000000000..67558faf46 --- /dev/null +++ b/backend/lib/secrets/resolvers/external.js @@ -0,0 +1,19 @@ +import { loadProvider, resolveFromProvider } from "./index.js"; + +/** + * Resolve credentials from an external store (Vault, AWS, Azure, Infisical, HTTP). + * @param {Object} credentialRef + * @returns {Promise} + */ +export const resolveExternalCredential = async (credentialRef) => { + if (credentialRef?.type !== "external") { + throw new errs.ValidationError("Invalid external credential reference"); + } + + if (!credentialRef.provider_id) { + throw new errs.ValidationError("external credential_ref requires provider_id"); + } + + const provider = await loadProvider(credentialRef.provider_id); + return resolveFromProvider(provider, credentialRef); +}; diff --git a/backend/lib/secrets/resolvers/http.js b/backend/lib/secrets/resolvers/http.js new file mode 100644 index 0000000000..b70c3d1732 --- /dev/null +++ b/backend/lib/secrets/resolvers/http.js @@ -0,0 +1,40 @@ +import errs from "../../error.js"; +import { toCertbotIni } from "../format.js"; + +/** + * Generic HTTP secret endpoint. + * meta: { url_template } — use {path} placeholder, e.g. https://vault.example/secrets/{path} + */ +export const resolveHttp = async (provider, secretRef, accessToken) => { + const { url_template, method = "GET", headers: extraHeaders = {} } = provider.meta || {}; + if (!url_template) { + throw new errs.ValidationError("HTTP provider requires url_template in meta"); + } + + const path = secretRef.path || ""; + const url = url_template.replace("{path}", encodeURIComponent(path.replace(/^\//, ""))); + + const headers = { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + ...extraHeaders, + }; + + const response = await fetch(url, { + method, + headers, + signal: AbortSignal.timeout(15000), + }); + + if (!response.ok) { + throw new errs.ValidationError(`HTTP secret fetch failed (${response.status})`); + } + + const contentType = response.headers.get("content-type") || ""; + if (contentType.includes("application/json")) { + const data = await response.json(); + return toCertbotIni(data, secretRef.field); + } + + return toCertbotIni(await response.text(), secretRef.field); +}; diff --git a/backend/lib/secrets/resolvers/index.js b/backend/lib/secrets/resolvers/index.js new file mode 100644 index 0000000000..4dbd3d121d --- /dev/null +++ b/backend/lib/secrets/resolvers/index.js @@ -0,0 +1,61 @@ +import credentialProviderModel from "../../../models/credential_provider.js"; +import errs from "../../error.js"; +import { fetchClientCredentialsToken } from "../oidc.js"; +import { readProviderSecret } from "../provider-storage.js"; +import { resolveAws } from "./aws.js"; +import { resolveAzure } from "./azure.js"; +import { resolveHttp } from "./http.js"; +import { resolveInfisical } from "./infisical.js"; +import { resolveVault } from "./vault.js"; + +const resolvers = { + vault: resolveVault, + aws: resolveAws, + azure: resolveAzure, + infisical: resolveInfisical, + http: resolveHttp, +}; + +export const loadProvider = async (providerId) => { + const provider = await credentialProviderModel + .query() + .where("id", providerId) + .andWhere("is_deleted", 0) + .first(); + + if (!provider) { + throw new errs.ValidationError(`Credential provider ${providerId} not found`); + } + + return provider; +}; + +export const getProviderAccessToken = async (provider) => { + const clientSecret = readProviderSecret(provider.id); + if (!provider.oidc_client_id || !clientSecret) { + throw new errs.ValidationError("Provider OIDC client_id and client_secret are required"); + } + + return fetchClientCredentialsToken({ + issuer: provider.oidc_issuer, + tokenUrl: provider.meta?.token_url, + clientId: provider.oidc_client_id, + clientSecret, + audience: provider.oidc_audience, + scope: provider.oidc_scope, + }); +}; + +/** + * @param {Object} provider + * @param {Object} secretRef + */ +export const resolveFromProvider = async (provider, secretRef) => { + const resolver = resolvers[provider.type]; + if (!resolver) { + throw new errs.ValidationError(`Unknown credential provider type: ${provider.type}`); + } + + const accessToken = await getProviderAccessToken(provider); + return resolver(provider, secretRef, accessToken); +}; diff --git a/backend/lib/secrets/resolvers/infisical.js b/backend/lib/secrets/resolvers/infisical.js new file mode 100644 index 0000000000..e377058879 --- /dev/null +++ b/backend/lib/secrets/resolvers/infisical.js @@ -0,0 +1,38 @@ +import errs from "../../error.js"; +import { toCertbotIni } from "../format.js"; + +/** + * Infisical universal auth / OIDC. + * meta: { host, identity_id } + * secretRef.path: secret path e.g. /dns/cloudflare + */ +export const resolveInfisical = async (provider, secretRef, accessToken) => { + const host = (provider.meta?.host || "https://app.infisical.com").replace(/\/$/, ""); + const { workspace_id, environment_slug = "prod" } = provider.meta || {}; + + if (!workspace_id) { + throw new errs.ValidationError("Infisical provider requires workspace_id in meta"); + } + + const secretPath = secretRef.path?.replace(/^\//, "") || ""; + const url = `${host}/api/v3/secrets/raw/${encodeURIComponent(secretPath)}?workspaceId=${workspace_id}&environment=${environment_slug}`; + + const response = await fetch(url, { + headers: { Authorization: `Bearer ${accessToken}` }, + signal: AbortSignal.timeout(15000), + }); + + if (!response.ok) { + throw new errs.ValidationError(`Infisical secret fetch failed (${response.status})`); + } + + const data = await response.json(); + const secret = data.secret || data; + if (secretRef.field && secret?.secretKey === secretRef.field) { + return toCertbotIni(secret.secretValue); + } + if (typeof secret === "object" && secret.secretValue) { + return toCertbotIni(secret.secretValue); + } + return toCertbotIni(data, secretRef.field); +}; diff --git a/backend/lib/secrets/resolvers/vault.js b/backend/lib/secrets/resolvers/vault.js new file mode 100644 index 0000000000..7dbbfee13c --- /dev/null +++ b/backend/lib/secrets/resolvers/vault.js @@ -0,0 +1,47 @@ +import errs from "../../error.js"; +import { toCertbotIni } from "../format.js"; + +/** + * HashiCorp Vault KV v2 via JWT/OIDC login. + * meta: { address, mount, role, jwt_auth_path } + */ +export const resolveVault = async (provider, secretRef, accessToken) => { + const { address, mount = "secret", role, jwt_auth_path = "jwt" } = provider.meta || {}; + if (!address) { + throw new errs.ValidationError("Vault address is required in provider meta"); + } + + const base = address.replace(/\/$/, ""); + let vaultToken = accessToken; + + if (role) { + const loginRes = await fetch(`${base}/v1/auth/${jwt_auth_path}/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ role, jwt: accessToken }), + signal: AbortSignal.timeout(15000), + }); + if (!loginRes.ok) { + throw new errs.ValidationError(`Vault JWT login failed (${loginRes.status})`); + } + const loginData = await loginRes.json(); + vaultToken = loginData.auth?.client_token; + if (!vaultToken) { + throw new errs.ValidationError("Vault login did not return client_token"); + } + } + + const path = secretRef.path?.replace(/^\//, ""); + const secretRes = await fetch(`${base}/v1/${mount}/data/${path}`, { + headers: { "X-Vault-Token": vaultToken }, + signal: AbortSignal.timeout(15000), + }); + + if (!secretRes.ok) { + throw new errs.ValidationError(`Vault read failed (${secretRes.status})`); + } + + const secretData = await secretRes.json(); + const payload = secretData.data?.data ?? secretData.data; + return toCertbotIni(payload, secretRef.field); +}; diff --git a/backend/lib/secrets/scrub.js b/backend/lib/secrets/scrub.js new file mode 100644 index 0000000000..5e6d58f023 --- /dev/null +++ b/backend/lib/secrets/scrub.js @@ -0,0 +1,46 @@ +const SENSITIVE_META_KEYS = [ + "dns_provider_credentials", + "certificate_key", + "certificate", + "intermediate_certificate", + "oidc_client_secret", +]; + +const SENSITIVE_REF_KEYS = ["credentials"]; + +/** + * Deep-clone and redact secrets from audit log / API meta payloads. + * @param {*} meta + * @returns {*} + */ +export const scrubAuditMeta = (meta) => { + if (!meta || typeof meta !== "object") { + return meta; + } + + if (Array.isArray(meta)) { + return meta.map(scrubAuditMeta); + } + + const out = {}; + for (const [key, value] of Object.entries(meta)) { + if (SENSITIVE_META_KEYS.includes(key)) { + out[key] = "[redacted]"; + continue; + } + if (key === "meta" && value && typeof value === "object") { + out.meta = scrubAuditMeta(value); + continue; + } + if (key === "credential_ref" && value && typeof value === "object") { + out.credential_ref = { ...value }; + continue; + } + if (typeof value === "object" && value !== null) { + out[key] = scrubAuditMeta(value); + } else { + out[key] = value; + } + } + return out; +}; diff --git a/backend/lib/secrets/storage.js b/backend/lib/secrets/storage.js new file mode 100644 index 0000000000..21b497b9cb --- /dev/null +++ b/backend/lib/secrets/storage.js @@ -0,0 +1,61 @@ +import fs from "node:fs"; +import path from "node:path"; +import { CREDENTIALS_DIR, PROVIDERS_DIR, decrypt, encrypt } from "./crypto.js"; + +export const ensureCredentialDirs = () => { + fs.mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 0o700 }); + fs.mkdirSync(PROVIDERS_DIR, { recursive: true, mode: 0o700 }); +}; + +/** + * @param {number} id + * @returns {string} + */ +export const getCredentialPath = (id) => path.join(CREDENTIALS_DIR, `${id}.enc`); + +/** + * @param {number} id + * @param {string} plaintext + */ +export const writeCredentialFile = (id, plaintext) => { + ensureCredentialDirs(); + const { buffer, keyId } = encrypt(plaintext); + const target = getCredentialPath(id); + const temp = `${target}.tmp`; + fs.writeFileSync(temp, buffer, { mode: 0o600 }); + fs.renameSync(temp, target); + return { storagePath: `${id}.enc`, keyId }; +}; + +/** + * @param {number} id + * @returns {string} + */ +export const readCredentialFile = (id) => { + const filePath = getCredentialPath(id); + if (!fs.existsSync(filePath)) { + throw new Error(`Credential file not found for id ${id}`); + } + return decrypt(fs.readFileSync(filePath)); +}; + +/** + * @param {number} id + */ +export const deleteCredentialFile = (id) => { + const filePath = getCredentialPath(id); + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } +}; + +/** + * @param {number} certificateId + * @param {string} plaintext + */ +export const writeCertbotCredentialsFile = (certificateId, plaintext) => { + const credentialsLocation = `/etc/letsencrypt/credentials/credentials-${certificateId}`; + fs.mkdirSync("/etc/letsencrypt/credentials", { recursive: true }); + fs.writeFileSync(credentialsLocation, plaintext, { mode: 0o600 }); + return credentialsLocation; +}; diff --git a/backend/lib/utils.js b/backend/lib/utils.js index a19142af03..dc08028321 100644 --- a/backend/lib/utils.js +++ b/backend/lib/utils.js @@ -51,13 +51,28 @@ const execFile = (cmd, args, options) => { * @param {Array} omissions * @returns {Function} */ -const omitRow = (omissions) => { +const omitNestedMeta = (row, metaKeys) => { + if (!row || typeof row !== "object" || !row.meta || typeof row.meta !== "object") { + return row; + } + const meta = { ...row.meta }; + for (const key of metaKeys) { + delete meta[key]; + } + return { ...row, meta }; +}; + +const omitRow = (omissions, metaOmissions = []) => { /** * @param {Object} row * @returns {Object} */ return (row) => { - return _.omit(row, omissions); + let result = _.omit(row, omissions); + if (metaOmissions.length) { + result = omitNestedMeta(result, metaOmissions); + } + return result; }; }; @@ -67,14 +82,14 @@ const omitRow = (omissions) => { * @param {Array} omissions * @returns {Function} */ -const omitRows = (omissions) => { +const omitRows = (omissions, metaOmissions = []) => { /** * @param {Array} rows * @returns {Object} */ return (rows) => { rows.forEach((row, idx) => { - rows[idx] = _.omit(row, omissions); + rows[idx] = omitRow(omissions, metaOmissions)(row); }); return rows; }; diff --git a/backend/migrations/20260603120000_credentials.js b/backend/migrations/20260603120000_credentials.js new file mode 100644 index 0000000000..5a3d92f19f --- /dev/null +++ b/backend/migrations/20260603120000_credentials.js @@ -0,0 +1,41 @@ +import { migrate as logger } from "../logger.js"; + +const migrateName = "credentials"; + +const up = (knex) => { + logger.info(`[${migrateName}] Migrating Up...`); + + return knex.schema + .createTable("credential", (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("dns_provider", 100).notNull(); + table.string("storage_path", 255).notNull(); + table.string("encryption_key_id", 32).notNull().defaultTo("v1"); + table.dateTime("last_used_at").nullable(); + }) + .then(() => { + logger.info(`[${migrateName}] credential table created`); + return knex.schema.table("user_permission", (table) => { + table.string("credentials", 20).notNull().defaultTo("manage"); + }); + }) + .then(() => { + logger.info(`[${migrateName}] user_permission.credentials column added`); + }); +}; + +const down = (knex) => { + logger.info(`[${migrateName}] Migrating Down...`); + return knex.schema + .table("user_permission", (table) => { + table.dropColumn("credentials"); + }) + .then(() => knex.schema.dropTableIfExists("credential")); +}; + +export { up, down }; diff --git a/backend/migrations/20260603120100_automation.js b/backend/migrations/20260603120100_automation.js new file mode 100644 index 0000000000..34c6157c91 --- /dev/null +++ b/backend/migrations/20260603120100_automation.js @@ -0,0 +1,65 @@ +import { migrate as logger } from "../logger.js"; + +const migrateName = "automation"; + +const up = (knex) => { + logger.info(`[${migrateName}] Migrating Up...`); + + return knex.schema + .createTable("api_key", (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.integer("is_revoked").notNull().unsigned().defaultTo(0); + table.string("name", 255).notNull(); + table.string("key_prefix", 16).notNull(); + table.string("key_hash", 255).notNull(); + table.json("permissions").notNull(); + table.dateTime("expires_on").nullable(); + table.dateTime("last_used_at").nullable(); + }) + .then(() => { + logger.info(`[${migrateName}] api_key table created`); + return knex.schema.createTable("job", (table) => { + table.increments().primary(); + table.dateTime("created_on").notNull(); + table.dateTime("modified_on").notNull(); + table.integer("owner_user_id").notNull().unsigned(); + table.string("type", 64).notNull(); + table.string("status", 32).notNull(); + table.json("payload").notNull(); + table.json("result").nullable(); + table.text("error").nullable(); + table.dateTime("finished_on").nullable(); + }); + }) + .then(() => { + logger.info(`[${migrateName}] job table created`); + return knex.schema.createTable("webhook_endpoint", (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.integer("is_enabled").notNull().unsigned().defaultTo(1); + table.string("name", 255).notNull(); + table.string("url", 2048).notNull(); + table.string("secret_path", 255).notNull(); + table.json("events").notNull(); + }); + }) + .then(() => { + logger.info(`[${migrateName}] webhook_endpoint table created`); + }); +}; + +const down = (knex) => { + return knex.schema + .dropTableIfExists("webhook_endpoint") + .then(() => knex.schema.dropTableIfExists("job")) + .then(() => knex.schema.dropTableIfExists("api_key")); +}; + +export { up, down }; diff --git a/backend/migrations/20260603120200_credential_providers.js b/backend/migrations/20260603120200_credential_providers.js new file mode 100644 index 0000000000..414c95cc32 --- /dev/null +++ b/backend/migrations/20260603120200_credential_providers.js @@ -0,0 +1,27 @@ +import { migrate as logger } from "../logger.js"; + +const migrateName = "credential_providers"; + +const up = (knex) => { + logger.info(`[${migrateName}] Migrating Up...`); + + return knex.schema.createTable("credential_provider", (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("type", 32).notNull(); + table.string("oidc_issuer", 2048).nullable(); + table.string("oidc_client_id", 512).nullable(); + table.string("oidc_client_secret_path", 255).nullable(); + table.string("oidc_audience", 512).nullable(); + table.string("oidc_scope", 512).nullable(); + table.json("meta").notNull(); + }); +}; + +const down = (knex) => knex.schema.dropTableIfExists("credential_provider"); + +export { up, down }; diff --git a/backend/models/api_key.js b/backend/models/api_key.js new file mode 100644 index 0000000000..47cf6b3220 --- /dev/null +++ b/backend/models/api_key.js @@ -0,0 +1,30 @@ +import { Model } from "objection"; +import db from "../db.js"; +import now from "./now_helper.js"; + +Model.knex(db()); + +class ApiKey extends Model { + $beforeInsert() { + this.created_on = now(); + this.modified_on = now(); + } + + $beforeUpdate() { + this.modified_on = now(); + } + + static get name() { + return "ApiKey"; + } + + static get tableName() { + return "api_key"; + } + + static get jsonAttributes() { + return ["permissions"]; + } +} + +export default ApiKey; diff --git a/backend/models/credential.js b/backend/models/credential.js new file mode 100644 index 0000000000..475e6f5d3d --- /dev/null +++ b/backend/models/credential.js @@ -0,0 +1,26 @@ +import { Model } from "objection"; +import db from "../db.js"; +import now from "./now_helper.js"; + +Model.knex(db()); + +class Credential extends Model { + $beforeInsert() { + this.created_on = now(); + this.modified_on = now(); + } + + $beforeUpdate() { + this.modified_on = now(); + } + + static get name() { + return "Credential"; + } + + static get tableName() { + return "credential"; + } +} + +export default Credential; diff --git a/backend/models/credential_provider.js b/backend/models/credential_provider.js new file mode 100644 index 0000000000..43b428e3b7 --- /dev/null +++ b/backend/models/credential_provider.js @@ -0,0 +1,33 @@ +import { Model } from "objection"; +import db from "../db.js"; +import now from "./now_helper.js"; + +Model.knex(db()); + +class CredentialProvider 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 "CredentialProvider"; + } + + static get tableName() { + return "credential_provider"; + } + + static get jsonAttributes() { + return ["meta"]; + } +} + +export default CredentialProvider; diff --git a/backend/models/job.js b/backend/models/job.js new file mode 100644 index 0000000000..a348f0767b --- /dev/null +++ b/backend/models/job.js @@ -0,0 +1,30 @@ +import { Model } from "objection"; +import db from "../db.js"; +import now from "./now_helper.js"; + +Model.knex(db()); + +class Job extends Model { + $beforeInsert() { + this.created_on = now(); + this.modified_on = now(); + } + + $beforeUpdate() { + this.modified_on = now(); + } + + static get name() { + return "Job"; + } + + static get tableName() { + return "job"; + } + + static get jsonAttributes() { + return ["payload", "result"]; + } +} + +export default Job; diff --git a/backend/models/webhook_endpoint.js b/backend/models/webhook_endpoint.js new file mode 100644 index 0000000000..46379d2230 --- /dev/null +++ b/backend/models/webhook_endpoint.js @@ -0,0 +1,30 @@ +import { Model } from "objection"; +import db from "../db.js"; +import now from "./now_helper.js"; + +Model.knex(db()); + +class WebhookEndpoint extends Model { + $beforeInsert() { + this.created_on = now(); + this.modified_on = now(); + } + + $beforeUpdate() { + this.modified_on = now(); + } + + static get name() { + return "WebhookEndpoint"; + } + + static get tableName() { + return "webhook_endpoint"; + } + + static get jsonAttributes() { + return ["events"]; + } +} + +export default WebhookEndpoint; diff --git a/backend/routes/api-keys.js b/backend/routes/api-keys.js new file mode 100644 index 0000000000..0a8e84f734 --- /dev/null +++ b/backend/routes/api-keys.js @@ -0,0 +1,68 @@ +import express from "express"; +import internalApiKey from "../internal/api-key.js"; +import jwtdecode from "../lib/express/jwt-decode.js"; +import validator from "../lib/validator/index.js"; +import { debug, express as logger } from "../logger.js"; + +const router = express.Router({ + caseSensitive: true, + strict: true, + mergeParams: true, +}); + +router + .route("/") + .options((_, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + .get(async (req, res, next) => { + try { + const rows = await internalApiKey.getAll(res.locals.access); + res.status(200).send(rows); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }) + .post(async (req, res, next) => { + try { + const payload = await validator( + { + additionalProperties: false, + required: ["name"], + properties: { + name: { type: "string", minLength: 1, maxLength: 255 }, + permissions: { type: "object" }, + expires_on: { type: ["string", "null"] }, + }, + }, + req.body, + ); + const result = await internalApiKey.create(res.locals.access, payload); + res.status(201).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +router + .route("/:api_key_id") + .options((_, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + .delete(async (req, res, next) => { + try { + await internalApiKey.delete(res.locals.access, { + id: Number.parseInt(req.params.api_key_id, 10), + }); + res.status(200).send(true); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +export default router; diff --git a/backend/routes/credential-providers.js b/backend/routes/credential-providers.js new file mode 100644 index 0000000000..c1ee07c65b --- /dev/null +++ b/backend/routes/credential-providers.js @@ -0,0 +1,135 @@ +import express from "express"; +import internalCredentialProvider from "../internal/credential-provider.js"; +import jwtdecode from "../lib/express/jwt-decode.js"; +import validator from "../lib/validator/index.js"; +import { debug, express as logger } from "../logger.js"; + +const router = express.Router({ + caseSensitive: true, + strict: true, + mergeParams: true, +}); + +const providerBodySchema = { + additionalProperties: false, + properties: { + name: { type: "string", minLength: 1 }, + type: { type: "string", enum: ["vault", "aws", "azure", "infisical", "http"] }, + oidc_issuer: { type: "string" }, + oidc_client_id: { type: "string" }, + oidc_client_secret: { type: "string" }, + oidc_audience: { type: "string" }, + oidc_scope: { type: "string" }, + meta: { type: "object" }, + }, +}; + +router + .route("/") + .options((_, res) => res.sendStatus(204)) + .all(jwtdecode()) + .get(async (req, res, next) => { + try { + res.status(200).send(await internalCredentialProvider.getAll(res.locals.access)); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }) + .post(async (req, res, next) => { + try { + const payload = await validator( + { ...providerBodySchema, required: ["name", "type", "oidc_issuer", "oidc_client_id", "oidc_client_secret"] }, + req.body, + ); + const result = await internalCredentialProvider.create(res.locals.access, payload); + res.status(201).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +router + .route("/:provider_id") + .options((_, res) => res.sendStatus(204)) + .all(jwtdecode()) + .get(async (req, res, next) => { + try { + const result = await internalCredentialProvider.get(res.locals.access, { + id: Number.parseInt(req.params.provider_id, 10), + }); + res.status(200).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }) + .put(async (req, res, next) => { + try { + const payload = await validator(providerBodySchema, req.body); + payload.id = Number.parseInt(req.params.provider_id, 10); + const result = await internalCredentialProvider.update(res.locals.access, payload); + res.status(200).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }) + .delete(async (req, res, next) => { + try { + await internalCredentialProvider.delete(res.locals.access, { + id: Number.parseInt(req.params.provider_id, 10), + }); + res.status(200).send(true); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +router + .route("/:provider_id/test") + .options((_, res) => res.sendStatus(204)) + .all(jwtdecode()) + .post(async (req, res, next) => { + try { + const result = await internalCredentialProvider.test(res.locals.access, { + id: Number.parseInt(req.params.provider_id, 10), + }); + res.status(200).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +router + .route("/:provider_id/test-resolve") + .options((_, res) => res.sendStatus(204)) + .all(jwtdecode()) + .post(async (req, res, next) => { + try { + const body = await validator( + { + additionalProperties: false, + required: ["path"], + properties: { + path: { type: "string", minLength: 1 }, + field: { type: "string" }, + }, + }, + req.body, + ); + const result = await internalCredentialProvider.testResolve(res.locals.access, { + id: Number.parseInt(req.params.provider_id, 10), + ...body, + }); + res.status(200).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +export default router; diff --git a/backend/routes/credentials.js b/backend/routes/credentials.js new file mode 100644 index 0000000000..7ddff8393c --- /dev/null +++ b/backend/routes/credentials.js @@ -0,0 +1,122 @@ +import express from "express"; +import internalCredential from "../internal/credential.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, +}); + +router + .route("/") + .options((_, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + .get(async (req, res, next) => { + try { + const rows = await internalCredential.getAll(res.locals.access); + res.status(200).send(rows); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }) + .post(async (req, res, next) => { + try { + const payload = await apiValidator(getValidationSchema("/credentials", "post"), req.body); + const result = await internalCredential.create(res.locals.access, payload); + res.status(201).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +router + .route("/migrate-legacy") + .options((_, res) => res.sendStatus(204)) + .all(jwtdecode()) + .post(async (req, res, next) => { + try { + const payload = await validator( + { + additionalProperties: false, + properties: { + dry_run: { type: "boolean" }, + }, + }, + req.body || {}, + ); + const result = await internalCredential.migrateLegacy(res.locals.access, payload); + res.status(200).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +router + .route("/:credential_id") + .options((_, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + .get(async (req, res, next) => { + try { + const result = await internalCredential.get(res.locals.access, { + id: Number.parseInt(req.params.credential_id, 10), + }); + res.status(200).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }) + .put(async (req, res, next) => { + try { + const payload = await apiValidator(getValidationSchema("/credentials/{credentialID}", "put"), req.body); + payload.id = Number.parseInt(req.params.credential_id, 10); + const result = await internalCredential.update(res.locals.access, payload); + res.status(200).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }) + .delete(async (req, res, next) => { + try { + await internalCredential.delete(res.locals.access, { + id: Number.parseInt(req.params.credential_id, 10), + }); + res.status(200).send(true); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +router + .route("/:credential_id/test") + .options((_, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + .post(async (req, res, next) => { + try { + const result = await internalCredential.test(res.locals.access, { + id: Number.parseInt(req.params.credential_id, 10), + }); + res.status(200).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +export default router; diff --git a/backend/routes/jobs.js b/backend/routes/jobs.js new file mode 100644 index 0000000000..bb0efe3432 --- /dev/null +++ b/backend/routes/jobs.js @@ -0,0 +1,45 @@ +import express from "express"; +import internalJob from "../internal/job.js"; +import jwtdecode from "../lib/express/jwt-decode.js"; +import { debug, express as logger } from "../logger.js"; + +const router = express.Router({ + caseSensitive: true, + strict: true, + mergeParams: true, +}); + +router + .route("/") + .options((_, res) => res.sendStatus(204)) + .all(jwtdecode()) + .get(async (req, res, next) => { + try { + const limit = req.query.limit ? Number.parseInt(req.query.limit, 10) : 50; + const rows = await internalJob.getAll(res.locals.access, { limit }); + res.status(200).send(rows); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +router + .route("/:job_id") + .options((_, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + .get(async (req, res, next) => { + try { + const result = await internalJob.get(res.locals.access, { + id: Number.parseInt(req.params.job_id, 10), + }); + res.status(200).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +export default router; diff --git a/backend/routes/main.js b/backend/routes/main.js index a308ea6179..a80113ea85 100644 --- a/backend/routes/main.js +++ b/backend/routes/main.js @@ -8,6 +8,11 @@ import auditLogRoutes from "./audit-log.js"; import ciRoutes from "./ci.js"; import accessListsRoutes from "./nginx/access_lists.js"; import certificatesHostsRoutes from "./nginx/certificates.js"; +import apiKeysRoutes from "./api-keys.js"; +import credentialProvidersRoutes from "./credential-providers.js"; +import credentialsRoutes from "./credentials.js"; +import jobsRoutes from "./jobs.js"; +import webhooksRoutes from "./webhooks.js"; import deadHostsRoutes from "./nginx/dead_hosts.js"; import proxyHostsRoutes from "./nginx/proxy_hosts.js"; import redirectionHostsRoutes from "./nginx/redirection_hosts.js"; @@ -59,6 +64,11 @@ router.use("/nginx/dead-hosts", deadHostsRoutes); router.use("/nginx/streams", streamsRoutes); router.use("/nginx/access-lists", accessListsRoutes); router.use("/nginx/certificates", certificatesHostsRoutes); +router.use("/credentials", credentialsRoutes); +router.use("/credential-providers", credentialProvidersRoutes); +router.use("/api-keys", apiKeysRoutes); +router.use("/jobs", jobsRoutes); +router.use("/webhooks", webhooksRoutes); // Only include CI routes if we're in a CI environment if (isCI()) { diff --git a/backend/routes/nginx/certificates.js b/backend/routes/nginx/certificates.js index 99f429b446..f503740d0f 100644 --- a/backend/routes/nginx/certificates.js +++ b/backend/routes/nginx/certificates.js @@ -1,6 +1,7 @@ import express from "express"; import dnsPlugins from "../../certbot/dns-plugins.json" with { type: "json" }; import internalCertificate from "../../internal/certificate.js"; +import internalJob from "../../internal/job.js"; import errs from "../../lib/error.js"; import jwtdecode from "../../lib/express/jwt-decode.js"; import apiValidator from "../../lib/validator/api.js"; @@ -75,10 +76,14 @@ router req.body, ); req.setTimeout(900000); // 15 minutes timeout - const result = await internalCertificate.create( - res.locals.access, - payload, - ); + + if (req.query.async === "true") { + const job = await internalJob.runCertificateCreate(res.locals.access, payload); + res.status(202).send({ job_id: job.id, status: job.status }); + return; + } + + const result = await internalCertificate.create(res.locals.access, payload); res.status(201).send(result); } catch (err) { debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); @@ -241,10 +246,40 @@ router } }) + /** + * PUT /api/nginx/certificates/123 + * + * Update an existing certificate + */ + .put(async (req, res, next) => { + try { + const payload = await validator( + { + additionalProperties: false, + properties: { + nice_name: { type: "string" }, + domain_names: { + type: "array", + items: { type: "string" }, + }, + meta: { type: "object" }, + }, + }, + req.body, + ); + payload.id = Number.parseInt(req.params.certificate_id, 10); + const result = await internalCertificate.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/certificates/123 * - * Update and existing certificate + * Delete an existing certificate */ .delete(async (req, res, next) => { try { @@ -313,8 +348,16 @@ router .post(async (req, res, next) => { req.setTimeout(900000); // 15 minutes timeout try { + const certId = Number.parseInt(req.params.certificate_id, 10); + + if (req.query.async === "true") { + const job = await internalJob.runCertificateRenew(res.locals.access, { id: certId }); + res.status(202).send({ job_id: job.id, status: job.status }); + return; + } + const result = await internalCertificate.renew(res.locals.access, { - id: Number.parseInt(req.params.certificate_id, 10), + id: certId, }); res.status(200).send(result); } catch (err) { diff --git a/backend/routes/webhooks.js b/backend/routes/webhooks.js new file mode 100644 index 0000000000..0c95b89c3f --- /dev/null +++ b/backend/routes/webhooks.js @@ -0,0 +1,70 @@ +import express from "express"; +import internalWebhook from "../internal/webhook.js"; +import jwtdecode from "../lib/express/jwt-decode.js"; +import validator from "../lib/validator/index.js"; +import { debug, express as logger } from "../logger.js"; + +const router = express.Router({ + caseSensitive: true, + strict: true, + mergeParams: true, +}); + +router + .route("/") + .options((_, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + .get(async (req, res, next) => { + try { + const rows = await internalWebhook.getAll(res.locals.access); + res.status(200).send(rows); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }) + .post(async (req, res, next) => { + try { + const payload = await validator( + { + additionalProperties: false, + required: ["name", "url", "events"], + properties: { + name: { type: "string", minLength: 1 }, + url: { type: "string", minLength: 1 }, + events: { type: "array", items: { type: "string" }, minItems: 1 }, + secret: { type: "string" }, + is_enabled: { type: "boolean" }, + }, + }, + req.body, + ); + const result = await internalWebhook.create(res.locals.access, payload); + res.status(201).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +router + .route("/:webhook_id") + .options((_, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + .delete(async (req, res, next) => { + try { + await internalWebhook.delete(res.locals.access, { + id: Number.parseInt(req.params.webhook_id, 10), + }); + res.status(200).send(true); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +export default router; diff --git a/backend/schema/components/certificate-object.json b/backend/schema/components/certificate-object.json index 80cd92befe..975833d2e9 100644 --- a/backend/schema/components/certificate-object.json +++ b/backend/schema/components/certificate-object.json @@ -62,6 +62,30 @@ "dns_provider_credentials": { "type": "string" }, + "credential_ref": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": ["internal", "external"] + }, + "id": { + "type": "integer", + "minimum": 1 + }, + "provider_id": { + "type": "integer", + "minimum": 1 + }, + "path": { + "type": "string" + }, + "field": { + "type": "string" + } + } + }, "dns_provider": { "type": "string" }, diff --git a/backend/schema/components/credential-object.json b/backend/schema/components/credential-object.json new file mode 100644 index 0000000000..e52f149979 --- /dev/null +++ b/backend/schema/components/credential-object.json @@ -0,0 +1,35 @@ +{ + "type": "object", + "description": "Credential metadata (secret stored on persistent volume)", + "required": ["id", "created_on", "modified_on", "owner_user_id", "name", "dns_provider"], + "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" + }, + "owner_user_id": { + "$ref": "../common.json#/properties/user_id" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "example": "Cloudflare Production" + }, + "dns_provider": { + "type": "string", + "minLength": 1, + "example": "cloudflare" + }, + "last_used_at": { + "type": ["string", "null"], + "example": "2026-06-03T12:00:00.000Z" + } + } +} diff --git a/backend/schema/components/permission-object.json b/backend/schema/components/permission-object.json index cae9d26c02..43d6bcbc2f 100644 --- a/backend/schema/components/permission-object.json +++ b/backend/schema/components/permission-object.json @@ -43,6 +43,12 @@ "description": "Certificates Permissions", "enum": ["hidden", "view", "manage"], "example": "hidden" + }, + "credentials": { + "type": "string", + "description": "Stored DNS Credentials Permissions", + "enum": ["hidden", "view", "manage"], + "example": "manage" } } } diff --git a/backend/schema/components/user-object.json b/backend/schema/components/user-object.json index 7acd0a4290..91dc5d8178 100644 --- a/backend/schema/components/user-object.json +++ b/backend/schema/components/user-object.json @@ -65,7 +65,8 @@ "dead_hosts", "streams", "access_lists", - "certificates" + "certificates", + "credentials" ], "properties": { "visibility": { @@ -109,6 +110,12 @@ "description": "Certificates access level", "example": "view", "pattern": "^(manage|view|hidden)$" + }, + "credentials": { + "type": "string", + "description": "Stored DNS credentials access level", + "example": "manage", + "pattern": "^(manage|view|hidden)$" } } } diff --git a/backend/schema/paths/api-keys/apiKeyID/delete.json b/backend/schema/paths/api-keys/apiKeyID/delete.json new file mode 100644 index 0000000000..6910f4ba0e --- /dev/null +++ b/backend/schema/paths/api-keys/apiKeyID/delete.json @@ -0,0 +1,9 @@ +{ + "operationId": "deleteApiKey", + "summary": "Revoke an API key", + "tags": ["api-keys"], + "security": [{ "bearerAuth": [] }], + "responses": { + "200": { "description": "OK" } + } +} diff --git a/backend/schema/paths/api-keys/get.json b/backend/schema/paths/api-keys/get.json new file mode 100644 index 0000000000..5bc4b9b79c --- /dev/null +++ b/backend/schema/paths/api-keys/get.json @@ -0,0 +1,16 @@ +{ + "operationId": "listApiKeys", + "summary": "List API keys", + "tags": ["api-keys"], + "security": [{ "bearerAuth": [] }], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "type": "array", "items": { "type": "object" } } + } + } + } + } +} diff --git a/backend/schema/paths/api-keys/post.json b/backend/schema/paths/api-keys/post.json new file mode 100644 index 0000000000..76c6f25c0a --- /dev/null +++ b/backend/schema/paths/api-keys/post.json @@ -0,0 +1,25 @@ +{ + "operationId": "createApiKey", + "summary": "Create an API key", + "tags": ["api-keys"], + "security": [{ "bearerAuth": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string" }, + "permissions": { "type": "object" }, + "expires_on": { "type": ["string", "null"] } + } + } + } + } + }, + "responses": { + "201": { "description": "Created" } + } +} diff --git a/backend/schema/paths/credential-providers/get.json b/backend/schema/paths/credential-providers/get.json new file mode 100644 index 0000000000..5fd0c1fe2e --- /dev/null +++ b/backend/schema/paths/credential-providers/get.json @@ -0,0 +1 @@ +{ "operationId": "listCredentialProviders", "summary": "List credential providers", "tags": ["credential-providers"], "responses": { "200": { "description": "OK" } } } diff --git a/backend/schema/paths/credential-providers/post.json b/backend/schema/paths/credential-providers/post.json new file mode 100644 index 0000000000..282cb036d0 --- /dev/null +++ b/backend/schema/paths/credential-providers/post.json @@ -0,0 +1 @@ +{ "operationId": "createCredentialProvider", "summary": "Create credential provider", "tags": ["credential-providers"], "responses": { "201": { "description": "Created" } } } diff --git a/backend/schema/paths/credential-providers/providerID/delete.json b/backend/schema/paths/credential-providers/providerID/delete.json new file mode 100644 index 0000000000..21108de082 --- /dev/null +++ b/backend/schema/paths/credential-providers/providerID/delete.json @@ -0,0 +1 @@ +{ "operationId": "deleteCredentialProvider", "summary": "Delete credential provider", "tags": ["credential-providers"], "responses": { "200": { "description": "OK" } } } diff --git a/backend/schema/paths/credential-providers/providerID/get.json b/backend/schema/paths/credential-providers/providerID/get.json new file mode 100644 index 0000000000..12fd5e84d7 --- /dev/null +++ b/backend/schema/paths/credential-providers/providerID/get.json @@ -0,0 +1 @@ +{ "operationId": "getCredentialProvider", "summary": "Get credential provider", "tags": ["credential-providers"], "responses": { "200": { "description": "OK" } } } diff --git a/backend/schema/paths/credential-providers/providerID/put.json b/backend/schema/paths/credential-providers/providerID/put.json new file mode 100644 index 0000000000..6085c26c1f --- /dev/null +++ b/backend/schema/paths/credential-providers/providerID/put.json @@ -0,0 +1 @@ +{ "operationId": "updateCredentialProvider", "summary": "Update credential provider", "tags": ["credential-providers"], "responses": { "200": { "description": "OK" } } } diff --git a/backend/schema/paths/credential-providers/providerID/test-resolve/post.json b/backend/schema/paths/credential-providers/providerID/test-resolve/post.json new file mode 100644 index 0000000000..b2ee335252 --- /dev/null +++ b/backend/schema/paths/credential-providers/providerID/test-resolve/post.json @@ -0,0 +1 @@ +{ "operationId": "testCredentialProviderResolve", "summary": "Test secret resolution", "tags": ["credential-providers"], "responses": { "200": { "description": "OK" } } } diff --git a/backend/schema/paths/credential-providers/providerID/test/post.json b/backend/schema/paths/credential-providers/providerID/test/post.json new file mode 100644 index 0000000000..1faf5ec922 --- /dev/null +++ b/backend/schema/paths/credential-providers/providerID/test/post.json @@ -0,0 +1 @@ +{ "operationId": "testCredentialProvider", "summary": "Test OIDC connection", "tags": ["credential-providers"], "responses": { "200": { "description": "OK" } } } diff --git a/backend/schema/paths/credentials/credentialID/delete.json b/backend/schema/paths/credentials/credentialID/delete.json new file mode 100644 index 0000000000..0fc8eb3ba6 --- /dev/null +++ b/backend/schema/paths/credentials/credentialID/delete.json @@ -0,0 +1,16 @@ +{ + "operationId": "deleteCredential", + "summary": "Delete a stored DNS credential", + "tags": ["credentials"], + "security": [{ "bearerAuth": ["credentials.manage"] }], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "type": "boolean" } + } + } + } + } +} diff --git a/backend/schema/paths/credentials/credentialID/get.json b/backend/schema/paths/credentials/credentialID/get.json new file mode 100644 index 0000000000..3cdbb2b79a --- /dev/null +++ b/backend/schema/paths/credentials/credentialID/get.json @@ -0,0 +1,16 @@ +{ + "operationId": "getCredential", + "summary": "Get a stored DNS credential", + "tags": ["credentials"], + "security": [{ "bearerAuth": ["credentials.view"] }], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "../../../components/credential-object.json" } + } + } + } + } +} diff --git a/backend/schema/paths/credentials/credentialID/put.json b/backend/schema/paths/credentials/credentialID/put.json new file mode 100644 index 0000000000..950d08a8b5 --- /dev/null +++ b/backend/schema/paths/credentials/credentialID/put.json @@ -0,0 +1,32 @@ +{ + "operationId": "updateCredential", + "summary": "Update a stored DNS credential", + "tags": ["credentials"], + "security": [{ "bearerAuth": ["credentials.manage"] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 255 }, + "dns_provider": { "type": "string", "minLength": 1 }, + "credentials": { "type": "string", "minLength": 1 } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "../../../components/credential-object.json" } + } + } + } + } +} diff --git a/backend/schema/paths/credentials/credentialID/test/post.json b/backend/schema/paths/credentials/credentialID/test/post.json new file mode 100644 index 0000000000..70ec348fcf --- /dev/null +++ b/backend/schema/paths/credentials/credentialID/test/post.json @@ -0,0 +1,23 @@ +{ + "operationId": "testCredential", + "summary": "Verify a stored credential can be read from the vault", + "tags": ["credentials"], + "security": [{ "bearerAuth": ["credentials.view"] }], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { "type": "boolean" }, + "dns_provider": { "type": "string" }, + "plugin_name": { "type": "string" } + } + } + } + } + } + } +} diff --git a/backend/schema/paths/credentials/get.json b/backend/schema/paths/credentials/get.json new file mode 100644 index 0000000000..1edf119be1 --- /dev/null +++ b/backend/schema/paths/credentials/get.json @@ -0,0 +1,19 @@ +{ + "operationId": "listCredentials", + "summary": "List stored DNS credentials", + "tags": ["credentials"], + "security": [{ "bearerAuth": ["credentials.view"] }], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { "$ref": "../../components/credential-object.json" } + } + } + } + } + } +} diff --git a/backend/schema/paths/credentials/migrate-legacy/post.json b/backend/schema/paths/credentials/migrate-legacy/post.json new file mode 100644 index 0000000000..eabe20f2cf --- /dev/null +++ b/backend/schema/paths/credentials/migrate-legacy/post.json @@ -0,0 +1,40 @@ +{ + "operationId": "migrateLegacyCredentials", + "summary": "Migrate plaintext DNS credentials from certificates into the vault", + "tags": ["credentials"], + "security": [{ "bearerAuth": ["credentials.manage"] }], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "dry_run": { "type": "boolean" } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["dry_run", "count", "results"], + "properties": { + "dry_run": { "type": "boolean" }, + "count": { "type": "integer" }, + "results": { + "type": "array", + "items": { "type": "object" } + } + } + } + } + } + } + } +} diff --git a/backend/schema/paths/credentials/post.json b/backend/schema/paths/credentials/post.json new file mode 100644 index 0000000000..504dc6a3be --- /dev/null +++ b/backend/schema/paths/credentials/post.json @@ -0,0 +1,37 @@ +{ + "operationId": "createCredential", + "summary": "Create a stored DNS credential", + "tags": ["credentials"], + "security": [{ "bearerAuth": ["credentials.manage"] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": ["name", "dns_provider", "credentials"], + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 255 }, + "dns_provider": { "type": "string", "minLength": 1 }, + "credentials": { + "type": "string", + "minLength": 1, + "description": "Certbot DNS plugin credentials file content (INI format)" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { "$ref": "../../components/credential-object.json" } + } + } + } + } +} diff --git a/backend/schema/paths/jobs/get.json b/backend/schema/paths/jobs/get.json new file mode 100644 index 0000000000..0743010efa --- /dev/null +++ b/backend/schema/paths/jobs/get.json @@ -0,0 +1,6 @@ +{ + "operationId": "listJobs", + "summary": "List recent async jobs", + "tags": ["jobs"], + "responses": { "200": { "description": "OK" } } +} diff --git a/backend/schema/paths/jobs/jobID/get.json b/backend/schema/paths/jobs/jobID/get.json new file mode 100644 index 0000000000..d55c70b6c4 --- /dev/null +++ b/backend/schema/paths/jobs/jobID/get.json @@ -0,0 +1,9 @@ +{ + "operationId": "getJob", + "summary": "Get async job status", + "tags": ["jobs"], + "security": [{ "bearerAuth": [] }], + "responses": { + "200": { "description": "OK" } + } +} diff --git a/backend/schema/paths/nginx/certificates/certID/put.json b/backend/schema/paths/nginx/certificates/certID/put.json new file mode 100644 index 0000000000..24c328b377 --- /dev/null +++ b/backend/schema/paths/nginx/certificates/certID/put.json @@ -0,0 +1,35 @@ +{ + "operationId": "updateCertificate", + "summary": "Update a Certificate", + "tags": ["certificates"], + "security": [{ "bearerAuth": ["certificates.manage"] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "nice_name": { "type": "string" }, + "domain_names": { + "type": "array", + "items": { "type": "string" } + }, + "meta": { "type": "object" } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "../../../../components/certificate-object.json" } + } + } + } + } +} diff --git a/backend/schema/paths/webhooks/get.json b/backend/schema/paths/webhooks/get.json new file mode 100644 index 0000000000..a02c037050 --- /dev/null +++ b/backend/schema/paths/webhooks/get.json @@ -0,0 +1,9 @@ +{ + "operationId": "listWebhooks", + "summary": "List webhook endpoints", + "tags": ["webhooks"], + "security": [{ "bearerAuth": [] }], + "responses": { + "200": { "description": "OK" } + } +} diff --git a/backend/schema/paths/webhooks/post.json b/backend/schema/paths/webhooks/post.json new file mode 100644 index 0000000000..34e1a765bf --- /dev/null +++ b/backend/schema/paths/webhooks/post.json @@ -0,0 +1,27 @@ +{ + "operationId": "createWebhook", + "summary": "Create a webhook endpoint", + "tags": ["webhooks"], + "security": [{ "bearerAuth": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["name", "url", "events"], + "properties": { + "name": { "type": "string" }, + "url": { "type": "string" }, + "events": { "type": "array", "items": { "type": "string" } }, + "secret": { "type": "string" }, + "is_enabled": { "type": "boolean" } + } + } + } + } + }, + "responses": { + "201": { "description": "Created" } + } +} diff --git a/backend/schema/paths/webhooks/webhookID/delete.json b/backend/schema/paths/webhooks/webhookID/delete.json new file mode 100644 index 0000000000..3a9ab235c4 --- /dev/null +++ b/backend/schema/paths/webhooks/webhookID/delete.json @@ -0,0 +1,9 @@ +{ + "operationId": "deleteWebhook", + "summary": "Delete a webhook endpoint", + "tags": ["webhooks"], + "security": [{ "bearerAuth": [] }], + "responses": { + "200": { "description": "OK" } + } +} diff --git a/backend/schema/swagger.json b/backend/schema/swagger.json index 4222f19ddd..4fb0794701 100644 --- a/backend/schema/swagger.json +++ b/backend/schema/swagger.json @@ -32,6 +32,26 @@ "name": "certificates", "description": "Endpoints related to Certificates" }, + { + "name": "credentials", + "description": "Endpoints related to stored DNS credentials" + }, + { + "name": "credential-providers", + "description": "External secret store providers (Vault, AWS, Azure, Infisical)" + }, + { + "name": "api-keys", + "description": "Endpoints for automation API keys" + }, + { + "name": "jobs", + "description": "Endpoints for async background jobs" + }, + { + "name": "webhooks", + "description": "Endpoints for outbound webhook configuration" + }, { "name": "404-hosts", "description": "Endpoints related to 404 Hosts" @@ -127,6 +147,9 @@ "get": { "$ref": "./paths/nginx/certificates/certID/get.json" }, + "put": { + "$ref": "./paths/nginx/certificates/certID/put.json" + }, "delete": { "$ref": "./paths/nginx/certificates/certID/delete.json" } @@ -357,6 +380,100 @@ "post": { "$ref": "./paths/users/userID/login/post.json" } + }, + "/credentials": { + "get": { + "$ref": "./paths/credentials/get.json" + }, + "post": { + "$ref": "./paths/credentials/post.json" + } + }, + "/credentials/migrate-legacy": { + "post": { + "$ref": "./paths/credentials/migrate-legacy/post.json" + } + }, + "/credentials/{credentialID}": { + "get": { + "$ref": "./paths/credentials/credentialID/get.json" + }, + "put": { + "$ref": "./paths/credentials/credentialID/put.json" + }, + "delete": { + "$ref": "./paths/credentials/credentialID/delete.json" + } + }, + "/credentials/{credentialID}/test": { + "post": { + "$ref": "./paths/credentials/credentialID/test/post.json" + } + }, + "/credential-providers": { + "get": { + "$ref": "./paths/credential-providers/get.json" + }, + "post": { + "$ref": "./paths/credential-providers/post.json" + } + }, + "/credential-providers/{providerID}": { + "get": { + "$ref": "./paths/credential-providers/providerID/get.json" + }, + "put": { + "$ref": "./paths/credential-providers/providerID/put.json" + }, + "delete": { + "$ref": "./paths/credential-providers/providerID/delete.json" + } + }, + "/credential-providers/{providerID}/test": { + "post": { + "$ref": "./paths/credential-providers/providerID/test/post.json" + } + }, + "/credential-providers/{providerID}/test-resolve": { + "post": { + "$ref": "./paths/credential-providers/providerID/test-resolve/post.json" + } + }, + "/api-keys": { + "get": { + "$ref": "./paths/api-keys/get.json" + }, + "post": { + "$ref": "./paths/api-keys/post.json" + } + }, + "/api-keys/{apiKeyID}": { + "delete": { + "$ref": "./paths/api-keys/apiKeyID/delete.json" + } + }, + "/jobs": { + "get": { + "$ref": "./paths/jobs/get.json" + } + }, + "/jobs/{jobID}": { + "get": { + "$ref": "./paths/jobs/jobID/get.json" + } + }, + "/webhooks": { + "get": { + "$ref": "./paths/webhooks/get.json" + }, + "post": { + "$ref": "./paths/webhooks/post.json" + } + }, + "/webhooks/{webhookID}": { + "delete": { + "$ref": "./paths/webhooks/webhookID/delete.json" + } } } } diff --git a/backend/setup.js b/backend/setup.js index 84f42793ea..c2c1913daa 100644 --- a/backend/setup.js +++ b/backend/setup.js @@ -1,4 +1,6 @@ import { installPlugins } from "./lib/certbot.js"; +import { materializeCertbotCredentials } from "./lib/secrets/resolve.js"; +import { ensureCredentialDirs } from "./lib/secrets/storage.js"; import utils from "./lib/utils.js"; import { setup as logger } from "./logger.js"; import authModel from "./models/auth.js"; @@ -61,6 +63,7 @@ const setupDefaultUser = async () => { user_id: user.id, visibility: "all", proxy_hosts: "manage", + credentials: "manage", redirection_hosts: "manage", dead_hosts: "manage", streams: "manage", @@ -118,15 +121,18 @@ const setupCertbotPlugins = async () => { plugins.push(certificate.meta.dns_provider); } - // Make sure credentials file exists - const credentials_loc = `/etc/letsencrypt/credentials/credentials-${certificate.id}`; - // Escape single quotes and backslashes - if (typeof certificate.meta.dns_provider_credentials === "string") { - const escapedCredentials = certificate.meta.dns_provider_credentials - .replaceAll("'", "\\'") - .replaceAll("\\", "\\\\"); - const credentials_cmd = `[ -f '${credentials_loc}' ] || { mkdir -p /etc/letsencrypt/credentials 2> /dev/null; echo '${escapedCredentials}' > '${credentials_loc}' && chmod 600 '${credentials_loc}'; }`; - promises.push(utils.exec(credentials_cmd)); + // Make sure credentials file exists (from vault or legacy meta) + if ( + certificate.meta?.credential_ref?.type === "internal" || + typeof certificate.meta.dns_provider_credentials === "string" + ) { + promises.push( + materializeCertbotCredentials(certificate).catch((err) => { + logger.warn( + `Could not restore credentials for certificate #${certificate.id}: ${err.message}`, + ); + }), + ); } } return true; @@ -163,4 +169,9 @@ const setupLogrotation = () => { return runLogrotate(); }; -export default () => setupDefaultUser().then(setupDefaultSettings).then(setupCertbotPlugins).then(setupLogrotation); +export default () => + ensureCredentialDirs() + .then(setupDefaultUser) + .then(setupDefaultSettings) + .then(setupCertbotPlugins) + .then(setupLogrotation); 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..42135d6e80 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 @@ -28,6 +28,8 @@ mkdir -p \ /data/nginx/dead_host \ /data/nginx/temp \ /data/letsencrypt-acme-challenge \ + /data/credentials \ + /data/credentials/providers \ /run/nginx \ /tmp/nginx/body \ /var/log/nginx \ diff --git a/docs/src/advanced/automation-api.md b/docs/src/advanced/automation-api.md new file mode 100644 index 0000000000..485bf3a993 --- /dev/null +++ b/docs/src/advanced/automation-api.md @@ -0,0 +1,139 @@ +--- +outline: deep +--- + +# Automation API + +Nginx Proxy Manager exposes a REST API at `/api` on the admin port (default `81`). OpenAPI schema: `GET /api/schema`. + +## Authentication + +### User JWT (existing) + +```bash +curl -s -X POST http://127.0.0.1:81/api/tokens \ + -H 'Content-Type: application/json' \ + -d '{"identity":"admin@example.com","secret":"your-password"}' +``` + +Use `Authorization: Bearer ` on subsequent requests. Tokens default to 1-day expiry; refresh with `GET /api/tokens`. + +### API keys (new) + +Admins can create long-lived keys: + +```bash +curl -s -X POST http://127.0.0.1:81/api/api-keys \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"name":"terraform","permissions":{"proxy_hosts":"manage","certificates":"manage"}}' +``` + +The response includes `key` once (`npmak_...`). Use it as: + +`Authorization: Bearer npmak__` + +## Proxy hosts + +| Method | Path | +|--------|------| +| GET | `/api/nginx/proxy-hosts` | +| POST | `/api/nginx/proxy-hosts` | +| GET | `/api/nginx/proxy-hosts/{id}` | +| PUT | `/api/nginx/proxy-hosts/{id}` | +| DELETE | `/api/nginx/proxy-hosts/{id}` | +| POST | `/api/nginx/proxy-hosts/{id}/enable` | +| POST | `/api/nginx/proxy-hosts/{id}/disable` | + +## Certificates + +| Method | Path | +|--------|------| +| GET | `/api/nginx/certificates` | +| POST | `/api/nginx/certificates` | +| PUT | `/api/nginx/certificates/{id}` | +| DELETE | `/api/nginx/certificates/{id}` | +| POST | `/api/nginx/certificates/{id}/renew` | + +Append `?async=true` to **POST** (create) or **renew** to receive `202` with `{ job_id, status }`. Poll `GET /api/jobs/{job_id}`. + +## DNS credential vault + +Secrets are encrypted on the **`/data` persistent volume** at `/data/credentials/`. Metadata is in the database. + +| Method | Path | +|--------|------| +| GET | `/api/credentials` | +| POST | `/api/credentials` | +| PUT | `/api/credentials/{id}` | +| DELETE | `/api/credentials/{id}` | +| POST | `/api/credentials/{id}/test` | + +Create a certificate referencing a stored credential: + +```json +{ + "provider": "letsencrypt", + "domain_names": ["example.com"], + "meta": { + "dns_challenge": true, + "dns_provider": "cloudflare", + "credential_ref": { "type": "internal", "id": 1 } + } +} +``` + +Set `NPM_SECRETS_ENCRYPTION_KEY` (32-byte base64) before first use in production, or NPM generates `/data/keys/secrets.json` on the data volume. + +## External credential stores (Vault, AWS, Azure, Infisical) + +Configure providers (admin, Settings → External Credential Stores or API): + +| Method | Path | +|--------|------| +| GET | `/api/credential-providers` | +| POST | `/api/credential-providers` | +| PUT | `/api/credential-providers/{id}` | +| DELETE | `/api/credential-providers/{id}` | +| POST | `/api/credential-providers/{id}/test` | +| POST | `/api/credential-providers/{id}/test-resolve` | + +Provider `type`: `vault`, `aws`, `azure`, `infisical`, `http`. OIDC client secrets are stored encrypted under `/data/credentials/providers/`. + +Reference in a certificate: + +```json +"meta": { + "dns_challenge": true, + "dns_provider": "cloudflare", + "credential_ref": { + "type": "external", + "provider_id": 1, + "path": "dns/cloudflare/prod", + "field": "optional_json_key" + } +} +``` + +## Webhooks + +Configure endpoints (admin only): + +| Method | Path | +|--------|------| +| GET | `/api/webhooks` | +| POST | `/api/webhooks` | +| DELETE | `/api/webhooks/{id}` | + +Events: `proxy_host.created|updated|deleted|enabled|disabled`, `certificate.created|updated|deleted|renewed`. Verify `X-NPM-Signature: sha256=` over the raw JSON body. + +## Docker volume + +Mount persistent storage: + +```yaml +volumes: + - ./data:/data +``` + +The credential vault requires `/data` to survive container recreation. diff --git a/frontend/src/Router.tsx b/frontend/src/Router.tsx index 6aa8f0894f..7c82bae1e4 100644 --- a/frontend/src/Router.tsx +++ b/frontend/src/Router.tsx @@ -18,6 +18,7 @@ const Login = lazy(() => import("src/pages/Login")); const Dashboard = lazy(() => import("src/pages/Dashboard")); const Settings = lazy(() => import("src/pages/Settings")); const Certificates = lazy(() => import("src/pages/Certificates")); +const Credentials = lazy(() => import("src/pages/Credentials")); const Access = lazy(() => import("src/pages/Access")); const AuditLog = lazy(() => import("src/pages/AuditLog")); const Users = lazy(() => import("src/pages/Users")); @@ -62,6 +63,7 @@ function Router() { } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/backend/createApiKey.ts b/frontend/src/api/backend/createApiKey.ts new file mode 100644 index 0000000000..5a4edd58a4 --- /dev/null +++ b/frontend/src/api/backend/createApiKey.ts @@ -0,0 +1,10 @@ +import * as api from "./base"; +import type { ApiKey } from "./getApiKeys"; + +export async function createApiKey(data: { + name: string; + permissions?: Record; + expiresOn?: string | null; +}): Promise { + return await api.post({ url: "/api-keys", data }); +} diff --git a/frontend/src/api/backend/createCredential.ts b/frontend/src/api/backend/createCredential.ts new file mode 100644 index 0000000000..9ac622be92 --- /dev/null +++ b/frontend/src/api/backend/createCredential.ts @@ -0,0 +1,10 @@ +import * as api from "./base"; +import type { StoredCredential } from "./getCredentials"; + +export async function createCredential(data: { + name: string; + dnsProvider: string; + credentials: string; +}): Promise { + return await api.post({ url: "/credentials", data }); +} diff --git a/frontend/src/api/backend/createCredentialProvider.ts b/frontend/src/api/backend/createCredentialProvider.ts new file mode 100644 index 0000000000..9b1dcecd78 --- /dev/null +++ b/frontend/src/api/backend/createCredentialProvider.ts @@ -0,0 +1,6 @@ +import * as api from "./base"; +import type { CredentialProvider } from "./getCredentialProviders"; + +export async function createCredentialProvider(data: Record): Promise { + return await api.post({ url: "/credential-providers", data }); +} diff --git a/frontend/src/api/backend/createWebhook.ts b/frontend/src/api/backend/createWebhook.ts new file mode 100644 index 0000000000..fb5ba5ade7 --- /dev/null +++ b/frontend/src/api/backend/createWebhook.ts @@ -0,0 +1,12 @@ +import * as api from "./base"; +import type { WebhookEndpoint } from "./getWebhooks"; + +export async function createWebhook(data: { + name: string; + url: string; + events: string[]; + secret?: string; + isEnabled?: boolean; +}): Promise { + return await api.post({ url: "/webhooks", data }); +} diff --git a/frontend/src/api/backend/deleteApiKey.ts b/frontend/src/api/backend/deleteApiKey.ts new file mode 100644 index 0000000000..b1b018a7f8 --- /dev/null +++ b/frontend/src/api/backend/deleteApiKey.ts @@ -0,0 +1,5 @@ +import * as api from "./base"; + +export async function deleteApiKey(id: number): Promise { + return await api.del({ url: `/api-keys/${id}` }); +} diff --git a/frontend/src/api/backend/deleteCredential.ts b/frontend/src/api/backend/deleteCredential.ts new file mode 100644 index 0000000000..4ca78f8c2e --- /dev/null +++ b/frontend/src/api/backend/deleteCredential.ts @@ -0,0 +1,5 @@ +import * as api from "./base"; + +export async function deleteCredential(id: number): Promise { + return await api.del({ url: `/credentials/${id}` }); +} diff --git a/frontend/src/api/backend/deleteCredentialProvider.ts b/frontend/src/api/backend/deleteCredentialProvider.ts new file mode 100644 index 0000000000..e2a24c1693 --- /dev/null +++ b/frontend/src/api/backend/deleteCredentialProvider.ts @@ -0,0 +1,5 @@ +import * as api from "./base"; + +export async function deleteCredentialProvider(id: number): Promise { + return await api.del({ url: `/credential-providers/${id}` }); +} diff --git a/frontend/src/api/backend/deleteWebhook.ts b/frontend/src/api/backend/deleteWebhook.ts new file mode 100644 index 0000000000..65f3e26039 --- /dev/null +++ b/frontend/src/api/backend/deleteWebhook.ts @@ -0,0 +1,5 @@ +import * as api from "./base"; + +export async function deleteWebhook(id: number): Promise { + return await api.del({ url: `/webhooks/${id}` }); +} diff --git a/frontend/src/api/backend/getApiKeys.ts b/frontend/src/api/backend/getApiKeys.ts new file mode 100644 index 0000000000..1d94a111e9 --- /dev/null +++ b/frontend/src/api/backend/getApiKeys.ts @@ -0,0 +1,15 @@ +import * as api from "./base"; + +export interface ApiKey { + id: number; + name: string; + keyPrefix: string; + permissions: Record; + expiresOn?: string | null; + lastUsedAt?: string | null; + createdOn: string; +} + +export async function getApiKeys(): Promise { + return await api.get({ url: "/api-keys" }); +} diff --git a/frontend/src/api/backend/getCredentialProviders.ts b/frontend/src/api/backend/getCredentialProviders.ts new file mode 100644 index 0000000000..5cb09295e2 --- /dev/null +++ b/frontend/src/api/backend/getCredentialProviders.ts @@ -0,0 +1,17 @@ +import * as api from "./base"; + +export interface CredentialProvider { + id: number; + name: string; + type: string; + oidcIssuer?: string; + oidcClientId?: string; + oidcAudience?: string; + oidcScope?: string; + meta: Record; + hasOidcSecret?: boolean; +} + +export async function getCredentialProviders(): Promise { + return await api.get({ url: "/credential-providers" }); +} diff --git a/frontend/src/api/backend/getCredentials.ts b/frontend/src/api/backend/getCredentials.ts new file mode 100644 index 0000000000..9c09ad6585 --- /dev/null +++ b/frontend/src/api/backend/getCredentials.ts @@ -0,0 +1,14 @@ +import * as api from "./base"; + +export interface StoredCredential { + id: number; + name: string; + dnsProvider: string; + createdOn: string; + modifiedOn: string; + lastUsedAt?: string | null; +} + +export async function getCredentials(): Promise { + return await api.get({ url: "/credentials" }); +} diff --git a/frontend/src/api/backend/getWebhooks.ts b/frontend/src/api/backend/getWebhooks.ts new file mode 100644 index 0000000000..188049ec43 --- /dev/null +++ b/frontend/src/api/backend/getWebhooks.ts @@ -0,0 +1,13 @@ +import * as api from "./base"; + +export interface WebhookEndpoint { + id: number; + name: string; + url: string; + events: string[]; + isEnabled: boolean; +} + +export async function getWebhooks(): Promise { + return await api.get({ url: "/webhooks" }); +} diff --git a/frontend/src/api/backend/index.ts b/frontend/src/api/backend/index.ts index 40cb4142fc..000ae7ac38 100644 --- a/frontend/src/api/backend/index.ts +++ b/frontend/src/api/backend/index.ts @@ -1,5 +1,9 @@ export * from "./checkVersion"; +export * from "./createApiKey"; export * from "./createAccessList"; +export * from "./createCredential"; +export * from "./createCredentialProvider"; +export * from "./createWebhook"; export * from "./createCertificate"; export * from "./createDeadHost"; export * from "./createProxyHost"; @@ -7,6 +11,10 @@ export * from "./createRedirectionHost"; export * from "./createStream"; export * from "./createUser"; export * from "./deleteAccessList"; +export * from "./deleteApiKey"; +export * from "./deleteCredential"; +export * from "./deleteCredentialProvider"; +export * from "./deleteWebhook"; export * from "./deleteCertificate"; export * from "./deleteDeadHost"; export * from "./deleteProxyHost"; @@ -16,6 +24,10 @@ export * from "./deleteUser"; export * from "./downloadCertificate"; export * from "./expansions"; export * from "./getAccessList"; +export * from "./getApiKeys"; +export * from "./getCredentialProviders"; +export * from "./getCredentials"; +export * from "./getWebhooks"; export * from "./getAccessLists"; export * from "./getAuditLog"; export * from "./getAuditLogs"; @@ -39,11 +51,15 @@ export * from "./getUser"; export * from "./getUsers"; export * from "./helpers"; export * from "./loginAsUser"; +export * from "./migrateLegacyCredentials"; export * from "./models"; export * from "./refreshToken"; export * from "./renewCertificate"; export * from "./responseTypes"; export * from "./setPermissions"; +export * from "./testCredentialProvider"; +export * from "./updateCredential"; +export * from "./updateCredentialProvider"; export * from "./testHttpCertificate"; export * from "./toggleDeadHost"; export * from "./toggleProxyHost"; diff --git a/frontend/src/api/backend/migrateLegacyCredentials.ts b/frontend/src/api/backend/migrateLegacyCredentials.ts new file mode 100644 index 0000000000..6ef292ce3d --- /dev/null +++ b/frontend/src/api/backend/migrateLegacyCredentials.ts @@ -0,0 +1,5 @@ +import * as api from "./base"; + +export async function migrateLegacyCredentials(dryRun = false) { + return await api.post({ url: "/credentials/migrate-legacy", data: { dryRun } }); +} diff --git a/frontend/src/api/backend/models.ts b/frontend/src/api/backend/models.ts index 2ae0b08348..e2a0d99c13 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; + credentials: string; } export interface User { diff --git a/frontend/src/api/backend/testCredentialProvider.ts b/frontend/src/api/backend/testCredentialProvider.ts new file mode 100644 index 0000000000..7a3dd3987a --- /dev/null +++ b/frontend/src/api/backend/testCredentialProvider.ts @@ -0,0 +1,9 @@ +import * as api from "./base"; + +export async function testCredentialProvider(id: number) { + return await api.post({ url: `/credential-providers/${id}/test` }); +} + +export async function testCredentialProviderResolve(id: number, path: string, field?: string) { + return await api.post({ url: `/credential-providers/${id}/test-resolve`, data: { path, field } }); +} diff --git a/frontend/src/api/backend/updateCredential.ts b/frontend/src/api/backend/updateCredential.ts new file mode 100644 index 0000000000..e5be234c58 --- /dev/null +++ b/frontend/src/api/backend/updateCredential.ts @@ -0,0 +1,9 @@ +import * as api from "./base"; +import type { StoredCredential } from "./getCredentials"; + +export async function updateCredential( + id: number, + data: Partial<{ name: string; dnsProvider: string; credentials: string }>, +): Promise { + return await api.put({ url: `/credentials/${id}`, data }); +} diff --git a/frontend/src/api/backend/updateCredentialProvider.ts b/frontend/src/api/backend/updateCredentialProvider.ts new file mode 100644 index 0000000000..251b77cfa0 --- /dev/null +++ b/frontend/src/api/backend/updateCredentialProvider.ts @@ -0,0 +1,9 @@ +import * as api from "./base"; +import type { CredentialProvider } from "./getCredentialProviders"; + +export async function updateCredentialProvider( + id: number, + data: Record, +): Promise { + return await api.put({ url: `/credential-providers/${id}`, data }); +} diff --git a/frontend/src/components/Form/DNSProviderFields.tsx b/frontend/src/components/Form/DNSProviderFields.tsx index 182654811a..312f5ccb9a 100644 --- a/frontend/src/components/Form/DNSProviderFields.tsx +++ b/frontend/src/components/Form/DNSProviderFields.tsx @@ -4,7 +4,7 @@ import { Field, useFormikContext } from "formik"; import { useState } from "react"; import Select, { type ActionMeta } from "react-select"; import type { DNSProvider } from "src/api/backend"; -import { useDnsProviders } from "src/hooks"; +import { useCredentialProviders, useCredentials, useDnsProviders } from "src/hooks"; import { intl, T } from "src/locale"; import styles from "./DNSProviderFields.module.css"; @@ -20,13 +20,18 @@ interface Props { export function DNSProviderFields({ showBoundaryBox = false }: Props) { const { values, setFieldValue } = useFormikContext(); const { data: dnsProviders, isLoading } = useDnsProviders(); + const { data: storedCredentials } = useCredentials(); + const { data: externalProviders } = useCredentialProviders(); const [dnsProviderId, setDnsProviderId] = useState(null); + const [credentialSource, setCredentialSource] = useState<"manual" | "internal" | "external">("manual"); const v: any = values || {}; const handleChange = (newValue: any, _actionMeta: ActionMeta) => { setFieldValue("meta.dnsProvider", newValue?.value); setFieldValue("meta.dnsProviderCredentials", newValue?.credentials); + setFieldValue("meta.credentialRef", undefined); + setCredentialSource("manual"); setDnsProviderId(newValue?.value); }; @@ -69,6 +74,114 @@ export function DNSProviderFields({ showBoundaryBox = false }: Props) { {dnsProviderId ? ( <> +
+ + +
+ + {credentialSource === "internal" && storedCredentials?.length ? ( +
+ + +
+ ) : null} + + {credentialSource === "external" && externalProviders?.length ? ( +
+
+ + +
+
+ + { + setFieldValue("meta.credentialRef", { + type: "external", + providerId: v.meta?.credentialRef?.providerId, + path: e.target.value, + }); + }} + /> +
+
+ ) : null} + + {credentialSource === "manual" ? ( {({ field }: any) => (
@@ -105,6 +218,7 @@ export function DNSProviderFields({ showBoundaryBox = false }: Props) {
)}
+ ) : null} {({ field }: any) => (
diff --git a/frontend/src/components/SiteMenu.tsx b/frontend/src/components/SiteMenu.tsx index 265150bb54..2885d39be0 100644 --- a/frontend/src/components/SiteMenu.tsx +++ b/frontend/src/components/SiteMenu.tsx @@ -2,6 +2,7 @@ import { IconBook, IconDeviceDesktop, IconHome, + IconKey, IconLock, IconSettings, IconShield, @@ -15,6 +16,7 @@ import { ACCESS_LISTS, ADMIN, CERTIFICATES, + CREDENTIALS, DEAD_HOSTS, type MANAGE, PROXY_HOSTS, @@ -83,6 +85,13 @@ const menuItems: MenuItem[] = [ permissionSection: CERTIFICATES, permission: VIEW, }, + { + to: "/credentials", + icon: IconKey, + label: "credentials", + permissionSection: CREDENTIALS, + permission: VIEW, + }, { to: "/users", icon: IconUser, diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index 744190ade1..4aad42ffb9 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -8,6 +8,8 @@ export * from "./useCheckVersion"; export * from "./useDeadHost"; export * from "./useDeadHosts"; export * from "./useDnsProviders"; +export * from "./useCredentials"; +export * from "./useCredentialProviders"; export * from "./useHealth"; export * from "./useHostReport"; export * from "./useProxyHost"; diff --git a/frontend/src/hooks/useCredentialProviders.ts b/frontend/src/hooks/useCredentialProviders.ts new file mode 100644 index 0000000000..4ef772bf11 --- /dev/null +++ b/frontend/src/hooks/useCredentialProviders.ts @@ -0,0 +1,9 @@ +import { useQuery } from "@tanstack/react-query"; +import { getCredentialProviders } from "src/api/backend/getCredentialProviders"; + +export function useCredentialProviders() { + return useQuery({ + queryKey: ["credential-providers"], + queryFn: getCredentialProviders, + }); +} diff --git a/frontend/src/hooks/useCredentials.ts b/frontend/src/hooks/useCredentials.ts new file mode 100644 index 0000000000..3da49ba799 --- /dev/null +++ b/frontend/src/hooks/useCredentials.ts @@ -0,0 +1,9 @@ +import { useQuery } from "@tanstack/react-query"; +import { getCredentials } from "src/api/backend/getCredentials"; + +export function useCredentials() { + return useQuery({ + queryKey: ["credentials"], + queryFn: getCredentials, + }); +} diff --git a/frontend/src/locale/src/HelpDoc/en/Credentials.md b/frontend/src/locale/src/HelpDoc/en/Credentials.md new file mode 100644 index 0000000000..621ea11070 --- /dev/null +++ b/frontend/src/locale/src/HelpDoc/en/Credentials.md @@ -0,0 +1,21 @@ +## DNS Credentials + +Stored credentials live on the **`/data` volume** under `/data/credentials/`, encrypted at rest. Use them when issuing DNS certificates so API tokens are not pasted into every certificate. + +### Adding a credential + +1. Open **Credentials** in the sidebar. +2. Choose **Add Credential**, pick the DNS provider, and paste the certbot INI content (same format as the legacy credentials file). +3. When creating or editing a DNS certificate, choose **Stored in NPM vault** and select the credential. + +### Migrating legacy certificates + +Certificates that still store `dns_provider_credentials` inline can be moved into the vault with **Migrate legacy DNS credentials**. Run a dry-run preview first, then apply the migration. + +### External stores + +Admins can configure **Settings → External Credential Stores** (Vault, AWS, Azure, Infisical, HTTP) with OIDC. Certificates can reference secrets by path instead of the internal vault. + +### Automation + +API keys and webhooks are under **Settings**. See the [Automation API](https://nginxproxymanager.com/advanced/automation-api/) documentation for Bearer auth, async jobs, and signed webhooks. diff --git a/frontend/src/locale/src/HelpDoc/en/index.ts b/frontend/src/locale/src/HelpDoc/en/index.ts index a9bb46ba7c..372d4e51f9 100644 --- a/frontend/src/locale/src/HelpDoc/en/index.ts +++ b/frontend/src/locale/src/HelpDoc/en/index.ts @@ -1,5 +1,6 @@ export * as AccessLists from "./AccessLists.md"; export * as Certificates from "./Certificates.md"; +export * as Credentials from "./Credentials.md"; export * as DeadHosts from "./DeadHosts.md"; export * as ProxyHosts from "./ProxyHosts.md"; export * as RedirectionHosts from "./RedirectionHosts.md"; diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json index bb00ac3322..93faacdd22 100644 --- a/frontend/src/locale/src/en.json +++ b/frontend/src/locale/src/en.json @@ -92,6 +92,72 @@ "access-lists": { "defaultMessage": "Access Lists" }, + "api-key": { + "defaultMessage": "API Key" + }, + "api-keys": { + "defaultMessage": "Automation API Keys" + }, + "api-keys.copy-once": { + "defaultMessage": "Copy this key now — it will not be shown again." + }, + "api-keys.revoke-confirm": { + "defaultMessage": "Revoke this API key? Automations using it will stop working." + }, + "api-keys.subtitle": { + "defaultMessage": "Bearer tokens (npmak_…) for scripts and CI. Admin only." + }, + "credential": { + "defaultMessage": "Credential" + }, + "credential-provider": { + "defaultMessage": "Credential Provider" + }, + "credential-providers": { + "defaultMessage": "External Credential Stores" + }, + "credential-providers.subtitle": { + "defaultMessage": "Connect Vault, AWS Secrets Manager, Azure Key Vault, or Infisical via OIDC." + }, + "credentials": { + "defaultMessage": "DNS Credentials" + }, + "credentials.secret-path": { + "defaultMessage": "Secret path" + }, + "credentials.source": { + "defaultMessage": "Credential source" + }, + "credentials.source.external": { + "defaultMessage": "External store (Vault, AWS, …)" + }, + "credentials.source.internal": { + "defaultMessage": "Stored in NPM vault" + }, + "credentials.source.manual": { + "defaultMessage": "Enter manually (one-time)" + }, + "credentials.stored": { + "defaultMessage": "Stored credential" + }, + "credentials.subtitle": { + "defaultMessage": "Encrypted on the /data volume and reusable for DNS certificates." + }, + "credentials.migrate": { + "defaultMessage": "Migrate legacy DNS credentials" + }, + "credentials.migrate.summary": { + "defaultMessage": "{count} certificate(s) would move inline credentials into the vault." + }, + "credential-providers.test-oidc": { + "defaultMessage": "Test OIDC" + }, + "credential-providers.test-resolve": { + "defaultMessage": "Test resolve" + }, + "credential-providers.resolve-path": { + "defaultMessage": "Secret path to resolve" + }, "action.add": { "defaultMessage": "Add" }, @@ -134,6 +200,18 @@ "auditlogs": { "defaultMessage": "Audit Logs" }, + "webhook": { + "defaultMessage": "Webhook" + }, + "webhooks": { + "defaultMessage": "Webhooks" + }, + "webhooks.signing-secret": { + "defaultMessage": "Signing secret (use for X-NPM-Signature verification)" + }, + "webhooks.subtitle": { + "defaultMessage": "Receive signed HTTP callbacks when hosts or certificates change." + }, "auto": { "defaultMessage": "Auto" }, diff --git a/frontend/src/modals/CredentialModal.tsx b/frontend/src/modals/CredentialModal.tsx new file mode 100644 index 0000000000..ef81446690 --- /dev/null +++ b/frontend/src/modals/CredentialModal.tsx @@ -0,0 +1,121 @@ +import EasyModal, { type InnerModalProps } from "ez-modal-react"; +import { Form, Formik, Field } from "formik"; +import { type ReactNode, useState } from "react"; +import { Alert } from "react-bootstrap"; +import Modal from "react-bootstrap/Modal"; +import CodeEditor from "@uiw/react-textarea-code-editor"; +import { createCredential, updateCredential } from "src/api/backend"; +import type { StoredCredential } from "src/api/backend/getCredentials"; +import { Button } from "src/components"; +import { useDnsProviders } from "src/hooks"; +import { T } from "src/locale"; +import { showObjectSuccess } from "src/notifications"; + +const showCredentialModal = (item?: StoredCredential) => { + EasyModal.show(CredentialModal, { item }); +}; + +interface Props extends InnerModalProps { + item?: StoredCredential; +} + +const CredentialModal = EasyModal.create(({ item, visible, remove }: Props) => { + const { data: dnsProviders, isLoading } = useDnsProviders(); + const [errorMsg, setErrorMsg] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const isNew = !item?.id; + + const onSubmit = async (values: any, { setSubmitting }: any) => { + if (isSubmitting) return; + setIsSubmitting(true); + setErrorMsg(null); + try { + if (isNew) { + await createCredential(values); + showObjectSuccess("credential", "saved"); + } else { + await updateCredential(item.id, values); + showObjectSuccess("credential", "saved"); + } + remove(); + } catch (err: any) { + setErrorMsg(err.message || "Error"); + } + setIsSubmitting(false); + setSubmitting(false); + }; + + return ( + + + {() => ( +
+ + + + + + + setErrorMsg(null)} dismissible> + {errorMsg} + +
+ + +
+
+ + + + {dnsProviders?.map((p) => ( + + ))} + +
+
+ + + {({ field }: any) => ( + field.onChange({ target: { name: field.name, value: e.target.value } })} + /> + )} + +
+
+ + + + +
+ )} +
+
+ ); +}); + +export { showCredentialModal }; diff --git a/frontend/src/modals/PermissionsModal.tsx b/frontend/src/modals/PermissionsModal.tsx index d363de9ede..c6ccf6e84c 100644 --- a/frontend/src/modals/PermissionsModal.tsx +++ b/frontend/src/modals/PermissionsModal.tsx @@ -147,6 +147,7 @@ const PermissionsModal = EasyModal.create(({ id, visible, remove }: Props) => { visibility: data.permissions?.visibility, accessLists: data.permissions?.accessLists, certificates: data.permissions?.certificates, + credentials: data.permissions?.credentials || "manage", deadHosts: data.permissions?.deadHosts, proxyHosts: data.permissions?.proxyHosts, redirectionHosts: data.permissions?.redirectionHosts, @@ -259,6 +260,14 @@ const PermissionsModal = EasyModal.create(({ id, visible, remove }: Props) => { {({ field, form }: any) => getPermissionButtons(field, form)}
+
+ + + {({ field, form }: any) => getPermissionButtons(field, form)} + +
)} diff --git a/frontend/src/modals/index.ts b/frontend/src/modals/index.ts index a06a0c0d71..49811a75ec 100644 --- a/frontend/src/modals/index.ts +++ b/frontend/src/modals/index.ts @@ -1,5 +1,6 @@ export * from "./AccessListModal"; export * from "./ChangePasswordModal"; +export * from "./CredentialModal"; export * from "./CustomCertificateModal"; export * from "./DeadHostModal"; export * from "./DeleteConfirmModal"; diff --git a/frontend/src/modules/Permissions.ts b/frontend/src/modules/Permissions.ts index 2d784213e4..ebc5309e20 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 CREDENTIALS = "credentials"; 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 CREDENTIALS; export type Permission = typeof MANAGE | typeof VIEW; diff --git a/frontend/src/pages/Credentials/Table.tsx b/frontend/src/pages/Credentials/Table.tsx new file mode 100644 index 0000000000..372d399986 --- /dev/null +++ b/frontend/src/pages/Credentials/Table.tsx @@ -0,0 +1,85 @@ +import { IconDotsVertical, IconEdit, IconTrash } from "@tabler/icons-react"; +import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table"; +import { useMemo } from "react"; +import type { StoredCredential } from "src/api/backend/getCredentials"; +import { EmptyData, HasPermission, ValueWithDateFormatter } from "src/components"; +import { TableLayout } from "src/components/Table/TableLayout"; +import { intl, T } from "src/locale"; +import { CREDENTIALS, MANAGE } from "src/modules/Permissions"; + +interface Props { + data: StoredCredential[]; + isFiltered?: boolean; + isFetching?: boolean; + onEdit: (item: StoredCredential) => 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("name", { + header: intl.formatMessage({ id: "column.name" }), + cell: (info) => ( + + ), + }), + columnHelper.accessor("dnsProvider", { + header: intl.formatMessage({ id: "certificates.dns.provider" }), + }), + columnHelper.accessor("lastUsedAt", { + header: "Last used", + cell: (info) => info.getValue() || "—", + }), + columnHelper.display({ + id: "actions", + cell: (info) => ( + + + + + ), + }), + ], + [onDelete, onEdit], + ); + + const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() }); + + if (!data.length) { + return ( + + ); + } + + return ; +} diff --git a/frontend/src/pages/Credentials/TableWrapper.tsx b/frontend/src/pages/Credentials/TableWrapper.tsx new file mode 100644 index 0000000000..1a211e102b --- /dev/null +++ b/frontend/src/pages/Credentials/TableWrapper.tsx @@ -0,0 +1,114 @@ +import { IconHelp, IconSearch } from "@tabler/icons-react"; +import { useState } from "react"; +import Alert from "react-bootstrap/Alert"; +import { deleteCredential, migrateLegacyCredentials } from "src/api/backend"; +import { Button, HasPermission, LoadingPage } from "src/components"; +import { useCredentials } from "src/hooks"; +import { T } from "src/locale"; +import { showCredentialModal, showDeleteConfirmModal, showHelpModal } from "src/modals"; +import { CREDENTIALS, MANAGE } from "src/modules/Permissions"; +import { showObjectSuccess } from "src/notifications"; +import Table from "./Table"; + +export default function TableWrapper() { + const [search, setSearch] = useState(""); + const [migrateMsg, setMigrateMsg] = useState(null); + const { isFetching, isLoading, isError, error, data } = useCredentials(); + + const handleMigrate = async (dryRun: boolean) => { + setMigrateMsg(null); + try { + const result = await migrateLegacyCredentials(dryRun); + setMigrateMsg( + dryRun + ? `Dry run: ${result.count} certificate(s) eligible` + : `Migrated ${result.count} certificate(s)`, + ); + } catch (e: any) { + setMigrateMsg(e.message); + } + }; + + if (isLoading) return ; + if (isError) return {error?.message || "Unknown error"}; + + const filtered = + search && data + ? data.filter((item) => item.name.toLowerCase().includes(search) || item.dnsProvider.includes(search)) + : data; + + return ( +
+
+
+
+
+
+

+ +

+

+ +

+
+
+
+ {data?.length ? ( +
+ + + + setSearch(e.target.value.toLowerCase().trim())} + /> +
+ ) : null} + + + + + + +
+
+
+
+ {migrateMsg ? ( +
+ + {migrateMsg} + +
+ ) : null} + showCredentialModal(item)} + onDelete={(id) => + showDeleteConfirmModal({ + title: , + onConfirm: async () => { + await deleteCredential(id); + showObjectSuccess("credential", "deleted"); + }, + invalidations: [["credentials"]], + children: , + }) + } + onNew={() => showCredentialModal()} + /> + + + ); +} diff --git a/frontend/src/pages/Credentials/index.tsx b/frontend/src/pages/Credentials/index.tsx new file mode 100644 index 0000000000..12ab73e90e --- /dev/null +++ b/frontend/src/pages/Credentials/index.tsx @@ -0,0 +1,11 @@ +import { HasPermission } from "src/components"; +import { CREDENTIALS, VIEW } from "src/modules/Permissions"; +import TableWrapper from "./TableWrapper"; + +const Credentials = () => ( + + + +); + +export default Credentials; diff --git a/frontend/src/pages/Settings/ApiKeys.tsx b/frontend/src/pages/Settings/ApiKeys.tsx new file mode 100644 index 0000000000..0d1f75dc5e --- /dev/null +++ b/frontend/src/pages/Settings/ApiKeys.tsx @@ -0,0 +1,127 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import Alert from "react-bootstrap/Alert"; +import { createApiKey, deleteApiKey, getApiKeys } from "src/api/backend"; +import { Button } from "src/components"; +import { useQuery } from "@tanstack/react-query"; +import { T } from "src/locale"; +import { showDeleteConfirmModal } from "src/modals"; + +const DEFAULT_PERMS = { + proxy_hosts: "manage", + certificates: "manage", + credentials: "manage", +}; + +export default function ApiKeys() { + const queryClient = useQueryClient(); + const { data, isLoading, error } = useQuery({ queryKey: ["api-keys"], queryFn: getApiKeys }); + const [form, setForm] = useState({ name: "", expiresOn: "" }); + const [createdKey, setCreatedKey] = useState(null); + const [submitError, setSubmitError] = useState(null); + + const handleCreate = async () => { + setSubmitError(null); + setCreatedKey(null); + try { + const result = await createApiKey({ + name: form.name, + permissions: DEFAULT_PERMS, + expiresOn: form.expiresOn || null, + }); + if (result.key) setCreatedKey(result.key); + queryClient.invalidateQueries({ queryKey: ["api-keys"] }); + setForm({ name: "", expiresOn: "" }); + } catch (e: any) { + setSubmitError(e.message); + } + }; + + if (isLoading) return

Loading...

; + + return ( +
+

+ +

+

+ +

+ {error ? {error.message} : null} + {submitError ? {submitError} : null} + {createdKey ? ( + + + + +
{createdKey}
+
+ ) : null} +
+
+
+ setForm({ ...form, name: e.target.value })} + /> +
+
+ setForm({ ...form, expiresOn: e.target.value })} + /> +
+
+ +
+
+
+
+ + + + + + + + + {data?.map((k) => ( + + + + + + + ))} + +
NamePrefixExpires +
{k.name} + npmak_{k.keyPrefix}_… + {k.expiresOn || "—"} + +
+
+ ); +} diff --git a/frontend/src/pages/Settings/CredentialProviders.tsx b/frontend/src/pages/Settings/CredentialProviders.tsx new file mode 100644 index 0000000000..64bf9ae403 --- /dev/null +++ b/frontend/src/pages/Settings/CredentialProviders.tsx @@ -0,0 +1,311 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import Alert from "react-bootstrap/Alert"; +import { + createCredentialProvider, + deleteCredentialProvider, + testCredentialProvider, + testCredentialProviderResolve, + updateCredentialProvider, +} from "src/api/backend"; +import type { CredentialProvider } from "src/api/backend/getCredentialProviders"; +import { Button, LoadingPage } from "src/components"; +import { useCredentialProviders } from "src/hooks"; +import { T } from "src/locale"; +import { showDeleteConfirmModal } from "src/modals"; +import { showObjectSuccess } from "src/notifications"; + +const PROVIDER_TYPES = ["vault", "aws", "azure", "infisical", "http"] as const; + +const defaultMeta: Record = { + vault: '{"address":"https://vault.example:8200","mount":"secret","role":"npm"}', + aws: '{"region":"us-east-1","role_arn":"arn:aws:iam::123456789012:role/npm-dns"}', + azure: '{"tenant_id":"...","vault_url":"https://myvault.vault.azure.net"}', + infisical: '{"host":"https://app.infisical.com","workspace_id":"..."}', + http: '{"url_template":"https://secrets.example/api/{path}"}', +}; + +type FormState = { + name: string; + type: (typeof PROVIDER_TYPES)[number]; + oidcIssuer: string; + oidcClientId: string; + oidcClientSecret: string; + oidcAudience: string; + metaJson: string; +}; + +const emptyForm = (type: FormState["type"] = "vault"): FormState => ({ + name: "", + type, + oidcIssuer: "", + oidcClientId: "", + oidcClientSecret: "", + oidcAudience: "", + metaJson: defaultMeta[type], +}); + +export default function CredentialProviders() { + const queryClient = useQueryClient(); + const { data, isLoading, isError, error } = useCredentialProviders(); + const [form, setForm] = useState(emptyForm()); + const [editingId, setEditingId] = useState(null); + const [resolvePath, setResolvePath] = useState>({}); + const [testResult, setTestResult] = useState(null); + const [submitError, setSubmitError] = useState(null); + + if (isLoading) return ; + + const parseMeta = () => { + try { + return JSON.parse(form.metaJson); + } catch { + setSubmitError("Meta must be valid JSON"); + return null; + } + }; + + const resetForm = () => { + setEditingId(null); + setForm(emptyForm()); + setSubmitError(null); + }; + + const startEdit = (p: CredentialProvider) => { + setEditingId(p.id); + setSubmitError(null); + setTestResult(null); + setForm({ + name: p.name, + type: p.type as FormState["type"], + oidcIssuer: p.oidcIssuer || "", + oidcClientId: p.oidcClientId || "", + oidcClientSecret: "", + oidcAudience: p.oidcAudience || "", + metaJson: JSON.stringify(p.meta || {}, null, 2), + }); + }; + + const handleSave = async () => { + setSubmitError(null); + const meta = parseMeta(); + if (!meta) return; + + try { + const payload = { + name: form.name, + type: form.type, + oidcIssuer: form.oidcIssuer, + oidcClientId: form.oidcClientId, + oidcAudience: form.oidcAudience || undefined, + meta, + ...(form.oidcClientSecret ? { oidcClientSecret: form.oidcClientSecret } : {}), + }; + + if (editingId) { + await updateCredentialProvider(editingId, payload); + } else { + if (!form.oidcClientSecret) { + setSubmitError("Client secret is required for new providers"); + return; + } + await createCredentialProvider(payload); + } + showObjectSuccess("credential-provider", "saved"); + queryClient.invalidateQueries({ queryKey: ["credential-providers"] }); + resetForm(); + } catch (e: any) { + setSubmitError(e.message); + } + }; + + const handleTestOidc = async (id: number) => { + setTestResult(null); + try { + const result = await testCredentialProvider(id); + setTestResult(`OIDC OK: ${result.name} (${result.type})`); + } catch (e: any) { + setTestResult(`OIDC failed: ${e.message}`); + } + }; + + const handleTestResolve = async (id: number) => { + const path = resolvePath[id]?.trim(); + if (!path) { + setTestResult("Enter a secret path first"); + return; + } + setTestResult(null); + try { + const result = await testCredentialProviderResolve(id, path); + setTestResult(`Resolve OK: ${result.ok ? "secret found" : "no data"} (${result.bytes ?? 0} bytes)`); + } catch (e: any) { + setTestResult(`Resolve failed: ${e.message}`); + } + }; + + return ( +
+

+ +

+

+ +

+ + {isError ? {error?.message} : null} + {submitError ? {submitError} : null} + {testResult ? {testResult} : null} + +
+
+ {editingId ? ( + + ) : ( + + )} +
+
+
+
+ + setForm({ ...form, name: e.target.value })} + /> +
+
+ + +
+
+ + setForm({ ...form, oidcIssuer: e.target.value })} + /> +
+
+ + setForm({ ...form, oidcClientId: e.target.value })} + /> +
+
+ + setForm({ ...form, oidcClientSecret: e.target.value })} + /> +
+
+ +