shadow.enabled in housecarl.yaml loads Housecarl's ruleset so that it blocks, rejects and translates nothing: drops and rejects count and accept (an accept only ends Housecarl's chain), policies accept and log what they would drop, NAT only counts, MSS clamping and the flowtable are left out, and raw rules that do more than accept, drop or reject are left out with a warning. Comments mark what would happen ([would drop]). Filter chains run after the other firewall's (+10) or before (-10); NAT chains always first. Every change is checked by nftables in both forms. No probation; takeovers and closing connections are refused. The boot fallback filters nothing. internal/tablewatch puts the table back after a flush ruleset. Mode in status, healthz, whoami, the web UI, the log and the tracer; hc ruleset --enforcing. docs/shadow.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
771 lines
24 KiB
Go
771 lines
24 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"
|
|
"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/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)
|
|
}
|
|
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,
|
|
}, 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-<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()) },
|
|
}
|
|
}
|