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