Files
adamandClaude Opus 5.5 bdd7acfd3b Internet exposure checks and response rate limiting
Jarl works out which views are reachable from the internet (match-clients
ending in a catch-all, not private-looking addresses: an internal view of a
global IPv6 prefix is still yours; a server without views: open allow-query
and a non-private listen address), with a per-view override (schema 9), and
checks public ones: rate limiting, open resolver, minimal-any, cache
snooping, open NOTIFY and transfers, version and host name, management
interfaces. Fixes are staged like any change, including rate limiting from
ISC's example in log-only mode with the secondaries exempt, then enforcing.
named logs what rate limiting limits (or would, in log-only mode, where its
counters don't move) as query-errors; Jarl routes those lines to its log
and counts them (rrl breakdown, ratelimited top lists, Prometheus
jarl_ratelimit_logged_total), shown on the new Exposure page.

A change that newly opens a public view up must be confirmed (accept_risk,
--accept-risk, a checkbox on the Changes page; 409 confirm_risk
otherwise). Findings can be acknowledged with a reason and stay visible as
info. GET /exposure, POST /exposure/fix|ack|unack, jarl exposure,
docs/exposure.md.

Also fixes query statistics skipping TSIG-signed queries ("IP#port/key
NAME" in named's log).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:38:56 +01:00

337 lines
9.0 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
// CheckChangeset, if set, runs once before Changeset is first used,
// and its error stops the request. The CLI checks a changeset taken
// from JARL_CHANGESET with it.
CheckChangeset func(ctx context.Context) error
csChecked bool
csErr error
DryRun bool
Force bool
// AcceptRisk confirms changes that open the server up
// (docs/exposure.md).
AcceptRisk 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.Short())
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)
}
// UseChangeset returns the changeset to stage into (0 for none), once
// CheckChangeset has accepted it.
func (c *Client) UseChangeset(ctx context.Context) (int64, error) {
if c.Changeset == 0 {
return 0, nil
}
if c.CheckChangeset != nil && !c.csChecked {
c.csChecked = true
c.csErr = c.CheckChangeset(ctx)
}
return c.Changeset, c.csErr
}
// 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
}
cs, err := c.UseChangeset(ctx)
if err != nil {
return &api.MutationResponse{}, err
}
if cs > 0 {
q.Set("changeset", fmt.Sprint(cs))
}
if c.DryRun {
q.Set("dry_run", "1")
}
if c.Force {
q.Set("force", "1")
}
if c.AcceptRisk {
q.Set("accept_risk", "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
}