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:
abrown38andClaude Opus 5.5 committed 2026-10-08 13:48:47 +00:00
1 parent 0221c78c8a
commit 6b44b92975
18 files changed
+1106 -73

No files matched your search

+248
View File
@@ -0,0 +1,248 @@
package server
import (
"bytes"
"crypto/tls"
"encoding/json"
"net/http"
"net/http/httptest"
"net/netip"
"strings"
"testing"
"jarl/internal/api"
)
// hreq is one request to the httpreq endpoint, as it would arrive from
// remote (with TLS when tlsOn is set), with extra headers.
type hreq struct {
path string // after /acme/httpreq
remote string
tlsOn bool
header map[string]string
user string
token string
body any
}
func (e *apiEnv) httpreq(q hreq) *httptest.ResponseRecorder {
e.t.Helper()
var rd *bytes.Reader
if q.body != nil {
b, _ := json.Marshal(q.body)
rd = bytes.NewReader(b)
} else {
rd = bytes.NewReader(nil)
}
method := "POST"
if q.path == "" {
method = "GET"
}
r := httptest.NewRequest(method, api.Prefix+"/acme/httpreq"+q.path, rd)
r.RemoteAddr = q.remote
if r.RemoteAddr == "" {
r.RemoteAddr = "192.0.2.9:40000"
}
if q.tlsOn {
r.TLS = &tls.ConnectionState{}
}
r.Header.Set("Content-Type", "application/json")
for k, v := range q.header {
r.Header.Set(k, v)
}
if q.token != "" {
r.SetBasicAuth(q.user, q.token)
}
w := httptest.NewRecorder()
e.srv.Handler().ServeHTTP(w, r)
return w
}
func errCode(w *httptest.ResponseRecorder) string {
var b api.ErrorBody
json.Unmarshal(w.Body.Bytes(), &b)
return b.Error.Code
}
func challenge(value string) map[string]any {
return map[string]any{"fqdn": "_acme-challenge.example.com.", "value": value}
}
func (e *apiEnv) txtValues(zone, name string) []string {
e.t.Helper()
var out []string
for _, r := range e.list("/zones/"+zone+"/records", e.token) {
if r["type"] == "TXT" && r["name"] == name {
out = append(out, r["data"].(string))
}
}
return out
}
func TestHTTPReqDisabledByDefault(t *testing.T) {
e := newAPIEnv(t)
w := e.httpreq(hreq{path: "/present", tlsOn: true, user: "admin", token: e.token, body: challenge("abc")})
if w.Code != 404 || errCode(w) != "disabled" {
t.Errorf("disabled endpoint = %d %s", w.Code, w.Body)
}
w = e.httpreq(hreq{})
var st map[string]any
json.Unmarshal(w.Body.Bytes(), &st)
if w.Code != 200 || st["enabled"] != false {
t.Errorf("status = %d %s", w.Code, w.Body)
}
}
func TestHTTPReqTransport(t *testing.T) {
proxy := netip.MustParsePrefix("10.0.0.2/32")
e := newAPIEnvConfig(t, Config{Version: "test", TrustedProxies: []netip.Prefix{proxy}, HTTPReq: HTTPReq{Enabled: true}})
e.exampleZone()
cases := []struct {
name string
remote string
tlsOn bool
header map[string]string
ok bool
}{
{"plain HTTP", "192.0.2.9:1", false, nil, false},
{"jarld's own TLS", "192.0.2.9:1", true, nil, true},
{"header from an untrusted client", "192.0.2.9:1", false, map[string]string{"X-Forwarded-Proto": "https"}, false},
{"trusted proxy, https", "10.0.0.2:1", false, map[string]string{"X-Forwarded-Proto": "https"}, true},
{"trusted proxy, http", "10.0.0.2:1", false, map[string]string{"X-Forwarded-Proto": "http"}, false},
{"trusted proxy, no header", "10.0.0.2:1", false, nil, false},
{"client-sent https, proxy appended http", "10.0.0.2:1", false, map[string]string{"X-Forwarded-Proto": "https, http"}, false},
{"client-sent http, proxy appended https", "10.0.0.2:1", false, map[string]string{"X-Forwarded-Proto": "http,https"}, true},
{"trusted proxy, Forwarded", "10.0.0.2:1", false, map[string]string{"Forwarded": `for=192.0.2.9;proto=https`}, true},
{"trusted proxy, Forwarded http", "10.0.0.2:1", false, map[string]string{"Forwarded": `for=192.0.2.9;Proto="http"`}, false},
{"loopback, not allowed", "127.0.0.1:1", false, nil, false},
}
for _, c := range cases {
w := e.httpreq(hreq{path: "/present", remote: c.remote, tlsOn: c.tlsOn, header: c.header, user: "admin", token: e.token, body: challenge("v1")})
if c.ok && w.Code != 200 || !c.ok && (w.Code != 403 || errCode(w) != "https_required") {
t.Errorf("%s: %d %s", c.name, w.Code, w.Body)
}
if !c.ok && !strings.Contains(w.Body.String(), "unencrypted") {
t.Errorf("%s: no warning about the exposed token: %s", c.name, w.Body)
}
// The status check agrees.
s := e.httpreq(hreq{remote: c.remote, tlsOn: c.tlsOn, header: c.header})
var st map[string]any
json.Unmarshal(s.Body.Bytes(), &st)
if st["accepted"] != c.ok || st["transport"] == "" {
t.Errorf("%s: status = %s", c.name, s.Body)
}
}
// Cookies follow the same rules (cookie_secure: auto).
r := httptest.NewRequest("GET", "/", nil)
r.RemoteAddr = "10.0.0.2:1"
r.Header.Set("Forwarded", "proto=https")
if !e.srv.isHTTPS(r) {
t.Error("Forwarded: proto=https from a trusted proxy isn't HTTPS")
}
}
func TestHTTPReqLoopbackAndPlain(t *testing.T) {
e := newAPIEnvConfig(t, Config{Version: "test", TrustedProxies: []netip.Prefix{netip.MustParsePrefix("::1/128")}, HTTPReq: HTTPReq{Enabled: true, AllowLoopbackHTTP: true}})
e.exampleZone()
for remote, ok := range map[string]bool{
"127.0.0.1:1": true, // lego on this host
"192.0.2.9:1": false, // still HTTPS-only from the network
"[::1]:1": false, // a trusted proxy: its headers decide, and it sent none
"10.20.30.40:1": false,
} {
w := e.httpreq(hreq{path: "/present", remote: remote, user: "admin", token: e.token, body: challenge("v1")})
if ok && w.Code != 200 || !ok && w.Code != 403 {
t.Errorf("%s: %d %s", remote, w.Code, w.Body)
}
}
e = newAPIEnvConfig(t, Config{Version: "test", HTTPReq: HTTPReq{Enabled: true, AllowPlainHTTP: true}})
e.exampleZone()
if w := e.httpreq(hreq{path: "/present", user: "admin", token: e.token, body: challenge("v1")}); w.Code != 200 {
t.Errorf("require_https off: %d %s", w.Code, w.Body)
}
}
func TestHTTPReqChallenges(t *testing.T) {
e := newAPIEnvConfig(t, Config{Version: "test", HTTPReq: HTTPReq{Enabled: true}})
e.exampleZone()
e.do("POST", "/users", e.token, map[string]any{"username": "lego", "password": "lego-password-123", "grant_strings": []string{"records:write names=_acme-challenge.* types=TXT"}}, 201)
var legoID int64
for _, u := range e.list("/users", e.token) {
if u["username"] == "lego" {
legoID = int64(u["id"].(float64))
}
}
tok := e.do("POST", "/users/"+itoa(legoID)+"/tokens", e.token, map[string]any{"name": "lego"}, 201)["token"].(string)
send := func(path, user, token string, body any) *httptest.ResponseRecorder {
return e.httpreq(hreq{path: path, tlsOn: true, user: user, token: token, body: body})
}
const name = "_acme-challenge.example.com."
// present, a retry of it, and a second value (apex and wildcard).
for _, v := range []string{"v1", "v1", "v2"} {
if w := send("/present", "lego", tok, challenge(v)); w.Code != 200 {
t.Fatalf("present %s: %d %s", v, w.Code, w.Body)
}
}
if got := e.txtValues("example.com", name); len(got) != 2 {
t.Errorf("after present: %v", got)
}
// cleanup removes one value; repeating it still succeeds.
for i := 0; i < 2; i++ {
if w := send("/cleanup", "lego", tok, challenge("v1")); w.Code != 200 {
t.Fatalf("cleanup: %d %s", w.Code, w.Body)
}
}
if got := e.txtValues("example.com", name); len(got) != 1 || !strings.Contains(got[0], "v2") {
t.Errorf("after cleanup: %v", got)
}
// Credentials.
for _, c := range []struct {
name, user, token string
}{
{"no credentials", "", ""},
{"wrong user", "admin", tok},
{"bad token", "lego", "jarl_aaaaaaaaaaaaaaaa_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},
} {
w := send("/present", c.user, c.token, challenge("v3"))
if w.Code != 401 || w.Header().Get("WWW-Authenticate") == "" {
t.Errorf("%s: %d %s", c.name, w.Code, w.Body)
}
}
// A Bearer token works too.
w := e.httpreq(hreq{path: "/cleanup", tlsOn: true, header: map[string]string{"Authorization": "Bearer " + tok}, body: challenge("v2")})
if w.Code != 200 {
t.Errorf("bearer: %d %s", w.Code, w.Body)
}
// Bodies and names.
for _, c := range []struct {
name string
body any
code int
}{
{"RAW mode", map[string]any{"domain": "example.com", "token": "t", "keyAuth": "k"}, 400},
{"no value", map[string]any{"fqdn": name}, 400},
{"value with a quote", map[string]any{"fqdn": name, "value": `a"b`}, 400},
{"wildcard", map[string]any{"fqdn": "*.example.com.", "value": "v"}, 400},
{"outside the token's grants", map[string]any{"fqdn": "www.example.com.", "value": "v"}, 403},
{"no such zone", map[string]any{"fqdn": "_acme-challenge.example.net.", "value": "v"}, 404},
} {
if w := send("/present", "lego", tok, c.body); w.Code != c.code {
t.Errorf("%s: %d, want %d: %s", c.name, w.Code, c.code, w.Body)
}
}
if w := send("/view/nosuch/present", "lego", tok, challenge("v4")); w.Code != 404 {
t.Errorf("unknown view: %d %s", w.Code, w.Body)
}
if w := e.httpreq(hreq{path: "/present", tlsOn: true, user: "lego", token: tok, body: challenge("v5")}); w.Code != http.StatusOK {
t.Errorf("final present: %d", w.Code)
}
}
func (e *apiEnv) exampleZone() {
e.t.Helper()
e.do("POST", "/zones", e.token, map[string]any{"name": "example.com", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}}}, 201)
}
+230
View File
@@ -0,0 +1,230 @@
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
}
+6
View File
@@ -100,6 +100,12 @@ func (s *Server) routes() {
m.HandleFunc("POST "+p+"/system/check", s.authed(s.systemCheck))
m.HandleFunc("POST "+p+"/system/resync", s.authed(s.systemResync))
m.HandleFunc("GET "+p+"/audit", s.authed(s.listAudit))
// lego's httpreq DNS provider (h_acme.go)
m.HandleFunc("GET "+p+"/acme/httpreq", s.public(s.httpreqStatus))
m.HandleFunc("POST "+p+"/acme/httpreq/present", s.httpreq(true))
m.HandleFunc("POST "+p+"/acme/httpreq/cleanup", s.httpreq(false))
m.HandleFunc("POST "+p+"/acme/httpreq/view/{view}/present", s.httpreq(true))
m.HandleFunc("POST "+p+"/acme/httpreq/view/{view}/cleanup", s.httpreq(false))
// Query statistics
m.HandleFunc("GET "+p+"/stats/status", s.authed(s.statsStatus))
m.HandleFunc("GET "+p+"/stats/summary", s.authed(s.statsSummary))
+75 -8
View File
@@ -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 "", ""
}
+12 -5
View File
@@ -26,12 +26,23 @@ import (
type apiEnv struct {
t *testing.T
ts *httptest.Server
srv *Server
st *store.Store
token string // admin token
}
// newAPIEnv starts an API server; a statistics collector may be given.
func newAPIEnv(t *testing.T, collector ...*stats.Collector) *apiEnv {
t.Helper()
cfg := Config{Version: "test"}
if len(collector) > 0 {
cfg.Stats = collector[0]
}
return newAPIEnvConfig(t, cfg)
}
// newAPIEnvConfig starts an API server with the given configuration.
func newAPIEnvConfig(t *testing.T, cfg Config) *apiEnv {
t.Helper()
dir := t.TempDir()
st, err := store.Open(filepath.Join(dir, "jarl.db"))
@@ -44,10 +55,6 @@ func newAPIEnv(t *testing.T, collector ...*stats.Collector) *apiEnv {
Paths: render.Paths{ConfigDir: filepath.Join(dir, "etc"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")},
StagingDir: filepath.Join(dir, "staging"), NoReload: true, SkipCheck: true,
}, quiet)
cfg := Config{Version: "test"}
if len(collector) > 0 {
cfg.Stats = collector[0]
}
srv := New(cfg, eng, quiet, nil)
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
@@ -56,7 +63,7 @@ func newAPIEnv(t *testing.T, collector ...*stats.Collector) *apiEnv {
st.CreateUser(context.Background(), admin)
tok, pid, sh := auth.NewToken()
st.CreateToken(context.Background(), &model.Token{UserID: admin.ID, Name: "test", PublicID: pid, SecretHash: sh})
env := &apiEnv{t: t, ts: ts, st: st, token: tok}
env := &apiEnv{t: t, ts: ts, srv: srv, st: st, token: tok}
env.do("PATCH", "/settings", env.token, map[string]any{"nameservers": []string{"ns1.example.com."}}, 200)
return env
}