Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
171 changes: 171 additions & 0 deletions docs/acme.md
Original file line number Diff line number Diff line change
@@ -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 <token>` and JSON bodies; errors come
back as `{"error": "<machine_code>", "message": "<text>"}`.

| Method and path | Body | Semantics |
| --- | --- | --- |
| `POST /v1/dns-01/present` | `{"fqdn": "_acme-challenge.a.example.com", "value": "<txt>"}` | 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.
5 changes: 5 additions & 0 deletions libs/contract/api/controllers-info.ts
Original file line number Diff line number Diff line change
@@ -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.',
Expand Down
29 changes: 29 additions & 0 deletions libs/contract/api/controllers/acme.ts
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions libs/contract/api/controllers/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './acme';
export * from './api-tokens';
export * from './auth';
export * from './bandwidth-stats';
Expand Down
31 changes: 31 additions & 0 deletions libs/contract/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof RequestBodySchema>;
export type Response = z.infer<typeof ResponseSchema>;
}
Original file line number Diff line number Diff line change
@@ -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<typeof RequestParamSchema>;
export type Response = z.infer<typeof ResponseSchema>;
}
Loading