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

+93
View File
@@ -0,0 +1,93 @@
// Package acme turns "publish (or remove) this DNS-01 challenge value"
// into record changes. The jarl CLI (jarl acme) and jarld's httpreq
// endpoint for lego both use it, so they behave the same way.
package acme
import (
"encoding/json"
"strings"
"jarl/internal/api"
"jarl/internal/dnsname"
)
// DefaultTTL is the TTL of challenge records.
const DefaultTTL = 60
// Comment is stored with every challenge record.
const Comment = "ACME DNS-01 challenge"
// Zone is a primary zone that may receive challenge records.
type Zone struct {
Name string // as Jarl stores it, e.g. "adbraun.lol."
View string // "" when views are not used
}
// Zones returns the zones that hold name: the longest matching zone, in
// every view that has it, or only in view when view is set. The record is
// written into every view so that the CA (usually in an external view) and
// internal resolvers see the same thing.
func Zones(name string, zones []Zone, view string) []Zone {
name = dnsname.Fqdn(name)
best := ""
var out []Zone
for _, z := range zones {
if view != "" && !strings.EqualFold(z.View, view) {
continue
}
zn := dnsname.Fqdn(z.Name)
if !dnsname.IsSubdomain(name, zn) {
continue
}
switch {
case len(zn) > len(best):
best, out = zn, []Zone{z}
case zn == best:
out = append(out, z)
}
}
return out
}
// Requests returns the changes that set (or, with set false, remove) the
// TXT values at name in each zone. Setting first deletes the same value, so
// a retried hook succeeds instead of failing on a duplicate record, and
// removing a value that isn't there succeeds too. Removing with no values
// removes every TXT record at name.
func Requests(name string, zones []Zone, values []string, set bool, ttl uint32) []api.ChangeRequest {
name = dnsname.Fqdn(name)
var reqs []api.ChangeRequest
add := func(op string, z Zone, extra map[string]any) {
m := map[string]any{"zone": z.Name, "name": name, "type": "TXT"}
if z.View != "" {
m["view"] = z.View
}
for k, v := range extra {
m[k] = v
}
b, _ := json.Marshal(m)
reqs = append(reqs, api.ChangeRequest{Op: op, Data: b})
}
for _, z := range zones {
if !set && len(values) == 0 {
add("record.delete", z, map[string]any{"missing_ok": true})
continue
}
for _, v := range values {
add("record.delete", z, map[string]any{"data": v, "missing_ok": true})
if set {
add("record.create", z, map[string]any{"data": v, "ttl": ttl, "comment": Comment})
}
}
}
return reqs
}
// Description is the changeset description for a change at name.
func Description(name string, set bool) string {
verb := "remove"
if set {
verb = "set"
}
return "ACME: " + verb + " challenge for " + dnsname.Display(dnsname.Fqdn(name))
}
+71
View File
@@ -0,0 +1,71 @@
package acme
import (
"encoding/json"
"testing"
)
func TestZones(t *testing.T) {
zones := []Zone{
{Name: "example.com.", View: "internal"},
{Name: "example.com.", View: "external"},
{Name: "acme.example.com.", View: "external"},
{Name: "example.net."},
}
cases := []struct {
name, view string
want []Zone
}{
{"_acme-challenge.example.com.", "", zones[:2]},
{"_acme-challenge.example.com", "External", zones[1:2]},
{"www.acme.example.com.", "", zones[2:3]},
{"_acme-challenge.WWW.example.net.", "", zones[3:4]},
{"_acme-challenge.example.org.", "", nil},
{"_acme-challenge.notexample.com.", "", nil},
}
for _, c := range cases {
got := Zones(c.name, zones, c.view)
if len(got) != len(c.want) {
t.Errorf("Zones(%s, %q) = %v, want %v", c.name, c.view, got, c.want)
continue
}
for i := range got {
if got[i] != c.want[i] {
t.Errorf("Zones(%s, %q) = %v, want %v", c.name, c.view, got, c.want)
}
}
}
}
func TestRequests(t *testing.T) {
zones := []Zone{{Name: "example.com.", View: "internal"}, {Name: "example.com.", View: "external"}}
ops := func(set bool, values ...string) []string {
var out []string
for _, r := range Requests("_acme-challenge.Example.com", zones, values, set, 60) {
var d map[string]any
json.Unmarshal(r.Data, &d)
if d["name"] != "_acme-challenge.example.com." || d["type"] != "TXT" {
t.Errorf("data = %v", d)
}
s := r.Op + " " + d["view"].(string)
if v, ok := d["data"]; ok {
s += " " + v.(string)
}
out = append(out, s)
}
return out
}
want := []string{"record.delete internal v1", "record.create internal v1", "record.delete external v1", "record.create external v1"}
if got := ops(true, "v1"); len(got) != 4 || got[0] != want[0] || got[1] != want[1] || got[3] != want[3] {
t.Errorf("set = %v", got)
}
if got := ops(false, "v1"); len(got) != 2 || got[0] != "record.delete internal v1" {
t.Errorf("unset = %v", got)
}
if got := ops(false); len(got) != 2 || got[1] != "record.delete external" {
t.Errorf("unset all = %v", got)
}
if d := Description("_acme-challenge.example.com.", true); d != "ACME: set challenge for _acme-challenge.example.com" {
t.Errorf("description = %q", d)
}
}
+37
View File
@@ -29,6 +29,27 @@ type Config struct {
BIND BIND `yaml:"bind"`
Audit Audit `yaml:"audit"`
Stats Stats `yaml:"stats"`
ACME ACME `yaml:"acme"`
}
// ACME configures the ACME helpers.
type ACME struct {
HTTPReq HTTPReq `yaml:"httpreq"`
}
// HTTPReq configures the endpoint for lego's httpreq DNS provider
// (docs/acme.md). lego sends an API token as an HTTP Basic password, so
// by default the endpoint only answers requests that came over HTTPS.
type HTTPReq struct {
Enabled bool `yaml:"enabled"`
// RequireHTTPS refuses requests that didn't arrive over HTTPS: jarld's
// own TLS, or a trusted proxy that says so in X-Forwarded-Proto or
// Forwarded.
RequireHTTPS bool `yaml:"require_https"`
// AllowLoopbackHTTP also accepts plain HTTP from 127.0.0.1 and ::1
// (lego on the DNS server itself), unless that address is a trusted
// proxy.
AllowLoopbackHTTP bool `yaml:"allow_loopback_http"`
}
// TLS configures HTTPS.
@@ -172,6 +193,7 @@ func Default() *Config {
Retention: StatsRetention{Minute: Duration(7 * 24 * time.Hour), Hour: Duration(90 * 24 * time.Hour), Day: Duration(730 * 24 * time.Hour)},
TopN: 100,
},
ACME: ACME{HTTPReq: HTTPReq{RequireHTTPS: true}},
}
}
@@ -374,4 +396,19 @@ stats:
top_n: 100 # names and clients kept per hour and per day
anonymize_clients: false # store 10.10.0.0/24 instead of 10.10.0.37
exclude_clients: [] # e.g. [127.0.0.1] to ignore local monitoring
# An endpoint for lego's "httpreq" DNS provider (docs/acme.md), so lego and
# Traefik can publish DNS-01 challenges without the jarl CLI. Off by
# default. lego sends an API token as an HTTP Basic password, so by default
# requests are only accepted over HTTPS: jarld's own tls (above), or a
# reverse proxy in trusted_proxies that sends "X-Forwarded-Proto: https"
# (or "Forwarded: proto=https"). Nothing else in Jarl depends on this.
acme:
httpreq:
enabled: false
require_https: true
# Also accept plain HTTP from 127.0.0.1 and ::1, for lego running on
# this server. Leave false if a reverse proxy runs on this server but
# isn't in trusted_proxies: its requests would look local.
allow_loopback_http: false
`
+16
View File
@@ -18,6 +18,22 @@ func TestExampleParses(t *testing.T) {
if c.BIND.ZoneDir != d.BIND.ZoneDir || time.Duration(c.Session.IdleTimeout) != 2*time.Hour || c.Listen[0] != "127.0.0.1:8053" {
t.Errorf("example differs from defaults: %+v", c)
}
if c.ACME != d.ACME || c.ACME.HTTPReq.Enabled || !c.ACME.HTTPReq.RequireHTTPS {
t.Errorf("acme = %+v, want the defaults %+v", c.ACME, d.ACME)
}
}
func TestHTTPReqDefaults(t *testing.T) {
path := filepath.Join(t.TempDir(), "jarl.yaml")
// Enabling the endpoint keeps require_https on unless it is turned off.
os.WriteFile(path, []byte("acme: {httpreq: {enabled: true}}\n"), 0o644)
c, err := Load(path, false)
if err != nil {
t.Fatal(err)
}
if h := c.ACME.HTTPReq; !h.Enabled || !h.RequireHTTPS || h.AllowLoopbackHTTP {
t.Errorf("httpreq = %+v", h)
}
}
func TestLoad(t *testing.T) {
+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
}