Skip to content

feature: Add external ACME CA support#258

Closed
gyptazy wants to merge 1 commit intoPegaProx:mainfrom
gyptazy:feature/249-external-acme-support
Closed

feature: Add external ACME CA support#258
gyptazy wants to merge 1 commit intoPegaProx:mainfrom
gyptazy:feature/249-external-acme-support

Conversation

@gyptazy
Copy link
Copy Markdown
Contributor

@gyptazy gyptazy commented Apr 4, 2026

feature: Add external ACME CA support
This feature adds support for external CAs that provide ACME based certification issuing.

Fixes: #249

Tests

Tests have been performed with StepCA and ACME endpoint. Please do not wonder, I run very short-living certificates by only 24hrs which might not be used. But adjusting the renewal is also possible in PegaProx.

Adding external ACME CA Endpoint

pegaprox_gyptazy_acme_ca01

After issued certificate by CA

pegaprox_gyptazy_acme_ca02

@qodo-code-review
Copy link
Copy Markdown

Review Summary by Qodo

Add external ACME CA support for custom certificate providers

✨ Enhancement

Grey Divider

Walkthroughs

Description
• Add support for external ACME certificate authorities beyond Let's Encrypt
• Introduce ACME provider selection with Let's Encrypt and custom URL options
• Add ACME directory URL field for custom ACME-compatible certificate servers
• Update validation logic to handle provider-specific requirements
• Extend translations across multiple languages for new ACME provider features
Diagram
flowchart LR
  A["User Settings"] -->|Select Provider| B["ACME Provider Selector"]
  B -->|Let's Encrypt| C["Let's Encrypt Flow"]
  B -->|Custom ACME| D["Custom Directory URL"]
  C -->|Email Required| E["Certificate Request"]
  D -->|Directory URL Required| E
  E -->|request_certificate| F["ACME Client"]
  F -->|get_directory_url| G["Resolve ACME Endpoint"]
  G -->|HTTP-01 Challenge| H["Issue Certificate"]
Loading

Grey Divider

File Changes

1. web/src/settings_modal.js ✨ Enhancement +65/-14

Add ACME provider selection and custom directory URL support

• Add acme_provider and acme_directory_url fields to settings state initialization
• Implement conditional rendering of ACME provider dropdown selector
• Add custom ACME directory URL input field with placeholder and hint text
• Update form data submission to include new ACME provider and directory URL fields
• Modify validation logic to require email only for Let's Encrypt provider
• Add validation for custom ACME directory URL when custom provider is selected
• Update ACME request payload to include provider and directory_url parameters
• Conditionally show staging checkbox only for Let's Encrypt provider
• Update button disabled state to check provider-specific requirements
• Change certificate auto-renewal indicator from is_letsencrypt to !is_self_signed

web/src/settings_modal.js


2. web/src/translations.js 📝 Documentation +35/-5

Add multilingual translations for ACME provider features

• Update ACME section title from "Let's Encrypt (ACME)" to "ACME Certificates" across all languages
• Add new translation keys for ACME provider selection: acmeProvider, acmeProviderLetsEncrypt,
 acmeProviderCustom
• Add new translation keys for custom ACME directory URL: acmeDirectoryUrl, acmeDirectoryUrlHint
• Add optional email hint translation: acmeEmailOptionalHint for non-Let's Encrypt providers
• Update translations in German, English, French, Spanish, and Portuguese languages

web/src/translations.js


3. pegaprox/api/settings.py ✨ Enhancement +34/-7

Update settings API to handle ACME provider configuration

• Add acme_provider field validation accepting 'letsencrypt' or 'custom' values
• Add acme_directory_url field handling with automatic clearing for non-custom providers
• Update settings update endpoint to process new ACME provider and directory URL fields
• Add ACME settings to form data processing in settings update handler
• Include new fields in get_acme_status() response for frontend synchronization
• Update audit logging to reference generic "ACME certificate" instead of Let's Encrypt specific
• Add validation for custom ACME directory URL format (http/https protocol check)
• Pass directory_url parameter to request_certificate() function call

pegaprox/api/settings.py


View more (3)
4. pegaprox/core/acme.py ✨ Enhancement +17/-10

Extend ACME client to support custom certificate authorities

• Add new get_directory_url() helper function to resolve ACME endpoint based on provider
• Update request_certificate() to accept optional directory_url parameter
• Modify certificate request logging to show environment type and ACME URL
• Add HTTP status check with raise_for_status() for directory request
• Update check_and_renew() to accept and pass through directory_url parameter
• Remove provider-specific check that skipped non-Let's Encrypt certificates
• Update docstrings to reference generic ACME instead of Let's Encrypt

