Files
housecarl/cmd/hc/main.go
T
abrown38andClaude Opus 5.5 34292eeebb Build Housecarl 0.1.0: the nftables firewall manager
Zones, address sets (IPv4 and IPv6 mixed, dynamic sets with timeouts) and
services, ordered rules in the early, input, forward and output chains,
port forwards with hairpin NAT and IPv6 pinholes, source NAT, redirects,
and switchable built-ins (loopback, conntrack, ICMPv6, antispoofing, MSS
clamping, DHCPv6 client, flowtable). Every change is validated, checked
with nft -c and applied in one transaction to Housecarl's own table,
inet housecarl; changes made over the network roll back unless
confirmed, and the confirmed ruleset is loaded at boot by
housecarl-ruleset.service. An empty configuration applies nothing.

Also: hit counters and the drop log (nflog), 'hc import' of an existing
nftables ruleset and 'hc takeover', the hc CLI, the REST API and the web
UI, users, scoped tokens (sets:write:NAME), the audit log, the web UI's
certificate through Jarl, Debian packages, docs, unit tests with real
nft 1.1.3, and an e2e test with real traffic (71 checks).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 07:57:14 +01:00

450 lines
12 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
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.
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)")
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(), logCmd(), interfaceCmd())
add("changes", 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)
} else if ip, ok := m["ip"]; ok {
fmt.Println(ip)
}
}
}
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()
}
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.DryRun:
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 {
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.