Skip to content

feature: Add DNS-01 challenge support for certificate requests#429

Merged
MrMasterbay merged 1 commit into
PegaProx:Testingfrom
gyptazy:feature/420-add-letsecnrypt-dns-01-support
May 30, 2026
Merged

feature: Add DNS-01 challenge support for certificate requests#429
MrMasterbay merged 1 commit into
PegaProx:Testingfrom
gyptazy:feature/420-add-letsecnrypt-dns-01-support

Conversation

@gyptazy
Copy link
Copy Markdown
Contributor

@gyptazy gyptazy commented May 19, 2026

feature: Add DNS-01 challenge support for certificate requests

Fixes: #420
Sponsored-by: credativ GmbH https://credativ.de

@gyptazy gyptazy marked this pull request as draft May 19, 2026 05:06
@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented May 19, 2026

Review Summary by Qodo

(Agentic_describe updated until commit 1c1b2c1)

Add DNS-01 ACME challenge support with RFC 2136 dynamic DNS

✨ Enhancement

Grey Divider

Walkthroughs

Description
• Add DNS-01 challenge support alongside HTTP-01 for ACME certificate requests
• Support RFC 2136 dynamic DNS updates for automated DNS record management
• Add manual DNS-01 mode for users to create TXT records manually
• Implement two-step DNS-01 flow: prepare challenge, then complete after propagation
• Add comprehensive UI controls for DNS provider configuration and TSIG settings
• Add dnspython dependency for RFC 2136 DNS update operations
Diagram
flowchart LR
  User["User selects<br/>DNS-01 challenge"] -->|Choose provider| Manual["Manual TXT<br/>Record"]
  User -->|Choose provider| RFC2136["RFC 2136<br/>Dynamic DNS"]
  Manual -->|Prepare challenge| PrepDNS["Prepare DNS<br/>Challenge"]
  RFC2136 -->|Auto-update DNS| PrepDNS
  PrepDNS -->|Show TXT record| Display["Display dns_name<br/>& dns_value"]
  Display -->|Admin creates record| Wait["Wait for<br/>propagation"]
  Wait -->|Complete validation| Validate["Validate challenge<br/>& issue cert"]
  Validate -->|Success| Cert["Certificate<br/>issued"]

Loading

File Changes

1. web/src/settings_modal.js ✨ Enhancement +236/-5

Add DNS-01 UI controls and challenge flow

• Add state variables for DNS-01 challenge type and RFC 2136 configuration (nameserver, port, zone,
 key name, secret, algorithm, TTL, propagation seconds)
• Add UI dropdown to select between HTTP-01 and DNS-01 challenge types
• Add conditional RFC 2136 configuration form with 8 input fields for TSIG settings
• Add DNS-01 challenge preparation display showing TXT record name and value
• Add "Continue DNS Validation" button to complete pending DNS-01 challenges
• Update ACME request button text and validation logic based on challenge type
• Pass DNS configuration to backend ACME request and completion endpoints

web/src/settings_modal.js


2. web/src/translations.js 📝 Documentation +44/-0

Add DNS-01 translation strings

• Add 22 new translation keys for DNS-01 challenge UI in both German and English
• Include labels for challenge type selection, DNS provider selection, RFC 2136 fields
• Add hint text explaining DNS-01 requirements and RFC 2136 automatic updates
• Add instructions for manual TXT record creation and propagation waiting

web/src/translations.js


3. pegaprox/api/helpers.py ⚙️ Configuration changes +10/-0

Add DNS-01 default settings

• Add 9 new default settings for DNS-01 configuration (challenge type, provider, RFC 2136
 parameters)
• Initialize defaults for nameserver, port, zone, key name, secret, algorithm, TTL, and propagation
 seconds

pegaprox/api/helpers.py


View more (5)
4. pegaprox/api/settings.py ✨ Enhancement +121/-4

Add DNS-01 settings endpoints and validation

• Add _sanitize_acme_dns_settings() helper to validate and sanitize RFC 2136 configuration with
 bounds checking
• Add _acme_dns_config() helper to build DNS config dict from settings
• Update get_server_settings() to mask TSIG secret like other sensitive fields
• Update update_server_settings() to handle challenge type and DNS settings in both JSON and form
 data
• Add new /api/settings/acme/dns/complete endpoint to finalize pending DNS-01 challenges
• Update request_acme_certificate() to accept and validate challenge type and DNS provider
 parameters
• Pass DNS configuration to backend ACME functions

pegaprox/api/settings.py


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

Pass DNS-01 config to renewal loop

• Update ACME renewal loop to pass challenge type, DNS provider, and DNS configuration to
 check_and_renew()
• Build DNS config dict from settings including RFC 2136 parameters

pegaprox/app.py


6. pegaprox/core/acme.py ✨ Enhancement +370/-144

Implement DNS-01 challenge logic and RFC 2136 support

• Add _dns01_value() to compute DNS-01 TXT record value from key authorization
• Add _dns01_record_name() to generate _acme-challenge DNS record name
• Add _rfc2136_algorithm() to map algorithm names to dnspython TSIG constants
• Add _rfc2136_update() to create/delete DNS records via RFC 2136 dynamic DNS with TSIG
• Add _validate_challenge() to poll ACME authorization status after challenge submission
• Add _finalize_order() to generate CSR, finalize order, and save certificate files
• Add _create_order() to handle ACME order creation and authorization retrieval
• Add prepare_dns01_challenge() for manual DNS-01 flow with pending challenge storage
• Add complete_dns01_challenge() to finalize pending DNS-01 challenges after TXT propagation
• Refactor request_certificate() to support both HTTP-01 and DNS-01 with provider selection
• Update check_and_renew() to accept challenge type, DNS provider, and DNS config parameters
• Add global _pending_dns_challenges dict to store DNS-01 challenges awaiting completion

pegaprox/core/acme.py


7. debian/control Dependencies +1/-0

Add dnspython dependency

• Add python3-dnspython as a new dependency for RFC 2136 DNS operations

debian/control


8. requirements.txt Dependencies +1/-0

Add dnspython requirement

• Add dnspython>=2.7.0 dependency for RFC 2136 DNS update functionality

requirements.txt


Grey Divider

ⓘ You are approaching your monthly quota for Qodo. Upgrade your plan

Qodo Logo

@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented May 19, 2026

Code Review by Qodo

🐞 Bugs (9) 📘 Rule violations (0) 📎 Requirement gaps (5)

Context used

Grey Divider


Action required

1. TSIG secret stored plaintext 🐞 Bug ⛨ Security ⭐ New
Description
_sanitize_acme_dns_settings() persists acme_dns_rfc2136_secret directly into server settings, and
server settings are stored as JSON values in SQLite without field-level encryption. This leaves the
RFC2136 TSIG credential readable at rest (e.g., from DB access/backups) and usable for unauthorized
dynamic DNS updates.
Code

pegaprox/api/settings.py[R42-45]

Evidence
The settings sanitizer writes the TSIG secret directly into the persisted settings dict, while the
DB layer stores settings values via JSON serialization without encrypting individual fields; this
contrasts with existing encrypted-secret handling for SMTP passwords in the same settings module.