pegaprox/core/acme.py


5. pegaprox/app.py ✨ Enhancement +3/-1

Enable ACME renewal for custom providers

• Update ACME renewal loop to pass directory_url from settings to check_and_renew()
• Enable certificate renewal for custom ACME providers during background renewal checks

pegaprox/app.py


6. web/index.html Additional files +10/-10

...

web/index.html


Grey Divider

Qodo Logo

@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented Apr 4, 2026

Code Review by Qodo

🐞 Bugs (4) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX Issues (0)

Grey Divider


Action required

1. Custom provider LE fallback 🐞 Bug ≡ Correctness
Description
update_server_settings() can persist acme_provider='custom' with an empty acme_directory_url; the
renewal loop then passes an empty directory_url and get_directory_url() silently falls back to Let’s
Encrypt. This can renew/issue from the wrong CA or cause confusing renewal failures when the
operator intended a custom CA.
Code

pegaprox/api/settings.py[R976-982]

+            if 'acme_provider' in data:
+                provider = str(data['acme_provider'] or 'letsencrypt').strip()
+                settings['acme_provider'] = provider if provider in ('letsencrypt', 'custom') else 'letsencrypt'
+            if 'acme_directory_url' in data:
+                settings['acme_directory_url'] = str(data['acme_directory_url'] or '').strip()
+            if settings.get('acme_provider') != 'custom':
+                settings['acme_directory_url'] = ''
Evidence
Server settings updates accept and store acme_provider/acme_directory_url without requiring a
directory URL for the custom provider, while the ACME client defaults to Let’s Encrypt whenever
directory_url is empty. The background renewal loop passes only acme_directory_url (not provider),
so a custom provider with a blank URL will resolve to Let’s Encrypt endpoints during renewal.

pegaprox/api/settings.py[969-982]
pegaprox/api/settings.py[1272-1279]
pegaprox/app.py[671-693]
pegaprox/core/acme.py[117-123]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`acme_provider='custom'` can be saved with an empty `acme_directory_url`, and the renewal thread will then fall back to Let’s Encrypt because `get_directory_url()` defaults when the URL is blank.

## Issue Context
This can lead to certificates being renewed/issued from the wrong CA or repeated renewal failures.

## Fix Focus Areas
- pegaprox/api/settings.py[969-982]
- pegaprox/api/settings.py[1272-1279]
- pegaprox/app.py[671-688]
- pegaprox/core/acme.py[117-123]

## Suggested fix
- In both JSON and form-data paths of `update_server_settings()`: if `acme_provider == 'custom'`, require a non-empty `acme_directory_url` (and validate it) or reject the update with 400.
- In the renewal loop: only pass/use `directory_url` when provider is `custom`; otherwise force empty.
- In `get_directory_url()`: consider failing (raising/returning error) when provider is custom but URL missing, rather than defaulting to Let’s Encrypt.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. ACME directory SSRF 🐞 Bug ⛨ Security
Description
Custom acme_directory_url is fetched server-side and the returned directory JSON controls subsequent
HEAD/POST request targets (newNonce/newAccount/newOrder/etc.), enabling SSRF to attacker-chosen URLs
(including internal services). This can be abused to reach internal-only endpoints from the PegaProx
host network context.
Code

pegaprox/core/acme.py[R117-141]

+def get_directory_url(staging=False, directory_url=None):
+    """Resolve the ACME directory URL, defaulting to Let's Encrypt."""
+    custom_url = (directory_url or '').strip()
+    if custom_url:
+        return custom_url
+    return ACME_DIRECTORY_STAGING if staging else ACME_DIRECTORY_PROD
+
+
+def request_certificate(domain, email, ssl_dir, staging=False, directory_url=None):
    """
-    Request a Let's Encrypt certificate via HTTP-01 challenge.
+    Request an ACME certificate via HTTP-01 challenge.

    Returns dict with 'success', 'message', optionally 'cert_path', 'key_path', 'expires'.
    The caller must ensure /.well-known/acme-challenge/<token> is served on port 80.
    """
-    acme_url = ACME_DIRECTORY_STAGING if staging else ACME_DIRECTORY_PROD
+    acme_url = get_directory_url(staging=staging, directory_url=directory_url)
    account_key_path = os.path.join(ssl_dir, 'acme_account.key')

    try:
        # Step 1: Load directory
