Files
housecarl/cmd/housecarld/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

669 lines
20 KiB
Go

// Command housecarld is Housecarl's daemon: it keeps the firewall's
// ruleset and serves the REST API and the web UI. It also has the commands
// that work on the database directly (init, user, backup, restore, check).
package main
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"log/slog"
"net"
"os"
"os/signal"
"path/filepath"
"sort"
"strings"
"syscall"
"time"
"github.com/spf13/cobra"
"golang.org/x/term"
"housecarl/internal/auth"
"housecarl/internal/certs"
"housecarl/internal/config"
"housecarl/internal/engine"
"housecarl/internal/hostif"
"housecarl/internal/jarl"
"housecarl/internal/model"
"housecarl/internal/nflog"
"housecarl/internal/nft"
"housecarl/internal/render"
"housecarl/internal/server"
"housecarl/internal/store"
"housecarl/internal/validate"
"housecarl/internal/version"
"housecarl/internal/web"
)
var configPath string
func main() {
if err := rootCmd().Execute(); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
func rootCmd() *cobra.Command {
root := &cobra.Command{
Use: "housecarld",
Short: "Housecarl's firewall daemon, API and web UI",
SilenceUsage: true,
SilenceErrors: true,
Version: version.String(),
}
root.PersistentFlags().StringVarP(&configPath, "config", "c", config.DefaultPath, "configuration file")
root.AddCommand(serveCmd(), initCmd(), userCmd(), checkCmd(), backupCmd(), restoreCmd(), configCmd(), versionCmd())
return root
}
func newLogger(cfg *config.Config, w io.Writer) *slog.Logger {
var level slog.Level
switch cfg.Log.Level {
case "debug":
level = slog.LevelDebug
case "warn":
level = slog.LevelWarn
case "error":
level = slog.LevelError
}
opts := &slog.HandlerOptions{Level: level}
if cfg.Log.Format == "json" {
return slog.New(slog.NewJSONHandler(w, opts))
}
// The journal adds its own timestamps.
if os.Getenv("JOURNAL_STREAM") != "" {
opts.ReplaceAttr = func(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey && len(groups) == 0 {
return slog.Attr{}
}
return a
}
}
return slog.New(slog.NewTextHandler(w, opts))
}
type app struct {
cfg *config.Config
log *slog.Logger
store *store.Store
}
func openApp(allowMissing bool) (*app, error) {
cfg, err := config.Load(configPath, allowMissing)
if err != nil {
return nil, fmt.Errorf("loading the configuration: %w", err)
}
log := newLogger(cfg, os.Stderr)
if err := os.MkdirAll(filepath.Dir(cfg.Database), 0o750); err != nil {
return nil, err
}
st, err := store.Open(cfg.Database)
if err != nil {
return nil, fmt.Errorf("opening the database %s: %w", cfg.Database, err)
}
return &app{cfg: cfg, log: log, store: st}, nil
}
// hostEnv describes this machine's interfaces for validation.
func hostEnv(h *hostif.Host) func() *validate.Env {
return func() *validate.Env {
all, err := h.All()
if err != nil {
return nil
}
return &validate.Env{Interfaces: all}
}
}
func serveCmd() *cobra.Command {
return &cobra.Command{
Use: "serve",
Short: "Keep the firewall's ruleset, and serve the API and the web UI",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
a, err := openApp(true)
if err != nil {
return err
}
defer a.store.Close()
release, err := lockDatabase(a.cfg.Database)
if err != nil {
return err
}
defer release()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
a.log.Info("starting", "version", version.String(), "database", a.cfg.Database)
if users, err := a.store.ListUsers(ctx); err == nil && len(users) == 0 {
a.log.Warn("there are no users yet; create an administrator with 'housecarld init' (root can use 'hc' on the local socket meanwhile)")
}
eng, err := engine.New(ctx, a.store, a.log)
if err != nil {
return err
}
host := &hostif.Host{}
eng.Env = hostEnv(host)
fw := &nft.Firewall{Runner: &nft.Runner{Command: nft.Wrapper()}, Interfaces: host.Exists, BootFile: a.cfg.Ruleset}
eng.Firewall = fw
for _, p := range eng.Problems() {
a.log.Warn("configuration problem: "+p.Message, "object", p.Object, "code", p.Code)
}
var acme *certs.Manager
if a.cfg.TLS.ACME.Domain != "" {
tok, err := a.cfg.Jarl.ReadToken()
if err != nil {
return err
}
jc, err := jarl.New(a.cfg.Jarl.Server, tok, a.cfg.Jarl.CACert, a.cfg.Jarl.Insecure)
if err != nil {
return err
}
acme = certs.New(ctx, certs.Config{ACME: a.cfg.TLS.ACME, Version: version.Short()}, jc, a.store, a.log)
go acme.Run(ctx)
}
srv := server.New(server.Config{
Listen: a.cfg.Listen, TLSCertFile: a.cfg.TLS.CertFile, TLSKeyFile: a.cfg.TLS.KeyFile, TrustedProxies: a.cfg.Proxies(),
SessionLifetime: time.Duration(a.cfg.Session.Lifetime), SessionIdle: time.Duration(a.cfg.Session.IdleTimeout),
CookieSecure: a.cfg.Session.CookieSecure, Socket: a.cfg.Socket, ACME: acme,
}, eng, fw, host, a.log, web.FS())
// Put the ruleset in force: what was on probation when
// housecarld stopped is rolled back first. An empty
// configuration applies nothing: its default policies would
// close the machine before any rule lets anyone in.
if !srv.ResumeProbation(ctx) {
if eng.State().Empty() {
a.log.Info("the configuration is empty: no ruleset is applied until the first change (or 'hc import')")
} else if err := eng.ApplyCurrent(ctx); err != nil {
a.log.Error("applying the ruleset failed; the one in force stays (see 'hc status')", "err", err)
} else if err := fw.SaveBoot(eng.State()); err != nil {
a.log.Error("saving the ruleset for the next boot failed", "err", err)
}
}
go nflog.Listen(ctx, render.LogGroup, a.log, srv.LogPacket)
hup := make(chan os.Signal, 1)
signal.Notify(hup, syscall.SIGHUP)
go func() {
for range hup {
sdNotify("RELOADING=1")
srv.ReloadTLS()
sdNotify("READY=1")
}
}()
err = srv.Serve(ctx, func() { sdNotify("READY=1") })
sdNotify("STOPPING=1")
a.log.Info("stopped")
return err
},
}
}
func sdNotify(state string) {
sock := os.Getenv("NOTIFY_SOCKET")
if sock == "" {
return
}
addr := &net.UnixAddr{Name: sock, Net: "unixgram"}
if strings.HasPrefix(sock, "@") {
addr.Name = "\x00" + sock[1:]
}
c, err := net.DialUnix("unixgram", nil, addr)
if err != nil {
return
}
defer c.Close()
c.Write([]byte(state))
}
// ---- init and users
func readPassword(prompt string, fromStdin bool) (string, error) {
if fromStdin || !term.IsTerminal(int(os.Stdin.Fd())) {
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return "", err
}
return strings.TrimRight(line, "\r\n"), nil
}
fmt.Fprint(os.Stderr, prompt)
b, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
if err != nil {
return "", err
}
fmt.Fprint(os.Stderr, "Repeat: ")
b2, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
if err != nil {
return "", err
}
if string(b) != string(b2) {
return "", errors.New("the passwords don't match")
}
return string(b), nil
}
// readLine reads one line a byte at a time, so nothing after it is
// buffered away from the password prompt that follows.
func readLine(r io.Reader) string {
var b []byte
one := make([]byte, 1)
for {
n, err := r.Read(one)
if n == 1 {
if one[0] == '\n' {
break
}
b = append(b, one[0])
}
if err != nil {
break
}
}
return strings.TrimRight(string(b), "\r")
}
func localPrincipal(u *model.User) *auth.Principal {
return &auth.Principal{User: u, Method: "local", IP: "local"}
}
func audit(ctx context.Context, st *store.Store, p *auth.Principal, action, object string) {
ev := &model.AuditEvent{Action: action, Object: object, Result: "success", Auth: p.Method, Username: p.Name()}
if p.User != nil {
ev.UserID = p.User.ID
}
st.AddAudit(ctx, ev)
}
func createToken(ctx context.Context, st *store.Store, u *model.User, name string) (string, error) {
secret, pid, hash := auth.NewToken()
if err := st.CreateToken(ctx, &model.Token{UserID: u.ID, Name: name, PublicID: pid, SecretHash: hash}); err != nil {
return "", err
}
return secret, nil
}
func initCmd() *cobra.Command {
var username string
var stdin, withToken bool
cmd := &cobra.Command{
Use: "init",
Short: "Create the database and the first administrator",
Long: `Creates Housecarl's database with a first administrator.
Housecarl starts with an empty configuration, and applies no ruleset until
it has one: import your nftables ruleset with 'hc import', or write rules
with 'hc rule add' or in the web UI (docs/install.md).`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
a, err := openApp(true)
if err != nil {
return err
}
defer a.store.Close()
ctx := context.Background()
users, err := a.store.ListUsers(ctx)
if err != nil {
return err
}
if len(users) > 0 {
return fmt.Errorf("Housecarl is already set up (%d users); add users with 'housecarld user add' or 'hc user add'", len(users))
}
// Asked for when not given, on a terminal.
if !cmd.Flags().Changed("user") && !stdin && term.IsTerminal(int(os.Stdin.Fd())) {
fmt.Fprint(os.Stderr, "The first administrator's user name [admin]: ")
if name := strings.TrimSpace(readLine(os.Stdin)); name != "" {
username = name
}
}
if err := server.ValidUsername(username); err != nil {
return err
}
pw, err := readPassword("Password for "+username+": ", stdin)
if err != nil {
return err
}
hash, err := auth.HashPassword(pw)
if err != nil {
return err
}
u := &model.User{Username: username, PasswordHash: hash, Admin: true, Comment: "created by housecarld init"}
if err := a.store.CreateUser(ctx, u); err != nil {
return err
}
audit(ctx, a.store, localPrincipal(u), "system.init", "user "+u.Username)
fmt.Printf("Created the administrator %q in %s.\n", u.Username, a.cfg.Database)
if withToken {
tok, err := createToken(ctx, a.store, u, "cli")
if err != nil {
return err
}
fmt.Printf("\nAPI token for the CLI (shown only once):\n\n %s\n", tok)
}
fmt.Println("\nNext steps:")
fmt.Println(" 1. Start the daemon: sudo systemctl enable --now housecarl")
fmt.Println(" 2. Import your ruleset: sudo hc import --defines /etc/nftables.conf -n (look first; docs/import.md)")
fmt.Println(" or let yourself in first: sudo hc rule add input --from 10.0.10.0/24 --service ssh (then zones, rules, NAT)")
fmt.Printf(" 3. Open the web UI: http://%s/ (see listen in %s)\n", a.cfg.Listen[0], configPath)
return nil
},
}
cmd.Flags().StringVarP(&username, "user", "u", "admin", "the administrator's name (best your own: each person should have their own account); asked for on a terminal")
cmd.Flags().BoolVar(&stdin, "password-stdin", false, "read the password from standard input")
cmd.Flags().BoolVar(&withToken, "token", false, "also make an API token for the CLI")
return cmd
}
func userCmd() *cobra.Command {
cmd := &cobra.Command{Use: "user", Short: "Manage users directly in the database (when the API can't be used)"}
var admin, stdin bool
add := &cobra.Command{
Use: "add NAME", Short: "Add a user", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
a, err := openApp(true)
if err != nil {
return err
}
defer a.store.Close()
if err := server.ValidUsername(args[0]); err != nil {
return err
}
pw, err := readPassword("Password for "+args[0]+": ", stdin)
if err != nil {
return err
}
hash, err := auth.HashPassword(pw)
if err != nil {
return err
}
u := &model.User{Username: args[0], PasswordHash: hash, Admin: admin}
if err := a.store.CreateUser(context.Background(), u); err != nil {
if errors.Is(err, store.ErrConflict) {
return fmt.Errorf("user %s already exists", args[0])
}
return err
}
audit(context.Background(), a.store, localPrincipal(&model.User{Username: "root"}), "user.create", "user "+u.Username)
fmt.Printf("added user %s\n", u.Username)
return nil
},
}
add.Flags().BoolVar(&admin, "admin", false, "an administrator")
add.Flags().BoolVar(&stdin, "password-stdin", false, "read the password from standard input")
cmd.AddCommand(add)
pw := &cobra.Command{
Use: "password NAME", Short: "Set a user's password (and log them out)", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
a, err := openApp(true)
if err != nil {
return err
}
defer a.store.Close()
ctx := context.Background()
u, err := a.store.GetUserByName(ctx, args[0])
if err != nil {
return fmt.Errorf("no user %s", args[0])
}
p, err := readPassword("New password for "+u.Username+": ", stdin)
if err != nil {
return err
}
if u.PasswordHash, err = auth.HashPassword(p); err != nil {
return err
}
u.Disabled = false
if err := a.store.UpdateUser(ctx, u); err != nil {
return err
}
a.store.DeleteUserSessions(ctx, u.ID, "")
audit(ctx, a.store, localPrincipal(&model.User{Username: "root"}), "user.password", "user "+u.Username)
fmt.Printf("set the password of %s\n", u.Username)
return nil
},
}
pw.Flags().BoolVar(&stdin, "password-stdin", false, "read the password from standard input")
cmd.AddCommand(pw)
cmd.AddCommand(&cobra.Command{
Use: "token NAME TOKEN-NAME", Short: "Make an API token for a user (printed once)", Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
a, err := openApp(true)
if err != nil {
return err
}
defer a.store.Close()
ctx := context.Background()
u, err := a.store.GetUserByName(ctx, args[0])
if err != nil {
return fmt.Errorf("no user %s", args[0])
}
tok, err := createToken(ctx, a.store, u, args[1])
if err != nil {
return err
}
audit(ctx, a.store, localPrincipal(&model.User{Username: "root"}), "token.create", "token "+u.Username+"/"+args[1])
fmt.Println(tok)
return nil
},
})
return cmd
}
func checkCmd() *cobra.Command {
return &cobra.Command{
Use: "check",
Short: "Check the configuration in the database against this machine",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
a, err := openApp(false)
if err != nil {
return err
}
defer a.store.Close()
eng, err := engine.New(context.Background(), a.store, a.log)
if err != nil {
return err
}
eng.Env = hostEnv(&hostif.Host{})
ps := eng.Problems()
for _, p := range ps {
fmt.Printf("%s: %s: %s [%s]\n", p.Severity, p.Object, p.Message, p.Code)
}
if validate.HasErrors(ps) {
return errors.New("the configuration has errors")
}
if len(ps) == 0 {
fmt.Println("ok: no problems")
}
return nil
},
}
}
// ---- backups
// backupName is a backup's file name; the time in it orders and prunes
// backups.
const backupPrefix, backupSuffix = "housecarl-", ".db"
func backupCmd() *cobra.Command {
return &cobra.Command{
Use: "backup [FILE]",
Short: "Write a backup of the database (and delete old ones)",
Long: `Writes a consistent copy of the database, also while housecarld runs. Without
FILE it goes to backup.dir as housecarl-<time>.db, and backups older than
backup.retention are deleted (keeping at least backup.keep_at_least).`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(configPath, true)
if err != nil {
return err
}
if _, err := os.Stat(cfg.Database); err != nil {
return fmt.Errorf("no database at %s yet (run 'housecarld init')", cfg.Database)
}
st, err := store.OpenExisting(cfg.Database)
if err != nil {
return err
}
defer st.Close()
dest := ""
if len(args) == 1 {
dest = args[0]
} else {
if err := os.MkdirAll(cfg.Backup.Dir, 0o700); err != nil {
return err
}
dest = filepath.Join(cfg.Backup.Dir, backupPrefix+time.Now().UTC().Format("20060102T150405Z")+backupSuffix)
}
tmp := dest + ".tmp"
os.Remove(tmp)
if err := st.Backup(context.Background(), tmp); err != nil {
return fmt.Errorf("writing the backup: %w", err)
}
if err := os.Rename(tmp, dest); err != nil {
return err
}
fmt.Println(dest)
if len(args) == 0 {
return prune(cfg)
}
return nil
},
}
}
func prune(cfg *config.Config) error {
entries, err := os.ReadDir(cfg.Backup.Dir)
if err != nil {
return err
}
type bk struct {
name string
t time.Time
}
var list []bk
for _, e := range entries {
n := e.Name()
if !strings.HasPrefix(n, backupPrefix) || !strings.HasSuffix(n, backupSuffix) {
continue
}
t, err := time.Parse("20060102T150405Z", strings.TrimSuffix(strings.TrimPrefix(n, backupPrefix), backupSuffix))
if err != nil {
continue
}
list = append(list, bk{n, t})
}
sort.Slice(list, func(i, j int) bool { return list[i].t.After(list[j].t) })
cut := time.Now().Add(-time.Duration(cfg.Backup.Retention))
for i, b := range list {
if i < cfg.Backup.KeepAtLeast || b.t.After(cut) {
continue
}
os.Remove(filepath.Join(cfg.Backup.Dir, b.name))
}
return nil
}
func restoreCmd() *cobra.Command {
var yes bool
cmd := &cobra.Command{
Use: "restore FILE",
Short: "Replace the database with a backup (housecarld must be stopped)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(configPath, true)
if err != nil {
return err
}
release, err := lockDatabase(cfg.Database)
if err != nil {
return fmt.Errorf("%w; stop it first: systemctl stop housecarl", err)
}
defer release()
src, err := store.OpenExisting(args[0])
if err != nil {
return fmt.Errorf("%s isn't a database: %w", args[0], err)
}
v, err := src.Version(context.Background())
if err != nil {
src.Close()
return fmt.Errorf("%s isn't a Housecarl database: %w", args[0], err)
}
var ok string
if err := src.DB().QueryRow(`PRAGMA integrity_check`).Scan(&ok); err != nil || ok != "ok" {
src.Close()
return fmt.Errorf("%s fails SQLite's integrity check: %s %v", args[0], ok, err)
}
src.Close()
if v > store.SchemaVersion() {
return fmt.Errorf("the backup is from a newer Housecarl (schema %d, this one has %d)", v, store.SchemaVersion())
}
if !yes {
fmt.Fprintf(os.Stderr, "Replace %s with %s? [y/N] ", cfg.Database, args[0])
var a string
fmt.Scanln(&a)
if a != "y" && a != "yes" {
return errors.New("not restored")
}
}
stamp := time.Now().UTC().Format("20060102T150405Z")
for _, ext := range []string{"", "-wal", "-shm"} {
if _, err := os.Stat(cfg.Database + ext); err == nil {
if err := os.Rename(cfg.Database+ext, cfg.Database+".before-restore-"+stamp+ext); err != nil {
return err
}
}
}
b, err := os.ReadFile(args[0])
if err != nil {
return err
}
if err := os.WriteFile(cfg.Database, b, 0o640); err != nil {
return err
}
st, err := store.Open(cfg.Database)
if err != nil {
return err
}
defer st.Close()
audit(context.Background(), st, localPrincipal(&model.User{Username: "root"}), "system.restore", args[0])
fmt.Printf("restored %s; the old database is %s.before-restore-%s\n", args[0], cfg.Database, stamp)
return nil
},
}
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
return cmd
}
func configCmd() *cobra.Command {
var example bool
cmd := &cobra.Command{
Use: "config", Short: "Show the configuration in use (--example: a commented example)", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if example {
fmt.Print(config.Example)
return nil
}
cfg, err := config.Load(configPath, true)
if err != nil {
return err
}
fmt.Printf("listen: %s\nsocket: %s\ndatabase: %s\nbackups: %s\n", strings.Join(cfg.Listen, ", "), cfg.Socket, cfg.Database, cfg.Backup.Dir)
return nil
},
}
cmd.Flags().BoolVar(&example, "example", false, "print a commented example configuration")
return cmd
}
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("housecarld", version.String()) },
}
}