initial commit
This commit is contained in:
@@ -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