// SPDX-FileCopyrightText: 2026 Echolot contributors // SPDX-License-Identifier: GPL-3.0-or-later // Package acmehttp answers ACME HTTP-01 challenges and sends everything else to HTTPS. // // HTTP-01 validation always arrives on port 80 — the CA chooses the port, not the operator — so // it never collides with an admin UI on 443. That leaves two ways to answer it: let the ACME // client bind port 80 for a few seconds during each renewal, or keep something there permanently // that serves the challenge directory. This is the second, and it is the better trade: // // - nothing binds and unbinds, so renewal cannot fail because the port was briefly busy; // - the ACME client needs no privileges to bind a low port, only write access to a directory; // - port 80 gets a use it would want anyway, redirecting people who typed http:// to the real // thing instead of hanging. // // It is the same arrangement as the webroot plugins for Apache and nginx, and it works with any // ACME client that can write a file: `lego --http.webroot`, `certbot --webroot`, `acme.sh -w`. // // The ACME client stays an external program on purpose. lego is also a Go library, but importing // it would put a large dependency tree into a server that deliberately has none — and the CLI does // the same job from a timer. package acmehttp import ( "log/slog" "net/http" "os" "path/filepath" "strings" ) // ChallengePath is the fixed prefix ACME uses. It is not configurable, by the specification. const ChallengePath = "/.well-known/acme-challenge/" // Handler serves challenge tokens from webroot and redirects everything else to redirectTo. // // webroot is the directory an ACME client writes into; the tokens themselves land in // /.well-known/acme-challenge/, which is exactly what --http.webroot expects. func Handler(webroot, redirectTo string) http.Handler { mux := http.NewServeMux() mux.HandleFunc(ChallengePath, func(w http.ResponseWriter, r *http.Request) { token := strings.TrimPrefix(r.URL.Path, ChallengePath) // Tokens are base64url from the CA. Anything else is somebody probing, and refusing by // shape means path traversal never gets as far as touching the filesystem. if token == "" || !validToken(token) { http.NotFound(w, r) return } body, err := os.ReadFile(filepath.Join(webroot, filepath.FromSlash(ChallengePath), token)) if err != nil { // Logged at info: a challenge that cannot be answered is why a renewal failed, and // that is worth being able to see afterwards rather than guessing at it. slog.Info("acme challenge not found", "token", token, "webroot", webroot) http.NotFound(w, r) return } slog.Info("answered acme challenge", "token", token, "from", r.RemoteAddr) w.Header().Set("Content-Type", "text/plain") _, _ = w.Write(body) }) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if redirectTo == "" { http.Error(w, "this port serves ACME challenges only", http.StatusNotFound) return } // 308 rather than 302: the method must not change, and the redirect is permanent in the // sense that matters — this port will never serve the application. http.Redirect(w, r, strings.TrimRight(redirectTo, "/")+r.URL.RequestURI(), http.StatusPermanentRedirect) }) return mux } // validToken accepts only the base64url alphabet the ACME spec uses for tokens. // // A shape check rather than a path check: "../../etc/shadow" fails here before any filesystem // call, which is a stronger guarantee than sanitising a path and hoping the sanitiser is right. func validToken(s string) bool { if len(s) > 128 { return false } for _, r := range s { switch { case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '.': default: return false } } // A bare "." or ".." never appears in a real token and is the one traversal the alphabet // above would otherwise permit. return s != "." && s != ".." } // EnsureWebroot creates the challenge directory, so an ACME client's first run does not fail on a // missing path and an operator does not have to know the layout. func EnsureWebroot(webroot string) error { return os.MkdirAll(filepath.Join(webroot, filepath.FromSlash(ChallengePath)), 0o755) }