pegaprox/api/settings.py[34-45]
pegaprox/api/settings.py[1173-1178]
pegaprox/core/db.py[3885-3899]

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_dns_rfc2136_secret` is currently stored as plaintext in `server_settings` (only masked on read), unlike other secrets (e.g., SMTP password) which are encrypted before persisting.

### Issue Context
- `save_server_settings()` ultimately persists each key via `PegaProxDB.save_server_setting()`, which JSON-dumps the value and writes it as-is.
- There is an established pattern for encrypting secrets at rest (`get_db()._encrypt()` / `_decrypt()`), used for `smtp_password`.

### Fix Focus Areas
- pegaprox/api/settings.py[34-45]
- pegaprox/api/settings.py[1173-1178]
- pegaprox/core/db.py[3885-3899]

### Implementation notes
- Encrypt `acme_dns_rfc2136_secret` before saving (similar to `smtp_password`).
- Decrypt the secret only at the point of use (e.g., when building `dns_config` passed to RFC2136 update calls / renewal loop), with a legacy fallback if the stored value is already plaintext.
- Keep existing masking behavior for API responses (`'********'`).

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


2. Global acme_challenge_type setting 📎 Requirement gap ⚙ Maintainability
Description
The PR stores acme_challenge_type as a single server-wide setting and uses it for renewal, so the
ACME challenge type cannot be selected per certificate. This violates the requirement for
per-certificate challenge selection in mixed environments.
Code

pegaprox/api/settings.py[R1652-1681]

Evidence
PR Compliance ID 3 requires challenge type selection per certificate, but the PR introduces and
relies on a single global acme_challenge_type in server settings and uses it for renewal,
preventing independent selection for different certificates.

Allow per-certificate selection of ACME challenge type (HTTP-01 vs DNS-01)
pegaprox/api/helpers.py[26-43]
pegaprox/api/settings.py[1649-1694]
pegaprox/app.py[852-881]

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_challenge_type` (and DNS provider config) is persisted as a single global server setting and then used by the renewal loop, so it cannot vary per certificate/domain. The compliance requirement expects per-certificate selection (HTTP-01 vs DNS-01), not a global-only toggle.
## Issue Context
- `request_acme_certificate()` persists the chosen challenge type into server settings.
- `acme_renewal_loop()` reads that single value for renewal.
- There is no per-certificate configuration object/record that would allow different domains/certs to use different challenge types.
## Fix Focus Areas
- pegaprox/api/settings.py[1649-1707]
- pegaprox/app.py[852-881]
- pegaprox/api/helpers.py[26-43]

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


3. Clears all HTTP-01 tokens 🐞 Bug ☼ Reliability
Description
In pegaprox.core.acme.request_certificate(), the exception handler calls
_pending_challenges.clear(), which can delete tokens for other in-flight HTTP-01 validations and
make the ACME challenge endpoint return 404 for those tokens. This can cause unrelated certificate
requests/renewals to fail under concurrent ACME operations.
Code

pegaprox/core/acme.py[R392-396]

Evidence
The code uses a process-global map for HTTP-01 tokens that is directly queried by the public ACME
challenge route; clearing the entire map on one request’s exception removes tokens needed by other
ongoing validations.

pegaprox/core/acme.py[23-28]
pegaprox/core/acme.py[380-396]
pegaprox/app.py[99-106]

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

## Issue description
`request_certificate()` clears the entire global `_pending_challenges` dict on *any* exception. This can remove tokens belonging to other in-flight HTTP-01 requests.
### Issue Context
`/.well-known/acme-challenge/<token>` serves values from `_pending_challenges`. Clearing the dict system-wide breaks unrelated challenges.
### Fix Focus Areas
- pegaprox/core/acme.py[392-396]
### Implementation notes
- Track the current `token` in scope and only `pop(token, None)` in the exception path.
- Avoid global `.clear()`; if you need cleanup, remove only entries created by this request.
- If `token` may be unset, guard accordingly (e.g., `if token: ...`).

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


View more (6)
4. RFC2136 blocks request thread 🐞 Bug ☼ Reliability
Description
The RFC2136 DNS-01 path sleeps for propagation and then synchronously polls ACME validation, causing
/api/settings/acme/request to block for up to ~11 minutes and tie up a worker while the client/proxy
may time out. Users can see a failure/timeout even if the certificate issuance eventually succeeds,
and busy servers can suffer worker starvation.
Code

pegaprox/core/acme.py[R427-438]

Evidence
The RFC2136 DNS-01 handler sleeps for propagation and then calls a polling validator; the settings
API executes this synchronously before responding, guaranteeing long-lived HTTP requests.

pegaprox/core/acme.py[196-217]
pegaprox/core/acme.py[421-453]
pegaprox/api/settings.py[1633-1715]

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 RFC2136 DNS-01 issuance path performs `time.sleep(propagation_seconds)` and then `_validate_challenge()` polling inside the HTTP request handler call chain. This can make the request take minutes.
### Issue Context
`request_acme_certificate()` calls `request_certificate()` inline and returns only when it finishes.
### Fix Focus Areas
- pegaprox/core/acme.py[427-438]
- pegaprox/api/settings.py[1703-1707]
### Implementation notes
Choose one:
1) **Async/background job**: enqueue RFC2136 issuance and return a job id/status endpoint.
2) **Two-step flow** (like manual): return `pending_dns` immediately after creating TXT; let a separate endpoint trigger validation/finalization.
Also consider hard upper bounds that keep request durations below typical reverse proxy timeouts.

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


5. DNS-01 lacks provider automation 📎 Requirement gap ⚙ Maintainability
Description
The DNS-01 flow requires an admin to manually create the TXT record and the code does not provide
any pluggable DNS provider model or webhook/script hook to create/update/remove _acme-challenge
records. This fails the requirement for an integration mechanism that can manage DNS records
throughout the DNS-01 lifecycle.
Code

pegaprox/core/acme.py[R339-376]

Evidence
PR Compliance ID 2 requires a pluggable provider model or generic hook to manage DNS TXT records.
The new DNS-01 implementation explicitly returns instructions for the admin to manually create the
TXT record and contains no provider/hook invocation for DNS record creation or cleanup.

Provide a pluggable DNS provider model or a generic webhook/script hook for DNS-01
pegaprox/core/acme.py[339-376]

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

## Issue description
DNS-01 currently requires manual DNS TXT record management and does not provide a pluggable provider model or a generic webhook/script hook to create/update/remove ACME TXT records.
## Issue Context
Compliance requires an extension/integration mechanism for DNS-01 that can reliably manage TXT records during issuance/renewal (create/update and cleanup/remove).
## Fix Focus Areas
- pegaprox/core/acme.py[339-376]

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


6. DNS-01 renewal explicitly skipped 📎 Requirement gap ☼ Reliability
Description
Automatic renewal is explicitly disabled when challenge_type is dns-01, so DNS-01 certificates
will not renew via the existing renewal workflow. This violates the requirement that issuance and
renewal workflows support DNS-01.
Code

pegaprox/core/acme.py[R481-483]

Evidence
PR Compliance ID 1 requires DNS-01 support for renewal, but check_and_renew() logs and returns
without attempting renewal when challenge_type == 'dns-01', so the daily renewal loop cannot renew
DNS-01 certificates.

Add ACME DNS-01 challenge support alongside existing HTTP-01
pegaprox/core/acme.py[466-485]
pegaprox/app.py[863-868]

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 renewal path returns early for `dns-01`, preventing DNS-01 certificates from being renewed by the renewal loop.
## Issue Context
Compliance requires DNS-01 to work for both issuance and renewal workflows.
## Fix Focus Areas
- pegaprox/core/acme.py[466-486]
- pegaprox/app.py[863-868]

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


7. Wildcard dns_name includes asterisk 📎 Requirement gap ≡ Correctness
Description
For wildcard requests like *.example.com, prepare_dns01_challenge() builds the DNS-01 TXT record
name as _acme-challenge.*.example.com, which is not a valid/correct ACME DNS-01 record name and
will cause wildcard validation (and thus wildcard certificate issuance) to fail. Wildcard DNS-01
challenges must publish the TXT record under _acme-challenge.example.com (without the *.).
Code

pegaprox/core/acme.py[R358-359]

Evidence
PR Compliance ID 4 requires wildcard certificates to work via DNS-01, but the referenced code
constructs dns_name by directly concatenating _acme-challenge. with the raw requested domain
and returning it to the UI; when the ACME identifier is a wildcard like *.example.com, the
domain value includes the leading *. so the resulting TXT name becomes
_acme-challenge.*.example.com, demonstrating why the record name is wrong and why wildcard DNS-01
validation cannot succeed.

