Reverse lookups (internal/names, POST /names) put a name beside each inside address in connections, the log, quota usage, the dashboard's quotas and the statistics' devices and blocked sources, in the web UI and hc (--no-names to leave them out). Only inside addresses are looked up: an inside zone's networks, and private ones outside the external zones'. Names are cached (names.cache, 10m) and never stored; names.resolver picks the DNS server. Also: hc stats top devices listed each device twice; the dashboard's quotas named the rule's ID twice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
776 lines
24 KiB
Go
776 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/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-<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()) },
|
|
}
|
|
}
|