Files
echolot/server/internal/acmehttp/acmehttp.go
T
mrambossekandClaude Fable 5 5d7f59a66a
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 34s
server-release / release (push) Successful in 35s
acme: answer HTTP-01 from the server itself, on port 80
HTTP-01 always arrives on port 80 - the CA chooses the port, not the operator -
so it never collides with an admin UI on 443. The conflict only exists for
TLS-ALPN-01, which is the challenge type that does use 443.

Given that, the server keeps a permanent listener on 80 that answers challenges
from a webroot and redirects everything else to the admin UI. Same arrangement
as the webroot plugins for Apache and nginx, and better than letting the ACME
client bind 80 per renewal: nothing binds and unbinds, so a renewal cannot fail
because the port was briefly busy, and the client needs only write access to a
directory instead of the privilege to bind a low port. Port 80 also gets a use
it would want anyway.

The ACME client stays an external program. 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.

Tokens are validated by *shape* before any filesystem call, so traversal never
reaches the disk - a stronger guarantee than sanitising a path and trusting the
sanitiser.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 18:16:09 +02:00

101 lines
4.2 KiB
Go

// 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
// <webroot>/.well-known/acme-challenge/<token>, 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)
}