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