Files
jarl/internal/client/client.go
T
abrown38andClaude Opus 5.5 293e6ddd33 Add backups, token expiry warnings and name server checks
- Daily database backups: jarl-backup.timer runs 'jarld backup', which
  writes jarl-<UTC time>.db into backup.dir, checks it before naming it,
  and prunes by the time in the name while always keeping the newest
  keep_at_least. New backup: section in jarl.yaml; the dashboard and
  'jarl status' show the newest backup and warn when it is stale or
  failed. The Debian package enables the timer.
- 'jarld restore [FILE | latest]': checks the backup, shows it next to
  the current database, keeps the current one aside and records the
  restore. 'jarld serve' holds a database lock, so a restore can't run
  under a live jarld and two jarlds can't share a database.
- Token expiry warnings from 14 days ahead: dashboard, token lists, a
  daily log line, and a CLI warning via the Jarl-Token-Expires header;
  'jarl token list --expiring'.
- 'jarl zone serials' and the zone page's Name servers tab: compare the
  serial on a primary zone's secondaries (NS records, also-notify) or a
  secondary zone's primaries with this server's.
- 'jarl zone delegation': for zones on the internet, check the parent's
  delegation and glue against the zone; an undelegated zone gets a note.
- Server: Jarl/<version> header; CLI exit code 2 for failed checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:02:34 +00:00

308 lines
8.2 KiB
Go

