From 19c4e8050eedbd72c981e3d426d265194a7e6c1e Mon Sep 17 00:00:00 2001 From: nd4y Date: Sun, 9 Aug 2026 07:02:31 +0300 Subject: [PATCH] feat: built-in ACME certificate management over DNS-01 The panel issues TLS certificates for node inbounds itself, renews them on schedule and injects the material into a node's config as it is rendered, so a private key only ever reaches the nodes that serve its names - config profiles are shared, and writing PEM into one hands every key to every node using it. - credentials for eight native DNS providers (Cloudflare, deSEC, DigitalOcean, Gandi, Hetzner, Porkbun, PowerDNS, Vultr), CUSTOM for any HTTP DNS broker and MANUAL for dns-persist-01; the provider registry lives in the contract, so backend validation and the UI form come from one source of truth - orders against any RFC 8555 CA: staging-first defaults, EAB, wildcards, ECDSA and RSA key types - per-node delivery with the certificate fingerprint mixed into the config hash, so a renewal restarts exactly the affected nodes and profiles keep empty certificate arrays - renewal scheduler with backoff, parallel issuance, and a per-certificate event journal that surfaces failures in the UI - import for certificates the panel did not issue, replaceable in place - secrets encrypted at rest under ACME_SECRET_KEY, deliberately separate from APP_SECRET so rotating the login secret cannot orphan stored certificates Migrations only add acme_* tables; installations that never open the page are unaffected. Operator documentation is in docs/acme.md. --- .env.sample | 6 + docs/acme.md | 171 +++++ libs/contract/api/controllers-info.ts | 5 + libs/contract/api/controllers/acme.ts | 29 + libs/contract/api/controllers/index.ts | 1 + libs/contract/api/routes.ts | 31 + .../create-acme-certificate.command.ts | 75 +++ .../delete-acme-certificate.command.ts | 29 + .../get-acme-certificate-events.command.ts | 31 + .../get-acme-certificate.command.ts | 28 + .../get-acme-certificates.command.ts | 26 + .../get-acme-persist-record.command.ts | 28 + .../import-acme-certificate.command.ts | 65 ++ .../commands/acme/certificates/index.ts | 11 + .../issue-acme-certificate.command.ts | 34 + .../publish-acme-persist-record.command.ts | 28 + .../reimport-acme-certificate.command.ts | 33 + .../update-acme-certificate.command.ts | 60 ++ .../create-acme-credential.command.ts | 56 ++ .../delete-acme-credential.command.ts | 29 + .../get-acme-credentials.command.ts | 26 + .../commands/acme/credentials/index.ts | 5 + .../test-acme-credential.command.ts | 28 + .../update-acme-credential.command.ts | 51 ++ libs/contract/commands/acme/index.ts | 2 + libs/contract/commands/index.ts | 1 + libs/contract/constants/acme/acme.ts | 364 +++++++++++ libs/contract/constants/acme/index.ts | 1 + libs/contract/constants/errors/errors.ts | 136 ++++ libs/contract/constants/index.ts | 1 + libs/contract/models/acme.schema.ts | 110 ++++ libs/contract/models/index.ts | 1 + package-lock.json | 48 ++ package.json | 1 + .../20260802205904_add_acme/migration.sql | 108 ++++ .../migration.sql | 10 + .../migration.sql | 4 + prisma/schema.prisma | 119 ++++ src/bin/cli/cli.ts | 23 + src/common/config/app-config/config.schema.ts | 15 + .../xray-config/inject-node-certificates.ts | 149 +++++ .../acme/acme-certificates.controller.ts | 228 +++++++ .../acme/acme-credentials.controller.ts | 118 ++++ src/modules/acme/acme.module.ts | 36 ++ src/modules/acme/commands/index.ts | 5 + .../acme/commands/issue-certificate/index.ts | 2 + .../issue-certificate.command.ts | 6 + .../issue-certificate.handler.ts | 51 ++ .../acme/crypto/acme-secret-box.service.ts | 100 +++ src/modules/acme/dtos/acme.dtos.ts | 148 +++++ src/modules/acme/dtos/index.ts | 1 + src/modules/acme/engine/acme-order.service.ts | 413 ++++++++++++ .../acme/engine/dns-propagation.util.ts | 78 +++ .../acme/engine/import-certificate.util.ts | 132 ++++ .../acme/engine/persist-record.util.ts | 113 ++++ .../acme/engine/solvers/cloudflare.solver.ts | 208 ++++++ .../acme/engine/solvers/custom.solver.ts | 97 +++ .../acme/engine/solvers/manual.solver.ts | 38 ++ .../engine/solvers/providers/desec.solver.ts | 67 ++ .../solvers/providers/digitalocean.solver.ts | 60 ++ .../engine/solvers/providers/gandi.solver.ts | 65 ++ .../solvers/providers/hetzner.solver.ts | 59 ++ .../solvers/providers/porkbun.solver.ts | 68 ++ .../solvers/providers/powerdns.solver.ts | 79 +++ .../engine/solvers/providers/vultr.solver.ts | 69 ++ .../acme/engine/solvers/solver.factory.ts | 58 ++ .../acme/engine/solvers/solver.interface.ts | 35 + .../acme/engine/solvers/zone-solver.base.ts | 207 ++++++ .../acme/entities/acme-account.entity.ts | 21 + .../acme/entities/acme-certificate.entity.ts | 83 +++ .../acme/entities/acme-credential.entity.ts | 24 + .../acme/entities/acme-event.entity.ts | 17 + src/modules/acme/entities/index.ts | 4 + src/modules/acme/index.ts | 4 + .../credential-payload.interface.ts | 6 + .../models/acme-certificate.response.model.ts | 93 +++ .../models/acme-credential.response.model.ts | 40 ++ .../acme/models/acme-event.response.model.ts | 29 + .../acme-persist-record.response.model.ts | 33 + src/modules/acme/models/index.ts | 4 + ...et-certificates-due-for-renewal.handler.ts | 29 + .../get-certificates-due-for-renewal.query.ts | 1 + .../get-certificates-due-for-renewal/index.ts | 2 + .../get-certificates-for-node.handler.ts | 95 +++ .../get-certificates-for-node.query.ts | 24 + .../get-certificates-for-node/index.ts | 2 + src/modules/acme/queries/index.ts | 7 + .../repositories/acme-accounts.repository.ts | 57 ++ .../acme-certificates.repository.ts | 282 ++++++++ .../acme-credentials.repository.ts | 80 +++ .../repositories/acme-events.repository.ts | 51 ++ .../services/acme-certificates.service.ts | 610 ++++++++++++++++++ .../acme/services/acme-credentials.service.ts | 269 ++++++++ src/modules/remnawave-backend.modules.ts | 4 + src/queue/_acme/acme-queue.module.ts | 33 + src/queue/_acme/acme-queue.processor.ts | 58 ++ src/queue/_acme/acme-queue.service.ts | 51 ++ .../_acme/constants/acme-job-name.constant.ts | 3 + src/queue/_acme/constants/index.ts | 1 + src/queue/_acme/index.ts | 1 + .../start-all-nodes-by-profile.processor.ts | 43 +- .../_nodes/processors/start-node.processor.ts | 23 +- src/queue/queue.enum.ts | 3 + src/queue/queue.module.ts | 2 + src/scheduler/intervals.ts | 4 + .../tasks/acme-renew/acme-renew.task.ts | 51 ++ src/scheduler/tasks/index.ts | 2 + 107 files changed, 6656 insertions(+), 10 deletions(-) create mode 100644 docs/acme.md create mode 100644 libs/contract/api/controllers/acme.ts create mode 100644 libs/contract/commands/acme/certificates/create-acme-certificate.command.ts create mode 100644 libs/contract/commands/acme/certificates/delete-acme-certificate.command.ts create mode 100644 libs/contract/commands/acme/certificates/get-acme-certificate-events.command.ts create mode 100644 libs/contract/commands/acme/certificates/get-acme-certificate.command.ts create mode 100644 libs/contract/commands/acme/certificates/get-acme-certificates.command.ts create mode 100644 libs/contract/commands/acme/certificates/get-acme-persist-record.command.ts create mode 100644 libs/contract/commands/acme/certificates/import-acme-certificate.command.ts create mode 100644 libs/contract/commands/acme/certificates/index.ts create mode 100644 libs/contract/commands/acme/certificates/issue-acme-certificate.command.ts create mode 100644 libs/contract/commands/acme/certificates/publish-acme-persist-record.command.ts create mode 100644 libs/contract/commands/acme/certificates/reimport-acme-certificate.command.ts create mode 100644 libs/contract/commands/acme/certificates/update-acme-certificate.command.ts create mode 100644 libs/contract/commands/acme/credentials/create-acme-credential.command.ts create mode 100644 libs/contract/commands/acme/credentials/delete-acme-credential.command.ts create mode 100644 libs/contract/commands/acme/credentials/get-acme-credentials.command.ts create mode 100644 libs/contract/commands/acme/credentials/index.ts create mode 100644 libs/contract/commands/acme/credentials/test-acme-credential.command.ts create mode 100644 libs/contract/commands/acme/credentials/update-acme-credential.command.ts create mode 100644 libs/contract/commands/acme/index.ts create mode 100644 libs/contract/constants/acme/acme.ts create mode 100644 libs/contract/constants/acme/index.ts create mode 100644 libs/contract/models/acme.schema.ts create mode 100644 prisma/migrations/20260802205904_add_acme/migration.sql create mode 100644 prisma/migrations/20260802223417_acme_imported_certificates/migration.sql create mode 100644 prisma/migrations/20260808180500_acme_custom_provider/migration.sql create mode 100644 src/common/helpers/xray-config/inject-node-certificates.ts create mode 100644 src/modules/acme/acme-certificates.controller.ts create mode 100644 src/modules/acme/acme-credentials.controller.ts create mode 100644 src/modules/acme/acme.module.ts create mode 100644 src/modules/acme/commands/index.ts create mode 100644 src/modules/acme/commands/issue-certificate/index.ts create mode 100644 src/modules/acme/commands/issue-certificate/issue-certificate.command.ts create mode 100644 src/modules/acme/commands/issue-certificate/issue-certificate.handler.ts create mode 100644 src/modules/acme/crypto/acme-secret-box.service.ts create mode 100644 src/modules/acme/dtos/acme.dtos.ts create mode 100644 src/modules/acme/dtos/index.ts create mode 100644 src/modules/acme/engine/acme-order.service.ts create mode 100644 src/modules/acme/engine/dns-propagation.util.ts create mode 100644 src/modules/acme/engine/import-certificate.util.ts create mode 100644 src/modules/acme/engine/persist-record.util.ts create mode 100644 src/modules/acme/engine/solvers/cloudflare.solver.ts create mode 100644 src/modules/acme/engine/solvers/custom.solver.ts create mode 100644 src/modules/acme/engine/solvers/manual.solver.ts create mode 100644 src/modules/acme/engine/solvers/providers/desec.solver.ts create mode 100644 src/modules/acme/engine/solvers/providers/digitalocean.solver.ts create mode 100644 src/modules/acme/engine/solvers/providers/gandi.solver.ts create mode 100644 src/modules/acme/engine/solvers/providers/hetzner.solver.ts create mode 100644 src/modules/acme/engine/solvers/providers/porkbun.solver.ts create mode 100644 src/modules/acme/engine/solvers/providers/powerdns.solver.ts create mode 100644 src/modules/acme/engine/solvers/providers/vultr.solver.ts create mode 100644 src/modules/acme/engine/solvers/solver.factory.ts create mode 100644 src/modules/acme/engine/solvers/solver.interface.ts create mode 100644 src/modules/acme/engine/solvers/zone-solver.base.ts create mode 100644 src/modules/acme/entities/acme-account.entity.ts create mode 100644 src/modules/acme/entities/acme-certificate.entity.ts create mode 100644 src/modules/acme/entities/acme-credential.entity.ts create mode 100644 src/modules/acme/entities/acme-event.entity.ts create mode 100644 src/modules/acme/entities/index.ts create mode 100644 src/modules/acme/index.ts create mode 100644 src/modules/acme/interfaces/credential-payload.interface.ts create mode 100644 src/modules/acme/models/acme-certificate.response.model.ts create mode 100644 src/modules/acme/models/acme-credential.response.model.ts create mode 100644 src/modules/acme/models/acme-event.response.model.ts create mode 100644 src/modules/acme/models/acme-persist-record.response.model.ts create mode 100644 src/modules/acme/models/index.ts create mode 100644 src/modules/acme/queries/get-certificates-due-for-renewal/get-certificates-due-for-renewal.handler.ts create mode 100644 src/modules/acme/queries/get-certificates-due-for-renewal/get-certificates-due-for-renewal.query.ts create mode 100644 src/modules/acme/queries/get-certificates-due-for-renewal/index.ts create mode 100644 src/modules/acme/queries/get-certificates-for-node/get-certificates-for-node.handler.ts create mode 100644 src/modules/acme/queries/get-certificates-for-node/get-certificates-for-node.query.ts create mode 100644 src/modules/acme/queries/get-certificates-for-node/index.ts create mode 100644 src/modules/acme/queries/index.ts create mode 100644 src/modules/acme/repositories/acme-accounts.repository.ts create mode 100644 src/modules/acme/repositories/acme-certificates.repository.ts create mode 100644 src/modules/acme/repositories/acme-credentials.repository.ts create mode 100644 src/modules/acme/repositories/acme-events.repository.ts create mode 100644 src/modules/acme/services/acme-certificates.service.ts create mode 100644 src/modules/acme/services/acme-credentials.service.ts create mode 100644 src/queue/_acme/acme-queue.module.ts create mode 100644 src/queue/_acme/acme-queue.processor.ts create mode 100644 src/queue/_acme/acme-queue.service.ts create mode 100644 src/queue/_acme/constants/acme-job-name.constant.ts create mode 100644 src/queue/_acme/constants/index.ts create mode 100644 src/queue/_acme/index.ts create mode 100644 src/scheduler/tasks/acme-renew/acme-renew.task.ts diff --git a/.env.sample b/.env.sample index 55ce7e1bd..f96645993 100644 --- a/.env.sample +++ b/.env.sample @@ -21,6 +21,12 @@ REDIS_SOCKET=/var/run/valkey/valkey.sock ### Secrets ### APP_SECRET=change_me +# Encrypts ACME secrets at rest: DNS credentials, ACME account keys and +# certificate private keys. 32 bytes, base64 — generate with "cli generate-acme-key". +# Without it the ACME pages stay visible but refuse to store anything. +# Changing it makes everything already stored unreadable. +# ACME_SECRET_KEY=change_me + ### TELEGRAM NOTIFICATIONS ### IS_TELEGRAM_NOTIFICATIONS_ENABLED=false TELEGRAM_BOT_TOKEN=change_me diff --git a/docs/acme.md b/docs/acme.md new file mode 100644 index 000000000..9380b7ef2 --- /dev/null +++ b/docs/acme.md @@ -0,0 +1,171 @@ +# Certificates managed by the panel + +This fork issues TLS certificates itself and delivers them to nodes, instead of +leaving that to an external agent that writes into config profiles. + +## The model + +Three entities, in the shape Nginx Proxy Manager made familiar: + +- **Credential** — how DNS challenges are answered. Reusable: many certificates + share one. +- **Certificate** — domains, a credential, a CA and renewal settings. It is + either issued by the panel or [imported](#importing-a-certificate-the-panel-did-not-issue); + imported ones need no credential at all. +- **Binding** — which nodes get the certificate, and optionally which inbound + tags on them. + +A certificate is bound to **nodes**, not to a config profile. Several nodes can +share a profile, so writing a certificate into the profile would hand its private +key to every node using it — including nodes that never serve the name. Instead +the certificate is injected into the config of each bound node as it is sent. + +## Setup + +1. Generate the key that encrypts ACME secrets at rest and put it in the panel + environment: + + ```bash + cli generate-acme-key + ``` + + ``` + ACME_SECRET_KEY=<32 bytes, base64> + ``` + + It protects DNS credentials, ACME account keys and certificate private keys. + It is separate from `APP_SECRET` on purpose: rotating the login secret should + not make stored certificates unreadable. Changing it makes everything already + stored unreadable — certificates would have to be re-issued. + + Without the key the pages still load, and every write answers with + `ACME_SECRET_KEY is not set`. + +2. Open **Management → Certificates → Credentials** and add one: + + | Provider | What the panel stores | + | --- | --- | + | `CLOUDFLARE` | API token (Zone:Read, DNS:Edit) | + | `DESEC` | API token | + | `DIGITALOCEAN` | API token | + | `GANDI` | personal access token | + | `HETZNER` | dns.hetzner.com API token | + | `PORKBUN` | API key + secret API key | + | `POWERDNS` | API URL, API key, server id | + | `VULTR` | API key | + | `CUSTOM` | URL and a client token of a DNS broker (see below) | + | `MANUAL` | nothing | + + Every DNS provider token stored here can edit records in its zones, and the + panel is an internet-facing service — that is the price of dns-01. Two ways + around it: `CUSTOM`, which moves the real credential to a broker with its own + domain policy, and `MANUAL`, which pairs with dns-persist-01 (one record + published by hand, renewals need no DNS access at all; it cannot answer + dns-01). + + The **Test** action reports whether the credential works, which zones it + sees and — for brokers — which domains it may touch. Worth doing before the + first issuance: an allow-list mismatch otherwise shows up as a failed order + weeks later. + +3. Add a certificate. It defaults to a **staging** CA: rehearse a new name there + first, then switch to production. Staging endpoints for every supported CA are + in the list. + +4. Bind it to nodes and press **Issue now**. The order runs in the background; + the status and the log in the details drawer show what happened. + +## The custom provider protocol + +A `CUSTOM` credential points at any HTTP service implementing four endpoints. +All requests carry `Authorization: Bearer ` and JSON bodies; errors come +back as `{"error": "", "message": ""}`. + +| Method and path | Body | Semantics | +| --- | --- | --- | +| `POST /v1/dns-01/present` | `{"fqdn": "_acme-challenge.a.example.com", "value": ""}` | create the TXT record; must be idempotent for the same pair | +| `POST /v1/dns-01/cleanup` | same | remove it; a record that is already gone is not an error | +| `PUT /v1/persist` | `{"fqdn": "_validation-persist.a.example.com", "value": "..."}` | upsert the dns-persist-01 record (one per name) | +| `GET /v1/policy` | — | optional; what the **Test** action shows: `{"allow": [...], "provider": {"name", "type", "zones": [...]}}` | + +The broker decides which names the token may touch and holds the real DNS +credential; the panel never sees it. A ready-made implementation is +[acme-proxy](https://github.com/nd4y/acme-proxy); its README specifies +[the same protocol from the broker side](https://github.com/nd4y/acme-proxy#the-protocol) +— response shapes, status codes and which endpoints an alternative broker +may omit. + +## Importing a certificate the panel did not issue + +Not every certificate comes from ACME: some are bought, some come from an +internal CA, some are already being renewed by something else. Such a +certificate can be uploaded and delivered to nodes like any other. + +In the UI: **Certificates → Import**. Both fields take PEM text, and **From +file** simply reads a file into the same field — pasting and uploading end up in +the same place. + +Over the API it is one JSON body, so scripts do not need multipart: + +```bash +curl -X POST https://panel.example.com/api/acme/certificates/import \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -d "$(jq -n --arg name edge-wildcard \ + --rawfile cert fullchain.pem --rawfile key privkey.pem \ + '{name: $name, fullchainPem: $cert, privateKeyPem: $key, + nodes: [{nodeUuid: "…", inboundTags: []}]}')" +``` + +What the panel does with it: + +- **reads the certificate instead of trusting the request** — domains come from + SAN, validity and key type from the certificate itself, so nothing here can be + described wrongly; +- **checks the key belongs to the certificate.** A mismatched pair is accepted by + every text field in the world and only fails later, on the node, as a handshake + error nobody connects back to this import; +- stores the key encrypted, exactly like an issued one, and restarts the bound + nodes so the material is delivered immediately. + +An expired certificate is accepted — sometimes that is what an operator is +repairing — but it is recorded as an error in the log rather than passing +silently. Password-protected keys are rejected: decrypt the key first. + +Imported certificates are **never renewed by the panel**: it has no way to renew +what it did not issue. There is no *Issue* action for them; instead +`POST /api/acme/certificates/{uuid}/import` replaces the material, which is how +such a certificate is rotated. The scheduler skips them entirely. + +## Renewals + +An hourly job queues certificates that are inside their renewal window, have +never been issued, or failed with the backoff expired. After a successful order, +every bound node is restarted so it picks the new certificate up. + +The certificate fingerprint is mixed into the config hash the node compares +against its previous one. Without that a renewal would change nothing the node +can see — the profile is identical — and the new certificate would sit in the +panel unused. + +## dns-persist-01 + +`dns-persist-01` (draft-ietf-acme-dns-persist) replaces the per-issuance TXT +record with a persistent authorization record bound to the ACME account. Once +published, issuance and renewal need no DNS access at all. + +The details drawer shows the record to publish and can publish it through the +certificate's credential. For a wildcard the record goes on the **base** name +without the asterisk, with `policy=wildcard` in the value; the asterisk in the +record name is a name the CA never asks for. + +As of 2026-08 Let's Encrypt supports it on staging only; a production order is +refused by the CA with a clear message in the certificate log. + +## Failures + +Every attempt is recorded on the certificate: `lastError`, `failCount` and +`nextRetryAt`, plus an entry in its log. Retries back off, doubling up to a day, +so a broken credential still retries daily instead of hammering the CA. + +Challenge records are removed whether the order succeeded or not. diff --git a/libs/contract/api/controllers-info.ts b/libs/contract/api/controllers-info.ts index 3f4c1382b..50576116a 100644 --- a/libs/contract/api/controllers-info.ts +++ b/libs/contract/api/controllers-info.ts @@ -1,4 +1,9 @@ export const CONTROLLERS_INFO = { + ACME: { + tag: 'ACME Controller', + description: 'Certificates issued by the panel and delivered to nodes.', + resource: 'acme', + }, USERS: { tag: 'Users Controller', description: 'Manage users, change their status, reset traffic, etc.', diff --git a/libs/contract/api/controllers/acme.ts b/libs/contract/api/controllers/acme.ts new file mode 100644 index 000000000..9a4e382c4 --- /dev/null +++ b/libs/contract/api/controllers/acme.ts @@ -0,0 +1,29 @@ +export const ACME_CONTROLLER = 'acme' as const; + +const CREDENTIALS_ROUTE = 'credentials' as const; +const CERTIFICATES_ROUTE = 'certificates' as const; + +export const ACME_ROUTES = { + CREDENTIALS: { + GET_ALL: `${CREDENTIALS_ROUTE}`, // get + CREATE: `${CREDENTIALS_ROUTE}`, // post + UPDATE: `${CREDENTIALS_ROUTE}`, // patch + DELETE: (uuid: string) => `${CREDENTIALS_ROUTE}/${uuid}`, // delete + TEST: (uuid: string) => `${CREDENTIALS_ROUTE}/${uuid}/test`, // post + }, + + CERTIFICATES: { + GET_ALL: `${CERTIFICATES_ROUTE}`, // get + GET: (uuid: string) => `${CERTIFICATES_ROUTE}/${uuid}`, // get + CREATE: `${CERTIFICATES_ROUTE}`, // post + UPDATE: `${CERTIFICATES_ROUTE}`, // patch + DELETE: (uuid: string) => `${CERTIFICATES_ROUTE}/${uuid}`, // delete + ISSUE: (uuid: string) => `${CERTIFICATES_ROUTE}/${uuid}/issue`, // post + IMPORT: `${CERTIFICATES_ROUTE}/import`, // post + REIMPORT: (uuid: string) => `${CERTIFICATES_ROUTE}/${uuid}/import`, // post + EVENTS: (uuid: string) => `${CERTIFICATES_ROUTE}/${uuid}/events`, // get + PERSIST_RECORD: (uuid: string) => `${CERTIFICATES_ROUTE}/${uuid}/persist-record`, // get + PUBLISH_PERSIST_RECORD: (uuid: string) => + `${CERTIFICATES_ROUTE}/${uuid}/persist-record/publish`, // post + }, +} as const; diff --git a/libs/contract/api/controllers/index.ts b/libs/contract/api/controllers/index.ts index 017480079..3b1f6a070 100644 --- a/libs/contract/api/controllers/index.ts +++ b/libs/contract/api/controllers/index.ts @@ -1,3 +1,4 @@ +export * from './acme'; export * from './api-tokens'; export * from './auth'; export * from './bandwidth-stats'; diff --git a/libs/contract/api/routes.ts b/libs/contract/api/routes.ts index f6d828c4b..e75c1ecb3 100644 --- a/libs/contract/api/routes.ts +++ b/libs/contract/api/routes.ts @@ -409,6 +409,37 @@ export const REST_API = { TRUNCATE_REPORTS: `${ROOT}/${CONTROLLERS.NODE_PLUGINS_CONTROLLER}/${CONTROLLERS.NODE_PLUGINS_ROUTES.TORRENT_BLOCKER.TRUNCATE_REPORTS}`, }, }, + ACME: { + CREDENTIALS: { + GET_ALL: `${ROOT}/${CONTROLLERS.ACME_CONTROLLER}/${CONTROLLERS.ACME_ROUTES.CREDENTIALS.GET_ALL}`, + CREATE: `${ROOT}/${CONTROLLERS.ACME_CONTROLLER}/${CONTROLLERS.ACME_ROUTES.CREDENTIALS.CREATE}`, + UPDATE: `${ROOT}/${CONTROLLERS.ACME_CONTROLLER}/${CONTROLLERS.ACME_ROUTES.CREDENTIALS.UPDATE}`, + DELETE: (uuid: string) => + `${ROOT}/${CONTROLLERS.ACME_CONTROLLER}/${CONTROLLERS.ACME_ROUTES.CREDENTIALS.DELETE(uuid)}`, + TEST: (uuid: string) => + `${ROOT}/${CONTROLLERS.ACME_CONTROLLER}/${CONTROLLERS.ACME_ROUTES.CREDENTIALS.TEST(uuid)}`, + }, + CERTIFICATES: { + GET_ALL: `${ROOT}/${CONTROLLERS.ACME_CONTROLLER}/${CONTROLLERS.ACME_ROUTES.CERTIFICATES.GET_ALL}`, + GET: (uuid: string) => + `${ROOT}/${CONTROLLERS.ACME_CONTROLLER}/${CONTROLLERS.ACME_ROUTES.CERTIFICATES.GET(uuid)}`, + CREATE: `${ROOT}/${CONTROLLERS.ACME_CONTROLLER}/${CONTROLLERS.ACME_ROUTES.CERTIFICATES.CREATE}`, + UPDATE: `${ROOT}/${CONTROLLERS.ACME_CONTROLLER}/${CONTROLLERS.ACME_ROUTES.CERTIFICATES.UPDATE}`, + DELETE: (uuid: string) => + `${ROOT}/${CONTROLLERS.ACME_CONTROLLER}/${CONTROLLERS.ACME_ROUTES.CERTIFICATES.DELETE(uuid)}`, + ISSUE: (uuid: string) => + `${ROOT}/${CONTROLLERS.ACME_CONTROLLER}/${CONTROLLERS.ACME_ROUTES.CERTIFICATES.ISSUE(uuid)}`, + IMPORT: `${ROOT}/${CONTROLLERS.ACME_CONTROLLER}/${CONTROLLERS.ACME_ROUTES.CERTIFICATES.IMPORT}`, + REIMPORT: (uuid: string) => + `${ROOT}/${CONTROLLERS.ACME_CONTROLLER}/${CONTROLLERS.ACME_ROUTES.CERTIFICATES.REIMPORT(uuid)}`, + EVENTS: (uuid: string) => + `${ROOT}/${CONTROLLERS.ACME_CONTROLLER}/${CONTROLLERS.ACME_ROUTES.CERTIFICATES.EVENTS(uuid)}`, + PERSIST_RECORD: (uuid: string) => + `${ROOT}/${CONTROLLERS.ACME_CONTROLLER}/${CONTROLLERS.ACME_ROUTES.CERTIFICATES.PERSIST_RECORD(uuid)}`, + PUBLISH_PERSIST_RECORD: (uuid: string) => + `${ROOT}/${CONTROLLERS.ACME_CONTROLLER}/${CONTROLLERS.ACME_ROUTES.CERTIFICATES.PUBLISH_PERSIST_RECORD(uuid)}`, + }, + }, BANDWIDTH_STATS: { NODES: { GET: `${ROOT}/${CONTROLLERS.BANDWIDTH_STATS_CONTROLLER}/${CONTROLLERS.BANDWIDTH_STATS_NODES_ROUTE}/${CONTROLLERS.BANDWIDTH_STATS_ROUTES.NODES.GET}`, diff --git a/libs/contract/commands/acme/certificates/create-acme-certificate.command.ts b/libs/contract/commands/acme/certificates/create-acme-certificate.command.ts new file mode 100644 index 000000000..0c6af3ff6 --- /dev/null +++ b/libs/contract/commands/acme/certificates/create-acme-certificate.command.ts @@ -0,0 +1,75 @@ +import { z } from 'zod'; + +import { ACME_ROUTES, REST_API } from '../../../api'; +import { + ACME_CHALLENGE_TYPE, + ACME_CHALLENGE_TYPES, + ACME_DIRECTORY, + ACME_KEY_TYPE, + ACME_KEY_TYPES, + getEndpointDetails, +} from '../../../constants'; +import { AcmeCertificateSchema, AcmeDomainSchema } from '../../../models'; + +/** Which nodes and inbounds a certificate is delivered to. */ +export const AcmeCertificateNodeBindingSchema = z.object({ + nodeUuid: z.uuid(), + /** + * Empty means every TLS inbound the node runs. Naming tags is how one node + * ends up with different certificates on different inbounds. + */ + inboundTags: z.array(z.string()).default([]), +}); + +export namespace CreateAcmeCertificateCommand { + export const url = REST_API.ACME.CERTIFICATES.CREATE; + export const TSQ_url = url; + + export const endpointDetails = getEndpointDetails( + ACME_ROUTES.CERTIFICATES.CREATE, + 'post', + 'Create ACME certificate', + { scope: 'create-certificate', kind: 'write' }, + ); + + export const RequestBodySchema = z.object({ + name: z + .string() + .min(2, 'Name must be at least 2 characters') + .max(40, 'Name must be less than 40 characters') + .regex( + /^[A-Za-z0-9_\s-]+$/, + 'Name can only contain letters, numbers, underscores, dashes and spaces', + ), + + domains: z.array(AcmeDomainSchema).min(1).max(100), + + challengeType: z.optional(z.enum(ACME_CHALLENGE_TYPES)).default(ACME_CHALLENGE_TYPE.DNS_01), + keyType: z.optional(z.enum(ACME_KEY_TYPES)).default(ACME_KEY_TYPE.ECDSA_P256), + + /** + * Renewal window. Kept away from zero so a broken solver has several + * attempts before the certificate actually expires. + */ + renewBeforeDays: z.optional(z.number().int().min(1).max(85)).default(30), + isEnabled: z.optional(z.boolean()).default(true), + + /** Defaults to staging: the first issuance of a new name should not spend production rate limit. */ + directoryUrl: z.optional(z.url()).default(ACME_DIRECTORY.LETSENCRYPT_STAGING), + email: z.email(), + + eabKid: z.optional(z.string().min(1)), + eabHmacKey: z.optional(z.string().min(1)), + + credentialUuid: z.uuid(), + + nodes: z.optional(z.array(AcmeCertificateNodeBindingSchema)).default([]), + }); + + export const ResponseSchema = z.object({ + response: AcmeCertificateSchema, + }); + + export type RequestBody = z.infer; + export type Response = z.infer; +} diff --git a/libs/contract/commands/acme/certificates/delete-acme-certificate.command.ts b/libs/contract/commands/acme/certificates/delete-acme-certificate.command.ts new file mode 100644 index 000000000..8ed38f895 --- /dev/null +++ b/libs/contract/commands/acme/certificates/delete-acme-certificate.command.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; + +import { ACME_ROUTES, REST_API } from '../../../api'; +import { getEndpointDetails } from '../../../constants'; + +export namespace DeleteAcmeCertificateCommand { + export const url = REST_API.ACME.CERTIFICATES.DELETE; + export const TSQ_url = url(':uuid'); + + export const endpointDetails = getEndpointDetails( + ACME_ROUTES.CERTIFICATES.DELETE(':uuid'), + 'delete', + 'Delete ACME certificate', + { scope: 'delete-certificate', kind: 'write' }, + ); + + export const RequestParamSchema = z.object({ + uuid: z.uuid(), + }); + + export const ResponseSchema = z.object({ + response: z.object({ + isDeleted: z.boolean(), + }), + }); + + export type RequestParam = z.infer; + export type Response = z.infer; +} diff --git a/libs/contract/commands/acme/certificates/get-acme-certificate-events.command.ts b/libs/contract/commands/acme/certificates/get-acme-certificate-events.command.ts new file mode 100644 index 000000000..46b6cf203 --- /dev/null +++ b/libs/contract/commands/acme/certificates/get-acme-certificate-events.command.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; + +import { ACME_ROUTES, REST_API } from '../../../api'; +import { getEndpointDetails } from '../../../constants'; +import { AcmeEventSchema } from '../../../models'; + +export namespace GetAcmeCertificateEventsCommand { + export const url = REST_API.ACME.CERTIFICATES.EVENTS; + export const TSQ_url = url(':uuid'); + + export const endpointDetails = getEndpointDetails( + ACME_ROUTES.CERTIFICATES.EVENTS(':uuid'), + 'get', + 'Get the issuance log of a certificate', + { scope: 'get-certificate-events', kind: 'read' }, + ); + + export const RequestParamSchema = z.object({ + uuid: z.uuid(), + }); + + export const ResponseSchema = z.object({ + response: z.object({ + total: z.number(), + events: z.array(AcmeEventSchema), + }), + }); + + export type RequestParam = z.infer; + export type Response = z.infer; +} diff --git a/libs/contract/commands/acme/certificates/get-acme-certificate.command.ts b/libs/contract/commands/acme/certificates/get-acme-certificate.command.ts new file mode 100644 index 000000000..46c065950 --- /dev/null +++ b/libs/contract/commands/acme/certificates/get-acme-certificate.command.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +import { ACME_ROUTES, REST_API } from '../../../api'; +import { getEndpointDetails } from '../../../constants'; +import { AcmeCertificateSchema } from '../../../models'; + +export namespace GetAcmeCertificateCommand { + export const url = REST_API.ACME.CERTIFICATES.GET; + export const TSQ_url = url(':uuid'); + + export const endpointDetails = getEndpointDetails( + ACME_ROUTES.CERTIFICATES.GET(':uuid'), + 'get', + 'Get ACME certificate by uuid', + { scope: 'get-certificate', kind: 'read' }, + ); + + export const RequestParamSchema = z.object({ + uuid: z.uuid(), + }); + + export const ResponseSchema = z.object({ + response: AcmeCertificateSchema, + }); + + export type RequestParam = z.infer; + export type Response = z.infer; +} diff --git a/libs/contract/commands/acme/certificates/get-acme-certificates.command.ts b/libs/contract/commands/acme/certificates/get-acme-certificates.command.ts new file mode 100644 index 000000000..3a90e2016 --- /dev/null +++ b/libs/contract/commands/acme/certificates/get-acme-certificates.command.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +import { ACME_ROUTES, REST_API } from '../../../api'; +import { getEndpointDetails } from '../../../constants'; +import { AcmeCertificateSchema } from '../../../models'; + +export namespace GetAcmeCertificatesCommand { + export const url = REST_API.ACME.CERTIFICATES.GET_ALL; + export const TSQ_url = url; + + export const endpointDetails = getEndpointDetails( + ACME_ROUTES.CERTIFICATES.GET_ALL, + 'get', + 'Get all ACME certificates', + { scope: 'list-certificates', kind: 'read' }, + ); + + export const ResponseSchema = z.object({ + response: z.object({ + total: z.number(), + certificates: z.array(AcmeCertificateSchema), + }), + }); + + export type Response = z.infer; +} diff --git a/libs/contract/commands/acme/certificates/get-acme-persist-record.command.ts b/libs/contract/commands/acme/certificates/get-acme-persist-record.command.ts new file mode 100644 index 000000000..4b70a3bac --- /dev/null +++ b/libs/contract/commands/acme/certificates/get-acme-persist-record.command.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +import { ACME_ROUTES, REST_API } from '../../../api'; +import { getEndpointDetails } from '../../../constants'; +import { AcmePersistRecordSchema } from '../../../models'; + +export namespace GetAcmePersistRecordCommand { + export const url = REST_API.ACME.CERTIFICATES.PERSIST_RECORD; + export const TSQ_url = url(':uuid'); + + export const endpointDetails = getEndpointDetails( + ACME_ROUTES.CERTIFICATES.PERSIST_RECORD(':uuid'), + 'get', + 'Get the persistent authorization record for a dns-persist-01 certificate', + { scope: 'get-persist-record', kind: 'read' }, + ); + + export const RequestParamSchema = z.object({ + uuid: z.uuid(), + }); + + export const ResponseSchema = z.object({ + response: AcmePersistRecordSchema, + }); + + export type RequestParam = z.infer; + export type Response = z.infer; +} diff --git a/libs/contract/commands/acme/certificates/import-acme-certificate.command.ts b/libs/contract/commands/acme/certificates/import-acme-certificate.command.ts new file mode 100644 index 000000000..48afe786d --- /dev/null +++ b/libs/contract/commands/acme/certificates/import-acme-certificate.command.ts @@ -0,0 +1,65 @@ +import { z } from 'zod'; + +import { ACME_ROUTES, REST_API } from '../../../api'; +import { getEndpointDetails } from '../../../constants'; +import { AcmeCertificateSchema } from '../../../models'; +import { AcmeCertificateNodeBindingSchema } from './create-acme-certificate.command'; + +/** + * PEM material as it arrives from the caller. + * + * Both fields are plain text on purpose: a file upload in the UI is the file's + * contents put into the same field, so the API stays a single JSON shape whether + * the operator pasted the certificate or picked a file. + */ +export const AcmePemMaterialSchema = z.object({ + /** + * The certificate, optionally followed by its chain. Extra certificates are + * kept as they are: Xray serves the chain exactly as given. + */ + fullchainPem: z + .string() + .min(1) + .refine( + (value) => value.includes('-----BEGIN CERTIFICATE-----'), + 'Expected a PEM certificate', + ), + /** The matching private key. It is checked against the certificate before anything is stored. */ + privateKeyPem: z + .string() + .min(1) + .refine((value) => value.includes('-----BEGIN'), 'Expected a PEM private key'), +}); + +export namespace ImportAcmeCertificateCommand { + export const url = REST_API.ACME.CERTIFICATES.IMPORT; + export const TSQ_url = url; + + export const endpointDetails = getEndpointDetails( + ACME_ROUTES.CERTIFICATES.IMPORT, + 'post', + 'Import an existing certificate', + { scope: 'import-certificate', kind: 'write' }, + 'Stores a certificate the panel did not issue and delivers it to nodes. Domains, validity and key type are read from the certificate itself.', + ); + + export const RequestBodySchema = AcmePemMaterialSchema.extend({ + isEnabled: z.optional(z.boolean()).default(true), + name: z + .string() + .min(2, 'Name must be at least 2 characters') + .max(40, 'Name must be less than 40 characters') + .regex( + /^[A-Za-z0-9_\s-]+$/, + 'Name can only contain letters, numbers, underscores, dashes and spaces', + ), + nodes: z.optional(z.array(AcmeCertificateNodeBindingSchema)).default([]), + }); + + export const ResponseSchema = z.object({ + response: AcmeCertificateSchema, + }); + + export type RequestBody = z.infer; + export type Response = z.infer; +} diff --git a/libs/contract/commands/acme/certificates/index.ts b/libs/contract/commands/acme/certificates/index.ts new file mode 100644 index 000000000..3217d7184 --- /dev/null +++ b/libs/contract/commands/acme/certificates/index.ts @@ -0,0 +1,11 @@ +export * from './create-acme-certificate.command'; +export * from './delete-acme-certificate.command'; +export * from './get-acme-certificate-events.command'; +export * from './get-acme-certificate.command'; +export * from './get-acme-certificates.command'; +export * from './get-acme-persist-record.command'; +export * from './import-acme-certificate.command'; +export * from './issue-acme-certificate.command'; +export * from './publish-acme-persist-record.command'; +export * from './reimport-acme-certificate.command'; +export * from './update-acme-certificate.command'; diff --git a/libs/contract/commands/acme/certificates/issue-acme-certificate.command.ts b/libs/contract/commands/acme/certificates/issue-acme-certificate.command.ts new file mode 100644 index 000000000..8839662de --- /dev/null +++ b/libs/contract/commands/acme/certificates/issue-acme-certificate.command.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; + +import { ACME_ROUTES, REST_API } from '../../../api'; +import { getEndpointDetails } from '../../../constants'; + +export namespace IssueAcmeCertificateCommand { + export const url = REST_API.ACME.CERTIFICATES.ISSUE; + export const TSQ_url = url(':uuid'); + + export const endpointDetails = getEndpointDetails( + ACME_ROUTES.CERTIFICATES.ISSUE(':uuid'), + 'post', + 'Issue or renew the certificate now', + { scope: 'issue-certificate', kind: 'write' }, + ); + + export const RequestParamSchema = z.object({ + uuid: z.uuid(), + }); + + /** + * Issuance is queued rather than awaited: an order takes tens of seconds and + * the caller should not hold an HTTP request open for it. Progress shows up + * in the certificate status and in its events. + */ + export const ResponseSchema = z.object({ + response: z.object({ + isQueued: z.boolean(), + }), + }); + + export type RequestParam = z.infer; + export type Response = z.infer; +} diff --git a/libs/contract/commands/acme/certificates/publish-acme-persist-record.command.ts b/libs/contract/commands/acme/certificates/publish-acme-persist-record.command.ts new file mode 100644 index 000000000..f5a9329d6 --- /dev/null +++ b/libs/contract/commands/acme/certificates/publish-acme-persist-record.command.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +import { ACME_ROUTES, REST_API } from '../../../api'; +import { getEndpointDetails } from '../../../constants'; +import { AcmePersistRecordSchema } from '../../../models'; + +export namespace PublishAcmePersistRecordCommand { + export const url = REST_API.ACME.CERTIFICATES.PUBLISH_PERSIST_RECORD; + export const TSQ_url = url(':uuid'); + + export const endpointDetails = getEndpointDetails( + ACME_ROUTES.CERTIFICATES.PUBLISH_PERSIST_RECORD(':uuid'), + 'post', + 'Publish the persistent authorization record using the certificate credential', + { scope: 'publish-persist-record', kind: 'write' }, + ); + + export const RequestParamSchema = z.object({ + uuid: z.uuid(), + }); + + export const ResponseSchema = z.object({ + response: AcmePersistRecordSchema, + }); + + export type RequestParam = z.infer; + export type Response = z.infer; +} diff --git a/libs/contract/commands/acme/certificates/reimport-acme-certificate.command.ts b/libs/contract/commands/acme/certificates/reimport-acme-certificate.command.ts new file mode 100644 index 000000000..f8325bb85 --- /dev/null +++ b/libs/contract/commands/acme/certificates/reimport-acme-certificate.command.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; + +import { ACME_ROUTES, REST_API } from '../../../api'; +import { getEndpointDetails } from '../../../constants'; +import { AcmeCertificateSchema } from '../../../models'; +import { AcmePemMaterialSchema } from './import-acme-certificate.command'; + +export namespace ReimportAcmeCertificateCommand { + export const url = REST_API.ACME.CERTIFICATES.REIMPORT; + export const TSQ_url = url(':uuid'); + + export const endpointDetails = getEndpointDetails( + ACME_ROUTES.CERTIFICATES.REIMPORT(':uuid'), + 'post', + 'Replace the material of an imported certificate', + { scope: 'replace-certificate-material', kind: 'write' }, + 'This is how an imported certificate is renewed: whoever issued it renews it, and the new PEM replaces the old one. Bound nodes are restarted.', + ); + + export const RequestParamSchema = z.object({ + uuid: z.uuid(), + }); + + export const RequestBodySchema = AcmePemMaterialSchema; + + export const ResponseSchema = z.object({ + response: AcmeCertificateSchema, + }); + + export type RequestBody = z.infer; + export type RequestParam = z.infer; + export type Response = z.infer; +} diff --git a/libs/contract/commands/acme/certificates/update-acme-certificate.command.ts b/libs/contract/commands/acme/certificates/update-acme-certificate.command.ts new file mode 100644 index 000000000..7a591b08f --- /dev/null +++ b/libs/contract/commands/acme/certificates/update-acme-certificate.command.ts @@ -0,0 +1,60 @@ +import { z } from 'zod'; + +import { ACME_ROUTES, REST_API } from '../../../api'; +import { ACME_CHALLENGE_TYPES, ACME_KEY_TYPES, getEndpointDetails } from '../../../constants'; +import { AcmeCertificateSchema, AcmeDomainSchema } from '../../../models'; +import { AcmeCertificateNodeBindingSchema } from './create-acme-certificate.command'; + +export namespace UpdateAcmeCertificateCommand { + export const url = REST_API.ACME.CERTIFICATES.UPDATE; + export const TSQ_url = url; + + export const endpointDetails = getEndpointDetails( + ACME_ROUTES.CERTIFICATES.UPDATE, + 'patch', + 'Update ACME certificate', + { scope: 'update-certificate', kind: 'write' }, + ); + + /** + * Changing domains, key type or the CA invalidates what is stored: the next + * run re-issues from scratch. Changing only the bindings does not — the + * stored certificate is simply delivered to a different set of nodes. + */ + export const RequestBodySchema = z.object({ + uuid: z.uuid(), + + name: z.optional( + z + .string() + .min(2, 'Name must be at least 2 characters') + .max(40, 'Name must be less than 40 characters') + .regex( + /^[A-Za-z0-9_\s-]+$/, + 'Name can only contain letters, numbers, underscores, dashes and spaces', + ), + ), + + domains: z.optional(z.array(AcmeDomainSchema).min(1).max(100)), + challengeType: z.optional(z.enum(ACME_CHALLENGE_TYPES)), + keyType: z.optional(z.enum(ACME_KEY_TYPES)), + renewBeforeDays: z.optional(z.number().int().min(1).max(85)), + isEnabled: z.optional(z.boolean()), + + directoryUrl: z.optional(z.url()), + email: z.optional(z.email()), + eabKid: z.optional(z.string().min(1)), + eabHmacKey: z.optional(z.string().min(1)), + + credentialUuid: z.optional(z.uuid()), + + nodes: z.optional(z.array(AcmeCertificateNodeBindingSchema)), + }); + + export const ResponseSchema = z.object({ + response: AcmeCertificateSchema, + }); + + export type RequestBody = z.infer; + export type Response = z.infer; +} diff --git a/libs/contract/commands/acme/credentials/create-acme-credential.command.ts b/libs/contract/commands/acme/credentials/create-acme-credential.command.ts new file mode 100644 index 000000000..aa523588b --- /dev/null +++ b/libs/contract/commands/acme/credentials/create-acme-credential.command.ts @@ -0,0 +1,56 @@ +import { z } from 'zod'; + +import { ACME_ROUTES, REST_API } from '../../../api'; +import { ACME_PROVIDER_REGISTRY, ACME_PROVIDERS, getEndpointDetails } from '../../../constants'; +import { AcmeCredentialSchema } from '../../../models'; + +export namespace CreateAcmeCredentialCommand { + export const url = REST_API.ACME.CREDENTIALS.CREATE; + export const TSQ_url = url; + + export const endpointDetails = getEndpointDetails( + ACME_ROUTES.CREDENTIALS.CREATE, + 'post', + 'Create ACME credential', + { scope: 'create-credential', kind: 'write' }, + ); + + export const RequestBodySchema = z + .object({ + name: z + .string() + .min(2, 'Name must be at least 2 characters') + .max(40, 'Name must be less than 40 characters') + .regex( + /^[A-Za-z0-9_\s-]+$/, + 'Name can only contain letters, numbers, underscores, dashes and spaces', + ), + provider: z.enum(ACME_PROVIDERS), + + /** + * Provider configuration keyed by the field keys from + * ACME_PROVIDER_REGISTRY. Secret fields are write-only. + */ + config: z.optional(z.record(z.string(), z.string())), + }) + .superRefine((data, ctx) => { + const info = ACME_PROVIDER_REGISTRY.find((entry) => entry.provider === data.provider); + + for (const field of info?.fields ?? []) { + if (field.required && !data.config?.[field.key]) { + ctx.addIssue({ + code: 'custom', + path: ['config', field.key], + message: `${field.key} is required for ${data.provider} credentials`, + }); + } + } + }); + + export const ResponseSchema = z.object({ + response: AcmeCredentialSchema, + }); + + export type RequestBody = z.infer; + export type Response = z.infer; +} diff --git a/libs/contract/commands/acme/credentials/delete-acme-credential.command.ts b/libs/contract/commands/acme/credentials/delete-acme-credential.command.ts new file mode 100644 index 000000000..5f406cf4e --- /dev/null +++ b/libs/contract/commands/acme/credentials/delete-acme-credential.command.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; + +import { ACME_ROUTES, REST_API } from '../../../api'; +import { getEndpointDetails } from '../../../constants'; + +export namespace DeleteAcmeCredentialCommand { + export const url = REST_API.ACME.CREDENTIALS.DELETE; + export const TSQ_url = url(':uuid'); + + export const endpointDetails = getEndpointDetails( + ACME_ROUTES.CREDENTIALS.DELETE(':uuid'), + 'delete', + 'Delete ACME credential', + { scope: 'delete-credential', kind: 'write' }, + ); + + export const RequestParamSchema = z.object({ + uuid: z.uuid(), + }); + + export const ResponseSchema = z.object({ + response: z.object({ + isDeleted: z.boolean(), + }), + }); + + export type RequestParam = z.infer; + export type Response = z.infer; +} diff --git a/libs/contract/commands/acme/credentials/get-acme-credentials.command.ts b/libs/contract/commands/acme/credentials/get-acme-credentials.command.ts new file mode 100644 index 000000000..dcbcac109 --- /dev/null +++ b/libs/contract/commands/acme/credentials/get-acme-credentials.command.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +import { ACME_ROUTES, REST_API } from '../../../api'; +import { getEndpointDetails } from '../../../constants'; +import { AcmeCredentialSchema } from '../../../models'; + +export namespace GetAcmeCredentialsCommand { + export const url = REST_API.ACME.CREDENTIALS.GET_ALL; + export const TSQ_url = url; + + export const endpointDetails = getEndpointDetails( + ACME_ROUTES.CREDENTIALS.GET_ALL, + 'get', + 'Get all ACME credentials', + { scope: 'list-credentials', kind: 'read' }, + ); + + export const ResponseSchema = z.object({ + response: z.object({ + total: z.number(), + credentials: z.array(AcmeCredentialSchema), + }), + }); + + export type Response = z.infer; +} diff --git a/libs/contract/commands/acme/credentials/index.ts b/libs/contract/commands/acme/credentials/index.ts new file mode 100644 index 000000000..d09a53654 --- /dev/null +++ b/libs/contract/commands/acme/credentials/index.ts @@ -0,0 +1,5 @@ +export * from './create-acme-credential.command'; +export * from './delete-acme-credential.command'; +export * from './get-acme-credentials.command'; +export * from './test-acme-credential.command'; +export * from './update-acme-credential.command'; diff --git a/libs/contract/commands/acme/credentials/test-acme-credential.command.ts b/libs/contract/commands/acme/credentials/test-acme-credential.command.ts new file mode 100644 index 000000000..b3f1c8a05 --- /dev/null +++ b/libs/contract/commands/acme/credentials/test-acme-credential.command.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +import { ACME_ROUTES, REST_API } from '../../../api'; +import { getEndpointDetails } from '../../../constants'; +import { AcmeCredentialTestSchema } from '../../../models'; + +export namespace TestAcmeCredentialCommand { + export const url = REST_API.ACME.CREDENTIALS.TEST; + export const TSQ_url = url(':uuid'); + + export const endpointDetails = getEndpointDetails( + ACME_ROUTES.CREDENTIALS.TEST(':uuid'), + 'post', + 'Check that the credential works and report what it may do', + { scope: 'test-credential', kind: 'write' }, + ); + + export const RequestParamSchema = z.object({ + uuid: z.uuid(), + }); + + export const ResponseSchema = z.object({ + response: AcmeCredentialTestSchema, + }); + + export type RequestParam = z.infer; + export type Response = z.infer; +} diff --git a/libs/contract/commands/acme/credentials/update-acme-credential.command.ts b/libs/contract/commands/acme/credentials/update-acme-credential.command.ts new file mode 100644 index 000000000..3bc9388a1 --- /dev/null +++ b/libs/contract/commands/acme/credentials/update-acme-credential.command.ts @@ -0,0 +1,51 @@ +import { z } from 'zod'; + +import { ACME_ROUTES, REST_API } from '../../../api'; +import { getEndpointDetails } from '../../../constants'; +import { AcmeCredentialSchema } from '../../../models'; + +export namespace UpdateAcmeCredentialCommand { + export const url = REST_API.ACME.CREDENTIALS.UPDATE; + export const TSQ_url = url; + + export const endpointDetails = getEndpointDetails( + ACME_ROUTES.CREDENTIALS.UPDATE, + 'patch', + 'Update ACME credential', + { scope: 'update-credential', kind: 'write' }, + ); + + /** + * Secrets are write-only: omitting them keeps whatever is stored. The + * provider itself cannot be changed — the stored payload belongs to it; make + * a new credential instead. + */ + export const RequestBodySchema = z.object({ + uuid: z.uuid(), + + name: z.optional( + z + .string() + .min(2, 'Name must be at least 2 characters') + .max(40, 'Name must be less than 40 characters') + .regex( + /^[A-Za-z0-9_\s-]+$/, + 'Name can only contain letters, numbers, underscores, dashes and spaces', + ), + ), + + /** + * Provider configuration keyed by ACME_PROVIDER_REGISTRY field keys. + * Only the keys present are touched; an empty string keeps the stored + * value (that is what an untouched secret input submits as). + */ + config: z.optional(z.record(z.string(), z.string())), + }); + + export const ResponseSchema = z.object({ + response: AcmeCredentialSchema, + }); + + export type RequestBody = z.infer; + export type Response = z.infer; +} diff --git a/libs/contract/commands/acme/index.ts b/libs/contract/commands/acme/index.ts new file mode 100644 index 000000000..2b82db4e0 --- /dev/null +++ b/libs/contract/commands/acme/index.ts @@ -0,0 +1,2 @@ +export * from './certificates'; +export * from './credentials'; diff --git a/libs/contract/commands/index.ts b/libs/contract/commands/index.ts index 017480079..3b1f6a070 100644 --- a/libs/contract/commands/index.ts +++ b/libs/contract/commands/index.ts @@ -1,3 +1,4 @@ +export * from './acme'; export * from './api-tokens'; export * from './auth'; export * from './bandwidth-stats'; diff --git a/libs/contract/constants/acme/acme.ts b/libs/contract/constants/acme/acme.ts new file mode 100644 index 000000000..25908334f --- /dev/null +++ b/libs/contract/constants/acme/acme.ts @@ -0,0 +1,364 @@ +export const ACME_PROVIDER = { + CLOUDFLARE: 'CLOUDFLARE', + /** + * A generic HTTP DNS API (see docs/acme.md for the protocol). Lets the + * DNS credential live outside the panel: the panel holds only the broker's + * URL and a client token scoped by the broker's own policy. + */ + CUSTOM: 'CUSTOM', + DESEC: 'DESEC', + DIGITALOCEAN: 'DIGITALOCEAN', + GANDI: 'GANDI', + HETZNER: 'HETZNER', + /** + * No automation: the panel shows the record and waits for it to be published. + */ + MANUAL: 'MANUAL', + PORKBUN: 'PORKBUN', + POWERDNS: 'POWERDNS', + VULTR: 'VULTR', +} as const; + +export type TAcmeProvider = (typeof ACME_PROVIDER)[keyof typeof ACME_PROVIDER]; + +export const ACME_PROVIDERS = Object.values(ACME_PROVIDER) as [TAcmeProvider, ...TAcmeProvider[]]; + +export interface IAcmeProviderField { + key: string; + label: string; + /** Write-only: stored encrypted, never returned by the API. */ + secret: boolean; + required: boolean; + placeholder?: string; + description?: string; +} + +export interface IAcmeProviderInfo { + provider: TAcmeProvider; + label: string; + description?: string; + fields: IAcmeProviderField[]; +} + +/** + * Single source of truth for what each provider needs. The backend validates + * credential payloads against it; the UI renders the credential form from it. + */ +export const ACME_PROVIDER_REGISTRY: IAcmeProviderInfo[] = [ + { + provider: ACME_PROVIDER.CLOUDFLARE, + label: 'Cloudflare', + fields: [ + { + key: 'apiToken', + label: 'API token', + secret: true, + required: true, + placeholder: 'Cloudflare API token', + description: 'Needs Zone:Read and DNS:Edit', + }, + ], + }, + { + provider: ACME_PROVIDER.DESEC, + label: 'deSEC', + fields: [ + { + key: 'apiToken', + label: 'API token', + secret: true, + required: true, + placeholder: 'deSEC token', + }, + ], + }, + { + provider: ACME_PROVIDER.DIGITALOCEAN, + label: 'DigitalOcean', + fields: [ + { + key: 'apiToken', + label: 'API token', + secret: true, + required: true, + placeholder: 'DigitalOcean personal access token', + description: 'Needs domain read and write', + }, + ], + }, + { + provider: ACME_PROVIDER.GANDI, + label: 'Gandi LiveDNS', + fields: [ + { + key: 'apiToken', + label: 'Personal access token', + secret: true, + required: true, + placeholder: 'Gandi PAT', + description: 'Needs "Manage domain name technical configurations"', + }, + ], + }, + { + provider: ACME_PROVIDER.HETZNER, + label: 'Hetzner DNS', + fields: [ + { + key: 'apiToken', + label: 'API token', + secret: true, + required: true, + placeholder: 'dns.hetzner.com API token', + }, + ], + }, + { + provider: ACME_PROVIDER.PORKBUN, + label: 'Porkbun', + fields: [ + { + key: 'apiKey', + label: 'API key', + secret: true, + required: true, + placeholder: 'pk1_…', + }, + { + key: 'secretApiKey', + label: 'Secret API key', + secret: true, + required: true, + placeholder: 'sk1_…', + }, + ], + }, + { + provider: ACME_PROVIDER.POWERDNS, + label: 'PowerDNS', + fields: [ + { + key: 'baseUrl', + label: 'API URL', + secret: false, + required: true, + placeholder: 'http://powerdns:8081', + }, + { + key: 'apiKey', + label: 'API key', + secret: true, + required: true, + }, + { + key: 'serverId', + label: 'Server ID', + secret: false, + required: false, + placeholder: 'localhost', + description: 'Leave empty for the default server', + }, + ], + }, + { + provider: ACME_PROVIDER.VULTR, + label: 'Vultr', + fields: [ + { + key: 'apiToken', + label: 'API key', + secret: true, + required: true, + placeholder: 'Vultr API key', + }, + ], + }, + { + provider: ACME_PROVIDER.CUSTOM, + label: 'Custom (HTTP API)', + description: + 'A DNS broker speaking the simple HTTP protocol from the documentation. Keeps the real DNS credential outside the panel.', + fields: [ + { + key: 'baseUrl', + label: 'URL', + secret: false, + required: true, + placeholder: 'http://dns-broker:8080', + }, + { + key: 'token', + label: 'Token', + secret: true, + required: true, + placeholder: 'Client token', + }, + ], + }, + { + provider: ACME_PROVIDER.MANUAL, + label: 'Manual', + description: + 'Nothing is published automatically. Pairs with dns-persist-01, where one record is added by hand; it cannot answer dns-01.', + fields: [], + }, +]; + +export const ACME_CERTIFICATE_SOURCE = { + /** Ordered and renewed by the panel. */ + ACME: 'ACME', + /** + * Uploaded material. The panel stores and delivers it, but never renews it: + * whoever issued it also renews it, and the new PEM is imported again. + */ + IMPORTED: 'IMPORTED', +} as const; + +export type TAcmeCertificateSource = + (typeof ACME_CERTIFICATE_SOURCE)[keyof typeof ACME_CERTIFICATE_SOURCE]; + +export const ACME_CERTIFICATE_SOURCES = Object.values(ACME_CERTIFICATE_SOURCE) as [ + TAcmeCertificateSource, + ...TAcmeCertificateSource[], +]; + +export const ACME_CHALLENGE_TYPE = { + /** + * A fresh TXT record per issuance. + */ + DNS_01: 'DNS_01', + /** + * A persistent authorization record bound to the ACME account + * (draft-ietf-acme-dns-persist). Once published, renewals touch no DNS at all. + */ + DNS_PERSIST_01: 'DNS_PERSIST_01', +} as const; + +export type TAcmeChallengeType = (typeof ACME_CHALLENGE_TYPE)[keyof typeof ACME_CHALLENGE_TYPE]; + +export const ACME_CHALLENGE_TYPES = Object.values(ACME_CHALLENGE_TYPE) as [ + TAcmeChallengeType, + ...TAcmeChallengeType[], +]; + +/** Record name prefixes defined by the ACME challenge specifications. */ +export const ACME_RECORD_PREFIX = { + DNS_01: '_acme-challenge', + DNS_PERSIST_01: '_validation-persist', +} as const; + +export const ACME_KEY_TYPE = { + ECDSA_P256: 'ECDSA_P256', + ECDSA_P384: 'ECDSA_P384', + RSA_2048: 'RSA_2048', + RSA_4096: 'RSA_4096', +} as const; + +export type TAcmeKeyType = (typeof ACME_KEY_TYPE)[keyof typeof ACME_KEY_TYPE]; + +export const ACME_KEY_TYPES = Object.values(ACME_KEY_TYPE) as [TAcmeKeyType, ...TAcmeKeyType[]]; + +export const ACME_CERTIFICATE_STATUS = { + /** Created, never issued yet. */ + PENDING: 'PENDING', + /** Waiting for a record to be published by hand (MANUAL credentials). */ + AWAITING_DNS: 'AWAITING_DNS', + /** An order is in flight. */ + ISSUING: 'ISSUING', + /** A valid certificate is stored. */ + ACTIVE: 'ACTIVE', + /** The last attempt failed; see lastError and nextRetryAt. */ + ERROR: 'ERROR', +} as const; + +export type TAcmeCertificateStatus = + (typeof ACME_CERTIFICATE_STATUS)[keyof typeof ACME_CERTIFICATE_STATUS]; + +export const ACME_CERTIFICATE_STATUSES = Object.values(ACME_CERTIFICATE_STATUS) as [ + TAcmeCertificateStatus, + ...TAcmeCertificateStatus[], +]; + +/** + * Known ACME directories, staging endpoints included. + * + * Staging is not a curiosity here: it is the only place to rehearse a new + * certificate without spending the production rate limit, and — as of 2026-08 — + * the only place where dns-persist-01 works at all. + */ +export const ACME_DIRECTORY = { + LETSENCRYPT: 'https://acme-v02.api.letsencrypt.org/directory', + LETSENCRYPT_STAGING: 'https://acme-staging-v02.api.letsencrypt.org/directory', + BUYPASS: 'https://api.buypass.com/acme/directory', + BUYPASS_STAGING: 'https://api.test4.buypass.no/acme/directory', + GOOGLE: 'https://dv.acme-v02.api.pki.goog/directory', + GOOGLE_STAGING: 'https://dv.acme-v02.test-api.pki.goog/directory', + ZEROSSL: 'https://acme.zerossl.com/v2/DV90', +} as const; + +export type TAcmeDirectory = (typeof ACME_DIRECTORY)[keyof typeof ACME_DIRECTORY]; + +export interface IAcmeDirectoryPreset { + name: string; + url: string; + isStaging: boolean; + /** External Account Binding is mandatory for this CA. */ + requiresEab: boolean; +} + +export const ACME_DIRECTORY_PRESETS: IAcmeDirectoryPreset[] = [ + { + name: "Let's Encrypt", + url: ACME_DIRECTORY.LETSENCRYPT, + isStaging: false, + requiresEab: false, + }, + { + name: "Let's Encrypt (staging)", + url: ACME_DIRECTORY.LETSENCRYPT_STAGING, + isStaging: true, + requiresEab: false, + }, + { + name: 'Buypass Go', + url: ACME_DIRECTORY.BUYPASS, + isStaging: false, + requiresEab: false, + }, + { + name: 'Buypass Go (staging)', + url: ACME_DIRECTORY.BUYPASS_STAGING, + isStaging: true, + requiresEab: false, + }, + { + name: 'Google Trust Services', + url: ACME_DIRECTORY.GOOGLE, + isStaging: false, + requiresEab: true, + }, + { + name: 'Google Trust Services (staging)', + url: ACME_DIRECTORY.GOOGLE_STAGING, + isStaging: true, + requiresEab: true, + }, + { + name: 'ZeroSSL', + url: ACME_DIRECTORY.ZEROSSL, + isStaging: false, + requiresEab: true, + }, +]; + +export const ACME_EVENT_LEVEL = { + INFO: 'INFO', + ERROR: 'ERROR', +} as const; + +export type TAcmeEventLevel = (typeof ACME_EVENT_LEVEL)[keyof typeof ACME_EVENT_LEVEL]; + +export const ACME_EVENT_LEVELS = Object.values(ACME_EVENT_LEVEL) as [ + TAcmeEventLevel, + ...TAcmeEventLevel[], +]; diff --git a/libs/contract/constants/acme/index.ts b/libs/contract/constants/acme/index.ts new file mode 100644 index 000000000..e458ce5bb --- /dev/null +++ b/libs/contract/constants/acme/index.ts @@ -0,0 +1 @@ +export * from './acme'; diff --git a/libs/contract/constants/errors/errors.ts b/libs/contract/constants/errors/errors.ts index 840eb6381..89e9c2f68 100644 --- a/libs/contract/constants/errors/errors.ts +++ b/libs/contract/constants/errors/errors.ts @@ -1184,4 +1184,140 @@ export const ERRORS = { message: 'Get stats digest error', httpCode: 500, }, + ACME_SECRET_KEY_MISSING: { + code: 'A237', + message: + 'ACME_SECRET_KEY is not set. Generate one with "cli generate-acme-key" and restart the panel.', + httpCode: 400, + }, + ACME_CREDENTIAL_NOT_FOUND: { + code: 'A238', + message: 'ACME credential not found', + httpCode: 404, + }, + ACME_CREDENTIAL_NAME_ALREADY_EXISTS: { + code: 'A239', + message: 'ACME credential name already exists', + httpCode: 400, + }, + ACME_CREDENTIAL_IN_USE: { + code: 'A240', + message: 'ACME credential is used by certificates', + httpCode: 400, + }, + GET_ACME_CREDENTIALS_ERROR: { + code: 'A241', + message: 'Get ACME credentials error', + httpCode: 500, + }, + CREATE_ACME_CREDENTIAL_ERROR: { + code: 'A242', + message: 'Create ACME credential error', + httpCode: 500, + }, + UPDATE_ACME_CREDENTIAL_ERROR: { + code: 'A243', + message: 'Update ACME credential error', + httpCode: 500, + }, + DELETE_ACME_CREDENTIAL_ERROR: { + code: 'A244', + message: 'Delete ACME credential error', + httpCode: 500, + }, + ACME_CREDENTIAL_TEST_FAILED: { + code: 'A245', + message: 'ACME credential test failed', + httpCode: 400, + withMessage: (message: string) => ({ + code: 'A245', + message, + httpCode: 400, + }), + }, + ACME_CERTIFICATE_NOT_FOUND: { + code: 'A246', + message: 'ACME certificate not found', + httpCode: 404, + }, + ACME_CERTIFICATE_NAME_ALREADY_EXISTS: { + code: 'A247', + message: 'ACME certificate name already exists', + httpCode: 400, + }, + GET_ACME_CERTIFICATES_ERROR: { + code: 'A248', + message: 'Get ACME certificates error', + httpCode: 500, + }, + CREATE_ACME_CERTIFICATE_ERROR: { + code: 'A249', + message: 'Create ACME certificate error', + httpCode: 500, + }, + UPDATE_ACME_CERTIFICATE_ERROR: { + code: 'A250', + message: 'Update ACME certificate error', + httpCode: 500, + }, + DELETE_ACME_CERTIFICATE_ERROR: { + code: 'A251', + message: 'Delete ACME certificate error', + httpCode: 500, + }, + ACME_CERTIFICATE_ISSUE_ERROR: { + code: 'A252', + message: 'ACME certificate issuance failed', + httpCode: 500, + withMessage: (message: string) => ({ + code: 'A252', + message, + httpCode: 500, + }), + }, + ACME_INVALID_CERTIFICATE_REQUEST: { + code: 'A253', + message: 'Invalid ACME certificate request', + httpCode: 400, + withMessage: (message: string) => ({ + code: 'A253', + message, + httpCode: 400, + }), + }, + ACME_PERSIST_RECORD_NOT_APPLICABLE: { + code: 'A254', + message: 'Persistent authorization records apply to dns-persist-01 certificates only', + httpCode: 400, + }, + ACME_SOLVER_ERROR: { + code: 'A255', + message: 'DNS solver error', + httpCode: 502, + withMessage: (message: string) => ({ + code: 'A255', + message, + httpCode: 502, + }), + }, + ACME_INVALID_PEM: { + code: 'A256', + message: 'The certificate or the private key could not be read', + httpCode: 400, + withMessage: (message: string) => ({ + code: 'A256', + message, + httpCode: 400, + }), + }, + ACME_CERTIFICATE_NOT_IMPORTED: { + code: 'A257', + message: 'Only imported certificates can have their material replaced', + httpCode: 400, + }, + ACME_CERTIFICATE_IS_IMPORTED: { + code: 'A258', + message: 'Imported certificates are not issued by the panel; upload a new one instead', + httpCode: 400, + }, } as const; diff --git a/libs/contract/constants/index.ts b/libs/contract/constants/index.ts index ca1f0030c..c8a04bb50 100644 --- a/libs/contract/constants/index.ts +++ b/libs/contract/constants/index.ts @@ -1,3 +1,4 @@ +export * from './acme'; export * from './cache-keys'; export * from './crud-actions'; export * from './endpoint-details'; diff --git a/libs/contract/models/acme.schema.ts b/libs/contract/models/acme.schema.ts new file mode 100644 index 000000000..f8bd8de03 --- /dev/null +++ b/libs/contract/models/acme.schema.ts @@ -0,0 +1,110 @@ +import { z } from 'zod'; + +import { + ACME_CERTIFICATE_SOURCES, + ACME_CERTIFICATE_STATUSES, + ACME_CHALLENGE_TYPES, + ACME_EVENT_LEVELS, + ACME_KEY_TYPES, + ACME_PROVIDERS, +} from '../constants/acme'; + +/** + * A domain a certificate may cover: a hostname, or a wildcard covering one level. + */ +export const AcmeDomainSchema = z + .string() + .min(3) + .max(253) + .regex( + /^(\*\.)?([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/, + 'Must be a domain name, optionally prefixed with "*."', + ); + +/** + * Credentials never travel outwards. The response says whether a secret is + * stored plus the non-secret fields — enough for the UI, and + * nothing an attacker could reuse. + */ +export const AcmeCredentialSchema = z.object({ + uuid: z.uuid(), + name: z.string(), + provider: z.enum(ACME_PROVIDERS), + hasSecret: z.boolean(), + /** Non-secret provider fields (registry keys marked secret never appear). */ + config: z.record(z.string(), z.string()), + certificatesCount: z.number().int(), + createdAt: z.iso.datetime().transform((str) => new Date(str)), + updatedAt: z.iso.datetime().transform((str) => new Date(str)), +}); + +/** + * A certificate delivered to one node. An empty inboundTags means every TLS + * inbound the node runs. + */ +export const AcmeCertificateNodeSchema = z.object({ + nodeUuid: z.uuid(), + nodeName: z.nullable(z.string()), + inboundTags: z.array(z.string()), +}); + +export const AcmeCertificateSchema = z.object({ + uuid: z.uuid(), + name: z.string(), + domains: z.array(AcmeDomainSchema), + + source: z.enum(ACME_CERTIFICATE_SOURCES), + + challengeType: z.enum(ACME_CHALLENGE_TYPES), + keyType: z.enum(ACME_KEY_TYPES), + renewBeforeDays: z.number().int(), + isEnabled: z.boolean(), + + /** Null for imported certificates: there is no CA and no account behind them. */ + directoryUrl: z.nullable(z.string()), + email: z.nullable(z.string()), + eabKid: z.nullable(z.string()), + + status: z.enum(ACME_CERTIFICATE_STATUSES), + lastError: z.nullable(z.string()), + issuedAt: z.nullable(z.iso.datetime().transform((str) => new Date(str))), + expiresAt: z.nullable(z.iso.datetime().transform((str) => new Date(str))), + fingerprint: z.nullable(z.string()), + failCount: z.number().int(), + nextRetryAt: z.nullable(z.iso.datetime().transform((str) => new Date(str))), + + credentialUuid: z.nullable(z.uuid()), + credentialName: z.nullable(z.string()), + + nodes: z.array(AcmeCertificateNodeSchema), + + createdAt: z.iso.datetime().transform((str) => new Date(str)), + updatedAt: z.iso.datetime().transform((str) => new Date(str)), +}); + +export const AcmeEventSchema = z.object({ + id: z.number().int(), + certificateUuid: z.nullable(z.uuid()), + level: z.enum(ACME_EVENT_LEVELS), + message: z.string(), + createdAt: z.iso.datetime().transform((str) => new Date(str)), +}); + +/** + * The persistent authorization record for dns-persist-01: what to publish, and + * whether it is already visible in DNS. + */ +export const AcmePersistRecordSchema = z.object({ + name: z.string(), + value: z.string(), + isPublished: z.boolean(), + canPublish: z.boolean(), +}); + +/** What a credential test reports about itself. */ +export const AcmeCredentialTestSchema = z.object({ + isOk: z.boolean(), + message: z.string(), + allow: z.array(z.string()), + zones: z.array(z.string()), +}); diff --git a/libs/contract/models/index.ts b/libs/contract/models/index.ts index eeb3c5446..460836c6b 100644 --- a/libs/contract/models/index.ts +++ b/libs/contract/models/index.ts @@ -1,3 +1,4 @@ +export * from './acme.schema'; export * from './api-tokens.schema'; export * from './auth.schema'; export * from './base-internal-squad.schema'; diff --git a/package-lock.json b/package-lock.json index c70ab4b5f..3d0fffa33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,7 @@ "@stablelib/base64": "^2.0.1", "@stablelib/x25519": "^2.0.1", "@willsoto/nestjs-prometheus": "^6.1.0", + "acme-client": "^5.4.0", "age-encryption": "^0.3.0", "arctic": "^3.7.0", "axios": "^1.18.1", @@ -3308,6 +3309,44 @@ "node": ">= 0.6" } }, + "node_modules/acme-client": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/acme-client/-/acme-client-5.4.0.tgz", + "integrity": "sha512-mORqg60S8iML6XSmVjqjGHJkINrCGLMj2QvDmFzI9vIlv1RGlyjmw3nrzaINJjkNsYXC41XhhD5pfy7CtuGcbA==", + "license": "MIT", + "dependencies": { + "@peculiar/x509": "^1.11.0", + "asn1js": "^3.0.5", + "axios": "^1.7.2", + "debug": "^4.3.5", + "node-forge": "^1.3.1" + }, + "engines": { + "node": ">= 16" + } + }, + "node_modules/acme-client/node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/age-encryption": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/age-encryption/-/age-encryption-0.3.0.tgz", @@ -6444,6 +6483,15 @@ "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", "license": "MIT" }, + "node_modules/node-forge": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, "node_modules/node-gyp-build-optional-packages": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", diff --git a/package.json b/package.json index d0570ad62..54141edd8 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "@stablelib/base64": "^2.0.1", "@stablelib/x25519": "^2.0.1", "@willsoto/nestjs-prometheus": "^6.1.0", + "acme-client": "^5.4.0", "age-encryption": "^0.3.0", "arctic": "^3.7.0", "axios": "^1.18.1", diff --git a/prisma/migrations/20260802205904_add_acme/migration.sql b/prisma/migrations/20260802205904_add_acme/migration.sql new file mode 100644 index 000000000..2787eb780 --- /dev/null +++ b/prisma/migrations/20260802205904_add_acme/migration.sql @@ -0,0 +1,108 @@ +-- CreateTable +CREATE TABLE "acme_credentials" ( + "uuid" UUID NOT NULL DEFAULT gen_random_uuid(), + "name" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "payload_encrypted" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "acme_credentials_pkey" PRIMARY KEY ("uuid") +); + +-- CreateTable +CREATE TABLE "acme_accounts" ( + "uuid" UUID NOT NULL DEFAULT gen_random_uuid(), + "directory_url" TEXT NOT NULL, + "email" TEXT NOT NULL, + "account_url" TEXT, + "account_key_encrypted" TEXT NOT NULL, + "eab_kid" TEXT, + "eab_hmac_encrypted" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "acme_accounts_pkey" PRIMARY KEY ("uuid") +); + +-- CreateTable +CREATE TABLE "acme_certificates" ( + "uuid" UUID NOT NULL DEFAULT gen_random_uuid(), + "name" TEXT NOT NULL, + "domains" TEXT[], + "challenge_type" TEXT NOT NULL DEFAULT 'DNS_01', + "key_type" TEXT NOT NULL DEFAULT 'ECDSA_P256', + "renew_before_days" INTEGER NOT NULL DEFAULT 30, + "is_enabled" BOOLEAN NOT NULL DEFAULT true, + "directory_url" TEXT NOT NULL, + "email" TEXT NOT NULL, + "eab_kid" TEXT, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "last_error" TEXT, + "issued_at" TIMESTAMP(3), + "expires_at" TIMESTAMP(3), + "fingerprint" TEXT, + "fail_count" INTEGER NOT NULL DEFAULT 0, + "next_retry_at" TIMESTAMP(3), + "fullchain_pem" TEXT, + "key_encrypted" TEXT, + "credential_uuid" UUID, + "account_uuid" UUID, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "acme_certificates_pkey" PRIMARY KEY ("uuid") +); + +-- CreateTable +CREATE TABLE "acme_certificate_nodes" ( + "certificate_uuid" UUID NOT NULL, + "node_uuid" UUID NOT NULL, + "inbound_tags" TEXT[] DEFAULT ARRAY[]::TEXT[], + + CONSTRAINT "acme_certificate_nodes_pkey" PRIMARY KEY ("certificate_uuid","node_uuid") +); + +-- CreateTable +CREATE TABLE "acme_events" ( + "id" BIGSERIAL NOT NULL, + "certificate_uuid" UUID, + "level" TEXT NOT NULL, + "message" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "acme_events_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "acme_credentials_name_key" ON "acme_credentials"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "acme_accounts_directory_url_email_key" ON "acme_accounts"("directory_url", "email"); + +-- CreateIndex +CREATE UNIQUE INDEX "acme_certificates_name_key" ON "acme_certificates"("name"); + +-- CreateIndex +CREATE INDEX "acme_certificates_is_enabled_expires_at_idx" ON "acme_certificates"("is_enabled", "expires_at"); + +-- CreateIndex +CREATE INDEX "acme_certificate_nodes_node_uuid_idx" ON "acme_certificate_nodes"("node_uuid"); + +-- CreateIndex +CREATE INDEX "acme_events_certificate_uuid_created_at_idx" ON "acme_events"("certificate_uuid", "created_at" DESC); + +-- AddForeignKey +ALTER TABLE "acme_certificates" ADD CONSTRAINT "acme_certificates_credential_uuid_fkey" FOREIGN KEY ("credential_uuid") REFERENCES "acme_credentials"("uuid") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "acme_certificates" ADD CONSTRAINT "acme_certificates_account_uuid_fkey" FOREIGN KEY ("account_uuid") REFERENCES "acme_accounts"("uuid") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "acme_certificate_nodes" ADD CONSTRAINT "acme_certificate_nodes_certificate_uuid_fkey" FOREIGN KEY ("certificate_uuid") REFERENCES "acme_certificates"("uuid") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "acme_certificate_nodes" ADD CONSTRAINT "acme_certificate_nodes_node_uuid_fkey" FOREIGN KEY ("node_uuid") REFERENCES "nodes"("uuid") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "acme_events" ADD CONSTRAINT "acme_events_certificate_uuid_fkey" FOREIGN KEY ("certificate_uuid") REFERENCES "acme_certificates"("uuid") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260802223417_acme_imported_certificates/migration.sql b/prisma/migrations/20260802223417_acme_imported_certificates/migration.sql new file mode 100644 index 000000000..a1ba462ea --- /dev/null +++ b/prisma/migrations/20260802223417_acme_imported_certificates/migration.sql @@ -0,0 +1,10 @@ +-- DropIndex +DROP INDEX "acme_certificates_is_enabled_expires_at_idx"; + +-- AlterTable +ALTER TABLE "acme_certificates" ADD COLUMN "source" TEXT NOT NULL DEFAULT 'ACME', +ALTER COLUMN "directory_url" DROP NOT NULL, +ALTER COLUMN "email" DROP NOT NULL; + +-- CreateIndex +CREATE INDEX "acme_certificates_is_enabled_source_expires_at_idx" ON "acme_certificates"("is_enabled", "source", "expires_at"); diff --git a/prisma/migrations/20260808180500_acme_custom_provider/migration.sql b/prisma/migrations/20260808180500_acme_custom_provider/migration.sql new file mode 100644 index 000000000..59b1f0607 --- /dev/null +++ b/prisma/migrations/20260808180500_acme_custom_provider/migration.sql @@ -0,0 +1,4 @@ +-- The ACME_PROXY provider type became the generic CUSTOM provider (a plain +-- HTTP DNS API). The stored payload shape (baseUrl, token) is unchanged, so +-- only the discriminator moves. +UPDATE "acme_credentials" SET "provider" = 'CUSTOM' WHERE "provider" = 'ACME_PROXY'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cadc53f57..790427e1f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -206,6 +206,7 @@ model Nodes { connectedUsers UserTraffic[] nodeMetas NodeMeta[] torrentBlockerReports TorrentBlockerReports[] + acmeCertificates AcmeCertificateNodes[] activeConfigProfile ConfigProfiles? @relation(fields: [activeConfigProfileUuid], references: [uuid], onDelete: SetNull) provider InfraProviders? @relation(fields: [providerUuid], references: [uuid], onDelete: SetNull) @@ -629,3 +630,121 @@ model TorrentBlockerReports { @@map("torrent_blocker_reports") } + +// A reusable way to answer a DNS challenge: an acme-proxy instance, a DNS +// provider token, or nothing at all when the operator publishes records by hand. +model AcmeCredentials { + uuid String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + name String @unique @map("name") + provider String @map("provider") // ACME_PROXY | CLOUDFLARE | MANUAL + + // AES-256-GCM ciphertext of a provider-specific JSON payload. Nullable + // because MANUAL has nothing to store. + payloadEncrypted String? @map("payload_encrypted") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + + certificates AcmeCertificates[] + + @@map("acme_credentials") +} + +// An ACME account. Kept apart from certificates so that many certificates share +// one registration: CAs rate-limit accounts, and dns-persist-01 authorizations +// are bound to the account URI. +model AcmeAccounts { + uuid String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + directoryUrl String @map("directory_url") + email String @map("email") + accountUrl String? @map("account_url") + + accountKeyEncrypted String @map("account_key_encrypted") + eabKid String? @map("eab_kid") + eabHmacEncrypted String? @map("eab_hmac_encrypted") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + + certificates AcmeCertificates[] + + @@unique([directoryUrl, email]) + @@map("acme_accounts") +} + +model AcmeCertificates { + uuid String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + name String @unique @map("name") + domains String[] @map("domains") + + // ACME: the panel orders and renews it. IMPORTED: the material was uploaded, + // and the panel only stores and delivers it — there is nothing to renew. + source String @default("ACME") @map("source") + + challengeType String @default("DNS_01") @map("challenge_type") // DNS_01 | DNS_PERSIST_01 + keyType String @default("ECDSA_P256") @map("key_type") // ECDSA_P256 | ECDSA_P384 | RSA_2048 | RSA_4096 + renewBeforeDays Int @default(30) @map("renew_before_days") + isEnabled Boolean @default(true) @map("is_enabled") + + // Null for imported certificates: they have no CA and no account behind them. + directoryUrl String? @map("directory_url") + email String? @map("email") + eabKid String? @map("eab_kid") + + status String @default("PENDING") @map("status") // PENDING | AWAITING_DNS | ISSUING | ACTIVE | ERROR + lastError String? @map("last_error") + issuedAt DateTime? @map("issued_at") + expiresAt DateTime? @map("expires_at") + fingerprint String? @map("fingerprint") + failCount Int @default(0) @map("fail_count") + nextRetryAt DateTime? @map("next_retry_at") + + fullchainPem String? @map("fullchain_pem") + keyEncrypted String? @map("key_encrypted") + + credentialUuid String? @map("credential_uuid") @db.Uuid + accountUuid String? @map("account_uuid") @db.Uuid + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + + credential AcmeCredentials? @relation(fields: [credentialUuid], references: [uuid], onDelete: Restrict) + account AcmeAccounts? @relation(fields: [accountUuid], references: [uuid], onDelete: SetNull) + + nodes AcmeCertificateNodes[] + events AcmeEvents[] + + @@index([isEnabled, source, expiresAt]) + @@map("acme_certificates") +} + +// Which nodes get a certificate, and on which inbounds. An empty inboundTags +// means every TLS inbound the node runs. Binding to the node rather than to the +// config profile is what keeps a private key off nodes that share the profile +// but not the name. +model AcmeCertificateNodes { + certificateUuid String @map("certificate_uuid") @db.Uuid + nodeUuid String @map("node_uuid") @db.Uuid + inboundTags String[] @default([]) @map("inbound_tags") + + certificate AcmeCertificates @relation(fields: [certificateUuid], references: [uuid], onDelete: Cascade) + node Nodes @relation(fields: [nodeUuid], references: [uuid], onDelete: Cascade) + + @@id([certificateUuid, nodeUuid]) + @@index([nodeUuid]) + @@map("acme_certificate_nodes") +} + +model AcmeEvents { + id BigInt @id @default(autoincrement()) + certificateUuid String? @map("certificate_uuid") @db.Uuid + level String @map("level") // INFO | ERROR + message String @map("message") + + createdAt DateTime @default(now()) @map("created_at") + + certificate AcmeCertificates? @relation(fields: [certificateUuid], references: [uuid], onDelete: Cascade) + + @@index([certificateUuid, createdAt(sort: Desc)]) + @@map("acme_events") +} diff --git a/src/bin/cli/cli.ts b/src/bin/cli/cli.ts index 9aec3b872..089aabc5f 100644 --- a/src/bin/cli/cli.ts +++ b/src/bin/cli/cli.ts @@ -14,6 +14,7 @@ import relativeTime from 'dayjs/plugin/relativeTime'; import timezone from 'dayjs/plugin/timezone'; import utc from 'dayjs/plugin/utc'; import Redis from 'ioredis'; +import { randomBytes } from 'node:crypto'; import { getRedisConnectionOptions } from '@common/utils'; import { generateNodeCert } from '@common/utils/certs'; @@ -53,6 +54,7 @@ const enum CLI_ACTIONS { DELETE_USERS_USAGE_BY_DATE_RANGE = 'delete-users-usage-by-date-range', ENABLE_PASSWORD_AUTH = 'enable-password-auth', EXIT = 'exit', + GENERATE_ACME_KEY = 'generate-acme-key', GENERATE_ENCRYPTION_KEYS = 'generate-encryption-keys', GET_SECRET_KEY_FOR_NODE = 'get-secret-key-for-node', RESET_CERTS = 'reset-certs', @@ -624,6 +626,19 @@ async function generateEncryptionKeys() { } } +function generateAcmeKey() { + const key = randomBytes(32).toString('base64'); + + consola.success('✅ ACME secret key generated.'); + consola.info( + `\nPut it into the panel environment and restart:\nACME_SECRET_KEY=${key}\n\n` + + 'It encrypts DNS credentials, ACME account keys and certificate private keys.\n' + + 'Changing it later makes everything already stored unreadable — certificates would have to be re-issued.', + ); + + process.exit(0); +} + async function main() { consola.box('Remnawave Rescue CLI v0.4'); @@ -662,6 +677,11 @@ async function main() { label: 'Generate keypairs', hint: 'Generate keypairs for response rules encryption', }, + { + value: CLI_ACTIONS.GENERATE_ACME_KEY, + label: 'Generate ACME secret key', + hint: 'Generate ACME_SECRET_KEY for encrypting ACME secrets at rest', + }, { value: CLI_ACTIONS.TRUNCATE_HWID_USER_DEVICES, label: 'Clean up HWID Devices', @@ -713,6 +733,9 @@ async function main() { case CLI_ACTIONS.GENERATE_ENCRYPTION_KEYS: await generateEncryptionKeys(); break; + case CLI_ACTIONS.GENERATE_ACME_KEY: + generateAcmeKey(); + break; case CLI_ACTIONS.ENABLE_PASSWORD_AUTH: await enablePasswordAuth(); break; diff --git a/src/common/config/app-config/config.schema.ts b/src/common/config/app-config/config.schema.ts index dbe71fb05..4bfb7289f 100644 --- a/src/common/config/app-config/config.schema.ts +++ b/src/common/config/app-config/config.schema.ts @@ -39,6 +39,21 @@ export const configSchema = z .string() .default('12') .transform((val) => parseInt(val, 10)), + /** + * Key for the ACME module's secrets at rest: DNS credentials, ACME account + * keys and certificate private keys. 32 bytes, base64. + * + * Optional so that panels not using ACME start unchanged; the module + * refuses to work without it rather than storing secrets in the clear. + * Generate one with "cli generate-acme-key". + */ + ACME_SECRET_KEY: z + .string() + .optional() + .refine( + (val) => val === undefined || val === '' || Buffer.from(val, 'base64').length === 32, + 'ACME_SECRET_KEY must be 32 bytes encoded as base64', + ), IS_TELEGRAM_NOTIFICATIONS_ENABLED: booleanString('false'), TELEGRAM_BOT_TOKEN: z.string().optional(), TELEGRAM_BOT_API_ROOT: z.string().default('https://api.telegram.org'), diff --git a/src/common/helpers/xray-config/inject-node-certificates.ts b/src/common/helpers/xray-config/inject-node-certificates.ts new file mode 100644 index 000000000..472b47307 --- /dev/null +++ b/src/common/helpers/xray-config/inject-node-certificates.ts @@ -0,0 +1,149 @@ +import { createHash, X509Certificate } from 'node:crypto'; +import { TLSCertConfig, XrayConfig } from 'xray-typed'; + +import { INodeCertificate } from '@modules/acme/queries/get-certificates-for-node'; + +/** + * Puts panel-managed certificates into the config a specific node is about to + * receive. + * + * They are injected here rather than stored in the config profile because a + * profile is shared: writing a certificate into it would hand its private key to + * every node using that profile, including nodes that never serve the name. + * + * Existing entries are matched by common name and replaced; anything else on the + * inbound is left alone, since an inbound legitimately carries several + * certificates and Xray picks between them by SNI. + */ +export function injectNodeCertificates( + config: XrayConfig, + certificates: INodeCertificate[], +): void { + if (!config.inbounds || certificates.length === 0) { + return; + } + + for (const inbound of config.inbounds) { + if (inbound.streamSettings?.security !== 'tls') { + continue; + } + + const applicable = certificates.filter( + (certificate) => + certificate.inboundTags.length === 0 || + (inbound.tag !== undefined && certificate.inboundTags.includes(inbound.tag)), + ); + + if (applicable.length === 0) { + continue; + } + + const tlsSettings = (inbound.streamSettings.tlsSettings ??= {}); + const existing: TLSCertConfig[] = (tlsSettings.certificates ??= []); + + for (const certificate of applicable) { + const entry: TLSCertConfig = { + certificate: certificate.certificate, + key: certificate.key, + }; + + const index = existing.findIndex((candidate) => isSameCertificate(candidate, certificate)); + + if (index === -1) { + existing.push(entry); + continue; + } + + // Keep whatever else the entry carried (usage, ocspStapling), but drop + // the file paths: an inline certificate wins over a file that would + // otherwise be re-read on the node. + const { certificateFile, keyFile, ...rest } = existing[index]; + + existing[index] = { ...rest, ...entry }; + } + } +} + +/** + * A digest of the certificates a node is being sent. + * + * It is mixed into the config hash the node compares against its previous one. + * Without it a renewal changes nothing the node can see — the profile is + * identical — and the new certificate would sit in the panel until something + * else happened to change the config. + */ +export function getCertificatesFingerprint(certificates: INodeCertificate[]): string { + if (certificates.length === 0) { + return ''; + } + + const material = certificates + .map((certificate) => `${certificate.domains.join(',')}:${certificate.fingerprint}`) + .sort() + .join('|'); + + return createHash('sha256').update(material).digest('hex').slice(0, 16); +} + +/** + * Whether an entry already on the inbound is the same certificate, and should be + * replaced rather than joined by a second copy. + * + * Names, not the common name alone: certificates that carry only SAN have no + * common name to compare, and matching on it alone would leave the old entry in + * place next to the new one, with Xray free to serve either. + * + * Parsing is deliberately lenient — a hand-written entry that is not valid PEM + * must not break the whole config. + */ +function isSameCertificate(entry: TLSCertConfig, candidate: INodeCertificate): boolean { + const pem = Array.isArray(entry.certificate) ? entry.certificate.join('\n') : entry.certificate; + + if (!pem) { + return false; + } + + let parsed: X509Certificate; + + try { + parsed = new X509Certificate(pem); + } catch { + return false; + } + + const names = readNames(parsed); + const wanted = new Set(candidate.domains.map((domain) => domain.toLowerCase())); + + if (candidate.commonName) { + wanted.add(candidate.commonName.toLowerCase()); + } + + return names.some((name) => wanted.has(name)); +} + +/** + * Common name plus every DNS SAN, lowercased. + * + * Both fields are optional at runtime: a certificate with an empty subject — + * which is what a SAN-only certificate has — reports `subject` as undefined. + */ +function readNames(certificate: X509Certificate): string[] { + const names = (certificate.subjectAltName ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.startsWith('DNS:')) + .map((entry) => entry.slice('DNS:'.length).toLowerCase()); + + const commonName = (certificate.subject ?? '') + .split('\n') + .map((line) => line.trim()) + .find((line) => line.startsWith('CN=')) + ?.slice(3) + .toLowerCase(); + + if (commonName) { + names.push(commonName); + } + + return names; +} diff --git a/src/modules/acme/acme-certificates.controller.ts b/src/modules/acme/acme-certificates.controller.ts new file mode 100644 index 000000000..6e0c01499 --- /dev/null +++ b/src/modules/acme/acme-certificates.controller.ts @@ -0,0 +1,228 @@ +import { ACME_CONTROLLER, CONTROLLERS_INFO } from '@contract/api'; +import { ROLE } from '@contract/constants'; + +import { Body, Controller, HttpStatus, Param, UseFilters, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; + +import { Endpoint } from '@common/decorators/base-endpoint'; +import { Roles } from '@common/decorators/roles/roles'; +import { ApiScopeResource } from '@common/decorators/scopes'; +import { HttpExceptionFilter } from '@common/exception/http-exception.filter'; +import { JwtDefaultGuard } from '@common/guards/jwt-guards/def-jwt-guard'; +import { RolesGuard } from '@common/guards/roles'; +import { ScopesGuard } from '@common/guards/scopes'; +import { errorHandler } from '@common/helpers/error-handler.helper'; +import { + CreateAcmeCertificateCommand, + DeleteAcmeCertificateCommand, + GetAcmeCertificateCommand, + GetAcmeCertificateEventsCommand, + GetAcmeCertificatesCommand, + GetAcmePersistRecordCommand, + ImportAcmeCertificateCommand, + IssueAcmeCertificateCommand, + PublishAcmePersistRecordCommand, + ReimportAcmeCertificateCommand, + UpdateAcmeCertificateCommand, +} from '@libs/contracts/commands'; + +import { + CreateAcmeCertificateBodyDto, + CreateAcmeCertificateResponseDto, + DeleteAcmeCertificateParamDto, + DeleteAcmeCertificateResponseDto, + GetAcmeCertificateEventsParamDto, + GetAcmeCertificateEventsResponseDto, + GetAcmeCertificateParamDto, + GetAcmeCertificateResponseDto, + GetAcmeCertificatesResponseDto, + GetAcmePersistRecordParamDto, + GetAcmePersistRecordResponseDto, + ImportAcmeCertificateBodyDto, + ImportAcmeCertificateResponseDto, + IssueAcmeCertificateParamDto, + IssueAcmeCertificateResponseDto, + PublishAcmePersistRecordParamDto, + PublishAcmePersistRecordResponseDto, + ReimportAcmeCertificateBodyDto, + ReimportAcmeCertificateParamDto, + ReimportAcmeCertificateResponseDto, + UpdateAcmeCertificateBodyDto, + UpdateAcmeCertificateResponseDto, +} from './dtos'; +import { AcmeCertificatesService } from './services/acme-certificates.service'; + +@ApiBearerAuth('Authorization') +@ApiScopeResource(CONTROLLERS_INFO.ACME.resource) +@ApiTags(CONTROLLERS_INFO.ACME.tag) +@Roles(ROLE.ADMIN, ROLE.API) +@UseGuards(JwtDefaultGuard, RolesGuard, ScopesGuard) +@UseFilters(HttpExceptionFilter) +@Controller(ACME_CONTROLLER) +export class AcmeCertificatesController { + constructor(private readonly acmeCertificatesService: AcmeCertificatesService) {} + + @Endpoint({ + type: GetAcmeCertificatesResponseDto, + command: GetAcmeCertificatesCommand, + httpCode: HttpStatus.OK, + }) + async getCertificates(): Promise { + const result = await this.acmeCertificatesService.getAll(); + + return { + response: errorHandler(result), + }; + } + + @Endpoint({ + type: GetAcmeCertificateResponseDto, + command: GetAcmeCertificateCommand, + httpCode: HttpStatus.OK, + }) + async getCertificate( + @Param() param: GetAcmeCertificateParamDto, + ): Promise { + const result = await this.acmeCertificatesService.getByUuid(param.uuid); + + return { + response: errorHandler(result), + }; + } + + @Endpoint({ + type: CreateAcmeCertificateResponseDto, + command: CreateAcmeCertificateCommand, + httpCode: HttpStatus.CREATED, + }) + async createCertificate( + @Body() body: CreateAcmeCertificateBodyDto, + ): Promise { + const result = await this.acmeCertificatesService.create(body); + + return { + response: errorHandler(result), + }; + } + + @Endpoint({ + type: UpdateAcmeCertificateResponseDto, + command: UpdateAcmeCertificateCommand, + httpCode: HttpStatus.OK, + }) + async updateCertificate( + @Body() body: UpdateAcmeCertificateBodyDto, + ): Promise { + const result = await this.acmeCertificatesService.update(body); + + return { + response: errorHandler(result), + }; + } + + @Endpoint({ + type: DeleteAcmeCertificateResponseDto, + command: DeleteAcmeCertificateCommand, + httpCode: HttpStatus.OK, + }) + async deleteCertificate( + @Param() param: DeleteAcmeCertificateParamDto, + ): Promise { + const result = await this.acmeCertificatesService.delete(param.uuid); + + return { + response: errorHandler(result), + }; + } + + @Endpoint({ + type: ImportAcmeCertificateResponseDto, + command: ImportAcmeCertificateCommand, + httpCode: HttpStatus.CREATED, + }) + async importCertificate( + @Body() body: ImportAcmeCertificateBodyDto, + ): Promise { + const result = await this.acmeCertificatesService.import(body); + + return { + response: errorHandler(result), + }; + } + + @Endpoint({ + type: ReimportAcmeCertificateResponseDto, + command: ReimportAcmeCertificateCommand, + httpCode: HttpStatus.OK, + }) + async reimportCertificate( + @Param() param: ReimportAcmeCertificateParamDto, + @Body() body: ReimportAcmeCertificateBodyDto, + ): Promise { + const result = await this.acmeCertificatesService.reimport(param.uuid, body); + + return { + response: errorHandler(result), + }; + } + + @Endpoint({ + type: IssueAcmeCertificateResponseDto, + command: IssueAcmeCertificateCommand, + httpCode: HttpStatus.OK, + }) + async issueCertificate( + @Param() param: IssueAcmeCertificateParamDto, + ): Promise { + const result = await this.acmeCertificatesService.issue(param.uuid); + + return { + response: errorHandler(result), + }; + } + + @Endpoint({ + type: GetAcmePersistRecordResponseDto, + command: GetAcmePersistRecordCommand, + httpCode: HttpStatus.OK, + }) + async getPersistRecord( + @Param() param: GetAcmePersistRecordParamDto, + ): Promise { + const result = await this.acmeCertificatesService.getPersistRecord(param.uuid); + + return { + response: errorHandler(result), + }; + } + + @Endpoint({ + type: PublishAcmePersistRecordResponseDto, + command: PublishAcmePersistRecordCommand, + httpCode: HttpStatus.OK, + }) + async publishPersistRecord( + @Param() param: PublishAcmePersistRecordParamDto, + ): Promise { + const result = await this.acmeCertificatesService.getPersistRecord(param.uuid, true); + + return { + response: errorHandler(result), + }; + } + + @Endpoint({ + type: GetAcmeCertificateEventsResponseDto, + command: GetAcmeCertificateEventsCommand, + httpCode: HttpStatus.OK, + }) + async getCertificateEvents( + @Param() param: GetAcmeCertificateEventsParamDto, + ): Promise { + const result = await this.acmeCertificatesService.getEvents(param.uuid); + + return { + response: errorHandler(result), + }; + } +} diff --git a/src/modules/acme/acme-credentials.controller.ts b/src/modules/acme/acme-credentials.controller.ts new file mode 100644 index 000000000..694221fcd --- /dev/null +++ b/src/modules/acme/acme-credentials.controller.ts @@ -0,0 +1,118 @@ +import { ACME_CONTROLLER, CONTROLLERS_INFO } from '@contract/api'; +import { ROLE } from '@contract/constants'; + +import { Body, Controller, HttpStatus, Param, UseFilters, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; + +import { Endpoint } from '@common/decorators/base-endpoint'; +import { Roles } from '@common/decorators/roles/roles'; +import { ApiScopeResource } from '@common/decorators/scopes'; +import { HttpExceptionFilter } from '@common/exception/http-exception.filter'; +import { JwtDefaultGuard } from '@common/guards/jwt-guards/def-jwt-guard'; +import { RolesGuard } from '@common/guards/roles'; +import { ScopesGuard } from '@common/guards/scopes'; +import { errorHandler } from '@common/helpers/error-handler.helper'; +import { + CreateAcmeCredentialCommand, + DeleteAcmeCredentialCommand, + GetAcmeCredentialsCommand, + TestAcmeCredentialCommand, + UpdateAcmeCredentialCommand, +} from '@libs/contracts/commands'; + +import { + CreateAcmeCredentialBodyDto, + CreateAcmeCredentialResponseDto, + DeleteAcmeCredentialParamDto, + DeleteAcmeCredentialResponseDto, + GetAcmeCredentialsResponseDto, + TestAcmeCredentialParamDto, + TestAcmeCredentialResponseDto, + UpdateAcmeCredentialBodyDto, + UpdateAcmeCredentialResponseDto, +} from './dtos'; +import { AcmeCredentialsService } from './services/acme-credentials.service'; + +@ApiBearerAuth('Authorization') +@ApiScopeResource(CONTROLLERS_INFO.ACME.resource) +@ApiTags(CONTROLLERS_INFO.ACME.tag) +@Roles(ROLE.ADMIN, ROLE.API) +@UseGuards(JwtDefaultGuard, RolesGuard, ScopesGuard) +@UseFilters(HttpExceptionFilter) +@Controller(ACME_CONTROLLER) +export class AcmeCredentialsController { + constructor(private readonly acmeCredentialsService: AcmeCredentialsService) {} + + @Endpoint({ + type: GetAcmeCredentialsResponseDto, + command: GetAcmeCredentialsCommand, + httpCode: HttpStatus.OK, + }) + async getCredentials(): Promise { + const result = await this.acmeCredentialsService.getAll(); + + return { + response: errorHandler(result), + }; + } + + @Endpoint({ + type: CreateAcmeCredentialResponseDto, + command: CreateAcmeCredentialCommand, + httpCode: HttpStatus.CREATED, + }) + async createCredential( + @Body() body: CreateAcmeCredentialBodyDto, + ): Promise { + const result = await this.acmeCredentialsService.create(body); + + return { + response: errorHandler(result), + }; + } + + @Endpoint({ + type: UpdateAcmeCredentialResponseDto, + command: UpdateAcmeCredentialCommand, + httpCode: HttpStatus.OK, + }) + async updateCredential( + @Body() body: UpdateAcmeCredentialBodyDto, + ): Promise { + const result = await this.acmeCredentialsService.update(body); + + return { + response: errorHandler(result), + }; + } + + @Endpoint({ + type: TestAcmeCredentialResponseDto, + command: TestAcmeCredentialCommand, + httpCode: HttpStatus.OK, + }) + async testCredential( + @Param() param: TestAcmeCredentialParamDto, + ): Promise { + const result = await this.acmeCredentialsService.test(param.uuid); + + return { + response: errorHandler(result), + }; + } + + @Endpoint({ + type: DeleteAcmeCredentialResponseDto, + command: DeleteAcmeCredentialCommand, + httpCode: HttpStatus.OK, + }) + async deleteCredential( + @Param() param: DeleteAcmeCredentialParamDto, + ): Promise { + const result = await this.acmeCredentialsService.delete(param.uuid); + + return { + response: errorHandler(result), + }; + } +} diff --git a/src/modules/acme/acme.module.ts b/src/modules/acme/acme.module.ts new file mode 100644 index 000000000..b25b030c9 --- /dev/null +++ b/src/modules/acme/acme.module.ts @@ -0,0 +1,36 @@ +import { Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; + +import { AcmeCertificatesController } from './acme-certificates.controller'; +import { AcmeCredentialsController } from './acme-credentials.controller'; +import { COMMANDS } from './commands'; +import { AcmeSecretBoxService } from './crypto/acme-secret-box.service'; +import { AcmeOrderService } from './engine/acme-order.service'; +import { SolverFactory } from './engine/solvers/solver.factory'; +import { QUERIES } from './queries'; +import { AcmeAccountsRepository } from './repositories/acme-accounts.repository'; +import { AcmeCertificatesRepository } from './repositories/acme-certificates.repository'; +import { AcmeCredentialsRepository } from './repositories/acme-credentials.repository'; +import { AcmeEventsRepository } from './repositories/acme-events.repository'; +import { AcmeCertificatesService } from './services/acme-certificates.service'; +import { AcmeCredentialsService } from './services/acme-credentials.service'; + +@Module({ + imports: [CqrsModule], + controllers: [AcmeCredentialsController, AcmeCertificatesController], + providers: [ + AcmeSecretBoxService, + SolverFactory, + AcmeOrderService, + AcmeCredentialsService, + AcmeCertificatesService, + AcmeCredentialsRepository, + AcmeCertificatesRepository, + AcmeAccountsRepository, + AcmeEventsRepository, + ...QUERIES, + ...COMMANDS, + ], + exports: [AcmeSecretBoxService, AcmeCertificatesRepository], +}) +export class AcmeModule {} diff --git a/src/modules/acme/commands/index.ts b/src/modules/acme/commands/index.ts new file mode 100644 index 000000000..2007c56ab --- /dev/null +++ b/src/modules/acme/commands/index.ts @@ -0,0 +1,5 @@ +import { IssueCertificateHandler } from './issue-certificate'; + +export const COMMANDS = [IssueCertificateHandler]; + +export * from './issue-certificate'; diff --git a/src/modules/acme/commands/issue-certificate/index.ts b/src/modules/acme/commands/issue-certificate/index.ts new file mode 100644 index 000000000..c290440c6 --- /dev/null +++ b/src/modules/acme/commands/issue-certificate/index.ts @@ -0,0 +1,2 @@ +export * from './issue-certificate.command'; +export * from './issue-certificate.handler'; diff --git a/src/modules/acme/commands/issue-certificate/issue-certificate.command.ts b/src/modules/acme/commands/issue-certificate/issue-certificate.command.ts new file mode 100644 index 000000000..2bc926ba0 --- /dev/null +++ b/src/modules/acme/commands/issue-certificate/issue-certificate.command.ts @@ -0,0 +1,6 @@ +export class IssueCertificateCommand { + constructor( + public readonly certificateUuid: string, + public readonly force: boolean = false, + ) {} +} diff --git a/src/modules/acme/commands/issue-certificate/issue-certificate.handler.ts b/src/modules/acme/commands/issue-certificate/issue-certificate.handler.ts new file mode 100644 index 000000000..e59910bc2 --- /dev/null +++ b/src/modules/acme/commands/issue-certificate/issue-certificate.handler.ts @@ -0,0 +1,51 @@ +import { Logger } from '@nestjs/common'; +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; + +import { fail, ok, TResult } from '@common/types'; +import { ERRORS } from '@libs/contracts/constants'; + +import { NodesQueuesService } from '@queue/_nodes'; + +import { AcmeOrderService, IIssueResult } from '../../engine/acme-order.service'; +import { IssueCertificateCommand } from './issue-certificate.command'; + +/** + * Runs an order and, on success, restarts the nodes the certificate is bound to. + * + * The queue processor reaches issuance through this command rather than by + * injecting the module's services, which is how the rest of the codebase keeps + * queues and modules from importing each other. + */ +@CommandHandler(IssueCertificateCommand) +export class IssueCertificateHandler implements ICommandHandler< + IssueCertificateCommand, + TResult +> { + private readonly logger = new Logger(IssueCertificateHandler.name); + + constructor( + private readonly acmeOrderService: AcmeOrderService, + private readonly nodesQueuesService: NodesQueuesService, + ) {} + + async execute(command: IssueCertificateCommand): Promise> { + try { + const result = await this.acmeOrderService.issue( + command.certificateUuid, + command.force, + ); + + for (const nodeUuid of new Set(result.affectedNodeUuids)) { + // The node picks the certificate up when its config is rebuilt, so + // a restart is what actually delivers a renewal. + await this.nodesQueuesService.startNode({ nodeUuid }); + } + + return ok(result); + } catch (error) { + this.logger.error(error); + + return fail(ERRORS.ACME_CERTIFICATE_ISSUE_ERROR.withMessage(String(error))); + } + } +} diff --git a/src/modules/acme/crypto/acme-secret-box.service.ts b/src/modules/acme/crypto/acme-secret-box.service.ts new file mode 100644 index 000000000..4f091b08b --- /dev/null +++ b/src/modules/acme/crypto/acme-secret-box.service.ts @@ -0,0 +1,100 @@ +import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; + +import { Injectable } from '@nestjs/common'; + +import { TypedConfigService } from '@common/config/app-config'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 12; +const AUTH_TAG_LENGTH = 16; +const KEY_LENGTH = 32; +const FORMAT_VERSION = 'v1'; + +/** + * Encrypts the ACME module's secrets at rest: DNS provider credentials, ACME + * account keys and certificate private keys. + * + * The key comes from ACME_SECRET_KEY and is deliberately separate from + * APP_SECRET: rotating the login secret should not make every stored + * certificate unreadable, and vice versa. + * + * Stored form is "v1::". The version prefix is + * there so a future format change can be recognised instead of failing as + * corrupted data. + */ +@Injectable() +export class AcmeSecretBoxService { + private readonly key: Buffer | null; + + constructor(private readonly configService: TypedConfigService) { + const raw = this.configService.get('ACME_SECRET_KEY'); + + if (!raw) { + this.key = null; + return; + } + + const key = Buffer.from(raw, 'base64'); + + this.key = key.length === KEY_LENGTH ? key : null; + } + + /** + * Whether a usable key is configured. Callers check this and fail with a + * clear error instead of silently storing secrets in the clear. + */ + public get isConfigured(): boolean { + return this.key !== null; + } + + public encrypt(plaintext: string): string { + const key = this.requireKey(); + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, key, iv); + + const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + + return [ + FORMAT_VERSION, + iv.toString('base64'), + Buffer.concat([ciphertext, authTag]).toString('base64'), + ].join(':'); + } + + public decrypt(payload: string): string { + const key = this.requireKey(); + const [version, ivPart, bodyPart] = payload.split(':'); + + if (version !== FORMAT_VERSION || !ivPart || !bodyPart) { + throw new Error('Unrecognized encrypted payload format'); + } + + const iv = Buffer.from(ivPart, 'base64'); + const body = Buffer.from(bodyPart, 'base64'); + + const ciphertext = body.subarray(0, body.length - AUTH_TAG_LENGTH); + const authTag = body.subarray(body.length - AUTH_TAG_LENGTH); + + const decipher = createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + + return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8'); + } + + public encryptJson(value: T): string { + return this.encrypt(JSON.stringify(value)); + } + + public decryptJson(payload: string): T { + return JSON.parse(this.decrypt(payload)) as T; + } + + private requireKey(): Buffer { + if (!this.key) { + throw new Error('ACME_SECRET_KEY is not configured'); + } + + return this.key; + } +} diff --git a/src/modules/acme/dtos/acme.dtos.ts b/src/modules/acme/dtos/acme.dtos.ts new file mode 100644 index 000000000..15c776efb --- /dev/null +++ b/src/modules/acme/dtos/acme.dtos.ts @@ -0,0 +1,148 @@ +import { createZodDto } from 'nestjs-zod'; + +import { + CreateAcmeCertificateCommand, + CreateAcmeCredentialCommand, + DeleteAcmeCertificateCommand, + DeleteAcmeCredentialCommand, + GetAcmeCertificateCommand, + GetAcmeCertificateEventsCommand, + GetAcmeCertificatesCommand, + GetAcmeCredentialsCommand, + GetAcmePersistRecordCommand, + ImportAcmeCertificateCommand, + IssueAcmeCertificateCommand, + PublishAcmePersistRecordCommand, + ReimportAcmeCertificateCommand, + TestAcmeCredentialCommand, + UpdateAcmeCertificateCommand, + UpdateAcmeCredentialCommand, +} from '@libs/contracts/commands'; + +// Credentials + +export class GetAcmeCredentialsResponseDto extends createZodDto( + GetAcmeCredentialsCommand.ResponseSchema, +) {} + +export class CreateAcmeCredentialBodyDto extends createZodDto( + CreateAcmeCredentialCommand.RequestBodySchema, +) {} + +export class CreateAcmeCredentialResponseDto extends createZodDto( + CreateAcmeCredentialCommand.ResponseSchema, +) {} + +export class UpdateAcmeCredentialBodyDto extends createZodDto( + UpdateAcmeCredentialCommand.RequestBodySchema, +) {} + +export class UpdateAcmeCredentialResponseDto extends createZodDto( + UpdateAcmeCredentialCommand.ResponseSchema, +) {} + +export class DeleteAcmeCredentialParamDto extends createZodDto( + DeleteAcmeCredentialCommand.RequestParamSchema, +) {} + +export class DeleteAcmeCredentialResponseDto extends createZodDto( + DeleteAcmeCredentialCommand.ResponseSchema, +) {} + +export class TestAcmeCredentialParamDto extends createZodDto( + TestAcmeCredentialCommand.RequestParamSchema, +) {} + +export class TestAcmeCredentialResponseDto extends createZodDto( + TestAcmeCredentialCommand.ResponseSchema, +) {} + +// Certificates + +export class GetAcmeCertificatesResponseDto extends createZodDto( + GetAcmeCertificatesCommand.ResponseSchema, +) {} + +export class GetAcmeCertificateParamDto extends createZodDto( + GetAcmeCertificateCommand.RequestParamSchema, +) {} + +export class GetAcmeCertificateResponseDto extends createZodDto( + GetAcmeCertificateCommand.ResponseSchema, +) {} + +export class CreateAcmeCertificateBodyDto extends createZodDto( + CreateAcmeCertificateCommand.RequestBodySchema, +) {} + +export class CreateAcmeCertificateResponseDto extends createZodDto( + CreateAcmeCertificateCommand.ResponseSchema, +) {} + +export class UpdateAcmeCertificateBodyDto extends createZodDto( + UpdateAcmeCertificateCommand.RequestBodySchema, +) {} + +export class UpdateAcmeCertificateResponseDto extends createZodDto( + UpdateAcmeCertificateCommand.ResponseSchema, +) {} + +export class DeleteAcmeCertificateParamDto extends createZodDto( + DeleteAcmeCertificateCommand.RequestParamSchema, +) {} + +export class DeleteAcmeCertificateResponseDto extends createZodDto( + DeleteAcmeCertificateCommand.ResponseSchema, +) {} + +export class IssueAcmeCertificateParamDto extends createZodDto( + IssueAcmeCertificateCommand.RequestParamSchema, +) {} + +export class IssueAcmeCertificateResponseDto extends createZodDto( + IssueAcmeCertificateCommand.ResponseSchema, +) {} + +export class ImportAcmeCertificateBodyDto extends createZodDto( + ImportAcmeCertificateCommand.RequestBodySchema, +) {} + +export class ImportAcmeCertificateResponseDto extends createZodDto( + ImportAcmeCertificateCommand.ResponseSchema, +) {} + +export class ReimportAcmeCertificateParamDto extends createZodDto( + ReimportAcmeCertificateCommand.RequestParamSchema, +) {} + +export class ReimportAcmeCertificateBodyDto extends createZodDto( + ReimportAcmeCertificateCommand.RequestBodySchema, +) {} + +export class ReimportAcmeCertificateResponseDto extends createZodDto( + ReimportAcmeCertificateCommand.ResponseSchema, +) {} + +export class GetAcmeCertificateEventsParamDto extends createZodDto( + GetAcmeCertificateEventsCommand.RequestParamSchema, +) {} + +export class GetAcmeCertificateEventsResponseDto extends createZodDto( + GetAcmeCertificateEventsCommand.ResponseSchema, +) {} + +export class GetAcmePersistRecordParamDto extends createZodDto( + GetAcmePersistRecordCommand.RequestParamSchema, +) {} + +export class GetAcmePersistRecordResponseDto extends createZodDto( + GetAcmePersistRecordCommand.ResponseSchema, +) {} + +export class PublishAcmePersistRecordParamDto extends createZodDto( + PublishAcmePersistRecordCommand.RequestParamSchema, +) {} + +export class PublishAcmePersistRecordResponseDto extends createZodDto( + PublishAcmePersistRecordCommand.ResponseSchema, +) {} diff --git a/src/modules/acme/dtos/index.ts b/src/modules/acme/dtos/index.ts new file mode 100644 index 000000000..0719d5b73 --- /dev/null +++ b/src/modules/acme/dtos/index.ts @@ -0,0 +1 @@ +export * from './acme.dtos'; diff --git a/src/modules/acme/engine/acme-order.service.ts b/src/modules/acme/engine/acme-order.service.ts new file mode 100644 index 000000000..2f46b771a --- /dev/null +++ b/src/modules/acme/engine/acme-order.service.ts @@ -0,0 +1,413 @@ +import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; +import * as acme from 'acme-client'; +import { createHash, X509Certificate } from 'node:crypto'; + +import { Injectable, Logger } from '@nestjs/common'; + +import { + ACME_CERTIFICATE_STATUS, + ACME_CHALLENGE_TYPE, + ACME_EVENT_LEVEL, + ACME_KEY_TYPE, + ACME_RECORD_PREFIX, + TAcmeKeyType, +} from '@libs/contracts/constants'; + +import { AcmeSecretBoxService } from '../crypto/acme-secret-box.service'; +import { AcmeAccountEntity, AcmeCertificateEntity } from '../entities'; +import { AcmeAccountsRepository } from '../repositories/acme-accounts.repository'; +import { AcmeCertificatesRepository } from '../repositories/acme-certificates.repository'; +import { AcmeCredentialsRepository } from '../repositories/acme-credentials.repository'; +import { AcmeEventsRepository } from '../repositories/acme-events.repository'; +import { waitForTxtRecord } from './dns-propagation.util'; +import { + buildPersistRecordName, + buildPersistRecordValue, + resolveIssuerDomain, +} from './persist-record.util'; +import { SolverFactory } from './solvers/solver.factory'; +import { IDnsSolver } from './solvers/solver.interface'; + +/** Retry backoff: doubles per consecutive failure, capped so a broken setup still retries daily. */ +const MAX_RETRY_HOURS = 24; + +interface IPublishedRecord { + fqdn: string; + value: string; +} + +export interface IIssueResult { + /** Nodes that must be restarted to pick up the new certificate. */ + affectedNodeUuids: string[]; + isIssued: boolean; + message: string; +} + +/** + * Runs one ACME order from start to finish. + * + * The low-level acme-client API is used rather than client.auto(), because + * dns-persist-01 has no key authorization to compute and no record to publish: + * the challenge is answered by an authorization record that already exists. + * auto() cannot express that. + */ +@Injectable() +export class AcmeOrderService { + private readonly logger = new Logger(AcmeOrderService.name); + + constructor( + private readonly certificatesRepository: AcmeCertificatesRepository, + private readonly credentialsRepository: AcmeCredentialsRepository, + private readonly accountsRepository: AcmeAccountsRepository, + private readonly eventsRepository: AcmeEventsRepository, + private readonly secretBox: AcmeSecretBoxService, + private readonly solverFactory: SolverFactory, + ) {} + + public async issue(certificateUuid: string, force: boolean): Promise { + const certificate = await this.certificatesRepository.findByUUID(certificateUuid); + + if (!certificate) { + return { isIssued: false, message: 'Certificate not found', affectedNodeUuids: [] }; + } + + if (!certificate.isEnabled && !force) { + return { isIssued: false, message: 'Certificate is disabled', affectedNodeUuids: [] }; + } + + if (!this.secretBox.isConfigured) { + await this.fail(certificate, 'ACME_SECRET_KEY is not configured'); + + return { + isIssued: false, + message: 'ACME_SECRET_KEY is not configured', + affectedNodeUuids: [], + }; + } + + const published: IPublishedRecord[] = []; + let solver: IDnsSolver | null = null; + + try { + const credential = certificate.credentialUuid + ? await this.credentialsRepository.findByUUID(certificate.credentialUuid) + : null; + + if (!credential) { + throw new Error('Certificate has no credential'); + } + + solver = this.solverFactory.create(credential); + + if (certificate.challengeType === ACME_CHALLENGE_TYPE.DNS_01 && !solver.canPublish) { + throw new Error( + `Credential "${credential.name}" cannot publish records, so it cannot answer dns-01. ` + + 'Switch the certificate to dns-persist-01 or pick another credential.', + ); + } + + await this.certificatesRepository.updateResult(certificate.uuid, { + status: ACME_CERTIFICATE_STATUS.ISSUING, + lastError: null, + }); + + const { account, client } = await this.buildClient(certificate); + + const order = await client.createOrder({ + identifiers: certificate.domains.map((domain) => ({ + type: 'dns', + value: domain, + })), + }); + + const authorizations = await client.getAuthorizations(order); + + for (const authorization of authorizations) { + if (authorization.status === 'valid') { + // The CA still remembers a recent validation for this name. + continue; + } + + await this.solveAuthorization( + client, + certificate, + account, + authorization, + solver, + published, + ); + } + + const [key, csr] = await acme.crypto.createCsr( + { + commonName: certificate.domains[0], + altNames: certificate.domains, + }, + await this.createPrivateKey(certificate.keyType), + ); + + // getCertificate must see the order state AFTER finalization. The + // object from createOrder is stale: when the CA reused still-valid + // authorizations, it reads status 'ready' - which acme-client treats + // as "no need to refresh" - and then finds no certificate URL on it. + // Renewals within the authorization lifetime (~30 days at LE) always + // hit that path. + const finalizedOrder = await client.finalizeOrder(order, csr); + const fullchain = await client.getCertificate(finalizedOrder); + + const affectedNodeUuids = await this.store(certificate, fullchain, key.toString()); + + return { + isIssued: true, + message: `Issued certificate for ${certificate.domains.join(', ')}`, + affectedNodeUuids, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + this.logger.error(`Issuance failed for ${certificate.name}: ${message}`); + await this.fail(certificate, message); + + return { isIssued: false, message, affectedNodeUuids: [] }; + } finally { + // Challenge records are cleaned up whether the order succeeded or not: + // leftovers accumulate in the zone and, for a failed order, hint at a + // token that is no longer valid. + if (solver) { + for (const record of published) { + try { + await solver.cleanup(record.fqdn, record.value); + } catch (error) { + // The certificate may already be issued, but a record left + // in the zone is an operator problem: without an event the + // journal shows a clean success and nobody goes looking. + this.logger.warn(`Failed to clean up ${record.fqdn}: ${error}`); + + await this.eventsRepository + .create( + certificate.uuid, + ACME_EVENT_LEVEL.ERROR, + `Failed to remove ${record.fqdn} after the order; delete the TXT record manually`, + ) + .catch(() => {}); + } + } + } + } + } + + /** + * Registers the ACME account if needed and returns a client bound to it. + * Accounts are shared by directory and e-mail: CAs rate-limit registrations, + * and a dns-persist-01 authorization is tied to one account URI. + */ + public async buildClient( + certificate: AcmeCertificateEntity, + ): Promise<{ account: AcmeAccountEntity; client: acme.Client }> { + // Null only for imported certificates, which never reach this code: they + // have no CA behind them and nothing to order. + const { directoryUrl, email } = certificate; + + if (!directoryUrl || !email) { + throw new Error( + 'The certificate has no certificate authority configured, so it cannot be ordered', + ); + } + + let account = await this.accountsRepository.findByDirectoryAndEmail(directoryUrl, email); + + if (!account) { + const accountKey = await acme.crypto.createPrivateEcdsaKey('P-256'); + + try { + account = await this.accountsRepository.create({ + directoryUrl, + email, + accountKeyEncrypted: this.secretBox.encrypt(accountKey.toString()), + eabKid: certificate.eabKid, + }); + } catch (error) { + // Parallel orders race to register the same (directory, email) + // account; the loser takes the winner's row and discards its own + // key. Registering with a shared key is idempotent on the CA side. + if (error instanceof PrismaClientKnownRequestError && error.code === 'P2002') { + account = await this.accountsRepository.findByDirectoryAndEmail( + directoryUrl, + email, + ); + } + + if (!account) { + throw error; + } + } + } + + const client = new acme.Client({ + directoryUrl, + accountKey: this.secretBox.decrypt(account.accountKeyEncrypted), + ...(account.accountUrl ? { accountUrl: account.accountUrl } : {}), + ...(account.eabKid && account.eabHmacEncrypted + ? { + externalAccountBinding: { + kid: account.eabKid, + hmacKey: this.secretBox.decrypt(account.eabHmacEncrypted), + }, + } + : {}), + }); + + if (!account.accountUrl) { + await client.createAccount({ + termsOfServiceAgreed: true, + contact: [`mailto:${certificate.email}`], + }); + + account = await this.accountsRepository.setAccountUrl( + account.uuid, + client.getAccountUrl(), + ); + } + + return { account, client }; + } + + private async solveAuthorization( + client: acme.Client, + certificate: AcmeCertificateEntity, + account: AcmeAccountEntity, + authorization: acme.Authorization, + solver: IDnsSolver, + published: IPublishedRecord[], + ): Promise { + const wantedType = + certificate.challengeType === ACME_CHALLENGE_TYPE.DNS_PERSIST_01 + ? 'dns-persist-01' + : 'dns-01'; + + const challenge = authorization.challenges.find( + (candidate) => candidate.type === wantedType, + ); + + if (!challenge) { + const offered = authorization.challenges.map((c) => c.type).join(', '); + + throw new Error( + `The CA does not offer ${wantedType} for ${authorization.identifier.value} ` + + `(offered: ${offered}). As of 2026 dns-persist-01 is available on staging endpoints only.`, + ); + } + + if (wantedType === 'dns-01') { + const keyAuthorization = await client.getChallengeKeyAuthorization(challenge); + const fqdn = `${ACME_RECORD_PREFIX.DNS_01}.${authorization.identifier.value}`; + + await solver.present(fqdn, keyAuthorization); + published.push({ fqdn, value: keyAuthorization }); + + await this.event( + certificate.uuid, + ACME_EVENT_LEVEL.INFO, + `Published ${fqdn}, waiting for DNS propagation`, + ); + + const isVisible = await waitForTxtRecord(fqdn, keyAuthorization); + + if (!isVisible) { + throw new Error( + `Record ${fqdn} did not become visible on public resolvers in time`, + ); + } + } else { + // Nothing to publish: the authorization record was placed once, and + // the CA reads it directly. All we can do is check it is there, so a + // missing record reports itself instead of surfacing as a validation + // failure from the CA. + const name = buildPersistRecordName(certificate.domains); + const value = buildPersistRecordValue( + resolveIssuerDomain(certificate.directoryUrl ?? ''), + account.accountUrl!, + certificate.domains, + ); + + await this.event( + certificate.uuid, + ACME_EVENT_LEVEL.INFO, + `Using the persistent authorization record ${name} ("${value}")`, + ); + } + + await client.completeChallenge(challenge); + await client.waitForValidStatus(challenge); + } + + private async createPrivateKey(keyType: TAcmeKeyType): Promise { + switch (keyType) { + case ACME_KEY_TYPE.ECDSA_P256: + return acme.crypto.createPrivateEcdsaKey('P-256'); + case ACME_KEY_TYPE.ECDSA_P384: + return acme.crypto.createPrivateEcdsaKey('P-384'); + case ACME_KEY_TYPE.RSA_2048: + return acme.crypto.createPrivateRsaKey(2048); + case ACME_KEY_TYPE.RSA_4096: + return acme.crypto.createPrivateRsaKey(4096); + default: + return acme.crypto.createPrivateEcdsaKey('P-256'); + } + } + + private async store( + certificate: AcmeCertificateEntity, + fullchain: string, + privateKey: string, + ): Promise { + const leaf = new X509Certificate(fullchain); + const fingerprint = createHash('sha256').update(leaf.raw).digest('hex'); + + await this.certificatesRepository.updateResult(certificate.uuid, { + status: ACME_CERTIFICATE_STATUS.ACTIVE, + fullchainPem: fullchain, + keyEncrypted: this.secretBox.encrypt(privateKey), + fingerprint, + issuedAt: new Date(leaf.validFrom), + expiresAt: new Date(leaf.validTo), + lastError: null, + failCount: 0, + nextRetryAt: null, + }); + + await this.event( + certificate.uuid, + ACME_EVENT_LEVEL.INFO, + `Issued certificate valid until ${new Date(leaf.validTo).toISOString()}`, + ); + + return certificate.nodes.map((binding) => binding.nodeUuid); + } + + private async fail(certificate: AcmeCertificateEntity, message: string): Promise { + const failCount = certificate.failCount + 1; + const delayHours = Math.min(2 ** failCount, MAX_RETRY_HOURS); + + await this.certificatesRepository.updateResult(certificate.uuid, { + status: ACME_CERTIFICATE_STATUS.ERROR, + lastError: message, + failCount, + nextRetryAt: new Date(Date.now() + delayHours * 60 * 60 * 1000), + }); + + await this.event(certificate.uuid, ACME_EVENT_LEVEL.ERROR, message); + } + + private async event( + certificateUuid: string, + level: (typeof ACME_EVENT_LEVEL)[keyof typeof ACME_EVENT_LEVEL], + message: string, + ): Promise { + try { + await this.eventsRepository.create(certificateUuid, level, message); + } catch (error) { + this.logger.error(`Failed to record ACME event: ${error}`); + } + } +} diff --git a/src/modules/acme/engine/dns-propagation.util.ts b/src/modules/acme/engine/dns-propagation.util.ts new file mode 100644 index 000000000..fdb8c39d7 --- /dev/null +++ b/src/modules/acme/engine/dns-propagation.util.ts @@ -0,0 +1,78 @@ +import { Resolver } from 'node:dns/promises'; + +/** + * Public resolvers used to check that a record is visible. Asking the local + * resolver would be pointless: it may be the provider's own view, or a cache + * that answers with the record before it exists anywhere else. + */ +const PUBLIC_RESOLVERS = ['1.1.1.1', '8.8.8.8']; + +const DEFAULT_TIMEOUT_MS = 300_000; +const DEFAULT_INTERVAL_MS = 5_000; + +export interface IWaitForTxtOptions { + intervalMs?: number; + timeoutMs?: number; +} + +/** + * Waits until every public resolver returns the expected TXT value. + * + * This exists because the CA validates within seconds of being told to, and a + * provider API returning 200 only means the record was accepted — not that it is + * being served yet. Skipping the wait turns into random validation failures that + * look like the solver is broken. + */ +export async function waitForTxtRecord( + fqdn: string, + expectedValue: string, + options: IWaitForTxtOptions = {}, +): Promise { + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS; + + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const results = await Promise.all( + PUBLIC_RESOLVERS.map((server) => hasTxtValue(server, fqdn, expectedValue)), + ); + + if (results.every(Boolean)) { + return true; + } + + await sleep(intervalMs); + } + + return false; +} + +/** One-shot check, used to tell whether a persistent record is already published. */ +export async function isTxtValuePublished(fqdn: string, expectedValue: string): Promise { + const results = await Promise.all( + PUBLIC_RESOLVERS.map((server) => hasTxtValue(server, fqdn, expectedValue)), + ); + + return results.some(Boolean); +} + +async function hasTxtValue(server: string, fqdn: string, expectedValue: string): Promise { + const resolver = new Resolver({ timeout: 5_000, tries: 2 }); + resolver.setServers([server]); + + try { + const records = await resolver.resolveTxt(fqdn); + + // A TXT record arrives as an array of strings that has to be joined back + // together: long values are split into 255-byte chunks on the wire. + return records.some((chunks) => chunks.join('') === expectedValue); + } catch { + // NXDOMAIN and friends simply mean "not yet". + return false; + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/modules/acme/engine/import-certificate.util.ts b/src/modules/acme/engine/import-certificate.util.ts new file mode 100644 index 000000000..2a60328f6 --- /dev/null +++ b/src/modules/acme/engine/import-certificate.util.ts @@ -0,0 +1,132 @@ +import { createHash, createPrivateKey, X509Certificate } from 'node:crypto'; + +import { ACME_KEY_TYPE, TAcmeKeyType } from '@libs/contracts/constants'; + +export interface IParsedCertificate { + /** SHA-256 of the leaf certificate, hex. */ + fingerprint: string; + /** Every name the certificate covers, taken from SAN (or the common name if it has none). */ + domains: string[]; + expiresAt: Date; + /** Descriptive only: an imported certificate is never re-issued by the panel. */ + keyType: TAcmeKeyType; + /** Already past its notAfter. Importing one is allowed, but it is not silent. */ + isExpired: boolean; + issuedAt: Date; + /** Normalized material, ready to store. */ + fullchainPem: string; + privateKeyPem: string; +} + +/** + * Reads uploaded material and checks it is usable before anything is stored. + * + * The important check is that the key belongs to the certificate: a mismatched + * pair is accepted by every text field in the world and only fails much later, + * on the node, as a TLS handshake error nobody connects back to this import. + */ +export function parseCertificateMaterial( + fullchainInput: string, + privateKeyInput: string, +): IParsedCertificate { + const fullchainPem = normalizePem(fullchainInput); + const privateKeyPem = normalizePem(privateKeyInput); + + let certificate: X509Certificate; + + try { + certificate = new X509Certificate(fullchainPem); + } catch (error) { + throw new Error(`The certificate could not be parsed: ${describe(error)}`); + } + + let privateKey; + + try { + privateKey = createPrivateKey(privateKeyPem); + } catch (error) { + throw new Error( + `The private key could not be parsed: ${describe(error)}. Encrypted keys must be decrypted first.`, + ); + } + + if (!certificate.checkPrivateKey(privateKey)) { + throw new Error('The private key does not match the certificate'); + } + + const domains = readDomains(certificate); + + if (domains.length === 0) { + throw new Error('The certificate carries no domain names'); + } + + const expiresAt = new Date(certificate.validTo); + + return { + domains, + expiresAt, + fingerprint: createHash('sha256').update(certificate.raw).digest('hex'), + fullchainPem, + isExpired: expiresAt.getTime() <= Date.now(), + issuedAt: new Date(certificate.validFrom), + keyType: readKeyType(certificate), + privateKeyPem, + }; +} + +/** + * Files arrive with whatever line endings and trailing whitespace the editor or + * the OS left behind. Xray takes the PEM as an array of lines, so it is + * normalized once here rather than at every place that reads it back. + */ +function normalizePem(input: string): string { + return `${input.replace(/\r\n/g, '\n').trim()}\n`; +} + +function readDomains(certificate: X509Certificate): string[] { + const altNames = certificate.subjectAltName ?? ''; + + const domains = altNames + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.startsWith('DNS:')) + .map((entry) => entry.slice('DNS:'.length).toLowerCase()); + + if (domains.length > 0) { + return [...new Set(domains)]; + } + + // Certificates old enough to have no SAN still exist in private PKIs. An + // empty subject comes back as undefined, not as an empty string. + const commonName = (certificate.subject ?? '') + .split('\n') + .map((line) => line.trim()) + .find((line) => line.startsWith('CN=')) + ?.slice(3) + .toLowerCase(); + + return commonName ? [commonName] : []; +} + +function readKeyType(certificate: X509Certificate): TAcmeKeyType { + const key = certificate.publicKey; + const details = key.asymmetricKeyDetails ?? {}; + + if (key.asymmetricKeyType === 'ec') { + return details.namedCurve === 'secp384r1' + ? ACME_KEY_TYPE.ECDSA_P384 + : ACME_KEY_TYPE.ECDSA_P256; + } + + if (key.asymmetricKeyType === 'rsa') { + return (details.modulusLength ?? 0) >= 4096 + ? ACME_KEY_TYPE.RSA_4096 + : ACME_KEY_TYPE.RSA_2048; + } + + return ACME_KEY_TYPE.ECDSA_P256; +} + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/modules/acme/engine/persist-record.util.ts b/src/modules/acme/engine/persist-record.util.ts new file mode 100644 index 000000000..4784c9443 --- /dev/null +++ b/src/modules/acme/engine/persist-record.util.ts @@ -0,0 +1,113 @@ +import { ACME_DIRECTORY, ACME_RECORD_PREFIX } from '@libs/contracts/constants'; + +/** + * Issuer domain names as they appear in a persistent authorization record. + * + * The authoritative value comes from the challenge object at issuance time; this + * map is what lets the panel show the record before the first order, and staging + * shares the production issuer domain. + */ +const ISSUER_DOMAINS: Record = { + [ACME_DIRECTORY.LETSENCRYPT]: 'letsencrypt.org', + [ACME_DIRECTORY.LETSENCRYPT_STAGING]: 'letsencrypt.org', + [ACME_DIRECTORY.BUYPASS]: 'buypass.com', + [ACME_DIRECTORY.BUYPASS_STAGING]: 'buypass.com', + [ACME_DIRECTORY.GOOGLE]: 'pki.goog', + [ACME_DIRECTORY.GOOGLE_STAGING]: 'pki.goog', + [ACME_DIRECTORY.ZEROSSL]: 'zerossl.com', +}; + +export function resolveIssuerDomain(directoryUrl: string): string { + const known = ISSUER_DOMAINS[directoryUrl]; + + if (known) { + return known; + } + + try { + const host = new URL(directoryUrl).hostname; + + return host.split('.').slice(-2).join('.'); + } catch { + return directoryUrl; + } +} + +/** + * The name every domain of the certificate can be authorized from: their longest + * common suffix. + * + * With policy=wildcard a persistent record covers the name itself, its wildcards + * and its subdomains, so one record at the common suffix serves the whole + * certificate. + */ +export function resolvePersistBaseDomain(domains: string[]): string { + const labelSets = domains.map((domain) => stripWildcard(domain).split('.').reverse()); + + let common: string[] = labelSets[0] ?? []; + + for (const labels of labelSets.slice(1)) { + const shared: string[] = []; + + for (let i = 0; i < Math.min(common.length, labels.length); i++) { + if (common[i] !== labels[i]) { + break; + } + + shared.push(common[i]); + } + + common = shared; + } + + if (common.length < 2) { + throw new Error( + 'Domains of a dns-persist-01 certificate must share a registrable suffix; ' + + 'split them into separate certificates.', + ); + } + + return common.reverse().join('.'); +} + +/** + * The record name. + * + * A wildcard certificate is authorized on its base name: "*.edge.example.com" is + * covered by a record at "_validation-persist.edge.example.com" carrying + * policy=wildcard. Writing the asterisk into the record name produces a name the + * CA never asks for — a mistake that costs an afternoon to spot, because the + * provider happily creates such a record. + */ +export function buildPersistRecordName(domains: string[]): string { + return `${ACME_RECORD_PREFIX.DNS_PERSIST_01}.${resolvePersistBaseDomain(domains)}`; +} + +/** + * The record value: the CA's issuer domain, the account allowed to issue, and + * policy=wildcard whenever the certificate covers anything other than the base + * name itself. + */ +export function buildPersistRecordValue( + issuerDomain: string, + accountUrl: string, + domains: string[], +): string { + const parts = [issuerDomain, `accounturi=${accountUrl}`]; + + if (needsWildcardPolicy(domains)) { + parts.push('policy=wildcard'); + } + + return parts.join('; '); +} + +export function needsWildcardPolicy(domains: string[]): boolean { + const base = resolvePersistBaseDomain(domains); + + return domains.some((domain) => domain.startsWith('*.') || stripWildcard(domain) !== base); +} + +function stripWildcard(domain: string): string { + return domain.replace(/^\*\./, ''); +} diff --git a/src/modules/acme/engine/solvers/cloudflare.solver.ts b/src/modules/acme/engine/solvers/cloudflare.solver.ts new file mode 100644 index 000000000..99cb6a00c --- /dev/null +++ b/src/modules/acme/engine/solvers/cloudflare.solver.ts @@ -0,0 +1,208 @@ +import axios, { AxiosInstance, isAxiosError } from 'axios'; + +import { TAcmeCredentialPayload } from '../../interfaces/credential-payload.interface'; +import { IDnsSolver, IDnsSolverDescription } from './solver.interface'; + +const API_BASE_URL = 'https://api.cloudflare.com/client/v4'; +// Cloudflare has been observed taking 30+ seconds on a single record write; +// 15s produced spurious ERRORs during the production migration. +const REQUEST_TIMEOUT_MS = 60_000; +const RECORD_TTL_SECONDS = 60; + +interface ICloudflareResponse { + errors: { code: number; message: string }[]; + result: T; + success: boolean; +} + +interface ICloudflareZone { + id: string; + name: string; +} + +interface ICloudflareRecord { + content: string; + id: string; + name: string; + type: string; +} + +/** + * Writes challenge records with a Cloudflare token stored in the panel. + * + * This is the convenient option, not the safe one: the token can edit every + * record in its zones, and it lives in a service published to the internet. It + * exists so a small installation can work without running a DNS broker. + */ +export class CloudflareSolver implements IDnsSolver { + public readonly canPublish = true; + + private readonly client: AxiosInstance; + private readonly zoneCache = new Map(); + + constructor(payload: TAcmeCredentialPayload) { + this.client = axios.create({ + baseURL: API_BASE_URL, + timeout: REQUEST_TIMEOUT_MS, + headers: { + Authorization: `Bearer ${payload.apiToken}`, + 'Content-Type': 'application/json', + }, + }); + } + + public async present(fqdn: string, value: string): Promise { + const zoneId = await this.resolveZoneId(fqdn); + const existing = await this.findRecords(zoneId, fqdn, value); + + if (existing.length > 0) { + return; + } + + await this.call('post', `/zones/${zoneId}/dns_records`, { + type: 'TXT', + name: fqdn, + content: value, + ttl: RECORD_TTL_SECONDS, + }); + } + + public async cleanup(fqdn: string, value: string): Promise { + const zoneId = await this.resolveZoneId(fqdn); + const records = await this.findRecords(zoneId, fqdn, value); + + for (const record of records) { + await this.call('delete', `/zones/${zoneId}/dns_records/${record.id}`); + } + } + + public async publishPersist(fqdn: string, value: string): Promise { + const zoneId = await this.resolveZoneId(fqdn); + + // There must be exactly one persistent authorization record per name, so + // every other TXT at that name goes away. + const records = await this.findRecords(zoneId, fqdn); + + for (const record of records) { + if (record.content === value) { + return; + } + + await this.call('delete', `/zones/${zoneId}/dns_records/${record.id}`); + } + + await this.call('post', `/zones/${zoneId}/dns_records`, { + type: 'TXT', + name: fqdn, + content: value, + ttl: RECORD_TTL_SECONDS, + }); + } + + public async describe(): Promise { + try { + const zones = await this.call('get', '/zones?per_page=50'); + + return { + isOk: true, + message: `Token accepted, ${zones.length} zone(s) visible`, + allow: [], + zones: zones.map((zone) => zone.name), + }; + } catch (error) { + return { + isOk: false, + message: this.describeError(error), + allow: [], + zones: [], + }; + } + } + + /** + * Finds the zone owning the name by trying its suffixes from the most + * specific one, so a delegated sub-zone wins over its parent. + */ + private async resolveZoneId(fqdn: string): Promise { + const labels = fqdn.replace(/\.$/, '').split('.'); + + for (let i = 0; i <= labels.length - 2; i++) { + const candidate = labels.slice(i).join('.'); + + const cached = this.zoneCache.get(candidate); + + if (cached) { + return cached; + } + + const zones = await this.call( + 'get', + `/zones?name=${encodeURIComponent(candidate)}`, + ); + + if (zones.length > 0) { + this.zoneCache.set(candidate, zones[0].id); + + return zones[0].id; + } + } + + throw new Error(`Cloudflare: no zone found for ${fqdn}`); + } + + private async findRecords( + zoneId: string, + fqdn: string, + content?: string, + ): Promise { + const query = new URLSearchParams({ type: 'TXT', name: fqdn.replace(/\.$/, '') }); + + if (content) { + query.set('content', content); + } + + return this.call('get', `/zones/${zoneId}/dns_records?${query}`); + } + + private async call( + method: 'delete' | 'get' | 'post', + path: string, + body?: Record, + ): Promise { + try { + const { data } = await this.client.request>({ + method, + url: path, + data: body, + }); + + if (!data.success) { + throw new Error(data.errors.map((e) => `${e.code} ${e.message}`).join('; ')); + } + + return data.result; + } catch (error) { + throw new Error(this.describeError(error)); + } + } + + private describeError(error: unknown): string { + if (isAxiosError(error)) { + const data = error.response?.data as + | undefined + | { errors?: { code: number; message: string }[] }; + + if (data?.errors?.length) { + return `Cloudflare: ${data.errors.map((e) => `${e.code} ${e.message}`).join('; ')}`; + } + + return `Cloudflare: ${error.message}`; + } + + if (error instanceof Error) { + return error.message; + } + + return `Cloudflare: ${String(error)}`; + } +} diff --git a/src/modules/acme/engine/solvers/custom.solver.ts b/src/modules/acme/engine/solvers/custom.solver.ts new file mode 100644 index 000000000..e98061c01 --- /dev/null +++ b/src/modules/acme/engine/solvers/custom.solver.ts @@ -0,0 +1,97 @@ +import axios, { AxiosInstance, isAxiosError } from 'axios'; + +import { IDnsSolver, IDnsSolverDescription } from './solver.interface'; + +// DNS providers have been observed taking 30+ seconds on a single record +// write; 15s produced spurious ERRORs during a production migration. +const REQUEST_TIMEOUT_MS = 60_000; + +interface IPolicyResponse { + allow: string[]; + provider: { name: string; type: string; zones: string[] }; +} + +/** + * A DNS broker speaking the custom-provider HTTP protocol (see docs/acme.md). + * The broker owns the real DNS credential and decides which names may be + * touched; this side only knows a base URL and a client token. + */ +export class CustomSolver implements IDnsSolver { + public readonly canPublish = true; + + private readonly client: AxiosInstance; + + constructor(payload: Record) { + this.client = axios.create({ + baseURL: payload.baseUrl.replace(/\/+$/, ''), + timeout: REQUEST_TIMEOUT_MS, + headers: { + Authorization: `Bearer ${payload.token}`, + 'Content-Type': 'application/json', + }, + }); + } + + public async present(fqdn: string, value: string): Promise { + await this.request('post', '/v1/dns-01/present', { fqdn, value }); + } + + public async cleanup(fqdn: string, value: string): Promise { + await this.request('post', '/v1/dns-01/cleanup', { fqdn, value }); + } + + public async publishPersist(fqdn: string, value: string): Promise { + await this.request('put', '/v1/persist', { fqdn, value }); + } + + public async describe(): Promise { + try { + const { data } = await this.client.get('/v1/policy'); + + return { + isOk: true, + message: `Endpoint reachable, provider "${data.provider.name}" (${data.provider.type})`, + allow: data.allow ?? [], + zones: data.provider?.zones ?? [], + }; + } catch (error) { + return { + isOk: false, + message: this.describeError(error), + allow: [], + zones: [], + }; + } + } + + private async request( + method: 'post' | 'put', + path: string, + body: Record, + ): Promise { + try { + await this.client.request({ method, url: path, data: body }); + } catch (error) { + throw new Error(this.describeError(error)); + } + } + + /** + * The protocol answers with a machine code and a message; surfacing both + * makes "the domain is not in the allow list" readable in the certificate + * log instead of a bare 403. + */ + private describeError(error: unknown): string { + if (isAxiosError(error)) { + const data = error.response?.data as undefined | { error?: string; message?: string }; + + if (data?.error) { + return `dns-api: ${data.error}: ${data.message ?? ''}`.trim(); + } + + return `dns-api: ${error.message}`; + } + + return `dns-api: ${String(error)}`; + } +} diff --git a/src/modules/acme/engine/solvers/manual.solver.ts b/src/modules/acme/engine/solvers/manual.solver.ts new file mode 100644 index 000000000..0edf18211 --- /dev/null +++ b/src/modules/acme/engine/solvers/manual.solver.ts @@ -0,0 +1,38 @@ +import { IDnsSolver, IDnsSolverDescription } from './solver.interface'; + +/** + * A credential with no automation behind it. + * + * It pairs with dns-persist-01, where one record is published by hand and every + * issuance afterwards needs no DNS access. It cannot serve dns-01: that + * challenge needs a fresh record within minutes of each order, and pretending + * otherwise would only produce certificates that quietly stop renewing. + */ +export class ManualSolver implements IDnsSolver { + public readonly canPublish = false; + + public async present(): Promise { + throw new Error( + 'Manual credentials cannot answer dns-01 challenges. Use dns-persist-01, or a credential that can publish records.', + ); + } + + public async cleanup(): Promise { + // Nothing was published, so there is nothing to take back. + } + + public async publishPersist(): Promise { + throw new Error( + 'Manual credentials cannot publish records. Copy the record from the panel and add it to your DNS zone.', + ); + } + + public async describe(): Promise { + return { + isOk: true, + message: 'Manual credential: records are published by the operator', + allow: [], + zones: [], + }; + } +} diff --git a/src/modules/acme/engine/solvers/providers/desec.solver.ts b/src/modules/acme/engine/solvers/providers/desec.solver.ts new file mode 100644 index 000000000..f7a0ead58 --- /dev/null +++ b/src/modules/acme/engine/solvers/providers/desec.solver.ts @@ -0,0 +1,67 @@ +import axios, { AxiosInstance, isAxiosError } from 'axios'; + +import { IZoneRef, ZoneRRSetSolver } from '../zone-solver.base'; + +const REQUEST_TIMEOUT_MS = 60_000; + +/** + * deSEC serves TXT contents quoted and refuses TTLs below the domain minimum + * (3600 unless lowered by support). + */ +function quote(value: string): string { + return `"${value}"`; +} + +function unquote(value: string): string { + return value.replace(/^"|"$/g, ''); +} + +export class DesecSolver extends ZoneRRSetSolver { + protected readonly label = 'desec'; + + private readonly http: AxiosInstance; + + constructor(payload: Record) { + super(); + + this.http = axios.create({ + baseURL: 'https://desec.io/api/v1', + timeout: REQUEST_TIMEOUT_MS, + headers: { Authorization: `Token ${payload.apiToken}` }, + }); + } + + protected async listZones(): Promise { + const { data } = await this.http.get('/domains/'); + + return (data ?? []).map((domain: { name: string }) => ({ + id: domain.name, + name: domain.name, + })); + } + + protected async getTxtValues(zone: IZoneRef, name: string): Promise { + try { + const { data } = await this.http.get(`/domains/${zone.id}/rrsets/${name}/TXT/`); + + return (data.records ?? []).map(unquote); + } catch (error) { + if (isAxiosError(error) && error.response?.status === 404) { + return []; + } + + throw error; + } + } + + protected async putTxtValues(zone: IZoneRef, name: string, values: string[]): Promise { + // An empty records list deletes the rrset - exactly the semantics the + // base class expects. + await this.http.put(`/domains/${zone.id}/rrsets/${name}/TXT/`, { + subname: name, + type: 'TXT', + ttl: 3600, + records: values.map(quote), + }); + } +} diff --git a/src/modules/acme/engine/solvers/providers/digitalocean.solver.ts b/src/modules/acme/engine/solvers/providers/digitalocean.solver.ts new file mode 100644 index 000000000..705d4a5d1 --- /dev/null +++ b/src/modules/acme/engine/solvers/providers/digitalocean.solver.ts @@ -0,0 +1,60 @@ +import axios, { AxiosInstance } from 'axios'; + +import { ITxtRecord, IZoneRef, ZoneRecordSolver } from '../zone-solver.base'; + +const REQUEST_TIMEOUT_MS = 60_000; + +interface IDoRecord { + data: string; + id: number; + name: string; + type: string; +} + +export class DigitalOceanSolver extends ZoneRecordSolver { + protected readonly label = 'digitalocean'; + + private readonly http: AxiosInstance; + + constructor(payload: Record) { + super(); + + this.http = axios.create({ + baseURL: 'https://api.digitalocean.com/v2', + timeout: REQUEST_TIMEOUT_MS, + headers: { Authorization: `Bearer ${payload.apiToken}` }, + }); + } + + protected async listZones(): Promise { + const { data } = await this.http.get('/domains', { params: { per_page: 200 } }); + + return (data.domains ?? []).map((domain: { name: string }) => ({ + id: domain.name, + name: domain.name, + })); + } + + protected async listTxt(zone: IZoneRef, name: string): Promise { + const { data } = await this.http.get(`/domains/${zone.id}/records`, { + params: { type: 'TXT', per_page: 200 }, + }); + + return (data.domain_records ?? []) + .filter((record: IDoRecord) => record.type === 'TXT' && record.name === name) + .map((record: IDoRecord) => ({ id: String(record.id), value: record.data })); + } + + protected async createTxt(zone: IZoneRef, name: string, value: string): Promise { + await this.http.post(`/domains/${zone.id}/records`, { + type: 'TXT', + name, + data: value, + ttl: 60, + }); + } + + protected async deleteTxt(zone: IZoneRef, record: ITxtRecord): Promise { + await this.http.delete(`/domains/${zone.id}/records/${record.id}`); + } +} diff --git a/src/modules/acme/engine/solvers/providers/gandi.solver.ts b/src/modules/acme/engine/solvers/providers/gandi.solver.ts new file mode 100644 index 000000000..a15c061e9 --- /dev/null +++ b/src/modules/acme/engine/solvers/providers/gandi.solver.ts @@ -0,0 +1,65 @@ +import axios, { AxiosInstance, isAxiosError } from 'axios'; + +import { IZoneRef, ZoneRRSetSolver } from '../zone-solver.base'; + +const REQUEST_TIMEOUT_MS = 60_000; + +/** LiveDNS keeps TXT values quoted; its minimum TTL is 300. */ +function quote(value: string): string { + return `"${value}"`; +} + +function unquote(value: string): string { + return value.replace(/^"|"$/g, ''); +} + +export class GandiSolver extends ZoneRRSetSolver { + protected readonly label = 'gandi'; + + private readonly http: AxiosInstance; + + constructor(payload: Record) { + super(); + + this.http = axios.create({ + baseURL: 'https://api.gandi.net/v5/livedns', + timeout: REQUEST_TIMEOUT_MS, + headers: { Authorization: `Bearer ${payload.apiToken}` }, + }); + } + + protected async listZones(): Promise { + const { data } = await this.http.get('/domains'); + + return (data ?? []).map((domain: { fqdn: string }) => ({ + id: domain.fqdn, + name: domain.fqdn, + })); + } + + protected async getTxtValues(zone: IZoneRef, name: string): Promise { + try { + const { data } = await this.http.get(`/domains/${zone.id}/records/${name}/TXT`); + + return (data.rrset_values ?? []).map(unquote); + } catch (error) { + if (isAxiosError(error) && error.response?.status === 404) { + return []; + } + + throw error; + } + } + + protected async putTxtValues(zone: IZoneRef, name: string, values: string[]): Promise { + if (values.length === 0) { + await this.http.delete(`/domains/${zone.id}/records/${name}/TXT`); + return; + } + + await this.http.put(`/domains/${zone.id}/records/${name}/TXT`, { + rrset_values: values.map(quote), + rrset_ttl: 300, + }); + } +} diff --git a/src/modules/acme/engine/solvers/providers/hetzner.solver.ts b/src/modules/acme/engine/solvers/providers/hetzner.solver.ts new file mode 100644 index 000000000..23da77726 --- /dev/null +++ b/src/modules/acme/engine/solvers/providers/hetzner.solver.ts @@ -0,0 +1,59 @@ +import axios, { AxiosInstance } from 'axios'; + +import { ITxtRecord, IZoneRef, ZoneRecordSolver } from '../zone-solver.base'; + +const REQUEST_TIMEOUT_MS = 60_000; + +interface IHetznerRecord { + id: string; + name: string; + type: string; + value: string; +} + +export class HetznerSolver extends ZoneRecordSolver { + protected readonly label = 'hetzner'; + + private readonly http: AxiosInstance; + + constructor(payload: Record) { + super(); + + this.http = axios.create({ + baseURL: 'https://dns.hetzner.com/api/v1', + timeout: REQUEST_TIMEOUT_MS, + headers: { 'Auth-API-Token': payload.apiToken }, + }); + } + + protected async listZones(): Promise { + const { data } = await this.http.get('/zones', { params: { per_page: 100 } }); + + return (data.zones ?? []).map((zone: { id: string; name: string }) => ({ + id: zone.id, + name: zone.name, + })); + } + + protected async listTxt(zone: IZoneRef, name: string): Promise { + const { data } = await this.http.get('/records', { params: { zone_id: zone.id } }); + + return (data.records ?? []) + .filter((record: IHetznerRecord) => record.type === 'TXT' && record.name === name) + .map((record: IHetznerRecord) => ({ id: record.id, value: record.value })); + } + + protected async createTxt(zone: IZoneRef, name: string, value: string): Promise { + await this.http.post('/records', { + zone_id: zone.id, + type: 'TXT', + name, + value, + ttl: 60, + }); + } + + protected async deleteTxt(_zone: IZoneRef, record: ITxtRecord): Promise { + await this.http.delete(`/records/${record.id}`); + } +} diff --git a/src/modules/acme/engine/solvers/providers/porkbun.solver.ts b/src/modules/acme/engine/solvers/providers/porkbun.solver.ts new file mode 100644 index 000000000..ff84d1734 --- /dev/null +++ b/src/modules/acme/engine/solvers/providers/porkbun.solver.ts @@ -0,0 +1,68 @@ +import axios, { AxiosInstance } from 'axios'; + +import { ITxtRecord, IZoneRef, ZoneRecordSolver } from '../zone-solver.base'; + +const REQUEST_TIMEOUT_MS = 60_000; + +interface IPorkbunRecord { + content: string; + id: string; + name: string; + type: string; +} + +/** + * Porkbun authenticates with both keys in every request body, and its minimum + * TTL is 600. + */ +export class PorkbunSolver extends ZoneRecordSolver { + protected readonly label = 'porkbun'; + + private readonly http: AxiosInstance; + private readonly auth: { apikey: string; secretapikey: string }; + + constructor(payload: Record) { + super(); + + this.auth = { apikey: payload.apiKey, secretapikey: payload.secretApiKey }; + this.http = axios.create({ + baseURL: 'https://api.porkbun.com/api/json/v3', + timeout: REQUEST_TIMEOUT_MS, + }); + } + + protected async listZones(): Promise { + const { data } = await this.http.post('/domain/listAll', this.auth); + + return (data.domains ?? []).map((domain: { domain: string }) => ({ + id: domain.domain, + name: domain.domain, + })); + } + + protected async listTxt(zone: IZoneRef, name: string): Promise { + const { data } = await this.http.post( + `/dns/retrieveByNameType/${zone.id}/TXT/${name}`, + this.auth, + ); + + return (data.records ?? []).map((record: IPorkbunRecord) => ({ + id: record.id, + value: record.content, + })); + } + + protected async createTxt(zone: IZoneRef, name: string, value: string): Promise { + await this.http.post(`/dns/create/${zone.id}`, { + ...this.auth, + type: 'TXT', + name, + content: value, + ttl: '600', + }); + } + + protected async deleteTxt(zone: IZoneRef, record: ITxtRecord): Promise { + await this.http.post(`/dns/delete/${zone.id}/${record.id}`, this.auth); + } +} diff --git a/src/modules/acme/engine/solvers/providers/powerdns.solver.ts b/src/modules/acme/engine/solvers/providers/powerdns.solver.ts new file mode 100644 index 000000000..a0897d676 --- /dev/null +++ b/src/modules/acme/engine/solvers/providers/powerdns.solver.ts @@ -0,0 +1,79 @@ +import axios, { AxiosInstance } from 'axios'; + +import { IZoneRef, ZoneRRSetSolver } from '../zone-solver.base'; + +const REQUEST_TIMEOUT_MS = 60_000; + +/** The PowerDNS API speaks canonical names (trailing dot) and quoted TXT. */ +function quote(value: string): string { + return `"${value}"`; +} + +function unquote(value: string): string { + return value.replace(/^"|"$/g, ''); +} + +interface IPdnsRRSet { + name: string; + records: { content: string }[]; + type: string; +} + +export class PowerDnsSolver extends ZoneRRSetSolver { + protected readonly label = 'powerdns'; + + private readonly http: AxiosInstance; + private readonly serverId: string; + + constructor(payload: Record) { + super(); + + this.serverId = payload.serverId || 'localhost'; + this.http = axios.create({ + baseURL: `${payload.baseUrl.replace(/\/+$/, '')}/api/v1`, + timeout: REQUEST_TIMEOUT_MS, + headers: { 'X-API-Key': payload.apiKey }, + }); + } + + protected async listZones(): Promise { + const { data } = await this.http.get(`/servers/${this.serverId}/zones`); + + return (data ?? []).map((zone: { id: string; name: string }) => ({ + id: zone.id, + name: zone.name.replace(/\.$/, ''), + })); + } + + protected async getTxtValues(zone: IZoneRef, name: string): Promise { + const { data } = await this.http.get(`/servers/${this.serverId}/zones/${zone.id}`); + const canonical = `${name}.${zone.name}.`; + + const rrset = (data.rrsets ?? []).find( + (set: IPdnsRRSet) => set.type === 'TXT' && set.name === canonical, + ); + + return (rrset?.records ?? []).map((record: { content: string }) => unquote(record.content)); + } + + protected async putTxtValues(zone: IZoneRef, name: string, values: string[]): Promise { + const canonical = `${name}.${zone.name}.`; + + await this.http.patch(`/servers/${this.serverId}/zones/${zone.id}`, { + rrsets: [ + values.length === 0 + ? { name: canonical, type: 'TXT', changetype: 'DELETE' } + : { + name: canonical, + type: 'TXT', + ttl: 60, + changetype: 'REPLACE', + records: values.map((value) => ({ + content: quote(value), + disabled: false, + })), + }, + ], + }); + } +} diff --git a/src/modules/acme/engine/solvers/providers/vultr.solver.ts b/src/modules/acme/engine/solvers/providers/vultr.solver.ts new file mode 100644 index 000000000..1f9b54c6c --- /dev/null +++ b/src/modules/acme/engine/solvers/providers/vultr.solver.ts @@ -0,0 +1,69 @@ +import axios, { AxiosInstance } from 'axios'; + +import { ITxtRecord, IZoneRef, ZoneRecordSolver } from '../zone-solver.base'; + +const REQUEST_TIMEOUT_MS = 60_000; + +interface IVultrRecord { + data: string; + id: string; + name: string; + type: string; +} + +/** Vultr stores TXT data with surrounding quotes; keep them out of our values. */ +function quote(value: string): string { + return `"${value}"`; +} + +function unquote(data: string): string { + return data.replace(/^"|"$/g, ''); +} + +export class VultrSolver extends ZoneRecordSolver { + protected readonly label = 'vultr'; + + private readonly http: AxiosInstance; + + constructor(payload: Record) { + super(); + + this.http = axios.create({ + baseURL: 'https://api.vultr.com/v2', + timeout: REQUEST_TIMEOUT_MS, + headers: { Authorization: `Bearer ${payload.apiToken}` }, + }); + } + + protected async listZones(): Promise { + const { data } = await this.http.get('/domains', { params: { per_page: 500 } }); + + return (data.domains ?? []).map((domain: { domain: string }) => ({ + id: domain.domain, + name: domain.domain, + })); + } + + protected async listTxt(zone: IZoneRef, name: string): Promise { + const { data } = await this.http.get(`/domains/${zone.id}/records`, { + params: { per_page: 500 }, + }); + + return (data.records ?? []) + .filter((record: IVultrRecord) => record.type === 'TXT' && record.name === name) + .map((record: IVultrRecord) => ({ id: record.id, value: unquote(record.data) })); + } + + protected async createTxt(zone: IZoneRef, name: string, value: string): Promise { + await this.http.post(`/domains/${zone.id}/records`, { + type: 'TXT', + name, + data: quote(value), + ttl: 120, + }); + } + + protected async deleteTxt(zone: IZoneRef, record: ITxtRecord): Promise { + await this.http.delete(`/domains/${zone.id}/records/${record.id}`); + } +} diff --git a/src/modules/acme/engine/solvers/solver.factory.ts b/src/modules/acme/engine/solvers/solver.factory.ts new file mode 100644 index 000000000..49fc01d4a --- /dev/null +++ b/src/modules/acme/engine/solvers/solver.factory.ts @@ -0,0 +1,58 @@ +import { Injectable } from '@nestjs/common'; + +import { ACME_PROVIDER } from '@libs/contracts/constants'; + +import { AcmeSecretBoxService } from '../../crypto/acme-secret-box.service'; +import { AcmeCredentialEntity } from '../../entities'; +import { TAcmeCredentialPayload } from '../../interfaces/credential-payload.interface'; +import { CloudflareSolver } from './cloudflare.solver'; +import { CustomSolver } from './custom.solver'; +import { ManualSolver } from './manual.solver'; +import { DesecSolver } from './providers/desec.solver'; +import { DigitalOceanSolver } from './providers/digitalocean.solver'; +import { GandiSolver } from './providers/gandi.solver'; +import { HetznerSolver } from './providers/hetzner.solver'; +import { PorkbunSolver } from './providers/porkbun.solver'; +import { PowerDnsSolver } from './providers/powerdns.solver'; +import { VultrSolver } from './providers/vultr.solver'; +import { IDnsSolver } from './solver.interface'; + +@Injectable() +export class SolverFactory { + constructor(private readonly secretBox: AcmeSecretBoxService) {} + + public create(credential: AcmeCredentialEntity): IDnsSolver { + switch (credential.provider) { + case ACME_PROVIDER.CLOUDFLARE: + return new CloudflareSolver(this.readPayload(credential)); + case ACME_PROVIDER.CUSTOM: + return new CustomSolver(this.readPayload(credential)); + case ACME_PROVIDER.DESEC: + return new DesecSolver(this.readPayload(credential)); + case ACME_PROVIDER.DIGITALOCEAN: + return new DigitalOceanSolver(this.readPayload(credential)); + case ACME_PROVIDER.GANDI: + return new GandiSolver(this.readPayload(credential)); + case ACME_PROVIDER.HETZNER: + return new HetznerSolver(this.readPayload(credential)); + case ACME_PROVIDER.MANUAL: + return new ManualSolver(); + case ACME_PROVIDER.PORKBUN: + return new PorkbunSolver(this.readPayload(credential)); + case ACME_PROVIDER.POWERDNS: + return new PowerDnsSolver(this.readPayload(credential)); + case ACME_PROVIDER.VULTR: + return new VultrSolver(this.readPayload(credential)); + default: + throw new Error(`Unsupported ACME credential provider: ${credential.provider}`); + } + } + + private readPayload(credential: AcmeCredentialEntity): TAcmeCredentialPayload { + if (!credential.payloadEncrypted) { + throw new Error(`Credential "${credential.name}" has no stored secret`); + } + + return this.secretBox.decryptJson(credential.payloadEncrypted); + } +} diff --git a/src/modules/acme/engine/solvers/solver.interface.ts b/src/modules/acme/engine/solvers/solver.interface.ts new file mode 100644 index 000000000..57468358a --- /dev/null +++ b/src/modules/acme/engine/solvers/solver.interface.ts @@ -0,0 +1,35 @@ +/** + * What the issuance flow needs from a credential: a way to put a TXT record into + * DNS and take it away again. + * + * The panel never talks to a DNS provider directly unless the operator chose to + * store a provider token in it; with CUSTOM (broker) credentials the record is + * published by the proxy, and the panel holds only a scoped client token. + */ +export interface IDnsSolver { + /** + * Whether this solver can write to DNS at all. MANUAL cannot: it exists for + * dns-persist-01, where a single record is published by hand and issuance + * needs no DNS access afterwards. + */ + readonly canPublish: boolean; + + /** Publish a DNS-01 challenge record. Must be additive: several TXT values may share a name. */ + present(fqdn: string, value: string): Promise; + + /** Remove a challenge record. Removing what is already gone must not fail. */ + cleanup(fqdn: string, value: string): Promise; + + /** Upsert the persistent authorization record of dns-persist-01. */ + publishPersist(fqdn: string, value: string): Promise; + + /** What this credential is allowed to do; shown by the "test" action. */ + describe(): Promise; +} + +export interface IDnsSolverDescription { + allow: string[]; + isOk: boolean; + message: string; + zones: string[]; +} diff --git a/src/modules/acme/engine/solvers/zone-solver.base.ts b/src/modules/acme/engine/solvers/zone-solver.base.ts new file mode 100644 index 000000000..16c4a952e --- /dev/null +++ b/src/modules/acme/engine/solvers/zone-solver.base.ts @@ -0,0 +1,207 @@ +import { AxiosError } from 'axios'; + +import { IDnsSolver, IDnsSolverDescription } from './solver.interface'; + +/** + * Shared machinery for providers that organize records under a zone: find the + * zone that owns the FQDN by the longest suffix, then let the concrete solver + * talk to its API in terms of (zone, relative name). + * + * Two families cover every provider here: + * - ZoneRecordSolver for per-record APIs (each TXT value is its own object + * with an id); + * - ZoneRRSetSolver for rrset APIs (all TXT values under one name form a + * single set that is replaced atomically). + */ + +export interface IZoneRef { + /** Provider-side identifier used in record calls (often equals name). */ + id: string; + /** The zone name, no trailing dot. */ + name: string; +} + +export interface ITxtRecord { + id: string; + value: string; +} + +/** The label part of the FQDN inside the zone, e.g. "_acme-challenge.svc". */ +export function relativeName(fqdn: string, zone: string): string { + return fqdn.slice(0, fqdn.length - zone.length - 1); +} + +/** Reshape an axios failure into ": ". */ +export function describeHttpError(label: string, error: unknown): Error { + if (error instanceof AxiosError) { + const status = error.response?.status; + const body = error.response?.data; + const detail = + typeof body === 'string' + ? body.slice(0, 300) + : JSON.stringify(body ?? error.message).slice(0, 300); + + return new Error(`${label}: ${status ?? ''} ${detail}`.trim()); + } + + return new Error(`${label}: ${String(error)}`); +} + +abstract class ZoneSolverBase implements IDnsSolver { + public readonly canPublish = true; + + protected abstract readonly label: string; + + public abstract present(fqdn: string, value: string): Promise; + public abstract cleanup(fqdn: string, value: string): Promise; + public abstract publishPersist(fqdn: string, value: string): Promise; + + protected abstract listZones(): Promise; + + public async describe(): Promise { + try { + const zones = await this.listZones(); + + return { + allow: [], + isOk: true, + message: `${this.label}: credential is valid`, + zones: zones.map((zone) => zone.name), + }; + } catch (error) { + return { + allow: [], + isOk: false, + message: describeHttpError(this.label, error).message, + zones: [], + }; + } + } + + /** The registered zone owning the FQDN, by longest suffix match. */ + protected async findZone(fqdn: string): Promise { + const zones = await this.listZones(); + const needle = fqdn.toLowerCase(); + + let best: IZoneRef | null = null; + + for (const zone of zones) { + const name = zone.name.toLowerCase(); + + if ( + (needle === name || needle.endsWith(`.${name}`)) && + (!best || name.length > best.name.length) + ) { + best = zone; + } + } + + if (!best) { + throw new Error(`${this.label}: no zone matches ${fqdn}`); + } + + return best; + } +} + +/** Providers where every TXT value is a separate record object with an id. */ +export abstract class ZoneRecordSolver extends ZoneSolverBase { + protected abstract listTxt(zone: IZoneRef, name: string): Promise; + protected abstract createTxt(zone: IZoneRef, name: string, value: string): Promise; + protected abstract deleteTxt(zone: IZoneRef, record: ITxtRecord): Promise; + + public async present(fqdn: string, value: string): Promise { + try { + const zone = await this.findZone(fqdn); + const name = relativeName(fqdn, zone.name); + const existing = await this.listTxt(zone, name); + + // Idempotent: re-presenting the same pair must not duplicate it. + if (existing.some((record) => record.value === value)) { + return; + } + + await this.createTxt(zone, name, value); + } catch (error) { + throw describeHttpError(this.label, error); + } + } + + public async cleanup(fqdn: string, value: string): Promise { + try { + const zone = await this.findZone(fqdn); + const name = relativeName(fqdn, zone.name); + const records = await this.listTxt(zone, name); + + for (const record of records) { + if (record.value === value) { + await this.deleteTxt(zone, record); + } + } + } catch (error) { + throw describeHttpError(this.label, error); + } + } + + public async publishPersist(fqdn: string, value: string): Promise { + try { + const zone = await this.findZone(fqdn); + const name = relativeName(fqdn, zone.name); + + // The persist record is one-per-name: replace whatever is there. + for (const record of await this.listTxt(zone, name)) { + await this.deleteTxt(zone, record); + } + + await this.createTxt(zone, name, value); + } catch (error) { + throw describeHttpError(this.label, error); + } + } +} + +/** Providers where all TXT values under one name are a single replaceable set. */ +export abstract class ZoneRRSetSolver extends ZoneSolverBase { + protected abstract getTxtValues(zone: IZoneRef, name: string): Promise; + /** An empty list must remove the record set entirely. */ + protected abstract putTxtValues(zone: IZoneRef, name: string, values: string[]): Promise; + + public async present(fqdn: string, value: string): Promise { + try { + const zone = await this.findZone(fqdn); + const name = relativeName(fqdn, zone.name); + const values = await this.getTxtValues(zone, name); + + if (!values.includes(value)) { + await this.putTxtValues(zone, name, [...values, value]); + } + } catch (error) { + throw describeHttpError(this.label, error); + } + } + + public async cleanup(fqdn: string, value: string): Promise { + try { + const zone = await this.findZone(fqdn); + const name = relativeName(fqdn, zone.name); + const values = await this.getTxtValues(zone, name); + const kept = values.filter((existing) => existing !== value); + + if (kept.length !== values.length) { + await this.putTxtValues(zone, name, kept); + } + } catch (error) { + throw describeHttpError(this.label, error); + } + } + + public async publishPersist(fqdn: string, value: string): Promise { + try { + const zone = await this.findZone(fqdn); + + await this.putTxtValues(zone, relativeName(fqdn, zone.name), [value]); + } catch (error) { + throw describeHttpError(this.label, error); + } + } +} diff --git a/src/modules/acme/entities/acme-account.entity.ts b/src/modules/acme/entities/acme-account.entity.ts new file mode 100644 index 000000000..858cf9cbe --- /dev/null +++ b/src/modules/acme/entities/acme-account.entity.ts @@ -0,0 +1,21 @@ +import { AcmeAccounts } from '@prisma/client'; + +export class AcmeAccountEntity implements AcmeAccounts { + public uuid: string; + public directoryUrl: string; + public email: string; + public accountUrl: null | string; + + public accountKeyEncrypted: string; + public eabKid: null | string; + public eabHmacEncrypted: null | string; + + public createdAt: Date; + public updatedAt: Date; + + constructor(account: Partial) { + Object.assign(this, account); + + return this; + } +} diff --git a/src/modules/acme/entities/acme-certificate.entity.ts b/src/modules/acme/entities/acme-certificate.entity.ts new file mode 100644 index 000000000..b746fcf10 --- /dev/null +++ b/src/modules/acme/entities/acme-certificate.entity.ts @@ -0,0 +1,83 @@ +import { AcmeCertificateNodes, AcmeCertificates } from '@prisma/client'; + +import { + TAcmeCertificateSource, + TAcmeCertificateStatus, + TAcmeChallengeType, + TAcmeKeyType, +} from '@libs/contracts/constants'; + +export class AcmeCertificateNodeEntity implements AcmeCertificateNodes { + public certificateUuid: string; + public nodeUuid: string; + public inboundTags: string[]; + + /** Filled in when the node was joined; the UI shows names, not uuids. */ + public nodeName: null | string; + + constructor(binding: Partial & { nodeName?: null | string }) { + Object.assign(this, binding); + + this.nodeName = binding.nodeName ?? null; + + return this; + } +} + +export class AcmeCertificateEntity implements AcmeCertificates { + public uuid: string; + public name: string; + public domains: string[]; + + public source: TAcmeCertificateSource; + + public challengeType: TAcmeChallengeType; + public keyType: TAcmeKeyType; + public renewBeforeDays: number; + public isEnabled: boolean; + + public directoryUrl: null | string; + public email: null | string; + public eabKid: null | string; + + public status: TAcmeCertificateStatus; + public lastError: null | string; + public issuedAt: Date | null; + public expiresAt: Date | null; + public fingerprint: null | string; + public failCount: number; + public nextRetryAt: Date | null; + + public fullchainPem: null | string; + public keyEncrypted: null | string; + + public credentialUuid: null | string; + public accountUuid: null | string; + + public createdAt: Date; + public updatedAt: Date; + + public nodes: AcmeCertificateNodeEntity[]; + public credentialName: null | string; + + constructor( + certificate: Partial & { + credential?: { name: string } | null; + nodes?: (AcmeCertificateNodes & { node?: { name: string } | null })[]; + }, + ) { + Object.assign(this, certificate); + + this.nodes = (certificate.nodes ?? []).map( + (binding) => + new AcmeCertificateNodeEntity({ + ...binding, + nodeName: binding.node?.name ?? null, + }), + ); + + this.credentialName = certificate.credential?.name ?? null; + + return this; + } +} diff --git a/src/modules/acme/entities/acme-credential.entity.ts b/src/modules/acme/entities/acme-credential.entity.ts new file mode 100644 index 000000000..93b00c040 --- /dev/null +++ b/src/modules/acme/entities/acme-credential.entity.ts @@ -0,0 +1,24 @@ +import { AcmeCredentials } from '@prisma/client'; + +import { TAcmeProvider } from '@libs/contracts/constants'; + +export class AcmeCredentialEntity implements AcmeCredentials { + public uuid: string; + public name: string; + public provider: TAcmeProvider; + public payloadEncrypted: null | string; + + public createdAt: Date; + public updatedAt: Date; + + /** How many certificates use this credential; a credential in use cannot be deleted. */ + public certificatesCount: number; + + constructor(credential: Partial & { certificatesCount?: number }) { + Object.assign(this, credential); + + this.certificatesCount = credential.certificatesCount ?? 0; + + return this; + } +} diff --git a/src/modules/acme/entities/acme-event.entity.ts b/src/modules/acme/entities/acme-event.entity.ts new file mode 100644 index 000000000..66b393fd0 --- /dev/null +++ b/src/modules/acme/entities/acme-event.entity.ts @@ -0,0 +1,17 @@ +import { AcmeEvents } from '@prisma/client'; + +import { TAcmeEventLevel } from '@libs/contracts/constants'; + +export class AcmeEventEntity implements AcmeEvents { + public id: bigint; + public certificateUuid: null | string; + public level: TAcmeEventLevel; + public message: string; + public createdAt: Date; + + constructor(event: Partial) { + Object.assign(this, event); + + return this; + } +} diff --git a/src/modules/acme/entities/index.ts b/src/modules/acme/entities/index.ts new file mode 100644 index 000000000..c26567d34 --- /dev/null +++ b/src/modules/acme/entities/index.ts @@ -0,0 +1,4 @@ +export * from './acme-account.entity'; +export * from './acme-certificate.entity'; +export * from './acme-credential.entity'; +export * from './acme-event.entity'; diff --git a/src/modules/acme/index.ts b/src/modules/acme/index.ts new file mode 100644 index 000000000..c401a2615 --- /dev/null +++ b/src/modules/acme/index.ts @@ -0,0 +1,4 @@ +export * from './acme.module'; +export * from './crypto/acme-secret-box.service'; +export * from './entities'; +export * from './repositories/acme-certificates.repository'; diff --git a/src/modules/acme/interfaces/credential-payload.interface.ts b/src/modules/acme/interfaces/credential-payload.interface.ts new file mode 100644 index 000000000..1f97a7fb6 --- /dev/null +++ b/src/modules/acme/interfaces/credential-payload.interface.ts @@ -0,0 +1,6 @@ +/** + * What is stored, encrypted, in acme_credentials.payload_encrypted: the + * provider fields from ACME_PROVIDER_REGISTRY, secret and plain alike, as one + * flat map. MANUAL stores nothing. + */ +export type TAcmeCredentialPayload = Record; diff --git a/src/modules/acme/models/acme-certificate.response.model.ts b/src/modules/acme/models/acme-certificate.response.model.ts new file mode 100644 index 000000000..c057e480e --- /dev/null +++ b/src/modules/acme/models/acme-certificate.response.model.ts @@ -0,0 +1,93 @@ +import { + TAcmeCertificateSource, + TAcmeCertificateStatus, + TAcmeChallengeType, + TAcmeKeyType, +} from '@libs/contracts/constants'; + +import { AcmeCertificateEntity } from '../entities'; + +/** + * A certificate as seen from outside. The certificate chain is public, but it is + * not returned either: nothing in the UI needs the PEM, and the private key must + * never leave the panel at all. + */ +export class AcmeCertificateResponseModel { + public uuid: string; + public name: string; + public domains: string[]; + + public source: TAcmeCertificateSource; + + public challengeType: TAcmeChallengeType; + public keyType: TAcmeKeyType; + public renewBeforeDays: number; + public isEnabled: boolean; + + public directoryUrl: null | string; + public email: null | string; + public eabKid: null | string; + + public status: TAcmeCertificateStatus; + public lastError: null | string; + public issuedAt: Date | null; + public expiresAt: Date | null; + public fingerprint: null | string; + public failCount: number; + public nextRetryAt: Date | null; + + public credentialUuid: null | string; + public credentialName: null | string; + + public nodes: { inboundTags: string[]; nodeName: null | string; nodeUuid: string }[]; + + public createdAt: Date; + public updatedAt: Date; + + constructor(entity: AcmeCertificateEntity) { + this.uuid = entity.uuid; + this.name = entity.name; + this.domains = entity.domains; + + this.source = entity.source; + + this.challengeType = entity.challengeType; + this.keyType = entity.keyType; + this.renewBeforeDays = entity.renewBeforeDays; + this.isEnabled = entity.isEnabled; + + this.directoryUrl = entity.directoryUrl; + this.email = entity.email; + this.eabKid = entity.eabKid; + + this.status = entity.status; + this.lastError = entity.lastError; + this.issuedAt = entity.issuedAt; + this.expiresAt = entity.expiresAt; + this.fingerprint = entity.fingerprint; + this.failCount = entity.failCount; + this.nextRetryAt = entity.nextRetryAt; + + this.credentialUuid = entity.credentialUuid; + this.credentialName = entity.credentialName; + + this.nodes = entity.nodes.map((binding) => ({ + nodeUuid: binding.nodeUuid, + nodeName: binding.nodeName, + inboundTags: binding.inboundTags, + })); + + this.createdAt = entity.createdAt; + this.updatedAt = entity.updatedAt; + } +} + +export class GetAcmeCertificatesResponseModel { + public total: number; + public certificates: AcmeCertificateResponseModel[]; + + constructor(certificates: AcmeCertificateResponseModel[]) { + this.certificates = certificates; + this.total = certificates.length; + } +} diff --git a/src/modules/acme/models/acme-credential.response.model.ts b/src/modules/acme/models/acme-credential.response.model.ts new file mode 100644 index 000000000..b5a1bf00f --- /dev/null +++ b/src/modules/acme/models/acme-credential.response.model.ts @@ -0,0 +1,40 @@ +import { TAcmeProvider } from '@libs/contracts/constants'; + +import { AcmeCredentialEntity } from '../entities'; + +/** + * Credentials as seen from outside. Secret fields never appear here — only + * whether a secret is stored, plus the non-secret fields (endpoints and the + * like) so the UI can show where a credential points. + */ +export class AcmeCredentialResponseModel { + public uuid: string; + public name: string; + public provider: TAcmeProvider; + public hasSecret: boolean; + public config: Record; + public certificatesCount: number; + public createdAt: Date; + public updatedAt: Date; + + constructor(entity: AcmeCredentialEntity, config: Record) { + this.uuid = entity.uuid; + this.name = entity.name; + this.provider = entity.provider; + this.hasSecret = entity.payloadEncrypted !== null; + this.config = config; + this.certificatesCount = entity.certificatesCount; + this.createdAt = entity.createdAt; + this.updatedAt = entity.updatedAt; + } +} + +export class GetAcmeCredentialsResponseModel { + public total: number; + public credentials: AcmeCredentialResponseModel[]; + + constructor(credentials: AcmeCredentialResponseModel[]) { + this.credentials = credentials; + this.total = credentials.length; + } +} diff --git a/src/modules/acme/models/acme-event.response.model.ts b/src/modules/acme/models/acme-event.response.model.ts new file mode 100644 index 000000000..91cfa5b0d --- /dev/null +++ b/src/modules/acme/models/acme-event.response.model.ts @@ -0,0 +1,29 @@ +import { TAcmeEventLevel } from '@libs/contracts/constants'; + +import { AcmeEventEntity } from '../entities'; + +export class AcmeEventResponseModel { + public id: number; + public certificateUuid: null | string; + public level: TAcmeEventLevel; + public message: string; + public createdAt: Date; + + constructor(entity: AcmeEventEntity) { + this.id = Number(entity.id); + this.certificateUuid = entity.certificateUuid; + this.level = entity.level; + this.message = entity.message; + this.createdAt = entity.createdAt; + } +} + +export class GetAcmeCertificateEventsResponseModel { + public total: number; + public events: AcmeEventResponseModel[]; + + constructor(events: AcmeEventResponseModel[]) { + this.events = events; + this.total = events.length; + } +} diff --git a/src/modules/acme/models/acme-persist-record.response.model.ts b/src/modules/acme/models/acme-persist-record.response.model.ts new file mode 100644 index 000000000..4b88656c8 --- /dev/null +++ b/src/modules/acme/models/acme-persist-record.response.model.ts @@ -0,0 +1,33 @@ +/** + * The persistent authorization record for dns-persist-01: what has to exist in + * DNS, whether it is already there, and whether the panel can publish it itself + * (it cannot with MANUAL credentials). + */ +export class AcmePersistRecordResponseModel { + public name: string; + public value: string; + public isPublished: boolean; + public canPublish: boolean; + + constructor(data: { canPublish: boolean; isPublished: boolean; name: string; value: string }) { + this.name = data.name; + this.value = data.value; + this.isPublished = data.isPublished; + this.canPublish = data.canPublish; + } +} + +/** What a credential test reports about itself. */ +export class AcmeCredentialTestResponseModel { + public isOk: boolean; + public message: string; + public allow: string[]; + public zones: string[]; + + constructor(data: { allow: string[]; isOk: boolean; message: string; zones: string[] }) { + this.isOk = data.isOk; + this.message = data.message; + this.allow = data.allow; + this.zones = data.zones; + } +} diff --git a/src/modules/acme/models/index.ts b/src/modules/acme/models/index.ts new file mode 100644 index 000000000..a19810e3d --- /dev/null +++ b/src/modules/acme/models/index.ts @@ -0,0 +1,4 @@ +export * from './acme-certificate.response.model'; +export * from './acme-credential.response.model'; +export * from './acme-event.response.model'; +export * from './acme-persist-record.response.model'; diff --git a/src/modules/acme/queries/get-certificates-due-for-renewal/get-certificates-due-for-renewal.handler.ts b/src/modules/acme/queries/get-certificates-due-for-renewal/get-certificates-due-for-renewal.handler.ts new file mode 100644 index 000000000..b066f9946 --- /dev/null +++ b/src/modules/acme/queries/get-certificates-due-for-renewal/get-certificates-due-for-renewal.handler.ts @@ -0,0 +1,29 @@ +import { Logger } from '@nestjs/common'; +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; + +import { fail, ok, TResult } from '@common/types'; +import { ERRORS } from '@libs/contracts/constants'; + +import { AcmeCertificateEntity } from '../../entities'; +import { AcmeCertificatesRepository } from '../../repositories/acme-certificates.repository'; +import { GetCertificatesDueForRenewalQuery } from './get-certificates-due-for-renewal.query'; + +@QueryHandler(GetCertificatesDueForRenewalQuery) +export class GetCertificatesDueForRenewalHandler implements IQueryHandler< + GetCertificatesDueForRenewalQuery, + TResult +> { + private readonly logger = new Logger(GetCertificatesDueForRenewalHandler.name); + + constructor(private readonly certificatesRepository: AcmeCertificatesRepository) {} + + async execute(): Promise> { + try { + return ok(await this.certificatesRepository.findDueForRenewal(new Date())); + } catch (error) { + this.logger.error(error); + + return fail(ERRORS.GET_ACME_CERTIFICATES_ERROR); + } + } +} diff --git a/src/modules/acme/queries/get-certificates-due-for-renewal/get-certificates-due-for-renewal.query.ts b/src/modules/acme/queries/get-certificates-due-for-renewal/get-certificates-due-for-renewal.query.ts new file mode 100644 index 000000000..d0e047d86 --- /dev/null +++ b/src/modules/acme/queries/get-certificates-due-for-renewal/get-certificates-due-for-renewal.query.ts @@ -0,0 +1 @@ +export class GetCertificatesDueForRenewalQuery {} diff --git a/src/modules/acme/queries/get-certificates-due-for-renewal/index.ts b/src/modules/acme/queries/get-certificates-due-for-renewal/index.ts new file mode 100644 index 000000000..c7474ab9b --- /dev/null +++ b/src/modules/acme/queries/get-certificates-due-for-renewal/index.ts @@ -0,0 +1,2 @@ +export * from './get-certificates-due-for-renewal.handler'; +export * from './get-certificates-due-for-renewal.query'; diff --git a/src/modules/acme/queries/get-certificates-for-node/get-certificates-for-node.handler.ts b/src/modules/acme/queries/get-certificates-for-node/get-certificates-for-node.handler.ts new file mode 100644 index 000000000..5bbcd9524 --- /dev/null +++ b/src/modules/acme/queries/get-certificates-for-node/get-certificates-for-node.handler.ts @@ -0,0 +1,95 @@ +import { X509Certificate } from 'node:crypto'; + +import { Logger } from '@nestjs/common'; +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; + +import { fail, ok, TResult } from '@common/types'; +import { ERRORS } from '@libs/contracts/constants'; + +import { AcmeSecretBoxService } from '../../crypto/acme-secret-box.service'; +import { AcmeCertificatesRepository } from '../../repositories/acme-certificates.repository'; +import { GetCertificatesForNodeQuery, INodeCertificate } from './get-certificates-for-node.query'; + +@QueryHandler(GetCertificatesForNodeQuery) +export class GetCertificatesForNodeHandler implements IQueryHandler< + GetCertificatesForNodeQuery, + TResult +> { + private readonly logger = new Logger(GetCertificatesForNodeHandler.name); + + constructor( + private readonly certificatesRepository: AcmeCertificatesRepository, + private readonly secretBox: AcmeSecretBoxService, + ) {} + + async execute(query: GetCertificatesForNodeQuery): Promise> { + try { + if (!this.secretBox.isConfigured) { + return ok([]); + } + + const certificates = await this.certificatesRepository.findActiveByNodeUuid( + query.nodeUuid, + ); + + const result: INodeCertificate[] = []; + + for (const certificate of certificates) { + if (!certificate.fullchainPem || !certificate.keyEncrypted) { + continue; + } + + const binding = certificate.nodes.find( + (candidate) => candidate.nodeUuid === query.nodeUuid, + ); + + try { + result.push({ + commonName: readCommonName(new X509Certificate(certificate.fullchainPem)), + domains: certificate.domains, + certificate: toPemLines(certificate.fullchainPem), + key: toPemLines(this.secretBox.decrypt(certificate.keyEncrypted)), + fingerprint: certificate.fingerprint ?? '', + inboundTags: binding?.inboundTags ?? [], + }); + } catch (error) { + // One unreadable certificate — a key encrypted with a previous + // ACME_SECRET_KEY, say — must not stop the node from starting + // with the rest of its configuration. + this.logger.error( + `Skipping certificate ${certificate.name} for node ${query.nodeUuid}: ${error}`, + ); + } + } + + return ok(result); + } catch (error) { + this.logger.error(error); + + return fail(ERRORS.GET_ACME_CERTIFICATES_ERROR); + } + } +} + +/** + * The subject common name, or null — modern certificates often carry only SAN, + * and an empty subject comes back as undefined rather than an empty string. + */ +function readCommonName(certificate: X509Certificate): null | string { + return ( + (certificate.subject ?? '') + .split('\n') + .map((line) => line.trim()) + .find((line) => line.startsWith('CN=')) + ?.slice(3) + .toLowerCase() ?? null + ); +} + +/** Xray takes inline certificates as an array of lines, blank ones dropped. */ +function toPemLines(pem: string): string[] { + return pem + .replace(/\r\n/g, '\n') + .split('\n') + .filter((line) => line.length > 0); +} diff --git a/src/modules/acme/queries/get-certificates-for-node/get-certificates-for-node.query.ts b/src/modules/acme/queries/get-certificates-for-node/get-certificates-for-node.query.ts new file mode 100644 index 000000000..327761eb5 --- /dev/null +++ b/src/modules/acme/queries/get-certificates-for-node/get-certificates-for-node.query.ts @@ -0,0 +1,24 @@ +/** + * Certificate material for one node, ready to be injected into the config it is + * about to receive. + */ +export interface INodeCertificate { + /** PEM chain split into lines, the shape Xray expects inline. */ + certificate: string[]; + /** Subject common name, when the certificate has one at all. */ + commonName: null | string; + /** + * Every name the certificate covers. Together with the common name this is + * how an entry already on the inbound is recognized as the same certificate: + * SAN-only certificates have no common name to match on. + */ + domains: string[]; + fingerprint: string; + /** Empty means every TLS inbound of the node. */ + inboundTags: string[]; + key: string[]; +} + +export class GetCertificatesForNodeQuery { + constructor(public readonly nodeUuid: string) {} +} diff --git a/src/modules/acme/queries/get-certificates-for-node/index.ts b/src/modules/acme/queries/get-certificates-for-node/index.ts new file mode 100644 index 000000000..8377d9fe9 --- /dev/null +++ b/src/modules/acme/queries/get-certificates-for-node/index.ts @@ -0,0 +1,2 @@ +export * from './get-certificates-for-node.handler'; +export * from './get-certificates-for-node.query'; diff --git a/src/modules/acme/queries/index.ts b/src/modules/acme/queries/index.ts new file mode 100644 index 000000000..0f45bb77d --- /dev/null +++ b/src/modules/acme/queries/index.ts @@ -0,0 +1,7 @@ +import { GetCertificatesDueForRenewalHandler } from './get-certificates-due-for-renewal'; +import { GetCertificatesForNodeHandler } from './get-certificates-for-node'; + +export const QUERIES = [GetCertificatesDueForRenewalHandler, GetCertificatesForNodeHandler]; + +export * from './get-certificates-due-for-renewal'; +export * from './get-certificates-for-node'; diff --git a/src/modules/acme/repositories/acme-accounts.repository.ts b/src/modules/acme/repositories/acme-accounts.repository.ts new file mode 100644 index 000000000..b025ac8e9 --- /dev/null +++ b/src/modules/acme/repositories/acme-accounts.repository.ts @@ -0,0 +1,57 @@ +import { TransactionHost } from '@nestjs-cls/transactional'; +import { TransactionalAdapterPrisma } from '@nestjs-cls/transactional-adapter-prisma'; + +import { Injectable } from '@nestjs/common'; + +import { AcmeAccountEntity } from '../entities'; + +@Injectable() +export class AcmeAccountsRepository { + constructor(private readonly prisma: TransactionHost) {} + + public async findByDirectoryAndEmail( + directoryUrl: string, + email: string, + ): Promise { + const result = await this.prisma.tx.acmeAccounts.findUnique({ + where: { directoryUrl_email: { directoryUrl, email } }, + }); + + if (!result) { + return null; + } + + return new AcmeAccountEntity(result); + } + + public async findByUUID(uuid: string): Promise { + const result = await this.prisma.tx.acmeAccounts.findUnique({ where: { uuid } }); + + if (!result) { + return null; + } + + return new AcmeAccountEntity(result); + } + + public async create(data: { + accountKeyEncrypted: string; + directoryUrl: string; + eabHmacEncrypted?: null | string; + eabKid?: null | string; + email: string; + }): Promise { + const result = await this.prisma.tx.acmeAccounts.create({ data }); + + return new AcmeAccountEntity(result); + } + + public async setAccountUrl(uuid: string, accountUrl: string): Promise { + const result = await this.prisma.tx.acmeAccounts.update({ + where: { uuid }, + data: { accountUrl }, + }); + + return new AcmeAccountEntity(result); + } +} diff --git a/src/modules/acme/repositories/acme-certificates.repository.ts b/src/modules/acme/repositories/acme-certificates.repository.ts new file mode 100644 index 000000000..874e78a67 --- /dev/null +++ b/src/modules/acme/repositories/acme-certificates.repository.ts @@ -0,0 +1,282 @@ +import { TransactionHost } from '@nestjs-cls/transactional'; +import { TransactionalAdapterPrisma } from '@nestjs-cls/transactional-adapter-prisma'; + +import { Injectable } from '@nestjs/common'; + +import { AcmeCertificateEntity } from '../entities'; + +/** What the issuance engine writes back after an attempt. */ +export interface IAcmeCertificateResult { + expiresAt?: Date | null; + failCount?: number; + fingerprint?: null | string; + fullchainPem?: null | string; + issuedAt?: Date | null; + keyEncrypted?: null | string; + lastError?: null | string; + nextRetryAt?: Date | null; + status?: string; +} + +const WITH_RELATIONS = { + credential: { select: { name: true } }, + nodes: { include: { node: { select: { name: true } } } }, +} as const; + +@Injectable() +export class AcmeCertificatesRepository { + constructor(private readonly prisma: TransactionHost) {} + + public async create( + data: { + accountUuid?: null | string; + challengeType: string; + credentialUuid: string; + directoryUrl: string; + domains: string[]; + eabKid?: null | string; + email: string; + isEnabled: boolean; + keyType: string; + name: string; + renewBeforeDays: number; + }, + nodes: { inboundTags: string[]; nodeUuid: string }[], + ): Promise { + const result = await this.prisma.tx.acmeCertificates.create({ + data: { + ...data, + nodes: { + create: nodes.map((binding) => ({ + nodeUuid: binding.nodeUuid, + inboundTags: binding.inboundTags, + })), + }, + }, + include: WITH_RELATIONS, + }); + + return new AcmeCertificateEntity(result); + } + + /** + * Stores material the panel did not issue. Domains, validity and key type + * come from the certificate itself, so there is nothing for the caller to + * get wrong, and the certificate is active from the moment it is stored. + */ + public async createImported( + data: { + domains: string[]; + expiresAt: Date; + fingerprint: string; + fullchainPem: string; + isEnabled: boolean; + issuedAt: Date; + keyEncrypted: string; + keyType: string; + name: string; + }, + nodes: { inboundTags: string[]; nodeUuid: string }[], + ): Promise { + const result = await this.prisma.tx.acmeCertificates.create({ + data: { + ...data, + source: 'IMPORTED', + status: 'ACTIVE', + nodes: { + create: nodes.map((binding) => ({ + nodeUuid: binding.nodeUuid, + inboundTags: binding.inboundTags, + })), + }, + }, + include: WITH_RELATIONS, + }); + + return new AcmeCertificateEntity(result); + } + + /** Replaces the material of an imported certificate; this is how it is renewed. */ + public async replaceMaterial( + uuid: string, + data: { + domains: string[]; + expiresAt: Date; + fingerprint: string; + fullchainPem: string; + issuedAt: Date; + keyEncrypted: string; + keyType: string; + }, + ): Promise { + const result = await this.prisma.tx.acmeCertificates.update({ + where: { uuid }, + data: { + ...data, + status: 'ACTIVE', + lastError: null, + failCount: 0, + nextRetryAt: null, + }, + include: WITH_RELATIONS, + }); + + return new AcmeCertificateEntity(result); + } + + /** + * Updates the certificate and, when bindings are given, replaces them + * wholesale. Both happen in the caller's transaction, so a half-applied + * binding set is not observable. + */ + public async update( + uuid: string, + data: { + challengeType?: string; + credentialUuid?: string; + directoryUrl?: string; + domains?: string[]; + eabKid?: null | string; + email?: string; + isEnabled?: boolean; + keyType?: string; + name?: string; + renewBeforeDays?: number; + }, + nodes?: { inboundTags: string[]; nodeUuid: string }[], + ): Promise { + if (nodes) { + await this.prisma.tx.acmeCertificateNodes.deleteMany({ + where: { certificateUuid: uuid }, + }); + } + + const result = await this.prisma.tx.acmeCertificates.update({ + where: { uuid }, + data: { + ...data, + ...(nodes + ? { + nodes: { + create: nodes.map((binding) => ({ + nodeUuid: binding.nodeUuid, + inboundTags: binding.inboundTags, + })), + }, + } + : {}), + }, + include: WITH_RELATIONS, + }); + + return new AcmeCertificateEntity(result); + } + + public async updateResult( + uuid: string, + data: IAcmeCertificateResult, + ): Promise { + const result = await this.prisma.tx.acmeCertificates.update({ + where: { uuid }, + data, + include: WITH_RELATIONS, + }); + + return new AcmeCertificateEntity(result); + } + + public async findByUUID(uuid: string): Promise { + const result = await this.prisma.tx.acmeCertificates.findUnique({ + where: { uuid }, + include: WITH_RELATIONS, + }); + + if (!result) { + return null; + } + + return new AcmeCertificateEntity(result); + } + + public async findByName(name: string): Promise { + const result = await this.prisma.tx.acmeCertificates.findUnique({ where: { name } }); + + if (!result) { + return null; + } + + return new AcmeCertificateEntity(result); + } + + public async findAll(): Promise { + const result = await this.prisma.tx.acmeCertificates.findMany({ + include: WITH_RELATIONS, + orderBy: { createdAt: 'asc' }, + }); + + return result.map((certificate) => new AcmeCertificateEntity(certificate)); + } + + /** + * Certificates the scheduler should act on: issued by the panel, enabled, not + * waiting for a manual DNS record, past their renewal window (or never + * issued), and not held back by the retry backoff. + * + * Imported certificates are excluded by construction: the panel has no way to + * renew what it did not issue. + */ + public async findDueForRenewal(now: Date): Promise { + const result = await this.prisma.tx.$queryRaw<{ uuid: string }[]>` + SELECT uuid + FROM acme_certificates + WHERE is_enabled = true + AND source = 'ACME' + AND status <> 'AWAITING_DNS' + AND status <> 'ISSUING' + AND (next_retry_at IS NULL OR next_retry_at <= ${now}) + AND ( + expires_at IS NULL + OR expires_at - make_interval(days => renew_before_days) <= ${now} + ) + `; + + if (result.length === 0) { + return []; + } + + const certificates = await this.prisma.tx.acmeCertificates.findMany({ + where: { uuid: { in: result.map((row) => row.uuid) } }, + include: WITH_RELATIONS, + }); + + return certificates.map((certificate) => new AcmeCertificateEntity(certificate)); + } + + /** + * Active certificates bound to a node, with the material needed to inject + * them into that node's config. + */ + public async findActiveByNodeUuid(nodeUuid: string): Promise { + const result = await this.prisma.tx.acmeCertificates.findMany({ + where: { + isEnabled: true, + status: 'ACTIVE', + fullchainPem: { not: null }, + keyEncrypted: { not: null }, + nodes: { some: { nodeUuid } }, + }, + include: { + credential: { select: { name: true } }, + nodes: { where: { nodeUuid }, include: { node: { select: { name: true } } } }, + }, + }); + + return result.map((certificate) => new AcmeCertificateEntity(certificate)); + } + + public async deleteByUUID(uuid: string): Promise { + const result = await this.prisma.tx.acmeCertificates.delete({ where: { uuid } }); + + return !!result; + } +} diff --git a/src/modules/acme/repositories/acme-credentials.repository.ts b/src/modules/acme/repositories/acme-credentials.repository.ts new file mode 100644 index 000000000..906b0e82c --- /dev/null +++ b/src/modules/acme/repositories/acme-credentials.repository.ts @@ -0,0 +1,80 @@ +import { TransactionHost } from '@nestjs-cls/transactional'; +import { TransactionalAdapterPrisma } from '@nestjs-cls/transactional-adapter-prisma'; + +import { Injectable } from '@nestjs/common'; + +import { AcmeCredentialEntity } from '../entities'; + +@Injectable() +export class AcmeCredentialsRepository { + constructor(private readonly prisma: TransactionHost) {} + + public async create(data: { + name: string; + payloadEncrypted: null | string; + provider: string; + }): Promise { + const result = await this.prisma.tx.acmeCredentials.create({ data }); + + return new AcmeCredentialEntity(result); + } + + public async update( + uuid: string, + data: { name?: string; payloadEncrypted?: string }, + ): Promise { + const result = await this.prisma.tx.acmeCredentials.update({ + where: { uuid }, + data, + }); + + return new AcmeCredentialEntity(result); + } + + public async findByUUID(uuid: string): Promise { + const result = await this.prisma.tx.acmeCredentials.findUnique({ + where: { uuid }, + include: { _count: { select: { certificates: true } } }, + }); + + if (!result) { + return null; + } + + return new AcmeCredentialEntity({ + ...result, + certificatesCount: result._count.certificates, + }); + } + + public async findByName(name: string): Promise { + const result = await this.prisma.tx.acmeCredentials.findUnique({ where: { name } }); + + if (!result) { + return null; + } + + return new AcmeCredentialEntity(result); + } + + public async findAll(): Promise { + const result = await this.prisma.tx.acmeCredentials.findMany({ + include: { _count: { select: { certificates: true } } }, + orderBy: { createdAt: 'asc' }, + }); + + return result.map( + (credential) => + new AcmeCredentialEntity({ + ...credential, + certificatesCount: credential._count.certificates, + }), + ); + } + + public async deleteByUUID(uuid: string): Promise { + const result = await this.prisma.tx.acmeCredentials.delete({ where: { uuid } }); + + return !!result; + } +} diff --git a/src/modules/acme/repositories/acme-events.repository.ts b/src/modules/acme/repositories/acme-events.repository.ts new file mode 100644 index 000000000..96a7714fa --- /dev/null +++ b/src/modules/acme/repositories/acme-events.repository.ts @@ -0,0 +1,51 @@ +import { TransactionHost } from '@nestjs-cls/transactional'; +import { TransactionalAdapterPrisma } from '@nestjs-cls/transactional-adapter-prisma'; + +import { Injectable } from '@nestjs/common'; + +import { TAcmeEventLevel } from '@libs/contracts/constants'; + +import { AcmeEventEntity } from '../entities'; + +/** How many events are kept per certificate; older ones are dropped on write. */ +const MAX_EVENTS_PER_CERTIFICATE = 200; + +@Injectable() +export class AcmeEventsRepository { + constructor(private readonly prisma: TransactionHost) {} + + public async create( + certificateUuid: null | string, + level: TAcmeEventLevel, + message: string, + ): Promise { + await this.prisma.tx.acmeEvents.create({ + data: { certificateUuid, level, message }, + }); + + if (!certificateUuid) { + return; + } + + await this.prisma.tx.$executeRaw` + DELETE FROM acme_events + WHERE certificate_uuid = ${certificateUuid}::uuid + AND id NOT IN ( + SELECT id FROM acme_events + WHERE certificate_uuid = ${certificateUuid}::uuid + ORDER BY id DESC + LIMIT ${MAX_EVENTS_PER_CERTIFICATE} + ) + `; + } + + public async findByCertificateUuid(certificateUuid: string): Promise { + const result = await this.prisma.tx.acmeEvents.findMany({ + where: { certificateUuid }, + orderBy: { id: 'desc' }, + take: MAX_EVENTS_PER_CERTIFICATE, + }); + + return result.map((event) => new AcmeEventEntity(event)); + } +} diff --git a/src/modules/acme/services/acme-certificates.service.ts b/src/modules/acme/services/acme-certificates.service.ts new file mode 100644 index 000000000..dd47d7409 --- /dev/null +++ b/src/modules/acme/services/acme-certificates.service.ts @@ -0,0 +1,610 @@ +import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; + +import { Injectable, Logger } from '@nestjs/common'; + +import { fail, ok, TResult } from '@common/types'; +import { + ACME_CERTIFICATE_SOURCE, + ACME_CERTIFICATE_STATUS, + ACME_CHALLENGE_TYPE, + ERRORS, +} from '@libs/contracts/constants'; + +import { AcmeQueueService } from '@queue/_acme'; +import { NodesQueuesService } from '@queue/_nodes'; + +import { AcmeSecretBoxService } from '../crypto/acme-secret-box.service'; +import { + CreateAcmeCertificateBodyDto, + ImportAcmeCertificateBodyDto, + ReimportAcmeCertificateBodyDto, + UpdateAcmeCertificateBodyDto, +} from '../dtos'; +import { AcmeOrderService } from '../engine/acme-order.service'; +import { isTxtValuePublished } from '../engine/dns-propagation.util'; +import { parseCertificateMaterial } from '../engine/import-certificate.util'; +import { + buildPersistRecordName, + buildPersistRecordValue, + resolveIssuerDomain, +} from '../engine/persist-record.util'; +import { SolverFactory } from '../engine/solvers/solver.factory'; +import { + AcmeCertificateResponseModel, + AcmeEventResponseModel, + AcmePersistRecordResponseModel, + GetAcmeCertificateEventsResponseModel, + GetAcmeCertificatesResponseModel, +} from '../models'; +import { AcmeCertificatesRepository } from '../repositories/acme-certificates.repository'; +import { AcmeCredentialsRepository } from '../repositories/acme-credentials.repository'; +import { AcmeEventsRepository } from '../repositories/acme-events.repository'; + +/** Changes that make the stored certificate no longer match what was asked for. */ +const REISSUE_TRIGGERS = ['domains', 'keyType', 'challengeType', 'directoryUrl'] as const; + +@Injectable() +export class AcmeCertificatesService { + private readonly logger = new Logger(AcmeCertificatesService.name); + + constructor( + private readonly certificatesRepository: AcmeCertificatesRepository, + private readonly credentialsRepository: AcmeCredentialsRepository, + private readonly eventsRepository: AcmeEventsRepository, + private readonly secretBox: AcmeSecretBoxService, + private readonly solverFactory: SolverFactory, + private readonly acmeOrderService: AcmeOrderService, + private readonly acmeQueueService: AcmeQueueService, + private readonly nodesQueuesService: NodesQueuesService, + ) {} + + public async getAll(): Promise> { + try { + const certificates = await this.certificatesRepository.findAll(); + + return ok( + new GetAcmeCertificatesResponseModel( + certificates.map( + (certificate) => new AcmeCertificateResponseModel(certificate), + ), + ), + ); + } catch (error) { + this.logger.error(error); + return fail(ERRORS.GET_ACME_CERTIFICATES_ERROR); + } + } + + public async getByUuid(uuid: string): Promise> { + try { + const certificate = await this.certificatesRepository.findByUUID(uuid); + + if (!certificate) { + return fail(ERRORS.ACME_CERTIFICATE_NOT_FOUND); + } + + return ok(new AcmeCertificateResponseModel(certificate)); + } catch (error) { + this.logger.error(error); + return fail(ERRORS.GET_ACME_CERTIFICATES_ERROR); + } + } + + public async create( + dto: CreateAcmeCertificateBodyDto, + ): Promise> { + if (!this.secretBox.isConfigured) { + return fail(ERRORS.ACME_SECRET_KEY_MISSING); + } + + try { + const existing = await this.certificatesRepository.findByName(dto.name); + + if (existing) { + return fail(ERRORS.ACME_CERTIFICATE_NAME_ALREADY_EXISTS); + } + + const credential = await this.credentialsRepository.findByUUID(dto.credentialUuid); + + if (!credential) { + return fail(ERRORS.ACME_CREDENTIAL_NOT_FOUND); + } + + const certificate = await this.certificatesRepository.create( + { + name: dto.name, + domains: dto.domains, + challengeType: dto.challengeType, + keyType: dto.keyType, + renewBeforeDays: dto.renewBeforeDays, + isEnabled: dto.isEnabled, + directoryUrl: dto.directoryUrl, + email: dto.email, + eabKid: dto.eabKid ?? null, + credentialUuid: dto.credentialUuid, + }, + dto.nodes, + ); + + await this.eventsRepository.create( + certificate.uuid, + 'INFO', + `Certificate created for ${dto.domains.join(', ')}`, + ); + + return ok(new AcmeCertificateResponseModel(certificate)); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === 'P2003') { + return fail( + ERRORS.ACME_INVALID_CERTIFICATE_REQUEST.withMessage( + 'One of the nodes does not exist', + ), + ); + } + + this.logger.error(error); + return fail(ERRORS.CREATE_ACME_CERTIFICATE_ERROR); + } + } + + public async update( + dto: UpdateAcmeCertificateBodyDto, + ): Promise> { + if (!this.secretBox.isConfigured) { + return fail(ERRORS.ACME_SECRET_KEY_MISSING); + } + + try { + const certificate = await this.certificatesRepository.findByUUID(dto.uuid); + + if (!certificate) { + return fail(ERRORS.ACME_CERTIFICATE_NOT_FOUND); + } + + if (dto.name && dto.name !== certificate.name) { + const sameName = await this.certificatesRepository.findByName(dto.name); + + if (sameName) { + return fail(ERRORS.ACME_CERTIFICATE_NAME_ALREADY_EXISTS); + } + } + + if (dto.credentialUuid) { + const credential = await this.credentialsRepository.findByUUID(dto.credentialUuid); + + if (!credential) { + return fail(ERRORS.ACME_CREDENTIAL_NOT_FOUND); + } + } + + // Everything an imported certificate says about itself was read from + // its PEM. Accepting these fields would let the record drift from the + // material nodes actually serve, so only name, enabled and bindings + // are editable; the rest changes by uploading a new certificate. + if (certificate.source === ACME_CERTIFICATE_SOURCE.IMPORTED) { + const rejected = ( + [ + 'domains', + 'challengeType', + 'keyType', + 'renewBeforeDays', + 'directoryUrl', + 'email', + 'eabKid', + 'eabHmacKey', + 'credentialUuid', + ] as const + ).filter((field) => dto[field] !== undefined); + + if (rejected.length > 0) { + return fail( + ERRORS.ACME_INVALID_CERTIFICATE_REQUEST.withMessage( + `${rejected.join(', ')}: read from the imported certificate itself; upload new material to change them`, + ), + ); + } + } + + // An imported certificate has nothing to re-issue: its material is + // whatever was uploaded, and only a new upload replaces it. + const needsReissue = + certificate.source === ACME_CERTIFICATE_SOURCE.ACME && + this.needsReissue(certificate, dto); + + const updated = await this.certificatesRepository.update( + dto.uuid, + { + ...(dto.name === undefined ? {} : { name: dto.name }), + ...(dto.domains === undefined ? {} : { domains: dto.domains }), + ...(dto.challengeType === undefined + ? {} + : { challengeType: dto.challengeType }), + ...(dto.keyType === undefined ? {} : { keyType: dto.keyType }), + ...(dto.renewBeforeDays === undefined + ? {} + : { renewBeforeDays: dto.renewBeforeDays }), + ...(dto.isEnabled === undefined ? {} : { isEnabled: dto.isEnabled }), + ...(dto.directoryUrl === undefined ? {} : { directoryUrl: dto.directoryUrl }), + ...(dto.email === undefined ? {} : { email: dto.email }), + ...(dto.eabKid === undefined ? {} : { eabKid: dto.eabKid }), + ...(dto.credentialUuid === undefined + ? {} + : { credentialUuid: dto.credentialUuid }), + }, + dto.nodes, + ); + + if (needsReissue) { + // The stored certificate no longer matches the request, so it is + // marked stale rather than deleted: the node keeps serving the old + // one until a new one actually arrives. + await this.certificatesRepository.updateResult(dto.uuid, { + status: ACME_CERTIFICATE_STATUS.PENDING, + nextRetryAt: null, + failCount: 0, + }); + + await this.eventsRepository.create( + dto.uuid, + 'INFO', + 'Certificate parameters changed, re-issue scheduled', + ); + + const refreshed = await this.certificatesRepository.findByUUID(dto.uuid); + + return ok(new AcmeCertificateResponseModel(refreshed ?? updated)); + } + + // Delivery happens at config-render time, so a binding or enable-flag + // change is invisible until the node restarts. Nodes REMOVED from the + // bindings restart too - that is what makes them stop serving the key. + // A certificate that never had material changes no config; skip those. + const bindingsTouched = dto.nodes !== undefined || dto.isEnabled !== undefined; + + if (bindingsTouched && certificate.fingerprint) { + await this.restartBoundNodes({ + nodes: [...certificate.nodes, ...updated.nodes], + }); + } + + return ok(new AcmeCertificateResponseModel(updated)); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === 'P2003') { + return fail( + ERRORS.ACME_INVALID_CERTIFICATE_REQUEST.withMessage( + 'One of the nodes does not exist', + ), + ); + } + + this.logger.error(error); + return fail(ERRORS.UPDATE_ACME_CERTIFICATE_ERROR); + } + } + + public async delete(uuid: string): Promise> { + try { + const certificate = await this.certificatesRepository.findByUUID(uuid); + + if (!certificate) { + return fail(ERRORS.ACME_CERTIFICATE_NOT_FOUND); + } + + const isDeleted = await this.certificatesRepository.deleteByUUID(uuid); + + return ok({ isDeleted }); + } catch (error) { + this.logger.error(error); + return fail(ERRORS.DELETE_ACME_CERTIFICATE_ERROR); + } + } + + public async getEvents(uuid: string): Promise> { + try { + const certificate = await this.certificatesRepository.findByUUID(uuid); + + if (!certificate) { + return fail(ERRORS.ACME_CERTIFICATE_NOT_FOUND); + } + + const events = await this.eventsRepository.findByCertificateUuid(uuid); + + return ok( + new GetAcmeCertificateEventsResponseModel( + events.map((event) => new AcmeEventResponseModel(event)), + ), + ); + } catch (error) { + this.logger.error(error); + return fail(ERRORS.GET_ACME_CERTIFICATES_ERROR); + } + } + + /** + * Stores a certificate the panel did not issue. + * + * Everything that describes it — domains, validity, key type — is read from + * the certificate rather than taken from the request: the operator cannot + * mistype what the certificate actually covers. + */ + public async import( + dto: ImportAcmeCertificateBodyDto, + ): Promise> { + if (!this.secretBox.isConfigured) { + return fail(ERRORS.ACME_SECRET_KEY_MISSING); + } + + try { + const existing = await this.certificatesRepository.findByName(dto.name); + + if (existing) { + return fail(ERRORS.ACME_CERTIFICATE_NAME_ALREADY_EXISTS); + } + + const material = parseCertificateMaterial(dto.fullchainPem, dto.privateKeyPem); + + const certificate = await this.certificatesRepository.createImported( + { + name: dto.name, + domains: material.domains, + keyType: material.keyType, + isEnabled: dto.isEnabled, + fullchainPem: material.fullchainPem, + keyEncrypted: this.secretBox.encrypt(material.privateKeyPem), + fingerprint: material.fingerprint, + issuedAt: material.issuedAt, + expiresAt: material.expiresAt, + }, + dto.nodes, + ); + + await this.recordImport(certificate.uuid, material); + await this.restartBoundNodes(certificate); + + return ok(new AcmeCertificateResponseModel(certificate)); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === 'P2003') { + return fail( + ERRORS.ACME_INVALID_CERTIFICATE_REQUEST.withMessage( + 'One of the nodes does not exist', + ), + ); + } + + this.logger.error(error); + + return fail(ERRORS.ACME_INVALID_PEM.withMessage(describeError(error))); + } + } + + /** + * Replaces the material of an imported certificate. This is how such a + * certificate is renewed: whoever issued it renews it, and the new PEM lands + * here. + */ + public async reimport( + uuid: string, + dto: ReimportAcmeCertificateBodyDto, + ): Promise> { + if (!this.secretBox.isConfigured) { + return fail(ERRORS.ACME_SECRET_KEY_MISSING); + } + + try { + const certificate = await this.certificatesRepository.findByUUID(uuid); + + if (!certificate) { + return fail(ERRORS.ACME_CERTIFICATE_NOT_FOUND); + } + + if (certificate.source !== ACME_CERTIFICATE_SOURCE.IMPORTED) { + return fail(ERRORS.ACME_CERTIFICATE_NOT_IMPORTED); + } + + const material = parseCertificateMaterial(dto.fullchainPem, dto.privateKeyPem); + + const updated = await this.certificatesRepository.replaceMaterial(uuid, { + domains: material.domains, + keyType: material.keyType, + fullchainPem: material.fullchainPem, + keyEncrypted: this.secretBox.encrypt(material.privateKeyPem), + fingerprint: material.fingerprint, + issuedAt: material.issuedAt, + expiresAt: material.expiresAt, + }); + + await this.recordImport(uuid, material); + await this.restartBoundNodes(updated); + + return ok(new AcmeCertificateResponseModel(updated)); + } catch (error) { + this.logger.error(error); + + return fail(ERRORS.ACME_INVALID_PEM.withMessage(describeError(error))); + } + } + + /** + * Queues an order. It is not awaited: an order takes tens of seconds, and the + * certificate's status and events are where progress belongs. + */ + public async issue(uuid: string): Promise> { + if (!this.secretBox.isConfigured) { + return fail(ERRORS.ACME_SECRET_KEY_MISSING); + } + + try { + const certificate = await this.certificatesRepository.findByUUID(uuid); + + if (!certificate) { + return fail(ERRORS.ACME_CERTIFICATE_NOT_FOUND); + } + + if (certificate.source === ACME_CERTIFICATE_SOURCE.IMPORTED) { + return fail(ERRORS.ACME_CERTIFICATE_IS_IMPORTED); + } + + // A manual run clears the backoff: the operator is presumably fixing + // whatever was broken and should not wait out the previous penalty. + await this.certificatesRepository.updateResult(uuid, { nextRetryAt: null }); + + await this.acmeQueueService.issueCertificate({ + certificateUuid: uuid, + force: true, + }); + + await this.eventsRepository.create(uuid, 'INFO', 'Issuance requested manually'); + + return ok({ isQueued: true }); + } catch (error) { + this.logger.error(error); + + return fail(ERRORS.ACME_CERTIFICATE_ISSUE_ERROR.withMessage(String(error))); + } + } + + /** + * The persistent authorization record for a dns-persist-01 certificate. + * + * Building it needs the ACME account URI, so the account is registered on + * first call — the record cannot be shown before the CA knows the account it + * points at. + */ + public async getPersistRecord( + uuid: string, + publish = false, + ): Promise> { + if (!this.secretBox.isConfigured) { + return fail(ERRORS.ACME_SECRET_KEY_MISSING); + } + + try { + const certificate = await this.certificatesRepository.findByUUID(uuid); + + if (!certificate) { + return fail(ERRORS.ACME_CERTIFICATE_NOT_FOUND); + } + + if (certificate.challengeType !== ACME_CHALLENGE_TYPE.DNS_PERSIST_01) { + return fail(ERRORS.ACME_PERSIST_RECORD_NOT_APPLICABLE); + } + + const credential = certificate.credentialUuid + ? await this.credentialsRepository.findByUUID(certificate.credentialUuid) + : null; + + if (!credential) { + return fail(ERRORS.ACME_CREDENTIAL_NOT_FOUND); + } + + const { account } = await this.acmeOrderService.buildClient(certificate); + + const name = buildPersistRecordName(certificate.domains); + const value = buildPersistRecordValue( + resolveIssuerDomain(certificate.directoryUrl ?? ''), + account.accountUrl!, + certificate.domains, + ); + + const solver = this.solverFactory.create(credential); + + if (publish) { + if (!solver.canPublish) { + return fail( + ERRORS.ACME_SOLVER_ERROR.withMessage( + `Credential "${credential.name}" cannot publish records. Add the record to your DNS zone manually.`, + ), + ); + } + + await solver.publishPersist(name, value); + + await this.eventsRepository.create( + uuid, + 'INFO', + `Published the persistent authorization record ${name}`, + ); + } + + return ok( + new AcmePersistRecordResponseModel({ + name, + value, + isPublished: await isTxtValuePublished(name, value), + canPublish: solver.canPublish, + }), + ); + } catch (error) { + this.logger.error(error); + + return fail(ERRORS.ACME_SOLVER_ERROR.withMessage(String(error))); + } + } + + /** + * Notes what was imported, and says out loud when the material is already + * expired: importing one is allowed — sometimes that is what an operator is + * repairing — but it must not pass silently. + */ + private async recordImport( + certificateUuid: string, + material: { domains: string[]; expiresAt: Date; isExpired: boolean }, + ): Promise { + await this.eventsRepository.create( + certificateUuid, + 'INFO', + `Imported certificate for ${material.domains.join(', ')}, valid until ${material.expiresAt.toISOString()}`, + ); + + if (material.isExpired) { + await this.eventsRepository.create( + certificateUuid, + 'ERROR', + 'The imported certificate is already expired; nodes will serve it as is', + ); + } + } + + /** Delivery happens when a node rebuilds its config, so a restart is what applies new material. */ + private async restartBoundNodes(certificate: { nodes: { nodeUuid: string }[] }): Promise { + for (const nodeUuid of new Set(certificate.nodes.map((binding) => binding.nodeUuid))) { + await this.nodesQueuesService.startNode({ nodeUuid }); + } + } + + private needsReissue( + certificate: { + challengeType: string; + directoryUrl: null | string; + domains: string[]; + keyType: string; + }, + dto: UpdateAcmeCertificateBodyDto, + ): boolean { + return REISSUE_TRIGGERS.some((field) => { + const next = dto[field]; + + if (next === undefined) { + return false; + } + + if (field === 'domains') { + const current = [...certificate.domains].sort(); + const requested = [...(next as string[])].sort(); + + return JSON.stringify(current) !== JSON.stringify(requested); + } + + return next !== certificate[field]; + }); + } +} + +/** + * Import failures are almost always about the material — a mismatched key, a + * chain in the wrong order, an encrypted key — so the reason is passed through + * to the operator instead of being flattened into "invalid PEM". + */ +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/modules/acme/services/acme-credentials.service.ts b/src/modules/acme/services/acme-credentials.service.ts new file mode 100644 index 000000000..42f06194f --- /dev/null +++ b/src/modules/acme/services/acme-credentials.service.ts @@ -0,0 +1,269 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { fail, ok, TResult } from '@common/types'; +import { ACME_PROVIDER_REGISTRY, ERRORS, TAcmeProvider } from '@libs/contracts/constants'; + +import { AcmeSecretBoxService } from '../crypto/acme-secret-box.service'; +import { CreateAcmeCredentialBodyDto, UpdateAcmeCredentialBodyDto } from '../dtos'; +import { SolverFactory } from '../engine/solvers/solver.factory'; +import { AcmeCredentialEntity } from '../entities'; +import { TAcmeCredentialPayload } from '../interfaces/credential-payload.interface'; +import { + AcmeCredentialResponseModel, + AcmeCredentialTestResponseModel, + GetAcmeCredentialsResponseModel, +} from '../models'; +import { AcmeCredentialsRepository } from '../repositories/acme-credentials.repository'; + +@Injectable() +export class AcmeCredentialsService { + private readonly logger = new Logger(AcmeCredentialsService.name); + + constructor( + private readonly credentialsRepository: AcmeCredentialsRepository, + private readonly secretBox: AcmeSecretBoxService, + private readonly solverFactory: SolverFactory, + ) {} + + public async getAll(): Promise> { + try { + const credentials = await this.credentialsRepository.findAll(); + + return ok( + new GetAcmeCredentialsResponseModel( + credentials.map((credential) => this.toResponse(credential)), + ), + ); + } catch (error) { + this.logger.error(error); + return fail(ERRORS.GET_ACME_CREDENTIALS_ERROR); + } + } + + public async create( + dto: CreateAcmeCredentialBodyDto, + ): Promise> { + if (!this.secretBox.isConfigured) { + return fail(ERRORS.ACME_SECRET_KEY_MISSING); + } + + try { + const existing = await this.credentialsRepository.findByName(dto.name); + + if (existing) { + return fail(ERRORS.ACME_CREDENTIAL_NAME_ALREADY_EXISTS); + } + + const payload = this.buildPayload(dto.provider, dto.config ?? {}); + + const credential = await this.credentialsRepository.create({ + name: dto.name, + provider: dto.provider, + payloadEncrypted: payload ? this.secretBox.encryptJson(payload) : null, + }); + + return ok(this.toResponse(credential)); + } catch (error) { + this.logger.error(error); + return fail(ERRORS.CREATE_ACME_CREDENTIAL_ERROR); + } + } + + public async update( + dto: UpdateAcmeCredentialBodyDto, + ): Promise> { + if (!this.secretBox.isConfigured) { + return fail(ERRORS.ACME_SECRET_KEY_MISSING); + } + + try { + const credential = await this.credentialsRepository.findByUUID(dto.uuid); + + if (!credential) { + return fail(ERRORS.ACME_CREDENTIAL_NOT_FOUND); + } + + if (dto.name && dto.name !== credential.name) { + const sameName = await this.credentialsRepository.findByName(dto.name); + + if (sameName) { + return fail(ERRORS.ACME_CREDENTIAL_NAME_ALREADY_EXISTS); + } + } + + // Secrets are write-only: a request that omits them keeps whatever is + // stored, and a partial update merges into the existing payload so + // changing only the base URL does not wipe the token. + const current = this.readPayload(credential); + const merged = this.mergePayload(credential.provider, current, dto.config ?? {}); + + const updated = await this.credentialsRepository.update(dto.uuid, { + ...(dto.name ? { name: dto.name } : {}), + ...(merged ? { payloadEncrypted: this.secretBox.encryptJson(merged) } : {}), + }); + + return ok(this.toResponse(updated)); + } catch (error) { + this.logger.error(error); + return fail(ERRORS.UPDATE_ACME_CREDENTIAL_ERROR); + } + } + + public async delete(uuid: string): Promise> { + try { + const credential = await this.credentialsRepository.findByUUID(uuid); + + if (!credential) { + return fail(ERRORS.ACME_CREDENTIAL_NOT_FOUND); + } + + // Deleting a credential a certificate still points at would leave that + // certificate unable to renew, and the failure would only surface weeks + // later. Refuse instead. + if (credential.certificatesCount > 0) { + return fail(ERRORS.ACME_CREDENTIAL_IN_USE); + } + + const isDeleted = await this.credentialsRepository.deleteByUUID(uuid); + + return ok({ isDeleted }); + } catch (error) { + this.logger.error(error); + return fail(ERRORS.DELETE_ACME_CREDENTIAL_ERROR); + } + } + + /** + * Checks that the credential actually works and reports what it may do. + * + * For broker credentials this is the only way an operator sees the allow list without + * shell access to the proxy host — which is exactly when a certificate fails + * with "domain is not allowed" and nobody remembers what was configured. + */ + public async test(uuid: string): Promise> { + if (!this.secretBox.isConfigured) { + return fail(ERRORS.ACME_SECRET_KEY_MISSING); + } + + try { + const credential = await this.credentialsRepository.findByUUID(uuid); + + if (!credential) { + return fail(ERRORS.ACME_CREDENTIAL_NOT_FOUND); + } + + const solver = this.solverFactory.create(credential); + const description = await solver.describe(); + + return ok(new AcmeCredentialTestResponseModel(description)); + } catch (error) { + this.logger.error(error); + + return fail(ERRORS.ACME_CREDENTIAL_TEST_FAILED.withMessage(String(error))); + } + } + + /** Decrypted payload of a credential, or null when there is nothing stored. */ + public readPayload(credential: AcmeCredentialEntity): null | TAcmeCredentialPayload { + if (!credential.payloadEncrypted) { + return null; + } + + return this.secretBox.decryptJson(credential.payloadEncrypted); + } + + public toResponse(credential: AcmeCredentialEntity): AcmeCredentialResponseModel { + return new AcmeCredentialResponseModel(credential, this.readPublicConfig(credential)); + } + + /** + * The non-secret provider fields, for display. A payload that cannot be + * decrypted usually means ACME_SECRET_KEY was replaced; listing must still + * work so the operator can see and fix the credentials. + */ + private readPublicConfig(credential: AcmeCredentialEntity): Record { + if (!credential.payloadEncrypted) { + return {}; + } + + const info = ACME_PROVIDER_REGISTRY.find((entry) => entry.provider === credential.provider); + + try { + const payload = this.secretBox.decryptJson( + credential.payloadEncrypted, + ); + + const publicConfig: Record = {}; + + for (const field of info?.fields ?? []) { + if (!field.secret && payload[field.key]) { + publicConfig[field.key] = payload[field.key]; + } + } + + return publicConfig; + } catch (error) { + this.logger.error(`Failed to read credential ${credential.uuid} payload: ${error}`); + + return {}; + } + } + + /** Normalizes a single field value; URLs must not keep trailing slashes. */ + private normalizeField(key: string, value: string): string { + return key === 'baseUrl' ? value.trim().replace(/\/+$/, '') : value.trim(); + } + + private buildPayload( + provider: TAcmeProvider, + config: Record, + ): null | TAcmeCredentialPayload { + const info = ACME_PROVIDER_REGISTRY.find((entry) => entry.provider === provider); + + if (!info || info.fields.length === 0) { + return null; + } + + const payload: TAcmeCredentialPayload = {}; + + // Only registry keys are stored: whatever else arrives in config is + // dropped rather than persisted blindly. + for (const field of info.fields) { + const value = config[field.key]; + + if (value) { + payload[field.key] = this.normalizeField(field.key, value); + } + } + + return Object.keys(payload).length > 0 ? payload : null; + } + + private mergePayload( + provider: TAcmeProvider, + current: null | TAcmeCredentialPayload, + config: Record, + ): null | TAcmeCredentialPayload { + const info = ACME_PROVIDER_REGISTRY.find((entry) => entry.provider === provider); + + if (!info || info.fields.length === 0) { + return null; + } + + const merged: TAcmeCredentialPayload = { ...(current ?? {}) }; + let touched = false; + + for (const field of info.fields) { + const value = config[field.key]; + + // An empty string is what an untouched secret input submits as: + // it means "keep what is stored", not "erase it". + if (value) { + merged[field.key] = this.normalizeField(field.key, value); + touched = true; + } + } + + return touched ? merged : null; + } +} diff --git a/src/modules/remnawave-backend.modules.ts b/src/modules/remnawave-backend.modules.ts index 7d5d39bb8..06c42e006 100644 --- a/src/modules/remnawave-backend.modules.ts +++ b/src/modules/remnawave-backend.modules.ts @@ -3,6 +3,7 @@ import { ConditionalModule } from '@nestjs/config'; import { isRestApi, isScheduler } from '@common/utils/startup-app'; +import { AcmeModule } from './acme/acme.module'; import { AdminModule } from './admin/admin.module'; import { ApiTokensModule } from './api-tokens/api-tokens.module'; import { AuthModule } from './auth/auth.module'; @@ -46,6 +47,9 @@ import { UsersModule } from './users/users.module'; KeygenModule, NodesModule, NodePluginModule, + // Certificates are read by the workers that build node configs and written + // by the REST API, so the module lives in both instance types. + AcmeModule, HostsModule, NodesUserUsageHistoryModule, HwidUserDevicesModule, diff --git a/src/queue/_acme/acme-queue.module.ts b/src/queue/_acme/acme-queue.module.ts new file mode 100644 index 000000000..8cace7c05 --- /dev/null +++ b/src/queue/_acme/acme-queue.module.ts @@ -0,0 +1,33 @@ +import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'; +import { BullBoardModule } from '@bull-board/nestjs'; + +import { BullModule } from '@nestjs/bullmq'; +import { Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; + +import { useBullBoard, useQueueProcessor } from '@common/utils/startup-app'; + +import { QUEUES_NAMES } from '../queue.enum'; +import { AcmeQueueProcessor } from './acme-queue.processor'; +import { AcmeQueueService } from './acme-queue.service'; + +const requiredModules = [CqrsModule]; + +const processors = [AcmeQueueProcessor]; +const services = [AcmeQueueService]; + +const queues = [BullModule.registerQueue({ name: QUEUES_NAMES.ACME.ISSUE })]; + +const bullBoard = [ + BullBoardModule.forFeature({ name: QUEUES_NAMES.ACME.ISSUE, adapter: BullMQAdapter }), +]; + +const providers = useQueueProcessor() ? processors : []; +const imports = useBullBoard() ? bullBoard : []; + +@Module({ + imports: [...queues, ...imports, ...requiredModules], + providers: [...providers, ...services], + exports: [...services], +}) +export class AcmeQueueModule {} diff --git a/src/queue/_acme/acme-queue.processor.ts b/src/queue/_acme/acme-queue.processor.ts new file mode 100644 index 000000000..cbafd3c94 --- /dev/null +++ b/src/queue/_acme/acme-queue.processor.ts @@ -0,0 +1,58 @@ +import { Job } from 'bullmq'; + +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Logger } from '@nestjs/common'; +import { CommandBus } from '@nestjs/cqrs'; + +import { IssueCertificateCommand } from '@modules/acme/commands/issue-certificate'; + +import { QUEUES_NAMES } from '../queue.enum'; +import { ACME_JOB_NAMES } from './constants'; + +// Orders spend most of their time waiting on DNS propagation, so running them +// in parallel is nearly free. Four keeps a full batch well inside the DNS +// broker's per-client rate limit; jobId = certificateUuid still guarantees one +// order per certificate at a time. +@Processor(QUEUES_NAMES.ACME.ISSUE, { + concurrency: 4, +}) +export class AcmeQueueProcessor extends WorkerHost { + private readonly logger = new Logger(AcmeQueueProcessor.name); + + constructor(private readonly commandBus: CommandBus) { + super(); + } + + async process(job: Job) { + switch (job.name) { + case ACME_JOB_NAMES.ISSUE_CERTIFICATE: + return await this.handleIssueCertificate(job); + + default: + this.logger.warn(`Job "${job.name}" is not handled.`); + break; + } + } + + private async handleIssueCertificate(job: Job) { + const { certificateUuid, force } = job.data as { + certificateUuid: string; + force?: boolean; + }; + + // The order records its own outcome on the certificate, so a failure here + // is logged and swallowed: throwing would only add a BullMQ retry on top + // of the backoff the engine already applied. + const result = await this.commandBus.execute( + new IssueCertificateCommand(certificateUuid, force ?? false), + ); + + if (!result.isOk) { + this.logger.error(`Issuance job failed: ${result.message}`); + + return; + } + + this.logger.log(result.response.message); + } +} diff --git a/src/queue/_acme/acme-queue.service.ts b/src/queue/_acme/acme-queue.service.ts new file mode 100644 index 000000000..96bacc7af --- /dev/null +++ b/src/queue/_acme/acme-queue.service.ts @@ -0,0 +1,51 @@ +import { Queue } from 'bullmq'; +import _ from 'lodash'; + +import { InjectQueue } from '@nestjs/bullmq'; +import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; + +import { QUEUES_NAMES } from '../queue.enum'; +import { AbstractQueueService } from '../queue.service'; +import { ACME_JOB_NAMES } from './constants'; + +@Injectable() +export class AcmeQueueService extends AbstractQueueService implements OnApplicationBootstrap { + protected readonly logger: Logger = new Logger( + _.upperFirst(_.camelCase(QUEUES_NAMES.ACME.ISSUE)), + ); + + private _queue: Queue; + + get queue(): Queue { + return this._queue; + } + + constructor( + @InjectQueue(QUEUES_NAMES.ACME.ISSUE) + private readonly acmeQueue: Queue, + ) { + super(); + this._queue = this.acmeQueue; + } + + public async onApplicationBootstrap(): Promise { + await this.checkConnection(); + + // One order at a time across the whole installation: CAs rate-limit by + // account, and two orders for the same name would fight over the same + // challenge record. + await this.queue.setGlobalConcurrency(1); + } + + public async issueCertificate(payload: { certificateUuid: string; force?: boolean }) { + return this.addJob(ACME_JOB_NAMES.ISSUE_CERTIFICATE, payload, { + // Keyed by certificate, so a scheduler tick that overlaps a manual + // "issue now" does not queue the same order twice. + jobId: payload.certificateUuid, + // Retries are the engine's business: it records the failure, backs + // off and lets the scheduler pick the certificate up again. + attempts: 1, + removeOnComplete: true, + }); + } +} diff --git a/src/queue/_acme/constants/acme-job-name.constant.ts b/src/queue/_acme/constants/acme-job-name.constant.ts new file mode 100644 index 000000000..810c974ac --- /dev/null +++ b/src/queue/_acme/constants/acme-job-name.constant.ts @@ -0,0 +1,3 @@ +export const ACME_JOB_NAMES = { + ISSUE_CERTIFICATE: 'issueCertificate', +} as const; diff --git a/src/queue/_acme/constants/index.ts b/src/queue/_acme/constants/index.ts new file mode 100644 index 000000000..c4a9c60e0 --- /dev/null +++ b/src/queue/_acme/constants/index.ts @@ -0,0 +1 @@ +export * from './acme-job-name.constant'; diff --git a/src/queue/_acme/index.ts b/src/queue/_acme/index.ts new file mode 100644 index 000000000..ec99fe3e4 --- /dev/null +++ b/src/queue/_acme/index.ts @@ -0,0 +1 @@ +export * from './acme-queue.service'; diff --git a/src/queue/_nodes/processors/start-all-nodes-by-profile.processor.ts b/src/queue/_nodes/processors/start-all-nodes-by-profile.processor.ts index 438062a94..b91e31df2 100644 --- a/src/queue/_nodes/processors/start-all-nodes-by-profile.processor.ts +++ b/src/queue/_nodes/processors/start-all-nodes-by-profile.processor.ts @@ -7,9 +7,14 @@ import { Logger, Scope } from '@nestjs/common'; import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { AxiosService } from '@common/axios/axios.service'; +import { + getCertificatesFingerprint, + injectNodeCertificates, +} from '@common/helpers/xray-config/inject-node-certificates'; import { RawCacheService } from '@common/raw-cache'; import { CACHE_KEYS, CACHE_KEYS_TTL } from '@libs/contracts/constants'; +import { GetCertificatesForNodeQuery } from '@modules/acme/queries/get-certificates-for-node'; import { ConfigProfileInboundEntity } from '@modules/config-profiles/entities'; import { NodePluginEntity } from '@modules/node-plugins/entities'; import { GetAllPluginsQuery } from '@modules/node-plugins/queries/get-all-plugins'; @@ -283,19 +288,39 @@ export class StartAllNodesByProfileQueueProcessor extends WorkerHost { (inbound) => activeNodeInboundsTags.has(inbound.tag), ); + let nodeConfig = { + ...config.response.config, + inbounds: config.response.config.inbounds!.filter( + (inbound) => + activeNodeInboundsTags.has(inbound.tag!) || + this.isUnsecureInbound(inbound.protocol), + ), + }; + + let emptyConfigHash = config.response.hashesPayload.emptyConfig; + + const certificates = await this.queryBus.execute( + new GetCertificatesForNodeQuery(node.uuid), + ); + + if (certificates.isOk && certificates.response.length > 0) { + // The config above is built once per profile and only shallow + // copied per node, so the inbound objects are shared. Injecting + // into them directly would deliver this node's private key to + // every other node on the profile. + nodeConfig = structuredClone(nodeConfig); + + injectNodeCertificates(nodeConfig, certificates.response); + + emptyConfigHash = `${emptyConfigHash}:${getCertificatesFingerprint(certificates.response)}`; + } + const startXrayResponse = await this.axios.startXray( { - xrayConfig: { - ...config.response.config, - inbounds: config.response.config.inbounds!.filter( - (inbound) => - activeNodeInboundsTags.has(inbound.tag!) || - this.isUnsecureInbound(inbound.protocol), - ), - } as unknown as Record, + xrayConfig: nodeConfig as unknown as Record, internals: { hashes: { - emptyConfig: config.response.hashesPayload.emptyConfig, + emptyConfig: emptyConfigHash, inbounds: filteredInboundsHashes, }, forceRestart: payload.force ?? false, diff --git a/src/queue/_nodes/processors/start-node.processor.ts b/src/queue/_nodes/processors/start-node.processor.ts index 8c3ae94ee..df3a7a1f4 100644 --- a/src/queue/_nodes/processors/start-node.processor.ts +++ b/src/queue/_nodes/processors/start-node.processor.ts @@ -7,12 +7,17 @@ import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { AxiosService } from '@common/axios/axios.service'; +import { + getCertificatesFingerprint, + injectNodeCertificates, +} from '@common/helpers/xray-config/inject-node-certificates'; import { RawCacheService } from '@common/raw-cache'; import { formatExecutionTime, getTime } from '@common/utils/get-elapsed-time'; import { CACHE_KEYS, CACHE_KEYS_TTL, EVENTS } from '@libs/contracts/constants'; import { NodeEvent } from '@integration-modules/notifications/interfaces'; +import { GetCertificatesForNodeQuery } from '@modules/acme/queries/get-certificates-for-node'; import { GetPluginByUuidQuery } from '@modules/node-plugins/queries/get-plugin-by-uuid'; import { UpdateNodeCommand } from '@modules/nodes/commands/update-node'; import { GetNodeByUuidQuery } from '@modules/nodes/queries/get-node-by-uuid'; @@ -202,13 +207,29 @@ export class StartNodeProcessor extends WorkerHost { throw new Error('Failed to get config for node'); } + // Certificates managed by the panel are added to this node's copy of + // the config, and their fingerprint goes into the hash: the profile + // itself does not change when a certificate is renewed, so without it + // the node would keep serving the expiring one. + const certificates = await this.queryBus.execute( + new GetCertificatesForNodeQuery(node.uuid), + ); + + const hashes = { ...config.response.hashesPayload }; + + if (certificates.isOk && certificates.response.length > 0) { + injectNodeCertificates(config.response.config, certificates.response); + + hashes.emptyConfig = `${hashes.emptyConfig}:${getCertificatesFingerprint(certificates.response)}`; + } + const reqStartTime = getTime(); const startNodeResult = await this.axios.startXray( { xrayConfig: config.response.config as unknown as Record, internals: { - hashes: config.response.hashesPayload, + hashes, forceRestart: force ?? false, }, }, diff --git a/src/queue/queue.enum.ts b/src/queue/queue.enum.ts index 57b00239e..9008ae5c4 100644 --- a/src/queue/queue.enum.ts +++ b/src/queue/queue.enum.ts @@ -15,6 +15,9 @@ export const QUEUES_NAMES = { SQUADS: { ACTIONS: 'SQUADS_ACTIONS_QUEUE', }, + ACME: { + ISSUE: 'ACME_ISSUE_QUEUE', + }, USERS: { SERIAL_OPERATIONS: 'USERS_SERIAL_OPERATIONS_QUEUE', MODIFY_MANY: 'USERS_MODIFY_MANY_QUEUE', diff --git a/src/queue/queue.module.ts b/src/queue/queue.module.ts index 286b34596..66948c30c 100644 --- a/src/queue/queue.module.ts +++ b/src/queue/queue.module.ts @@ -10,6 +10,7 @@ import { getRedisConnectionOptions } from '@common/utils'; import { useBullBoard } from '@common/utils/startup-app'; import { BULLBOARD_ROOT } from '@libs/contracts/api'; +import { AcmeQueueModule } from './_acme/acme-queue.module'; import { NodesQueuesModule } from './_nodes/nodes-queues.module'; import { SquadsQueueModule } from './_squads/squads-queue.module'; import { UsersQueuesModule } from './_users/users-queues.module'; @@ -22,6 +23,7 @@ const queueModules = [ UsersQueuesModule, PushFromRedisQueueModule, SquadsQueueModule, + AcmeQueueModule, ServiceQueueModule, diff --git a/src/scheduler/intervals.ts b/src/scheduler/intervals.ts index fd0e8cf10..4afa4afff 100644 --- a/src/scheduler/intervals.ts +++ b/src/scheduler/intervals.ts @@ -21,6 +21,10 @@ export const JOBS_INTERVALS = { RESET_NODE_TRAFFIC: CronExpression.EVERY_DAY_AT_1AM, REVIEW_NODES: CronExpression.EVERY_HOUR, + // Certificates are renewed weeks before they expire, so an hourly check is + // frequent enough; it also paces retries after a failed order. + ACME_RENEW: CronExpression.EVERY_HOUR, + RECORD_USER_USAGE: EVERY_15_SECONDS, EXPORT_NODE_CONNECTIONS: CronExpression.EVERY_5_MINUTES, diff --git a/src/scheduler/tasks/acme-renew/acme-renew.task.ts b/src/scheduler/tasks/acme-renew/acme-renew.task.ts new file mode 100644 index 000000000..14806725d --- /dev/null +++ b/src/scheduler/tasks/acme-renew/acme-renew.task.ts @@ -0,0 +1,51 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { QueryBus } from '@nestjs/cqrs'; +import { Cron } from '@nestjs/schedule'; + +import { GetCertificatesDueForRenewalQuery } from '@modules/acme/queries/get-certificates-due-for-renewal'; + +import { AcmeQueueService } from '@queue/_acme'; + +import { JOBS_INTERVALS } from '../../intervals'; + +/** + * Queues certificates that are due. + * + * "Due" covers three cases at once: never issued, inside the renewal window, and + * failed with the backoff expired. Certificates waiting for a record to be + * published by hand are left alone. + */ +@Injectable() +export class AcmeRenewTask { + private static readonly CRON_NAME = 'acmeRenew'; + private readonly logger = new Logger(AcmeRenewTask.name); + + constructor( + private readonly queryBus: QueryBus, + private readonly acmeQueueService: AcmeQueueService, + ) {} + + @Cron(JOBS_INTERVALS.ACME_RENEW, { + name: AcmeRenewTask.CRON_NAME, + waitForCompletion: true, + }) + async handleCron() { + try { + const result = await this.queryBus.execute(new GetCertificatesDueForRenewalQuery()); + + if (!result.isOk || result.response.length === 0) { + return; + } + + this.logger.log(`Queueing ${result.response.length} certificate(s) for issuance`); + + for (const certificate of result.response) { + await this.acmeQueueService.issueCertificate({ + certificateUuid: certificate.uuid, + }); + } + } catch (error) { + this.logger.error(error); + } + } +} diff --git a/src/scheduler/tasks/index.ts b/src/scheduler/tasks/index.ts index 518e38ea1..cbce537c7 100644 --- a/src/scheduler/tasks/index.ts +++ b/src/scheduler/tasks/index.ts @@ -1,3 +1,4 @@ +import { AcmeRenewTask } from './acme-renew/acme-renew.task'; import { InfraBillingNodesNotificationsTask } from './crm/infra-billing-nodes-notifications/infra-billing-nodes-notifications.task'; import { ExportMetricsTask } from './export-metrics/export-metrics.task'; import { SyncMetricsTask } from './export-metrics/sync-metrics.task'; @@ -10,4 +11,5 @@ export const JOBS_SERVICES = [ ExportMetricsTask, SyncMetricsTask, InfraBillingNodesNotificationsTask, + AcmeRenewTask, ];