Files
abrown38andClaude Opus 5.5 7b3f0f764c The web UI's certificate from Let's Encrypt, through Jarl
tls.acme names the certificate and jarl: the Jarl server and a token that
may only write the challenge record. Steward gets the certificate with
DNS-01 (lego), writing the record through Jarl's API into every view that
has the zone, renews it with a third of its lifetime left and swaps it
in without a restart. A check runs before every order: Jarl, the zone,
the token's permission, a test record, the zone's name servers, public
resolvers, clean-up; each step says what to fix. Staging by default.

With a certificate, each listener serves HTTPS and plain HTTP on one port
(redirecting plain HTTP, except from the server itself).

steward cert / cert check / cert renew, Server -> Certificate in the web
UI, GET /certificate, POST /certificate/check|renew. docs/certificates.md
and docs/jarl.md (the Jarl user and the token's grant).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:46:36 +01:00

250 lines
6.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"
"steward/internal/version"
)
// ChallengeTTL is the TTL of challenge records (as in Jarl).
const ChallengeTTL = 60
// Comment marks the challenge records Steward writes.
const Comment = "ACME DNS-01 challenge (Steward)"
// 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", "steward/"+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)
}