overrides
build / build-push (push) Successful in 29s

This commit is contained in:
mats committed 2026-09-28 16:25:55 +02:00
1 parent 98b65945f6
commit 0b6dcbd547
13 files changed
+417 -68

No files matched your search

+11 -4
View File
@@ -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))
+63 -3
View File
@@ -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")
+25 -6
View File
@@ -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()
+164 -38
View File
@@ -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")
+35
View File
@@ -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()
}
}
}
+25 -2
View File
@@ -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
+46 -4
View File
@@ -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)
+2 -2
View File
@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title>
<script type="module" crossorigin src="/assets/index-CzcgOHn-.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-FxcuHhfL.css">
<script type="module" crossorigin src="/assets/index-DnNommMg.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bf5OWE1r.css">
</head>
<body>
<div id="root"></div>