Files
abrown38andClaude Opus 5.5 6b44b92975 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>
2026-10-08 13:48:47 +00:00

249 lines
8.8 KiB
Go

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)
}