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
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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 "", ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user