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