Support wildcard certificates enabled by DNS-01 (e.g., *.internal.example.com)
pegaprox/core/acme.py[355-360]
pegaprox/core/acme.py[355-376]
web/src/settings_modal.js[5856-5884]

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

## Issue description
`prepare_dns01_challenge()` constructs the DNS-01 TXT record name as `_acme-challenge.{domain}` using `domain` verbatim; for wildcard identifiers (where `domain` starts with `*.`), this produces an invalid/incorrect TXT record name (`_acme-challenge.*.example.com`) and prevents wildcard certificate issuance.
## Issue Context
Wildcard certificates require DNS-01, and ACME orders may use identifiers like `*.example.com`, but the TXT record for DNS-01 must be published at `_acme-challenge.<base-domain>` (e.g., `_acme-challenge.example.com`) without the `*.` label.
## Fix Focus Areas
- pegaprox/core/acme.py[355-360]

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


8. No DNS propagation wait setting 📎 Requirement gap ☼ Reliability
Description
The DNS-01 flow provides no default propagation wait and no user-configurable override; validation
is triggered immediately when the admin clicks continue. This makes DNS-01 predictably flaky on
slower DNS providers and does not meet the propagation-wait requirement.
Code

pegaprox/core/acme.py[R382-399]

Evidence
PR Compliance ID 5 requires a default DNS propagation wait and an override. The code instructs the
admin to 'wait for propagation' but does not implement any waiting behavior nor expose any
propagation-wait configuration before calling _validate_challenge().

Implement sensible DNS propagation wait defaults with user override
pegaprox/core/acme.py[369-376]
pegaprox/core/acme.py[382-399]

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

## Issue description
DNS-01 completion immediately notifies ACME without any built-in propagation wait or configurable override.
## Issue Context
Compliance requires a sensible default propagation wait and a user override, applied during both issuance and renewal.
## Fix Focus Areas
- pegaprox/core/acme.py[339-376]
- pegaprox/core/acme.py[382-399]
- pegaprox/api/helpers.py[31-35]
- pegaprox/api/settings.py[1006-1011]
- web/src/settings_modal.js[5833-5884]

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


9. DNS challenge state volatile 🐞 Bug ☼ Reliability
Description
DNS-01 pending challenges are stored only in the process-global _pending_dns_challenges dict, so
restarting the server or running multiple workers will lose the stored ACME order context and make
/api/settings/acme/dns/complete fail. This makes DNS-01 completion unreliable in production
deployments.
Code

pegaprox/core/acme.py[R384-386]

Evidence
Pending DNS challenges are kept only in _pending_dns_challenges and looked up by challenge_id
during completion; no persistence layer is used, so the completion endpoint depends on in-memory
process state.

pegaprox/core/acme.py[26-28]
pegaprox/core/acme.py[361-367]
pegaprox/core/acme.py[382-389]
pegaprox/api/settings.py[1646-1674]

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

## Issue description
DNS-01 flow stores the entire pending order context in a module-level dict. That state is lost on process restart and is not shared across workers, so the follow-up completion request cannot succeed reliably.
## Issue Context
`prepare_dns01_challenge()` returns a `challenge_id` to the UI, and the UI later calls `/api/settings/acme/dns/complete` with that id. The backend currently requires the original in-memory context to exist.
## Fix Focus Areas
- pegaprox/core/acme.py[26-28]
- pegaprox/core/acme.py[339-367]
- pegaprox/core/acme.py[382-411]
- pegaprox/api/settings.py[1646-1674]

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



Remediation recommended

10. Challenge activation errors delayed 🐞 Bug ☼ Reliability ⭐ New
Description
_validate_challenge() starts polling authorization status without checking whether the initial
challenge activation POST succeeded or returned an ACME error payload. If activation fails but the
authorization never transitions to "invalid", the caller can receive a generic timeout instead of
the actionable activation error details.
Code

pegaprox/core/acme.py[R199-209]

Evidence
The current _validate_challenge implementation polls immediately after the activation POST and
never inspects the activation response status or error payload, so activation failures that don't
quickly surface via authz polling can degrade into timeouts.

pegaprox/core/acme.py[199-226]

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

### Issue description
`_validate_challenge()` does not validate `ch_resp.status_code` (or parse its error body) before entering the polling loop. This can hide the true cause of immediate activation failures and waste up to 60 seconds before returning a timeout.

### Issue Context
The polling loop does extract challenge errors if the authorization status becomes `invalid`, but it never surfaces activation errors directly from `ch_resp`.

### Fix Focus Areas
- pegaprox/core/acme.py[199-226]

### Implementation notes
- After `ch_resp = _signed_request(...)`, check for non-2xx (and/or ACME-specific error responses).
- If non-2xx, parse `ch_resp.json()` (guarding JSON decode) and return a failure with `detail` (and include the refreshed `Replay-Nonce` if you want to support subsequent retries).
- Optionally log the activation error at debug/info level to aid diagnostics without leaking secrets.

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


11. Renewal lacks validation 🐞 Bug ☼ Reliability
Description
In acme_renewal_loop(), acme_challenge_type/acme_dns_provider are taken from persisted settings
without clamping to supported values, so an existing invalid (but non-empty) value will make
check_and_renew() call request_certificate() with unsupported parameters and renewal will fail every
day. This is especially relevant for pre-existing settings written before these validations existed
or after manual DB edits.
Code

pegaprox/app.py[R863-871]

Evidence
The renewal loop passes raw values from settings (only defaulting when falsy), while
request_certificate() explicitly rejects any challenge type other than 'dns-01' or 'http-01';
therefore an invalid but non-empty stored string causes renewal failures.

