Files
housecarl/cmd/hc/changesets.go
T
abrown38andClaude Opus 5.5 2880f7ddc9 Add changesets, bandwidth limits and quotas; generic examples
Changesets, as in Jarl: configuration changes are operations that can be
staged in a changeset, checked as they are added (validation and nft -c),
reviewed with the diff of the ruleset, and applied together as one
history entry, then on probation like any change made over the network.
The web UI collects edits for review by default (pending rows, a bar with
Review and Apply now, a Changes page, a switch for live edits); hc stages
with -C ID or HOUSECARL_CHANGESET (checked like JARL_CHANGESET) and has
hc changeset create/list/show/diff/apply/discard/remove. Undo, import and
takeover refuse a changeset. --dry-run shows the ruleset's diff.

Bandwidth and quotas: rate limits in bytes, per source or destination;
quotas per rule or per address that reset daily, weekly, monthly on a
day, or by hand, with their usage carried across reloads, saved for
reboots, shown by hc quotas and on the rules page, and reset with hc rule
reset-quota. Rules that count traffic are checked before established
connections are accepted (so they see every packet) and can't accept.
Per-address limits group IPv6 by /64 by default (--group-v4/-v6).
Per-source rate meters use update. The importer turns meters, byte rates
and quotas into these fields.

The examples in the web UI, hc's help and the docs use documentation
addresses and generic names instead of the maintainer's network.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:18:54 +01:00

378 lines
12 KiB
Go

