Files
jarl/cmd/jarl/changes.go
T
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

460 lines
14 KiB
Go

package main
import (
"encoding/json"
"fmt"
"io"
"os"
"strconv"
"strings"
"github.com/spf13/cobra"
"go.yaml.in/yaml/v3"
"jarl/internal/api"
"jarl/internal/client"
)
// batchFile is the format of 'jarl apply -f'.
type batchFile struct {
Description string `yaml:"description" json:"description"`
Changes []batchEntry `yaml:"changes" json:"changes"`
}
type batchEntry struct {
Add map[string]any `yaml:"add,omitempty" json:"add,omitempty"`
Delete map[string]any `yaml:"delete,omitempty" json:"delete,omitempty"`
Set map[string]any `yaml:"set,omitempty" json:"set,omitempty"`
Update map[string]any `yaml:"update,omitempty" json:"update,omitempty"`
// Disable and Enable find records like Delete: by id, or by zone,
// name, type and optionally data.
Disable map[string]any `yaml:"disable,omitempty" json:"disable,omitempty"`
Enable map[string]any `yaml:"enable,omitempty" json:"enable,omitempty"`
// Any API operation: op, id, data.
Op string `yaml:"op,omitempty" json:"op,omitempty"`
ID int64 `yaml:"id,omitempty" json:"id,omitempty"`
Data map[string]any `yaml:"data,omitempty" json:"data,omitempty"`
}
func toInt64(v any) (int64, bool) {
switch n := v.(type) {
case int:
return int64(n), true
case int64:
return n, true
case float64:
return int64(n), true
case string:
i, err := strconv.ParseInt(n, 10, 64)
return i, err == nil
}
return 0, false
}
func rawJSON(v any) json.RawMessage {
b, _ := json.Marshal(v)
return b
}
// requests converts the file's entries into API change requests.
func (b *batchFile) requests() ([]api.ChangeRequest, error) {
var out []api.ChangeRequest
for i, e := range b.Changes {
n := 0
for _, set := range []bool{e.Add != nil, e.Delete != nil, e.Set != nil, e.Update != nil, e.Disable != nil, e.Enable != nil, e.Op != ""} {
if set {
n++
}
}
if n != 1 {
return nil, fmt.Errorf("change %d: use exactly one of add, delete, set, update, disable, enable or op", i+1)
}
switch {
case e.Add != nil:
out = append(out, api.ChangeRequest{Op: "record.create", Data: rawJSON(e.Add)})
case e.Delete != nil:
out = append(out, byIDOrMatch("record.delete", e.Delete))
case e.Disable != nil:
out = append(out, byIDOrMatch("record.disable", e.Disable))
case e.Enable != nil:
out = append(out, byIDOrMatch("record.enable", e.Enable))
case e.Set != nil:
s := e.Set
switch d := s["data"].(type) {
case string:
s["data"] = []string{d}
case nil:
s["data"] = []string{}
}
out = append(out, api.ChangeRequest{Op: "rrset.replace", Data: rawJSON(s)})
case e.Update != nil:
u := e.Update
id, ok := toInt64(u["id"])
if !ok {
return nil, fmt.Errorf("change %d: update needs the record id", i+1)
}
delete(u, "id")
out = append(out, api.ChangeRequest{Op: "record.update", ID: id, Data: rawJSON(u)})
default:
var data json.RawMessage
if e.Data != nil {
data = rawJSON(e.Data)
}
out = append(out, api.ChangeRequest{Op: e.Op, ID: e.ID, Data: data})
}
}
return out, nil
}
// byIDOrMatch is a request for the record with d's id, or for the records
// d matches.
func byIDOrMatch(op string, d map[string]any) api.ChangeRequest {
if id, ok := toInt64(d["id"]); ok {
delete(d, "id")
return api.ChangeRequest{Op: op, ID: id, Data: rawJSON(d)}
}
return api.ChangeRequest{Op: op, Data: rawJSON(d)}
}
// submitBatch applies requests as one changeset, or stages them into the
// changeset given with --changeset.
func submitBatch(c *client.Client, description string, reqs []api.ChangeRequest) error {
var resp api.MutationResponse
cs, err := c.UseChangeset(ctx())
if err != nil {
return err
}
if cs > 0 {
if err := c.Do(ctx(), "POST", fmt.Sprintf("/changesets/%d/ops", cs), nil, map[string]any{"ops": reqs}, &resp); err != nil {
return err
}
return report(&resp)
}
if err := c.Do(ctx(), "POST", "/apply", nil, api.BatchRequest{Description: description, Ops: reqs, DryRun: c.DryRun, Force: c.Force, AcceptRisk: c.AcceptRisk}, &resp); err != nil {
return err
}
return report(&resp)
}
func applyCmd() *cobra.Command {
var file, desc string
cmd := &cobra.Command{
Use: "apply -f FILE",
Short: "Apply a batch of changes from a YAML or JSON file, all or nothing",
Long: `Applies the changes in FILE ('-' for standard input) as one changeset: they
are validated together and either all are applied or none.
description: move the web server
changes:
- add: {zone: example.com, name: www2, type: A, data: 192.0.2.20, ttl: 300, ptr: true}
- delete: {zone: example.com, name: www, type: A} # every A record of www
- delete: {zone: example.com, name: old, type: CNAME, data: www} # just this record
- delete: {id: 1234}
- set: {zone: example.com, name: api, type: A, data: [192.0.2.30, 192.0.2.31], ttl: 60}
- update: {id: 1235, data: 192.0.2.40}
- op: zone.create # any API operation
data: {name: lab.example.com, nameservers: [ns1.example.com.]}
Records name zones by name (add "view: NAME" when the zone exists in several
views). With --changeset the changes are staged instead; with --dry-run they
are only validated.`,
RunE: func(cmd *cobra.Command, args []string) error {
if file == "" {
return fmt.Errorf("-f FILE is required")
}
var data []byte
var err error
if file == "-" {
data, err = io.ReadAll(os.Stdin)
} else {
data, err = os.ReadFile(file)
}
if err != nil {
return err
}
var b batchFile
if err := yaml.Unmarshal(data, &b); err != nil {
return fmt.Errorf("%s: %w", file, err)
}
if desc != "" {
b.Description = desc
}
reqs, err := b.requests()
if err != nil {
return err
}
if len(reqs) == 0 {
return fmt.Errorf("%s has no changes", file)
}
c, err := newClient()
if err != nil {
return err
}
return submitBatch(c, b.Description, reqs)
},
}
cmd.Flags().StringVarP(&file, "file", "f", "", "file with the changes ('-' for standard input)")
cmd.Flags().StringVar(&desc, "description", "", "description for the history")
return cmd
}
func changesetCmd() *cobra.Command {
cmd := &cobra.Command{Use: "changeset", Aliases: []string{"cs"}, Short: "Stage changes and apply them together"}
var status string
var mine bool
list := &cobra.Command{
Use: "list",
Short: "List changesets (open ones by default)",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
qv := q("status", status)
if mine {
qv.Set("mine", "1")
}
var list []api.Changeset
if err := c.Get(ctx(), "/changesets", qv, &list); err != nil {
return err
}
return output(list, func(w io.Writer) {
fmt.Fprintln(w, "ID\tSTATUS\tUSER\tVIA\tOPS\tDESCRIPTION")
for _, cs := range list {
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%d\t%s\n", cs.ID, cs.Status, cs.Username, cs.Via, len(cs.Ops), cs.Description)
}
})
},
}
list.Flags().StringVar(&status, "status", "open", "open, applied or discarded (empty for all)")
list.Flags().BoolVar(&mine, "mine", false, "only my changesets")
create := &cobra.Command{
Use: "create [DESCRIPTION]",
Short: "Start a changeset; prints its ID",
Example: ` export JARL_CHANGESET=$(jarl changeset create "move web servers" -q)
jarl record add example.com www2 A 192.0.2.20
jarl record delete example.com www A
jarl changeset diff # JARL_CHANGESET's
jarl changeset apply
unset JARL_CHANGESET`,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var cs api.Changeset
if err := c.Do(ctx(), "POST", "/changesets", nil, map[string]any{"description": strings.Join(args, " ")}, &cs); err != nil {
return err
}
if flagQuiet {
fmt.Println(cs.ID)
return nil
}
fmt.Printf("created changeset %d\nstage changes with --changeset %d (or export JARL_CHANGESET=%d), then 'jarl changeset apply'\n", cs.ID, cs.ID, cs.ID)
return nil
},
}
show := &cobra.Command{
Use: "show [ID]",
Short: "Show a changeset's operations (default: -C or JARL_CHANGESET)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
id, _, err := changesetArg(c, args, false)
if err != nil {
return err
}
var cs api.Changeset
if err := c.Get(ctx(), "/changesets/"+id, nil, &cs); err != nil {
return err
}
return output(cs, func(w io.Writer) {
fmt.Fprintf(w, "Changeset:\t%d (%s)\nDescription:\t%s\nCreated:\t%s by %s via %s\n", cs.ID, cs.Status, cs.Description, cs.CreatedAt.Format("2006-01-02 15:04"), cs.Username, cs.Via)
if cs.AppliedAt != nil {
fmt.Fprintf(w, "Applied:\t%s by %s\n", cs.AppliedAt.Format("2006-01-02 15:04"), cs.AppliedBy)
}
fmt.Fprintln(w, "\n#\tOPERATION")
for _, op := range cs.Ops {
fmt.Fprintf(w, "%d\t%s\n", op.Seq, op.Summary)
}
if cs.Diff != "" {
w.(interface{ Flush() error }).Flush()
fmt.Println()
fmt.Print(cs.Diff)
}
})
},
}
diff := &cobra.Command{
Use: "diff [ID]",
Short: "Validate an open changeset and show what applying it would change (default: -C or JARL_CHANGESET)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
id, fromEnv, err := changesetArg(c, args, true)
if err != nil {
return err
}
var raw json.RawMessage
if err := c.Get(ctx(), "/changesets/"+id+"/preview", nil, &raw); err != nil {
return err
}
if flagOutput != "table" {
var v any
json.Unmarshal(raw, &v)
return output(v, nil)
}
r := client.ParseResult(raw)
for _, op := range r.Ops {
fmt.Printf("#%d %s\n", op.Seq, op.Summary)
}
if r.Error != nil {
fmt.Println("error:", r.Error.Message)
}
printProblems(os.Stdout, r.Problems)
printCheck(os.Stdout, r.Check)
for _, w := range r.Warnings {
fmt.Println("warning:", w)
}
fmt.Println()
fmt.Print(r.Diff)
if r.Valid {
how := "jarl changeset apply " + id
if fromEnv {
how = "jarl changeset apply"
}
fmt.Println("\nvalid: apply with '" + how + "'")
return nil
}
return fmt.Errorf("the changeset can't be applied as it is")
},
}
apply := &cobra.Command{
Use: "apply [ID]",
Short: "Apply an open changeset (default: -C or JARL_CHANGESET)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
id, fromEnv, err := changesetArg(c, args, true)
if err != nil {
return err
}
var resp api.MutationResponse
if err := c.Do(ctx(), "POST", "/changesets/"+id+"/apply", nil, map[string]any{"force": c.Force, "dry_run": c.DryRun, "accept_risk": c.AcceptRisk}, &resp); err != nil {
return err
}
if err := report(&resp); err != nil {
return err
}
if fromEnv && resp.Applied {
envChangesetDone(id, "applied")
}
return nil
},
}
discard := &cobra.Command{
Use: "discard [ID]",
Short: "Throw away an open changeset (default: -C or JARL_CHANGESET)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
id, fromEnv, err := changesetArg(c, args, true)
if err != nil {
return err
}
if err := c.Do(ctx(), "POST", "/changesets/"+id+"/discard", nil, nil, nil); err != nil {
return err
}
if !flagQuiet {
fmt.Println("discarded changeset", id)
}
if fromEnv {
envChangesetDone(id, "discarded")
}
return nil
},
}
remove := &cobra.Command{
Use: "remove [ID] SEQ",
Short: "Remove one operation from an open changeset (default: -C or JARL_CHANGESET)",
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
seq := args[len(args)-1]
id, _, err := changesetArg(c, args[:len(args)-1], true)
if err != nil {
return err
}
return c.Do(ctx(), "DELETE", "/changesets/"+id+"/ops/"+seq, nil, nil, nil)
},
}
revert := &cobra.Command{
Use: "revert ID",
Short: "Create a changeset that undoes an applied changeset (review it, then apply)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var cs api.Changeset
if err := c.Do(ctx(), "POST", "/changesets/"+args[0]+"/revert", nil, nil, &cs); err != nil {
return err
}
if flagQuiet {
fmt.Println(cs.ID)
return nil
}
fmt.Printf("created changeset %d with %d operations; review with 'jarl changeset diff %d', apply with 'jarl changeset apply %d'\n", cs.ID, len(cs.Ops), cs.ID, cs.ID)
return nil
},
}
cmd.AddCommand(list, create, show, diff, apply, discard, remove, revert)
return cmd
}
func historyCmd() *cobra.Command {
var limit int
cmd := &cobra.Command{
Use: "history",
Short: "List applied changesets",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var list []api.Changeset
if err := c.Get(ctx(), "/changesets", q("status", "applied", "limit", fmt.Sprint(limit)), &list); err != nil {
return err
}
return output(list, func(w io.Writer) {
fmt.Fprintln(w, "ID\tAPPLIED\tBY\tVIA\tOPS\tDESCRIPTION")
for _, cs := range list {
at := ""
if cs.AppliedAt != nil {
at = cs.AppliedAt.Local().Format("2006-01-02 15:04")
}
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%d\t%s\n", cs.ID, at, cs.AppliedBy, cs.Via, len(cs.Ops), cs.Description)
}
})
},
}
cmd.Flags().IntVar(&limit, "limit", 30, "how many")
return cmd
}