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>
469 lines
13 KiB
Go
469 lines
13 KiB
Go
// 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
|
|
)
|
|
|
|
// 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
|
|
}
|
|
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: confirm it with 'hc
|
|
confirm' (within 2 minutes by default) or it is rolled back by itself, so a
|
|
change that cuts you off undoes itself. 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.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(), settingsCmd(),
|
|
rulesetCmd(), countersCmd(), quotasCmd(), logCmd(), 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
|
|
}
|
|
|
|
// 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:
|
|
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.
|