A disabled zone stays in Jarl with its records, options and history, still editable, but named doesn't load it; enabling it serves it as it is then. Signed zones, and zones another view shares, can't be disabled. jarl zone disable|enable, the zone page's Disable/Enable button. Database schema 7. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
231 lines
8.9 KiB
Go
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 && !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
|
|
}
|