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>
378 lines
12 KiB
Go
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
|
|
}
|