Skip to content

fix(core): serve HTTP-01 ACME challenges via webroot provider - #347

Open
Splicho wants to merge 1 commit into
Billionmail:devfrom
Splicho:fix/acme-http01-webroot
Open

fix(core): serve HTTP-01 ACME challenges via webroot provider#347
Splicho wants to merge 1 commit into
Billionmail:devfrom
Splicho:fix/acme-http01-webroot

Conversation

@Splicho

@Splicho Splicho commented May 19, 2026

Copy link
Copy Markdown

Problem

The panel's Apply Free Cert UI fails with Invalid response from http://<host>/.well-known/acme-challenge/<token>: 404 on every domain. This is reproducible on a clean install of billionmail/core:4.9.3 with port 80 reachable and DNS pointed at the host:

Failed to apply for SSL certificate: error: one or more domains had a problem:
[mail.example.com] invalid authorization: acme: error: 403 ::
urn:ietf:params:acme:error:unauthorized :: <ip>:
Invalid response from http://mail.example.com/.well-known/acme-challenge/<token>: 404

Root cause

Two issues compound:

  1. SafePath middleware blocks unauthenticated ACME requests. The hook at cmd.go returns 404 for any non-/api/ request without a session cookie. Let's Encrypt validation servers have no session, so they never reach the ACME handler.

  2. The static file handler shadows the ACME proxy. manifest/config/config.yaml sets fileServerEnabled: true, which makes GoFrame's static file handler run ahead of bound handlers. Even with the SafePath gate bypassed, requests for /.well-known/acme-challenge/<token> are intercepted by the static handler and 404 because the file doesn't exist on disk. The bound proxy at s.BindHandler(\"/.well-known/acme-challenge/*any\", …) and the lego internal server at 127.0.0.1:60880 are never reached.

I confirmed (1) by adding a SafePath bypass and observing requests reach the bound handler. I confirmed (2) by adding a unique response body to the bound handler and seeing the static handler's stock Not Found returned instead.

Fix

  • Switch the HTTP-01 provider from http01.NewProviderServer(\"127.0.0.1\", \"60880\") to lego's webroot provider, pointing at server.serverRoot. lego writes the challenge token to <serverRoot>/.well-known/acme-challenge/<token> directly. The existing static handler serves it. lego removes the file after validation completes.
  • Remove the now-dead 60880 reverse proxy in cmd.go.
  • Add a /.well-known/acme-challenge/ bypass to the SafePath middleware so LE can reach the webroot path.

The webroot path is read from config (server.serverRoot, defaulting to public/dist) instead of being hardcoded.

Diff stats

core/internal/cmd/cmd.go           | 23 +++++++++--------------
core/internal/service/acme/acme.go | 17 +++++++++++++----
2 files changed, 22 insertions(+), 18 deletions(-)

Verification

End-to-end test against billionmail/core:4.9.3 with the patched binary deployed:

Check Result
POST /api/ssl/apply_cert {\"success\":true,\"msg\":\"Certificate applied successfully\"}
letsencrypts row created status=1, correct not_after, valid subject
TLS on :443 / :993 / :587 Issuer Let's Encrypt R12, CN matches host
Cleanup <serverRoot>/.well-known/acme-challenge/ empty after issuance

Tested with two domains (mail.emudevs.gg, mail.synapse-themes.cc); both issued cleanly through the panel UI.

Notes

  • No new dependency: github.com/go-acme/lego/v4/providers/http/webroot is part of the lego module already in go.mod.
  • g.Cfg().MustGet(ctx, \"server.serverRoot\", \"public/dist\") is used so admins who customize serverRoot aren't broken.
  • The DNS-01 paths (Cloudflare, Aliyun, Tencent, etc.) are unchanged.

The panel's "Apply Free Cert" UI fails with 404 on every domain because
two issues stack on top of each other:

1. The SafePath middleware in cmd.go returns 404 for any unauthenticated
   request without a session cookie. Let's Encrypt validation requests
   have no cookie, so they hit the gate before reaching the ACME handler.

2. GoFrame's static file handler (fileServerEnabled: true in
   manifest/config/config.yaml) runs ahead of bound handlers. Even if
   the SafePath gate is bypassed, the bound /.well-known/acme-challenge
   proxy is shadowed by the static handler returning 404 for the missing
   token file. The lego internal server on 127.0.0.1:60880 is never
   reached.

Switch the HTTP-01 provider from lego's NewProviderServer to its webroot
provider, pointing at server.serverRoot. lego writes the token directly
to <serverRoot>/.well-known/acme-challenge/<token>, where the existing
static handler serves it, and lego removes it after validation. The
60880 reverse proxy in cmd.go is now dead code and is removed. A small
SafePath bypass for /.well-known/acme-challenge/ keeps the path open
for unauthenticated LE validation requests.

Verified end-to-end on billionmail/core:4.9.3:
- /api/ssl/apply_cert returns success for fresh domains
- letsencrypts row created with status=1 and correct not_after
- Live TLS confirmed on 443/993/587 (issuer Let's Encrypt R12)
- Challenge dir empty after issuance (cleanup works)
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.

1 participant