pegaprox/app.py[851-881]
pegaprox/core/acme.py[346-362]

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_renewal_loop()` forwards `acme_challenge_type` and `acme_dns_provider` from settings without validating them at use-time. If those values are invalid but non-empty, `request_certificate()` will reject them and auto-renew will repeatedly fail.
### Issue Context
Settings are validated when saved via the settings API/UI, but the renewal loop should still be defensive against invalid persisted values (e.g., legacy data, manual DB edits, partial migrations).
### Fix Focus Areas
- pegaprox/app.py[851-881]
- pegaprox/core/acme.py[346-362]
### Suggested fix
- In `acme_renewal_loop()`, clamp:
- `challenge_type` to `('http-01','dns-01')` with default `'http-01'`
- `dns_provider` to `('manual','rfc2136')` with default `'manual'`
- (Optional) also coerce `dns_config` numeric fields (`port`, `ttl`, `propagation_seconds`) to safe ints before passing to `check_and_renew()`.

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


12. RFC2136 algorithm unvalidated 🐞 Bug ≡ Correctness
Description
_sanitize_acme_dns_settings() persists any acme_dns_rfc2136_algorithm string (only lowercasing), but
the RFC2136 implementation only supports a fixed whitelist; invalid values will be accepted/saved
and later make RFC 2136 DNS updates fail with “Unsupported RFC 2136 TSIG algorithm”. This turns a
bad input into a runtime failure instead of being corrected/rejected at settings save time.
Code

pegaprox/api/settings.py[R34-45]

Evidence
Backend sanitization only normalizes algorithm text, while the RFC2136 code path only recognizes a
fixed set and explicitly fails when the mapping returns None; this proves unsupported algorithms can
be persisted and later break DNS updates.

pegaprox/api/settings.py[34-45]
pegaprox/core/acme.py[142-180]

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

## Issue description
`_sanitize_acme_dns_settings()` stores `acme_dns_rfc2136_algorithm` without validating it. Later, `_rfc2136_update()` calls `_rfc2136_algorithm()` which returns `None` for unknown values, causing RFC2136 DNS-01 to fail at runtime.
### Issue Context
The web UI dropdown restricts algorithm choices, but API callers (or corrupted/legacy persisted settings) can still store unsupported strings.
### Fix Focus Areas
- pegaprox/api/settings.py[34-58]
- pegaprox/core/acme.py[142-180]
### Suggested fix
- In `_sanitize_acme_dns_settings()`, enforce a whitelist:
- `('hmac-md5','hmac-sha1','hmac-sha224','hmac-sha256','hmac-sha384','hmac-sha512')`
- default to `'hmac-sha512'` when invalid
- (Optional) return a validation error when the provider is `rfc2136` and the algorithm is invalid, to fail fast in the settings API.

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


View more (2)
13. Expired DNS challenges linger 🐞 Bug ☼ Reliability
Description
Manual DNS-01 challenges are stored in the global _pending_dns_challenges dict and are only removed
on successful completion or when a completion call happens after expiry, so abandoned challenges can
accumulate indefinitely in long-running processes. This can slowly increase memory usage and keep
sensitive ACME context in-memory longer than intended.
Code

pegaprox/core/acme.py[R457-462]

Evidence
The code inserts pending DNS challenges with an expiry timestamp, but removal occurs only inside the
completion path; there is no other cleanup call site.

pegaprox/core/acme.py[26-28]
pegaprox/core/acme.py[457-462]
pegaprox/core/acme.py[480-507]

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

## Issue description
`_pending_dns_challenges` entries are created with `expires_at` but there is no periodic cleanup; expiry is enforced only when `complete_dns01_challenge()` is called.
### Issue Context
If a user prepares a DNS-01 challenge and never completes it, the entry remains in memory forever.
### Fix Focus Areas
- pegaprox/core/acme.py[457-462]
- pegaprox/core/acme.py[478-507]
### Implementation notes
- Add a small cleanup function that prunes expired entries and call it:
- opportunistically at the start of `prepare_dns01_challenge()` and `complete_dns01_challenge()`, and/or
- on a timer (if there’s an existing scheduler/loop).
- Consider limiting max pending entries (cap) to prevent unbounded growth.

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


14. Form URL validation missing 🐞 Bug ⚙ Maintainability
Description
update_server_settings() rejects non-HTTPS acme_directory_url in the JSON branch but accepts and
persists any value in the multipart/form-data branch. This allows invalid/custom URLs to be saved
via the web UI path, causing later ACME requests to fail with a less-direct error.
Code

pegaprox/api/settings.py[R1368-1369]

Evidence
The JSON branch explicitly rejects non-HTTPS directory URLs, while the form branch stores the string
without any scheme check before persisting settings.

pegaprox/api/settings.py[1015-1020]
pegaprox/api/settings.py[1361-1370]

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 JSON settings update path enforces HTTPS for `acme_directory_url`, but the multipart/form-data path does not. This creates inconsistent validation and can persist invalid URLs.
## Issue Context
The web UI saves server settings via `FormData` (multipart), so this is the primary path for most users.
## Fix Focus Areas
- pegaprox/api/settings.py[1015-1020]
- pegaprox/api/settings.py[1361-1370]

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


Grey Divider

Previous review results

Review updated until commit 1c1b2c1

Results up to commit 1c1b2c1


🐞 Bugs (7) 📘 Rule violations (0) 📎 Requirement gaps (5)

Context used

Action required
1. Global acme_challenge_type setting 📎 Requirement gap ⚙ Maintainability ⭐ New
Description
The PR stores acme_challenge_type as a single server-wide setting and uses it for renewal, so the
ACME challenge type cannot be selected per certificate. This violates the requirement for
per-certificate challenge selection in mixed environments.
Code

pegaprox/api/settings.py[R1652-1681]

+        challenge_type = str(data.get('challenge_type') or settings.get('acme_challenge_type', 'http-01')).strip()
        acme_provider = str(data.get('provider') or settings.get('acme_provider', 'letsencrypt')).strip() or 'letsencrypt'
        directory_url = str(data.get('directory_url') or settings.get('acme_directory_url', '')).strip()
+        if data.get('dns_provider') and not data.get('acme_dns_provider'):
+            data['acme_dns_provider'] = data.get('dns_provider')
+        settings = _sanitize_acme_dns_settings(settings, data)
+        dns_provider = settings.get('acme_dns_provider', 'manual')

        if not domain:
            return jsonify({'error': 'Domain is required'}), 400
        if acme_provider not in ('letsencrypt', 'custom'):
            return jsonify({'error': 'Invalid ACME provider'}), 400
+        if challenge_type not in ('http-01', 'dns-01'):
+            return jsonify({'error': 'Invalid ACME challenge type'}), 400
+        if dns_provider not in ('manual', 'rfc2136'):
+            return jsonify({'error': 'Invalid DNS-01 provider'}), 400
+        if challenge_type == 'dns-01' and dns_provider == 'rfc2136':
+            missing = [
+                label for label, value in (
+                    ('RFC 2136 nameserver', settings.get('acme_dns_rfc2136_nameserver')),
+                    ('RFC 2136 zone', settings.get('acme_dns_rfc2136_zone')),
+                    ('RFC 2136 key name', settings.get('acme_dns_rfc2136_key_name')),
+                    ('RFC 2136 secret', settings.get('acme_dns_rfc2136_secret')),
+                ) if not value
+            ]
+            if missing:
+                return jsonify({'error': ', '.join(missing) + ' required'}), 400
        if acme_provider == 'custom':
            if not directory_url:
                return jsonify({'error': 'Custom ACME directory URL is required'}), 400
Evidence
PR Compliance ID 3 requires challenge type selection per certificate, but the PR introduces and
relies on a single global acme_challenge_type in server settings and uses it for renewal,
preventing independent selection for different certificates.

Allow per-certificate selection of ACME challenge type (HTTP-01 vs DNS-01)
pegaprox/api/helpers.py[26-43]
pegaprox/api/settings.py[1649-1694]
pegaprox/app.py[852-881]

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_challenge_type` (and DNS provider config) is persisted as a single global server setting and then used by the renewal loop, so it cannot vary per certificate/domain. The compliance requirement expects per-certificate selection (HTTP-01 vs DNS-01), not a global-only toggle.

## Issue Context
- `request_acme_certificate()` persists the chosen challenge type into server settings.
- `acme_renewal_loop()` reads that single value for renewal.
- There is no per-certificate configuration object/record that would allow different domains/certs to use different challenge types.

## Fix Focus Areas
- pegaprox/api/settings.py[1649-1707]
- pegaprox/app.py[852-881]
- pegaprox/api/helpers.py[26-43]

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


2. Clears all HTTP-01 tokens 🐞 Bug ☼ Reliability
Description
In pegaprox.core.acme.request_certificate(), the exception handler calls
_pending_challenges.clear(), which can delete tokens for other in-flight HTTP-01 validations and
make the ACME challenge endpoint return 404 for those tokens. This can cause unrelated certificate
requests/renewals to fail under concurrent ACME operations.
Code

pegaprox/core/acme.py[R392-396]

+    except Exception as e:
+        logging.error(f"[ACME] Certificate request failed: {e}")
+        # cleanup pending challenge
+        _pending_challenges.clear()
+        return {'success': False, 'message': str(e)}
Evidence
The code uses a process-global map for HTTP-01 tokens that is directly queried by the public ACME
challenge route; clearing the entire map on one request’s exception removes tokens needed by other
ongoing validations.

pegaprox/core/acme.py[23-28]
pegaprox/core/acme.py[380-396]
pegaprox/app.py[99-106]

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

