Files
jarl/internal/server/h_acme.go
T
abrown38andClaude Opus 5.5 6b44b92975 Add an httpreq endpoint for lego and Traefik
jarld can now answer lego's httpreq DNS provider
(POST /api/v1/acme/httpreq/present and /cleanup), so lego and Traefik
publish DNS-01 challenges with only environment variables, without the
jarl CLI. lego's username and API token arrive as HTTP Basic auth,
accepted on these routes only.

The endpoint is off by default (acme.httpreq.enabled) and HTTPS-only by
default: jarld's own TLS, or a trusted proxy that says so with
X-Forwarded-Proto or Forwarded (the last value counts). Plain HTTP can be
allowed from loopback only or everywhere. GET /api/v1/acme/httpreq shows
how jarld sees a request, to check a proxy setup.

jarl acme and the endpoint share their code in internal/acme. Documented
in docs/acme.md, configuration.md, api.md and permissions.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:48:47 +00:00

231 lines
8.9 KiB
Go

package server
// An endpoint for lego's "httpreq" DNS provider, so lego (and Traefik,
// which uses lego) can publish DNS-01 challenges without the jarl CLI.
// docs/acme.md explains the setup; this is how it works.
//
// lego is configured with HTTPREQ_ENDPOINT, HTTPREQ_USERNAME and
// HTTPREQ_PASSWORD. For each challenge it sends
//
// POST <endpoint>/present {"fqdn": "_acme-challenge.example.com.", "value": "..."}
// POST <endpoint>/cleanup (the same body, once the CA has checked it)
//
// with the username and password as HTTP Basic auth, and treats any 2xx
// answer as success. fqdn is the full record name, after lego has followed
// any CNAME (into a dedicated ACME zone, for example).
//
// Jarl's endpoint is /api/v1/acme/httpreq, or /api/v1/acme/httpreq/view/NAME
// to write into one view only. The password is a Jarl API token and the
// username is the user that owns it. The request then does exactly what
// "jarl acme set|unset --exact FQDN VALUE" does (package acme): it writes
// the TXT record in the longest matching primary zone, in every view that
// has the zone, as one changeset, and answers once named serves it. The
// token's grants apply as usual, and the audit log records the change.
//
// Basic auth is accepted on these routes only; the rest of the API takes
// Bearer tokens and sessions. The endpoint is off unless
// acme.httpreq.enabled is set, and because the token travels in every
// request it refuses requests that didn't come over HTTPS (see transport)
// unless acme.httpreq.require_https is turned off.
import (
"fmt"
"net"
"net/http"
"net/netip"
"regexp"
"strings"
"jarl/internal/acme"
"jarl/internal/auth"
"jarl/internal/dnsname"
"jarl/internal/engine"
"jarl/internal/model"
)
// HTTPReq configures the httpreq endpoint. The zero value is off, and
// when on, HTTPS-only.
type HTTPReq struct {
Enabled bool
// AllowPlainHTTP accepts requests over plain HTTP from anywhere
// (require_https: false).
AllowPlainHTTP bool
// AllowLoopbackHTTP accepts plain HTTP from 127.0.0.1 and ::1, unless
// that address is a trusted proxy.
AllowLoopbackHTTP bool
}
// httpreqBody is what lego sends. In its RAW mode (HTTPREQ_MODE=RAW) it
// sends domain, token and keyAuth instead, which Jarl doesn't support.
type httpreqBody struct {
FQDN string `json:"fqdn"`
Value string `json:"value"`
Domain string `json:"domain"`
Token string `json:"token"`
KeyAuth string `json:"keyAuth"`
}
// A DNS-01 value is the base64url SHA-256 digest of the key authorization
// (RFC 8555, section 8.4): 43 characters from this alphabet.
var acmeValueRe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,255}$`)
const httpreqRealm = `Basic realm="jarl httpreq", charset="UTF-8"`
// httpreq handles POST .../present (set) and .../cleanup.
func (s *Server) httpreq(set bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
if err := s.handleHTTPReq(w, r, set); err != nil {
s.writeErr(w, r, err, nil)
}
}
}
func (s *Server) handleHTTPReq(w http.ResponseWriter, r *http.Request, set bool) error {
if !s.cfg.HTTPReq.Enabled {
return &httpError{http.StatusNotFound, "disabled", "the httpreq endpoint is off; set acme.httpreq.enabled: true in jarl.yaml and restart jarld (docs/acme.md)"}
}
if ok, why := s.httpreqTransport(r); !ok {
ip := s.clientIP(r)
s.log.Warn("httpreq request refused: not HTTPS", "ip", ip, "why", why)
s.eng.Audit(r.Context(), &auth.Principal{IP: ip, Agent: r.UserAgent(), Method: "token"}, "acme.httpreq_refused", "", 0, "", "denied", "not HTTPS: "+why)
msg := "the httpreq endpoint only accepts HTTPS (acme.httpreq.require_https), and this request came over " + why + "."
if r.Header.Get("Authorization") != "" {
msg += " Its token may have crossed the network unencrypted; if so, revoke it and create a new one."
}
return &httpError{http.StatusForbidden, "https_required", msg}
}
p, err := s.httpreqPrincipal(w, r)
if err != nil {
return err
}
var in httpreqBody
if err := decodeJSON(r, &in); err != nil {
return err
}
if in.FQDN == "" && (in.Domain != "" || in.KeyAuth != "") {
return badRequest("this looks like lego's RAW mode (domain, token, keyAuth); Jarl needs the default mode: unset HTTPREQ_MODE")
}
if in.FQDN == "" || in.Value == "" {
return badRequest(`expected {"fqdn": "_acme-challenge.example.com.", "value": "..."}`)
}
name := dnsname.Fqdn(in.FQDN)
if strings.HasPrefix(name, "*.") {
return badRequest("fqdn %q is a wildcard; send the challenge name (_acme-challenge.<domain>)", in.FQDN)
}
if err := dnsname.CheckOwner(name); err != nil {
return badRequest("fqdn %q: %v", in.FQDN, err)
}
if !acmeValueRe.MatchString(in.Value) {
return badRequest("value must be a DNS-01 challenge value (letters, digits, - and _)")
}
view := r.PathValue("view")
st, err := s.store.LoadState(r.Context())
if err != nil {
return err
}
zones := acme.Zones(name, primaryZonesFor(st, p), view)
if len(zones) == 0 {
where := ""
if view != "" {
where = " in view " + view
}
return notFound("no primary zone%s that this token can see contains %s", where, dnsname.Display(name))
}
reqs := toRequests(acme.Requests(name, zones, []string{in.Value}, set, acme.DefaultTTL))
res, err := s.eng.Submit(r.Context(), reqs, p, "api", acme.Description(name, set)+" (lego httpreq)", engine.ApplyOptions{})
if err != nil {
s.writeErr(w, r, err, res)
return nil
}
out := map[string]any{"ok": true, "fqdn": name, "changeset_id": res.ChangesetID}
var where []string
for _, z := range zones {
if z.View != "" {
where = append(where, dnsname.Display(z.Name)+" (view "+z.View+")")
} else {
where = append(where, dnsname.Display(z.Name))
}
}
out["zones"] = where
writeJSON(w, http.StatusOK, out)
return nil
}
// primaryZonesFor lists the primary zones p can see.
func primaryZonesFor(st *model.State, p *auth.Principal) []acme.Zone {
var out []acme.Zone
for _, z := range st.SortedZones() {
vn := st.ViewName(z)
if z.Type == model.ZonePrimary && p.CanSeeZone(z.Name, vn) {
out = append(out, acme.Zone{Name: z.Name, View: vn})
}
}
return out
}
// httpreqTransport reports whether the endpoint accepts r's transport.
func (s *Server) httpreqTransport(r *http.Request) (bool, string) {
https, why := s.transport(r)
if https || s.cfg.HTTPReq.AllowPlainHTTP {
return true, why
}
if s.cfg.HTTPReq.AllowLoopbackHTTP {
host, _, _ := net.SplitHostPort(r.RemoteAddr)
if a, err := netip.ParseAddr(host); err == nil && a.Unmap().IsLoopback() && !s.trusted(a) {
return true, "plain HTTP from " + host + " (allowed by allow_loopback_http)"
}
}
return false, why
}
// httpreqPrincipal authenticates an httpreq request: HTTP Basic with the
// token's user as the username and the API token as the password. A
// Bearer token works too (handy with curl).
func (s *Server) httpreqPrincipal(w http.ResponseWriter, r *http.Request) (*auth.Principal, error) {
user, tok, ok := r.BasicAuth()
if !ok {
if scheme, _, _ := strings.Cut(r.Header.Get("Authorization"), " "); strings.EqualFold(scheme, "Bearer") {
p, _, err := s.authenticate(r)
return p, err
}
w.Header().Set("WWW-Authenticate", httpreqRealm)
return nil, &httpError{http.StatusUnauthorized, "unauthenticated", "send the Jarl user as the HTTP Basic username (HTTPREQ_USERNAME) and an API token as the password (HTTPREQ_PASSWORD)"}
}
ip := s.clientIP(r)
if s.limiter.blocked("ip:"+ip, failsPerIP) {
return nil, &httpError{http.StatusTooManyRequests, "rate_limited", "too many failed attempts; try again later"}
}
fail := func(reason string) error {
s.limiter.fail("ip:" + ip)
s.eng.Audit(r.Context(), &auth.Principal{User: &model.User{Username: user}, IP: ip, Agent: r.UserAgent(), Method: "token"}, "auth.token_failed", "", 0, "", "failure", "httpreq: "+reason)
w.Header().Set("WWW-Authenticate", httpreqRealm)
return &httpError{http.StatusUnauthorized, "unauthenticated", "invalid credentials: " + reason}
}
p, err := s.tokenPrincipal(r.Context(), strings.TrimSpace(tok), ip, r.UserAgent())
if err != nil {
return nil, fail(err.Error() + " (HTTPREQ_PASSWORD must be a Jarl API token)")
}
if !strings.EqualFold(strings.TrimSpace(user), p.User.Username) {
return nil, fail(fmt.Sprintf("the token doesn't belong to user %q (HTTPREQ_USERNAME must be the token's user)", user))
}
return p, nil
}
// httpreqStatus answers GET /acme/httpreq without authentication, so a
// setup can be checked (e.g. with curl through the reverse proxy): is the
// endpoint on, and would this request count as HTTPS?
func (s *Server) httpreqStatus(w http.ResponseWriter, r *http.Request) error {
https, why := s.transport(r)
accepted, _ := s.httpreqTransport(r)
out := map[string]any{"enabled": s.cfg.HTTPReq.Enabled, "https": https, "transport": why}
if s.cfg.HTTPReq.Enabled {
out["accepted"] = accepted
if !accepted {
out["hint"] = "requests like this one are refused; see 'HTTPS and reverse proxies' in docs/acme.md"
}
}
writeJSON(w, http.StatusOK, out)
return nil
}