Files
jarl/cmd/jarl/acme.go
T
adamandClaude Opus 5.5 057433c198 Linked zones: shared records kept in step across views
Zones of the same name in different views can be linked. Shared records
are kept in step: a change in one view reaches the others in the same
changeset, as explicit operations, so the diff, permission checks,
history and undo cover every view. Records local to a view override the
shared records of that name and type there; removing the override brings
the shared ones back, and sharing a view's version makes it every view's.
Each zone keeps its own options, serial and file.

Linking existing zones changes no answer: what is the same everywhere
becomes shared, the rest stays local. Unlinking keeps every record, all
local. ACME challenges are written once per link, and --view all changes
a link once. jarl zone link/unlink, zone create --link-with, record
--local/--shared, LINK column, web UI controls, POST /zones/{zone}/link
and /unlink, validation checks linked_*, store migration 11, docs and
e2e.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:53:58 +01:00

213 lines
6.6 KiB
Go

package main
import (
"context"
"fmt"
"net"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"jarl/internal/acme"
"jarl/internal/api"
"jarl/internal/client"
)
const acmePrefix = "_acme-challenge."
// acmeOptions are the flags shared by the acme subcommands.
type acmeOptions struct {
view string
ttl uint32
exact bool
followCNAME bool
resolver string
}
func (o *acmeOptions) flags(cmd *cobra.Command) {
cmd.Flags().StringVar(&o.view, "view", "", "only the zone in this view (default: every view that has the zone)")
cmd.Flags().BoolVar(&o.exact, "exact", false, "use DOMAIN as the record name as it is, without adding _acme-challenge.")
cmd.Flags().BoolVar(&o.followCNAME, "follow-cname", false, "if the challenge name is a CNAME (e.g. into a dedicated ACME zone), write the record at its target")
cmd.Flags().StringVar(&o.resolver, "resolver", "", "DNS server for --follow-cname, HOST[:PORT] (default: the system's resolvers)")
}
// dnsResolver returns the resolver used to follow CNAMEs.
func (o *acmeOptions) dnsResolver() *net.Resolver {
if o.resolver == "" {
return net.DefaultResolver
}
addr := o.resolver
if _, _, err := net.SplitHostPort(addr); err != nil {
addr = net.JoinHostPort(addr, "53")
}
return &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, network, addr)
}}
}
// challengeName returns the FQDN of the TXT record for a domain.
func (o *acmeOptions) challengeName(domain string) (string, error) {
d := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(domain), "."))
if d == "" {
return "", fmt.Errorf("no domain given")
}
if !o.exact {
d = strings.TrimPrefix(d, "*.")
if !strings.HasPrefix(d, acmePrefix) {
d = acmePrefix + d
}
}
name := d + "."
if o.followCNAME {
c, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// A lookup error usually just means there is no CNAME (or no
// record yet), so keep the name.
if target, err := o.dnsResolver().LookupCNAME(c, name); err == nil && !strings.EqualFold(target, name) {
if !flagQuiet {
fmt.Fprintf(os.Stderr, "%s is a CNAME for %s\n", dot(name), dot(target))
}
name = strings.ToLower(target)
}
}
return name, nil
}
// acmeZones finds the primary zones (one per view) that contain name.
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
}
refs := make([]acme.Zone, len(zones))
for i, z := range zones {
refs[i] = acme.Zone{Name: z.Name, View: z.View, Link: z.LinkGroup}
}
out := acme.Zones(name, refs, view)
if len(out) == 0 {
where := ""
if view != "" {
where = " in view " + view
}
return nil, fmt.Errorf("no primary zone%s that you can see contains %s", where, dot(name))
}
return out, nil
}
// acmeChange adds (set) or removes the challenge values for domain.
func acmeChange(o *acmeOptions, domain string, values []string, set bool) error {
name, err := o.challengeName(domain)
if err != nil {
return err
}
c, err := newClient()
if err != nil {
return err
}
zones, err := acmeZones(c, name, o.view)
if err != nil {
return err
}
return submitBatch(c, acme.Description(name, set), acme.Requests(name, zones, values, set, o.ttl))
}
// envOrError returns an environment variable that a hook needs.
func envOrError(name string) (string, error) {
v := os.Getenv(name)
if v == "" {
return "", fmt.Errorf("%s is not set (run this command as a certbot hook)", name)
}
return v, nil
}
func acmeCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "acme",
Short: "Publish and remove ACME DNS-01 challenge records (Let's Encrypt)",
Long: `Publishes and removes the _acme-challenge TXT records that ACME certificate
authorities such as Let's Encrypt check for DNS-01 challenges. When the
command returns, named is already serving the record: Jarl reloads the zone
and checks its serial before it reports success.
The zone is found automatically (the longest matching primary zone). If the
zone exists in several views, the record goes into each of them, so it is
seen both from inside and from the internet.
Give the hook its own token that can only touch challenge records:
jarl user create certbot --no-password --role acme
jarl token create certbot --user certbot --role acme
certbot:
certbot certonly --manual --preferred-challenges dns \
--manual-auth-hook 'jarl acme certbot-auth' \
--manual-cleanup-hook 'jarl acme certbot-cleanup' \
-d adbraun.lol -d '*.adbraun.lol'
acme.sh: copy contrib/acme.sh/dns_jarl.sh into ~/.acme.sh/dnsapi/ and use
'--dns dns_jarl' (see docs/acme.md).`,
}
o := &acmeOptions{}
set := &cobra.Command{
Use: "set DOMAIN VALUE...",
Short: "Publish challenge values for DOMAIN (at _acme-challenge.DOMAIN)",
Example: ` jarl acme set www.adbraun.lol gfj9Xq...Rg85nM
jarl acme set '*.adbraun.lol' value-for-wildcard value-for-apex`,
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
return acmeChange(o, args[0], args[1:], true)
},
}
o.flags(set)
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)",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return acmeChange(o, args[0], args[1:], false)
},
}
o.flags(unset)
auth := &cobra.Command{
Use: "certbot-auth",
Short: "certbot --manual-auth-hook: publish $CERTBOT_VALIDATION for $CERTBOT_DOMAIN",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
domain, err := envOrError("CERTBOT_DOMAIN")
if err != nil {
return err
}
value, err := envOrError("CERTBOT_VALIDATION")
if err != nil {
return err
}
return acmeChange(o, domain, []string{value}, true)
},
}
o.flags(auth)
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",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
domain, err := envOrError("CERTBOT_DOMAIN")
if err != nil {
return err
}
value, err := envOrError("CERTBOT_VALIDATION")
if err != nil {
return err
}
return acmeChange(o, domain, []string{value}, false)
},
}
o.flags(cleanup)
cmd.AddCommand(set, unset, auth, cleanup)
return cmd
}