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

+9 -53
View File
@@ -10,6 +10,7 @@ import (
"github.com/spf13/cobra"
"jarl/internal/acme"
"jarl/internal/api"
"jarl/internal/client"
)
@@ -75,28 +76,16 @@ func (o *acmeOptions) challengeName(domain string) (string, error) {
}
// acmeZones finds the primary zones (one per view) that contain name.
func acmeZones(c *client.Client, name, view string) ([]api.Zone, error) {
func acmeZones(c *client.Client, name, view string) ([]acme.Zone, error) {
var zones []api.Zone
if err := c.Get(ctx(), "/zones", q("type", "primary"), &zones); err != nil {
return nil, err
}
best := ""
var out []api.Zone
for _, z := range zones {
if view != "" && !strings.EqualFold(z.View, view) {
continue
}
zn := strings.ToLower(z.Name)
if !(name == zn || zn == "." || strings.HasSuffix(name, "."+zn)) {
continue
}
switch {
case len(zn) > len(best):
best, out = zn, []api.Zone{z}
case zn == best:
out = append(out, z)
}
refs := make([]acme.Zone, len(zones))
for i, z := range zones {
refs[i] = acme.Zone{Name: z.Name, View: z.View}
}
out := acme.Zones(name, refs, view)
if len(out) == 0 {
where := ""
if view != "" {
@@ -121,40 +110,7 @@ func acmeChange(o *acmeOptions, domain string, values []string, set bool) error
if err != nil {
return err
}
var reqs []api.ChangeRequest
for _, z := range zones {
base := map[string]any{"zone": z.Name, "name": name, "type": "TXT"}
if z.View != "" {
base["view"] = z.View
}
with := func(extra map[string]any) map[string]any {
m := map[string]any{}
for k, v := range base {
m[k] = v
}
for k, v := range extra {
m[k] = v
}
return m
}
if !set && len(values) == 0 {
reqs = append(reqs, api.ChangeRequest{Op: "record.delete", Data: rawJSON(with(map[string]any{"missing_ok": true}))})
continue
}
for _, v := range values {
// Deleting first makes a repeated 'set' (a retried hook)
// succeed instead of failing on a duplicate record.
reqs = append(reqs, api.ChangeRequest{Op: "record.delete", Data: rawJSON(with(map[string]any{"data": v, "missing_ok": true}))})
if set {
reqs = append(reqs, api.ChangeRequest{Op: "record.create", Data: rawJSON(with(map[string]any{"data": v, "ttl": o.ttl, "comment": "ACME DNS-01 challenge"}))})
}
}
}
verb := "remove"
if set {
verb = "set"
}
return submitBatch(c, fmt.Sprintf("ACME: %s challenge for %s", verb, dot(name)), reqs)
return submitBatch(c, acme.Description(name, set), acme.Requests(name, zones, values, set, o.ttl))
}
// envOrError returns an environment variable that a hook needs.
@@ -206,7 +162,7 @@ acme.sh: copy contrib/acme.sh/dns_jarl.sh into ~/.acme.sh/dnsapi/ and use
},
}
o.flags(set)
set.Flags().Uint32Var(&o.ttl, "ttl", 60, "TTL of the challenge record")
set.Flags().Uint32Var(&o.ttl, "ttl", acme.DefaultTTL, "TTL of the challenge record")
unset := &cobra.Command{
Use: "unset DOMAIN [VALUE...]",
Short: "Remove challenge values for DOMAIN (all of them when no VALUE is given)",
@@ -233,7 +189,7 @@ acme.sh: copy contrib/acme.sh/dns_jarl.sh into ~/.acme.sh/dnsapi/ and use
},
}
o.flags(auth)
auth.Flags().Uint32Var(&o.ttl, "ttl", 60, "TTL of the challenge record")
auth.Flags().Uint32Var(&o.ttl, "ttl", acme.DefaultTTL, "TTL of the challenge record")
cleanup := &cobra.Command{
Use: "certbot-cleanup",
Short: "certbot --manual-cleanup-hook: remove $CERTBOT_VALIDATION for $CERTBOT_DOMAIN",
+5
View File
@@ -287,6 +287,11 @@ func serveCmd() *cobra.Command {
TrustedProxies: a.cfg.Proxies(), SessionLifetime: time.Duration(a.cfg.Session.Lifetime),
SessionIdle: time.Duration(a.cfg.Session.IdleTimeout), CookieSecure: a.cfg.Session.CookieSecure,
Version: version.Version, Stats: collector,
HTTPReq: server.HTTPReq{
Enabled: a.cfg.ACME.HTTPReq.Enabled,
AllowPlainHTTP: !a.cfg.ACME.HTTPReq.RequireHTTPS,
AllowLoopbackHTTP: a.cfg.ACME.HTTPReq.AllowLoopbackHTTP,
},
}, a.eng, a.log, web.FS())
hup := make(chan os.Signal, 1)
signal.Notify(hup, syscall.SIGHUP)