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