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 }