// Command hc is the command line client for Housecarl. It talks to // housecarld's REST API: on the server itself through the local socket, or // anywhere with the server's URL and an API token. package main import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/url" "os" "path/filepath" "strconv" "strings" "text/tabwriter" "time" "github.com/spf13/cobra" "go.yaml.in/yaml/v3" "housecarl/internal/api" "housecarl/internal/client" "housecarl/internal/model" "housecarl/internal/validate" "housecarl/internal/version" ) func main() { if err := rootCmd().Execute(); err != nil { printError(err) os.Exit(exitCode(err)) } } // Global flags. var ( flagServer string flagToken string flagConfig string flagDryRun bool flagOutput string flagInsecure bool flagCACert string flagQuiet bool flagNoConfirm bool flagNoNames bool // lastClient is the client the command made (to confirm a change by // itself after it). lastClient *client.Client ) // DefaultSocket is housecarld's local socket. const DefaultSocket = "/run/housecarl/housecarl.sock" // cliConfig is the CLI's configuration file. type cliConfig struct { Server string `yaml:"server,omitempty"` Token string `yaml:"token,omitempty"` Insecure bool `yaml:"insecure,omitempty"` CACert string `yaml:"ca_cert,omitempty"` } func defaultConfigPath() string { if p := os.Getenv("HOUSECARL_CLI_CONFIG"); p != "" { return p } dir, err := os.UserConfigDir() if err != nil { dir = filepath.Join(os.Getenv("HOME"), ".config") } return filepath.Join(dir, "housecarl", "config.yaml") } func loadCLIConfig() (*cliConfig, error) { c := &cliConfig{} b, err := os.ReadFile(flagConfig) if errors.Is(err, os.ErrNotExist) { return c, nil } if err != nil { return nil, err } return c, yaml.Unmarshal(b, c) } func saveCLIConfig(c *cliConfig) error { if err := os.MkdirAll(filepath.Dir(flagConfig), 0o700); err != nil { return err } b, err := yaml.Marshal(c) if err != nil { return err } return os.WriteFile(flagConfig, b, 0o600) } func firstNonEmpty(vals ...string) string { for _, v := range vals { if v != "" { return v } } return "" } // newClient connects to the server: --server, HOUSECARL_SERVER, the config // file, or else the local socket. func newClient() (*client.Client, error) { cfg, err := loadCLIConfig() if err != nil { return nil, fmt.Errorf("reading %s: %w", flagConfig, err) } server := firstNonEmpty(flagServer, os.Getenv("HOUSECARL_SERVER"), cfg.Server) token := firstNonEmpty(flagToken, os.Getenv("HOUSECARL_TOKEN"), cfg.Token) if server == "" || server == "local" { sock := firstNonEmpty(os.Getenv("HOUSECARL_SOCKET"), DefaultSocket) if _, err := os.Stat(sock); err != nil { return nil, fmt.Errorf("no server configured, and there is no local socket %s: use --server URL, HOUSECARL_SERVER or 'hc config set server https://...'; on the server itself, check that housecarld runs", sock) } server = "unix://" + sock } if strings.HasPrefix(token, "@") { b, err := os.ReadFile(token[1:]) if err != nil { return nil, fmt.Errorf("reading the token file: %w", err) } token = strings.TrimSpace(string(b)) } if _, isSocket := client.SocketPath(server); !isSocket && token == "" { return nil, errors.New("no API token: use --token, HOUSECARL_TOKEN or 'hc config set token'") } c, err := client.New(client.Options{Server: server, Token: token, Insecure: flagInsecure || cfg.Insecure, CACert: firstNonEmpty(flagCACert, cfg.CACert)}) if err != nil { return nil, err } c.DryRun = flagDryRun if err := setupChangeset(c); err != nil { return nil, err } lastClient = c return c, nil } func rootCmd() *cobra.Command { root := &cobra.Command{ Use: "hc", Short: "Command line client for Housecarl, the firewall", Long: `hc manages a Housecarl firewall through its API. Every change is checked as a whole, then by nftables, before it is put in force, and is recorded in the history, where 'hc history undo ID' takes it back. --dry-run shows what a change would do, with the ruleset's diff. Changes are made at once; to review several and apply them together, stage them in a changeset ('hc changeset --help', -C ID or HOUSECARL_CHANGESET). A change made over the network is on probation: hc confirms it by itself once it reaches Housecarl again over a new connection, and a change that cuts you off can't be confirmed, so it is rolled back by itself (within 2 minutes by default). --no-confirm leaves it to 'hc confirm'. On the firewall itself, root uses housecarld's local socket: no token, no probation. From elsewhere, set the server and an API token once: hc config set server https://firewall.example.com:8070 hc config set token (paste the token at the prompt) Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found, 5 conflict, 6 invalid or rejected by validation, 7 server error.`, SilenceUsage: true, SilenceErrors: true, Version: version.String(), } pf := root.PersistentFlags() pf.StringVarP(&flagServer, "server", "s", "", "server URL, or \"local\" for the local socket (env HOUSECARL_SERVER)") pf.StringVar(&flagToken, "token", "", "API token, or @FILE to read it from a file (env HOUSECARL_TOKEN)") pf.StringVar(&flagConfig, "config", defaultConfigPath(), "CLI configuration file") pf.BoolVarP(&flagDryRun, "dry-run", "n", false, "check and show a change without making it") pf.StringVarP(&flagOutput, "output", "o", "table", "output format: table, json, yaml or id") pf.BoolVar(&flagInsecure, "insecure", false, "don't verify the server's TLS certificate") pf.StringVar(&flagCACert, "ca-cert", "", "PEM file with a CA to trust for the server's certificate") pf.BoolVarP(&flagQuiet, "quiet", "q", false, "print only errors (and IDs of new objects)") pf.BoolVar(&flagNoConfirm, "no-confirm", false, "leave a change on probation instead of confirming it once this client reaches Housecarl again") pf.BoolVar(&flagNoNames, "no-names", false, "show inside addresses without their names (reverse lookups)") pf.Int64VarP(&flagChangeset, "changeset", "C", 0, "stage configuration changes in this changeset instead of applying them (env HOUSECARL_CHANGESET)") root.AddGroup(&cobra.Group{ID: "fw", Title: "Firewall:"}, &cobra.Group{ID: "changes", Title: "Changes:"}, &cobra.Group{ID: "admin", Title: "Administration:"}) add := func(group string, cmds ...*cobra.Command) { for _, c := range cmds { c.GroupID = group root.AddCommand(c) } } add("fw", statusCmd(), zoneCmd(), setCmd(), serviceCmd(), ruleCmd(), forwardCmd(), snatCmd(), redirectCmd(), banlistCmd(), bansCmd(), banCmd(), unbanCmd(), settingsCmd(), rulesetCmd(), countersCmd(), quotasCmd(), logCmd(), connectionsCmd(), traceCmd(), matrixCmd(), statsCmd(), interfaceCmd()) add("changes", changesetCmd(), confirmCmd(), rollbackCmd(), historyCmd(), importCmd(), takeoverCmd()) add("admin", whoamiCmd(), userCmd(), tokenCmd(), passwordCmd(), certCmd(), auditCmd(), configCmd()) return root } func ctx() context.Context { return context.Background() } // ---- output func output(v any, table func(w io.Writer)) error { switch flagOutput { case "json": enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(v) case "yaml": b, err := json.Marshal(v) if err != nil { return err } var x any if err := json.Unmarshal(b, &x); err != nil { return err } return yaml.NewEncoder(os.Stdout).Encode(x) case "id": return outputIDs(v) case "table", "": if table == nil { flagOutput = "yaml" return output(v, nil) } tw := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0) table(tw) return tw.Flush() } return fmt.Errorf("unknown output format %q (use table, json, yaml or id)", flagOutput) } // outputIDs prints the id of each object. func outputIDs(v any) error { b, err := json.Marshal(v) if err != nil { return err } dec := json.NewDecoder(bytes.NewReader(b)) dec.UseNumber() var x any if err := dec.Decode(&x); err != nil { return err } list, ok := x.([]any) if !ok { list = []any{x} } for _, it := range list { if m, ok := it.(map[string]any); ok { if id, ok := m["id"]; ok { fmt.Println(id) } } } return nil } func printProblems(w io.Writer, ps []validate.Problem) { for _, p := range ps { obj := "" if p.Object != "" { obj = p.Object + ": " } fmt.Fprintf(w, "%s: %s%s [%s]\n", p.Severity, obj, p.Message, p.Code) } } func printError(err error) { var ce *client.Error if errors.As(err, &ce) { ps := ce.Info.Problems if len(ps) == 1 { fmt.Fprintf(os.Stderr, "error: %s [%s]\n", ce.Info.Message, ps[0].Code) return } fmt.Fprintln(os.Stderr, "error:", ce.Info.Message) if len(ps) > 1 { printProblems(os.Stderr, ps) } return } fmt.Fprintln(os.Stderr, "error:", err) } // exitCode maps errors to exit codes: 1 other errors, 3 not authenticated // or not allowed, 4 not found, 5 conflict, 6 invalid request or rejected // by validation, 7 server error. func exitCode(err error) int { var ce *client.Error if errors.As(err, &ce) { switch { case ce.Status == 401 || ce.Status == 403: return 3 case ce.Status == 404: return 4 case ce.Status == 409: return 5 case ce.Status == 400 || ce.Status == 422: return 6 case ce.Status >= 500: return 7 } } return 1 } // autoConfirm confirms a change with its token: the request goes over a // new connection (the server closed the change's), so it gets through // only if the new ruleset still lets this client reach Housecarl. func autoConfirm(token string) error { if lastClient == nil { return errors.New("no client") } c, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() var out map[string]int return lastClient.Do(c, "POST", "/confirm", nil, api.ConfirmInput{Token: token}, &out) } // report prints the outcome of a change. func report(resp *api.MutationResponse) error { switch flagOutput { case "json", "yaml": return output(resp, nil) case "id": if len(resp.Object) > 0 { return outputIDs(resp.Object) } return nil } w := os.Stdout verb := func(c model.Change) string { if resp.DryRun { return "would " + c.Action() } if resp.Staged { return "staged: " + c.Action() } return c.Action() + "d" } if !flagQuiet || resp.DryRun { for _, c := range resp.Changes { if c.Kind == model.KindSettings { fmt.Fprintf(w, "%s settings\n", verb(c)) continue } fmt.Fprintf(w, "%s %s %s\n", verb(c), c.Kind, c.Label) } } switch { case resp.Staged && resp.Changeset != nil: if !flagQuiet { cs := resp.Changeset fmt.Fprintf(w, "staged in changeset %d as operation %d (%s now); review with 'hc changeset diff %d', apply with 'hc changeset apply %d'\n", cs.ID, cs.Ops[len(cs.Ops)-1].Seq, plural(len(cs.Ops), "operation"), cs.ID, cs.ID) } case resp.DryRun: if resp.Diff != "" && !flagQuiet { fmt.Fprintln(w) printDiff(w, resp.Diff) fmt.Fprintln(w) } fmt.Fprintln(w, "dry run: nothing was changed") case resp.Applied && resp.ConfirmBy != nil && resp.ConfirmToken != "" && !flagNoConfirm: if err := autoConfirm(resp.ConfirmToken); err != nil { fmt.Fprintf(w, "applied on probation (history %d), but couldn't confirm it: %v\nIt is rolled back within %s unless confirmed ('hc confirm')\n", resp.HistoryID, err, time.Until(*resp.ConfirmBy).Round(time.Second)) } else if !flagQuiet { fmt.Fprintf(w, "applied and confirmed (history %d): this client reached Housecarl again over a new connection\n", resp.HistoryID) } case resp.Applied && resp.ConfirmBy != nil: fmt.Fprintf(w, "applied on probation (history %d): confirm with 'hc confirm' within %s, or it is rolled back\n", resp.HistoryID, time.Until(*resp.ConfirmBy).Round(time.Second)) case resp.Applied && !flagQuiet: fmt.Fprintf(w, "applied and saved (history %d; undo with 'hc history undo %d')\n", resp.HistoryID, resp.HistoryID) case !resp.Applied && len(resp.Changes) == 0 && !flagQuiet: fmt.Fprintln(w, "nothing to change") } if flagQuiet && len(resp.Object) > 0 && (resp.Applied || resp.Staged) { outputIDs(resp.Object) } printProblems(os.Stderr, resp.Warnings) return nil } func q(kv ...string) url.Values { v := url.Values{} for i := 0; i+1 < len(kv); i += 2 { if kv[i+1] != "" { v.Set(kv[i], kv[i+1]) } } return v } func confirm(prompt string, yes bool) bool { if yes || flagDryRun { return true } fmt.Fprintf(os.Stderr, "%s [y/N] ", prompt) var answer string fmt.Scanln(&answer) return strings.EqualFold(answer, "y") || strings.EqualFold(answer, "yes") } // ---- durations // parseSeconds reads a duration: "12h", "30m", "7d", "1d12h" or plain // seconds. func parseSeconds(s string) (int, error) { s = strings.TrimSpace(s) if n, err := strconv.Atoi(s); err == nil { return n, nil } days := 0 if i := strings.Index(s, "d"); i > 0 { n, err := strconv.Atoi(s[:i]) if err != nil { return 0, fmt.Errorf("invalid duration %q (use e.g. 12h, 30m or 7d)", s) } days, s = n, s[i+1:] } var d time.Duration if s != "" { var err error if d, err = time.ParseDuration(s); err != nil { return 0, fmt.Errorf("invalid duration %q (use e.g. 12h, 30m or 7d)", s) } } return days*86400 + int(d/time.Second), nil } // fmtSeconds writes seconds the short way: 43200 is "12h", 90 is "1m30s". func fmtSeconds(n int) string { if n <= 0 { return "-" } var parts []string for _, u := range []struct { s int name string }{{86400, "d"}, {3600, "h"}, {60, "m"}, {1, "s"}} { if n >= u.s { parts = append(parts, strconv.Itoa(n/u.s)+u.name) n %= u.s } } return strings.Join(parts, "") } // fmtWhen writes a time relative to now: "in 3h", "2d ago". func fmtWhen(t time.Time) string { if t.IsZero() { return "-" } d := time.Until(t) ago := d < 0 if ago { d = -d } var s string switch { case d < time.Minute: return "now" case d < time.Hour: s = fmt.Sprintf("%dm", int(d.Minutes())) case d < 48*time.Hour: s = fmt.Sprintf("%dh%02dm", int(d.Hours()), int(d.Minutes())%60) default: s = fmt.Sprintf("%dd", int(d.Hours()/24)) } if ago { return s + " ago" } return "in " + s } // fmtSeen is a last-seen time, or "-" for a client never seen. func fmtSeen(t *time.Time) string { if t == nil { return "-" } return fmtWhen(*t) } func orDash(s string) string { if s == "" { return "-" } return s } // optionsArg turns --option NAME=VALUE flags into option inputs.