- interfaces.exclude in steward.yaml (names or shell patterns) leaves interfaces out of GET /interfaces, the web UI's picker and 'steward interface list'. Subnets on them are still served. - With the ping check on, active leases are pinged every minute; a client that answers gets a last-seen time (leases.last_seen, migration 4). Not answering never takes a client offline. - The activity log filters by subnet (--subnet, the Activity page, GET /events?subnet=). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
658 lines
19 KiB
Go
658 lines
19 KiB
Go
// Command stewardd is Steward's daemon: the DHCP server, its 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"
|
|
|
|
"steward/internal/auth"
|
|
"steward/internal/certs"
|
|
"steward/internal/config"
|
|
"steward/internal/dhcpd"
|
|
"steward/internal/engine"
|
|
"steward/internal/jarl"
|
|
"steward/internal/model"
|
|
"steward/internal/server"
|
|
"steward/internal/store"
|
|
"steward/internal/validate"
|
|
"steward/internal/version"
|
|
"steward/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: "stewardd",
|
|
Short: "Steward's DHCP server, 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 *dhcpd.HostInterfaces) 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: "Serve DHCP, 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 'stewardd init' (root can use 'steward' on the local socket meanwhile)")
|
|
}
|
|
eng, err := engine.New(ctx, a.store, a.log)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
host := &dhcpd.HostInterfaces{}
|
|
eng.Env = hostEnv(host)
|
|
pinger := &dhcpd.ICMPPinger{Log: a.log}
|
|
d := dhcpd.New(a.log, a.store, host, pinger)
|
|
if err := d.LoadLeases(ctx); err != nil {
|
|
return fmt.Errorf("reading the leases: %w", err)
|
|
}
|
|
d.SetConfig(eng.State())
|
|
eng.OnChange = d.SetConfig
|
|
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, PingProblem: pinger.Problem, ACME: acme,
|
|
ExcludeInterfaces: a.cfg.Interfaces.Exclude,
|
|
}, eng, d, host, a.log, web.FS())
|
|
go d.Run(ctx)
|
|
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 Steward's database with a first administrator.
|
|
|
|
Steward starts with no subnets: add them with 'steward subnet add', in the
|
|
web UI, or import a Kea configuration with 'steward import kea'.`,
|
|
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("Steward is already set up (%d users); add users with 'stewardd user add' or 'steward 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 stewardd 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 steward")
|
|
fmt.Println(" 2. Add a subnet: sudo steward subnet add 10.0.10.0/24 --interface eth0 --pool 10.0.10.100-10.0.10.199 --option routers=10.0.10.1")
|
|
fmt.Println(" or import Kea's setup: sudo steward import kea /etc/kea/kea-dhcp4.conf --leases /var/lib/kea/kea-leases4.csv")
|
|
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(&dhcpd.HostInterfaces{})
|
|
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 = "steward-", ".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 stewardd runs. Without
|
|
FILE it goes to backup.dir as steward-<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 'stewardd 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 (stewardd 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 steward", 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 Steward 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 Steward (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("stewardd", version.String()) },
|
|
}
|
|
}
|