// 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" "encoding/json" "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/conntrack" "housecarl/internal/engine" "housecarl/internal/hostif" "housecarl/internal/jarl" "housecarl/internal/linkwatch" "housecarl/internal/model" "housecarl/internal/names" "housecarl/internal/nflog" "housecarl/internal/nft" "housecarl/internal/render" "housecarl/internal/server" "housecarl/internal/stats" "housecarl/internal/store" "housecarl/internal/tablewatch" "housecarl/internal/trace" "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, Shadow: a.cfg.Shadow.Enabled, ShadowBefore: a.cfg.Shadow.Position == config.ShadowBefore} eng.Firewall = fw if fw.Shadow { a.log.Warn("shadow mode: Housecarl's ruleset is loaded but blocks, rejects and translates nothing; the other firewall decides (docs/shadow.md)", "position", a.cfg.Shadow.Position) } 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) } var resolver *names.Resolver if a.cfg.Names.Enabled { resolver = names.New(a.cfg.Names.Resolver, time.Duration(a.cfg.Names.Cache)) } 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, Shadow: a.cfg.Shadow.Enabled, ShadowPosition: a.cfg.Shadow.Position, Names: resolver, }, 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. // What the quotas used before housecarld (or the machine) // stopped comes back with the next apply. restoreQuotas := func() { if v, err := a.store.GetMeta(ctx, server.QuotaUsageKey); err == nil && v != "" { var u nft.QuotaUsage if json.Unmarshal([]byte(v), &u) == nil { fw.RestoreQuotas(&u) } } } restoreQuotas() 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) } } // A flowtable device that appears later (ppp0 once PPPoE is up, // or again after it reconnects) is added by applying again. go func() { err := linkwatch.Watch(ctx, func() { host.Forget() if !fw.FlowtableStale(eng.State()) { return } if err := eng.ApplyCurrent(ctx); err != nil { a.log.Error("applying the ruleset again for the flowtable's devices failed", "err", err) return } a.log.Info("interfaces changed: applied the ruleset again, with the flowtable's devices that exist now") }) if err != nil { a.log.Warn("not watching the interfaces: a flowtable device that appears later is only added by the next change", "err", err) } }() // In shadow mode the other firewall's ruleset is reloaded now // and then, and a ruleset file usually starts with flush // ruleset, which deletes Housecarl's table too: put it back. if fw.Shadow { go func() { err := tablewatch.Watch(ctx, func() { if _, err := fw.Table(ctx); !errors.Is(err, nft.ErrNoTable) || eng.State().Empty() { return } // The quotas' usage went with the table: from // what was last saved. restoreQuotas() if err := eng.ApplyCurrent(ctx); err != nil { a.log.Error("Housecarl's table was deleted, and putting it back failed", "err", err) return } a.log.Info("Housecarl's table was deleted (flush ruleset?): put it back") }) if err != nil { a.log.Warn("not watching nftables: if Housecarl's table is deleted, only the next change or a restart puts it back", "err", err) } }() } go nflog.Listen(ctx, render.LogGroup, a.log, srv.LogPacket) go srv.RunQuotas(ctx) srv.SetConntrack(conntrack.Client{}) statsDone := startStats(ctx, a, fw, srv) // A trace left behind by a crash. fw.DeleteTable(ctx, "inet", trace.Table) 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") srv.SaveQuotaUsage(context.Background()) <-statsDone a.log.Info("stopped") return err }, } } // startStats opens the statistics database and runs the collector until // ctx ends; the channel closes once it wrote its last figures. func startStats(ctx context.Context, a *app, fw *nft.Firewall, srv *server.Server) <-chan struct{} { done := make(chan struct{}) c := a.cfg.Stats if !c.Enabled { close(done) return done } st, err := stats.OpenStore(c.Database) if err != nil { a.log.Error("statistics are off: opening their database failed (deleting it is safe)", "err", err) close(done) return done } opts := stats.Options{TopN: c.TopN, RetainMinute: time.Duration(c.Retention.Minute), RetainHour: time.Duration(c.Retention.Hour), RetainDay: time.Duration(c.Retention.Day)} // Tests sample more often than every minute. if v := os.Getenv("HOUSECARL_STATS_INTERVAL"); v != "" { opts.Interval, _ = time.ParseDuration(v) } src := stats.Sources{Table: fw.Table} srv.StatsSources(&src) col := stats.NewCollector(st, opts, src, a.log) srv.SetStats(col) go func() { defer close(done) defer st.Close() col.Run(ctx) }() return done } 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-