diff --git a/internal/api/api.go b/internal/api/api.go index 8bfa9cf..f6c1928 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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 } diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 9954a3b..2d980a1 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -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) } diff --git a/internal/api/writable_test.go b/internal/api/writable_test.go new file mode 100644 index 0000000..b98d026 --- /dev/null +++ b/internal/api/writable_test.go @@ -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") + } +} diff --git a/internal/app/console_e2e_test.go b/internal/app/console_e2e_test.go index 5977794..fc80515 100644 --- a/internal/app/console_e2e_test.go +++ b/internal/app/console_e2e_test.go @@ -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) + } +} diff --git a/internal/app/integration_test.go b/internal/app/integration_test.go index 19603c9..741ccb7 100644 --- a/internal/app/integration_test.go +++ b/internal/app/integration_test.go @@ -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) + } +} diff --git a/internal/console/hooks.go b/internal/console/hooks.go index 5a0cf2b..10df747 100644 --- a/internal/console/hooks.go +++ b/internal/console/hooks.go @@ -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) } diff --git a/web/src/App.tsx b/web/src/App.tsx index 8fd0fc0..662d25b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() { } /> } /> } /> - } /> } /> } /> } /> diff --git a/web/src/components/layout/AppShell.tsx b/web/src/components/layout/AppShell.tsx index 2f9b632..fd4275e 100644 --- a/web/src/components/layout/AppShell.tsx +++ b/web/src/components/layout/AppShell.tsx @@ -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 }, diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index e93ca80..f7c931d 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -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 { return apiFetch('/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 { + 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 { + return apiFetch(`/api/blocklist-sources/${encodeURIComponent(name)}`, { + method: 'PUT', + body: JSON.stringify(spec), + }) +} + +export function deleteBlocklistSource(name: string): Promise { + return apiFetch(`/api/blocklist-sources/${encodeURIComponent(name)}`, { method: 'DELETE' }) +} + export function getBlocklists(): Promise { return apiFetch('/api/blocklists') } @@ -366,21 +393,6 @@ export function getSettings(): Promise { return apiFetch('/api/settings') } -export function getLocalZones(): Promise { - return apiFetch('/api/local-zones') -} - -export function saveLocalZone(name: string, content: string): Promise { - return apiFetch(`/api/local-zones/${encodeURIComponent(name)}`, { - method: 'PUT', - body: JSON.stringify({ content }), - }) -} - -export function deleteLocalZone(name: string): Promise { - return apiFetch(`/api/local-zones/${encodeURIComponent(name)}`, { method: 'DELETE' }) -} - export function putSettings(yaml: string): Promise { return apiFetch('/api/settings', { method: 'PUT', diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 139e5f7..389c527 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -163,6 +163,26 @@ export interface BlocklistStats { sources_detail: Record } +/** 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 diff --git a/web/src/pages/Blocklists.tsx b/web/src/pages/Blocklists.tsx index 1c12b35..1e8a522 100644 --- a/web/src/pages/Blocklists.tsx +++ b/web/src/pages/Blocklists.tsx @@ -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(() => sourcesApi.data ?? [], [sourcesApi.data]) + const canEditSources = managed && canWrite + const refetchAll = (): void => { + refetch() + sourcesApi.refetch() + } + useAutoRefresh(refetchAll, 15000) + + const [sourceDraft, setSourceDraft] = useState(null) + const [sourceError, setSourceError] = useState(null) + const [pendingRemove, setPendingRemove] = useState(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>({}) const [pendingRows, setPendingRows] = useState>({}) @@ -140,6 +227,35 @@ export default function Blocklists() { refetch() } + const submitSource = async (): Promise => { + 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 => { + 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 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() { ), refresh: ( - void onRefresh([item.name])} - /> + + void onRefresh([item.name])} + /> + {objectFor(item.name) ? ( + <> + { + const src = objectFor(item.name) + if (src) { + setSourceError(null) + setSourceDraft(draftFromSource(src)) + } + }} + /> + setPendingRemove(item.name)} + /> + + ) : null} + ), } }) + // 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: ( + + {src.name} + waiting for the resolver + + ), + format: {src.spec.format || 'auto'}, + enabled: {src.spec.disabled ? 'off' : 'on'}, + rules: —, + size: —, + updated: —, + duration: —, + source: ( + + {truncate(src.spec.url)} + + ), + refresh: ( + + { setSourceError(null); setSourceDraft(draftFromSource(src)) }} /> + setPendingRemove(src.name)} /> + + ), + }) + } + // 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={ - +
+ {managed ? ( + + ) : null} + +
} /> @@ -338,17 +532,110 @@ export default function Blocklists() { empty={ } /> -

-

+ {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.'}

+ + 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={ +
+ + +
+ } + > + {sourceDraft ? ( +
+ {sourceError ? ( +

{sourceError}

+ ) : null} + setSourceDraft({ ...sourceDraft, name: e.target.value })} + autoComplete="off" + spellCheck={false} + /> + setSourceDraft({ ...sourceDraft, url: e.target.value })} + autoComplete="off" + spellCheck={false} + /> +
+ setSourceDraft({ ...sourceDraft, refresh: e.target.value })} + autoComplete="off" + /> +
+ setSourceDraft({ ...sourceDraft, categories: e.target.value })} + autoComplete="off" + /> + setSourceDraft({ ...sourceDraft, comment: e.target.value })} + /> + setSourceDraft({ ...sourceDraft, enabled: next })} + label={sourceDraft.enabled ? 'enabled' : 'disabled'} + /> +
+ ) : null} +
+ + 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} + /> ) } diff --git a/web/src/pages/Clients.tsx b/web/src/pages/Clients.tsx index a35e536..c4bdf97 100644 --- a/web/src/pages/Clients.tsx +++ b/web/src/pages/Clients.tsx @@ -213,7 +213,21 @@ export default function Clients() { const { canWrite } = useWhoami() const { data, error, loading, refetch } = useApi(() => getClients(), []) - const groups = useMemo(() => 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( + () => (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('') const [defaultDraft, setDefaultDraft] = useState(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)'}`} >
setForm((f) => ({ ...f, list: e.target.value }))} - autoComplete="off" - />
} - /> - -
- -
- {zones.length === 0 &&

No local zones yet.

} - {zones.map((zone) => ( - - ))} -
-
- - void onDelete()} loading={remove.pending}>Delete zone - ) : undefined} - > - {creating ? ( - - setNewName(event.currentTarget.value)} autoFocus /> - - - ) : draft === null ? ( -

Choose a zone or create one to add records.

- ) : ( -
- {served === null ? ( -

- The daemon has not compiled a zone called{' '} - {draft.name}. — the file may be invalid, or - it may be a name the resolver does not serve. The errors are on the status page. -

- ) : ( -
-

- - {served.record_count} record{served.record_count === 1 ? '' : 's'} served - {' '} - 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. -

-
- ({ - __key: `${record.name}-${record.type}-${record.value}-${i}`, - name: ( - - {record.name} - {record.wildcard ? ( - wildcard - ) : null} - - ), - type: {record.type}, - value: ( - - {record.value} - - ), - ttl: {record.ttl}, - managed: record.managed ? 'console' : 'file', - }))} - rowKey={(row) => String(row.__key)} - /> - - - )} -
-
- setDraft({ ...draft, name: event.currentTarget.value })} /> -
- -
-