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:
commit
bd6b8778e4
122 files changed
+29217
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user