Files
2026-08-16 21:18:45 -05:00

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