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:
1 parent
7d58b80583
commit
7fa48bcdf9
18 files changed
+726
-404
No files matched your search
+21
-12
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user