Jarl 0.1.0: manage BIND through a web UI, REST API and CLI

Jarl keeps a BIND 9.20 server's whole configuration (views, ACLs, TSIG
keys, zones of every type, records, named.conf options) in SQLite and
renders named.conf, keys.conf and zone files from it.

Every change goes through one changeset pipeline: whole-configuration
validation, named-checkconf -z and named-checkzone in a staging
directory, atomic install, rndc reconfig/reload, serial verification
with rndc zonestatus, and rollback if named does not load the change.
Changes apply one at a time by default or are bundled and applied
together. Applied changesets can be reverted.

- jarld: the daemon with the REST API and an embedded web UI, plus import,
  adopt, check, resync, backup and local user recovery commands
- jarl: the CLI, including ACME DNS-01 hooks for certbot and acme.sh
- users with argon2id passwords and CSRF-protected sessions; hashed API
  tokens with granular grants and roles; an audit log
- systemd unit, sysusers.d/tmpfiles.d files, Makefile
- unit tests, integration tests against a real named, scripts/e2e.sh
- docs/ guides, README and CHANGELOG

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-07 15:03:13 +00:00
commit bd6b8778e4
122 files changed
+29217

No files matched your search

+256
View File
@@ -0,0 +1,256 @@
package main
import (
"context"
"fmt"
"net"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"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) ([]api.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)
}
}
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
}
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)
}
// 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", 60, "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", 60, "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
}