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>
This commit is contained in:
1 parent
54beb0bba3
commit
34292eeebb
118 files changed
+22165
-114
No files matched your search
+421
-14
@@ -1,42 +1,449 @@
|
||||
// Command hc is Housecarl's command line client: it will manage the
|
||||
// firewall through housecarld's API, as jarl and steward do for theirs.
|
||||
// For now it only knows its version.
|
||||
// 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 {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
os.Exit(1)
|
||||
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: "Manage a Housecarl firewall",
|
||||
Long: `hc will manage a Housecarl firewall through housecarld's API: its zones,
|
||||
address sets, rules and port forwards. It doesn't yet: Housecarl is at its
|
||||
start (docs/design.md).`,
|
||||
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(),
|
||||
}
|
||||
root.AddCommand(versionCmd())
|
||||
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 versionCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "version", Short: "Print the version", Args: cobra.NoArgs,
|
||||
Run: func(cmd *cobra.Command, args []string) { fmt.Println("hc", version.String()) },
|
||||
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.
|
||||
Reference in new issue
Block a user