306 lines
9.0 KiB
Go
306 lines
9.0 KiB
Go
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
|
|
}
|