- 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>
308 lines
8.2 KiB
Go
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
|
|
}
|