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