-        logging.info(f"[ACME] Starting certificate request for {domain} ({'staging' if staging else 'production'})")
+        environment = 'custom' if (directory_url or '').strip() else ('staging' if staging else 'production')
+        logging.info(f"[ACME] Starting certificate request for {domain} ({environment}) via {acme_url}")
        dir_resp = requests.get(acme_url, timeout=15)
+        dir_resp.raise_for_status()
        directory = dir_resp.json()
Evidence
The request endpoint persists a user-supplied directory_url and passes it into the ACME client. The
client performs requests.get(acme_url) and then trusts URLs inside the directory JSON for
subsequent requests.head() and requests.post() calls, so a malicious directory response can
redirect the server to arbitrary internal URLs.

pegaprox/api/settings.py[1491-1524]
pegaprox/core/acme.py[117-142]
pegaprox/core/acme.py[107-110]
pegaprox/core/acme.py[84-104]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The ACME directory URL is user-configurable, and the directory response controls additional URLs that the server will call (`newNonce`, `newAccount`, `newOrder`, etc.). This enables SSRF because the server will make HTTP requests to attacker-provided URLs.

## Issue Context
Even if the initial `directory_url` looks benign, a malicious directory can return endpoint URLs pointing to internal IPs/hosts.

## Fix Focus Areas
- pegaprox/api/settings.py[1491-1524]
- pegaprox/core/acme.py[117-142]
- pegaprox/core/acme.py[107-110]
- pegaprox/core/acme.py[84-104]

## Suggested fix
- Parse and validate `directory_url` using `urllib.parse`.
- Enforce a strict policy for all directory-provided endpoints:
 - same scheme/host/port as the original `directory_url` (same-origin), OR an explicit allowlist.
 - disallow redirects (`allow_redirects=False`) when fetching the directory and/or for subsequent calls.
 - resolve hostnames and block private/loopback/link-local ranges.
- Consider storing a validated, normalized URL and rejecting unsafe ones at save time.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Renewal overwrites self-signed 🐞 Bug ☼ Reliability
Description
check_and_renew() no longer gates renewals by issuer and does not check is_self_signed, so once ACME
is enabled it may attempt renewal and replace even self-signed/manual certificates when they near
expiry. This can cause unexpected certificate changes and downtime if ACME prerequisites (port 80
routing/domain) aren’t met.
Code

pegaprox/core/acme.py[R346-357]

+def check_and_renew(domain, email, ssl_dir, staging=False, days_before=30, directory_url=None):
    """Check if cert needs renewal and renew if so. Returns True if renewed."""
    info = get_cert_info(ssl_dir)
    if not info:
        return False

-    if not info['is_letsencrypt']:
-        return False
-
    if info['days_left'] > days_before:
        logging.debug(f"[ACME] Cert still valid for {info['days_left']} days, no renewal needed")
        return False

    logging.info(f"[ACME] Cert expires in {info['days_left']} days, renewing...")
-    result = request_certificate(domain, email, ssl_dir, staging)
+    result = request_certificate(domain, email, ssl_dir, staging=staging, directory_url=directory_url)
Evidence
get_cert_info() computes is_self_signed, but check_and_renew() renews solely based on days_left
and will call request_certificate() regardless of certificate type. The renewal loop triggers
whenever acme_enabled is true and a domain is set.

pegaprox/core/acme.py[297-340]
pegaprox/core/acme.py[346-363]
pegaprox/app.py[671-688]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
ACME auto-renewal now runs for self-signed/manual certs because issuer-based gating was removed and there is no replacement check.

## Issue Context
This can lead to unexpected certificate replacement or repeated renewal failures.

## Fix Focus Areas
- pegaprox/core/acme.py[297-363]
- pegaprox/app.py[671-688]

## Suggested fix
- Add an explicit guard in `check_and_renew()`:
 - `if info.get('is_self_signed'): return False`
- Optionally also require that ACME is "managed" (e.g., a stored flag set only after a successful ACME issuance) before renewing.
- In the renewal loop, validate provider configuration (custom requires directory_url) before calling `check_and_renew()`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

4. HTTP ACME URL allowed 🐞 Bug ⛨ Security
Description
The ACME request endpoint explicitly accepts custom directory URLs starting with http://, allowing
certificate issuance and account operations over plaintext. This weakens transport security and can
enable interception or modification of ACME traffic on the network path.
Code

pegaprox/api/settings.py[R1501-1505]

+        if acme_provider == 'custom':
+            if not directory_url:
+                return jsonify({'error': 'Custom ACME directory URL is required'}), 400
+            if not directory_url.startswith(('https://', 'http://')):
+                return jsonify({'error': 'ACME directory URL must start with http:// or https://'}), 400
Evidence
The API validation allows both http:// and https:// for custom directory URLs, and the ACME client
then uses requests.get/head/post against those URLs. This means the protocol can be plaintext if
http:// is configured.

