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>
213 lines
6.6 KiB
Go
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
|
|
}
|