Subnets served per interface, pools, reservations, options at three levels, leases in SQLite saved before the ACK, ping and decline conflict checks, validated changes with history and undo, users and tokens, an audit log, a Kea importer, a sandboxed systemd unit and Debian packages. Tested with real udhcpc and dhcpcd clients in Docker (scripts/e2e.sh). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
615 lines
18 KiB
Go
615 lines
18 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/config"
|
|
"steward/internal/dhcpd"
|
|
"steward/internal/engine"
|
|
"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)
|
|
}
|
|
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,
|
|
}, 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
|
|
}
|
|
|
|
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))
|
|
}
|
|
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)")
|
|
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()) },
|
|
}
|
|
}
|