## Issue description
`request_certificate()` clears the entire global `_pending_challenges` dict on *any* exception. This can remove tokens belonging to other in-flight HTTP-01 requests.
### Issue Context
`/.well-known/acme-challenge/<token>` serves values from `_pending_challenges`. Clearing the dict system-wide breaks unrelated challenges.
### Fix Focus Areas
- pegaprox/core/acme.py[392-396]
### Implementation notes
- Track the current `token` in scope and only `pop(token, None)` in the exception path.
- Avoid global `.clear()`; if you need cleanup, remove only entries created by this request.
- If `token` may be unset, guard accordingly (e.g., `if token: ...`).

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


3. RFC2136 blocks request thread 🐞 Bug ☼ Reliability
Description
The RFC2136 DNS-01 path sleeps for propagation and then synchronously polls ACME validation, causing
/api/settings/acme/request to block for up to ~11 minutes and tie up a worker while the client/proxy
may time out. Users can see a failure/timeout even if the certificate issuance eventually succeeds,
and busy servers can suffer worker starvation.
Code

pegaprox/core/acme.py[R427-438]

+            propagation_seconds = max(0, min(600, int(dns_config.get('propagation_seconds') or 30)))
+            if propagation_seconds:
+                logging.info(f"[ACME] Waiting {propagation_seconds}s for RFC 2136 DNS propagation")
+                time.sleep(propagation_seconds)
+
+            result = _validate_challenge(
+                context['authz_url'],
+                dns_challenge['url'],
+                context['account_key'],
+                context['nonce'],
+                context['kid'],
+            )
Evidence
The RFC2136 DNS-01 handler sleeps for propagation and then calls a polling validator; the settings
API executes this synchronously before responding, guaranteeing long-lived HTTP requests.

pegaprox/core/acme.py[196-217]
pegaprox/core/acme.py[421-453]
pegaprox/api/settings.py[1633-1715]

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 RFC2136 DNS-01 issuance path performs `time.sleep(propagation_seconds)` and then `_validate_challenge()` polling inside the HTTP request handler call chain. This can make the request take minutes.
### Issue Context
`request_acme_certificate()` calls `request_certificate()` inline and returns only when it finishes.
### Fix Focus Areas
- pegaprox/core/acme.py[427-438]
- pegaprox/api/settings.py[1703-1707]
### Implementation notes
Choose one:
1) **Async/background job**: enqueue RFC2136 issuance and return a job id/status endpoint.
2) **Two-step flow** (like manual): return `pending_dns` immediately after creating TXT; let a separate endpoint trigger validation/finalization.
Also consider hard upper bounds that keep request durations below typical reverse proxy timeouts.

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


View more (5)
4. DNS-01 lacks provider automation 📎 Requirement gap ⚙ Maintainability
Description
The DNS-01 flow requires an admin to manually create the TXT record and the code does not provide
any pluggable DNS provider model or webhook/script hook to create/update/remove _acme-challenge
records. This fails the requirement for an integration mechanism that can manage DNS records
throughout the DNS-01 lifecycle.
Code

pegaprox/core/acme.py[R339-376]

+def prepare_dns01_challenge(domain, email, ssl_dir, staging=False, directory_url=None):
+    """Prepare a DNS-01 challenge and return the TXT record the admin must create."""
+    try:
+        context = _create_order(domain, email, ssl_dir, staging=staging, directory_url=directory_url)
+        if not context.get('success'):
+            return context
+
+        dns_challenge = None
+        for ch in context['authz'].get('challenges', []):
+            if ch['type'] == 'dns-01':
+                dns_challenge = ch
              break
-            time.sleep(2)
-            poll_resp = _signed_request(order_url, None, account_key, nonce, kid)
-            nonce = poll_resp.headers.get('Replay-Nonce', nonce)
-            order_data = poll_resp.json()
-        else:
-            return {'success': False, 'message': 'Timed out waiting for certificate'}
-
-        # Step 12: Download certificate
-        cert_url = order_data['certificate']
-        cert_resp = _signed_request(cert_url, None, account_key, nonce, kid)
-        cert_pem = cert_resp.text  # fullchain PEM
-
-        # Step 13: Save cert + key
-        os.makedirs(ssl_dir, exist_ok=True)
-        cert_path = os.path.join(ssl_dir, 'cert.pem')
-        key_path = os.path.join(ssl_dir, 'key.pem')
-
-        with open(cert_path, 'w') as f:
-            f.write(cert_pem)
-        os.chmod(cert_path, 0o644)
-
-        with open(key_path, 'wb') as f:
-            f.write(domain_key.private_bytes(
-                serialization.Encoding.PEM,
-                serialization.PrivateFormat.PKCS8,
-                serialization.NoEncryption()
-            ))
-        os.chmod(key_path, 0o600)
-
-        # Parse expiry from cert
-        cert_obj = x509.load_pem_x509_certificate(cert_pem.encode())
-        expires = cert_obj.not_valid_after_utc.isoformat()
-
-        logging.info(f"[ACME] Certificate saved! Expires: {expires}")
+
+        if not dns_challenge:
+            return {'success': False, 'message': 'No DNS-01 challenge offered by ACME server'}
+
+        token = dns_challenge['token']
+        key_authorization = f"{token}.{_jwk_thumbprint(context['account_key'])}"
+        state_id = secrets.token_urlsafe(24)
+        dns_name = f"_acme-challenge.{domain}".rstrip('.')
+        dns_value = _dns01_value(key_authorization)
+
+        _pending_dns_challenges[state_id] = {
+            **context,
+            'domain': domain,
+            'challenge_url': dns_challenge['url'],
+            'expires_at': time.time() + 3600,
+        }
+
+        logging.info(f"[ACME] DNS-01 challenge prepared for {domain}")
      return {
-            'success': True,
-            'message': f"Certificate issued for {domain}",
-            'cert_path': cert_path,
-            'key_path': key_path,
-            'expires': expires,
+            'success': False,
+            'pending_dns': True,
+            'message': 'Create the DNS TXT record, wait for propagation, then continue validation.',
+            'challenge_id': state_id,
+            'dns_name': dns_name,
+            'dns_value': dns_value,
      }
Evidence
PR Compliance ID 2 requires a pluggable provider model or generic hook to manage DNS TXT records.
The new DNS-01 implementation explicitly returns instructions for the admin to manually create the
TXT record and contains no provider/hook invocation for DNS record creation or cleanup.

Provide a pluggable DNS provider model or a generic webhook/script hook for DNS-01
pegaprox/core/acme.py[339-376]

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

## Issue description
DNS-01 currently requires manual DNS TXT record management and does not provide a pluggable provider model or a generic webhook/script hook to create/update/remove ACME TXT records.
## Issue Context
Compliance requires an extension/integration mechanism for DNS-01 that can reliably manage TXT records during issuance/renewal (create/update and cleanup/remove).
## Fix Focus Areas
- pegaprox/core/acme.py[339-376]

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


5. DNS-01 renewal explicitly skipped 📎 Requirement gap ☼ Reliability
Description
Automatic renewal is explicitly disabled when challenge_type is dns-01, so DNS-01 certificates
will not renew via the existing renewal workflow. This violates the requirement that issuance and
renewal workflows support DNS-01.
Code

pegaprox/core/acme.py[R481-483]

+    if challenge_type == 'dns-01':
+        logging.info("[ACME] Skipping automatic renewal for manual DNS-01 challenge")
+        return False
Evidence
PR Compliance ID 1 requires DNS-01 support for renewal, but check_and_renew() logs and returns
without attempting renewal when challenge_type == 'dns-01', so the daily renewal loop cannot renew
DNS-01 certificates.

Add ACME DNS-01 challenge support alongside existing HTTP-01
pegaprox/core/acme.py[466-485]
pegaprox/app.py[863-868]

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 renewal path returns early for `dns-01`, preventing DNS-01 certificates from being renewed by the renewal loop.
## Issue Context
Compliance requires DNS-01 to work for both issuance and renewal workflows.
## Fix Focus Areas
- pegaprox/core/acme.py[466-486]
- pegaprox/app.py[863-868]

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


