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>
This commit is contained in:
1 parent
0221c78c8a
commit
6b44b92975
18 files changed
+1106
-73
No files matched your search
@@ -16,6 +16,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"jarl/internal/api"
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/engine"
|
||||
"jarl/internal/stats"
|
||||
@@ -37,6 +38,8 @@ type Config struct {
|
||||
Version string
|
||||
// Stats is the query statistics collector (nil when disabled).
|
||||
Stats *stats.Collector
|
||||
// HTTPReq configures the endpoint for lego's httpreq DNS provider.
|
||||
HTTPReq HTTPReq
|
||||
}
|
||||
|
||||
// Server is the HTTP server.
|
||||
@@ -150,6 +153,7 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
s.logHTTPReq(tlsCfg != nil)
|
||||
if ready != nil {
|
||||
ready()
|
||||
}
|
||||
@@ -374,13 +378,76 @@ func (s *Server) trusted(a netip.Addr) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Server) isHTTPS(r *http.Request) bool {
|
||||
if r.TLS != nil {
|
||||
return true
|
||||
// logHTTPReq says at startup whether the httpreq endpoint can work.
|
||||
func (s *Server) logHTTPReq(tls bool) {
|
||||
h := s.cfg.HTTPReq
|
||||
switch {
|
||||
case !h.Enabled:
|
||||
case h.AllowPlainHTTP:
|
||||
s.log.Warn("acme.httpreq accepts plain HTTP (require_https: false): API tokens may travel unencrypted", "path", api.Prefix+"/acme/httpreq")
|
||||
case !tls && len(s.cfg.TrustedProxies) == 0 && !h.AllowLoopbackHTTP:
|
||||
s.log.Warn("acme.httpreq is enabled but jarld has no tls and no trusted_proxies, so every request will be refused as not HTTPS", "path", api.Prefix+"/acme/httpreq")
|
||||
default:
|
||||
s.log.Info("acme.httpreq endpoint enabled", "path", api.Prefix+"/acme/httpreq", "tls", tls, "trusted_proxies", len(s.cfg.TrustedProxies), "allow_loopback_http", h.AllowLoopbackHTTP)
|
||||
}
|
||||
host, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
if a, err := netip.ParseAddr(host); err == nil && s.trusted(a) {
|
||||
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Server) isHTTPS(r *http.Request) bool {
|
||||
https, _ := s.transport(r)
|
||||
return https
|
||||
}
|
||||
|
||||
// transport reports whether r reached Jarl over HTTPS, and explains how
|
||||
// that was decided (for error messages and the httpreq status check).
|
||||
//
|
||||
// A request is HTTPS when jarld terminated TLS itself (tls in jarl.yaml),
|
||||
// or when it came straight from a trusted proxy (trusted_proxies) that
|
||||
// says the client used HTTPS: X-Forwarded-Proto, or, if that is absent,
|
||||
// the proto parameter of Forwarded (RFC 7239). Proxies append to these
|
||||
// headers, so the last value is the one the nearest proxy set; anything a
|
||||
// client sent itself comes earlier and is ignored. From any other address
|
||||
// the headers are ignored, since anyone could send them.
|
||||
func (s *Server) transport(r *http.Request) (https bool, why string) {
|
||||
if r.TLS != nil {
|
||||
return true, "HTTPS to jarld (tls in jarl.yaml)"
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
peer, err := netip.ParseAddr(host)
|
||||
if err != nil || !s.trusted(peer) {
|
||||
if r.Header.Get("X-Forwarded-Proto") != "" || r.Header.Get("Forwarded") != "" {
|
||||
return false, fmt.Sprintf("plain HTTP from %s, which sent X-Forwarded-Proto or Forwarded but isn't in trusted_proxies, so the header was ignored", host)
|
||||
}
|
||||
return false, fmt.Sprintf("plain HTTP from %s (jarld has no tls configured, and %s isn't a trusted proxy)", host, host)
|
||||
}
|
||||
proto, header := forwardedProto(r)
|
||||
switch {
|
||||
case proto == "":
|
||||
return false, fmt.Sprintf("trusted proxy %s, which sent neither X-Forwarded-Proto nor Forwarded: proto=, so the client's protocol is unknown", host)
|
||||
case proto == "https":
|
||||
return true, fmt.Sprintf("HTTPS to trusted proxy %s (%s: https)", host, header)
|
||||
default:
|
||||
return false, fmt.Sprintf("trusted proxy %s, which says the client used %s (%s: %s)", host, proto, header, proto)
|
||||
}
|
||||
}
|
||||
|
||||
// forwardedProto returns the client's protocol as the nearest proxy
|
||||
// reported it, and the header it came from.
|
||||
func forwardedProto(r *http.Request) (proto, header string) {
|
||||
if vals := r.Header.Values("X-Forwarded-Proto"); len(vals) > 0 {
|
||||
parts := strings.Split(strings.Join(vals, ","), ",")
|
||||
return strings.ToLower(strings.TrimSpace(parts[len(parts)-1])), "X-Forwarded-Proto"
|
||||
}
|
||||
if vals := r.Header.Values("Forwarded"); len(vals) > 0 {
|
||||
elems := strings.Split(strings.Join(vals, ","), ",")
|
||||
for _, pair := range strings.Split(elems[len(elems)-1], ";") {
|
||||
k, v, _ := strings.Cut(strings.TrimSpace(pair), "=")
|
||||
if strings.EqualFold(k, "proto") {
|
||||
return strings.ToLower(strings.Trim(strings.TrimSpace(v), `"`)), "Forwarded"
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
Reference in new issue
Block a user