pegaprox/api/settings.py[1501-1508]
pegaprox/core/acme.py[137-142]
pegaprox/core/acme.py[84-104]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Custom ACME directory URLs are allowed to use `http://`, which permits plaintext ACME operations.

## Issue Context
The ACME client subsequently performs HEAD/POST/GET calls to the directory and its endpoint URLs.

## Fix Focus Areas
- pegaprox/api/settings.py[1501-1505]

## Suggested fix
- Require `https://` for `acme_provider == 'custom'` (return 400 otherwise).
- If HTTP must be supported for specific environments, gate it behind an explicit advanced setting and display a strong warning in UI/logs.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

Comment thread pegaprox/api/settings.py
Comment on lines +976 to +982
if 'acme_provider' in data:
provider = str(data['acme_provider'] or 'letsencrypt').strip()
settings['acme_provider'] = provider if provider in ('letsencrypt', 'custom') else 'letsencrypt'
if 'acme_directory_url' in data:
settings['acme_directory_url'] = str(data['acme_directory_url'] or '').strip()
if settings.get('acme_provider') != 'custom':
settings['acme_directory_url'] = ''
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Custom provider le fallback 🐞 Bug ≡ Correctness

update_server_settings() can persist acme_provider='custom' with an empty acme_directory_url; the
renewal loop then passes an empty directory_url and get_directory_url() silently falls back to Let’s
Encrypt. This can renew/issue from the wrong CA or cause confusing renewal failures when the
operator intended a custom CA.
Agent Prompt
## Issue description
`acme_provider='custom'` can be saved with an empty `acme_directory_url`, and the renewal thread will then fall back to Let’s Encrypt because `get_directory_url()` defaults when the URL is blank.

## Issue Context
This can lead to certificates being renewed/issued from the wrong CA or repeated renewal failures.

## Fix Focus Areas
- pegaprox/api/settings.py[969-982]
- pegaprox/api/settings.py[1272-1279]
- pegaprox/app.py[671-688]
- pegaprox/core/acme.py[117-123]

## Suggested fix
- In both JSON and form-data paths of `update_server_settings()`: if `acme_provider == 'custom'`, require a non-empty `acme_directory_url` (and validate it) or reject the update with 400.
- In the renewal loop: only pass/use `directory_url` when provider is `custom`; otherwise force empty.
- In `get_directory_url()`: consider failing (raising/returning error) when provider is custom but URL missing, rather than defaulting to Let’s Encrypt.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread pegaprox/core/acme.py
Comment on lines +117 to 141
def get_directory_url(staging=False, directory_url=None):
"""Resolve the ACME directory URL, defaulting to Let's Encrypt."""
custom_url = (directory_url or '').strip()
if custom_url:
return custom_url
return ACME_DIRECTORY_STAGING if staging else ACME_DIRECTORY_PROD


def request_certificate(domain, email, ssl_dir, staging=False, directory_url=None):
"""
Request a Let's Encrypt certificate via HTTP-01 challenge.
Request an ACME certificate via HTTP-01 challenge.

Returns dict with 'success', 'message', optionally 'cert_path', 'key_path', 'expires'.
The caller must ensure /.well-known/acme-challenge/<token> is served on port 80.
"""
acme_url = ACME_DIRECTORY_STAGING if staging else ACME_DIRECTORY_PROD
acme_url = get_directory_url(staging=staging, directory_url=directory_url)
account_key_path = os.path.join(ssl_dir, 'acme_account.key')

try:
# Step 1: Load directory
logging.info(f"[ACME] Starting certificate request for {domain} ({'staging' if staging else 'production'})")
environment = 'custom' if (directory_url or '').strip() else ('staging' if staging else 'production')
logging.info(f"[ACME] Starting certificate request for {domain} ({environment}) via {acme_url}")
dir_resp = requests.get(acme_url, timeout=15)
dir_resp.raise_for_status()
directory = dir_resp.json()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Acme directory ssrf 🐞 Bug ⛨ Security

Custom acme_directory_url is fetched server-side and the returned directory JSON controls subsequent
HEAD/POST request targets (newNonce/newAccount/newOrder/etc.), enabling SSRF to attacker-chosen URLs
(including internal services). This can be abused to reach internal-only endpoints from the PegaProx
host network context.
Agent Prompt
## Issue description
The ACME directory URL is user-configurable, and the directory response controls additional URLs that the server will call (`newNonce`, `newAccount`, `newOrder`, etc.). This enables SSRF because the server will make HTTP requests to attacker-provided URLs.