6. Wildcard dns_name includes asterisk 📎 Requirement gap ≡ Correctness
Description
For wildcard requests like *.example.com, prepare_dns01_challenge() builds the DNS-01 TXT record
name as _acme-challenge.*.example.com, which is not a valid/correct ACME DNS-01 record name and
will cause wildcard validation (and thus wildcard certificate issuance) to fail. Wildcard DNS-01
challenges must publish the TXT record under _acme-challenge.example.com (without the *.).
Code

pegaprox/core/acme.py[R358-359]

+        dns_name = f"_acme-challenge.{domain}".rstrip('.')
+        dns_value = _dns01_value(key_authorization)
Evidence
PR Compliance ID 4 requires wildcard certificates to work via DNS-01, but the referenced code
constructs dns_name by directly concatenating _acme-challenge. with the raw requested domain
and returning it to the UI; when the ACME identifier is a wildcard like *.example.com, the
domain value includes the leading *. so the resulting TXT name becomes
_acme-challenge.*.example.com, demonstrating why the record name is wrong and why wildcard DNS-01
validation cannot succeed.

Support wildcard certificates enabled by DNS-01 (e.g., *.internal.example.com)
pegaprox/core/acme.py[355-360]
pegaprox/core/acme.py[355-376]
web/src/settings_modal.js[5856-5884]

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

## Issue description
`prepare_dns01_challenge()` constructs the DNS-01 TXT record name as `_acme-challenge.{domain}` using `domain` verbatim; for wildcard identifiers (where `domain` starts with `*.`), this produces an invalid/incorrect TXT record name (`_acme-challenge.*.example.com`) and prevents wildcard certificate issuance.
## Issue Context
Wildcard certificates require DNS-01, and ACME orders may use identifiers like `*.example.com`, but the TXT record for DNS-01 must be published at `_acme-challenge.<base-domain>` (e.g., `_acme-challenge.example.com`) without the `*.` label.
## Fix Focus Areas
- pegaprox/core/acme.py[355-360]

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


7. No DNS propagation wait setting 📎 Requirement gap ☼ Reliability
Description
The DNS-01 flow provides no default propagation wait and no user-configurable override; validation
is triggered immediately when the admin clicks continue. This makes DNS-01 predictably flaky on
slower DNS providers and does not meet the propagation-wait requirement.
Code

pegaprox/core/acme.py[R382-399]

+def complete_dns01_challenge(challenge_id, ssl_dir):
+    """Continue a pending DNS-01 challenge after the TXT record has propagated."""
+    context = _pending_dns_challenges.get(challenge_id)
+    if not context:
+        return {'success': False, 'message': 'DNS-01 challenge was not found or has expired'}
+    if context.get('expires_at', 0) < time.time():
+        _pending_dns_challenges.pop(challenge_id, None)
+        return {'success': False, 'message': 'DNS-01 challenge expired; request a new challenge'}
+
+    try:
+        result = _validate_challenge(
+            context['authz_url'],
+            context['challenge_url'],
+            context['account_key'],
+            context['nonce'],
+            context['kid'],
+        )
+        if not result.get('success'):
Evidence
PR Compliance ID 5 requires a default DNS propagation wait and an override. The code instructs the
admin to 'wait for propagation' but does not implement any waiting behavior nor expose any
propagation-wait configuration before calling _validate_challenge().

Implement sensible DNS propagation wait defaults with user override
pegaprox/core/acme.py[369-376]
pegaprox/core/acme.py[382-399]

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

## Issue description
DNS-01 completion immediately notifies ACME without any built-in propagation wait or configurable override.
## Issue Context
Compliance requires a sensible default propagation wait and a user override, applied during both issuance and renewal.
## Fix Focus Areas
- pegaprox/core/acme.py[339-376]
- pegaprox/core/acme.py[382-399]
- pegaprox/api/helpers.py[31-35]
- pegaprox/api/settings.py[1006-1011]
- web/src/settings_modal.js[5833-5884]

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


8. DNS challenge state volatile 🐞 Bug ☼ Reliability
Description
DNS-01 pending challenges are stored only in the process-global _pending_dns_challenges dict, so
restarting the server or running multiple workers will lose the stored ACME order context and make
/api/settings/acme/dns/complete fail. This makes DNS-01 completion unreliable in production
deployments.
Code

pegaprox/core/acme.py[R384-386]

+    context = _pending_dns_challenges.get(challenge_id)
+...

Comment thread pegaprox/core/acme.py Outdated
Comment on lines 339 to 376
def prepare_dns01_challenge(domain, email, ssl_dir, staging=False, directory_url=None):
"""Prepare a DNS-01 challenge and return the TXT record the admin must create."""
try:
context = _create_order(domain, email, ssl_dir, staging=staging, directory_url=directory_url)
if not context.get('success'):
return context

dns_challenge = None
for ch in context['authz'].get('challenges', []):
if ch['type'] == 'dns-01':
dns_challenge = ch
break
time.sleep(2)
poll_resp = _signed_request(order_url, None, account_key, nonce, kid)
nonce = poll_resp.headers.get('Replay-Nonce', nonce)
order_data = poll_resp.json()
else:
return {'success': False, 'message': 'Timed out waiting for certificate'}

# Step 12: Download certificate
cert_url = order_data['certificate']
cert_resp = _signed_request(cert_url, None, account_key, nonce, kid)
cert_pem = cert_resp.text # fullchain PEM

# Step 13: Save cert + key
os.makedirs(ssl_dir, exist_ok=True)
cert_path = os.path.join(ssl_dir, 'cert.pem')
key_path = os.path.join(ssl_dir, 'key.pem')

with open(cert_path, 'w') as f:
f.write(cert_pem)
os.chmod(cert_path, 0o644)

with open(key_path, 'wb') as f:
f.write(domain_key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption()
))
os.chmod(key_path, 0o600)

# Parse expiry from cert
cert_obj = x509.load_pem_x509_certificate(cert_pem.encode())
expires = cert_obj.not_valid_after_utc.isoformat()

logging.info(f"[ACME] Certificate saved! Expires: {expires}")

if not dns_challenge:
return {'success': False, 'message': 'No DNS-01 challenge offered by ACME server'}

token = dns_challenge['token']
key_authorization = f"{token}.{_jwk_thumbprint(context['account_key'])}"
state_id = secrets.token_urlsafe(24)
dns_name = f"_acme-challenge.{domain}".rstrip('.')
dns_value = _dns01_value(key_authorization)

_pending_dns_challenges[state_id] = {
**context,
'domain': domain,
'challenge_url': dns_challenge['url'],
'expires_at': time.time() + 3600,
}

logging.info(f"[ACME] DNS-01 challenge prepared for {domain}")
return {
'success': True,
'message': f"Certificate issued for {domain}",
'cert_path': cert_path,
'key_path': key_path,
'expires': expires,
'success': False,
'pending_dns': True,
'message': 'Create the DNS TXT record, wait for propagation, then continue validation.',
'challenge_id': state_id,
'dns_name': dns_name,
'dns_value': dns_value,
}
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. Dns-01 lacks provider automation 📎 Requirement gap ⚙ Maintainability

The DNS-01 flow requires an admin to manually create the TXT record and the code does not provide
any pluggable DNS provider model or webhook/script hook to create/update/remove _acme-challenge
records. This fails the requirement for an integration mechanism that can manage DNS records
throughout the DNS-01 lifecycle.
Agent Prompt
## Issue description
DNS-01 currently requires manual DNS TXT record management and does not provide a pluggable provider model or a generic webhook/script hook to create/update/remove ACME TXT records.

## Issue Context
Compliance requires an extension/integration mechanism for DNS-01 that can reliably manage TXT records during issuance/renewal (create/update and cleanup/remove).

## Fix Focus Areas
- pegaprox/core/acme.py[339-376]

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

