initial commit

This commit is contained in:
2026-08-16 21:18:45 -05:00
commit 1e05a01bcf
122 changed files with 29178 additions and 0 deletions
+237
View File
@@ -0,0 +1,237 @@
package app
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/miekg/dns"
"github.com/owen/vibedns/internal/auditlog"
"github.com/owen/vibedns/internal/auth"
"github.com/owen/vibedns/internal/config"
"github.com/owen/vibedns/internal/database"
"github.com/owen/vibedns/internal/models"
"github.com/owen/vibedns/internal/resolver"
)
// Admin returns the administrator account.
func (a *App) Admin(ctx context.Context) (models.Admin, error) {
admin, err := a.DB.Admin(ctx)
if errors.Is(err, database.ErrNotFound) {
return admin, NotFound("No administrator account exists.")
}
if err != nil {
return admin, Internal(err, "The administrator account could not be loaded.")
}
return admin, nil
}
// ChangeCredentials updates the administrator username and/or password.
//
// The current password is always required: knowing the session is
// authenticated is not enough, because HTTP Basic credentials are replayed by
// the browser and a stolen session should not be able to lock out the owner.
func (a *App) ChangeCredentials(ctx context.Context, actor auditlog.Actor,
currentPassword, newUsername, newPassword, confirmPassword string) error {
admin, err := a.Admin(ctx)
if err != nil {
return err
}
ok, verr := auth.VerifyPassword(admin.PasswordHash, currentPassword)
if verr != nil {
return Internal(verr, "The stored password could not be verified.")
}
if !ok {
return Forbidden("The current password is incorrect.")
}
username := strings.TrimSpace(newUsername)
if username == "" {
username = admin.Username
}
if err := config.ValidateUsername(username); err != nil {
return Invalid("%s", err.Error())
}
hash := admin.PasswordHash
passwordChanged := false
if newPassword != "" {
if newPassword != confirmPassword {
return Invalid("The new passwords do not match.")
}
if err := auth.ValidatePassword(newPassword); err != nil {
return Invalid("%s", err.Error())
}
if newPassword == currentPassword {
return Invalid("The new password must differ from the current one.")
}
hash, err = auth.HashPassword(newPassword)
if err != nil {
return Internal(err, "The new password could not be stored.")
}
passwordChanged = true
} else if auth.NeedsRehash(admin.PasswordHash) {
// Take the opportunity to upgrade an old hash while we have the
// plaintext in hand.
if h, herr := auth.HashPassword(currentPassword); herr == nil {
hash = h
}
}
if username == admin.Username && !passwordChanged {
return Invalid("Nothing was changed.")
}
if err := a.DB.UpdateAdminCredentials(ctx, username, hash, false); err != nil {
return Internal(err, "The credentials could not be saved.")
}
// The old password must stop working immediately.
a.Auth.InvalidateCredentials()
what := []string{}
if username != admin.Username {
what = append(what, "username")
}
if passwordChanged {
what = append(what, "password")
}
a.Audit.Record(ctx, actor, "admin.credentials_changed", auditlog.ObjectAdmin, "1", username,
auditlog.Changes("changed", strings.Join(what, " and ")))
return nil
}
// --- API tokens ---------------------------------------------------------
// APITokens lists every token. Secrets are never included.
func (a *App) APITokens(ctx context.Context) ([]models.APIToken, error) {
tokens, err := a.DB.APITokens(ctx)
if err != nil {
return nil, Internal(err, "The API tokens could not be loaded.")
}
return tokens, nil
}
// CreateAPIToken mints a token. The secret is returned once and never stored.
func (a *App) CreateAPIToken(ctx context.Context, actor auditlog.Actor, name, description string) (models.APIToken, error) {
name = strings.TrimSpace(name)
if name == "" {
return models.APIToken{}, Invalid("A token name is required.")
}
if len(name) > 100 {
return models.APIToken{}, Invalid("The token name must be 100 characters or fewer.")
}
tok, err := auth.GenerateToken()
if err != nil {
return models.APIToken{}, Internal(err, "The token could not be generated.")
}
created, err := a.DB.CreateAPIToken(ctx, name, strings.TrimSpace(description), tok.Prefix, tok.Hash)
if err != nil {
return models.APIToken{}, translate(err, "Token not found.",
fmt.Sprintf("An API token named %q already exists.", name))
}
created.Secret = tok.Secret
// The audit entry records that a token was created, never its value.
a.Audit.RecordID(ctx, actor, "token.create", auditlog.ObjectToken, created.ID, name,
auditlog.Changes("prefix", tok.Prefix))
return created, nil
}
// SetAPITokenEnabled enables or disables a token.
func (a *App) SetAPITokenEnabled(ctx context.Context, actor auditlog.Actor, id int64, enabled bool) error {
tok, err := a.DB.APIToken(ctx, id)
if err != nil {
return translate(err, fmt.Sprintf("API token %d was not found.", id), "")
}
if err := a.DB.SetAPITokenEnabled(ctx, id, enabled); err != nil {
return translate(err, fmt.Sprintf("API token %d was not found.", id), "")
}
action := "token.disable"
if enabled {
action = "token.enable"
}
a.Audit.RecordID(ctx, actor, action, auditlog.ObjectToken, id, tok.Name, "")
return nil
}
// DeleteAPIToken revokes a token permanently.
func (a *App) DeleteAPIToken(ctx context.Context, actor auditlog.Actor, id int64) error {
tok, err := a.DB.APIToken(ctx, id)
if err != nil {
return translate(err, fmt.Sprintf("API token %d was not found.", id), "")
}
if err := a.DB.DeleteAPIToken(ctx, id); err != nil {
return translate(err, fmt.Sprintf("API token %d was not found.", id), "")
}
a.Audit.RecordID(ctx, actor, "token.revoke", auditlog.ObjectToken, id, tok.Name, "")
return nil
}
// --- Diagnostics --------------------------------------------------------
// resolverCheck is a thin seam so settings.go can probe an upstream without
// importing the resolver package itself.
func resolverCheck(ctx context.Context, addr, qname string, s config.Settings) (time.Duration, string, error) {
timeout := time.Duration(s.Resolver.TimeoutMS) * time.Millisecond
return resolver.Check(ctx, addr, qname, timeout)
}
// LookupResult is the outcome of the UI's built-in query tool.
type LookupResult struct {
Question string `json:"question"`
Rcode string `json:"rcode"`
Source string `json:"source"`
Answers []string `json:"answers"`
Authority []string `json:"authority"`
Duration time.Duration `json:"duration"`
}
// Lookup runs a query through the full server pipeline, exactly as a client on
// the given address would experience it.
func (a *App) Lookup(ctx context.Context, name, qtype, clientIP string, dnssec bool) (*LookupResult, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, Invalid("Enter a name to look up.")
}
fqdn := dns.Fqdn(name)
t, ok := dns.StringToType[strings.ToUpper(strings.TrimSpace(qtype))]
if !ok {
if qtype == "" {
t = dns.TypeA
} else {
return nil, Invalid("%q is not a known record type.", qtype)
}
}
client, ok := netipAddr(clientIP)
if !ok {
return nil, Invalid("%q is not a valid client IP address.", clientIP)
}
start := time.Now()
msg, source, err := a.DNS.Resolve(ctx, fqdn, t, client, dnssec)
if err != nil {
return nil, Internal(err, "The lookup could not be completed.")
}
res := &LookupResult{
Question: fmt.Sprintf("%s %s", fqdn, dns.TypeToString[t]),
Rcode: dns.RcodeToString[msg.Rcode],
Source: source,
Duration: time.Since(start),
}
for _, rr := range msg.Answer {
res.Answers = append(res.Answers, rr.String())
}
for _, rr := range msg.Ns {
res.Authority = append(res.Authority, rr.String())
}
return res, nil
}
+320
View File
@@ -0,0 +1,320 @@
package app
import (
"context"
"encoding/base64"
"errors"
"fmt"
"log/slog"
"os"
"time"
"github.com/owen/vibedns/internal/auditlog"
"github.com/owen/vibedns/internal/auth"
"github.com/owen/vibedns/internal/backup"
"github.com/owen/vibedns/internal/cache"
"github.com/owen/vibedns/internal/config"
"github.com/owen/vibedns/internal/database"
"github.com/owen/vibedns/internal/dnsengine"
"github.com/owen/vibedns/internal/metrics"
"github.com/owen/vibedns/internal/querylog"
"github.com/owen/vibedns/internal/ratelimit"
"github.com/owen/vibedns/internal/resolver"
"github.com/owen/vibedns/internal/runtimecfg"
"github.com/owen/vibedns/internal/version"
)
// keyCSRFSecret stores the CSRF signing key so tokens survive a restart.
const keyCSRFSecret = "security.csrf_key"
// App wires every component together and exposes the operations the
// management interface performs.
type App struct {
Boot config.Bootstrap
DB *database.DB
Runtime *runtimecfg.Manager
Cache *cache.Cache
Resolver *resolver.Resolver
Limiter *ratelimit.Limiter
Metrics *metrics.Metrics
QueryLog *querylog.Logger
Audit *auditlog.Logger
Auth *auth.Authenticator
Backups *backup.Manager
DNS *dnsengine.Server
Log *slog.Logger
cancel context.CancelFunc
started time.Time
}
// New builds the application. The database must already be migrated.
func New(ctx context.Context, boot config.Bootstrap, db *database.DB, log *slog.Logger) (*App, error) {
rt, err := runtimecfg.New(ctx, db, log)
if err != nil {
return nil, fmt.Errorf("build the initial configuration snapshot: %w", err)
}
settings := rt.Settings()
csrfKey, err := loadOrCreateCSRFKey(ctx, db)
if err != nil {
return nil, err
}
a := &App{
Boot: boot,
DB: db,
Runtime: rt,
Log: log,
started: time.Now(),
}
a.Metrics = metrics.New(version.Version)
a.Cache = cache.New(cacheConfig(settings))
a.Resolver = resolver.New(resolverConfig(settings))
a.Limiter = ratelimit.New(rateLimitConfig(settings))
a.QueryLog = querylog.New(db, log, queryLogConfig(settings))
a.Audit = auditlog.New(db, log)
a.Auth = auth.New(db, log, csrfKey)
a.Auth.SetTrustedProxies(settings.HTTP.TrustedProxies)
a.Backups = backup.New(db, log, backupConfig(settings))
a.DNS = dnsengine.New(dnsengine.Options{
Runtime: rt,
Cache: a.Cache,
Resolver: a.Resolver,
Limiter: a.Limiter,
Metrics: a.Metrics,
QueryLog: a.QueryLog,
Log: log,
})
// Every subsystem picks up new settings from the same reload event, so a
// change in the UI takes effect without a restart.
rt.OnReload(func(s *runtimecfg.Snapshot) {
a.Cache.SetConfig(cacheConfig(s.Settings))
a.Resolver.SetConfig(resolverConfig(s.Settings))
a.Limiter.SetConfig(rateLimitConfig(s.Settings))
a.QueryLog.SetConfig(queryLogConfig(s.Settings))
a.Backups.SetConfig(backupConfig(s.Settings))
a.Auth.SetTrustedProxies(s.Settings.HTTP.TrustedProxies)
})
a.Metrics.SetGaugeSource(a.gauges)
return a, nil
}
func cacheConfig(s config.Settings) cache.Config {
return cache.Config{
Enabled: s.Cache.Enabled,
MaxEntries: s.Cache.MaxEntries,
MinTTL: uint32(s.Cache.MinTTL),
MaxTTL: uint32(s.Cache.MaxTTL),
NegativeTTL: uint32(s.Cache.NegativeTTL),
ServeStale: s.Cache.ServeStale,
StaleTTL: uint32(s.Cache.StaleTTL),
Prefetch: s.Cache.Prefetch,
PrefetchPercent: s.Cache.PrefetchPercent,
}
}
func resolverConfig(s config.Settings) resolver.Config {
return resolver.Config{
Upstreams: s.Resolver.Upstreams,
Timeout: time.Duration(s.Resolver.TimeoutMS) * time.Millisecond,
Retries: s.Resolver.Retries,
Strategy: s.Resolver.Strategy,
DNSSEC: s.Resolver.DNSSEC,
EDNSUDPSize: uint16(s.DNS.EDNSUDPSize),
MaxConcurrent: s.Resolver.MaxConcurrent,
}
}
func rateLimitConfig(s config.Settings) ratelimit.Config {
return ratelimit.Config{
Enabled: s.RateLimit.Enabled,
QPS: s.RateLimit.QPS,
Burst: s.RateLimit.Burst,
Exempt: s.RateLimit.ExemptNetworks,
}
}
func queryLogConfig(s config.Settings) querylog.Config {
return querylog.Config{
Enabled: s.QueryLog.Enabled,
RetentionDays: s.QueryLog.RetentionDays,
MaxRows: s.QueryLog.MaxRows,
CleanupMinutes: s.QueryLog.CleanupMinutes,
IgnoreNetworks: s.QueryLog.IgnoreNetworks,
IgnoreDomains: s.QueryLog.IgnoreDomains,
}
}
func backupConfig(s config.Settings) backup.Config {
return backup.Config{
Enabled: s.Backup.Enabled,
Directory: s.Backup.Directory,
IntervalHours: s.Backup.IntervalHours,
Retention: s.Backup.Retention,
}
}
// loadOrCreateCSRFKey fetches the persisted CSRF signing key, creating one on
// first run. Persisting it means tokens in open browser tabs survive a restart.
func loadOrCreateCSRFKey(ctx context.Context, db *database.DB) ([]byte, error) {
if v, ok, err := db.Setting(ctx, keyCSRFSecret); err != nil {
return nil, fmt.Errorf("read the CSRF signing key: %w", err)
} else if ok && v != "" {
key, err := base64.RawStdEncoding.DecodeString(v)
if err == nil && len(key) >= 32 {
return key, nil
}
}
encoded, err := auth.RandomKey(32)
if err != nil {
return nil, err
}
if err := db.SetSetting(ctx, keyCSRFSecret, encoded); err != nil {
return nil, fmt.Errorf("store the CSRF signing key: %w", err)
}
key, err := base64.RawStdEncoding.DecodeString(encoded)
if err != nil {
return nil, fmt.Errorf("decode the CSRF signing key: %w", err)
}
return key, nil
}
// Start launches every background worker and binds the DNS listeners.
func (a *App) Start(ctx context.Context) error {
ctx, a.cancel = context.WithCancel(ctx)
a.Runtime.Start(ctx)
a.QueryLog.Start(ctx)
a.Backups.Start(ctx)
done := ctx.Done()
go a.Cache.Run(done, func() time.Duration {
return time.Duration(a.Runtime.Settings().Cache.CleanupSeconds) * time.Second
})
go a.Limiter.Run(done, time.Minute, 10*time.Minute)
go a.pruneAuditLoop(ctx)
if err := a.DNS.Start(ctx); err != nil {
a.cancel()
return err
}
return nil
}
// Shutdown stops the DNS listeners and drains the background workers.
func (a *App) Shutdown(ctx context.Context) error {
err := a.DNS.Shutdown(ctx)
if a.cancel != nil {
a.cancel()
}
a.QueryLog.Stop()
a.Runtime.Stop()
a.Backups.Stop()
return err
}
// pruneAuditLoop keeps the audit log bounded.
func (a *App) pruneAuditLoop(ctx context.Context) {
t := time.NewTicker(6 * time.Hour)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
max := a.Runtime.Settings().Logging.AuditMaxRows
if n, err := a.DB.PruneAuditLogs(ctx, max); err != nil {
a.Log.Warn("could not prune the audit log", "error", err)
} else if n > 0 {
a.Log.Debug("pruned audit log", "rows", n)
}
}
}
}
// Settings returns the active runtime settings.
func (a *App) Settings() config.Settings { return a.Runtime.Settings() }
// Snapshot returns the active configuration snapshot.
func (a *App) Snapshot() *runtimecfg.Snapshot { return a.Runtime.Current() }
// StartedAt returns when the application started.
func (a *App) StartedAt() time.Time { return a.started }
// Uptime returns how long the application has been running.
func (a *App) Uptime() time.Duration { return time.Since(a.started) }
// Reload rebuilds the configuration snapshot immediately.
func (a *App) Reload(ctx context.Context) error {
if err := a.Runtime.Reload(ctx); err != nil {
return Internal(err, "The configuration could not be reloaded.")
}
return nil
}
// gauges samples live values for the metrics endpoint.
func (a *App) gauges() metrics.Gauges {
snap := a.Runtime.Current()
cs := a.Cache.Stats()
rs := a.Resolver.Stats()
g := metrics.Gauges{
CacheEntries: int64(cs.Entries),
CacheBytes: cs.Bytes,
Zones: int64(snap.ZoneCount),
Records: int64(snap.RecordCount),
BlacklistDomains: int64(snap.BlacklistDomains),
AllowlistDomains: int64(snap.AllowlistDomains),
Networks: int64(snap.NetworkCount),
UpstreamsTotal: int64(rs.Upstreams),
UpstreamsHealthy: int64(rs.Healthy),
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if n, err := a.DB.QueryLogCount(ctx); err == nil {
g.QueryLogRows = n
}
return g
}
// Ready reports whether the server is able to answer queries. It backs /readyz.
func (a *App) Ready(ctx context.Context) error {
if !a.DNS.Running() {
return errors.New("DNS listeners are not running")
}
if err := a.DB.PingContext(ctx); err != nil {
return fmt.Errorf("database is unreachable: %w", err)
}
if a.Runtime.Current() == nil {
return errors.New("configuration has not been loaded")
}
return nil
}
// translate converts a storage error into a user-facing one.
func translate(err error, notFound, conflict string) error {
switch {
case err == nil:
return nil
case errors.Is(err, database.ErrNotFound):
return NotFound("%s", notFound)
case errors.Is(err, database.ErrConflict):
return Conflict("%s", conflict)
default:
return Internal(err, "The change could not be saved.")
}
}
// Hostname returns the machine name, shown on the dashboard.
func Hostname() string {
h, err := os.Hostname()
if err != nil || h == "" {
return "unknown"
}
return h
}
+96
View File
@@ -0,0 +1,96 @@
package app
import (
"context"
"fmt"
"os"
"github.com/owen/vibedns/internal/auditlog"
"github.com/owen/vibedns/internal/backup"
)
// BackupStatus returns the backup configuration and inventory.
func (a *App) BackupStatus() backup.Status { return a.Backups.Status() }
// BackupList lists the available backup files, newest first.
func (a *App) BackupList() ([]backup.Info, error) {
list, err := a.Backups.List()
if err != nil {
return nil, Internal(err, "The backup directory could not be read.")
}
return list, nil
}
// RunBackup creates a backup immediately.
func (a *App) RunBackup(ctx context.Context, actor auditlog.Actor) (backup.Info, error) {
info, err := a.Backups.Run(ctx)
if err != nil {
return info, Invalid("Database backup failed: %s", err.Error())
}
a.Audit.Record(ctx, actor, "backup.create", auditlog.ObjectBackup, info.Name, info.Name,
auditlog.Changes("bytes", fmt.Sprint(info.SizeBytes)))
return info, nil
}
// OpenBackup opens a backup file for download.
func (a *App) OpenBackup(name string) (*os.File, backup.Info, error) {
path, err := backup.Resolve(a.Backups.Directory(), name)
if err != nil {
return nil, backup.Info{}, NotFound("%s", err.Error())
}
f, err := os.Open(path)
if err != nil {
return nil, backup.Info{}, Internal(err, "The backup could not be opened.")
}
fi, err := f.Stat()
if err != nil {
f.Close()
return nil, backup.Info{}, Internal(err, "The backup could not be read.")
}
return f, backup.Info{Name: name, Path: path, SizeBytes: fi.Size(), CreatedAt: fi.ModTime()}, nil
}
// DeleteBackup removes a backup file.
func (a *App) DeleteBackup(ctx context.Context, actor auditlog.Actor, name string) error {
if err := backup.Delete(a.Backups.Directory(), name); err != nil {
return NotFound("%s", err.Error())
}
a.Audit.Record(ctx, actor, "backup.delete", auditlog.ObjectBackup, name, name, "")
return nil
}
// StageRestore validates a backup and schedules it to replace the live
// database on the next start.
//
// The swap is deliberately deferred: overwriting the database file while
// connections are open would leave the running process reading a file that no
// longer exists. The operator restarts, and the restore is applied cleanly
// before anything opens the database.
func (a *App) StageRestore(ctx context.Context, actor auditlog.Actor, name string) error {
path, err := backup.Resolve(a.Backups.Directory(), name)
if err != nil {
return NotFound("%s", err.Error())
}
if err := backup.StageRestore(a.DB.Path(), path); err != nil {
return Invalid("%s", err.Error())
}
a.Audit.Record(ctx, actor, "backup.restore_staged", auditlog.ObjectBackup, name, name,
"applies on the next restart")
a.Log.Warn("database restore staged; it will be applied on the next start", "backup", name)
return nil
}
// PendingRestore reports whether a restore is waiting for a restart.
func (a *App) PendingRestore() bool {
_, pending := backup.PendingRestore(a.DB.Path())
return pending
}
// CancelRestore discards a staged restore.
func (a *App) CancelRestore(ctx context.Context, actor auditlog.Actor) error {
if err := backup.CancelRestore(a.DB.Path()); err != nil {
return Internal(err, "The staged restore could not be cancelled.")
}
a.Audit.Record(ctx, actor, "backup.restore_cancelled", auditlog.ObjectBackup, "", "", "")
return nil
}
+65
View File
@@ -0,0 +1,65 @@
package app
import (
"context"
"fmt"
"strings"
"github.com/miekg/dns"
"github.com/owen/vibedns/internal/auditlog"
"github.com/owen/vibedns/internal/cache"
)
// CacheStats returns the cache counters for the dashboard and cache page.
func (a *App) CacheStats() cache.Stats { return a.Cache.Stats() }
// CacheEntries browses the cache.
func (a *App) CacheEntries(search string, limit, offset int) ([]cache.EntryView, int) {
return a.Cache.Entries(search, limit, offset)
}
// FlushCache empties the resolver cache.
func (a *App) FlushCache(ctx context.Context, actor auditlog.Actor) (int, error) {
n := a.Cache.Flush()
a.Audit.Record(ctx, actor, "cache.flush", auditlog.ObjectCache, "", "resolver cache",
auditlog.Changes("entries", fmt.Sprint(n)))
return n, nil
}
// FlushCacheName removes every cached entry for one name.
func (a *App) FlushCacheName(ctx context.Context, actor auditlog.Actor, name string) (int, error) {
name = strings.TrimSpace(name)
if name == "" {
return 0, Invalid("Enter a name to remove from the cache.")
}
n := a.Cache.FlushName(name)
if n == 0 {
return 0, NotFound("%s is not in the cache.", strings.TrimSuffix(dns.Fqdn(name), "."))
}
a.Audit.Record(ctx, actor, "cache.flush_name", auditlog.ObjectCache, "", name,
auditlog.Changes("entries", fmt.Sprint(n)))
return n, nil
}
// DeleteCacheEntry removes one specific cached response.
func (a *App) DeleteCacheEntry(ctx context.Context, actor auditlog.Actor, name, qtype string, do bool) error {
name = strings.ToLower(dns.Fqdn(strings.TrimSpace(name)))
t, ok := dns.StringToType[strings.ToUpper(strings.TrimSpace(qtype))]
if !ok {
return Invalid("%q is not a known record type.", qtype)
}
key := cache.Key{Name: name, Type: t, Class: dns.ClassINET, DO: do}
if !a.Cache.Delete(key) {
return NotFound("%s %s is not in the cache.", strings.TrimSuffix(name, "."), strings.ToUpper(qtype))
}
a.Audit.Record(ctx, actor, "cache.delete_entry", auditlog.ObjectCache, "", key.String(), "")
return nil
}
// ResetStats zeroes the runtime counters.
func (a *App) ResetStats(ctx context.Context, actor auditlog.Actor) {
a.Metrics.Reset()
a.Cache.ResetStats()
a.Audit.Record(ctx, actor, "stats.reset", auditlog.ObjectCache, "", "statistics", "")
}
+337
View File
@@ -0,0 +1,337 @@
package app
import (
"context"
"encoding/json"
"fmt"
"io"
"time"
"github.com/owen/vibedns/internal/auditlog"
"github.com/owen/vibedns/internal/config"
"github.com/owen/vibedns/internal/database"
"github.com/owen/vibedns/internal/models"
"github.com/owen/vibedns/internal/version"
)
// ConfigExportVersion is the schema version of the export format. It is
// checked on import so a future format change fails loudly rather than being
// half-applied.
const ConfigExportVersion = 1
// ConfigExport is a portable snapshot of the configuration.
//
// It deliberately excludes the administrator password hash and every API token
// hash: an export is meant to be copied between machines and checked into a
// configuration repository, so it must not carry credentials.
type ConfigExport struct {
FormatVersion int `json:"format_version"`
ExportedAt time.Time `json:"exported_at"`
AppVersion string `json:"app_version"`
Settings map[string]string `json:"settings"`
Zones []ExportedZone `json:"zones"`
Networks []ExportedNetwork `json:"networks"`
Policies []ExportedPolicy `json:"policies"`
Lists []ExportedList `json:"lists"`
}
// ExportedZone is a zone with its records.
type ExportedZone struct {
models.Zone
Records []models.Record `json:"records"`
}
// ExportedNetwork is a network with the names of its policies.
type ExportedNetwork struct {
models.Network
PolicyNames []string `json:"policy_names"`
}
// ExportedPolicy is a policy with the names of its lists.
type ExportedPolicy struct {
models.Policy
ListNames []string `json:"list_names"`
}
// ExportedList is a domain list with its domains.
type ExportedList struct {
models.DomainList
Domains []ExportedDomain `json:"domains"`
}
// ExportedDomain is one entry in a domain list.
type ExportedDomain struct {
Domain string `json:"domain"`
MatchSubdomains bool `json:"match_subdomains"`
Comment string `json:"comment,omitempty"`
}
// ExportConfig builds a configuration export.
//
// includeDomains controls whether imported blocklists are included. A single
// blocklist can hold hundreds of thousands of domains that are reproducible
// from their source URL, so the default export omits them.
func (a *App) ExportConfig(ctx context.Context, includeDomains bool) (*ConfigExport, error) {
stored, err := a.DB.Settings(ctx)
if err != nil {
return nil, Internal(err, "The settings could not be exported.")
}
// The CSRF signing key is a secret and is regenerated per installation.
delete(stored, keyCSRFSecret)
out := &ConfigExport{
FormatVersion: ConfigExportVersion,
ExportedAt: time.Now().UTC(),
AppVersion: version.Version,
Settings: stored,
}
zones, err := a.DB.Zones(ctx, database.ZoneFilter{})
if err != nil {
return nil, Internal(err, "The zones could not be exported.")
}
for _, z := range zones {
recs, err := a.DB.ZoneRecordsRaw(ctx, z.ID)
if err != nil {
return nil, Internal(err, "The zone records could not be exported.")
}
out.Zones = append(out.Zones, ExportedZone{Zone: z, Records: recs})
}
nets, err := a.DB.Networks(ctx, "", true)
if err != nil {
return nil, Internal(err, "The networks could not be exported.")
}
for _, n := range nets {
e := ExportedNetwork{Network: n}
for _, p := range n.Policies {
e.PolicyNames = append(e.PolicyNames, p.Name)
}
e.Network.Policies = nil // names carry the relationship instead of IDs
out.Networks = append(out.Networks, e)
}
policies, err := a.DB.Policies(ctx)
if err != nil {
return nil, Internal(err, "The policies could not be exported.")
}
for _, p := range policies {
e := ExportedPolicy{Policy: p}
e.ListNames = append(append([]string{}, p.BlacklistName...), p.AllowlistName...)
e.Policy.BlacklistIDs, e.Policy.AllowlistIDs = nil, nil
out.Policies = append(out.Policies, e)
}
lists, err := a.DB.DomainLists(ctx, "", "")
if err != nil {
return nil, Internal(err, "The lists could not be exported.")
}
for _, l := range lists {
e := ExportedList{DomainList: l}
if includeDomains {
err := a.DB.ExportDomains(ctx, l.ID, func(domain string, sub bool) {
e.Domains = append(e.Domains, ExportedDomain{Domain: domain, MatchSubdomains: sub})
})
if err != nil {
return nil, Internal(err, "The list domains could not be exported.")
}
}
out.Lists = append(out.Lists, e)
}
return out, nil
}
// WriteConfigExport writes an export as indented JSON.
func (a *App) WriteConfigExport(ctx context.Context, w io.Writer, includeDomains bool) error {
export, err := a.ExportConfig(ctx, includeDomains)
if err != nil {
return err
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
if err := enc.Encode(export); err != nil {
return Internal(err, "The export could not be written.")
}
return nil
}
// ImportReport summarises what a configuration import created.
type ImportReport struct {
Zones int `json:"zones"`
Records int `json:"records"`
Networks int `json:"networks"`
Policies int `json:"policies"`
Lists int `json:"lists"`
Domains int `json:"domains"`
Settings int `json:"settings"`
Skipped int `json:"skipped"`
Conflicts []string `json:"conflicts,omitempty"`
}
// ImportConfig applies a configuration export.
//
// Objects that already exist are skipped rather than overwritten, and reported
// in Conflicts, so an import can never silently destroy configuration that is
// already in production.
func (a *App) ImportConfig(ctx context.Context, actor auditlog.Actor, r io.Reader, applySettings bool) (*ImportReport, error) {
var in ConfigExport
dec := json.NewDecoder(r)
dec.DisallowUnknownFields()
if err := dec.Decode(&in); err != nil {
return nil, Invalid("The import file could not be read as a VibeDNS configuration export: %v", err)
}
if in.FormatVersion != ConfigExportVersion {
return nil, Invalid("This export uses format version %d, but this server understands version %d.",
in.FormatVersion, ConfigExportVersion)
}
rep := &ImportReport{}
// Lists first: policies reference them by name.
listIDs := map[string]int64{}
for _, l := range in.Lists {
existing, err := a.DB.DomainLists(ctx, l.Kind, l.Name)
if err != nil {
return nil, Internal(err, "Existing lists could not be checked.")
}
var id int64
found := false
for _, e := range existing {
if e.Name == l.Name && e.Kind == l.Kind {
id, found = e.ID, true
break
}
}
if !found {
created, err := a.DB.CreateDomainList(ctx, l.DomainList)
if err != nil {
rep.Skipped++
rep.Conflicts = append(rep.Conflicts, fmt.Sprintf("list %q could not be created", l.Name))
continue
}
id = created.ID
rep.Lists++
} else {
rep.Conflicts = append(rep.Conflicts, fmt.Sprintf("list %q already exists and was left unchanged", l.Name))
}
listIDs[l.Kind+"/"+l.Name] = id
if len(l.Domains) > 0 {
rows := make([]database.ImportDomain, 0, len(l.Domains))
for _, d := range l.Domains {
rows = append(rows, database.ImportDomain{
Domain: d.Domain, MatchSubdomains: d.MatchSubdomains, Comment: d.Comment,
})
}
imported, _, err := a.DB.ImportDomains(ctx, id, rows)
if err != nil {
return nil, Internal(err, "The list domains could not be imported.")
}
rep.Domains += imported
}
}
// Policies next: networks reference them by name.
policyIDs := map[string]int64{}
existingPolicies, err := a.DB.Policies(ctx)
if err != nil {
return nil, Internal(err, "Existing policies could not be checked.")
}
for _, p := range existingPolicies {
policyIDs[p.Name] = p.ID
}
for _, p := range in.Policies {
if _, exists := policyIDs[p.Name]; exists {
rep.Conflicts = append(rep.Conflicts, fmt.Sprintf("policy %q already exists and was left unchanged", p.Name))
continue
}
var ids []int64
for _, name := range p.ListNames {
if id, ok := listIDs[models.KindBlacklist+"/"+name]; ok {
ids = append(ids, id)
} else if id, ok := listIDs[models.KindAllowlist+"/"+name]; ok {
ids = append(ids, id)
}
}
created, err := a.DB.CreatePolicy(ctx, p.Policy, ids)
if err != nil {
rep.Skipped++
continue
}
policyIDs[p.Name] = created.ID
rep.Policies++
}
// Networks.
existingNets, err := a.DB.Networks(ctx, "", false)
if err != nil {
return nil, Internal(err, "Existing networks could not be checked.")
}
netNames := map[string]bool{}
for _, n := range existingNets {
netNames[n.Name] = true
}
for _, n := range in.Networks {
if netNames[n.Name] {
rep.Conflicts = append(rep.Conflicts, fmt.Sprintf("network %q already exists and was left unchanged", n.Name))
continue
}
var ids []int64
for _, name := range n.PolicyNames {
if id, ok := policyIDs[name]; ok {
ids = append(ids, id)
}
}
if _, err := a.DB.CreateNetwork(ctx, n.Network, ids); err != nil {
rep.Skipped++
continue
}
rep.Networks++
}
// Zones and their records.
for _, z := range in.Zones {
if _, err := a.DB.ZoneByName(ctx, z.Name); err == nil {
rep.Conflicts = append(rep.Conflicts, fmt.Sprintf("zone %s already exists and was left unchanged", z.Name))
continue
}
zone := z.Zone
zone.ID = 0
created, err := a.DB.CreateZone(ctx, zone)
if err != nil {
rep.Skipped++
rep.Conflicts = append(rep.Conflicts, fmt.Sprintf("zone %s could not be created", z.Name))
continue
}
rep.Zones++
if len(z.Records) > 0 {
if err := a.DB.AppendZoneRecords(ctx, created.ID, z.Records); err != nil {
return nil, Internal(err, "The zone records could not be imported.")
}
rep.Records += len(z.Records)
}
}
if applySettings && len(in.Settings) > 0 {
settings := config.LoadSettings(in.Settings)
if err := settings.Validate(); err != nil {
rep.Conflicts = append(rep.Conflicts,
fmt.Sprintf("settings were not applied because they are invalid: %v", err))
} else {
delete(in.Settings, keyCSRFSecret)
if err := a.DB.SetSettings(ctx, in.Settings); err != nil {
return nil, Internal(err, "The settings could not be imported.")
}
rep.Settings = len(in.Settings)
}
}
a.Audit.Record(ctx, actor, "config.import", auditlog.ObjectConfig, "", "configuration import",
auditlog.Changes(
"zones", fmt.Sprint(rep.Zones), "records", fmt.Sprint(rep.Records),
"networks", fmt.Sprint(rep.Networks), "policies", fmt.Sprint(rep.Policies),
"lists", fmt.Sprint(rep.Lists), "domains", fmt.Sprint(rep.Domains)))
a.Runtime.RequestReload()
return rep, nil
}
+79
View File
@@ -0,0 +1,79 @@
// Package app is the service layer. It holds every business operation the
// management interface offers, so the HTML handlers and the REST API share one
// implementation of validation, auditing and cache invalidation rather than
// each growing their own.
package app
import (
"errors"
"fmt"
"net/http"
)
// Error is a user-facing failure with an HTTP status attached.
//
// Messages are written for an administrator reading them in a toast or a JSON
// response: they say what went wrong and, where useful, what to do about it.
// Internal detail goes in the wrapped error, which is logged but never shown.
type Error struct {
Status int
Message string
Err error
}
func (e *Error) Error() string {
if e.Err != nil {
return fmt.Sprintf("%s: %v", e.Message, e.Err)
}
return e.Message
}
func (e *Error) Unwrap() error { return e.Err }
// Invalid reports a client mistake such as a malformed record.
func Invalid(format string, args ...any) *Error {
return &Error{Status: http.StatusBadRequest, Message: fmt.Sprintf(format, args...)}
}
// NotFound reports a missing object.
func NotFound(format string, args ...any) *Error {
return &Error{Status: http.StatusNotFound, Message: fmt.Sprintf(format, args...)}
}
// Conflict reports a uniqueness violation.
func Conflict(format string, args ...any) *Error {
return &Error{Status: http.StatusConflict, Message: fmt.Sprintf(format, args...)}
}
// Forbidden reports an operation the caller may not perform.
func Forbidden(format string, args ...any) *Error {
return &Error{Status: http.StatusForbidden, Message: fmt.Sprintf(format, args...)}
}
// Internal wraps an unexpected failure. The message is safe to show; err is
// logged server-side.
func Internal(err error, format string, args ...any) *Error {
return &Error{Status: http.StatusInternalServerError, Message: fmt.Sprintf(format, args...), Err: err}
}
// StatusOf maps any error to an HTTP status code.
func StatusOf(err error) int {
var e *Error
if errors.As(err, &e) {
return e.Status
}
return http.StatusInternalServerError
}
// MessageOf returns the user-facing message for an error, falling back to a
// generic sentence so internal detail never leaks into a response.
func MessageOf(err error) string {
var e *Error
if errors.As(err, &e) {
return e.Message
}
return "An unexpected error occurred. Check the server log for details."
}
// IsInternal reports whether an error should be logged with its full detail.
func IsInternal(err error) bool { return StatusOf(err) >= 500 }
+442
View File
@@ -0,0 +1,442 @@
package app_test
import (
"context"
"io"
"log/slog"
"net/netip"
"path/filepath"
"strings"
"testing"
"github.com/miekg/dns"
"github.com/owen/vibedns/internal/app"
"github.com/owen/vibedns/internal/auditlog"
"github.com/owen/vibedns/internal/config"
"github.com/owen/vibedns/internal/database"
"github.com/owen/vibedns/internal/models"
)
// newTestApp builds a fully wired application against a temporary database,
// without binding any listener.
func newTestApp(t *testing.T) *app.App {
t.Helper()
ctx := context.Background()
path := filepath.Join(t.TempDir(), "test.db")
db, err := database.Open(path)
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { db.Close() })
if _, err := db.Migrate(ctx); err != nil {
t.Fatalf("migrate: %v", err)
}
log := slog.New(slog.NewTextHandler(io.Discard, nil))
boot := config.DefaultBootstrap()
boot.DBPath = path
a, err := app.New(ctx, boot, db, log)
if err != nil {
t.Fatalf("build app: %v", err)
}
return a
}
func testActor() auditlog.Actor {
return auditlog.Actor{Name: "test", Source: auditlog.SourceCLI, ClientIP: "127.0.0.1"}
}
// TestZoneLifecycleAndResolution walks the path an operator actually takes:
// create a zone, add records, and confirm the DNS engine serves them.
func TestZoneLifecycleAndResolution(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
zone, err := a.CreateZone(ctx, testActor(), app.ZoneInput{
Name: "example.com",
Description: "integration test zone",
AdminEmail: "hostmaster@example.com",
})
if err != nil {
t.Fatalf("create zone: %v", err)
}
if zone.Name != "example.com." {
t.Errorf("zone name = %q, want the normalised form", zone.Name)
}
records := []app.RecordInput{
{Name: "@", Type: "A", Data: "192.0.2.10"},
{Name: "www", Type: "CNAME", Data: "example.com."},
{Name: "mail", Type: "A", Data: "192.0.2.20"},
{Name: "@", Type: "MX", Data: "10 mail.example.com."},
{Name: "txt", Type: "TXT", Fields: map[string]string{"text": "hello world"}},
}
for _, in := range records {
if _, err := a.CreateRecord(ctx, testActor(), zone.ID, in); err != nil {
t.Fatalf("create record %s %s: %v", in.Name, in.Type, err)
}
}
// The snapshot is rebuilt on demand rather than waiting for the debounce.
if err := a.Reload(ctx); err != nil {
t.Fatalf("reload: %v", err)
}
snap := a.Snapshot()
if snap.ZoneCount != 1 {
t.Fatalf("indexed zones = %d, want 1", snap.ZoneCount)
}
if len(snap.Problems) != 0 {
t.Errorf("build problems: %v", snap.Problems)
}
client := netip.MustParseAddr("127.0.0.1")
tests := []struct {
name string
qname string
qtype uint16
rcode int
wantIn string
answers int
}{
{"apex A", "example.com.", dns.TypeA, dns.RcodeSuccess, "192.0.2.10", 1},
{"host A", "mail.example.com.", dns.TypeA, dns.RcodeSuccess, "192.0.2.20", 1},
{"CNAME is followed", "www.example.com.", dns.TypeA, dns.RcodeSuccess, "192.0.2.10", 2},
{"MX", "example.com.", dns.TypeMX, dns.RcodeSuccess, "mail.example.com.", 1},
{"TXT", "txt.example.com.", dns.TypeTXT, dns.RcodeSuccess, "hello world", 1},
{"NODATA", "mail.example.com.", dns.TypeTXT, dns.RcodeSuccess, "", 0},
{"NXDOMAIN", "missing.example.com.", dns.TypeA, dns.RcodeNameError, "", 0},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
msg, source, err := a.DNS.Resolve(ctx, tc.qname, tc.qtype, client, false)
if err != nil {
t.Fatalf("resolve: %v", err)
}
if msg.Rcode != tc.rcode {
t.Errorf("rcode = %s, want %s", dns.RcodeToString[msg.Rcode], dns.RcodeToString[tc.rcode])
}
if len(msg.Answer) != tc.answers {
t.Errorf("answers = %d, want %d: %v", len(msg.Answer), tc.answers, msg.Answer)
}
if source != models.SourceAuthoritative {
t.Errorf("source = %q, want authoritative", source)
}
if tc.wantIn != "" {
var found bool
for _, rr := range msg.Answer {
if strings.Contains(rr.String(), tc.wantIn) {
found = true
}
}
if !found {
t.Errorf("no answer contains %q: %v", tc.wantIn, msg.Answer)
}
}
})
}
}
func TestCNAMEConflictIsRejected(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
zone, err := a.CreateZone(ctx, testActor(), app.ZoneInput{Name: "example.com"})
if err != nil {
t.Fatalf("create zone: %v", err)
}
if _, err := a.CreateRecord(ctx, testActor(), zone.ID,
app.RecordInput{Name: "www", Type: "A", Data: "192.0.2.1"}); err != nil {
t.Fatalf("create A: %v", err)
}
// A CNAME cannot coexist with the A record already at that name.
_, err = a.CreateRecord(ctx, testActor(), zone.ID,
app.RecordInput{Name: "www", Type: "CNAME", Data: "other.example.com."})
if err == nil {
t.Fatal("expected the conflicting CNAME to be rejected")
}
if app.StatusOf(err) != 400 {
t.Errorf("status = %d, want 400", app.StatusOf(err))
}
// And a CNAME at the apex is always wrong.
_, err = a.CreateRecord(ctx, testActor(), zone.ID,
app.RecordInput{Name: "@", Type: "CNAME", Data: "other.example.com."})
if err == nil {
t.Error("expected an apex CNAME to be rejected")
}
}
func TestReverseZoneCreationFromCIDR(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
zone, err := a.CreateZone(ctx, testActor(), app.ZoneInput{
CIDR: "192.168.1.0/24",
Kind: "reverse4",
})
if err != nil {
t.Fatalf("create reverse zone: %v", err)
}
if zone.Name != "1.168.192.in-addr.arpa." {
t.Errorf("zone name = %q, want 1.168.192.in-addr.arpa.", zone.Name)
}
if _, err := a.CreateRecord(ctx, testActor(), zone.ID,
app.RecordInput{Name: "10", Type: "PTR", Data: "host.example.com."}); err != nil {
t.Fatalf("create PTR: %v", err)
}
if err := a.Reload(ctx); err != nil {
t.Fatalf("reload: %v", err)
}
msg, _, err := a.DNS.Resolve(ctx, "10.1.168.192.in-addr.arpa.", dns.TypePTR,
netip.MustParseAddr("127.0.0.1"), false)
if err != nil {
t.Fatalf("resolve PTR: %v", err)
}
if len(msg.Answer) != 1 {
t.Fatalf("PTR answers = %d, want 1", len(msg.Answer))
}
if ptr, ok := msg.Answer[0].(*dns.PTR); !ok || ptr.Ptr != "host.example.com." {
t.Errorf("PTR answer = %v", msg.Answer[0])
}
}
// TestPolicyBlocking exercises the filtering path end to end: import a
// blocklist, attach it to a policy and a network, and confirm the DNS engine
// blocks a matching query from a client in that network.
func TestPolicyBlocking(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
list, err := a.CreateDomainList(ctx, testActor(), app.ListInput{
Kind: models.KindBlacklist, Name: "Test Blocks",
})
if err != nil {
t.Fatalf("create list: %v", err)
}
summary, err := a.ImportDomains(ctx, testActor(), list.ID,
strings.NewReader("0.0.0.0 ads.example\ntracker.example.net\n# a comment\n"), true)
if err != nil {
t.Fatalf("import: %v", err)
}
if summary.Imported != 2 {
t.Fatalf("imported = %d, want 2", summary.Imported)
}
policy, err := a.CreatePolicy(ctx, testActor(), app.PolicyInput{
Name: "Test Policy", BlockAction: "nxdomain", ListIDs: []int64{list.ID},
})
if err != nil {
t.Fatalf("create policy: %v", err)
}
if _, err := a.CreateNetwork(ctx, testActor(), app.NetworkInput{
Name: "Test Net", CIDR: "100.64.30.0/24", PolicyIDs: []int64{policy.ID},
}); err != nil {
t.Fatalf("create network: %v", err)
}
if err := a.Reload(ctx); err != nil {
t.Fatalf("reload: %v", err)
}
inNetwork := netip.MustParseAddr("100.64.30.5")
outside := netip.MustParseAddr("192.0.2.1")
// A blocked name from inside the network.
msg, source, err := a.DNS.Resolve(ctx, "ads.example.", dns.TypeA, inNetwork, false)
if err != nil {
t.Fatalf("resolve: %v", err)
}
if msg.Rcode != dns.RcodeNameError {
t.Errorf("rcode = %s, want NXDOMAIN", dns.RcodeToString[msg.Rcode])
}
if source != models.SourceBlocked {
t.Errorf("source = %q, want blocked", source)
}
// A subdomain of a blocked name is covered without being stored.
msg, _, _ = a.DNS.Resolve(ctx, "cdn.ads.example.", dns.TypeA, inNetwork, false)
if msg.Rcode != dns.RcodeNameError {
t.Errorf("subdomain rcode = %s, want NXDOMAIN", dns.RcodeToString[msg.Rcode])
}
// The same name from a client outside the network is not blocked. With no
// upstream reachable in a test it will fail to resolve, but it must not be
// blocked, and it must not be refused for a private client.
_, source, _ = a.DNS.Resolve(ctx, "ads.example.", dns.TypeA, outside, false)
if source == models.SourceBlocked {
t.Error("a client outside the configured network was filtered")
}
}
// TestRecursionIsRefusedByDefaultForPublicClients is the open-resolver guard.
func TestRecursionIsRefusedForClientsOutsideTheACL(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
// A public address is not in the default private-network ACL.
public := netip.MustParseAddr("203.0.113.50")
msg, source, err := a.DNS.Resolve(ctx, "example.org.", dns.TypeA, public, false)
if err != nil {
t.Fatalf("resolve: %v", err)
}
if msg.Rcode != dns.RcodeRefused {
t.Errorf("rcode = %s, want REFUSED for a client outside the recursion ACL",
dns.RcodeToString[msg.Rcode])
}
if source != models.SourceRefused {
t.Errorf("source = %q, want refused", source)
}
}
// TestAuthoritativeAnswersSurviveRecursionDenial: a client that may not
// recurse must still get answers for zones we are authoritative for.
func TestAuthoritativeAnswersWorkWithoutRecursionRights(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
zone, err := a.CreateZone(ctx, testActor(), app.ZoneInput{Name: "internal.example"})
if err != nil {
t.Fatalf("create zone: %v", err)
}
if _, err := a.CreateRecord(ctx, testActor(), zone.ID,
app.RecordInput{Name: "@", Type: "A", Data: "192.0.2.1"}); err != nil {
t.Fatalf("create record: %v", err)
}
if err := a.Reload(ctx); err != nil {
t.Fatalf("reload: %v", err)
}
public := netip.MustParseAddr("203.0.113.50")
msg, source, err := a.DNS.Resolve(ctx, "internal.example.", dns.TypeA, public, false)
if err != nil {
t.Fatalf("resolve: %v", err)
}
if msg.Rcode != dns.RcodeSuccess {
t.Errorf("rcode = %s, want NOERROR: an authoritative zone must answer "+
"even when the client may not recurse", dns.RcodeToString[msg.Rcode])
}
if source != models.SourceAuthoritative {
t.Errorf("source = %q, want authoritative", source)
}
}
func TestSettingsValidationRejectsOpenResolver(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
next := a.Settings()
next.Resolver.AllowNetworks = nil // would deny everyone
if err := a.SaveSettings(ctx, testActor(), app.GroupResolver, next); err == nil {
t.Error("an empty recursion ACL with recursion enabled should be rejected")
}
next = a.Settings()
next.Resolver.Upstreams = nil
if err := a.SaveSettings(ctx, testActor(), app.GroupResolver, next); err == nil {
t.Error("enabling recursion with no upstreams should be rejected")
}
}
func TestConfigExportOmitsSecrets(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
if _, err := a.CreateAPIToken(ctx, testActor(), "test-token", ""); err != nil {
t.Fatalf("create token: %v", err)
}
var buf strings.Builder
if err := a.WriteConfigExport(ctx, &buf, false); err != nil {
t.Fatalf("export: %v", err)
}
out := buf.String()
for _, forbidden := range []string{"argon2id", "password_hash", "token_hash", "csrf_key", "vibedns_"} {
if strings.Contains(out, forbidden) {
t.Errorf("the configuration export contains %q, which must never leave the server", forbidden)
}
}
}
func TestAuditLogRecordsChanges(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
if _, err := a.CreateZone(ctx, testActor(), app.ZoneInput{Name: "audited.example"}); err != nil {
t.Fatalf("create zone: %v", err)
}
entries, total, err := a.AuditLogs(ctx, database.AuditFilter{Limit: 10})
if err != nil {
t.Fatalf("read audit log: %v", err)
}
if total == 0 {
t.Fatal("no audit entry was recorded for a zone creation")
}
var found bool
for _, e := range entries {
if e.Action == "zone.create" && e.ObjectName == "audited.example." {
found = true
}
}
if !found {
t.Errorf("no zone.create entry found in %v", entries)
}
}
func TestBulkRecordOperations(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
zone, err := a.CreateZone(ctx, testActor(), app.ZoneInput{Name: "bulk.example"})
if err != nil {
t.Fatalf("create zone: %v", err)
}
var ids []int64
for _, name := range []string{"a", "b", "c"} {
rec, err := a.CreateRecord(ctx, testActor(), zone.ID,
app.RecordInput{Name: name, Type: "A", Data: "192.0.2.1"})
if err != nil {
t.Fatalf("create %s: %v", name, err)
}
ids = append(ids, rec.ID)
}
n, err := a.BulkRecords(ctx, testActor(), zone.ID, ids, app.BulkDisable)
if err != nil {
t.Fatalf("bulk disable: %v", err)
}
if n != 3 {
t.Errorf("disabled %d, want 3", n)
}
// Disabled records must not be served.
if err := a.Reload(ctx); err != nil {
t.Fatalf("reload: %v", err)
}
msg, _, _ := a.DNS.Resolve(ctx, "a.bulk.example.", dns.TypeA, netip.MustParseAddr("127.0.0.1"), false)
if len(msg.Answer) != 0 {
t.Errorf("a disabled record was still served: %v", msg.Answer)
}
n, err = a.BulkRecords(ctx, testActor(), zone.ID, ids, app.BulkDelete)
if err != nil {
t.Fatalf("bulk delete: %v", err)
}
if n != 3 {
t.Errorf("deleted %d, want 3", n)
}
}
+647
View File
@@ -0,0 +1,647 @@
package app
import (
"context"
"errors"
"fmt"
"io"
"strings"
"github.com/owen/vibedns/internal/auditlog"
"github.com/owen/vibedns/internal/blacklist"
"github.com/owen/vibedns/internal/config"
"github.com/owen/vibedns/internal/database"
"github.com/owen/vibedns/internal/models"
"github.com/owen/vibedns/internal/validate"
)
// --- Client networks ----------------------------------------------------
// NetworkInput is the editable surface of a client network.
type NetworkInput struct {
Name string `json:"name"`
CIDR string `json:"cidr"`
Description string `json:"description"`
Enabled *bool `json:"enabled"`
PolicyIDs []int64 `json:"policy_ids"`
}
// Networks lists client networks with their policy assignments.
func (a *App) Networks(ctx context.Context, search string) ([]models.Network, error) {
nets, err := a.DB.Networks(ctx, search, true)
if err != nil {
return nil, Internal(err, "The network list could not be loaded.")
}
return nets, nil
}
// Network loads one client network.
func (a *App) Network(ctx context.Context, id int64) (models.Network, error) {
n, err := a.DB.Network(ctx, id)
if errors.Is(err, database.ErrNotFound) {
return n, NotFound("Network %d was not found.", id)
}
if err != nil {
return n, Internal(err, "The network could not be loaded.")
}
return n, nil
}
func (a *App) normaliseNetwork(in NetworkInput, base models.Network) (models.Network, error) {
n := base
if name := strings.TrimSpace(in.Name); name != "" {
n.Name = name
}
if n.Name == "" {
return n, Invalid("A network name is required.")
}
if cidr := strings.TrimSpace(in.CIDR); cidr != "" {
p, err := config.ParseCIDROrIP(cidr)
if err != nil {
return n, Invalid("Subnet %q: %s", cidr, err.Error())
}
n.CIDR = p.String()
}
if n.CIDR == "" {
return n, Invalid("A subnet in CIDR notation is required, for example 192.168.1.0/24.")
}
n.Description = strings.TrimSpace(in.Description)
if in.Enabled != nil {
n.Enabled = *in.Enabled
} else if base.ID == 0 {
n.Enabled = true
}
return n, nil
}
// CreateNetwork stores a client network.
func (a *App) CreateNetwork(ctx context.Context, actor auditlog.Actor, in NetworkInput) (models.Network, error) {
n, err := a.normaliseNetwork(in, models.Network{})
if err != nil {
return models.Network{}, err
}
created, err := a.DB.CreateNetwork(ctx, n, in.PolicyIDs)
if err != nil {
return models.Network{}, translate(err, "Network not found.",
fmt.Sprintf("A network named %q already exists.", n.Name))
}
a.Audit.RecordID(ctx, actor, "network.create", auditlog.ObjectNetwork, created.ID, created.Name,
auditlog.Changes("cidr", created.CIDR, "policies", fmt.Sprint(len(in.PolicyIDs))))
a.Runtime.RequestReload()
return created, nil
}
// UpdateNetwork saves a client network and its policy assignments.
func (a *App) UpdateNetwork(ctx context.Context, actor auditlog.Actor, id int64, in NetworkInput) (models.Network, error) {
existing, err := a.Network(ctx, id)
if err != nil {
return models.Network{}, err
}
n, err := a.normaliseNetwork(in, existing)
if err != nil {
return models.Network{}, err
}
n.ID = id
updated, err := a.DB.UpdateNetwork(ctx, n, in.PolicyIDs)
if err != nil {
return models.Network{}, translate(err, fmt.Sprintf("Network %d was not found.", id),
fmt.Sprintf("A network named %q already exists.", n.Name))
}
a.Audit.RecordID(ctx, actor, "network.update", auditlog.ObjectNetwork, id, updated.Name,
auditlog.Changes("cidr", updated.CIDR, "policies", fmt.Sprint(len(in.PolicyIDs))))
a.Runtime.RequestReload()
return updated, nil
}
// SetNetworkEnabled toggles a client network.
func (a *App) SetNetworkEnabled(ctx context.Context, actor auditlog.Actor, id int64, enabled bool) error {
n, err := a.Network(ctx, id)
if err != nil {
return err
}
if err := a.DB.SetNetworkEnabled(ctx, id, enabled); err != nil {
return translate(err, fmt.Sprintf("Network %d was not found.", id), "")
}
action := "network.disable"
if enabled {
action = "network.enable"
}
a.Audit.RecordID(ctx, actor, action, auditlog.ObjectNetwork, id, n.Name, "")
a.Runtime.RequestReload()
return nil
}
// DeleteNetwork removes a client network.
func (a *App) DeleteNetwork(ctx context.Context, actor auditlog.Actor, id int64) error {
n, err := a.Network(ctx, id)
if err != nil {
return err
}
if err := a.DB.DeleteNetwork(ctx, id); err != nil {
return translate(err, fmt.Sprintf("Network %d was not found.", id), "")
}
a.Audit.RecordID(ctx, actor, "network.delete", auditlog.ObjectNetwork, id, n.Name,
auditlog.Changes("cidr", n.CIDR))
a.Runtime.RequestReload()
return nil
}
// --- Policies -----------------------------------------------------------
// PolicyInput is the editable surface of a policy.
type PolicyInput struct {
Name string `json:"name"`
Description string `json:"description"`
Enabled *bool `json:"enabled"`
BlockAction string `json:"block_action"`
SinkholeIPv4 string `json:"sinkhole_ipv4"`
SinkholeIPv6 string `json:"sinkhole_ipv6"`
BlockTTL uint32 `json:"block_ttl"`
ListIDs []int64 `json:"list_ids"`
}
// Policies lists every policy.
func (a *App) Policies(ctx context.Context) ([]models.Policy, error) {
p, err := a.DB.Policies(ctx)
if err != nil {
return nil, Internal(err, "The policy list could not be loaded.")
}
return p, nil
}
// Policy loads one policy.
func (a *App) Policy(ctx context.Context, id int64) (models.Policy, error) {
p, err := a.DB.Policy(ctx, id)
if errors.Is(err, database.ErrNotFound) {
return p, NotFound("Policy %d was not found.", id)
}
if err != nil {
return p, Internal(err, "The policy could not be loaded.")
}
return p, nil
}
func (a *App) normalisePolicy(in PolicyInput, base models.Policy) (models.Policy, error) {
p := base
if name := strings.TrimSpace(in.Name); name != "" {
p.Name = name
}
if p.Name == "" {
return p, Invalid("A policy name is required.")
}
p.Description = strings.TrimSpace(in.Description)
action := models.BlockAction(strings.ToLower(strings.TrimSpace(in.BlockAction)))
if action == "" {
action = base.BlockAction
}
if action == "" {
action = models.BlockNXDOMAIN
}
if !action.Valid() {
return p, Invalid("Block action %q must be nxdomain, refused or sinkhole.", in.BlockAction)
}
p.BlockAction = action
p.SinkholeIPv4 = strings.TrimSpace(in.SinkholeIPv4)
if p.SinkholeIPv4 == "" {
p.SinkholeIPv4 = "0.0.0.0"
}
p.SinkholeIPv6 = strings.TrimSpace(in.SinkholeIPv6)
if p.SinkholeIPv6 == "" {
p.SinkholeIPv6 = "::"
}
if action == models.BlockSinkhole {
if err := requireIP(p.SinkholeIPv4, true); err != nil {
return p, Invalid("Sinkhole IPv4 address: %s", err.Error())
}
if err := requireIP(p.SinkholeIPv6, false); err != nil {
return p, Invalid("Sinkhole IPv6 address: %s", err.Error())
}
}
p.BlockTTL = in.BlockTTL
if p.BlockTTL == 0 {
p.BlockTTL = base.BlockTTL
}
if p.BlockTTL == 0 {
p.BlockTTL = 60
}
if p.BlockTTL > 86400 {
return p, Invalid("The block TTL must be 86400 seconds or less.")
}
if in.Enabled != nil {
p.Enabled = *in.Enabled
} else if base.ID == 0 {
p.Enabled = true
}
return p, nil
}
func requireIP(s string, wantV4 bool) error {
p, err := config.ParseCIDROrIP(s)
if err != nil {
return errors.New("must be a valid IP address")
}
if p.Addr().Is4() != wantV4 {
if wantV4 {
return errors.New("must be an IPv4 address")
}
return errors.New("must be an IPv6 address")
}
return nil
}
// CreatePolicy stores a policy.
func (a *App) CreatePolicy(ctx context.Context, actor auditlog.Actor, in PolicyInput) (models.Policy, error) {
p, err := a.normalisePolicy(in, models.Policy{})
if err != nil {
return models.Policy{}, err
}
created, err := a.DB.CreatePolicy(ctx, p, in.ListIDs)
if err != nil {
return models.Policy{}, translate(err, "Policy not found.",
fmt.Sprintf("A policy named %q already exists.", p.Name))
}
a.Audit.RecordID(ctx, actor, "policy.create", auditlog.ObjectPolicy, created.ID, created.Name,
auditlog.Changes("action", string(created.BlockAction), "lists", fmt.Sprint(len(in.ListIDs))))
a.Runtime.RequestReload()
return created, nil
}
// UpdatePolicy saves a policy.
func (a *App) UpdatePolicy(ctx context.Context, actor auditlog.Actor, id int64, in PolicyInput) (models.Policy, error) {
existing, err := a.Policy(ctx, id)
if err != nil {
return models.Policy{}, err
}
p, err := a.normalisePolicy(in, existing)
if err != nil {
return models.Policy{}, err
}
p.ID = id
updated, err := a.DB.UpdatePolicy(ctx, p, in.ListIDs)
if err != nil {
return models.Policy{}, translate(err, fmt.Sprintf("Policy %d was not found.", id),
fmt.Sprintf("A policy named %q already exists.", p.Name))
}
a.Audit.RecordID(ctx, actor, "policy.update", auditlog.ObjectPolicy, id, updated.Name,
auditlog.Changes("action", string(updated.BlockAction), "lists", fmt.Sprint(len(in.ListIDs))))
a.Runtime.RequestReload()
return updated, nil
}
// SetPolicyEnabled toggles a policy.
func (a *App) SetPolicyEnabled(ctx context.Context, actor auditlog.Actor, id int64, enabled bool) error {
p, err := a.Policy(ctx, id)
if err != nil {
return err
}
if err := a.DB.SetPolicyEnabled(ctx, id, enabled); err != nil {
return translate(err, fmt.Sprintf("Policy %d was not found.", id), "")
}
action := "policy.disable"
if enabled {
action = "policy.enable"
}
a.Audit.RecordID(ctx, actor, action, auditlog.ObjectPolicy, id, p.Name, "")
a.Runtime.RequestReload()
return nil
}
// DeletePolicy removes a policy.
func (a *App) DeletePolicy(ctx context.Context, actor auditlog.Actor, id int64) error {
p, err := a.Policy(ctx, id)
if err != nil {
return err
}
if err := a.DB.DeletePolicy(ctx, id); err != nil {
return translate(err, fmt.Sprintf("Policy %d was not found.", id), "")
}
a.Audit.RecordID(ctx, actor, "policy.delete", auditlog.ObjectPolicy, id, p.Name, "")
a.Runtime.RequestReload()
return nil
}
// --- Domain lists -------------------------------------------------------
// ListInput is the editable surface of a blacklist or allowlist.
type ListInput struct {
Kind string `json:"kind"`
Name string `json:"name"`
Description string `json:"description"`
Enabled *bool `json:"enabled"`
SourceURL string `json:"source_url"`
}
// DomainLists returns blacklists, allowlists, or both when kind is empty.
func (a *App) DomainLists(ctx context.Context, kind, search string) ([]models.DomainList, error) {
lists, err := a.DB.DomainLists(ctx, kind, search)
if err != nil {
return nil, Internal(err, "The list could not be loaded.")
}
return lists, nil
}
// DomainList loads one list.
func (a *App) DomainList(ctx context.Context, id int64) (models.DomainList, error) {
l, err := a.DB.DomainList(ctx, id)
if errors.Is(err, database.ErrNotFound) {
return l, NotFound("List %d was not found.", id)
}
if err != nil {
return l, Internal(err, "The list could not be loaded.")
}
return l, nil
}
// CreateDomainList stores a blacklist or allowlist.
func (a *App) CreateDomainList(ctx context.Context, actor auditlog.Actor, in ListInput) (models.DomainList, error) {
kind := strings.ToLower(strings.TrimSpace(in.Kind))
if kind != models.KindBlacklist && kind != models.KindAllowlist {
return models.DomainList{}, Invalid("List kind must be blacklist or allowlist.")
}
name := strings.TrimSpace(in.Name)
if name == "" {
return models.DomainList{}, Invalid("A list name is required.")
}
enabled := true
if in.Enabled != nil {
enabled = *in.Enabled
}
l := models.DomainList{
Kind: kind,
Name: name,
Description: strings.TrimSpace(in.Description),
Enabled: enabled,
SourceURL: strings.TrimSpace(in.SourceURL),
}
created, err := a.DB.CreateDomainList(ctx, l)
if err != nil {
return models.DomainList{}, translate(err, "List not found.",
fmt.Sprintf("A %s named %q already exists.", kind, name))
}
a.Audit.RecordID(ctx, actor, "list.create", auditlog.ObjectList, created.ID, created.Name,
auditlog.Changes("kind", created.Kind))
a.Runtime.RequestReload()
return created, nil
}
// UpdateDomainList saves list metadata.
func (a *App) UpdateDomainList(ctx context.Context, actor auditlog.Actor, id int64, in ListInput) (models.DomainList, error) {
existing, err := a.DomainList(ctx, id)
if err != nil {
return models.DomainList{}, err
}
if name := strings.TrimSpace(in.Name); name != "" {
existing.Name = name
}
existing.Description = strings.TrimSpace(in.Description)
existing.SourceURL = strings.TrimSpace(in.SourceURL)
if in.Enabled != nil {
existing.Enabled = *in.Enabled
}
updated, err := a.DB.UpdateDomainList(ctx, existing)
if err != nil {
return models.DomainList{}, translate(err, fmt.Sprintf("List %d was not found.", id),
fmt.Sprintf("A list named %q already exists.", existing.Name))
}
a.Audit.RecordID(ctx, actor, "list.update", auditlog.ObjectList, id, updated.Name, "")
a.Runtime.RequestReload()
return updated, nil
}
// SetDomainListEnabled toggles a list.
func (a *App) SetDomainListEnabled(ctx context.Context, actor auditlog.Actor, id int64, enabled bool) error {
l, err := a.DomainList(ctx, id)
if err != nil {
return err
}
if err := a.DB.SetDomainListEnabled(ctx, id, enabled); err != nil {
return translate(err, fmt.Sprintf("List %d was not found.", id), "")
}
action := "list.disable"
if enabled {
action = "list.enable"
}
a.Audit.RecordID(ctx, actor, action, auditlog.ObjectList, id, l.Name, "")
a.Runtime.RequestReload()
return nil
}
// DeleteDomainList removes a list and every domain in it.
func (a *App) DeleteDomainList(ctx context.Context, actor auditlog.Actor, id int64) error {
l, err := a.DomainList(ctx, id)
if err != nil {
return err
}
if err := a.DB.DeleteDomainList(ctx, id); err != nil {
return translate(err, fmt.Sprintf("List %d was not found.", id), "")
}
a.Audit.RecordID(ctx, actor, "list.delete", auditlog.ObjectList, id, l.Name,
auditlog.Changes("domains", fmt.Sprint(l.DomainCount)))
a.Runtime.RequestReload()
return nil
}
// --- Domain entries -----------------------------------------------------
// DomainEntries pages through a list's domains.
func (a *App) DomainEntries(ctx context.Context, listID int64, search string, limit, offset int) ([]models.DomainEntry, int, error) {
entries, total, err := a.DB.DomainEntries(ctx, listID, search, limit, offset)
if err != nil {
return nil, 0, Internal(err, "The domains could not be loaded.")
}
return entries, total, nil
}
// AddDomain adds one domain to a list.
func (a *App) AddDomain(ctx context.Context, actor auditlog.Actor, listID int64, domain string, matchSubdomains bool, comment string) (models.DomainEntry, error) {
l, err := a.DomainList(ctx, listID)
if err != nil {
return models.DomainEntry{}, err
}
d := strings.TrimSpace(domain)
if strings.HasPrefix(d, "*.") {
d = strings.TrimPrefix(d, "*.")
matchSubdomains = true
}
normalised, err := validate.NormaliseDomain(d)
if err != nil {
return models.DomainEntry{}, Invalid("%s", err.Error())
}
entry, err := a.DB.AddDomain(ctx, models.DomainEntry{
ListID: listID,
Domain: normalised,
MatchSubdomains: matchSubdomains,
Enabled: true,
Comment: strings.TrimSpace(comment),
})
if err != nil {
return models.DomainEntry{}, translate(err, "List not found.",
fmt.Sprintf("%s is already in %s.", normalised, l.Name))
}
a.Audit.RecordID(ctx, actor, "domain.add", auditlog.ObjectDomain, entry.ID, normalised,
auditlog.Changes("list", l.Name))
a.Runtime.RequestReload()
return entry, nil
}
// UpdateDomain saves an existing domain entry.
func (a *App) UpdateDomain(ctx context.Context, actor auditlog.Actor, listID, id int64, domain string, matchSubdomains, enabled bool, comment string) error {
normalised, err := validate.NormaliseDomain(strings.TrimPrefix(strings.TrimSpace(domain), "*."))
if err != nil {
return Invalid("%s", err.Error())
}
e := models.DomainEntry{
ID: id, ListID: listID, Domain: normalised,
MatchSubdomains: matchSubdomains, Enabled: enabled, Comment: strings.TrimSpace(comment),
}
if err := a.DB.UpdateDomain(ctx, e); err != nil {
return translate(err, fmt.Sprintf("Domain %d was not found.", id),
fmt.Sprintf("%s is already in this list.", normalised))
}
a.Audit.RecordID(ctx, actor, "domain.update", auditlog.ObjectDomain, id, normalised, "")
a.Runtime.RequestReload()
return nil
}
// DeleteDomain removes one domain from a list.
func (a *App) DeleteDomain(ctx context.Context, actor auditlog.Actor, id int64) error {
if err := a.DB.DeleteDomain(ctx, id); err != nil {
return translate(err, fmt.Sprintf("Domain %d was not found.", id), "")
}
a.Audit.RecordID(ctx, actor, "domain.delete", auditlog.ObjectDomain, id, "", "")
a.Runtime.RequestReload()
return nil
}
// ClearDomains empties a list.
func (a *App) ClearDomains(ctx context.Context, actor auditlog.Actor, listID int64) (int64, error) {
l, err := a.DomainList(ctx, listID)
if err != nil {
return 0, err
}
n, err := a.DB.ClearDomains(ctx, listID)
if err != nil {
return 0, Internal(err, "The list could not be cleared.")
}
a.Audit.RecordID(ctx, actor, "list.clear", auditlog.ObjectList, listID, l.Name,
auditlog.Changes("removed", fmt.Sprint(n)))
a.Runtime.RequestReload()
return n, nil
}
// ImportDomains parses a domain list and stores it.
//
// Parsing happens fully in memory and the insert runs as a single transaction
// with one prepared statement, so a list of several hundred thousand domains
// is one commit rather than one commit per domain.
func (a *App) ImportDomains(ctx context.Context, actor auditlog.Actor, listID int64,
r io.Reader, matchSubdomains bool) (models.ImportSummary, error) {
l, err := a.DomainList(ctx, listID)
if err != nil {
return models.ImportSummary{}, err
}
parsed, summary := blacklist.Parse(r, blacklist.ParseOptions{DefaultMatchSubdomains: matchSubdomains})
if len(parsed) == 0 {
if summary.LinesProcessed == 0 {
return summary, Invalid("The import was empty.")
}
return summary, Invalid("No valid domains were found in %d lines. "+
"Supported formats are a plain domain list, a hosts file, or Adblock-style ||domain^ rules.",
summary.LinesProcessed)
}
rows := make([]database.ImportDomain, 0, len(parsed))
for _, p := range parsed {
rows = append(rows, database.ImportDomain{Domain: p.Domain, MatchSubdomains: p.MatchSubdomains})
}
imported, duplicates, err := a.DB.ImportDomains(ctx, listID, rows)
if err != nil {
return summary, Internal(err, "The domains could not be imported.")
}
// The parser counts duplicates within the file; the database reports
// domains that were already present. The summary shows the total.
summary.Imported = imported
summary.Duplicates += duplicates
a.Audit.RecordID(ctx, actor, "list.import", auditlog.ObjectList, listID, l.Name,
auditlog.Changes(
"imported", fmt.Sprint(summary.Imported),
"duplicates", fmt.Sprint(summary.Duplicates),
"invalid", fmt.Sprint(summary.Invalid),
"lines", fmt.Sprint(summary.LinesProcessed)))
a.Runtime.RequestReload()
return summary, nil
}
// ExportDomains writes a list as a plain domain list.
func (a *App) ExportDomains(ctx context.Context, listID int64, w io.Writer) (models.DomainList, error) {
l, err := a.DomainList(ctx, listID)
if err != nil {
return l, err
}
fmt.Fprintf(w, "# %s\n", l.Name)
if l.Description != "" {
fmt.Fprintf(w, "# %s\n", l.Description)
}
fmt.Fprintf(w, "# %d domains exported by VibeDNS\n", l.DomainCount)
err = a.DB.ExportDomains(ctx, listID, func(domain string, matchSubdomains bool) {
if matchSubdomains {
fmt.Fprintln(w, domain)
return
}
// A domain that must match exactly is written in a form the importer
// will not silently widen.
fmt.Fprintf(w, "%s # exact\n", domain)
})
if err != nil {
return l, Internal(err, "The domains could not be exported.")
}
return l, nil
}
// LookupDomain reports which lists cover a name, for the "why was this
// blocked?" tool.
type LookupHit struct {
ListID int64 `json:"list_id"`
ListName string `json:"list_name"`
Kind string `json:"kind"`
Matched string `json:"matched_domain"`
}
// LookupDomain checks a name against every compiled list.
func (a *App) LookupDomain(ctx context.Context, name string) ([]LookupHit, error) {
domain, err := validate.NormaliseDomain(name)
if err != nil {
return nil, Invalid("%s", err.Error())
}
lists, err := a.DB.DomainLists(ctx, "", "")
if err != nil {
return nil, Internal(err, "The lists could not be loaded.")
}
sets := a.Snapshot().Policy.Sets()
var hits []LookupHit
for _, l := range lists {
set, ok := sets[l.ID]
if !ok {
continue // list is disabled, so it was not compiled
}
if matched, found := set.Match(domain); found {
hits = append(hits, LookupHit{
ListID: l.ID, ListName: l.Name, Kind: l.Kind, Matched: matched,
})
}
}
return hits, nil
}
+331
View File
@@ -0,0 +1,331 @@
package app
import (
"context"
"errors"
"fmt"
"strings"
"github.com/owen/vibedns/internal/auditlog"
"github.com/owen/vibedns/internal/database"
"github.com/owen/vibedns/internal/models"
"github.com/owen/vibedns/internal/validate"
)
// RecordInput is the editable surface of a resource record.
//
// Data may be supplied either as finished rdata (the advanced editor and the
// REST API) or as the individual fields of a type-specific editor, which the
// service assembles and quotes correctly.
type RecordInput struct {
Name string `json:"name"`
Type string `json:"type"`
Data string `json:"data"`
Fields map[string]string `json:"fields,omitempty"`
TTL *uint32 `json:"ttl"`
Enabled *bool `json:"enabled"`
Comment string `json:"comment"`
}
// Records lists records matching a filter, with the total match count.
func (a *App) Records(ctx context.Context, f database.RecordFilter) ([]models.Record, int, error) {
recs, total, err := a.DB.Records(ctx, f)
if err != nil {
return nil, 0, Internal(err, "The record list could not be loaded.")
}
return recs, total, nil
}
// Record loads one record.
func (a *App) Record(ctx context.Context, id int64) (models.Record, error) {
r, err := a.DB.Record(ctx, id)
if errors.Is(err, database.ErrNotFound) {
return r, NotFound("Record %d was not found.", id)
}
if err != nil {
return r, Internal(err, "The record could not be loaded.")
}
return r, nil
}
// CreateRecord validates and stores a record.
func (a *App) CreateRecord(ctx context.Context, actor auditlog.Actor, zoneID int64, in RecordInput) (models.Record, error) {
zone, err := a.Zone(ctx, zoneID)
if err != nil {
return models.Record{}, err
}
rec, err := a.prepareRecord(ctx, zone, in, 0)
if err != nil {
return models.Record{}, err
}
created, err := a.DB.CreateRecord(ctx, rec)
if err != nil {
return models.Record{}, Internal(err, "The record could not be saved.")
}
a.Audit.RecordID(ctx, actor, "record.create", auditlog.ObjectRecord, created.ID,
validate.AbsoluteName(created.Name, zone.Name),
auditlog.Changes("zone", zone.Name, "type", created.Type, "data", created.Data))
a.Runtime.RequestReload()
return created, nil
}
// UpdateRecord validates and saves an existing record.
func (a *App) UpdateRecord(ctx context.Context, actor auditlog.Actor, id int64, in RecordInput) (models.Record, error) {
existing, err := a.Record(ctx, id)
if err != nil {
return models.Record{}, err
}
zone, err := a.Zone(ctx, existing.ZoneID)
if err != nil {
return models.Record{}, err
}
// Carry forward anything the caller did not supply, so a PATCH-style
// update does not silently blank fields.
if strings.TrimSpace(in.Name) == "" {
in.Name = existing.Name
}
if strings.TrimSpace(in.Type) == "" {
in.Type = existing.Type
}
if in.Enabled == nil {
e := existing.Enabled
in.Enabled = &e
}
rec, err := a.prepareRecord(ctx, zone, in, id)
if err != nil {
return models.Record{}, err
}
rec.ID = id
rec.ZoneID = existing.ZoneID
updated, err := a.DB.UpdateRecord(ctx, rec)
if err != nil {
return models.Record{}, translate(err, fmt.Sprintf("Record %d was not found.", id), "")
}
a.Audit.RecordID(ctx, actor, "record.update", auditlog.ObjectRecord, id,
validate.AbsoluteName(updated.Name, zone.Name),
auditlog.Changes("zone", zone.Name, "type", updated.Type, "data", updated.Data))
a.Runtime.RequestReload()
return updated, nil
}
// prepareRecord validates a record against its zone. excludeID lets an update
// ignore the record being edited when checking for conflicts.
func (a *App) prepareRecord(ctx context.Context, zone models.Zone, in RecordInput, excludeID int64) (models.Record, error) {
rtype, err := validate.NormaliseType(in.Type)
if err != nil {
return models.Record{}, Invalid("%s", err.Error())
}
name, err := validate.NormaliseRecordName(in.Name, zone.Name)
if err != nil {
return models.Record{}, Invalid("%s", err.Error())
}
data := strings.TrimSpace(in.Data)
if data == "" && len(in.Fields) > 0 {
data, err = validate.AssembleRData(rtype, in.Fields)
if err != nil {
return models.Record{}, Invalid("%s", err.Error())
}
}
if data == "" {
return models.Record{}, Invalid("Record data is required for a %s record.", rtype)
}
// TXT-style types are quoted for the caller when they clearly are not.
if (rtype == "TXT" || rtype == "SPF") && !strings.HasPrefix(data, `"`) {
data = validate.QuoteTXT(data)
}
ttl := zone.DefaultTTL
if in.TTL != nil {
if *in.TTL < 1 || *in.TTL > 604800 {
return models.Record{}, Invalid("The TTL must be between 1 and 604800 seconds.")
}
ttl = *in.TTL
}
if name == "@" {
if err := validate.ApexRestricted(rtype); err != nil {
return models.Record{}, Invalid("%s", err.Error())
}
}
// Compile the record now so a malformed value is reported here, with a
// message about this record, rather than as a warning at index-build time.
if _, err := validate.BuildRR(zone.Name, name, rtype, data, ttl); err != nil {
return models.Record{}, Invalid("%s", err.Error())
}
if err := a.checkCNAMEConflict(ctx, zone, name, rtype, excludeID); err != nil {
return models.Record{}, err
}
enabled := true
if in.Enabled != nil {
enabled = *in.Enabled
}
rec := models.Record{
ZoneID: zone.ID,
Name: name,
Type: rtype,
Data: data,
Enabled: enabled,
Comment: strings.TrimSpace(in.Comment),
}
if in.TTL != nil {
rec.TTL = in.TTL
}
return rec, nil
}
// checkCNAMEConflict enforces the RFC 1034 rule that a CNAME may not coexist
// with other data at the same name.
func (a *App) checkCNAMEConflict(ctx context.Context, zone models.Zone, name, rtype string, excludeID int64) error {
existing, _, err := a.DB.Records(ctx, database.RecordFilter{ZoneID: zone.ID})
if err != nil {
return Internal(err, "Existing records could not be checked.")
}
var types []string
for _, r := range existing {
if r.ID == excludeID || r.Name != name {
continue
}
types = append(types, r.Type)
}
if err := validate.CNAMEConflict(rtype, types); err != nil {
return Invalid("%s", err.Error())
}
return nil
}
// SetRecordEnabled toggles one record.
func (a *App) SetRecordEnabled(ctx context.Context, actor auditlog.Actor, id int64, enabled bool) error {
rec, err := a.Record(ctx, id)
if err != nil {
return err
}
if err := a.DB.SetRecordEnabled(ctx, id, enabled); err != nil {
return translate(err, fmt.Sprintf("Record %d was not found.", id), "")
}
action := "record.disable"
if enabled {
action = "record.enable"
}
a.Audit.RecordID(ctx, actor, action, auditlog.ObjectRecord, id, rec.Name,
auditlog.Changes("type", rec.Type))
a.Runtime.RequestReload()
return nil
}
// DeleteRecord removes one record.
func (a *App) DeleteRecord(ctx context.Context, actor auditlog.Actor, id int64) error {
rec, err := a.Record(ctx, id)
if err != nil {
return err
}
if err := a.DB.DeleteRecord(ctx, id); err != nil {
return translate(err, fmt.Sprintf("Record %d was not found.", id), "")
}
a.Audit.RecordID(ctx, actor, "record.delete", auditlog.ObjectRecord, id, rec.Name,
auditlog.Changes("type", rec.Type, "data", rec.Data))
a.Runtime.RequestReload()
return nil
}
// BulkAction names a bulk operation on selected records.
type BulkAction string
// Supported bulk operations.
const (
BulkEnable BulkAction = "enable"
BulkDisable BulkAction = "disable"
BulkDelete BulkAction = "delete"
)
// BulkRecords applies an action to several records of one zone.
func (a *App) BulkRecords(ctx context.Context, actor auditlog.Actor, zoneID int64, ids []int64, action BulkAction) (int, error) {
zone, err := a.Zone(ctx, zoneID)
if err != nil {
return 0, err
}
if len(ids) == 0 {
return 0, Invalid("Select at least one record first.")
}
var n int
switch action {
case BulkDelete:
n, err = a.DB.DeleteRecords(ctx, zoneID, ids)
case BulkEnable:
n, err = a.DB.SetRecordsEnabled(ctx, zoneID, ids, true)
case BulkDisable:
n, err = a.DB.SetRecordsEnabled(ctx, zoneID, ids, false)
default:
return 0, Invalid("Unknown bulk action %q.", action)
}
if err != nil {
return 0, Internal(err, "The selected records could not be updated.")
}
a.Audit.RecordID(ctx, actor, "record.bulk_"+string(action), auditlog.ObjectRecord, zoneID, zone.Name,
auditlog.Changes("records", fmt.Sprint(n)))
a.Runtime.RequestReload()
return n, nil
}
// RecordTypes returns the record type catalogue for the editors.
func (a *App) RecordTypes() []validate.TypeInfo { return validate.TypeInfos() }
// RecordTypesInUse lists the distinct types present in a zone, for filters.
func (a *App) RecordTypesInUse(ctx context.Context, zoneID int64) ([]string, error) {
types, err := a.DB.RecordTypesInUse(ctx, zoneID)
if err != nil {
return nil, Internal(err, "Record types could not be loaded.")
}
return types, nil
}
// PTRSuggestion describes the reverse record the UI offers to create alongside
// an address record.
type PTRSuggestion struct {
ZoneID int64 `json:"zone_id"`
ZoneName string `json:"zone_name"`
Name string `json:"name"`
Data string `json:"data"`
}
// SuggestPTR finds the reverse zone covering an address and returns the PTR
// record that would point back at hostname.
func (a *App) SuggestPTR(ctx context.Context, ip, hostname string) (*PTRSuggestion, error) {
ptrName, err := validate.PTRName(ip)
if err != nil {
return nil, Invalid("%s", err.Error())
}
target, err := validate.NormaliseFQDN(hostname)
if err != nil {
return nil, Invalid("%s", err.Error())
}
zones, err := a.DB.Zones(ctx, database.ZoneFilter{Kind: "reverse"})
if err != nil {
return nil, Internal(err, "Reverse zones could not be loaded.")
}
var best models.Zone
for _, z := range zones {
if validate.IsSubdomain(ptrName, z.Name) && len(z.Name) > len(best.Name) {
best = z
}
}
if best.ID == 0 {
return nil, NotFound("No reverse zone covers %s. Create one first.", ip)
}
rel, err := validate.NormaliseRecordName(ptrName, best.Name)
if err != nil {
return nil, Invalid("%s", err.Error())
}
return &PTRSuggestion{ZoneID: best.ID, ZoneName: best.Name, Name: rel, Data: target}, nil
}
+122
View File
@@ -0,0 +1,122 @@
package app
import (
"context"
"fmt"
"sort"
"strings"
"github.com/owen/vibedns/internal/auditlog"
"github.com/owen/vibedns/internal/config"
)
// RestartRequired lists the settings that only take effect after a restart,
// because they control a bound socket.
var RestartRequired = map[string]string{
config.KeyDNSUDPListen: "DNS UDP listen address",
config.KeyDNSTCPListen: "DNS TCP listen address",
config.KeyHTTPListen: "Management HTTP listen address",
}
// SettingsGroup names a page of the settings interface.
type SettingsGroup string
// Settings pages.
const (
GroupDNS SettingsGroup = "dns"
GroupResolver SettingsGroup = "resolver"
GroupCache SettingsGroup = "cache"
GroupLogging SettingsGroup = "logging"
GroupHTTP SettingsGroup = "http"
GroupBackup SettingsGroup = "backup"
GroupRateLimit SettingsGroup = "ratelimit"
)
// SaveSettings validates and persists a complete settings object.
//
// Validation runs against the merged result rather than the submitted fields,
// so a change that would leave the server in an unusable state — recursion on
// with no upstreams, or an empty ACL — is rejected before it is stored.
func (a *App) SaveSettings(ctx context.Context, actor auditlog.Actor, group SettingsGroup, next config.Settings) error {
next.Normalise()
if err := next.Validate(); err != nil {
return Invalid("%s", err.Error())
}
current := a.Settings()
changed := diffSettings(current.ToMap(), next.ToMap())
if len(changed) == 0 {
return nil
}
// Only the keys belonging to this group are written, so two administrators
// editing different pages cannot overwrite each other's work.
toWrite := map[string]string{}
full := next.ToMap()
for _, k := range changed {
toWrite[k] = full[k]
}
if err := a.DB.SetSettings(ctx, toWrite); err != nil {
return Internal(err, "The settings could not be saved.")
}
a.Audit.Record(ctx, actor, "settings.update", auditlog.ObjectSettings, string(group), string(group),
auditlog.Changes("keys", strings.Join(changed, " ")))
if err := a.Runtime.Reload(ctx); err != nil {
return Internal(err, "The settings were saved but could not be applied. Restart the server.")
}
return nil
}
// diffSettings returns the keys whose values differ.
func diffSettings(before, after map[string]string) []string {
var changed []string
for k, v := range after {
if before[k] != v {
changed = append(changed, k)
}
}
sort.Strings(changed)
return changed
}
// PendingRestart reports which changed settings need a restart to take effect.
func (a *App) PendingRestart(ctx context.Context) []string {
current := a.Settings()
udp, tcp := a.DNS.ListenAddrs()
var pending []string
if current.DNS.UDPListen != udp {
pending = append(pending, fmt.Sprintf("DNS UDP address (listening on %s, configured as %s)",
udp, current.DNS.UDPListen))
}
if current.DNS.TCPListen != tcp {
pending = append(pending, fmt.Sprintf("DNS TCP address (listening on %s, configured as %s)",
tcp, current.DNS.TCPListen))
}
return pending
}
// TestUpstream probes one upstream resolver on demand.
func (a *App) TestUpstream(ctx context.Context, addr, qname string) (string, error) {
s := a.Settings()
if strings.TrimSpace(qname) == "" {
qname = "example.com"
}
upstreams := config.SplitLines(addr)
if len(upstreams) == 0 {
return "", Invalid("Enter an upstream resolver address.")
}
target := upstreams[0]
if !strings.Contains(target, ":") {
target += ":53"
}
rtt, rcode, err := resolverCheck(ctx, target, qname, s)
if err != nil {
return "", Invalid("%s", err.Error())
}
return fmt.Sprintf("%s answered %s in %.0f ms", target, rcode, float64(rtt.Microseconds())/1000), nil
}
+305
View File
@@ -0,0 +1,305 @@
package app
import (
"context"
"net/netip"
"strings"
"time"
"github.com/owen/vibedns/internal/auditlog"
"github.com/owen/vibedns/internal/cache"
"github.com/owen/vibedns/internal/database"
"github.com/owen/vibedns/internal/metrics"
"github.com/owen/vibedns/internal/models"
"github.com/owen/vibedns/internal/ratelimit"
"github.com/owen/vibedns/internal/resolver"
)
// Dashboard is everything the overview page and /api/v1/stats report.
type Dashboard struct {
Uptime time.Duration `json:"uptime"`
UptimeText string `json:"uptime_text"`
StartedAt time.Time `json:"started_at"`
Hostname string `json:"hostname"`
Version string `json:"version"`
TotalQueries int64 `json:"total_queries"`
QueriesPerSec float64 `json:"queries_per_second"`
Authoritative int64 `json:"authoritative_queries"`
Recursive int64 `json:"recursive_queries"`
Blocked int64 `json:"blocked_queries"`
Refused int64 `json:"refused_queries"`
RateLimited int64 `json:"ratelimited_queries"`
Errors int64 `json:"errors"`
AvgQueryMS float64 `json:"avg_query_ms"`
AvgResolverMS float64 `json:"avg_resolver_ms"`
BlockRate float64 `json:"block_rate"`
CacheHits int64 `json:"cache_hits"`
CacheMisses int64 `json:"cache_misses"`
CacheHitRate float64 `json:"cache_hit_rate"`
CacheEntries int `json:"cache_entries"`
CacheBytes int64 `json:"cache_bytes"`
CacheEnabled bool `json:"cache_enabled"`
Zones int `json:"zones"`
Records int `json:"records"`
Blacklists int `json:"blacklists"`
BlacklistDomains int `json:"blacklist_domains"`
Allowlists int `json:"allowlists"`
AllowlistDomains int `json:"allowlist_domains"`
Networks int `json:"networks"`
Policies int `json:"policies"`
QueriesByType []metrics.LabelValue `json:"queries_by_type"`
QueriesByRcode []metrics.LabelValue `json:"queries_by_rcode"`
QueriesBySource []metrics.LabelValue `json:"queries_by_source"`
TopDomains []database.NameCount `json:"top_domains"`
TopBlocked []database.NameCount `json:"top_blocked"`
TopClients []database.NameCount `json:"top_clients"`
Activity []database.TimeBucket `json:"activity"`
Upstreams []resolver.Status `json:"upstreams"`
RateLimit ratelimit.Stats `json:"rate_limit"`
QueryLog QueryLogStatus `json:"query_log"`
Recent []models.QueryLogEntry `json:"recent"`
RecursionEnabled bool `json:"recursion_enabled"`
DNSRunning bool `json:"dns_running"`
QueryLogEnabled bool `json:"query_log_enabled"`
}
// QueryLogStatus summarises the query log for the dashboard.
type QueryLogStatus struct {
Enabled bool `json:"enabled"`
Rows int64 `json:"rows"`
Written int64 `json:"written"`
Dropped int64 `json:"dropped"`
}
// activityWindow is how far back the dashboard chart looks.
const activityWindow = 24 * time.Hour
// activityBuckets is how many points the chart plots.
const activityBuckets = 48
// Dashboard assembles the overview data.
//
// The counters come from memory; only the top-N lists and the activity chart
// touch SQLite, and they are read-only aggregate queries over an indexed
// timestamp column.
func (a *App) Dashboard(ctx context.Context, topN int) (*Dashboard, error) {
if topN <= 0 {
topN = 10
}
m := a.Metrics
snap := a.Snapshot()
cs := a.Cache.Stats()
d := &Dashboard{
Uptime: a.Uptime(),
UptimeText: FormatDuration(a.Uptime()),
StartedAt: a.StartedAt(),
Hostname: Hostname(),
TotalQueries: m.QueriesTotal.Load(),
QueriesPerSec: m.QueriesPerSecond(),
Authoritative: m.Authoritative.Load(),
Recursive: m.Recursive.Load(),
Blocked: m.Blocked.Load(),
Refused: m.Refused.Load(),
RateLimited: m.RateLimited.Load(),
Errors: m.Errors.Load(),
AvgQueryMS: m.AvgQueryMS(),
AvgResolverMS: m.AvgResolverMS(),
CacheHits: m.CacheHits.Load(),
CacheMisses: m.CacheMisses.Load(),
CacheHitRate: m.CacheHitRate(),
CacheEntries: cs.Entries,
CacheBytes: cs.Bytes,
CacheEnabled: cs.Enabled,
Zones: snap.ZoneCount,
Records: snap.RecordCount,
BlacklistDomains: snap.BlacklistDomains,
AllowlistDomains: snap.AllowlistDomains,
Networks: snap.NetworkCount,
QueriesByType: m.ByType(),
QueriesByRcode: m.ByRcode(),
QueriesBySource: m.BySource(),
Upstreams: a.Resolver.Statuses(),
RateLimit: a.Limiter.Stats(),
RecursionEnabled: snap.Settings.DNS.Recursion,
DNSRunning: a.DNS.Running(),
QueryLogEnabled: snap.Settings.QueryLog.Enabled,
}
if d.TotalQueries > 0 {
d.BlockRate = float64(d.Blocked) / float64(d.TotalQueries) * 100
}
// Zone and record counts from the database include disabled objects, which
// the operator still wants to see on the dashboard.
if zones, records, err := a.DB.CountZonesAndRecords(ctx); err == nil {
d.Zones, d.Records = zones, records
}
if bl, bd, al, ad, err := a.DB.CountDomainLists(ctx); err == nil {
d.Blacklists, d.BlacklistDomains = bl, bd
d.Allowlists, d.AllowlistDomains = al, ad
}
if policies, err := a.DB.Policies(ctx); err == nil {
d.Policies = len(policies)
}
qs := a.QueryLog.Stats()
d.QueryLog = QueryLogStatus{Enabled: qs.Enabled, Written: qs.Written, Dropped: qs.Dropped}
if n, err := a.DB.QueryLogCount(ctx); err == nil {
d.QueryLog.Rows = n
}
// Query-log derived panels are only meaningful when logging is on.
if snap.Settings.QueryLog.Enabled {
since := time.Now().Add(-activityWindow)
if v, err := a.DB.TopQueried(ctx, since, topN); err == nil {
d.TopDomains = v
}
if v, err := a.DB.TopBlocked(ctx, since, topN); err == nil {
d.TopBlocked = v
}
if v, err := a.DB.TopClients(ctx, since, topN); err == nil {
d.TopClients = v
}
if v, err := a.DB.ActivityBuckets(ctx, since, activityWindow/activityBuckets, activityBuckets); err == nil {
d.Activity = v
}
if v, _, err := a.DB.QueryLogs(ctx, database.QueryLogFilter{Limit: 15}); err == nil {
d.Recent = v
}
}
return d, nil
}
// QueryLogs searches the query log.
func (a *App) QueryLogs(ctx context.Context, f database.QueryLogFilter) ([]models.QueryLogEntry, int, error) {
entries, total, err := a.DB.QueryLogs(ctx, f)
if err != nil {
return nil, 0, Internal(err, "The query log could not be loaded.")
}
return entries, total, nil
}
// ClearQueryLog empties the query log.
func (a *App) ClearQueryLog(ctx context.Context, actor auditlog.Actor) (int64, error) {
n, err := a.DB.TruncateQueryLogs(ctx)
if err != nil {
return 0, Internal(err, "The query log could not be cleared.")
}
a.Audit.Record(ctx, actor, "querylog.clear", auditlog.ObjectQueryLog, "", "query log",
auditlog.Changes("rows", formatInt(n)))
return n, nil
}
// AuditLogs searches the audit log.
func (a *App) AuditLogs(ctx context.Context, f database.AuditFilter) ([]models.AuditEntry, int, error) {
entries, total, err := a.DB.AuditLogs(ctx, f)
if err != nil {
return nil, 0, Internal(err, "The audit log could not be loaded.")
}
return entries, total, nil
}
// DatabaseStats reports database size and row counts.
func (a *App) DatabaseStats(ctx context.Context) (database.Stats, error) {
s, err := a.DB.Stats(ctx)
if err != nil {
return s, Internal(err, "Database statistics could not be read.")
}
return s, nil
}
// CacheStatsView bundles cache counters with its configuration for the UI.
type CacheStatsView struct {
cache.Stats
MinTTL int `json:"min_ttl"`
MaxTTL int `json:"max_ttl"`
NegativeTTL int `json:"negative_ttl"`
ServeStale bool `json:"serve_stale"`
StaleTTL int `json:"stale_ttl"`
Prefetch bool `json:"prefetch"`
}
// CacheView returns cache counters together with the active configuration.
func (a *App) CacheView() CacheStatsView {
s := a.Settings().Cache
return CacheStatsView{
Stats: a.Cache.Stats(),
MinTTL: s.MinTTL,
MaxTTL: s.MaxTTL,
NegativeTTL: s.NegativeTTL,
ServeStale: s.ServeStale,
StaleTTL: s.StaleTTL,
Prefetch: s.Prefetch,
}
}
// FormatDuration renders a duration the way an operator reads uptime.
func FormatDuration(d time.Duration) string {
if d < time.Minute {
return formatInt(int64(d.Seconds())) + "s"
}
days := int64(d.Hours()) / 24
hours := int64(d.Hours()) % 24
mins := int64(d.Minutes()) % 60
var parts []string
if days > 0 {
parts = append(parts, formatInt(days)+"d")
}
if hours > 0 {
parts = append(parts, formatInt(hours)+"h")
}
if mins > 0 || len(parts) == 0 {
parts = append(parts, formatInt(mins)+"m")
}
return strings.Join(parts, " ")
}
func formatInt(v int64) string {
if v == 0 {
return "0"
}
neg := v < 0
if neg {
v = -v
}
var buf [24]byte
i := len(buf)
for v > 0 {
i--
buf[i] = byte('0' + v%10)
v /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
// netipAddr parses a client address for the lookup tool, defaulting to
// loopback when the field is left blank.
func netipAddr(s string) (netip.Addr, bool) {
s = strings.TrimSpace(s)
if s == "" {
return netip.MustParseAddr("127.0.0.1"), true
}
addr, err := netip.ParseAddr(s)
if err != nil {
return netip.Addr{}, false
}
return addr.Unmap(), true
}
+377
View File
@@ -0,0 +1,377 @@
package app
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"strings"
"github.com/owen/vibedns/internal/auditlog"
"github.com/owen/vibedns/internal/database"
"github.com/owen/vibedns/internal/models"
"github.com/owen/vibedns/internal/validate"
"github.com/owen/vibedns/internal/zonefile"
)
// ZoneInput is the editable surface of a zone.
type ZoneInput struct {
Name string `json:"name"`
Kind string `json:"kind"`
CIDR string `json:"cidr"` // reverse zones may be created from a subnet instead
Description string `json:"description"`
Enabled *bool `json:"enabled"`
DefaultTTL uint32 `json:"default_ttl"`
PrimaryNS string `json:"primary_ns"`
AdminEmail string `json:"admin_email"`
Refresh uint32 `json:"refresh"`
Retry uint32 `json:"retry"`
Expire uint32 `json:"expire"`
Minimum uint32 `json:"minimum"`
AutoSerial *bool `json:"auto_serial"`
Serial *uint32 `json:"serial"`
}
// Zones lists zones matching a filter.
func (a *App) Zones(ctx context.Context, f database.ZoneFilter) ([]models.Zone, error) {
zones, err := a.DB.Zones(ctx, f)
if err != nil {
return nil, Internal(err, "The zone list could not be loaded.")
}
return zones, nil
}
// Zone loads one zone.
func (a *App) Zone(ctx context.Context, id int64) (models.Zone, error) {
z, err := a.DB.Zone(ctx, id)
if errors.Is(err, database.ErrNotFound) {
return z, NotFound("Zone %d was not found.", id)
}
if err != nil {
return z, Internal(err, "The zone could not be loaded.")
}
return z, nil
}
// CreateZone validates and stores a new zone.
//
// A reverse zone may be given either as an explicit apex name or as the subnet
// it covers, which is what the UI sends: administrators should not have to
// reverse octets by hand.
func (a *App) CreateZone(ctx context.Context, actor auditlog.Actor, in ZoneInput) (models.Zone, error) {
z, note, err := a.normaliseZoneInput(in, models.Zone{})
if err != nil {
return models.Zone{}, err
}
created, err := a.DB.CreateZone(ctx, z)
if err != nil {
return models.Zone{}, translate(err,
"Zone not found.",
fmt.Sprintf("A zone named %s already exists.", strings.TrimSuffix(z.Name, ".")))
}
a.Audit.RecordID(ctx, actor, "zone.create", auditlog.ObjectZone, created.ID, created.Name,
auditlog.Changes("kind", string(created.Kind), "ttl", fmt.Sprint(created.DefaultTTL)))
a.Runtime.RequestReload()
if note != "" {
a.Log.Info("reverse zone name derived from subnet", "zone", created.Name, "note", note)
}
return created, nil
}
// ReverseZoneName previews the zone apex a subnet maps to, for the UI's live
// hint under the CIDR field.
func (a *App) ReverseZoneName(cidr string) (name, note string, err error) {
name, note, err = validate.ReverseZone(cidr)
if err != nil {
return "", "", Invalid("%s", err.Error())
}
return name, note, nil
}
// UpdateZone saves zone metadata.
func (a *App) UpdateZone(ctx context.Context, actor auditlog.Actor, id int64, in ZoneInput) (models.Zone, error) {
existing, err := a.Zone(ctx, id)
if err != nil {
return models.Zone{}, err
}
z, _, err := a.normaliseZoneInput(in, existing)
if err != nil {
return models.Zone{}, err
}
z.ID = id
z.CreatedAt = existing.CreatedAt
updated, err := a.DB.UpdateZone(ctx, z)
if err != nil {
return models.Zone{}, translate(err,
fmt.Sprintf("Zone %d was not found.", id),
fmt.Sprintf("A zone named %s already exists.", strings.TrimSuffix(z.Name, ".")))
}
a.Audit.RecordID(ctx, actor, "zone.update", auditlog.ObjectZone, id, updated.Name,
auditlog.Changes("serial", fmt.Sprint(updated.Serial), "ttl", fmt.Sprint(updated.DefaultTTL)))
a.Runtime.RequestReload()
return updated, nil
}
// normaliseZoneInput validates input and merges it over an existing zone.
func (a *App) normaliseZoneInput(in ZoneInput, base models.Zone) (models.Zone, string, error) {
z := base
var note string
name := strings.TrimSpace(in.Name)
if cidr := strings.TrimSpace(in.CIDR); cidr != "" && name == "" {
derived, n, err := validate.ReverseZone(cidr)
if err != nil {
return z, "", Invalid("%s", err.Error())
}
name = derived
note = n
kind, err := validate.ReverseZoneKindForCIDR(cidr)
if err == nil {
in.Kind = kind
}
}
if name == "" && base.Name == "" {
return z, "", Invalid("A zone name is required.")
}
if name != "" {
normalised, err := validate.NormaliseZoneName(name)
if err != nil {
return z, "", Invalid("%s", err.Error())
}
z.Name = normalised
}
kind := models.ZoneKind(strings.TrimSpace(in.Kind))
if kind == "" {
kind = models.ZoneKind(validate.ZoneKindForName(z.Name))
}
if !kind.Valid() {
return z, "", Invalid("Zone kind %q must be forward, reverse4 or reverse6.", in.Kind)
}
z.Kind = kind
z.Description = strings.TrimSpace(in.Description)
if in.Enabled != nil {
z.Enabled = *in.Enabled
} else if base.ID == 0 {
z.Enabled = true
}
z.DefaultTTL = in.DefaultTTL
if z.DefaultTTL == 0 {
z.DefaultTTL = base.DefaultTTL
}
if z.DefaultTTL == 0 {
z.DefaultTTL = a.Settings().DNS.DefaultTTL
}
if z.DefaultTTL < 1 || z.DefaultTTL > 604800 {
return z, "", Invalid("The default TTL must be between 1 and 604800 seconds.")
}
z.PrimaryNS = strings.TrimSpace(in.PrimaryNS)
if z.PrimaryNS == "" {
z.PrimaryNS = base.PrimaryNS
}
if z.PrimaryNS == "" {
z.PrimaryNS = "ns1." + z.Name
}
ns, err := validate.NormaliseFQDN(z.PrimaryNS)
if err != nil {
return z, "", Invalid("Primary name server: %s", err.Error())
}
z.PrimaryNS = ns
z.AdminEmail = strings.TrimSpace(in.AdminEmail)
if z.AdminEmail == "" {
z.AdminEmail = base.AdminEmail
}
if z.AdminEmail == "" {
z.AdminEmail = "hostmaster@" + strings.TrimSuffix(z.Name, ".")
}
z.Refresh = orDefault(in.Refresh, base.Refresh, 7200)
z.Retry = orDefault(in.Retry, base.Retry, 3600)
z.Expire = orDefault(in.Expire, base.Expire, 1209600)
z.Minimum = orDefault(in.Minimum, base.Minimum, 3600)
if in.AutoSerial != nil {
z.AutoSerial = *in.AutoSerial
} else if base.ID == 0 {
z.AutoSerial = true
}
if in.Serial != nil {
// A manual serial override is allowed, which matters when migrating a
// zone from another server that is already at a higher serial.
if *in.Serial == 0 {
return z, "", Invalid("The serial must be at least 1.")
}
z.Serial = *in.Serial
} else if z.Serial == 0 {
z.Serial = 1
}
return z, note, nil
}
func orDefault(v, fallback, def uint32) uint32 {
if v != 0 {
return v
}
if fallback != 0 {
return fallback
}
return def
}
// SetZoneEnabled toggles a zone.
func (a *App) SetZoneEnabled(ctx context.Context, actor auditlog.Actor, id int64, enabled bool) error {
z, err := a.Zone(ctx, id)
if err != nil {
return err
}
if err := a.DB.SetZoneEnabled(ctx, id, enabled); err != nil {
return translate(err, fmt.Sprintf("Zone %d was not found.", id), "")
}
action := "zone.disable"
if enabled {
action = "zone.enable"
}
a.Audit.RecordID(ctx, actor, action, auditlog.ObjectZone, id, z.Name, "")
a.Runtime.RequestReload()
return nil
}
// DeleteZone removes a zone and all of its records.
func (a *App) DeleteZone(ctx context.Context, actor auditlog.Actor, id int64) error {
z, err := a.Zone(ctx, id)
if err != nil {
return err
}
if err := a.DB.DeleteZone(ctx, id); err != nil {
return translate(err, fmt.Sprintf("Zone %d was not found.", id), "")
}
a.Audit.RecordID(ctx, actor, "zone.delete", auditlog.ObjectZone, id, z.Name,
auditlog.Changes("records", fmt.Sprint(z.RecordCount)))
a.Runtime.RequestReload()
return nil
}
// CloneZone copies a zone under a new name.
func (a *App) CloneZone(ctx context.Context, actor auditlog.Actor, id int64, newName, description string) (models.Zone, error) {
src, err := a.Zone(ctx, id)
if err != nil {
return models.Zone{}, err
}
name, err := validate.NormaliseZoneName(newName)
if err != nil {
return models.Zone{}, Invalid("%s", err.Error())
}
if name == src.Name {
return models.Zone{}, Invalid("The new zone name must differ from the zone being cloned.")
}
clone, err := a.DB.CloneZone(ctx, id, name, strings.TrimSpace(description))
if err != nil {
return models.Zone{}, translate(err,
fmt.Sprintf("Zone %d was not found.", id),
fmt.Sprintf("A zone named %s already exists.", strings.TrimSuffix(name, ".")))
}
a.Audit.RecordID(ctx, actor, "zone.clone", auditlog.ObjectZone, clone.ID, clone.Name,
auditlog.Changes("source", src.Name))
a.Runtime.RequestReload()
return clone, nil
}
// --- Zone file import and export ---------------------------------------
// ImportMode selects how an imported zone file is applied.
type ImportMode string
const (
// ImportReplace discards the zone's existing records.
ImportReplace ImportMode = "replace"
// ImportMerge adds the imported records to what is already there.
ImportMerge ImportMode = "merge"
)
// ImportResult reports the outcome of a zone file import.
type ImportResult struct {
Zone models.Zone `json:"zone"`
Summary zonefile.ParseSummary `json:"summary"`
Created bool `json:"zone_created"`
}
// ImportZoneFile parses a BIND zone file and stores its records.
//
// The whole file is validated before anything is written, so a syntax error
// halfway through never leaves a zone half-imported.
func (a *App) ImportZoneFile(ctx context.Context, actor auditlog.Actor, zoneID int64,
r io.Reader, mode ImportMode) (*ImportResult, error) {
z, err := a.Zone(ctx, zoneID)
if err != nil {
return nil, err
}
parsed, err := zonefile.Parse(r, z.Name, z.DefaultTTL)
if err != nil {
return nil, Invalid("%s", err.Error())
}
if problems := zonefile.ValidateRecords(z.Name, parsed.Records, z.DefaultTTL); len(problems) > 0 {
return nil, Invalid("The zone file contains records this server cannot store:\n%s",
strings.Join(problems, "\n"))
}
switch mode {
case ImportMerge:
err = a.DB.AppendZoneRecords(ctx, zoneID, parsed.Records)
default:
mode = ImportReplace
err = a.DB.ReplaceZoneRecords(ctx, zoneID, parsed.Records)
}
if err != nil {
return nil, Internal(err, "The imported records could not be saved.")
}
// Adopt the SOA timers from the file, but keep our own serial management
// unless the file's serial is higher.
if parsed.SOA != nil {
updated := z
zonefile.ZoneMetadataFromSOA(&updated, parsed.SOA)
if updated.Serial < z.Serial {
updated.Serial = z.Serial
}
if _, err := a.DB.UpdateZone(ctx, updated); err != nil {
a.Log.Warn("could not apply imported SOA values", "zone", z.Name, "error", err)
} else {
z = updated
}
}
a.Audit.RecordID(ctx, actor, "zone.import", auditlog.ObjectZone, zoneID, z.Name,
auditlog.Changes("mode", string(mode), "records", fmt.Sprint(parsed.Summary.RecordsParsed)))
a.Runtime.RequestReload()
return &ImportResult{Zone: z, Summary: parsed.Summary}, nil
}
// ExportZoneFile renders a zone as a BIND zone file.
func (a *App) ExportZoneFile(ctx context.Context, zoneID int64) (models.Zone, []byte, error) {
z, err := a.Zone(ctx, zoneID)
if err != nil {
return z, nil, err
}
recs, err := a.DB.ZoneRecordsRaw(ctx, zoneID)
if err != nil {
return z, nil, Internal(err, "The zone records could not be loaded.")
}
var buf bytes.Buffer
if err := zonefile.Export(&buf, z, recs); err != nil {
return z, nil, Internal(err, "The zone file could not be generated.")
}
return z, buf.Bytes(), nil
}