597 lines
16 KiB
Go
597 lines
16 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/owen/vibedns/internal/auditlog"
|
|
"github.com/owen/vibedns/internal/auth"
|
|
"github.com/owen/vibedns/internal/backup"
|
|
"github.com/owen/vibedns/internal/config"
|
|
"github.com/owen/vibedns/internal/database"
|
|
)
|
|
|
|
// --- config -------------------------------------------------------------
|
|
|
|
func configCommand() *Command {
|
|
return &Command{
|
|
Name: "config",
|
|
Summary: "inspect and validate configuration (check, show)",
|
|
Usage: "Usage: vibedns config <check|show> [flags]\n\n" +
|
|
" check validate the bootstrap and stored configuration\n" +
|
|
" show print the effective configuration",
|
|
Run: runConfig,
|
|
}
|
|
}
|
|
|
|
func runConfig(ctx context.Context, env *Env, args []string) error {
|
|
sub, rest := splitSub(args)
|
|
switch sub {
|
|
case "check", "":
|
|
return runConfigCheck(ctx, env, rest)
|
|
case "show":
|
|
return runConfigShow(ctx, env, rest)
|
|
default:
|
|
return Exit(2, "unknown subcommand %q; expected check or show", sub)
|
|
}
|
|
}
|
|
|
|
// runConfigCheck validates everything it can without binding a port, so it is
|
|
// safe to run on a live server and useful in a deployment pipeline.
|
|
func runConfigCheck(ctx context.Context, env *Env, args []string) error {
|
|
c := configCommand()
|
|
fs := newFlagSet(env, c)
|
|
env.Boot.BindFlags(fs)
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
|
|
w := env.Stdout
|
|
problems := 0
|
|
report := func(ok bool, label, detail string) {
|
|
if ok {
|
|
fmt.Fprintf(w, " ok %-28s %s\n", label, detail)
|
|
return
|
|
}
|
|
problems++
|
|
fmt.Fprintf(w, " FAIL %-28s %s\n", label, detail)
|
|
}
|
|
|
|
fmt.Fprintf(w, "Configuration check\n\n")
|
|
|
|
if err := env.Boot.Validate(); err != nil {
|
|
report(false, "startup options", err.Error())
|
|
} else {
|
|
report(true, "startup options", "valid")
|
|
}
|
|
|
|
if _, err := os.Stat(env.Boot.DBPath); err != nil {
|
|
report(true, "database", fmt.Sprintf("%s (will be created)", env.Boot.DBPath))
|
|
fmt.Fprintf(w, "\n%d problem(s) found.\n", problems)
|
|
if problems > 0 {
|
|
return Exit(1, "configuration check failed")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
db, err := database.Open(env.Boot.DBPath)
|
|
if err != nil {
|
|
report(false, "database", err.Error())
|
|
return Exit(1, "configuration check failed")
|
|
}
|
|
defer db.Close()
|
|
report(true, "database", env.Boot.DBPath)
|
|
|
|
statuses, err := db.MigrationStatuses(ctx)
|
|
if err != nil {
|
|
report(false, "migrations", err.Error())
|
|
} else {
|
|
pending, drift := 0, 0
|
|
for _, m := range statuses {
|
|
if !m.Applied {
|
|
pending++
|
|
}
|
|
if m.Drifted {
|
|
drift++
|
|
}
|
|
}
|
|
switch {
|
|
case drift > 0:
|
|
report(false, "migrations", fmt.Sprintf("%d applied migration(s) no longer match the embedded files", drift))
|
|
case pending > 0:
|
|
report(true, "migrations", fmt.Sprintf("%d pending, will apply on start", pending))
|
|
default:
|
|
report(true, "migrations", fmt.Sprintf("all %d applied", len(statuses)))
|
|
}
|
|
}
|
|
|
|
stored, err := db.Settings(ctx)
|
|
if err != nil {
|
|
report(false, "settings", err.Error())
|
|
return Exit(1, "configuration check failed")
|
|
}
|
|
settings := config.LoadSettings(stored)
|
|
if err := settings.Validate(); err != nil {
|
|
report(false, "settings", err.Error())
|
|
} else {
|
|
report(true, "settings", "valid")
|
|
}
|
|
|
|
// The open-resolver check is the one worth being loud about.
|
|
if settings.DNS.Recursion {
|
|
wide := widestAllowance(settings.Resolver.AllowNetworks)
|
|
if wide != "" {
|
|
report(false, "recursion ACL",
|
|
fmt.Sprintf("%s allows the entire Internet to use this server as a resolver", wide))
|
|
} else {
|
|
report(true, "recursion ACL",
|
|
fmt.Sprintf("%d network(s) permitted", len(settings.Resolver.AllowNetworks)))
|
|
}
|
|
} else {
|
|
report(true, "recursion", "disabled")
|
|
}
|
|
|
|
if _, err := db.Admin(ctx); errors.Is(err, database.ErrNotFound) {
|
|
report(true, "administrator", "not created yet, will be generated on first start")
|
|
} else if err != nil {
|
|
report(false, "administrator", err.Error())
|
|
} else {
|
|
report(true, "administrator", "present")
|
|
}
|
|
|
|
if settings.Backup.Enabled {
|
|
dir := settings.Backup.Directory
|
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
|
report(false, "backup directory", err.Error())
|
|
} else {
|
|
report(true, "backup directory", dir)
|
|
}
|
|
}
|
|
|
|
fmt.Fprintf(w, "\n%d problem(s) found.\n", problems)
|
|
if problems > 0 {
|
|
return Exit(1, "configuration check failed")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// widestAllowance returns the first ACL entry that covers the whole Internet.
|
|
func widestAllowance(allow []string) string {
|
|
for _, a := range allow {
|
|
switch strings.TrimSpace(a) {
|
|
case "0.0.0.0/0", "::/0":
|
|
return a
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func runConfigShow(ctx context.Context, env *Env, args []string) error {
|
|
c := configCommand()
|
|
fs := newFlagSet(env, c)
|
|
env.Boot.BindFlags(fs)
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
|
|
db, err := database.Open(env.Boot.DBPath)
|
|
if err != nil {
|
|
return Exit(1, "%v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
stored, err := db.Settings(ctx)
|
|
if err != nil {
|
|
return Exit(1, "%v", err)
|
|
}
|
|
settings := config.LoadSettings(stored)
|
|
|
|
keys := settings.ToMap()
|
|
names := make([]string, 0, len(keys))
|
|
for k := range keys {
|
|
names = append(names, k)
|
|
}
|
|
sortStrings(names)
|
|
|
|
fmt.Fprintf(env.Stdout, "# effective configuration for %s\n", env.Boot.DBPath)
|
|
for _, k := range names {
|
|
v := keys[k]
|
|
if strings.Contains(v, "\n") {
|
|
v = strings.ReplaceAll(v, "\n", ",")
|
|
}
|
|
fmt.Fprintf(env.Stdout, "%-32s %s\n", k, v)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- admin --------------------------------------------------------------
|
|
|
|
func adminCommand() *Command {
|
|
return &Command{
|
|
Name: "admin",
|
|
Summary: "manage the administrator account (reset-password, show)",
|
|
Usage: "Usage: vibedns admin <reset-password|show> [flags]\n\n" +
|
|
" reset-password generate or set a new administrator password\n" +
|
|
" show print the administrator username",
|
|
Run: runAdmin,
|
|
}
|
|
}
|
|
|
|
func runAdmin(ctx context.Context, env *Env, args []string) error {
|
|
sub, rest := splitSub(args)
|
|
switch sub {
|
|
case "reset-password":
|
|
return runResetPassword(ctx, env, rest)
|
|
case "show":
|
|
return runAdminShow(ctx, env, rest)
|
|
default:
|
|
return Exit(2, "unknown subcommand %q; expected reset-password or show", sub)
|
|
}
|
|
}
|
|
|
|
// runResetPassword is the recovery path for a lost password. It requires
|
|
// filesystem access to the database, which is the only credential it can
|
|
// sensibly demand.
|
|
func runResetPassword(ctx context.Context, env *Env, args []string) error {
|
|
c := adminCommand()
|
|
fs := newFlagSet(env, c)
|
|
env.Boot.BindFlags(fs)
|
|
password := fs.String("password", "",
|
|
"new password (omit to generate one; prefer omitting, since arguments are visible in the process list)")
|
|
username := fs.String("username", "", "also change the username")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
|
|
db, err := database.Open(env.Boot.DBPath)
|
|
if err != nil {
|
|
return Exit(1, "%v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
if _, err := db.Migrate(ctx); err != nil {
|
|
return Exit(1, "%v", err)
|
|
}
|
|
|
|
admin, err := db.Admin(ctx)
|
|
if errors.Is(err, database.ErrNotFound) {
|
|
return Exit(1, "no administrator account exists yet; start the server once to create one")
|
|
}
|
|
if err != nil {
|
|
return Exit(1, "%v", err)
|
|
}
|
|
|
|
newPassword := *password
|
|
generated := false
|
|
if newPassword == "" {
|
|
newPassword, err = auth.GeneratePassword(20)
|
|
if err != nil {
|
|
return Exit(1, "%v", err)
|
|
}
|
|
generated = true
|
|
}
|
|
if err := auth.ValidatePassword(newPassword); err != nil {
|
|
return Exit(2, "%v", err)
|
|
}
|
|
|
|
name := admin.Username
|
|
if *username != "" {
|
|
if err := config.ValidateUsername(*username); err != nil {
|
|
return Exit(2, "%v", err)
|
|
}
|
|
name = *username
|
|
}
|
|
|
|
hash, err := auth.HashPassword(newPassword)
|
|
if err != nil {
|
|
return Exit(1, "%v", err)
|
|
}
|
|
// The reset flag is set so the UI keeps prompting until a human picks a
|
|
// password of their own.
|
|
if err := db.UpdateAdminCredentials(ctx, name, hash, generated); err != nil {
|
|
return Exit(1, "%v", err)
|
|
}
|
|
|
|
log := newLogger("error", "text", env.Stderr)
|
|
audit := auditlog.New(db, log)
|
|
audit.Record(ctx, auditlog.CLIActor(), "admin.password_reset", auditlog.ObjectAdmin, "1", name,
|
|
"password reset from the command line")
|
|
|
|
fmt.Fprintf(env.Stdout, "\nAdministrator credentials updated.\n\n")
|
|
fmt.Fprintf(env.Stdout, " Username: %s\n", name)
|
|
if generated {
|
|
fmt.Fprintf(env.Stdout, " Password: %s\n", newPassword)
|
|
fmt.Fprintf(env.Stdout, "\nThis password will not be displayed again.\n")
|
|
} else {
|
|
fmt.Fprintf(env.Stdout, " Password: (as supplied)\n")
|
|
}
|
|
fmt.Fprintf(env.Stdout, "\nRestart the server, or wait a few minutes, for the change to take effect\n"+
|
|
"on sessions that authenticated with the old password.\n\n")
|
|
return nil
|
|
}
|
|
|
|
func runAdminShow(ctx context.Context, env *Env, args []string) error {
|
|
c := adminCommand()
|
|
fs := newFlagSet(env, c)
|
|
env.Boot.BindFlags(fs)
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
|
|
db, err := database.Open(env.Boot.DBPath)
|
|
if err != nil {
|
|
return Exit(1, "%v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
admin, err := db.Admin(ctx)
|
|
if errors.Is(err, database.ErrNotFound) {
|
|
return Exit(1, "no administrator account exists yet")
|
|
}
|
|
if err != nil {
|
|
return Exit(1, "%v", err)
|
|
}
|
|
|
|
fmt.Fprintf(env.Stdout, "Username: %s\n", admin.Username)
|
|
fmt.Fprintf(env.Stdout, "Created: %s\n", admin.CreatedAt.Format("2006-01-02 15:04:05"))
|
|
fmt.Fprintf(env.Stdout, "Updated: %s\n", admin.UpdatedAt.Format("2006-01-02 15:04:05"))
|
|
if admin.LastLoginAt != nil {
|
|
fmt.Fprintf(env.Stdout, "Last sign-in: %s\n", admin.LastLoginAt.Format("2006-01-02 15:04:05"))
|
|
} else {
|
|
fmt.Fprintf(env.Stdout, "Last sign-in: never\n")
|
|
}
|
|
fmt.Fprintf(env.Stdout, "Must change password: %t\n", admin.MustChangePassword)
|
|
return nil
|
|
}
|
|
|
|
// --- database -----------------------------------------------------------
|
|
|
|
func databaseCommand() *Command {
|
|
return &Command{
|
|
Name: "database",
|
|
Summary: "database maintenance (migrate, backup, restore, vacuum, stats)",
|
|
Usage: "Usage: vibedns database <migrate|backup|restore|vacuum|stats> [flags]\n\n" +
|
|
" migrate apply pending schema migrations\n" +
|
|
" backup write a consistent backup copy\n" +
|
|
" restore stage a backup to be applied on the next start\n" +
|
|
" vacuum reclaim space after large deletions\n" +
|
|
" stats print size and row counts",
|
|
Run: runDatabase,
|
|
}
|
|
}
|
|
|
|
func runDatabase(ctx context.Context, env *Env, args []string) error {
|
|
sub, rest := splitSub(args)
|
|
switch sub {
|
|
case "migrate":
|
|
return runMigrate(ctx, env, rest)
|
|
case "backup":
|
|
return runBackup(ctx, env, rest)
|
|
case "restore":
|
|
return runRestore(ctx, env, rest)
|
|
case "vacuum":
|
|
return runVacuum(ctx, env, rest)
|
|
case "stats":
|
|
return runDBStats(ctx, env, rest)
|
|
default:
|
|
return Exit(2, "unknown subcommand %q; expected migrate, backup, restore, vacuum or stats", sub)
|
|
}
|
|
}
|
|
|
|
func openDB(env *Env, fs interface{ Parse([]string) error }, args []string) (*database.DB, error) {
|
|
if err := fs.Parse(args); err != nil {
|
|
return nil, err
|
|
}
|
|
db, err := database.Open(env.Boot.DBPath)
|
|
if err != nil {
|
|
return nil, Exit(1, "%v", err)
|
|
}
|
|
return db, nil
|
|
}
|
|
|
|
func runMigrate(ctx context.Context, env *Env, args []string) error {
|
|
c := databaseCommand()
|
|
fs := newFlagSet(env, c)
|
|
env.Boot.BindFlags(fs)
|
|
db, err := openDB(env, fs, args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer db.Close()
|
|
|
|
before, _ := db.SchemaVersion(ctx)
|
|
applied, err := db.Migrate(ctx)
|
|
if err != nil {
|
|
return Exit(1, "%v", err)
|
|
}
|
|
after, _ := db.SchemaVersion(ctx)
|
|
|
|
if applied == 0 {
|
|
fmt.Fprintf(env.Stdout, "Database is already at schema version %d; nothing to do.\n", after)
|
|
return nil
|
|
}
|
|
fmt.Fprintf(env.Stdout, "Applied %d migration(s): schema version %d to %d.\n", applied, before, after)
|
|
return nil
|
|
}
|
|
|
|
func runBackup(ctx context.Context, env *Env, args []string) error {
|
|
c := databaseCommand()
|
|
fs := newFlagSet(env, c)
|
|
env.Boot.BindFlags(fs)
|
|
output := fs.String("output", "", "directory to write the backup into (defaults to the configured one)")
|
|
db, err := openDB(env, fs, args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer db.Close()
|
|
|
|
if _, err := db.Migrate(ctx); err != nil {
|
|
return Exit(1, "%v", err)
|
|
}
|
|
|
|
stored, err := db.Settings(ctx)
|
|
if err != nil {
|
|
return Exit(1, "%v", err)
|
|
}
|
|
settings := config.LoadSettings(stored)
|
|
|
|
dir := *output
|
|
if dir == "" {
|
|
dir = settings.Backup.Directory
|
|
}
|
|
if dir == "" {
|
|
dir = filepath.Join(filepath.Dir(env.Boot.DBPath), "backups")
|
|
}
|
|
|
|
log := newLogger("error", "text", env.Stderr)
|
|
mgr := backup.New(db, log, backup.Config{
|
|
Enabled: true,
|
|
Directory: dir,
|
|
IntervalHours: settings.Backup.IntervalHours,
|
|
Retention: settings.Backup.Retention,
|
|
})
|
|
|
|
info, err := mgr.Run(ctx)
|
|
if err != nil {
|
|
return Exit(1, "%v", err)
|
|
}
|
|
|
|
audit := auditlog.New(db, log)
|
|
audit.Record(ctx, auditlog.CLIActor(), "backup.create", auditlog.ObjectBackup, info.Name, info.Name,
|
|
fmt.Sprintf("bytes=%d", info.SizeBytes))
|
|
|
|
fmt.Fprintf(env.Stdout, "Backup written to %s (%.1f MB).\n", info.Path, info.SizeMB())
|
|
return nil
|
|
}
|
|
|
|
func runRestore(ctx context.Context, env *Env, args []string) error {
|
|
c := databaseCommand()
|
|
fs := newFlagSet(env, c)
|
|
env.Boot.BindFlags(fs)
|
|
file := fs.String("file", "", "backup file to restore from (required)")
|
|
yes := fs.Bool("yes", false, "skip the interactive confirmation")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
if *file == "" {
|
|
return Exit(2, "--file is required; pass the path to a backup produced by `database backup`")
|
|
}
|
|
|
|
if err := backup.Verify(*file); err != nil {
|
|
return Exit(1, "%v", err)
|
|
}
|
|
|
|
prompt := fmt.Sprintf(
|
|
"Restoring %s will replace every zone, record, policy and setting in %s.\n"+
|
|
"The current database is preserved alongside it. Continue?",
|
|
*file, env.Boot.DBPath)
|
|
if !*yes && !confirm(env, prompt) {
|
|
fmt.Fprintln(env.Stdout, "Restore cancelled.")
|
|
return nil
|
|
}
|
|
|
|
if err := backup.StageRestore(env.Boot.DBPath, *file); err != nil {
|
|
return Exit(1, "%v", err)
|
|
}
|
|
fmt.Fprintf(env.Stdout,
|
|
"Restore staged. It is applied the next time the server starts.\n"+
|
|
"Restart the service now to complete it.\n")
|
|
return nil
|
|
}
|
|
|
|
func runVacuum(ctx context.Context, env *Env, args []string) error {
|
|
c := databaseCommand()
|
|
fs := newFlagSet(env, c)
|
|
env.Boot.BindFlags(fs)
|
|
db, err := openDB(env, fs, args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer db.Close()
|
|
|
|
before, _ := db.Stats(ctx)
|
|
if err := db.Checkpoint(ctx); err != nil {
|
|
return Exit(1, "checkpoint the write-ahead log: %v", err)
|
|
}
|
|
if err := db.Vacuum(ctx); err != nil {
|
|
return Exit(1, "vacuum: %v", err)
|
|
}
|
|
after, _ := db.Stats(ctx)
|
|
|
|
fmt.Fprintf(env.Stdout, "Vacuum complete: %s reclaimed (%s to %s).\n",
|
|
humanSize(before.SizeBytes-after.SizeBytes),
|
|
humanSize(before.SizeBytes), humanSize(after.SizeBytes))
|
|
return nil
|
|
}
|
|
|
|
func runDBStats(ctx context.Context, env *Env, args []string) error {
|
|
c := databaseCommand()
|
|
fs := newFlagSet(env, c)
|
|
env.Boot.BindFlags(fs)
|
|
db, err := openDB(env, fs, args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer db.Close()
|
|
|
|
s, err := db.Stats(ctx)
|
|
if err != nil {
|
|
return Exit(1, "%v", err)
|
|
}
|
|
|
|
fmt.Fprintf(env.Stdout, "Path: %s\n", s.Path)
|
|
fmt.Fprintf(env.Stdout, "Size: %s\n", humanSize(s.SizeBytes))
|
|
fmt.Fprintf(env.Stdout, "Write-ahead log: %s\n", humanSize(s.WALBytes))
|
|
fmt.Fprintf(env.Stdout, "Schema version: %d\n", s.SchemaVer)
|
|
fmt.Fprintf(env.Stdout, "Free pages: %d of %d\n", s.FreePages, s.PageCount)
|
|
fmt.Fprintf(env.Stdout, "\nZones: %d\n", s.Zones)
|
|
fmt.Fprintf(env.Stdout, "Records: %d\n", s.Records)
|
|
fmt.Fprintf(env.Stdout, "List domains: %d\n", s.Domains)
|
|
fmt.Fprintf(env.Stdout, "Query log rows: %d\n", s.QueryLogs)
|
|
fmt.Fprintf(env.Stdout, "Audit log rows: %d\n", s.AuditLogs)
|
|
fmt.Fprintf(env.Stdout, "API tokens: %d\n", s.APITokens)
|
|
return nil
|
|
}
|
|
|
|
// --- helpers ------------------------------------------------------------
|
|
|
|
// splitSub pulls the second-level subcommand out of the argument list.
|
|
func splitSub(args []string) (string, []string) {
|
|
for i, a := range args {
|
|
if !strings.HasPrefix(a, "-") {
|
|
return a, append(append([]string{}, args[:i]...), args[i+1:]...)
|
|
}
|
|
}
|
|
return "", args
|
|
}
|
|
|
|
func sortStrings(s []string) {
|
|
for i := 1; i < len(s); i++ {
|
|
for j := i; j > 0 && s[j] < s[j-1]; j-- {
|
|
s[j], s[j-1] = s[j-1], s[j]
|
|
}
|
|
}
|
|
}
|
|
|
|
func humanSize(b int64) string {
|
|
const unit = 1024
|
|
if b < 0 {
|
|
return "0 B"
|
|
}
|
|
if b < unit {
|
|
return fmt.Sprintf("%d B", b)
|
|
}
|
|
f := float64(b)
|
|
for _, u := range []string{"KB", "MB", "GB", "TB"} {
|
|
f /= unit
|
|
if f < unit {
|
|
return fmt.Sprintf("%.1f %s", f, u)
|
|
}
|
|
}
|
|
return fmt.Sprintf("%.1f PB", f)
|
|
}
|