// 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) }