A program bans an address (every port, or a protocol's ports) for a time; the ban list says what it is kept from: the firewall, some hosts behind it, or everything. Bans drop ahead of every rule, close the address's connections, group IPv6 by /64, are kept in the database across restarts and audited. Never banned: the inside, the firewall's own addresses, the caller, the list's never. bans grant (bans:write:LIST), hc banlist/bans/ban/unban, Traffic -> Bans, a fail2ban action in contrib/, and failed logins and refused tokens logged for Guard and fail2ban. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
498 lines
14 KiB
Go
498 lines
14 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
|
|
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.
|