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>
This commit is contained in:
1 parent
d0ee2c8844
commit
7b3f0f764c
32 files changed
+2544
-27
No files matched your search
@@ -0,0 +1,249 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in new issue
Block a user