Files
adamandClaude Opus 5.5 71ebed2b73
release / release (push) Failing after 18s
Log failed logins and refused tokens, address first
For Guard's jarl preset and fail2ban: msg="login failed" ip=... user=...
and msg="token refused" ip=..., beside the audit records.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:29:37 +01:00

233 lines
9.1 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 && !z.Disabled && p.CanSeeZone(z.Name, vn) {
out = append(out, acme.Zone{Name: z.Name, View: vn, Link: z.LinkGroup})
}
}
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)
// For Guard and fail2ban: the address first, so a name can't fake it.
s.log.Warn("token refused", "ip", ip, "reason", "httpreq: "+reason)
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
}