Serves a page explaining why a domain was blocked instead of leaving a sinkholed client with a dead connection. Binds its own HTTP/HTTPS listeners with self-signed, per-hostname TLS certs generated on the fly, re-evaluates the requesting client against the policy engine per request, and renders an HTML template editable from Settings with a live preview. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TTKpGMQzpfDsvedu1hvSUf
137 lines
4.4 KiB
Go
137 lines
4.4 KiB
Go
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",
|
|
config.KeyBlockPageEnabled: "Block page enabled state",
|
|
config.KeyBlockPageHTTPListen: "Block page HTTP listen address",
|
|
config.KeyBlockPageHTTPSListen: "Block page HTTPS 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"
|
|
GroupBlockPage SettingsGroup = "blockpage"
|
|
)
|
|
|
|
// 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))
|
|
}
|
|
|
|
bpHTTP, bpHTTPS := a.BlockPage.ListenAddrs()
|
|
bpRunning := a.BlockPage.Running()
|
|
if current.BlockPage.Enabled != bpRunning {
|
|
pending = append(pending, "Block page enabled state has changed")
|
|
} else if bpRunning && (current.BlockPage.HTTPListen != bpHTTP || current.BlockPage.HTTPSListen != bpHTTPS) {
|
|
pending = append(pending, fmt.Sprintf(
|
|
"Block page address (listening on %s/%s, configured as %s/%s)",
|
|
bpHTTP, bpHTTPS, current.BlockPage.HTTPListen, current.BlockPage.HTTPSListen))
|
|
}
|
|
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
|
|
}
|