diff --git a/internal/api/api.go b/internal/api/api.go index 76fe4e1..a90a9f4 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -67,7 +67,8 @@ type Hooks struct { DomainCheck func(name string) (any, error) // Resolve runs one query through the live pipeline and reports the // outcome, so the console can test the server it is talking to. - Resolve func(ctx context.Context, name, qtype string) (any, error) + Resolve func(ctx context.Context, name, qtype string) (any, error) + ResolveUpstream func(ctx context.Context, name, qtype, upstream string) (any, error) // ListDomains pages the compiled rules. ListDomains func(q DomainQuery) (any, error) // AddDomain adds an inline rule and persists it. @@ -75,9 +76,12 @@ type Hooks struct { // DeleteDomain removes a rule and persists the change. DeleteDomain func(ctx context.Context, name, action, list string) error // LocalZones lists, saves and deletes UI-managed DNS master files. - LocalZones func(ctx context.Context) ([]ZoneFile, error) - SaveLocalZone func(ctx context.Context, name, content string) error - DeleteLocalZone func(ctx context.Context, name string) error + LocalZones func(ctx context.Context) ([]ZoneFile, error) + SaveLocalZone func(ctx context.Context, name, content string) error + DeleteLocalZone func(ctx context.Context, name string) error + AddressOverrides func(ctx context.Context) (any, error) + SaveAddressOverride func(ctx context.Context, domain, address string) error + DeleteAddressOverride func(ctx context.Context, domain string) error // ListZones returns the compiled local zones with their records and // delegations, and whether the API may rewrite them. ListZones func() (any, error) @@ -290,6 +294,9 @@ func (s *Server) routes(cfg *config.Config) *http.ServeMux { mux.Handle("DELETE "+base+"domains/{name...}", s.authed(s.handleDomainDelete, true)) mux.Handle("GET "+base+"domains/check", s.authed(s.handleDomainCheck, false)) mux.Handle("POST "+base+"dns/test", s.authed(s.handleDNSTest, false)) + mux.Handle("GET "+base+"address-overrides", s.authed(s.handleAddressOverrides, false)) + mux.Handle("PUT "+base+"address-overrides", s.authed(s.handleAddressOverridePut, true)) + mux.Handle("DELETE "+base+"address-overrides/{domain}", s.authed(s.handleAddressOverrideDelete, true)) mux.Handle("GET "+base+"local-zones", s.authed(s.handleLocalZones, false)) mux.Handle("PUT "+base+"local-zones/{name}", s.authed(s.handleLocalZonePut, true)) mux.Handle("DELETE "+base+"local-zones/{name}", s.authed(s.handleLocalZoneDelete, true)) diff --git a/internal/api/handlers.go b/internal/api/handlers.go index bfbee4b..f7307b3 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -1,7 +1,9 @@ package api import ( + "context" "encoding/json" + "errors" "fmt" "net/http" "os" @@ -188,8 +190,9 @@ func (s *Server) handleDNSTest(w http.ResponseWriter, r *http.Request) { return } var in struct { - Name string `json:"name"` - QType string `json:"type"` + Name string `json:"name"` + QType string `json:"type"` + Upstream string `json:"upstream"` } if err := decodeJSON(r, &in); err != nil { writeError(w, http.StatusBadRequest, "malformed request body") @@ -200,7 +203,20 @@ func (s *Server) handleDNSTest(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "name is required") return } - v, err := s.hooks.Resolve(r.Context(), name, in.QType) + resolve := s.hooks.Resolve + if in.Upstream != "" { + resolve = func(ctx context.Context, n, qt string) (any, error) { + if s.hooks.ResolveUpstream == nil { + return nil, errors.New("direct upstream lookup unavailable") + } + return s.hooks.ResolveUpstream(ctx, n, qt, in.Upstream) + } + } + if resolve == nil { + writeError(w, http.StatusNotImplemented, "dns test not available") + return + } + v, err := resolve(r.Context(), name, in.QType) if err != nil { writeJSON(w, http.StatusOK, map[string]any{"name": name, "error": err.Error()}) return @@ -208,6 +224,50 @@ func (s *Server) handleDNSTest(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, v) } +func (s *Server) handleAddressOverrides(w http.ResponseWriter, r *http.Request) { + if s.hooks.AddressOverrides == nil { + writeError(w, http.StatusNotImplemented, "address overrides unavailable") + return + } + v, err := s.hooks.AddressOverrides(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, v) +} +func (s *Server) handleAddressOverridePut(w http.ResponseWriter, r *http.Request) { + if s.hooks.SaveAddressOverride == nil { + writeError(w, http.StatusNotImplemented, "address overrides are read-only") + return + } + var in struct { + Domain string `json:"domain"` + Address string `json:"address"` + } + if decodeJSON(r, &in) != nil { + writeError(w, http.StatusBadRequest, "malformed request body") + return + } + if err := s.hooks.SaveAddressOverride(r.Context(), in.Domain, in.Address); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} +func (s *Server) handleAddressOverrideDelete(w http.ResponseWriter, r *http.Request) { + if s.hooks.DeleteAddressOverride == nil { + writeError(w, http.StatusNotImplemented, "address overrides are read-only") + return + } + domain := r.PathValue("domain") + if err := s.hooks.DeleteAddressOverride(r.Context(), domain); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + func (s *Server) handleLocalZones(w http.ResponseWriter, r *http.Request) { if s.hooks.LocalZones == nil { writeError(w, http.StatusNotImplemented, "local zone editor is unavailable") diff --git a/internal/app/app.go b/internal/app/app.go index 6dbf14d..527dfab 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -56,12 +56,13 @@ type App struct { cfg *config.Store ver string - blocks *blocklist.Manager - cache *cache.Manager - cstore *cache.Store - logger *qlog.Logger - aclH *acl.Holder - local *localzone.Holder + blocks *blocklist.Manager + overrideMu sync.Mutex + cache *cache.Manager + cstore *cache.Store + logger *qlog.Logger + aclH *acl.Holder + local *localzone.Holder // zones is the Kubernetes-sourced zone client. zones *kubezones.Client // managedZones is the console-managed zone directory: the only zone data @@ -316,6 +317,23 @@ func (a *App) startBlocklist() { Config: a.cfg, Logger: a.log, }) + if a.zones != nil { + ctx := context.Background() + content, exists, err := a.zones.Data(ctx, "address-overrides.rpz") + if err == nil && !exists { + if seed, readErr := os.ReadFile("/etc/fancy-dns/rpz/address-overrides.rpz"); readErr == nil { + content = string(seed) + if saveErr := a.zones.SaveData(ctx, "address-overrides.rpz", content); saveErr != nil { + a.log.Warn("seed address overrides failed", "error", saveErr) + } + } + } + if err == nil { + a.blocks.SetManagedRPZ(content) + } else { + a.log.Warn("load address overrides failed", "error", err) + } + } // Compile once before serving so the first queries are not unprotected. if _, err := a.blocks.RefreshAll(context.Background()); err != nil { a.markDegraded("blocklist: " + err.Error()) @@ -576,6 +594,7 @@ func (a *App) startCluster(ctx context.Context, cfg *config.Config) { // probes and the metrics gauge. func (a *App) startBackground(ctx context.Context, cfg *config.Config) { go a.watchLocalZones(ctx) + go a.watchAddressOverrides(ctx) // Cache snapshots. if a.cstore != nil { interval := cfg.Cache.Persist.Interval.D() diff --git a/internal/app/hooks.go b/internal/app/hooks.go index 270ae01..b8f5abe 100644 --- a/internal/app/hooks.go +++ b/internal/app/hooks.go @@ -26,47 +26,173 @@ import ( // components. Everything the UI can read or change goes through here. func (a *App) hooks() api.Hooks { return api.Hooks{ - Store: a.cfg, - Logger: a.log, - Version: a.ver, - StartedAt: a.startedAt, - Dashboard: a.dashboard, - Status: a.status, - Health: a.health, - BlocklistState: a.blocklistState, - BlocklistStats: a.blocklistStats, - RefreshBlocklists: a.refreshBlocklists, - ToggleBlocklist: a.toggleBlocklist, - DomainCheck: a.domainCheck, - Resolve: a.resolveTest, - ListDomains: a.listDomains, - AddDomain: a.addDomain, - DeleteDomain: a.deleteDomain, - ListZones: a.listZones, - AddZoneRecord: a.addZoneRecord, - PutZoneRecord: a.putZoneRecord, - DeleteZoneRecord: a.deleteZoneRecord, - DeleteZone: a.deleteZone, - AddZoneDelegation: a.addZoneDelegation, - DeleteZoneDelegation: a.deleteZoneDelegation, - LocalZones: a.listLocalZones, - SaveLocalZone: a.saveLocalZone, - DeleteLocalZone: a.deleteLocalZone, - GetACL: a.getACL, - PutACL: a.putACL, - GetUpstream: a.getUpstreams, - PutUpstream: a.putUpstream, - SearchLog: a.searchLog, - LogStats: a.logStats, - SubscribeLog: a.subscribeLog, - ClearLog: a.clearLog, - CacheStats: a.cacheStats, - CacheTop: a.cacheTop, - FlushCache: a.flushCache, - Flush: a.flush, + Store: a.cfg, + Logger: a.log, + Version: a.ver, + StartedAt: a.startedAt, + Dashboard: a.dashboard, + Status: a.status, + Health: a.health, + BlocklistState: a.blocklistState, + BlocklistStats: a.blocklistStats, + RefreshBlocklists: a.refreshBlocklists, + ToggleBlocklist: a.toggleBlocklist, + DomainCheck: a.domainCheck, + Resolve: a.resolveTest, + ResolveUpstream: a.resolveTestUpstream, + AddressOverrides: a.listAddressOverrides, + SaveAddressOverride: a.saveAddressOverride, + DeleteAddressOverride: a.deleteAddressOverride, + ListDomains: a.listDomains, + AddDomain: a.addDomain, + DeleteDomain: a.deleteDomain, + ListZones: a.listZones, + AddZoneRecord: a.addZoneRecord, + PutZoneRecord: a.putZoneRecord, + DeleteZoneRecord: a.deleteZoneRecord, + DeleteZone: a.deleteZone, + AddZoneDelegation: a.addZoneDelegation, + DeleteZoneDelegation: a.deleteZoneDelegation, + LocalZones: a.listLocalZones, + SaveLocalZone: a.saveLocalZone, + DeleteLocalZone: a.deleteLocalZone, + GetACL: a.getACL, + PutACL: a.putACL, + GetUpstream: a.getUpstreams, + PutUpstream: a.putUpstream, + SearchLog: a.searchLog, + LogStats: a.logStats, + SubscribeLog: a.subscribeLog, + ClearLog: a.clearLog, + CacheStats: a.cacheStats, + CacheTop: a.cacheTop, + FlushCache: a.flushCache, + Flush: a.flush, } } +func (a *App) resolveTestUpstream(ctx context.Context, name, qtype, upstream string) (any, error) { + if a.pool == nil { + return nil, errors.New("upstream pool is not running") + } + up := a.pool.Get(strings.TrimSpace(upstream)) + if up == nil { + return nil, fmt.Errorf("unknown upstream %q", upstream) + } + qt := dns.TypeA + if qtype != "" { + var ok bool + qt, ok = dns.StringToType[strings.ToUpper(strings.TrimSpace(qtype))] + if !ok { + return nil, fmt.Errorf("unknown record type %q", qtype) + } + } + q := new(dns.Msg) + q.SetQuestion(dns.Fqdn(name), qt) + q.RecursionDesired = true + started := time.Now() + resp, addr, err := a.pool.Exchange(ctx, q, []string{up.Addr()}, 5*time.Second) + if err != nil { + return nil, err + } + out := map[string]any{"name": dns.Fqdn(name), "type": dns.TypeToString[qt], "rcode": "SERVFAIL", "action": "upstream", "duration_ms": time.Since(started).Milliseconds(), "upstream": addr, "group": "", "cache": "bypass", "local": false, "truncated": false, "blocked": false, "block_source": "", "answers": []string{}} + if resp != nil { + out["rcode"] = dns.RcodeToString[resp.Rcode] + ans := []string{} + for _, rr := range resp.Answer { + ans = append(ans, strings.TrimSpace(rr.String())) + } + out["answers"] = ans + } + return out, nil +} + +type addressOverride struct { + Domain string `json:"domain"` + Address string `json:"address"` +} + +func (a *App) listAddressOverrides(ctx context.Context) (any, error) { + if a.zones == nil { + return map[string]any{"items": []addressOverride{}, "writable": false}, nil + } + raw, ok, err := a.zones.Data(ctx, "address-overrides.rpz") + if err != nil { + return nil, err + } + items := parseAddressOverrides(raw) + return map[string]any{"items": items, "writable": true, "initialized": ok}, nil +} +func parseAddressOverrides(raw string) []addressOverride { + out := []addressOverride{} + for _, line := range strings.Split(raw, "\n") { + f := strings.Fields(line) + if len(f) >= 3 && !strings.HasPrefix(f[0], "$") && (strings.EqualFold(f[1], "A") || strings.EqualFold(f[1], "AAAA")) { + out = append(out, addressOverride{Domain: strings.TrimSuffix(f[0], "."), Address: f[2]}) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Domain < out[j].Domain }) + return out +} +func (a *App) saveAddressOverride(ctx context.Context, domain, address string) error { + return a.mutateAddressOverride(ctx, domain, address, false) +} +func (a *App) deleteAddressOverride(ctx context.Context, domain string) error { + return a.mutateAddressOverride(ctx, domain, "", true) +} +func (a *App) mutateAddressOverride(ctx context.Context, domain, address string, remove bool) error { + a.overrideMu.Lock() + defer a.overrideMu.Unlock() + if a.zones == nil { + return errors.New("address overrides are writable only in Kubernetes") + } + domain = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(domain)), ".") + if _, ok := dns.IsDomainName(dns.Fqdn(domain)); !ok || domain == "" { + return errors.New("enter a valid domain name") + } + ip, err := netip.ParseAddr(strings.TrimSpace(address)) + if !remove && err != nil { + return errors.New("enter a valid IPv4 or IPv6 address") + } + raw, _, err := a.zones.Data(ctx, "address-overrides.rpz") + if err != nil { + return err + } + items := parseAddressOverrides(raw) + next := make([]addressOverride, 0, len(items)+1) + for _, it := range items { + if it.Domain != domain { + next = append(next, it) + } + } + if !remove { + next = append(next, addressOverride{Domain: domain, Address: ip.String()}) + } + sort.Slice(next, func(i, j int) bool { return next[i].Domain < next[j].Domain }) + var b strings.Builder + b.WriteString("$TTL 60\n") + for _, it := range next { + typ := "A" + if strings.Contains(it.Address, ":") { + typ = "AAAA" + } + fmt.Fprintf(&b, "%s %s %s\n", it.Domain, typ, it.Address) + } + if err := a.zones.SaveData(ctx, "address-overrides.rpz", b.String()); err != nil { + return err + } + if a.blocks != nil { + a.blocks.SetManagedRPZ(b.String()) + if _, err := a.blocks.RefreshAll(ctx); err != nil { + return err + } + } + if a.cache != nil { + a.cache.Flush() + } + return nil +} + func (a *App) listLocalZones(ctx context.Context) ([]api.ZoneFile, error) { if a.zones == nil { return nil, fmt.Errorf("local zone editor is available only in Kubernetes") diff --git a/internal/app/overrides_watch.go b/internal/app/overrides_watch.go new file mode 100644 index 0000000..8ce58a6 --- /dev/null +++ b/internal/app/overrides_watch.go @@ -0,0 +1,35 @@ +package app + +import ( + "context" + "time" +) + +func (a *App) watchAddressOverrides(ctx context.Context) { + if a.zones == nil || a.blocks == nil { + return + } + t := time.NewTicker(2 * time.Second) + defer t.Stop() + var last string + for { + select { + case <-ctx.Done(): + return + case <-t.C: + content, ok, err := a.zones.Data(ctx, "address-overrides.rpz") + if err != nil || !ok || content == last { + continue + } + a.overrideMu.Lock() + a.blocks.SetManagedRPZ(content) + if _, err := a.blocks.RefreshAll(ctx); err == nil { + if a.cache != nil { + a.cache.Flush() + } + last = content + } + a.overrideMu.Unlock() + } + } +} diff --git a/internal/blocklist/manager.go b/internal/blocklist/manager.go index ce48a04..05bf1e3 100644 --- a/internal/blocklist/manager.go +++ b/internal/blocklist/manager.go @@ -56,8 +56,10 @@ type Manager struct { http *http.Client stats *StateHolder - mu sync.Mutex - running map[string]bool // in-flight refreshes, by source name + mu sync.Mutex + running map[string]bool // in-flight refreshes, by source name + managedMu sync.RWMutex + managedRPZ string // trigger lets the cluster ask one instance to refresh (leader election). trigger chan string @@ -126,6 +128,13 @@ func (m *Manager) Holder() *Holder { return m.hold } // State returns the detailed state the API serves. func (m *Manager) State() *State { return m.stats.Get() } +// SetManagedRPZ replaces the shared UI-managed address override source. +func (m *Manager) SetManagedRPZ(content string) { + m.managedMu.Lock() + m.managedRPZ = content + m.managedMu.Unlock() +} + // RefreshAll compiles every enabled source plus the local zones. The previous // set is kept if every source fails, so a bad deploy never leaves the // resolver with no blocklists at all. @@ -137,6 +146,20 @@ func (m *Manager) RefreshAll(ctx context.Context) (State, error) { sources []Source errs = map[string]string{} ) + m.managedMu.RLock() + managedRPZ := m.managedRPZ + m.managedMu.RUnlock() + if strings.TrimSpace(managedRPZ) != "" { + rules, st, err := Parse(FormatRPZ, []byte(managedRPZ), ParseOptions{Subdomains: true, DefaultTTL: 60}) + if err != nil { + errs["managed:address-overrides"] = err.Error() + } else { + for _, r := range rules { + b.Add(r, "managed:address-overrides", nil) + } + b.NoteSource(SourceMeta{Name: "managed:address-overrides", Format: string(FormatRPZ), Rules: len(rules), Bytes: st.Bytes, UpdatedAt: start, Duration: config.Duration(time.Since(start)), Enabled: true}) + } + } // Inline block/allow entries first: they are cheap, and putting them // before the lists means a later list cannot override an operator's diff --git a/internal/kubezones/client.go b/internal/kubezones/client.go index ca5a9c7..c63cd06 100644 --- a/internal/kubezones/client.go +++ b/internal/kubezones/client.go @@ -42,6 +42,16 @@ type Client struct { // InCluster returns nil outside Kubernetes. The projected service account // token must be available; RBAC limits it to the fancy-dns-zones ConfigMap. func InCluster() (*Client, error) { + name := os.Getenv("FANCYDNS_ZONE_CONFIGMAP") + if name == "" { + name = defaultName + } + return InClusterConfigMap(name) +} + +// InClusterConfigMap returns a client for a named ConfigMap. RBAC on the +// service account still limits which ConfigMaps it can read or patch. +func InClusterConfigMap(name string) (*Client, error) { host := os.Getenv("KUBERNETES_SERVICE_HOST") if host == "" { return nil, nil @@ -69,9 +79,8 @@ func InCluster() (*Client, error) { if !pool.AppendCertsFromPEM(ca) { return nil, errors.New("kube zones: invalid service account CA") } - name := os.Getenv("FANCYDNS_ZONE_CONFIGMAP") - if name == "" { - name = defaultName + if strings.TrimSpace(name) == "" { + return nil, errors.New("kube zones: ConfigMap name is required") } return &Client{ baseURL: "https://" + net.JoinHostPort(host, port), @@ -119,14 +128,47 @@ func (c *Client) Delete(ctx context.Context, name string) error { return c.change(ctx, filename(name), nil) } +// Data reads one arbitrary ConfigMap data key. It is used for console-owned +// shared files that are not zone master files. +func (c *Client) Data(ctx context.Context, key string) (string, bool, error) { + if c == nil { + return "", false, errors.New("kube zones: Kubernetes ConfigMap is unavailable") + } + cm, _, err := c.get(ctx) + if err != nil { + return "", false, err + } + value, ok := cm.Data[key] + return value, ok, nil +} + +// SaveData creates or replaces one arbitrary ConfigMap data key, retrying +// resource-version conflicts so independent edits to different keys merge. +func (c *Client) SaveData(ctx context.Context, key, content string) error { + if c == nil { + return errors.New("kube zones: Kubernetes ConfigMap is unavailable") + } + if key == "" || len(key) > 253 || strings.ContainsAny(key, `/\\`) { + return errors.New("kube zones: invalid ConfigMap data key") + } + if len(content) > 512*1024 { + return errors.New("kube zones: ConfigMap data exceeds 512 KiB") + } + return c.changeData(ctx, key, &content) +} + func filename(name string) string { return "zone--" + strings.TrimSuffix(name, ".") + ".zone" } func (c *Client) change(ctx context.Context, name string, content *string) error { if !validKey(name) { return errors.New("zone filename must be a DNS name ending in .zone") } + return c.changeData(ctx, name, content) +} + +func (c *Client) changeData(ctx context.Context, name string, content *string) error { if content != nil && len(*content) > 512*1024 { - return errors.New("zone file exceeds 512 KiB") + return errors.New("ConfigMap data exceeds 512 KiB") } for attempt := 0; attempt < 3; attempt++ { _, rv, err := c.get(ctx) diff --git a/internal/web/dist/index.html b/internal/web/dist/index.html index 6d9e6ff..2cfa418 100644 --- a/internal/web/dist/index.html +++ b/internal/web/dist/index.html @@ -5,8 +5,8 @@