## Issue Context
Even if the initial `directory_url` looks benign, a malicious directory can return endpoint URLs pointing to internal IPs/hosts.

## Fix Focus Areas
- pegaprox/api/settings.py[1491-1524]
- pegaprox/core/acme.py[117-142]
- pegaprox/core/acme.py[107-110]
- pegaprox/core/acme.py[84-104]

## Suggested fix
- Parse and validate `directory_url` using `urllib.parse`.
- Enforce a strict policy for all directory-provided endpoints:
  - same scheme/host/port as the original `directory_url` (same-origin), OR an explicit allowlist.
  - disallow redirects (`allow_redirects=False`) when fetching the directory and/or for subsequent calls.
  - resolve hostnames and block private/loopback/link-local ranges.
- Consider storing a validated, normalized URL and rejecting unsafe ones at save time.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Contributor Author

@gyptazy gyptazy Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes and when running an internal CA we of course need to access private/internal (RFC1918) addresses which are not public accessible. A personal CA must not be operated on public facing interfaces/addresses. It can also be operated on non standard ports which makes it non-sense to make further validations as https://myca.company.dev:8443 (pointing to 192.168.0.10) would also be legit.

Comment thread pegaprox/core/acme.py
Comment on lines +346 to +357
def check_and_renew(domain, email, ssl_dir, staging=False, days_before=30, directory_url=None):
"""Check if cert needs renewal and renew if so. Returns True if renewed."""
info = get_cert_info(ssl_dir)
if not info:
return False

if not info['is_letsencrypt']:
return False

if info['days_left'] > days_before:
logging.debug(f"[ACME] Cert still valid for {info['days_left']} days, no renewal needed")
return False

logging.info(f"[ACME] Cert expires in {info['days_left']} days, renewing...")
result = request_certificate(domain, email, ssl_dir, staging)
result = request_certificate(domain, email, ssl_dir, staging=staging, directory_url=directory_url)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

3. Renewal overwrites self-signed 🐞 Bug ☼ Reliability

check_and_renew() no longer gates renewals by issuer and does not check is_self_signed, so once ACME
is enabled it may attempt renewal and replace even self-signed/manual certificates when they near
expiry. This can cause unexpected certificate changes and downtime if ACME prerequisites (port 80
routing/domain) aren’t met.
Agent Prompt
## Issue description
ACME auto-renewal now runs for self-signed/manual certs because issuer-based gating was removed and there is no replacement check.

## Issue Context
This can lead to unexpected certificate replacement or repeated renewal failures.

## Fix Focus Areas
- pegaprox/core/acme.py[297-363]
- pegaprox/app.py[671-688]

## Suggested fix
- Add an explicit guard in `check_and_renew()`:
  - `if info.get('is_self_signed'): return False`
- Optionally also require that ACME is "managed" (e.g., a stored flag set only after a successful ACME issuance) before renewing.
- In the renewal loop, validate provider configuration (custom requires directory_url) before calling `check_and_renew()`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

MrMasterbay added a commit that referenced this pull request Apr 6, 2026
- Client Portal plugin: self-service VM management for hosting customers
- Public Status Page plugin: cluster health for monitoring screens (#126)
- Integrated syslog server (UDP/TCP port 1514) with log viewer (PR #257, gyptazy)
- External ACME CA support — custom directory URLs e.g. StepCA (#249, PR #258, gyptazy)
- Markdown VM descriptions with edit/preview toggle (PR #263, newtscamander2)
- Inline tag selector with Proxmox tag dropdown + validation (PR #263)
- Node/tag filter dropdowns in Resource Management (PR #263)
- Plugin config editor (JSON textarea in Settings UI)
- portal_only user flag, VNC audit logging, portal actions in task bar
- fix: setReconfigureCluster prop missing in sidebar (PR #261, newtscamander2)
- fix: OIDC skip JWT verification not persisted (#188)
- fix: list view table too wide in modern layout
- security: timing-safe auth key, TOTP rate limit, absolute session timeout
- security: admin pw change revokes all sessions, plugin path traversal fix
- security: file upload magic bytes, API token escalation fix, DB key permissions
@MrMasterbay
Copy link
Copy Markdown
Contributor

Close den Pull da manuell implementiert :) .

Auch erwähnt natürlich

@MrMasterbay MrMasterbay closed this Apr 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ACME for non-Let's Encrypt ACME servers

2 participants