Comment thread pegaprox/core/acme.py Outdated
Comment on lines +481 to +483
if challenge_type == 'dns-01':
logging.info("[ACME] Skipping automatic renewal for manual DNS-01 challenge")
return False
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. Dns-01 renewal explicitly skipped 📎 Requirement gap ☼ Reliability

Automatic renewal is explicitly disabled when challenge_type is dns-01, so DNS-01 certificates
will not renew via the existing renewal workflow. This violates the requirement that issuance and
renewal workflows support DNS-01.
Agent Prompt
## Issue description
The renewal path returns early for `dns-01`, preventing DNS-01 certificates from being renewed by the renewal loop.

## Issue Context
Compliance requires DNS-01 to work for both issuance and renewal workflows.

## Fix Focus Areas
- pegaprox/core/acme.py[466-486]
- pegaprox/app.py[863-868]

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

Comment thread pegaprox/core/acme.py Outdated
Comment on lines +358 to +359
dns_name = f"_acme-challenge.{domain}".rstrip('.')
dns_value = _dns01_value(key_authorization)
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. Wildcard dns_name includes asterisk 📎 Requirement gap ≡ Correctness

For wildcard requests like *.example.com, prepare_dns01_challenge() builds the DNS-01 TXT record
name as _acme-challenge.*.example.com, which is not a valid/correct ACME DNS-01 record name and
will cause wildcard validation (and thus wildcard certificate issuance) to fail. Wildcard DNS-01
challenges must publish the TXT record under _acme-challenge.example.com (without the *.).
Agent Prompt
## Issue description
`prepare_dns01_challenge()` constructs the DNS-01 TXT record name as `_acme-challenge.{domain}` using `domain` verbatim; for wildcard identifiers (where `domain` starts with `*.`), this produces an invalid/incorrect TXT record name (`_acme-challenge.*.example.com`) and prevents wildcard certificate issuance.

## Issue Context
Wildcard certificates require DNS-01, and ACME orders may use identifiers like `*.example.com`, but the TXT record for DNS-01 must be published at `_acme-challenge.<base-domain>` (e.g., `_acme-challenge.example.com`) without the `*.` label.

## Fix Focus Areas
- pegaprox/core/acme.py[355-360]

ⓘ 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 +382 to +399
def complete_dns01_challenge(challenge_id, ssl_dir):
"""Continue a pending DNS-01 challenge after the TXT record has propagated."""
context = _pending_dns_challenges.get(challenge_id)
if not context:
return {'success': False, 'message': 'DNS-01 challenge was not found or has expired'}
if context.get('expires_at', 0) < time.time():
_pending_dns_challenges.pop(challenge_id, None)
return {'success': False, 'message': 'DNS-01 challenge expired; request a new challenge'}

try:
result = _validate_challenge(
context['authz_url'],
context['challenge_url'],
context['account_key'],
context['nonce'],
context['kid'],
)
if not result.get('success'):
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

4. No dns propagation wait setting 📎 Requirement gap ☼ Reliability

The DNS-01 flow provides no default propagation wait and no user-configurable override; validation
is triggered immediately when the admin clicks continue. This makes DNS-01 predictably flaky on
slower DNS providers and does not meet the propagation-wait requirement.
Agent Prompt
## Issue description
DNS-01 completion immediately notifies ACME without any built-in propagation wait or configurable override.

## Issue Context
Compliance requires a sensible default propagation wait and a user override, applied during both issuance and renewal.

## Fix Focus Areas
- pegaprox/core/acme.py[339-376]
- pegaprox/core/acme.py[382-399]
- pegaprox/api/helpers.py[31-35]
- pegaprox/api/settings.py[1006-1011]
- web/src/settings_modal.js[5833-5884]

ⓘ 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 +384 to +386
context = _pending_dns_challenges.get(challenge_id)
if not context:
return {'success': False, 'message': 'DNS-01 challenge was not found or has expired'}
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

5. Dns challenge state volatile 🐞 Bug ☼ Reliability

DNS-01 pending challenges are stored only in the process-global _pending_dns_challenges dict, so
restarting the server or running multiple workers will lose the stored ACME order context and make
/api/settings/acme/dns/complete fail. This makes DNS-01 completion unreliable in production
deployments.
Agent Prompt
## Issue description
DNS-01 flow stores the entire pending order context in a module-level dict. That state is lost on process restart and is not shared across workers, so the follow-up completion request cannot succeed reliably.

## Issue Context
`prepare_dns01_challenge()` returns a `challenge_id` to the UI, and the UI later calls `/api/settings/acme/dns/complete` with that id. The backend currently requires the original in-memory context to exist.

## Fix Focus Areas
- pegaprox/core/acme.py[26-28]
- pegaprox/core/acme.py[339-367]
- pegaprox/core/acme.py[382-411]
- pegaprox/api/settings.py[1646-1674]

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

@gyptazy gyptazy force-pushed the feature/420-add-letsecnrypt-dns-01-support branch 2 times, most recently from 8ea009e to c58528d Compare May 19, 2026 05:27
@gyptazy
Copy link
Copy Markdown
Contributor Author

gyptazy commented May 19, 2026

/review

(still WIP)

@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented May 19, 2026

Persistent review updated to latest commit c58528d

Comment thread pegaprox/core/acme.py
Comment thread pegaprox/core/acme.py
Comment on lines +427 to +438
propagation_seconds = max(0, min(600, int(dns_config.get('propagation_seconds') or 30)))
if propagation_seconds:
logging.info(f"[ACME] Waiting {propagation_seconds}s for RFC 2136 DNS propagation")
time.sleep(propagation_seconds)

result = _validate_challenge(
context['authz_url'],
dns_challenge['url'],
context['account_key'],
context['nonce'],
context['kid'],
)
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. Rfc2136 blocks request thread 🐞 Bug ☼ Reliability

The RFC2136 DNS-01 path sleeps for propagation and then synchronously polls ACME validation, causing
/api/settings/acme/request to block for up to ~11 minutes and tie up a worker while the client/proxy
may time out. Users can see a failure/timeout even if the certificate issuance eventually succeeds,
and busy servers can suffer worker starvation.
Agent Prompt
### Issue description
The RFC2136 DNS-01 issuance path performs `time.sleep(propagation_seconds)` and then `_validate_challenge()` polling inside the HTTP request handler call chain. This can make the request take minutes.

### Issue Context
`request_acme_certificate()` calls `request_certificate()` inline and returns only when it finishes.

### Fix Focus Areas
- pegaprox/core/acme.py[427-438]
- pegaprox/api/settings.py[1703-1707]

### Implementation notes
Choose one:
1) **Async/background job**: enqueue RFC2136 issuance and return a job id/status endpoint.
2) **Two-step flow** (like manual): return `pending_dns` immediately after creating TXT; let a separate endpoint trigger validation/finalization.

Also consider hard upper bounds that keep request durations below typical reverse proxy timeouts.

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

@gyptazy gyptazy force-pushed the feature/420-add-letsecnrypt-dns-01-support branch from c58528d to 1c1b2c1 Compare May 19, 2026 12:02
@gyptazy
Copy link
Copy Markdown
Contributor Author

gyptazy commented May 19, 2026

/review

@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented May 19, 2026

Persistent review updated to latest commit 1c1b2c1

Comment thread pegaprox/api/settings.py
Comment on lines +1652 to 1681
challenge_type = str(data.get('challenge_type') or settings.get('acme_challenge_type', 'http-01')).strip()
acme_provider = str(data.get('provider') or settings.get('acme_provider', 'letsencrypt')).strip() or 'letsencrypt'
directory_url = str(data.get('directory_url') or settings.get('acme_directory_url', '')).strip()
if data.get('dns_provider') and not data.get('acme_dns_provider'):
data['acme_dns_provider'] = data.get('dns_provider')
settings = _sanitize_acme_dns_settings(settings, data)
dns_provider = settings.get('acme_dns_provider', 'manual')

