Cluster UI: Blocklists management, locked config-file items, read-only upstreams; honour allow_config_write

The console now works as the cluster's editor, without the monitoring pages.

Web: Blocklists gets Add/Edit/Remove for BlockList objects (and shows ones the
resolver has not compiled yet); Clients marks cluster groups and locks config-file
groups and the default policy; Upstreams becomes a read-only health view when the
config cannot be written; Zones and Domains lose the managed-directory and list
copy; the Local zones page and its API calls are removed; nav is Status,
Blocklists, Domains, Lookup, Address redirects, Zones, Clients, Upstreams, Settings.

Console: a change to a group the resolver's config file defines is refused with the
reason instead of being skipped silently, and /api/clients says what the console
can and cannot edit.

Server: isFileWritable called a read-only existing file (a mounted ConfigMap)
writable, and api.allow_config_write was not reflected in the writable flag nor
checked by the upstream/ACL edit path. Both fixed; tests pin them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
matsandClaude Sonnet 5.5 committed 2026-10-16 10:56:21 +02:00
1 parent 7d58b80583
commit 7fa48bcdf9
18 files changed
+726 -404

No files matched your search

+21 -12
View File
@@ -615,23 +615,32 @@ func queryTime(r *http.Request, key string) time.Time {
return time.Time{}
}
// isFileWritable reports whether the config file at path can be rewritten. A file
// that exists and refuses a write (a ConfigMap mounted into a pod, a read-only
// bind mount) is not writable: only a file that does not exist yet counts as
// writable, and then only when its directory accepts a new file.
func isFileWritable(path string) bool {
if path == "" {
return false
}
f, err := os.OpenFile(path, os.O_WRONLY, 0)
if err != nil {
// A missing file in a writable directory is still writable.
dir := path
if i := strings.LastIndexByte(dir, '/'); i > 0 {
dir = dir[:i]
}
d, derr := os.Stat(dir)
if derr != nil {
return false
}
return d.IsDir()
if err == nil {
_ = f.Close()
return true
}
_ = f.Close()
if !os.IsNotExist(err) {
return false
}
dir := "."
if i := strings.LastIndexByte(path, '/'); i > 0 {
dir = path[:i]
}
probe, err := os.CreateTemp(dir, ".fancy-dns-writable-*")
if err != nil {
return false
}
name := probe.Name()
_ = probe.Close()
_ = os.Remove(name)
return true
}
+12 -2
View File
@@ -48,7 +48,7 @@ func (s *Server) handleStatus(w http.ResponseWriter, _ *http.Request) {
"generation": s.hooks.Store.Generation(),
"path": s.hooks.Store.Path(),
"last_error": s.hooks.Store.LastError(),
"writable": isFileWritable(s.hooks.Store.Path()),
"writable": s.configWritable(),
"reload_ready": s.hooks.Store.Path() != "",
},
"components": s.hooks.Status(),
@@ -460,7 +460,7 @@ func (s *Server) handleSettingsGet(w http.ResponseWriter, _ *http.Request) {
"defaults_yaml": string(defaults),
"last_error": s.hooks.Store.LastError(),
"reload_available": path != "",
"writable": isFileWritable(path),
"writable": s.configWritable(),
})
}
@@ -555,12 +555,22 @@ func (s *Server) handleSettingsReload(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": s.hooks.Store.Get().Version})
}
// configWritable is whether an edit to the config can land: the operator allows
// it (api.allow_config_write) and the file accepts a write. The console uses it
// to decide between an editor and a read-only view.
func (s *Server) configWritable() bool {
return s.hooks.Store.Get().API.AllowConfigWrite && isFileWritable(s.hooks.Store.Path())
}
// commit writes the config to disk, swaps it in and notifies the cluster.
func (s *Server) commit(next *config.Config) error {
path := s.hooks.Store.Path()
if path == "" {
return fmt.Errorf("cannot persist: no config file path is known")
}
if !s.hooks.Store.Get().API.AllowConfigWrite {
return fmt.Errorf("cannot persist: config writes are disabled (api.allow_config_write is false)")
}
if !isFileWritable(path) {
return fmt.Errorf("cannot persist: %s is not writable", path)
}
+51
View File
@@ -0,0 +1,51 @@
package api
import (
"os"
"path/filepath"
"testing"
)
// A config file that exists but refuses a write is not writable. This is what a
// ConfigMap mounted into a pod looks like, and calling it writable made the
// console offer an editor whose every save failed.
func TestIsFileWritable(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root can write a read-only file")
}
dir := t.TempDir()
rw := filepath.Join(dir, "rw.yaml")
if err := os.WriteFile(rw, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
ro := filepath.Join(dir, "ro.yaml")
if err := os.WriteFile(ro, []byte("x"), 0o444); err != nil {
t.Fatal(err)
}
cases := map[string]struct {
path string
want bool
}{
"writable file": {rw, true},
"read-only file": {ro, false},
"missing file, open dir": {filepath.Join(dir, "new.yaml"), true},
"missing file, no dir": {filepath.Join(dir, "nodir", "x.yaml"), false},
"no path": {"", false},
}
for name, tc := range cases {
if got := isFileWritable(tc.path); got != tc.want {
t.Errorf("%s: isFileWritable(%q) = %v, want %v", name, tc.path, got, tc.want)
}
}
// A directory that refuses new files makes a missing file unwritable too.
locked := filepath.Join(dir, "locked")
if err := os.Mkdir(locked, 0o555); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(locked, 0o755) })
if isFileWritable(filepath.Join(locked, "x.yaml")) {
t.Error("a file in a directory that refuses new files was reported writable")
}
}
+131 -31
View File
@@ -1,6 +1,7 @@
package app
import (
"context"
"encoding/json"
"io"
"net/http"
@@ -10,10 +11,13 @@ import (
"time"
"github.com/miekg/dns"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/dynamic"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/console"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/kubeedit"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/kubesource"
)
// TestConsoleEditsReachTheResolver runs both halves: a console with its own
@@ -22,34 +26,15 @@ import (
// which is the whole deployment contract.
func TestConsoleEditsReachTheResolver(t *testing.T) {
cluster := kubeClient()
h := newHarness(t, nil, func(o *Options) {
h := newHarness(t, func(c *config.Config) {
// A group the resolver's own config file defines, which the console must
// show but not rewrite.
c.ACL.Groups = []config.Group{{Name: "from-file", Policy: "allow", Match: config.Match{CIDRs: []string{"198.51.100.0/24"}}}}
}, func(o *Options) {
o.Kube = KubeOptions{Enabled: true, Namespace: "dns", Client: cluster, SyncTimeout: 5 * time.Second}
})
rt, err := console.NewRuntime("http://"+h.api, h.key)
if err != nil {
t.Fatal(err)
}
cfg := config.Default()
cfg.API.Auth.Mode = "apikey"
cfg.API.Auth.APIKeys = []string{"console-key"}
cfg.API.Auth.TrustLoopback = false
cfg.API.UI = false
c, err := console.New(console.Options{
Config: config.NewStore(cfg, ""),
Runtime: rt,
Editor: kubeedit.New(cluster, "dns"),
Version: "test",
})
if err != nil {
t.Fatal(err)
}
handler, err := c.Handler()
if err != nil {
t.Fatal(err)
}
ts := httptest.NewServer(handler)
t.Cleanup(ts.Close)
ts := startConsole(t, h, cluster)
do := func(method, path, body, key string) (int, string) {
t.Helper()
@@ -88,14 +73,40 @@ func TestConsoleEditsReachTheResolver(t *testing.T) {
t.Fatalf("proxied status = %v (%v)", status, err)
}
// The config file's group is listed, marked as not editable here, and a
// change to it is refused with the reason rather than quietly ignored.
var pre struct {
Groups []struct{ Name, Source string } `json:"groups"`
Default *bool `json:"default_policy_editable"`
Files *bool `json:"file_groups_editable"`
}
if err := json.Unmarshal([]byte(ok("GET", "/api/clients", "")), &pre); err != nil || len(pre.Groups) != 1 || pre.Groups[0].Name != "from-file" || pre.Groups[0].Source != "" {
t.Fatalf("clients before any edit = %+v (%v)", pre, err)
}
if pre.Default == nil || *pre.Default || pre.Files == nil || *pre.Files {
t.Errorf("the console must say what it cannot edit: %+v", pre)
}
if code, out := do("PUT", "/api/clients", `{"groups":[{"name":"from-file","policy":"deny","match":{"cidrs":["198.51.100.0/24"]}}]}`, "console-key"); code != http.StatusBadRequest || !strings.Contains(out, "config file") {
t.Errorf("changing a config-file group = %d %s, want 400 naming the config file", code, out)
}
if code, out := do("PUT", "/api/clients", `{"groups":[],"default_policy":"deny"}`, "console-key"); code != http.StatusBadRequest || !strings.Contains(out, "default_policy") {
t.Errorf("changing the default policy = %d %s, want 400", code, out)
}
// Sending the file's group back unchanged, as the page does, is fine and
// writes nothing for it.
ok("PUT", "/api/clients", `{"groups":[{"name":"from-file","policy":"allow","match":{"cidrs":["198.51.100.0/24"]}}]}`)
if l, _ := cluster.Resource(kubesource.GroupGVR).Namespace("dns").List(context.Background(), metav1.ListOptions{}); len(l.Items) != 0 {
t.Fatalf("echoing the file's group created %d objects", len(l.Items))
}
// A group saved in the console becomes a ClientGroup, then policy.
ok("PUT", "/api/clients", `{"groups":[{"name":"no-loopback","policy":"deny","match":{"cidrs":["127.0.0.0/8"]}}]}`)
ok("PUT", "/api/clients", `{"groups":[{"name":"from-file","policy":"allow","match":{"cidrs":["198.51.100.0/24"]}},{"name":"no-loopback","policy":"deny","match":{"cidrs":["127.0.0.0/8"]}}]}`)
type clients struct {
Groups []struct{ Name, Source string } `json:"groups"`
}
// Read back at once, before the resolver can have seen it.
var view clients
if err := json.Unmarshal([]byte(ok("GET", "/api/clients", "")), &view); err != nil || len(view.Groups) != 1 || view.Groups[0].Name != "no-loopback" {
if err := json.Unmarshal([]byte(ok("GET", "/api/clients", "")), &view); err != nil || len(view.Groups) != 2 || view.Groups[1].Name != "no-loopback" {
t.Fatalf("clients right after save = %+v (%v)", view, err)
}
eventually(t, "the resolver to apply the saved group", func() bool {
@@ -105,10 +116,7 @@ func TestConsoleEditsReachTheResolver(t *testing.T) {
ok("PUT", "/api/clients", `{"groups":[]}`)
view = clients{}
raw := ok("GET", "/api/clients", "")
if !strings.Contains(raw, `"groups":[]`) {
t.Errorf("an empty group list must be [] and not null: %s", raw)
}
if err := json.Unmarshal([]byte(raw), &view); err != nil || len(view.Groups) != 0 {
if err := json.Unmarshal([]byte(raw), &view); err != nil || len(view.Groups) != 1 || view.Groups[0].Name != "from-file" {
t.Fatalf("clients after delete = %+v (%v)", view, err)
}
eventually(t, "the resolver to drop the deleted group", func() bool {
@@ -158,6 +166,47 @@ func TestConsoleEditsReachTheResolver(t *testing.T) {
return !m.Authoritative
})
// Blocklist sources: BlockList objects the Blocklists page manages.
ok("PUT", "/api/blocklist-sources/test-list", `{"url":"https://example.invalid/hosts","format":"hosts","refresh":"12h","categories":["ads"]}`)
var srcs struct {
Sources []struct {
Name string `json:"name"`
Spec struct {
URL string `json:"url"`
Refresh string `json:"refresh"`
Disabled bool `json:"disabled"`
Cats []string `json:"categories"`
} `json:"spec"`
} `json:"sources"`
}
if err := json.Unmarshal([]byte(ok("GET", "/api/blocklist-sources", "")), &srcs); err != nil || len(srcs.Sources) != 1 ||
srcs.Sources[0].Name != "test-list" || srcs.Sources[0].Spec.Refresh != "12h" {
t.Fatalf("sources right after save = %+v (%v)", srcs, err)
}
// The toggle on the page turns the object off and back on.
ok("POST", "/api/blocklists/toggle", `{"name":"test-list","enabled":false}`)
srcs.Sources = nil
_ = json.Unmarshal([]byte(ok("GET", "/api/blocklist-sources", "")), &srcs)
if len(srcs.Sources) != 1 || !srcs.Sources[0].Spec.Disabled {
t.Errorf("the toggle did not disable the BlockList: %+v", srcs)
}
for body, why := range map[string]string{
`{"url":"file:///etc/passwd"}`: "a file URL",
`{"url":"https://x/y","format":"yaml"}`: "an unknown format",
`{"url":"https://x/y","refresh":"soon"}`: "an unparsable interval",
} {
if code, out := do("PUT", "/api/blocklist-sources/bad-list", body, "console-key"); code != http.StatusBadRequest {
t.Errorf("%s was accepted: %d %s", why, code, out)
}
}
if code, _ := do("PUT", "/api/blocklist-sources/Not_A_Name", `{"url":"https://x/y"}`, "console-key"); code != http.StatusBadRequest {
t.Errorf("a name that is not a Kubernetes object name = %d, want 400", code)
}
ok("DELETE", "/api/blocklist-sources/test-list", "")
if code, _ := do("DELETE", "/api/blocklist-sources/test-list", "", "console-key"); code != http.StatusNotFound {
t.Errorf("deleting a list twice = %d, want 404", code)
}
// Address redirects: the page that used to rewrite a ConfigMap.
ok("PUT", "/api/address-overrides", `{"domain":"pin.test","address":"10.7.7.1"}`)
var ov struct {
@@ -209,3 +258,54 @@ func dnsFirstA(m *dns.Msg) (string, bool) {
}
return a.A.String(), true
}
// startConsole runs a console, with its own authentication, in front of the
// resolver in h and writing to cluster. Its API key is "console-key".
func startConsole(t *testing.T, h *harness, cluster dynamic.Interface) *httptest.Server {
t.Helper()
rt, err := console.NewRuntime("http://"+h.api, h.key)
if err != nil {
t.Fatal(err)
}
cfg := config.Default()
cfg.API.Auth.Mode = "apikey"
cfg.API.Auth.APIKeys = []string{"console-key"}
cfg.API.Auth.TrustLoopback = false
cfg.API.UI = false
c, err := console.New(console.Options{
Config: config.NewStore(cfg, ""),
Runtime: rt,
Editor: kubeedit.New(cluster, "dns"),
Version: "test",
})
if err != nil {
t.Fatal(err)
}
handler, err := c.Handler()
if err != nil {
t.Fatal(err)
}
ts := httptest.NewServer(handler)
t.Cleanup(ts.Close)
return ts
}
// An empty list is [] on the wire, not null: the page counts what it is given.
func TestConsoleEmptyGroupListIsAnArray(t *testing.T) {
cluster := kubeClient()
h := newHarness(t, nil, func(o *Options) {
o.Kube = KubeOptions{Enabled: true, Namespace: "dns", Client: cluster, SyncTimeout: 5 * time.Second}
})
ts := startConsole(t, h, cluster)
req, _ := http.NewRequest("GET", ts.URL+"/api/clients", nil)
req.Header.Set("X-API-Key", "console-key")
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
if !strings.Contains(string(body), `"groups":[]`) {
t.Errorf("an empty group list must be [] and not null: %s", body)
}
}
+43
View File
@@ -4,6 +4,8 @@ import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -789,3 +791,44 @@ func TestConcurrentQueries(t *testing.T) {
}
}
}
// TestConfigWritesFollowAllowConfigWrite: with api.allow_config_write off, which
// is how the cluster deployment runs, nothing may rewrite the config file, and
// the console is told so (config.writable) instead of offering editors whose
// every save fails. The upstream editor used to ignore the setting.
func TestConfigWritesFollowAllowConfigWrite(t *testing.T) {
h := newHarness(t, func(c *config.Config) { c.API.AllowConfigWrite = false })
before := h.OriginalConfig()
_, body := h.get("/api/status")
var st struct {
Config struct {
Writable bool `json:"writable"`
} `json:"config"`
}
if err := json.Unmarshal(body, &st); err != nil || st.Config.Writable {
t.Errorf("status config.writable = %v (%v), want false", st.Config.Writable, err)
}
_, body = h.get("/api/settings")
var settings struct {
Writable bool `json:"writable"`
}
if err := json.Unmarshal(body, &settings); err != nil || settings.Writable {
t.Errorf("settings writable = %v (%v), want false", settings.Writable, err)
}
up := fmt.Sprintf(`{"policy":"failover","servers":[{"addr":%q,"proto":"udp","weight":1}],"max_attempts":1,"breaker_threshold":5,"healthcheck_interval_s":0}`, h.up.addr)
if code, out := h.do("PUT", "/api/upstreams", up); code != http.StatusBadRequest || !strings.Contains(string(out), "allow_config_write") {
t.Errorf("PUT /api/upstreams = %d %s, want a 400 naming the setting", code, out)
}
if code, _ := h.do("PUT", "/api/clients", `{"groups":[]}`); code != http.StatusBadRequest {
t.Errorf("PUT /api/clients = %d, want 400", code)
}
if code, _ := h.do("PUT", "/api/settings", `{"config_yaml":"x: 1"}`); code != http.StatusForbidden {
t.Errorf("PUT /api/settings = %d, want 403", code)
}
raw, err := os.ReadFile(filepath.Join(h.dir, "config.yaml"))
if err != nil || string(raw) != before {
t.Errorf("the config file changed (%v)", err)
}
}
+31 -4
View File
@@ -2,6 +2,7 @@ package console
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
@@ -52,12 +53,27 @@ func (c *Console) health() any {
// reads.
type clientsView struct {
Groups []struct {
Name string `json:"name"`
config.Group
Source string `json:"source"`
} `json:"groups"`
DefaultPolicy string `json:"default_policy"`
}
// sameGroup compares two groups by what they configure, so an empty list and a
// missing one are the same.
func sameGroup(a, b config.Group) bool {
norm := func(g config.Group) string {
for _, p := range []*[]string{&g.Match.CIDRs, &g.Match.PTRSuffixes, &g.Match.Tags, &g.AllowDomains, &g.DenyDomains, &g.Upstreams} {
if *p == nil {
*p = []string{}
}
}
out, _ := json.Marshal(g)
return string(out)
}
return norm(a) == norm(b)
}
func (c *Console) putACL(ctx context.Context, groups []config.Group, def string) (int64, error) {
var view clientsView
if err := c.opt.Runtime.Get(ctx, "/api/clients", &view); err != nil {
@@ -69,13 +85,21 @@ func (c *Console) putACL(ctx context.Context, groups []config.Group, def string)
if def != "" && def != view.DefaultPolicy {
return 0, fmt.Errorf("default_policy is resolver configuration (currently %q), not a ClientGroup; change it in the resolver's config", view.DefaultPolicy)
}
inFile := map[string]bool{}
inFile := map[string]config.Group{}
for _, g := range view.Groups {
if g.Source == "" {
inFile[g.Name] = true
inFile[g.Name] = g.Group
}
}
res, err := c.opt.Editor.SyncGroups(ctx, groups, func(n string) bool { return inFile[n] }, actor(ctx))
// The console writes cluster objects. A group the resolver's config file
// defines is left alone, and a change to it is refused, because skipping it
// quietly would make the save look as if it worked.
for _, g := range groups {
if have, ok := inFile[g.Name]; ok && !sameGroup(have, g) {
return 0, fmt.Errorf("group %q is defined in the resolver's config file; change it there", g.Name)
}
}
res, err := c.opt.Editor.SyncGroups(ctx, groups, func(n string) bool { _, ok := inFile[n]; return ok }, actor(ctx))
if err != nil {
return 0, err
}
@@ -209,6 +233,9 @@ func (c *Console) handleClients(w http.ResponseWriter, r *http.Request) {
merged = append(merged, groupJSON(o))
}
view["groups"] = merged
// What this editor can and cannot change, so the page can say so.
view["default_policy_editable"] = false
view["file_groups_editable"] = false
api.WriteJSON(w, http.StatusOK, view)
}
-2
View File
@@ -17,7 +17,6 @@ import Clients from './pages/Clients.tsx'
import Dashboard from './pages/Dashboard.tsx'
import Domains from './pages/Domains.tsx'
import Login from './pages/Login.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'
@@ -54,7 +53,6 @@ function AppRoutes() {
<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 />} />
<Route path="/upstreams" element={<Upstreams />} />
+1 -3
View File
@@ -3,7 +3,6 @@ import type { ReactNode } from 'react'
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
import type { LucideIcon } from 'lucide-react'
import {
FileCode2,
Globe,
LayoutDashboard,
Layers,
@@ -41,8 +40,7 @@ const NAV: NavItem[] = [
{ to: '/domains', label: 'Domains', icon: Globe },
{ to: '/lookup', label: 'Lookup', icon: Search },
{ to: '/overrides', label: 'Address redirects', icon: MapPin },
{ to: '/local-zones', label: 'Zone files', icon: FileCode2 },
{ to: '/zones', label: 'Managed zones', icon: Layers },
{ to: '/zones', label: 'Zones', icon: Layers },
{ to: '/clients', label: 'Clients', icon: Users },
{ to: '/upstreams', label: 'Upstreams', icon: Network },
{ to: '/settings', label: 'Settings', icon: Settings },
+28 -16
View File
@@ -20,7 +20,6 @@ import type {
FlushCacheResponse,
LoginInput,
LoginResponse,
LocalZonesResponse,
OkResponse,
RefreshBlocklistsResponse,
ReloadSettingsResponse,
@@ -36,6 +35,8 @@ import type {
ZoneRecordInput,
ZonesResponse,
ZoneWriteResponse,
BlockListSpec,
BlocklistSource,
Links,
StatusResponse,
} from './types.ts'
@@ -224,6 +225,32 @@ export function getLinks(): Promise<Links> {
return apiFetch<Links>('/api/links')
}
/**
* The BlockList objects, or null when this server does not manage them: the
* console does, a resolver running on its own does not (it answers 404), and
* the page then shows the compiled lists without edit controls.
*/
export async function getBlocklistSources(): Promise<BlocklistSource[] | null> {
try {
const res = await apiFetch<{ sources: BlocklistSource[] | null }>('/api/blocklist-sources')
return res.sources ?? []
} catch (err) {
if (err instanceof ApiError && [404, 405, 501].includes(err.status)) return null
throw err
}
}
export function putBlocklistSource(name: string, spec: BlockListSpec): Promise<OkResponse> {
return apiFetch<OkResponse>(`/api/blocklist-sources/${encodeURIComponent(name)}`, {
method: 'PUT',
body: JSON.stringify(spec),
})
}
export function deleteBlocklistSource(name: string): Promise<OkResponse> {
return apiFetch<OkResponse>(`/api/blocklist-sources/${encodeURIComponent(name)}`, { method: 'DELETE' })
}
export function getBlocklists(): Promise<BlocklistsResponse> {
return apiFetch<BlocklistsResponse>('/api/blocklists')
}
@@ -366,21 +393,6 @@ export function getSettings(): Promise<SettingsResponse> {
return apiFetch<SettingsResponse>('/api/settings')
}
export function getLocalZones(): Promise<LocalZonesResponse> {
return apiFetch<LocalZonesResponse>('/api/local-zones')
}
export function saveLocalZone(name: string, content: string): Promise<OkResponse> {
return apiFetch<OkResponse>(`/api/local-zones/${encodeURIComponent(name)}`, {
method: 'PUT',
body: JSON.stringify({ content }),
})
}
export function deleteLocalZone(name: string): Promise<OkResponse> {
return apiFetch<OkResponse>(`/api/local-zones/${encodeURIComponent(name)}`, { method: 'DELETE' })
}
export function putSettings(yaml: string): Promise<SaveSettingsResponse> {
return apiFetch<SaveSettingsResponse>('/api/settings', {
method: 'PUT',
+27 -11
View File
@@ -163,6 +163,26 @@ export interface BlocklistStats {
sources_detail: Record<string, BlocklistSourceMeta>
}
/** A BlockList object, as the console stores it (GET /api/blocklist-sources). */
export interface BlockListSpec {
url: string
format?: string
mirror?: string[]
disabled?: boolean
/** A duration such as "24h". */
refresh?: string
timeout?: string
max_bytes?: number
categories?: string[]
comment?: string
insecure_skip_tls_verify?: boolean
}
export interface BlocklistSource {
name: string
spec: BlockListSpec
}
export interface BlocklistsResponse {
items: BlocklistItem[]
stats: BlocklistStats
@@ -291,6 +311,13 @@ export interface ClientsResponse {
groups: ClientGroup[]
default_policy: DefaultPolicy
default_rate_limit: number
/**
* Set (to false) by the console, which edits ClientGroup objects and cannot
* edit what the resolver's config file defines. Absent when the resolver
* itself is the editor, which is when both are editable.
*/
default_policy_editable?: boolean
file_groups_editable?: boolean
}
/**
@@ -442,17 +469,6 @@ export interface SettingsResponse {
writable: boolean
}
// --- GET /api/local-zones -------------------------------------------------
export interface ZoneFile {
name: string
content: string
}
export interface LocalZonesResponse {
zones: ZoneFile[]
}
export interface SaveSettingsResponse {
ok: boolean
version: number
+312 -25
View File
@@ -8,11 +8,19 @@
import { useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import { AlertTriangle, Clock, Layers, RefreshCw, ShieldCheck, Split, Trash2 } from 'lucide-react'
import { AlertTriangle, Clock, Layers, Pencil, Plus, RefreshCw, ShieldCheck, Split, Trash2 } from 'lucide-react'
import { checkDomain, getBlocklists, refreshBlocklists, toggleBlocklist } from '../lib/api'
import {
checkDomain,
deleteBlocklistSource,
getBlocklists,
getBlocklistSources,
putBlocklistSource,
refreshBlocklists,
toggleBlocklist,
} from '../lib/api'
import { useWhoami } from '../lib/session.ts'
import type { BlocklistItem, BlocklistStats } from '../lib/types'
import type { BlockListSpec, BlocklistItem, BlocklistSource, BlocklistStats } from '../lib/types'
import { useApi, useAsyncAction } from '../hooks/useApi'
import { useAutoRefresh } from '../hooks/useAutoRefresh'
import { useDebounced } from '../hooks/useDebounced'
@@ -22,9 +30,12 @@ import {
Button,
Card,
CardBody,
ConfirmDialog,
Dialog,
EmptyState,
IconButton,
Input,
Select,
StatCard,
Switch,
Table,
@@ -62,6 +73,67 @@ const EMPTY_STATS: BlocklistStats = {
sources_detail: {},
}
const FORMATS = [
{ value: 'auto', label: 'Detect from the content' },
{ value: 'hosts', label: 'hosts file' },
{ value: 'plain', label: 'One domain per line' },
{ value: 'adblock', label: 'Adblock Plus' },
{ value: 'rpz', label: 'RPZ zone' },
]
/** The form for one BlockList. Fields the form does not show ride along in `extra`. */
interface SourceDraft {
isNew: boolean
name: string
url: string
format: string
refresh: string
categories: string
comment: string
enabled: boolean
extra: BlockListSpec
}
const EMPTY_SOURCE: SourceDraft = {
isNew: true,
name: '',
url: '',
format: 'auto',
refresh: '24h',
categories: '',
comment: '',
enabled: true,
extra: { url: '' },
}
function draftFromSource(src: BlocklistSource): SourceDraft {
const { spec } = src
return {
isNew: false,
name: src.name,
url: spec.url,
format: spec.format || 'auto',
refresh: spec.refresh ?? '',
categories: (spec.categories ?? []).join(', '),
comment: spec.comment ?? '',
enabled: !spec.disabled,
extra: spec,
}
}
function specFromDraft(d: SourceDraft): BlockListSpec {
const categories = d.categories.split(',').map((c) => c.trim()).filter((c) => c !== '')
return {
...d.extra,
url: d.url.trim(),
format: d.format === 'auto' ? undefined : d.format,
refresh: d.refresh.trim() || undefined,
categories: categories.length > 0 ? categories : undefined,
comment: d.comment.trim() || undefined,
disabled: d.enabled ? undefined : true,
}
}
function truncate(value: string, max = 42): string {
if (value.length <= max) return value
return `${value.slice(0, max - 1)}…`
@@ -71,7 +143,22 @@ export default function Blocklists() {
const { toast } = useToast()
const { canWrite } = useWhoami()
const { data, error, loading, refetch } = useApi(() => getBlocklists(), [])
useAutoRefresh(refetch, 15000)
// The BlockList objects, when this server manages them (the console does).
const sourcesApi = useApi(() => getBlocklistSources(), [])
const managed = sourcesApi.data !== null && sourcesApi.data !== undefined
const sources = useMemo<BlocklistSource[]>(() => sourcesApi.data ?? [], [sourcesApi.data])
const canEditSources = managed && canWrite
const refetchAll = (): void => {
refetch()
sourcesApi.refetch()
}
useAutoRefresh(refetchAll, 15000)
const [sourceDraft, setSourceDraft] = useState<SourceDraft | null>(null)
const [sourceError, setSourceError] = useState<string | null>(null)
const [pendingRemove, setPendingRemove] = useState<string | null>(null)
const saveSource = useAsyncAction(async (d: SourceDraft) => putBlocklistSource(d.name.trim(), specFromDraft(d)))
const removeSource = useAsyncAction(async (name: string) => deleteBlocklistSource(name))
const [overrides, setOverrides] = useState<Record<string, boolean>>({})
const [pendingRows, setPendingRows] = useState<Record<string, boolean>>({})
@@ -140,6 +227,35 @@ export default function Blocklists() {
refetch()
}
const submitSource = async (): Promise<void> => {
if (!sourceDraft) return
setSourceError(null)
if (sourceDraft.name.trim() === '' || sourceDraft.url.trim() === '') {
setSourceError('A list needs a name and a URL.')
return
}
const res = await saveSource.run(sourceDraft)
if (res === null) {
setSourceError(saveSource.error ?? 'The server rejected that list.')
return
}
notify(toast, 'ok', `${sourceDraft.isNew ? 'added' : 'saved'} ${sourceDraft.name.trim()}`)
setSourceDraft(null)
refetchAll()
}
const confirmRemove = async (): Promise<void> => {
if (pendingRemove === null) return
const res = await removeSource.run(pendingRemove)
if (res === null) {
notify(toast, 'fail', removeSource.error ?? `could not remove ${pendingRemove}`)
return
}
notify(toast, 'ok', `removed ${pendingRemove}`)
setPendingRemove(null)
refetchAll()
}
if (error) return <PageError error={error} onRetry={refetch} />
const columns = [
@@ -151,9 +267,11 @@ export default function Blocklists() {
{ key: 'updated', header: 'Updated', width: '12%' },
{ key: 'duration', header: 'Duration', width: '10%', align: 'right' as const },
{ key: 'source', header: 'Source', width: '24%' },
{ key: 'refresh', header: '', width: '5%', align: 'right' as const },
{ key: 'refresh', header: '', width: managed ? '10%' : '5%', align: 'right' as const },
]
const objectFor = (name: string): BlocklistSource | undefined => sources.find((x) => x.name === name)
const rows: Row[] = items.map((item) => {
// Everything about a source except its name and parse stats lives under
// meta; before that nested shape these columns read fields that never
@@ -200,17 +318,77 @@ export default function Blocklists() {
</span>
),
refresh: (
<IconButton
icon={RefreshCw}
label={`refresh ${item.name}`}
variant="ghost"
disabled={busy || !canWrite}
onClick={() => void onRefresh([item.name])}
/>
<span className="flex justify-end gap-0.5">
<IconButton
icon={RefreshCw}
label={`refresh ${item.name}`}
variant="ghost"
disabled={busy || !canWrite}
onClick={() => void onRefresh([item.name])}
/>
{objectFor(item.name) ? (
<>
<IconButton
icon={Pencil}
label={`edit ${item.name}`}
variant="ghost"
disabled={!canEditSources}
onClick={() => {
const src = objectFor(item.name)
if (src) {
setSourceError(null)
setSourceDraft(draftFromSource(src))
}
}}
/>
<IconButton
icon={Trash2}
label={`remove ${item.name}`}
variant="ghost"
disabled={!canEditSources}
onClick={() => setPendingRemove(item.name)}
/>
</>
) : null}
</span>
),
}
})
// Lists that exist as objects but that the resolver has not compiled yet (just
// added, or failing to download): shown, so a save is never invisible.
for (const src of sources) {
if (items.some((i) => i.name === src.name)) continue
rows.push({
__key: `pending-${src.name}`,
name: (
<span className="flex min-w-0 flex-col">
<span className="truncate font-medium">{src.name}</span>
<span className="truncate text-xs text-slate-500">waiting for the resolver</span>
</span>
),
format: <Badge tone="muted">{src.spec.format || 'auto'}</Badge>,
enabled: <Badge tone={src.spec.disabled ? 'muted' : 'default'}>{src.spec.disabled ? 'off' : 'on'}</Badge>,
rules: <span className="font-mono text-xs">—</span>,
size: <span className="font-mono text-xs">—</span>,
updated: <span className="text-xs">—</span>,
duration: <span className="font-mono text-xs">—</span>,
source: (
<span className="block max-w-[240px] truncate font-mono text-xs text-slate-500" title={src.spec.url}>
{truncate(src.spec.url)}
</span>
),
refresh: (
<span className="flex justify-end gap-0.5">
<IconButton icon={Pencil} label={`edit ${src.name}`} variant="ghost" disabled={!canEditSources}
onClick={() => { setSourceError(null); setSourceDraft(draftFromSource(src)) }} />
<IconButton icon={Trash2} label={`remove ${src.name}`} variant="ghost" disabled={!canEditSources}
onClick={() => setPendingRemove(src.name)} />
</span>
),
})
}
// The lookup answers 200 with an error string instead of a 4xx, so its three
// shapes are told apart by their keys rather than by the status code.
const lookupError = result !== null && 'error' in result ? result.error : null
@@ -225,15 +403,31 @@ export default function Blocklists() {
title="Blocklists"
description="Compiled rule sources, their freshness and their health."
actions={
<Button
icon={RefreshCw}
onClick={() => void onRefresh()}
loading={refresh.pending}
disabled={!canWrite}
title={canWrite ? undefined : 'This API key is read-only'}
>
Refresh all
</Button>
<div className="flex items-center gap-2">
{managed ? (
<Button
variant="outline"
icon={Plus}
onClick={() => {
setSourceError(null)
setSourceDraft(EMPTY_SOURCE)
}}
disabled={!canEditSources}
title={canWrite ? undefined : 'This API key is read-only'}
>
Add list
</Button>
) : null}
<Button
icon={RefreshCw}
onClick={() => void onRefresh()}
loading={refresh.pending}
disabled={!canWrite}
title={canWrite ? undefined : 'This API key is read-only'}
>
Refresh all
</Button>
</div>
}
/>
@@ -338,17 +532,110 @@ export default function Blocklists() {
empty={
<EmptyState
title="No blocklists configured"
description="Add list sources in the configuration to compile rules."
description={
managed
? 'Add a list above: a URL to a hosts file, an Adblock list or an RPZ zone.'
: 'Add list sources in the configuration to compile rules.'
}
/>
}
/>
</div>
</Card>
<p className="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
<Trash2 size={12} aria-hidden="true" />
Removing a list is a configuration change: delete its source in Settings, then refresh.
<p className="text-xs text-slate-500 dark:text-slate-400">
{managed
? 'Lists you add here are BlockList objects in the cluster. Lists named in the resolver\'s config file are shown but edited there. Turning a list off keeps it, and the resolver drops its rules.'
: 'Lists come from the resolver\'s configuration; edit the config and reload to change them.'}
</p>
<Dialog
open={sourceDraft !== null}
onClose={() => setSourceDraft(null)}
title={sourceDraft?.isNew ? 'Add a blocklist' : `Edit ${sourceDraft?.name ?? ''}`}
description="The resolver downloads the list, keeps a copy on its volume and refreshes it on the interval."
footer={
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setSourceDraft(null)} disabled={saveSource.pending}>
Cancel
</Button>
<Button onClick={() => void submitSource()} loading={saveSource.pending}>
{sourceDraft?.isNew ? 'Add list' : 'Save changes'}
</Button>
</div>
}
>
{sourceDraft ? (
<div className="space-y-3">
{sourceError ? (
<p className="rounded-md bg-red-500/10 px-3 py-2 text-sm text-red-700 dark:text-red-300">{sourceError}</p>
) : null}
<Input
label="Name"
hint="Lower-case letters, digits, '-' and '.'. It becomes the object's name and cannot be changed later."
value={sourceDraft.name}
disabled={!sourceDraft.isNew}
onChange={(e) => setSourceDraft({ ...sourceDraft, name: e.target.value })}
autoComplete="off"
spellCheck={false}
/>
<Input
label="URL"
placeholder="https://example.com/hosts"
hint="http or https."
value={sourceDraft.url}
onChange={(e) => setSourceDraft({ ...sourceDraft, url: e.target.value })}
autoComplete="off"
spellCheck={false}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Select
label="Format"
value={sourceDraft.format}
onChange={(e) => setSourceDraft({ ...sourceDraft, format: e.target.value })}
options={FORMATS}
/>
<Input
label="Refresh every"
placeholder="24h"
hint="Empty means the default, 24h."
value={sourceDraft.refresh}
onChange={(e) => setSourceDraft({ ...sourceDraft, refresh: e.target.value })}
autoComplete="off"
/>
</div>
<Input
label="Categories"
placeholder="ads, tracking"
hint="Comma separated labels shown next to the rules."
value={sourceDraft.categories}
onChange={(e) => setSourceDraft({ ...sourceDraft, categories: e.target.value })}
autoComplete="off"
/>
<Input
label="Comment"
value={sourceDraft.comment}
onChange={(e) => setSourceDraft({ ...sourceDraft, comment: e.target.value })}
/>
<Switch
checked={sourceDraft.enabled}
onChange={(next) => setSourceDraft({ ...sourceDraft, enabled: next })}
label={sourceDraft.enabled ? 'enabled' : 'disabled'}
/>
</div>
) : null}
</Dialog>
<ConfirmDialog
open={pendingRemove !== null}
onClose={() => setPendingRemove(null)}
onConfirm={() => void confirmRemove()}
title="Remove this blocklist?"
message={pendingRemove ? `${pendingRemove} and its rules are dropped from the resolver.` : ''}
confirmLabel="Remove"
destructive
pending={removeSource.pending}
/>
</div>
)
}
+25 -5
View File
@@ -213,7 +213,21 @@ export default function Clients() {
const { canWrite } = useWhoami()
const { data, error, loading, refetch } = useApi(() => getClients(), [])
const groups = useMemo<ClientGroup[]>(() => data?.groups ?? [], [data])
// 'history' rows are traffic counters for a name that is not a group (the
// default bucket, or a group removed since); saving them back would try to
// create them.
const groups = useMemo<ClientGroup[]>(
() => (data?.groups ?? []).filter((g) => g.source !== 'history'),
[data],
)
const defaultEditable = data?.default_policy_editable !== false
// A group the resolver's config file defines cannot be changed through the
// console, which writes cluster objects; saying so beats a save that
// appears to work.
const isLocked = (g: ClientGroup): boolean =>
g.source !== 'kubernetes' && data?.file_groups_editable === false
const lockedReason = "Defined in the resolver's config file; edit it there"
const [defaultPolicy] = useState<string>('')
const [defaultDraft, setDefaultDraft] = useState<string | null>(null)
const [editorOpen, setEditorOpen] = useState(false)
@@ -357,13 +371,13 @@ export default function Clients() {
title="Default policy"
subtitle={`Applied to clients that match no group. Default rate limit: ${
formatNumber(data?.default_rate_limit ?? 0)
} qps`}
} qps${defaultEditable ? '' : ' · set in the resolver\'s config (acl.default_policy)'}`}
>
<div className="w-full sm:max-w-xs">
<Select
label="Policy"
value={effectiveDefault}
disabled={loading || save.pending || !canWrite}
disabled={loading || save.pending || !canWrite || !defaultEditable}
onChange={(e) => void saveDefault(e.target.value)}
options={DEFAULT_POLICIES}
/>
@@ -397,6 +411,11 @@ export default function Clients() {
<Badge tone={group.policy === 'deny' ? 'danger' : group.policy === 'block_only' ? 'warn' : 'ok'}>
{group.policy}
</Badge>
{group.source === 'kubernetes' ? (
<Badge tone="info">cluster</Badge>
) : isLocked(group) ? (
<Badge tone="muted">config file</Badge>
) : null}
</div>
{group.comment ? (
<p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">{group.comment}</p>
@@ -417,13 +436,14 @@ export default function Clients() {
</div>
</div>
<div className="flex shrink-0 gap-1">
<IconButton icon={Pencil} label={`edit ${group.name}`} variant="ghost" disabled={!canWrite} onClick={() => openEdit(i)} />
<IconButton icon={Pencil} label={`edit ${group.name}`} variant="ghost" disabled={!canWrite || isLocked(group)} title={isLocked(group) ? lockedReason : undefined} onClick={() => openEdit(i)} />
<IconButton icon={Copy} label={`duplicate ${group.name}`} variant="ghost" disabled={!canWrite} onClick={() => duplicate(i)} />
<IconButton
icon={Trash2}
label={`delete ${group.name}`}
variant="ghost"
disabled={!canWrite}
disabled={!canWrite || isLocked(group)}
title={isLocked(group) ? lockedReason : undefined}
onClick={() => setPendingDelete(i)}
/>
</div>
+1 -9
View File
@@ -343,14 +343,6 @@ export default function Domains() {
onChange={(e) => setForm((f) => ({ ...f, action: e.target.value }))}
options={ACTIONS.map((a) => ({ value: a.value, label: a.label }))}
/>
<Input
label="List"
placeholder="inline"
hint="Optional group name, defaults to the inline list."
value={form.list}
onChange={(e) => setForm((f) => ({ ...f, list: e.target.value }))}
autoComplete="off"
/>
</div>
<button
type="button"
@@ -380,7 +372,7 @@ export default function Domains() {
title="Delete this rule?"
message={
pendingDelete
? `${pendingDelete.name} (${pendingDelete.action}) will be removed and the change persisted to the configuration file.`
? `${pendingDelete.name} (${pendingDelete.action}) will be removed from the DomainRule it belongs to.`
: ''
}
confirmLabel="Delete"
-242
View File
@@ -1,242 +0,0 @@
/** Edit console-managed authoritative zone files shared by DNS replicas. */
import { useEffect, useMemo, useState } from 'react'
import { FileCode2, Plus, Save, Trash2 } from 'lucide-react'
import { deleteLocalZone, getLocalZones, getZones, saveLocalZone } from '../lib/api'
import { useWhoami } from '../lib/session.ts'
import type { ZoneRecord } from '../lib/types'
import { useApi, useAsyncAction } from '../hooks/useApi'
import {
Badge,
Button,
Card,
CardBody,
Input,
Table,
Textarea,
useToast,
} from '../components/ui'
import { LoadingBlock, PageError, PageHeader } from '../components/layout'
const EXAMPLE = '$ORIGIN example.net.\n$TTL 300\n@ IN A 192.0.2.10\nwww IN CNAME @\n'
function validApex(value: string): boolean {
const apex = value.trim().replace(/\.$/, '')
if (apex === '' || apex.length > 242) return false
return apex.split('.').every((label) =>
label.length > 0 && label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(label),
)
}
export default function LocalZones() {
const { canWrite } = useWhoami()
const { toast } = useToast()
const { data, error, loading, refetch } = useApi(() => getLocalZones(), [])
// What the daemon has actually compiled out of the selected file. A raw
// textarea says nothing about what a zone ends up serving, and guessing
// from $TTL and owner columns is a bad way to find a typo.
const { data: compiled } = useApi(getZones, [])
const [selected, setSelected] = useState<string | null>(null)
const [draft, setDraft] = useState<{ name: string; content: string } | null>(null)
const [creating, setCreating] = useState(false)
const [newName, setNewName] = useState('')
const save = useAsyncAction(async (input: { name: string; content: string }) =>
saveLocalZone(input.name, input.content),
)
const remove = useAsyncAction(async (name: string) => deleteLocalZone(name))
const zones = data?.zones ?? []
const active = useMemo(() => zones.find((zone) => zone.name === selected) ?? null, [zones, selected])
// The compiled view is keyed by the zone's canonical apex, which carries a
// trailing dot the file listing does not.
const served = useMemo(() => {
if (active === null || compiled === null) return null
const want = active.name.endsWith('.') ? active.name : `${active.name}.`
return compiled.zones.find((zone) => zone.origin === want) ?? null
}, [active, compiled])
useEffect(() => {
if (selected === null && zones.length > 0 && !creating && draft === null) setSelected(zones[0].name)
}, [creating, draft, selected, zones])
useEffect(() => {
if (active !== null && draft === null) setDraft({ name: active.name, content: active.content })
if (active === null && selected !== null && !creating) setDraft(null)
}, [active, creating, draft, selected])
const startNew = (): void => {
setSelected(null)
setDraft(null)
setNewName('')
setCreating(true)
}
const beginCreate = (): void => {
const name = newName.trim().replace(/\.$/, '').toLowerCase()
if (!validApex(name)) {
toast({ title: 'Enter a valid DNS zone name', tone: 'danger' })
return
}
if (zones.some((zone) => zone.name === name)) {
toast({ title: 'That zone already exists', tone: 'danger' })
return
}
setSelected(null)
setDraft({ name, content: `$ORIGIN ${name}.\n$TTL 300\n` })
setCreating(false)
}
const onSave = async (): Promise<void> => {
if (draft === null || !validApex(draft.name)) return
const result = await save.run(draft)
if (result === null) {
toast({ title: 'Could not save zone', description: save.error ?? undefined, tone: 'danger' })
return
}
setSelected(draft.name.trim().replace(/\.$/, '').toLowerCase())
setDraft(null)
await refetch()
toast({ title: 'Zone saved', description: 'The DNS pods will load the updated file.', tone: 'ok' })
}
const onDelete = async (): Promise<void> => {
if (selected === null) return
if (!window.confirm(`Delete the ${selected} zone and all of its records?`)) return
const result = await remove.run(selected)
if (result === null) {
toast({ title: 'Could not delete zone', description: remove.error ?? undefined, tone: 'danger' })
return
}
setSelected(null)
setDraft(null)
await refetch()
toast({ title: 'Zone deleted', tone: 'ok' })
}
if (loading) return <LoadingBlock />
if (error !== null) return <PageError error={error} onRetry={() => void refetch()} />
const changed = draft !== null && (active === null || draft.name !== active.name || draft.content !== active.content)
return (
<div className="space-y-4">
<PageHeader
title="Zone files"
description="Edit master-file data for authoritative zones. These files are shared across replicas; names missing beneath a zone apex return NXDOMAIN. Use Managed zones for record-by-record editing."
actions={<Button icon={Plus} onClick={startNew} disabled={!canWrite}>New zone</Button>}
/>
<div className="grid gap-4 xl:grid-cols-[minmax(220px,0.7fr)_minmax(0,2fr)]">
<Card title="Zones" subtitle={`${zones.length} master files`} padded={false}>
<div className="space-y-1 p-2">
{zones.length === 0 && <p className="px-3 py-5 text-sm text-fg-subtle">No local zones yet.</p>}
{zones.map((zone) => (
<button
key={zone.name}
type="button"
onClick={() => { setCreating(false); setDraft(null); setSelected(zone.name) }}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors ${selected === zone.name ? 'bg-accent-soft text-accent' : 'text-fg-muted hover:bg-surface-2 hover:text-fg'}`}
>
<FileCode2 size={15} aria-hidden="true" />
<span className="truncate font-mono">{zone.name}.</span>
</button>
))}
</div>
</Card>
<Card
title={draft?.name ? `${draft.name}.zone` : 'Zone file'}
subtitle="RFC 1035 master-file syntax. The zone apex is used as the default origin."
padded={false}
actions={selected !== null && draft !== null && canWrite ? (
<Button variant="danger" icon={Trash2} onClick={() => void onDelete()} loading={remove.pending}>Delete zone</Button>
) : undefined}
>
{creating ? (
<CardBody className="space-y-3">
<Input label="Zone apex" placeholder="example.net" value={newName} onChange={(event) => setNewName(event.currentTarget.value)} autoFocus />
<Button variant="primary" icon={Plus} onClick={beginCreate}>Create zone</Button>
</CardBody>
) : draft === null ? (
<CardBody><p className="text-sm text-fg-subtle">Choose a zone or create one to add records.</p></CardBody>
) : (
<div className="space-y-3 p-4">
{served === null ? (
<p className="rounded-md border border-line bg-surface-2 px-3 py-2 text-xs text-fg-muted">
The daemon has not compiled a zone called{' '}
<span className="font-mono">{draft.name}.</span> — the file may be invalid, or
it may be a name the resolver does not serve. The errors are on the status page.
</p>
) : (
<div className="rounded-lg border border-line">
<p className="border-b border-line bg-surface-2 px-3 py-2 text-xs text-fg-muted">
<span className="font-semibold text-fg">
{served.record_count} record{served.record_count === 1 ? '' : 's'} served
</span>{' '}
for this apex
{served.serial > 0 ? ` · SOA serial ${served.serial}` : ''}
{served.delegations.length > 0
? ` · ${served.delegations.length} delegation${served.delegations.length === 1 ? '' : 's'}`
: ''}
. This is what the resolver answers with, compiled from the file below.
</p>
<div className="p-2">
<Table
columns={[
{ key: 'name', header: 'Name', width: '40%' },
{ key: 'type', header: 'Type', width: '10%' },
{ key: 'value', header: 'Value', width: '34%' },
{ key: 'ttl', header: 'TTL', width: '10%', align: 'right' },
{ key: 'managed', header: 'Source', width: '6%' },
]}
rows={served.records.map((record: ZoneRecord, i) => ({
__key: `${record.name}-${record.type}-${record.value}-${i}`,
name: (
<span className="block max-w-[280px] truncate font-mono text-xs">
{record.name}
{record.wildcard ? (
<span className="ml-1 text-fg-subtle">wildcard</span>
) : null}
</span>
),
type: <Badge tone={record.delegation ? 'warn' : 'default'}>{record.type}</Badge>,
value: (
<span className="block max-w-[320px] truncate font-mono text-xs">
{record.value}
</span>
),
ttl: <span className="font-mono text-xs">{record.ttl}</span>,
managed: record.managed ? 'console' : 'file',
}))}
rowKey={(row) => String(row.__key)}
/>
</div>
</div>
)}
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="min-w-56 flex-1">
<Input label="Zone apex" value={draft.name} disabled={active !== null} onChange={(event) => setDraft({ ...draft, name: event.currentTarget.value })} />
</div>
<Button variant="primary" icon={Save} onClick={() => void onSave()} loading={save.pending} disabled={!canWrite || !changed || !validApex(draft.name)}>Save records</Button>
</div>
<Textarea
monospace
rows={20}
spellCheck={false}
aria-label="DNS zone records"
value={draft.content}
onChange={(event) => setDraft({ ...draft, content: event.currentTarget.value })}
placeholder={EXAMPLE}
className="min-h-[420px] font-mono text-xs leading-6"
/>
<p className="text-xs text-fg-subtle">Example: <code>@ IN A 192.0.2.10</code> or <code>www IN CNAME @</code>. Invalid records are rejected before saving.</p>
{!canWrite && <p className="text-xs text-warn">Your account is read-only.</p>}
</div>
)}
</Card>
</div>
</div>
)
}
+1 -1
View File
@@ -286,7 +286,7 @@ export default function Lookup() {
open={override !== null}
onClose={() => setOverride(null)}
title="Create an override"
description="The record is written to the console's managed zone, which makes that apex authoritative for the names it holds."
description="The record is added to the DNSZone for that apex, which must already exist: a zone is authoritative for every name under it, so a new one is created from the Zones page with its own name servers."
footer={
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setOverride(null)} disabled={write.pending}>
+1 -1
View File
@@ -125,7 +125,7 @@ export default function Overrides() {
<Card
title="Current redirects"
subtitle={`${items.length} ${items.length === 1 ? 'domain' : 'domains'} · source: managed:address-overrides`}
subtitle={`${items.length} ${items.length === 1 ? 'domain' : 'domains'} · AddressOverride objects`}
>
{items.length === 0 ? (
<EmptyState
+21 -2
View File
@@ -20,7 +20,7 @@ import {
Trash2,
} from 'lucide-react'
import { getUpstreams, putUpstreams } from '../lib/api'
import { getStatus, getUpstreams, putUpstreams } from '../lib/api'
import { useWhoami } from '../lib/session.ts'
import type { UpstreamBootstrap, UpstreamPolicy, UpstreamProto, UpstreamStat } from '../lib/types'
import { useApi, useAsyncAction } from '../hooks/useApi'
@@ -173,8 +173,15 @@ function splitList(value: string): string[] {
export default function Upstreams() {
const { toast } = useToast()
const { canWrite } = useWhoami()
const { canWrite: keyCanWrite } = useWhoami()
const { data, error, loading, refetch } = useApi(() => getUpstreams(), [])
// Upstreams are deployment configuration: in the cluster they come from the
// resolver's config file, which is mounted read-only, so this page shows live
// health and cannot save. The status endpoint says whether the file can be
// written.
const { data: status } = useApi(() => getStatus(), [])
const configWritable = status?.config.writable !== false
const canWrite = keyCanWrite && configWritable
const [draft, setDraft] = useState<Draft | null>(null)
const [editorOpen, setEditorOpen] = useState(false)
@@ -360,6 +367,16 @@ export default function Upstreams() {
}
/>
{!configWritable ? (
<p className="rounded-md bg-sky-500/10 px-3 py-2 text-sm text-sky-800 dark:text-sky-200">
Upstreams are part of the resolver's configuration (in Kubernetes, its ConfigMap), so this
page shows their live health and cannot change them. Edit the config and reload.
</p>
) : null}
{/* A disabled fieldset disables every control inside it, so a read-only
page cannot build a draft it could never save. */}
<fieldset disabled={!canWrite} className="m-0 min-w-0 space-y-4 border-0 p-0">
<Card title="Pool behaviour">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Select
@@ -484,6 +501,8 @@ export default function Upstreams() {
)}
</Card>
</fieldset>
<p className="text-xs text-slate-500 dark:text-slate-400">
{working.servers.length} server{working.servers.length === 1 ? '' : 's'} ·{' '}
{formatNumber(working.servers.reduce((sum, s) => sum + s.weight, 0))} total weight ·
+20 -38
View File
@@ -2,17 +2,17 @@
* Zones — the local zones this resolver answers for itself, and the
* delegations that hand a name to another server.
*
* The console only ever writes the managed zone directory, so a record from an
* operator's master file is shown and left alone: it carries no edit controls,
* and the API would refuse it anyway. Everything the page does is a
* request-then-refetch, because every write recompiles the zone store and the
* serial in the answer is the cheapest way to see that the file moved.
* In the cluster a zone is a DNSZone object and the console edits it; a record
* from a master file in the resolver's own config is shown and left alone: it
* carries no edit controls, and the API would refuse it anyway. Everything the
* page does is a request-then-refetch: a write changes the object, the resolver
* re-renders the zone, and the serial in the answer is the cheapest way to see
* that it moved.
*/
import { useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import { AlertTriangle, FileCode2, List, Pencil, Plus, Search, Trash2, Waypoints } from 'lucide-react'
import { AlertTriangle, List, Pencil, Plus, Search, Trash2, Waypoints } from 'lucide-react'
import {
addZoneDelegation,
@@ -122,7 +122,6 @@ const NEW_ZONE = '__new__'
export default function Zones() {
const { toast } = useToast()
const { canWrite } = useWhoami()
const navigate = useNavigate()
const { data, error, loading, refetch } = useApi(getZones, [])
const [origin, setOrigin] = useState('')
@@ -329,7 +328,7 @@ export default function Zones() {
const noWriteReason = !canWrite
? 'This API key is read-only'
: !writable
? 'No writable managed zone directory'
? 'Zones are edited from the console, not from the resolver itself'
: undefined
const recordColumns = [
@@ -383,7 +382,7 @@ export default function Zones() {
/>
</>
) : (
<span className="text-[10px] text-fg-subtle" title="This record is in a master file, not the managed directory">
<span className="text-[10px] text-fg-subtle" title="This record is in a master file in the resolver's config, not in a DNSZone object">
read-only
</span>
)}
@@ -418,8 +417,8 @@ export default function Zones() {
return (
<div className="space-y-4">
<PageHeader
title="Managed zones"
description="Manage records and delegations for console-owned authoritative zones. Use Zone files to edit master-file data."
title="Zones"
description="Records and delegations for the zones this resolver answers for itself."
actions={
<div className="flex flex-wrap gap-2">
<Button
@@ -442,18 +441,11 @@ export default function Zones() {
}
/>
{data !== null && data.managed_dir === '' ? (
{data !== null && !data.writable ? (
<Notice tone="info" title="Zones are read-only here">
Set <code className="font-mono">blocklist.managed_zones</code> to a writable directory and
this page can create zones, add records and delegate names. The zones below are
compiled from <code className="font-mono">blocklist.local_zones</code> and are not
rewritten by the console.
</Notice>
) : null}
{data !== null && data.managed_dir !== '' && !data.writable ? (
<Notice tone="warn" title="The managed zone directory is not writable">
<code className="font-mono">{data.managed_dir}</code> exists but the daemon cannot
write to it, so the console is read-only. The zone files themselves are fine.
This is the resolver's own view. Zones from master files named in{' '}
<code className="font-mono">blocklist.local_zones</code> are edited on disk; zones kept as
DNSZone objects are edited from the console.
</Notice>
) : null}
{data !== null && data.error !== '' ? (
@@ -467,9 +459,9 @@ export default function Zones() {
<EmptyState
title="No local zones"
description={
data?.managed_dir === ''
? 'Add a zone with blocklist.local_zones, or set blocklist.managed_zones and create one from here.'
: 'Create one from here: adding a record to a zone that does not exist yet starts it.'
writable
? 'Create one from here: adding a record to a zone that does not exist yet starts it.'
: 'Add a master file to blocklist.local_zones, or create a zone from the console.'
}
/>
</Card>
@@ -492,23 +484,13 @@ export default function Zones() {
</p>
<p className="text-fg-subtle">
{zone?.managed === true
? 'Managed by this console'
: `From ${zone?.files.length ?? 0} file${zone?.files.length === 1 ? '' : 's'}`}
? 'A DNSZone object'
: `From ${zone?.files.length ?? 0} master file${zone?.files.length === 1 ? '' : 's'} (read-only)`}
{data !== null ? ` · loaded ${formatRelative(data.built_at)}` : ''}
</p>
</div>
</div>
<div className="flex items-end justify-end gap-2">
{zone !== null && !zone.managed ? (
<Button
variant="ghost"
icon={FileCode2}
onClick={() => navigate('/local-zones')}
title="This zone comes from a master file, not the managed directory. Local zones edits that file."
>
Edit the file
</Button>
) : null}
{zone !== null && zone.managed ? (
<Button
variant="ghost"