initial commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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", "")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user