if not domain:
return jsonify({'error': 'Domain is required'}), 400
if acme_provider not in ('letsencrypt', 'custom'):
return jsonify({'error': 'Invalid ACME provider'}), 400
if challenge_type not in ('http-01', 'dns-01'):
return jsonify({'error': 'Invalid ACME challenge type'}), 400
if dns_provider not in ('manual', 'rfc2136'):
return jsonify({'error': 'Invalid DNS-01 provider'}), 400
if challenge_type == 'dns-01' and dns_provider == 'rfc2136':
missing = [
label for label, value in (
('RFC 2136 nameserver', settings.get('acme_dns_rfc2136_nameserver')),
('RFC 2136 zone', settings.get('acme_dns_rfc2136_zone')),
('RFC 2136 key name', settings.get('acme_dns_rfc2136_key_name')),
('RFC 2136 secret', settings.get('acme_dns_rfc2136_secret')),
) if not value
]
if missing:
return jsonify({'error': ', '.join(missing) + ' required'}), 400
if acme_provider == 'custom':
if not directory_url:
return jsonify({'error': 'Custom ACME directory URL is required'}), 400
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. Global acme_challenge_type setting 📎 Requirement gap ⚙ Maintainability

The PR stores acme_challenge_type as a single server-wide setting and uses it for renewal, so the
ACME challenge type cannot be selected per certificate. This violates the requirement for
per-certificate challenge selection in mixed environments.
Agent Prompt
## Issue description
`acme_challenge_type` (and DNS provider config) is persisted as a single global server setting and then used by the renewal loop, so it cannot vary per certificate/domain. The compliance requirement expects per-certificate selection (HTTP-01 vs DNS-01), not a global-only toggle.

## Issue Context
- `request_acme_certificate()` persists the chosen challenge type into server settings.
- `acme_renewal_loop()` reads that single value for renewal.
- There is no per-certificate configuration object/record that would allow different domains/certs to use different challenge types.

## Fix Focus Areas
- pegaprox/api/settings.py[1649-1707]
- pegaprox/app.py[852-881]
- pegaprox/api/helpers.py[26-43]

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

@gyptazy gyptazy changed the base branch from main to Testing May 22, 2026 12:25
@gyptazy gyptazy marked this pull request as ready for review May 22, 2026 12:25
@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented May 22, 2026

Persistent review updated to latest commit 1c1b2c1

@gyptazy
Copy link
Copy Markdown
Contributor Author

gyptazy commented May 22, 2026

Switched to target branch testing.
Ready for review.

Comment thread pegaprox/api/settings.py Outdated
@gyptazy gyptazy marked this pull request as draft May 22, 2026 12:35
@gyptazy gyptazy force-pushed the feature/420-add-letsecnrypt-dns-01-support branch 3 times, most recently from 7e234d6 to a2b004b Compare May 29, 2026 09:35
@gyptazy gyptazy force-pushed the feature/420-add-letsecnrypt-dns-01-support branch from a2b004b to a7df7d1 Compare May 29, 2026 09:40
@gyptazy
Copy link
Copy Markdown
Contributor Author

gyptazy commented May 29, 2026

/review

@qodo-code-review
Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@gyptazy
Copy link
Copy Markdown
Contributor Author

gyptazy commented May 29, 2026

Adjusted the plain text sig handling and the frontend part.
Ready for review.

Cheers,
gyptazy

@gyptazy gyptazy marked this pull request as ready for review May 29, 2026 09:41
@qodo-code-review
Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@mkellermann97
Copy link
Copy Markdown
Contributor

Hey @gyptazy — reviewed the diff end-to-end. Verdict: looks clean.

Highlights from the read-through:

  • TSIG secret round-trip — encrypted at rest via get_db()._encrypt(), plaintext returned to the UI is masked to ********, real secret only decrypted at the moment of the DNS update. Same pattern we use elsewhere for sensitive server settings, so it slots in cleanly.
  • Algorithm mapping defensive with getattr(dns.tsig, ..., None) + the "unsupported algorithm" error path — handles dnspython version skew without crashing.
  • Manual + RFC 2136 paths separated, both wired through the existing request_certificate orchestrator; nothing in here forks the auth flow in a way that bypasses session checks.
  • No subprocess shell=True / eval / exec patterns introduced. python3-dnspython added properly to debian/control + requirements.txt.
  • i18n keys included, frontend compiled.

One housekeeping bit: GH shows mergeable=DIRTY against Testing — looks like my changes from earlier this morning (5b0a1b5 worldmap feature + e16a4fa SR planned-failover-task-status) touched api/settings.py + api/helpers.py + web/src/translations.js + web/index.html, same files you touched. If you can git fetch origin && git rebase origin/Testing and force-push the feature branch, I'll merge as soon as the conflicts clear (no review-round-2 needed unless you have to rewrite a chunk during the rebase — which I doubt, our edits are in unrelated parts of those files).

opencollective.com/pegaprox if PegaProx earns the ACME-DNS work back.

— Marcus

@gyptazy
Copy link
Copy Markdown
Contributor Author

gyptazy commented May 29, 2026

Hey @mkellermann97,

thanks so far. Regarding the conflicting file: it’s luckily just affecting the index.html file and this gets recreated by running web/Dev/build.sh and is not really human friendly editable.

so, force merge and recreating the file could lead into a solution or I revert the changes on that file, push again and we rebuild the html afterwards on a second branch again. If you prefer the second solution, just let me know.

cheers,
gyptazy

@MrMasterbay MrMasterbay merged commit 01836be into PegaProx:Testing May 30, 2026
3 checks passed
@MrMasterbay
Copy link
Copy Markdown
Contributor

MrMasterbay commented May 30, 2026

Merged. Conflict resolution kept Testing's web/index.html (carried the #451 light-theme input contrast fix and the v0.9.11 version bump that landed after this PR was cut), then rebuilt the frontend from the merged sources so DNS-01 settings UI + your translation keys are in the compiled artifact.

Thanks gyptazy. Testing tip is 01836be.

MrMasterbay added a commit that referenced this pull request May 30, 2026
All five surfaces aligned per the version-bumping checklist:
  pegaprox/constants.py    PEGAPROX_VERSION = "Beta 0.9.12.0"  / PEGAPROX_BUILD = "2026.05.30"
  web/src/constants.js     PEGAPROX_VERSION = "Beta 0.9.12.0"
  version.json             version=0.9.12.0  build=2026.05.30  release_date=2026-05-30
  README.md                version badge → 0.9.12.0-beta
  web/index.html           rebuilt — PEGAPROX_VERSION="Beta 0.9.12.0"

version.json changelog gets a new top entry summarising the v0.9.12.0
train (vacation R3 wrap-up): #509 davinkevin SQLCipher mlock heuristic,
#510 davinkevin per-node-status info→debug, gyptazy PRs #429 (ACME
DNS-01) + #508 (PVE subscription management with key-mask), #413
blackshocks SR layer 5, the CodeAnt batch (API-token role-refresh, WS
cluster scope, SSH-WS SSRF, vmware path-traversal, OIDC discovery
pre-validation, subscription-key mask, useEffect clusterId dep), the
Aikido May-30 batch (3rd-party action SHA pinning, template-injection,
Debian cloud-image SHA512 verify), plain-JSON config fallback removal,
CPU dropdown sort, worldmap wheel passive-listener fix, first-run setup
wizard, alert-email html-escape, audit-log CRLF strip, ACME directory
SSRF guard, version.json worldmap-files manifest gap.

No tag, no GitHub release cut — that decision is yours. Testing-only
push so the build line on dev installs flips to 0.9.12.0 today; the
mirror upload + docs.pegaprox.com pass still pending until you give the
release-cut OK.
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.

Add DNS-01 challenge type to Letsencrypt

3 participants