// Package client is a small Go client for Jarl's REST API, used by the
// jarl CLI.
package client
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"strings"
"time"
"jarl/internal/api"
"jarl/internal/version"
)
// Client talks to a Jarl server.
type Client struct {
BaseURL string
Token string
HTTP *http.Client
// Changeset, if set, stages changes into this changeset instead of
// applying them.
Changeset int64
DryRun bool
Force bool
// Preflight lets only requests through that can't change anything:
// reads and dry runs. Running a command on several servers uses it to
// check the command everywhere before running it anywhere.
Preflight bool
// Mutated records that a preflight run sent a (dry-run) change.
Mutated bool
// OnTokenExpiry, if set, is called with the token's expiry time when
// jarld says the token expires soon (api.TokenExpiresHeader).
OnTokenExpiry func(time.Time)
}
// ErrNoPreflight is returned in Preflight mode for a request that would
// change something and has no dry run.
var ErrNoPreflight = errors.New("this command can't be checked without running it, so it can't run on several servers at once; run it on one server at a time with --server NAME")
// Options configure New.
type Options struct {
Server string
Token string
Insecure bool // skip TLS certificate verification
CACert string // PEM file with an extra trusted CA
Timeout time.Duration
}
// New creates a client.
func New(o Options) (*Client, error) {
if o.Server == "" {
return nil, fmt.Errorf("no server configured: use --server, JARL_SERVER or 'jarl config set server https://...'")
}
tr := http.DefaultTransport.(*http.Transport).Clone()
base := strings.TrimSuffix(o.Server, "/")
if path, ok := SocketPath(o.Server); ok {
// jarld's local unix socket: every request goes to the socket,
// whatever the URL's host.
tr.Proxy = nil
tr.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", path)
}
base = "http://jarld"
} else if u, err := url.Parse(o.Server); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return nil, fmt.Errorf("invalid server URL %q (use e.g. https://dns.example.com:8053, or unix:///run/jarl/jarl.sock)", o.Server)
}
tc := &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: o.Insecure} //nolint:gosec // explicit user choice
if o.CACert != "" {
pem, err := os.ReadFile(o.CACert)
if err != nil {
return nil, err
}
pool, err := x509.SystemCertPool()
if err != nil || pool == nil {
pool = x509.NewCertPool()
}
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("no certificates in %s", o.CACert)
}
tc.RootCAs = pool
}
tr.TLSClientConfig = tc
timeout := o.Timeout
if timeout == 0 {
timeout = 2 * time.Minute
}
return &Client{BaseURL: base, Token: o.Token, HTTP: &http.Client{Transport: tr, Timeout: timeout}}, nil
}
// SocketPath returns the path of a unix:// server URL
// ("unix:///run/jarl/jarl.sock").
func SocketPath(server string) (string, bool) {
path, ok := strings.CutPrefix(server, "unix://")
if !ok || !strings.HasPrefix(path, "/") {
return "", false
}
return path, true
}
// Error is an API error.
type Error struct {
Status int
Info api.ErrorInfo
}
func (e *Error) Error() string {
if e.Info.Message != "" {
return e.Info.Message
}
return fmt.Sprintf("HTTP %d", e.Status)
}
// Problem is a validation problem (a subset of the server's fields).
type Problem struct {
Severity string `json:"severity"`
Code string `json:"code"`
Message string `json:"message"`
Zone string `json:"zone"`
View string `json:"view"`
}
// CheckMessage is a named-checkconf message.
type CheckMessage struct {
Text string `json:"text"`
Error bool `json:"error"`
Object string `json:"object"`
}
// Problems returns the validation problems attached to the error.
func (e *Error) Problems() []Problem {
var ps []Problem
if len(e.Info.Problems) > 0 {
json.Unmarshal(e.Info.Problems, &ps)
}
return ps
}
// Check returns named-checkconf messages attached to the error.
func (e *Error) Check() []CheckMessage {
var ms []CheckMessage
if len(e.Info.Check) > 0 {
json.Unmarshal(e.Info.Check, &ms)
}
return ms
}
// Do sends a request. body is marshaled as JSON; out (if not nil) receives
// the JSON response.
func (c *Client) Do(ctx context.Context, method, path string, query url.Values, body, out any) error {
if c.Preflight && method != http.MethodGet && path != "/system/check" {
if !isDryRun(query, body) {
return ErrNoPreflight
}
c.Mutated = true
}
u := c.BaseURL + api.Prefix + path
if len(query) > 0 {
u += "?" + query.Encode()
}
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, u, rd)
if err != nil {
return err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "jarl-cli/"+version.Version)
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
resp, err := c.HTTP.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if v := resp.Header.Get(api.TokenExpiresHeader); v != "" && c.OnTokenExpiry != nil {
if t, err := time.Parse(time.RFC3339, v); err == nil {
c.OnTokenExpiry(t)
}
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode >= 400 {
var eb api.ErrorBody
if json.Unmarshal(data, &eb) != nil || eb.Error.Message == "" {
eb.Error.Message = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(data)))
}
return &Error{Status: resp.StatusCode, Info: eb.Error}
}
if out == nil || len(data) == 0 {
return nil
}
if s, ok := out.(*string); ok {
*s = string(data)
return nil
}
return json.Unmarshal(data, out)
}
// isDryRun reports whether a request asks for a dry run, in its query or
// its body.
func isDryRun(query url.Values, body any) bool {
if query.Get("dry_run") == "1" {
return true
}
switch b := body.(type) {
case api.BatchRequest:
return b.DryRun
case *api.BatchRequest:
return b != nil && b.DryRun
case map[string]any:
v, _ := b["dry_run"].(bool)
return v
case map[string]bool:
return b["dry_run"]
}
return false
}
// Get is Do with GET.
func (c *Client) Get(ctx context.Context, path string, query url.Values, out any) error {
return c.Do(ctx, http.MethodGet, path, query, nil, out)
}
// Mutate sends a configuration change, honoring Changeset, DryRun and
// Force.
func (c *Client) Mutate(ctx context.Context, method, path string, query url.Values, body any) (*api.MutationResponse, error) {
q := url.Values{}
for k, v := range query {
q[k] = v
}
if c.Changeset > 0 {
q.Set("changeset", fmt.Sprint(c.Changeset))
}
if c.DryRun {
q.Set("dry_run", "1")
}
if c.Force {
q.Set("force", "1")
}
var out api.MutationResponse
err := c.Do(ctx, method, path, q, body, &out)
return &out, err
}
// Result is the part of an engine result the CLI shows.
type Result struct {
ChangesetID int64 `json:"changeset_id"`
Applied bool `json:"applied"`
Valid bool `json:"valid"`
Problems []Problem `json:"problems"`
Existing []Problem `json:"existing_problems"`
Check []CheckMessage `json:"check"`
Diff string `json:"diff"`
Files []string `json:"files_changed"`
Serials []struct {
Zone string `json:"zone"`
View string `json:"view"`
Old uint32 `json:"old"`
New uint32 `json:"new"`
} `json:"serials"`
Reloaded []string `json:"reloaded"`
Warnings []string `json:"warnings"`
Ops []struct {
Seq int `json:"seq"`
Op string `json:"op"`
ID int64 `json:"id"`
Summary string `json:"summary"`
} `json:"ops"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
// ParseResult decodes the result embedded in a mutation response or error.
func ParseResult(raw json.RawMessage) *Result {
if len(raw) == 0 {
return nil
}
var r Result
if json.Unmarshal(raw, &r) != nil {
return nil
}
return &r
}