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 /present {"fqdn": "_acme-challenge.example.com.", "value": "..."} // POST /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.)", 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}) } } 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 }