A set can take its addresses from Jarl: every A and AAAA record of its
names (NAME@VIEW), CNAMEs followed within Jarl's zones in the same view.
housecarld reads them every jarl.refresh and after each change, keeps
only the addresses within the zones and networks the set allows, and
keeps what it last read (saved in the database and the boot ruleset)
while Jarl can't be reached. A change of the addresses applies the
ruleset again without a history entry and is audited (set.jarl).
hc set add --jarl --within, hc set jarl; the kind "from Jarl" and
Addresses… on the Sets page; GET /sets/{name}/jarl and /jarl/refresh;
validation codes set_jarl_*, status warnings jarl_*; docs/jarl.md.
e2e phase with scripts/fakejarl.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
279 lines
7.6 KiB
Go
279 lines
7.6 KiB
Go
// Package jarl talks to Jarl, the BIND manager, through its REST API: who
|
|
// the token belongs to, which zones it can see, and DNS-01 challenge
|
|
// records. It writes challenge records the way 'jarl acme' does: into the
|
|
// longest matching primary zone, in every view that has it (once per set
|
|
// of linked zones), so the CA and internal resolvers see the same thing.
|
|
package jarl
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"housecarl/internal/version"
|
|
)
|
|
|
|
// ChallengeTTL is the TTL of challenge records (as in Jarl).
|
|
const ChallengeTTL = 60
|
|
|
|
// Comment marks the challenge records Housecarl writes.
|
|
const Comment = "ACME DNS-01 challenge (Housecarl)"
|
|
|
|
// Client is a Jarl server and a token.
|
|
type Client struct {
|
|
base string
|
|
token string
|
|
http *http.Client
|
|
}
|
|
|
|
// New makes a client. caCert is a PEM file of a CA to trust ("" for the
|
|
// system's).
|
|
func New(server, token, caCert string, insecure bool) (*Client, error) {
|
|
tc := &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: insecure} //nolint:gosec // the user's choice
|
|
if caCert != "" {
|
|
pem, err := os.ReadFile(caCert)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("jarl.ca_cert: %w", err)
|
|
}
|
|
pool, err := x509.SystemCertPool()
|
|
if err != nil || pool == nil {
|
|
pool = x509.NewCertPool()
|
|
}
|
|
if !pool.AppendCertsFromPEM(pem) {
|
|
return nil, fmt.Errorf("jarl.ca_cert: no certificates in %s", caCert)
|
|
}
|
|
tc.RootCAs = pool
|
|
}
|
|
tr := http.DefaultTransport.(*http.Transport).Clone()
|
|
tr.TLSClientConfig = tc
|
|
return &Client{base: strings.TrimSuffix(server, "/") + "/api/v1", token: token, http: &http.Client{Transport: tr, Timeout: 2 * time.Minute}}, nil
|
|
}
|
|
|
|
// Error is an error answer from Jarl.
|
|
type Error struct {
|
|
Status int
|
|
Code string
|
|
Message string
|
|
}
|
|
|
|
func (e *Error) Error() string { return fmt.Sprintf("Jarl answered %d: %s", e.Status, e.Message) }
|
|
|
|
func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
|
|
var rd io.Reader
|
|
if body != nil {
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rd = bytes.NewReader(b)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, c.base+path, rd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("User-Agent", "housecarl/"+version.Short())
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("can't reach Jarl at %s: %w", strings.TrimSuffix(c.base, "/api/v1"), err)
|
|
}
|
|
defer resp.Body.Close()
|
|
b, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if resp.StatusCode >= 400 {
|
|
var eb struct {
|
|
Error struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
}
|
|
e := &Error{Status: resp.StatusCode, Message: strings.TrimSpace(string(b))}
|
|
if json.Unmarshal(b, &eb) == nil && eb.Error.Message != "" {
|
|
e.Code, e.Message = eb.Error.Code, eb.Error.Message
|
|
}
|
|
return e
|
|
}
|
|
if out != nil {
|
|
return json.Unmarshal(b, out)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Whoami is who the token belongs to.
|
|
type Whoami struct {
|
|
User struct {
|
|
Username string `json:"username"`
|
|
} `json:"user"`
|
|
Admin bool `json:"admin"`
|
|
Method string `json:"method"`
|
|
Permissions []string `json:"permissions"`
|
|
Build struct {
|
|
Label string `json:"label"`
|
|
} `json:"build"`
|
|
}
|
|
|
|
// Whoami asks Jarl who the token is.
|
|
func (c *Client) Whoami(ctx context.Context) (*Whoami, error) {
|
|
var w Whoami
|
|
return &w, c.do(ctx, http.MethodGet, "/auth/whoami", nil, &w)
|
|
}
|
|
|
|
// Zone is a primary zone the token can see.
|
|
type Zone struct {
|
|
Name string `json:"name"` // "adbraun.lol."
|
|
View string `json:"view,omitempty"`
|
|
LinkGroup int64 `json:"link_group,omitempty"`
|
|
}
|
|
|
|
// Zones lists the primary zones the token can see.
|
|
func (c *Client) Zones(ctx context.Context) ([]Zone, error) {
|
|
var zs []Zone
|
|
return zs, c.do(ctx, http.MethodGet, "/zones?type=primary", nil, &zs)
|
|
}
|
|
|
|
func fqdn(n string) string {
|
|
n = strings.ToLower(strings.TrimSpace(n))
|
|
if !strings.HasSuffix(n, ".") {
|
|
n += "."
|
|
}
|
|
return n
|
|
}
|
|
|
|
// ChallengeName is the record name for a domain's DNS-01 challenge.
|
|
func ChallengeName(domain string) string {
|
|
d := strings.TrimPrefix(strings.ToLower(strings.TrimSuffix(domain, ".")), "*.")
|
|
if !strings.HasPrefix(d, "_acme-challenge.") {
|
|
d = "_acme-challenge." + d
|
|
}
|
|
return d + "."
|
|
}
|
|
|
|
// ZonesFor picks the zones a record at name goes into: the longest
|
|
// matching zone, in every view that has it (or only view), once per set of
|
|
// linked zones.
|
|
func ZonesFor(name string, zones []Zone, view string) []Zone {
|
|
name = fqdn(name)
|
|
best := ""
|
|
var out []Zone
|
|
for _, z := range zones {
|
|
if view != "" && !strings.EqualFold(z.View, view) {
|
|
continue
|
|
}
|
|
zn := fqdn(z.Name)
|
|
if name != zn && !strings.HasSuffix(name, "."+zn) && zn != "." {
|
|
continue
|
|
}
|
|
switch {
|
|
case len(zn) > len(best):
|
|
best, out = zn, []Zone{z}
|
|
case zn == best:
|
|
out = append(out, z)
|
|
}
|
|
}
|
|
var dedup []Zone
|
|
links := map[int64]bool{}
|
|
for _, z := range out {
|
|
if z.LinkGroup != 0 {
|
|
if links[z.LinkGroup] {
|
|
continue
|
|
}
|
|
links[z.LinkGroup] = true
|
|
}
|
|
dedup = append(dedup, z)
|
|
}
|
|
return dedup
|
|
}
|
|
|
|
type op struct {
|
|
Op string `json:"op"`
|
|
Data any `json:"data"`
|
|
}
|
|
|
|
type batch struct {
|
|
Description string `json:"description"`
|
|
Ops []op `json:"ops"`
|
|
DryRun bool `json:"dry_run,omitempty"`
|
|
}
|
|
|
|
// SetTXT publishes (set) or removes a TXT value at name in zones, as one
|
|
// change in Jarl. Setting first removes the same value, so a retry
|
|
// succeeds; removing a value that isn't there succeeds too. With dryRun
|
|
// Jarl only checks the change, including the token's permission.
|
|
func (c *Client) SetTXT(ctx context.Context, name, value string, zones []Zone, set, dryRun bool, why string) error {
|
|
if len(zones) == 0 {
|
|
return errors.New("no zone to write into")
|
|
}
|
|
var ops []op
|
|
for _, z := range zones {
|
|
base := map[string]any{"zone": z.Name, "name": fqdn(name), "type": "TXT", "data": value}
|
|
if z.View != "" {
|
|
base["view"] = z.View
|
|
}
|
|
del := map[string]any{"missing_ok": true}
|
|
for k, v := range base {
|
|
del[k] = v
|
|
}
|
|
ops = append(ops, op{Op: "record.delete", Data: del})
|
|
if set {
|
|
add := map[string]any{"ttl": ChallengeTTL, "comment": Comment}
|
|
for k, v := range base {
|
|
add[k] = v
|
|
}
|
|
ops = append(ops, op{Op: "record.create", Data: add})
|
|
}
|
|
}
|
|
verb := "remove"
|
|
if set {
|
|
verb = "set"
|
|
}
|
|
desc := "ACME: " + verb + " challenge for " + strings.TrimSuffix(name, ".")
|
|
if why != "" {
|
|
desc += " (" + why + ")"
|
|
}
|
|
return c.do(ctx, http.MethodPost, "/apply", batch{Description: desc, Ops: ops, DryRun: dryRun}, nil)
|
|
}
|
|
|
|
// ZoneInfo is a zone as Jarl lists it, of any type.
|
|
type ZoneInfo struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"` // "example.com."
|
|
View string `json:"view,omitempty"`
|
|
Type string `json:"type"`
|
|
InView string `json:"in_view,omitempty"` // type in-view: the view whose zone it serves
|
|
Disabled bool `json:"disabled,omitempty"`
|
|
}
|
|
|
|
// AllZones lists every zone the token can see, of any type.
|
|
func (c *Client) AllZones(ctx context.Context) ([]ZoneInfo, error) {
|
|
var zs []ZoneInfo
|
|
return zs, c.do(ctx, http.MethodGet, "/zones", nil, &zs)
|
|
}
|
|
|
|
// Record is a record as Jarl lists it.
|
|
type Record struct {
|
|
Name string `json:"name"` // "nas.example.com."
|
|
Type string `json:"type"`
|
|
Data string `json:"data"`
|
|
}
|
|
|
|
// Records lists a zone's enabled records (zone: its ID).
|
|
func (c *Client) Records(ctx context.Context, zone int64) ([]Record, error) {
|
|
var rs []Record
|
|
return rs, c.do(ctx, http.MethodGet, fmt.Sprintf("/zones/%d/records?disabled=0", zone), nil, &rs)
|
|
}
|