initial commit
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
// Package cli implements the command line interface.
|
||||
//
|
||||
// `serve` is the default when no subcommand is given, so running the binary
|
||||
// with no arguments starts the server, which is the overwhelmingly common case.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/owen/vibedns/internal/config"
|
||||
"github.com/owen/vibedns/internal/version"
|
||||
)
|
||||
|
||||
// Command is one subcommand.
|
||||
type Command struct {
|
||||
Name string
|
||||
Summary string
|
||||
Usage string
|
||||
Run func(ctx context.Context, env *Env, args []string) error
|
||||
}
|
||||
|
||||
// Env carries what every command needs.
|
||||
type Env struct {
|
||||
Boot config.Bootstrap
|
||||
Stdout io.Writer
|
||||
Stderr io.Writer
|
||||
Log *slog.Logger
|
||||
}
|
||||
|
||||
// ExitError carries a specific process exit status.
|
||||
type ExitError struct {
|
||||
Code int
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *ExitError) Error() string { return e.Err.Error() }
|
||||
func (e *ExitError) Unwrap() error { return e.Err }
|
||||
|
||||
// Exit wraps an error with an exit code.
|
||||
func Exit(code int, format string, args ...any) error {
|
||||
return &ExitError{Code: code, Err: fmt.Errorf(format, args...)}
|
||||
}
|
||||
|
||||
// commands is the full command table.
|
||||
func commands() []*Command {
|
||||
return []*Command{
|
||||
serveCommand(),
|
||||
versionCommand(),
|
||||
configCommand(),
|
||||
adminCommand(),
|
||||
databaseCommand(),
|
||||
}
|
||||
}
|
||||
|
||||
// Main parses arguments and runs the selected command.
|
||||
func Main(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||
env := &Env{Boot: config.DefaultBootstrap(), Stdout: stdout, Stderr: stderr}
|
||||
|
||||
// Find the subcommand: the first argument that is not a flag. This lets
|
||||
// both `vibedns --db x serve` and `vibedns serve --db x` work.
|
||||
name := ""
|
||||
rest := args
|
||||
for i, a := range args {
|
||||
if !strings.HasPrefix(a, "-") {
|
||||
name = a
|
||||
rest = append(append([]string{}, args[:i]...), args[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
switch name {
|
||||
case "help", "-h", "--help", "":
|
||||
if name == "" && !wantsHelp(args) {
|
||||
// No subcommand: serve.
|
||||
return run(ctx, env, serveCommand(), args)
|
||||
}
|
||||
usage(stdout)
|
||||
return 0
|
||||
}
|
||||
|
||||
for _, c := range commands() {
|
||||
if c.Name == name {
|
||||
return run(ctx, env, c, rest)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(stderr, "unknown command %q\n\n", name)
|
||||
usage(stderr)
|
||||
return 2
|
||||
}
|
||||
|
||||
func wantsHelp(args []string) bool {
|
||||
for _, a := range args {
|
||||
if a == "-h" || a == "--help" || a == "help" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func run(ctx context.Context, env *Env, c *Command, args []string) int {
|
||||
err := c.Run(ctx, env, args)
|
||||
if err == nil {
|
||||
return 0
|
||||
}
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return 0
|
||||
}
|
||||
var ee *ExitError
|
||||
if errors.As(err, &ee) {
|
||||
fmt.Fprintf(env.Stderr, "%s: %v\n", c.Name, ee.Err)
|
||||
return ee.Code
|
||||
}
|
||||
fmt.Fprintf(env.Stderr, "%s: %v\n", c.Name, err)
|
||||
return 1
|
||||
}
|
||||
|
||||
func usage(w io.Writer) {
|
||||
fmt.Fprintf(w, `%s %s — authoritative DNS server, recursive resolver and filtering appliance
|
||||
|
||||
Usage:
|
||||
vibedns [command] [flags]
|
||||
|
||||
Commands:
|
||||
`, version.Name, version.Version)
|
||||
|
||||
for _, c := range commands() {
|
||||
fmt.Fprintf(w, " %-20s %s\n", c.Name, c.Summary)
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, `
|
||||
Running with no command starts the server.
|
||||
|
||||
Common flags:
|
||||
--db PATH SQLite database file (default %s)
|
||||
--http ADDR management interface address (default %s)
|
||||
--dns ADDR DNS listen address for UDP and TCP (default %s)
|
||||
--log-level LEVEL debug, info, warn or error
|
||||
--log-format FORMAT text or json
|
||||
|
||||
Environment variables:
|
||||
%-22s database file
|
||||
%-22s management interface address
|
||||
%-22s DNS listen address
|
||||
%-22s initial administrator username
|
||||
%-22s initial administrator password
|
||||
%-22s log level
|
||||
%-22s log format
|
||||
|
||||
Examples:
|
||||
vibedns
|
||||
vibedns serve --db /var/lib/vibedns/dns.db --http 127.0.0.1:8080
|
||||
vibedns config check
|
||||
vibedns admin reset-password
|
||||
vibedns database backup --output /var/backups
|
||||
vibedns version
|
||||
|
||||
`,
|
||||
config.DefaultDBPath, config.DefaultHTTPAddr, config.DefaultDNSAddr,
|
||||
config.EnvDBPath, config.EnvHTTPAddr, config.EnvDNSAddr,
|
||||
config.EnvAdminUsername, config.EnvAdminPassword,
|
||||
config.EnvLogLevel, config.EnvLogFormat)
|
||||
}
|
||||
|
||||
// newFlagSet builds a flag set that prints its own usage on error.
|
||||
func newFlagSet(env *Env, c *Command) *flag.FlagSet {
|
||||
fs := flag.NewFlagSet(c.Name, flag.ContinueOnError)
|
||||
fs.SetOutput(env.Stderr)
|
||||
fs.Usage = func() {
|
||||
fmt.Fprintf(env.Stderr, "%s\n\n", c.Usage)
|
||||
fs.PrintDefaults()
|
||||
}
|
||||
return fs
|
||||
}
|
||||
|
||||
// versionCommand prints build information.
|
||||
func versionCommand() *Command {
|
||||
return &Command{
|
||||
Name: "version",
|
||||
Summary: "print version and build information",
|
||||
Usage: "Usage: vibedns version",
|
||||
Run: func(ctx context.Context, env *Env, args []string) error {
|
||||
fmt.Fprint(env.Stdout, version.Long())
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// confirm asks for an interactive yes/no answer. It returns false when stdin
|
||||
// is not a terminal, so a piped invocation can never be silently destructive.
|
||||
func confirm(env *Env, prompt string) bool {
|
||||
fi, err := os.Stdin.Stat()
|
||||
if err != nil || (fi.Mode()&os.ModeCharDevice) == 0 {
|
||||
fmt.Fprintf(env.Stderr,
|
||||
"%s\nRefusing to continue without an interactive confirmation; pass --yes to proceed.\n", prompt)
|
||||
return false
|
||||
}
|
||||
fmt.Fprintf(env.Stdout, "%s [y/N]: ", prompt)
|
||||
var answer string
|
||||
_, _ = fmt.Fscanln(os.Stdin, &answer)
|
||||
answer = strings.ToLower(strings.TrimSpace(answer))
|
||||
return answer == "y" || answer == "yes"
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/owen/vibedns/internal/api"
|
||||
"github.com/owen/vibedns/internal/app"
|
||||
"github.com/owen/vibedns/internal/auth"
|
||||
"github.com/owen/vibedns/internal/backup"
|
||||
"github.com/owen/vibedns/internal/config"
|
||||
"github.com/owen/vibedns/internal/database"
|
||||
"github.com/owen/vibedns/internal/version"
|
||||
"github.com/owen/vibedns/internal/web"
|
||||
)
|
||||
|
||||
func serveCommand() *Command {
|
||||
return &Command{
|
||||
Name: "serve",
|
||||
Summary: "run the DNS server and management interface (default)",
|
||||
Usage: "Usage: vibedns serve [flags]",
|
||||
Run: runServe,
|
||||
}
|
||||
}
|
||||
|
||||
func runServe(ctx context.Context, env *Env, args []string) error {
|
||||
c := serveCommand()
|
||||
fs := newFlagSet(env, c)
|
||||
env.Boot.BindFlags(fs)
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
env.Boot.NoteFlagsSet(fs)
|
||||
|
||||
if err := env.Boot.Validate(); err != nil {
|
||||
return Exit(2, "%v", err)
|
||||
}
|
||||
|
||||
log := newLogger(env.Boot.LogLevel, env.Boot.LogFormat, env.Stderr)
|
||||
env.Log = log
|
||||
|
||||
// A staged restore is applied before anything opens the database, which is
|
||||
// the only moment it can be swapped safely.
|
||||
if _, err := backup.ApplyPendingRestore(env.Boot.DBPath, log); err != nil {
|
||||
return Exit(1, "could not apply the staged database restore: %v", err)
|
||||
}
|
||||
|
||||
db, err := database.Open(env.Boot.DBPath)
|
||||
if err != nil {
|
||||
return Exit(1, "%v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
applied, err := db.Migrate(ctx)
|
||||
if err != nil {
|
||||
return Exit(1, "%v", err)
|
||||
}
|
||||
if applied > 0 {
|
||||
log.Info("database migrations applied", "count", applied)
|
||||
}
|
||||
|
||||
application, err := app.New(ctx, env.Boot, db, log)
|
||||
if err != nil {
|
||||
return Exit(1, "%v", err)
|
||||
}
|
||||
|
||||
// Startup-critical addresses on the command line win over stored settings,
|
||||
// which is what makes a misconfigured listen address recoverable.
|
||||
if err := applyAddressOverrides(ctx, application, env.Boot); err != nil {
|
||||
return Exit(1, "%v", err)
|
||||
}
|
||||
|
||||
generated, err := ensureAdmin(ctx, application, env.Boot)
|
||||
if err != nil {
|
||||
return Exit(1, "%v", err)
|
||||
}
|
||||
|
||||
// Re-read the settings after any override, then check them before binding.
|
||||
settings := application.Settings()
|
||||
if err := settings.Validate(); err != nil {
|
||||
log.Warn("stored settings have a problem", "error", err)
|
||||
}
|
||||
|
||||
apiServer := api.New(application, log)
|
||||
webServer, err := web.New(web.Options{App: application, Log: log, API: apiServer.Handler()})
|
||||
if err != nil {
|
||||
return Exit(1, "could not prepare the management interface: %v", err)
|
||||
}
|
||||
|
||||
runCtx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
if err := application.Start(runCtx); err != nil {
|
||||
return Exit(1, "%v", err)
|
||||
}
|
||||
|
||||
httpAddr := settings.HTTP.Listen
|
||||
if err := webServer.Start(httpAddr); err != nil {
|
||||
_ = application.Shutdown(context.Background())
|
||||
return Exit(1, "%v", err)
|
||||
}
|
||||
|
||||
printBanner(env, application, settings, generated)
|
||||
|
||||
<-runCtx.Done()
|
||||
log.Info("shutting down")
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := webServer.Shutdown(shutdownCtx); err != nil {
|
||||
log.Warn("management interface did not stop cleanly", "error", err)
|
||||
}
|
||||
if err := application.Shutdown(shutdownCtx); err != nil {
|
||||
log.Warn("DNS server did not stop cleanly", "error", err)
|
||||
}
|
||||
if err := db.Checkpoint(shutdownCtx); err != nil {
|
||||
log.Warn("could not checkpoint the write-ahead log", "error", err)
|
||||
}
|
||||
log.Info("stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyAddressOverrides persists listen addresses supplied on the command line
|
||||
// or in the environment, so the running process and the stored configuration
|
||||
// agree about where it is listening.
|
||||
func applyAddressOverrides(ctx context.Context, a *app.App, boot config.Bootstrap) error {
|
||||
next := a.Settings()
|
||||
changed := false
|
||||
|
||||
if boot.DNSAddrOverridden() {
|
||||
if next.DNS.UDPListen != boot.DNSUDPAddr || next.DNS.TCPListen != boot.DNSTCPAddr {
|
||||
next.DNS.UDPListen = boot.DNSUDPAddr
|
||||
next.DNS.TCPListen = boot.DNSTCPAddr
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if boot.HTTPAddrOverridden() && next.HTTP.Listen != boot.HTTPAddr {
|
||||
next.HTTP.Listen = boot.HTTPAddr
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
|
||||
next.Normalise()
|
||||
if err := a.DB.SetSettings(ctx, next.ToMap()); err != nil {
|
||||
return fmt.Errorf("store the listen addresses: %w", err)
|
||||
}
|
||||
return a.Runtime.Reload(ctx)
|
||||
}
|
||||
|
||||
// ensureAdmin creates the administrator on first run, returning the generated
|
||||
// password when one had to be invented.
|
||||
func ensureAdmin(ctx context.Context, a *app.App, boot config.Bootstrap) (string, error) {
|
||||
username := boot.AdminUsername
|
||||
if username == "" {
|
||||
username = "admin"
|
||||
}
|
||||
password := boot.AdminPassword
|
||||
if password != "" {
|
||||
if err := auth.ValidatePassword(password); err != nil {
|
||||
return "", fmt.Errorf("the administrator password supplied in %s is unusable: %w",
|
||||
config.EnvAdminPassword, err)
|
||||
}
|
||||
}
|
||||
_, generated, err := a.Auth.EnsureAdmin(ctx, username, password)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create the administrator account: %w", err)
|
||||
}
|
||||
return generated, nil
|
||||
}
|
||||
|
||||
// printBanner writes the startup summary an operator reads once.
|
||||
func printBanner(env *Env, a *app.App, settings config.Settings, generatedPassword string) {
|
||||
w := env.Stdout
|
||||
admin, _ := a.Admin(context.Background())
|
||||
|
||||
scheme := "http"
|
||||
host := settings.HTTP.Listen
|
||||
if strings.HasPrefix(host, "0.0.0.0:") {
|
||||
host = "127.0.0.1:" + strings.TrimPrefix(host, "0.0.0.0:")
|
||||
} else if strings.HasPrefix(host, "[::]:") {
|
||||
host = "127.0.0.1:" + strings.TrimPrefix(host, "[::]:")
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "\n%s %s starting\n\n", version.Name, version.Version)
|
||||
fmt.Fprintf(w, " Database: %s\n", a.DB.Path())
|
||||
fmt.Fprintf(w, " DNS UDP: %s\n", settings.DNS.UDPListen)
|
||||
fmt.Fprintf(w, " DNS TCP: %s\n", settings.DNS.TCPListen)
|
||||
fmt.Fprintf(w, " Management: %s://%s\n", scheme, host)
|
||||
|
||||
snap := a.Snapshot()
|
||||
fmt.Fprintf(w, " Zones: %d (%d records)\n", snap.ZoneCount, snap.RecordCount)
|
||||
fmt.Fprintf(w, " Filtering: %d networks, %s blocked domains\n",
|
||||
snap.NetworkCount, formatCount(snap.BlacklistDomains))
|
||||
|
||||
if settings.DNS.Recursion {
|
||||
fmt.Fprintf(w, " Recursion: enabled for %d network(s), %d upstream(s)\n",
|
||||
len(settings.Resolver.AllowNetworks), len(settings.Resolver.Upstreams))
|
||||
} else {
|
||||
fmt.Fprintf(w, " Recursion: disabled (authoritative only)\n")
|
||||
}
|
||||
|
||||
if generatedPassword != "" {
|
||||
fmt.Fprintf(w, "\n Initial administrator:\n")
|
||||
fmt.Fprintf(w, " Username: %s\n", admin.Username)
|
||||
fmt.Fprintf(w, " Password: %s\n", generatedPassword)
|
||||
fmt.Fprintf(w, "\n This password will not be displayed again.\n")
|
||||
fmt.Fprintf(w, " Change it at %s://%s/account\n", scheme, host)
|
||||
}
|
||||
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
func formatCount(n int) string {
|
||||
s := fmt.Sprintf("%d", n)
|
||||
if n < 1000 {
|
||||
return s
|
||||
}
|
||||
var out []string
|
||||
for len(s) > 3 {
|
||||
out = append([]string{s[len(s)-3:]}, out...)
|
||||
s = s[:len(s)-3]
|
||||
}
|
||||
return strings.Join(append([]string{s}, out...), ",")
|
||||
}
|
||||
|
||||
// newLogger builds the structured logger.
|
||||
func newLogger(level, format string, w io.Writer) *slog.Logger {
|
||||
var lv slog.Level
|
||||
switch strings.ToLower(level) {
|
||||
case "debug":
|
||||
lv = slog.LevelDebug
|
||||
case "warn":
|
||||
lv = slog.LevelWarn
|
||||
case "error":
|
||||
lv = slog.LevelError
|
||||
default:
|
||||
lv = slog.LevelInfo
|
||||
}
|
||||
opts := &slog.HandlerOptions{Level: lv}
|
||||
|
||||
var h slog.Handler
|
||||
if strings.ToLower(format) == "json" {
|
||||
h = slog.NewJSONHandler(w, opts)
|
||||
} else {
|
||||
h = slog.NewTextHandler(w, opts)
|
||||
}
|
||||
return slog.New(h)
|
||||
}
|
||||
Reference in New Issue
Block a user