package main
import (
"errors"
"fmt"
"io"
"os"
"strconv"
"strings"
"github.com/spf13/cobra"
"housecarl/internal/api"
"housecarl/internal/client"
"housecarl/internal/model"
)
// Changesets: configuration changes staged to be reviewed and applied
// together. -C/--changeset stages into a changeset as given (housecarld
// still refuses one that isn't open, or another user's unless you are an
// administrator). One from HOUSECARL_CHANGESET is checked first: an
// exported ID outlives its changeset (it gets applied), and IDs keep
// growing, so a stale or mistyped one could name someone else's later. It
// is used only when it is open and yours, and hc says on stderr which
// changeset that is.
var flagChangeset int64
// envChangeset returns the changeset in HOUSECARL_CHANGESET (0 if unset).
func envChangeset() (int64, error) {
v := os.Getenv("HOUSECARL_CHANGESET")
if v == "" {
return 0, nil
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil || n <= 0 {
return 0, fmt.Errorf("invalid HOUSECARL_CHANGESET %q", v)
}
return n, nil
}
const startHint = "export HOUSECARL_CHANGESET=$(hc changeset create -q)"
// checkEnvChangeset checks the changeset from HOUSECARL_CHANGESET before
// it is used. With mustOwn, it must also be open and the caller's own
// (for staging into it, applying or discarding it).
func checkEnvChangeset(c *client.Client, id int64, mustOwn bool) (*model.Changeset, error) {
var cs model.Changeset
if err := c.Do(ctx(), "GET", fmt.Sprintf("/changesets/%d", id), nil, nil, &cs); err != nil {
var ce *client.Error
if errors.As(err, &ce) && ce.Status == 404 {
return nil, fmt.Errorf("HOUSECARL_CHANGESET is %d, but there is no changeset %d: start one with '%s', or 'unset HOUSECARL_CHANGESET' to make changes at once", id, id, startHint)
}
return nil, err
}
if mustOwn {
if cs.Status != model.ChangesetOpen {
return nil, fmt.Errorf("HOUSECARL_CHANGESET is %d, but changeset %d is %s: 'unset HOUSECARL_CHANGESET' to make changes at once, or start a new one with '%s'", id, id, cs.Status, startHint)
}
var me api.Whoami
if err := c.Do(ctx(), "GET", "/auth/whoami", nil, nil, &me); err != nil {
return nil, err
}
mine := me.User != nil && cs.UserID == me.User.ID && (me.User.ID != 0 || cs.Username == me.User.Username)
if !mine {
return nil, fmt.Errorf("HOUSECARL_CHANGESET is %d, but changeset %d is %s's (made via %s), not yours, so it isn't used from HOUSECARL_CHANGESET; give it as -C %d to use it anyway, or 'unset HOUSECARL_CHANGESET'", id, id, cs.Username, cs.Via, id)
}
}
if !flagQuiet {
desc := cs.Description
if desc == "" {
desc = "no description"
}
fmt.Fprintf(os.Stderr, "changeset %d (%s, %s) from HOUSECARL_CHANGESET\n", id, desc, plural(len(cs.Ops), "operation"))
}
return &cs, nil
}
// setupChangeset makes the client stage configuration changes: in
// --changeset's changeset, else HOUSECARL_CHANGESET's (checked before
// the first change).
func setupChangeset(c *client.Client) error {
if flagChangeset > 0 {
c.Changeset = flagChangeset
return nil
}
n, err := envChangeset()
if err != nil || n == 0 {
return err
}
c.Changeset = n
c.BeforeStage = func() error {
_, err := checkEnvChangeset(c, n, true)
return err
}
return nil
}
// changesetArg returns the changeset a changeset subcommand works on: the
// ID argument, else --changeset, else HOUSECARL_CHANGESET (checked).
// fromEnv says it came from HOUSECARL_CHANGESET.
func changesetArg(c *client.Client, args []string, mustOwn bool) (id string, fromEnv bool, err error) {
if len(args) > 0 {
return args[0], false, nil
}
if flagChangeset > 0 {
return strconv.FormatInt(flagChangeset, 10), false, nil
}
n, err := envChangeset()
if err != nil {
return "", false, err
}
if n == 0 {
return "", false, errors.New("which changeset? Give its ID, or set HOUSECARL_CHANGESET (" + startHint + ")")
}
if _, err := checkEnvChangeset(c, n, mustOwn); err != nil {
return "", false, err
}
return strconv.FormatInt(n, 10), true, nil
}
// envChangesetDone reminds that HOUSECARL_CHANGESET still names a
// changeset that was just applied or discarded.
func envChangesetDone(id, what string) {
if !flagQuiet {
fmt.Fprintf(os.Stderr, "HOUSECARL_CHANGESET still names changeset %s, which is now %s: run 'unset HOUSECARL_CHANGESET' (or export a new one) before making more changes\n", id, what)
}
}
func plural(n int, word string) string {
if n == 1 {
return "1 " + word
}
return strconv.Itoa(n) + " " + word + "s"
}
// printDiff prints a unified diff, colored on a terminal.
func printDiff(w io.Writer, diff string) {
color := isTerminal(os.Stdout)
for _, line := range strings.Split(strings.TrimSuffix(diff, "\n"), "\n") {
switch {
case !color:
case strings.HasPrefix(line, "+++"), strings.HasPrefix(line, "---"):
line = "\x1b[1m" + line + "\x1b[0m"
case strings.HasPrefix(line, "+"):
line = "\x1b[32m" + line + "\x1b[0m"
case strings.HasPrefix(line, "-"):
line = "\x1b[31m" + line + "\x1b[0m"
case strings.HasPrefix(line, "@@"):
line = "\x1b[36m" + line + "\x1b[0m"
}
fmt.Fprintln(w, line)
}
}
func isTerminal(f *os.File) bool {
fi, err := f.Stat()
return err == nil && fi.Mode()&os.ModeCharDevice != 0
}
func changesetCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "changeset",
Aliases: []string{"changesets", "cs"},
Short: "Stage changes to review and apply together",
Long: `A changeset collects configuration changes to apply together, as one
change in the history. Each change is checked as it is staged; 'hc
changeset diff' shows what applying them would do: the problems, nftables'
check and the ruleset's diff. Over the network an applied changeset is on
probation like any change.
The web UI stages its edits in a changeset by default; hc and the API
make changes at once unless one is named with -C ID or HOUSECARL_CHANGESET.
Changesets staged in the web UI show up here too, and the other way round.`,
Example: ` export HOUSECARL_CHANGESET=$(hc changeset create "guest network" -q)
hc zone add guest -i eth1.30
hc rule add forward --in guest --out wan
hc changeset diff # HOUSECARL_CHANGESET's
hc changeset apply
unset HOUSECARL_CHANGESET`,
}
cmd.AddCommand(&cobra.Command{
Use: "create [DESCRIPTION]", Short: "Start a changeset (-q prints only its ID)", Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
in := api.ChangesetInput{}
if len(args) > 0 {
in.Description = args[0]
}
var cs model.Changeset
if err := c.Do(ctx(), "POST", "/changesets", nil, in, &cs); err != nil {
return err
}
if flagQuiet || flagOutput == "id" {
fmt.Println(cs.ID)
return nil
}
fmt.Printf("created changeset %d\nstage changes with -C %d (or export HOUSECARL_CHANGESET=%d), then 'hc changeset apply'\n", cs.ID, cs.ID, cs.ID)
return nil
},
})
var status string
var mine bool
list := &cobra.Command{
Use: "list", Short: "List changesets (open ones by default)", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var css []*model.Changeset
query := q("status", status)
if mine {
query.Set("mine", "1")
}
if err := c.Do(ctx(), "GET", "/changesets", query, nil, &css); err != nil {
return err
}
return output(css, func(w io.Writer) {
fmt.Fprintln(w, "ID\tSTATUS\tBY\tVIA\tOPERATIONS\tUPDATED\tDESCRIPTION")
for _, cs := range css {
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%d\t%s\t%s\n", cs.ID, cs.Status, cs.Username, cs.Via, len(cs.Ops), cs.UpdatedAt.Local().Format("2006-01-02 15:04"), cs.Description)
}
})
},
}
list.Flags().StringVar(&status, "status", "open", "open, applied, discarded, or \"\" for all")
list.Flags().BoolVar(&mine, "mine", false, "only your own")
cmd.AddCommand(list)
cmd.AddCommand(&cobra.Command{
Use: "show [ID]", Short: "Show a changeset's operations (default: -C or HOUSECARL_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 model.Changeset
if err := c.Do(ctx(), "GET", "/changesets/"+id, nil, nil, &cs); err != nil {
return err
}
return output(&cs, func(w io.Writer) {
fmt.Fprintf(w, "Changeset:\t%d (%s)\n", cs.ID, cs.Status)
fmt.Fprintf(w, "By:\t%s via %s, %s\n", cs.Username, cs.Via, cs.CreatedAt.Local().Format("2006-01-02 15:04"))
if cs.Description != "" {
fmt.Fprintf(w, "Description:\t%s\n", cs.Description)
}
if cs.HistoryID != 0 {
fmt.Fprintf(w, "History:\t%d\n", cs.HistoryID)
}
fmt.Fprintln(w, "\nOP\tCHANGE")
for _, o := range cs.Ops {
fmt.Fprintf(w, "%d\t%s\n", o.Seq, o.Summary)
}
})
},
})
cmd.AddCommand(&cobra.Command{
Use: "diff [ID]", Short: "Check an open changeset and show what applying it would change (default: -C or HOUSECARL_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 pv api.ChangesetPreview
if err := c.Do(ctx(), "GET", "/changesets/"+id+"/preview", nil, nil, &pv); err != nil {
return err
}
if flagOutput == "json" || flagOutput == "yaml" {
return output(&pv, nil)
}
for _, ch := range pv.Changes {
if ch.Kind == model.KindSettings {
fmt.Printf("would %s settings\n", ch.Action())
} else {
fmt.Printf("would %s %s %s\n", ch.Action(), ch.Kind, ch.Label)
}
}
printProblems(os.Stderr, pv.Warnings)
if pv.Error != nil {
printProblems(os.Stderr, pv.Error.Problems)
return fmt.Errorf("changeset %s can't be applied: %s", id, pv.Error.Message)
}
if !pv.Valid {
fmt.Println("nothing to change")
return nil
}
fmt.Println()
printDiff(os.Stdout, pv.Diff)
fmt.Printf("\nvalid: nftables accepts the new ruleset; apply with 'hc changeset apply %s'\n", id)
return nil
},
})
var desc string
apply := &cobra.Command{
Use: "apply [ID]", Short: "Apply an open changeset (default: -C or HOUSECARL_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, api.ChangesetInput{Description: desc}, &resp); err != nil {
return err
}
if err := report(&resp); err != nil {
return err
}
if fromEnv {
envChangesetDone(id, "applied")
}
return nil
},
}
apply.Flags().StringVar(&desc, "description", "", "the history entry's description (default: the changeset's)")
cmd.AddCommand(apply)
cmd.AddCommand(&cobra.Command{
Use: "discard [ID]", Short: "Throw away an open changeset (default: -C or HOUSECARL_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.Printf("discarded changeset %s\n", id)
}
if fromEnv {
envChangesetDone(id, "discarded")
}
return nil
},
})
cmd.AddCommand(&cobra.Command{
Use: "remove [ID] OP", Short: "Remove one operation from an open changeset (default: -C or HOUSECARL_CHANGESET)", Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
op := args[len(args)-1]
id, _, err := changesetArg(c, args[:len(args)-1], true)
if err != nil {
return err
}
var cs model.Changeset
if err := c.Do(ctx(), "DELETE", "/changesets/"+id+"/ops/"+op, nil, nil, &cs); err != nil {
return err
}
if !flagQuiet {
fmt.Printf("removed operation %s; changeset %s has %s\n", op, id, plural(len(cs.Ops), "operation"))
}
return nil
},
})
return cmd
}