1 parent
98b65945f6
commit
0b6dcbd547
13 files changed
+417
-68
No files matched your search
+11
-4
@@ -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))
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Vendored
+2
-2
@@ -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>
|
||||
|
||||
@@ -22,6 +22,7 @@ import Logs from './pages/Logs.tsx'
|
||||
import LocalZones from './pages/LocalZones.tsx'
|
||||
import Lookup from './pages/Lookup.tsx'
|
||||
import NotFound from './pages/NotFound.tsx'
|
||||
import Overrides from './pages/Overrides.tsx'
|
||||
import Settings from './pages/Settings.tsx'
|
||||
import Upstreams from './pages/Upstreams.tsx'
|
||||
import Zones from './pages/Zones.tsx'
|
||||
@@ -54,6 +55,7 @@ function AppRoutes() {
|
||||
<Route path="/blocklists" element={<Blocklists />} />
|
||||
<Route path="/domains" element={<Domains />} />
|
||||
<Route path="/lookup" element={<Lookup />} />
|
||||
<Route path="/overrides" element={<Overrides />} />
|
||||
<Route path="/local-zones" element={<LocalZones />} />
|
||||
<Route path="/zones" element={<Zones />} />
|
||||
<Route path="/clients" element={<Clients />} />
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
LogOut,
|
||||
Menu,
|
||||
Network,
|
||||
MapPin,
|
||||
PanelLeftClose,
|
||||
ScrollText,
|
||||
Search,
|
||||
@@ -41,6 +42,7 @@ const NAV: NavItem[] = [
|
||||
{ to: '/blocklists', label: 'Blocklists', icon: ListChecks },
|
||||
{ to: '/domains', label: 'Domains', icon: Globe },
|
||||
{ to: '/lookup', label: 'Lookup', icon: Search },
|
||||
{ to: '/overrides', label: 'Address overrides', icon: MapPin },
|
||||
{ to: '/local-zones', label: 'Local zones', icon: FileCode2 },
|
||||
{ to: '/zones', label: 'Zones', icon: Layers },
|
||||
{ to: '/clients', label: 'Clients', icon: Users },
|
||||
|
||||
+8
-2
@@ -280,13 +280,19 @@ export function checkDomain(name: string): Promise<DomainCheckResponse> {
|
||||
}
|
||||
|
||||
/** Runs one query through the live pipeline, the console's connectivity test. */
|
||||
export function testDns(name: string, qtype: string): Promise<DnsTestResponse> {
|
||||
export function testDns(name: string, qtype: string, upstream = ''): Promise<DnsTestResponse> {
|
||||
return apiFetch<DnsTestResponse>('/api/dns/test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, type: qtype }),
|
||||
body: JSON.stringify({ name, type: qtype, upstream }),
|
||||
})
|
||||
}
|
||||
|
||||
export interface AddressOverride { domain: string; address: string }
|
||||
export interface AddressOverridesResponse { items: AddressOverride[]; writable: boolean; initialized?: boolean }
|
||||
export function getAddressOverrides(): Promise<AddressOverridesResponse> { return apiFetch<AddressOverridesResponse>('/api/address-overrides') }
|
||||
export function putAddressOverride(domain: string,address: string): Promise<{ok:boolean}> { return apiFetch('/api/address-overrides',{method:'PUT',body:JSON.stringify({domain,address})}) }
|
||||
export function deleteAddressOverride(domain: string): Promise<{ok:boolean}> { return apiFetch(`/api/address-overrides/${encodeURIComponent(domain)}`,{method:'DELETE'}) }
|
||||
|
||||
export function getZones(): Promise<ZonesResponse> {
|
||||
return apiFetch<ZonesResponse>('/api/zones')
|
||||
}
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
* with, and every entry can be turned into a local-zone override from there.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Plus, Search } from 'lucide-react'
|
||||
|
||||
import { addZoneRecord, testDns } from '../lib/api'
|
||||
import { addZoneRecord, getUpstreams, testDns } from '../lib/api'
|
||||
import { useWhoami } from '../lib/session.ts'
|
||||
import type { DnsTestResponse } from '../lib/types'
|
||||
import { useAsyncAction } from '../hooks/useApi'
|
||||
@@ -88,13 +88,16 @@ export default function Lookup() {
|
||||
const [result, setResult] = useState<DnsTestResponse | null>(null)
|
||||
const [entries, setEntries] = useState<Entry[]>([])
|
||||
const [listed, setListed] = useState('')
|
||||
const [upstreams, setUpstreams] = useState<string[]>([])
|
||||
const [upstream, setUpstream] = useState('')
|
||||
useEffect(() => { void getUpstreams().then((r) => setUpstreams(r.servers.map((s) => s.addr))).catch(() => setUpstreams([])) }, [])
|
||||
|
||||
const [override, setOverride] = useState<Entry | null>(null)
|
||||
const [zone, setZone] = useState('')
|
||||
const [relName, setRelName] = useState('')
|
||||
const [ttl, setTtl] = useState('')
|
||||
|
||||
const test = useAsyncAction(async (target: string, type: string) => testDns(target, type))
|
||||
const test = useAsyncAction(async (target: string, type: string) => testDns(target, type, upstream))
|
||||
const write = useAsyncAction(async (input: { zone: string; name: string; type: string; ttl: number; value: string }) =>
|
||||
addZoneRecord({
|
||||
zone: input.zone,
|
||||
@@ -119,7 +122,7 @@ export default function Lookup() {
|
||||
|
||||
const listEntries = async (): Promise<void> => {
|
||||
if (trimmed === '') return
|
||||
const results = await Promise.all(ENTRY_TYPES.map((t) => testDns(trimmed, t)))
|
||||
const results = await Promise.all(ENTRY_TYPES.map((t) => testDns(trimmed, t, upstream)))
|
||||
const found: Entry[] = []
|
||||
for (const [i, res] of results.entries()) {
|
||||
if (res.error !== undefined) continue
|
||||
@@ -191,11 +194,11 @@ export default function Lookup() {
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="Lookup"
|
||||
description="Test the server, list a domain's records, and copy one into a local zone as an override."
|
||||
description="Test the server or query one configured upstream directly."
|
||||
/>
|
||||
|
||||
<Card title="Test a name" subtitle="Resolved through the live pipeline, exactly as a client would see it.">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[1fr_auto_auto]">
|
||||
<Card title="Test a name" subtitle={upstream ? 'Sent directly to the selected upstream, bypassing local policy and cache.' : 'Resolved through the live pipeline, as a client would see it.'}>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[1fr_auto_auto_auto]">
|
||||
<Input
|
||||
label="Name"
|
||||
placeholder="example.com or www.example.com"
|
||||
@@ -214,6 +217,7 @@ export default function Lookup() {
|
||||
onChange={(e) => setQtype(e.target.value)}
|
||||
options={TEST_TYPES.map((t) => ({ value: t, label: t }))}
|
||||
/>
|
||||
<Select label="Resolver" value={upstream} onChange={(e) => setUpstream(e.target.value)} options={[{value:'',label:'Full pipeline'},...upstreams.map((u)=>({value:u,label:u}))]} />
|
||||
<div className="flex items-end">
|
||||
<Button onClick={() => void runTest()} loading={test.pending} disabled={trimmed === ''}>
|
||||
Test
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useState } from 'react'
|
||||
import { Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { deleteAddressOverride, getAddressOverrides, putAddressOverride } from '../lib/api'
|
||||
import { useApi } from '../hooks/useApi'
|
||||
import { useWhoami } from '../lib/session.ts'
|
||||
import { Button, Card, EmptyState, IconButton, Input, Table, useToast } from '../components/ui'
|
||||
import { PageHeader } from '../components/layout'
|
||||
|
||||
export default function Overrides() {
|
||||
const {canWrite}=useWhoami(); const {toast}=useToast(); const {data,error,loading,refetch}=useApi(()=>getAddressOverrides(),[])
|
||||
const [domain,setDomain]=useState(''); const [address,setAddress]=useState('')
|
||||
const save=async()=>{try{await putAddressOverride(domain,address);setDomain('');setAddress('');refetch();toast({title:'Address override saved',tone:'ok'})}catch(e){toast({title:e instanceof Error?e.message:'Could not save override',tone:'danger'})}}
|
||||
const remove=async(d:string)=>{try{await deleteAddressOverride(d);refetch();toast({title:`Removed ${d}`,tone:'ok'})}catch(e){toast({title:e instanceof Error?e.message:'Could not remove override',tone:'danger'})}}
|
||||
const items=data?.items??[]
|
||||
return <div className="space-y-4"><PageHeader title="Address overrides" description="Map a domain and all its subdomains to an IP address. These rules answer through DNS policy without creating an authoritative zone." />
|
||||
<Card title="Add or edit an override" subtitle={data?.writable?'Changes are shared across replicas.':'The shared Kubernetes ConfigMap is unavailable; overrides are read-only.'}>
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_1fr_auto] items-end"><Input label="Domain" placeholder="example.com" value={domain} onChange={e=>setDomain(e.target.value)} disabled={!canWrite||!data?.writable}/><Input label="Address" placeholder="10.10.3.10 or 2001:db8::1" value={address} onChange={e=>setAddress(e.target.value)} disabled={!canWrite||!data?.writable}/><Button icon={Plus} onClick={()=>void save()} disabled={!canWrite||!data?.writable||!domain.trim()||!address.trim()}>{items.some(i=>i.domain===domain.trim().toLowerCase())?'Save':'Add override'}</Button></div>
|
||||
</Card>
|
||||
<Card title="Overrides" subtitle={`${items.length} domain${items.length===1?'':'s'}`}>
|
||||
{error?<p className="text-sm text-red-600">{error}</p>:loading?<p className="text-sm text-slate-500">Loading…</p>:items.length===0?<EmptyState title="No address overrides" description="Add a domain and destination IP above."/>:<Table columns={[{key:'domain',header:'Domain'},{key:'address',header:'Address'},{key:'actions',header:'',align:'right'}]} rows={items.map(i=>({__key:i.domain,domain:<span className="font-mono">{i.domain}</span>,address:<span className="font-mono">{i.address}</span>,actions:<span className="flex justify-end gap-1"><IconButton icon={Pencil} label={`Edit ${i.domain}`} onClick={()=>{setDomain(i.domain);setAddress(i.address)}} disabled={!canWrite||!data?.writable}/><IconButton icon={Trash2} label={`Delete ${i.domain}`} onClick={()=>void remove(i.domain)} disabled={!canWrite||!data?.writable}/></span>}))} rowKey={(row)=>String(row.__key)}/>}
|
||||
</Card>
|
||||
</div>
|
||||
}
|
||||
Reference in new issue
Block a user