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 [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 [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 [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) }