Multi-server CLI, more reverse zones, and a fix for new view directories

- CLI: name several servers and groups in the config file; select them
  with -s/--server NAME|GROUP|all|URL or JARL_SERVER. A command aimed at
  several servers is dry-run on all of them first and only runs if every
  one accepts it. Named servers always use their own token.
- Reverse zones: /32 and /128 get single-address zones; networks between
  octet or nibble boundaries (e.g. a /22) are covered by several zones
  created together, from the CLI and the web UI.
- Fix: under jarl.service's RestrictSUIDSGID, creating a view directory
  failed on chmod with the setgid bit; fall back to the permission bits.
- Web UI: the review/live switch is a small header button, and the
  pending-changes bar only shows when there are pending changes.
- TSIG key wording; the keys page points to restricted API tokens.
- Docs: several servers (cli.md), a secondary server (operations.md),
  tokens vs TSIG keys (permissions.md); CHANGELOG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-08 08:12:03 +00:00
1 parent a7d389be27
commit 785b0006f9
22 files changed
+1375 -185

No files matched your search

+24 -1
View File
@@ -108,9 +108,32 @@ func (d *Deployer) EnsureDir(dir string, mode fs.FileMode) error {
return fmt.Errorf("chgrp %s: %w", dir, err)
}
}
return os.Chmod(dir, mode)
fi, err := os.Stat(dir)
if err != nil {
return err
}
// mkdir applies the umask, and a setgid parent passes its bit on, so
// the mode usually needs fixing. Under systemd's RestrictSUIDSGID (as in
// jarl.service) any chmod that includes the setgid bit fails with
// EPERM; the permission bits matter more (named writes journals here),
// and every file Jarl writes gets the group explicitly, so fall back to
// them alone.
if fi.Mode()&(fs.ModePerm|fs.ModeSetgid) == mode&(fs.ModePerm|fs.ModeSetgid) {
return nil
}
err = chmod(dir, mode)
if errors.Is(err, fs.ErrPermission) && mode&fs.ModeSetgid != 0 {
err = chmod(dir, mode.Perm())
}
if err != nil {
return fmt.Errorf("chmod %s: %w", dir, err)
}
return nil
}
// chmod is os.Chmod; tests replace it to simulate a sandbox.
var chmod = os.Chmod
func (d *Deployer) writeAtomic(path string, content []byte, mode fs.FileMode) error {
dir := filepath.Dir(path)
if err := d.EnsureDir(dir, d.dirMode(dir)); err != nil {
+47
View File
@@ -0,0 +1,47 @@
package engine
import (
"io/fs"
"os"
"path/filepath"
"syscall"
"testing"
)
// Under systemd's RestrictSUIDSGID a chmod that sets the setgid bit fails
// with EPERM; EnsureDir must still create a group-writable directory.
func TestEnsureDirSetgidRestricted(t *testing.T) {
old := chmod
defer func() { chmod = old }()
chmod = func(name string, mode fs.FileMode) error {
if mode&fs.ModeSetgid != 0 {
return &fs.PathError{Op: "chmod", Path: name, Err: syscall.EPERM}
}
return os.Chmod(name, mode)
}
oldMask := syscall.Umask(0o027)
defer syscall.Umask(oldMask)
root := t.TempDir()
d := &Deployer{NamedWritable: []string{root}}
dir := filepath.Join(root, "internal")
if err := d.EnsureDir(dir, d.dirMode(dir)); err != nil {
t.Fatal(err)
}
fi, err := os.Stat(dir)
if err != nil {
t.Fatal(err)
}
if fi.Mode().Perm() != 0o770 {
t.Errorf("mode = %v, want group-writable 0770", fi.Mode())
}
// Unrestricted, the setgid bit is set too.
chmod = os.Chmod
dir2 := filepath.Join(root, "external")
if err := d.EnsureDir(dir2, d.dirMode(dir2)); err != nil {
t.Fatal(err)
}
if fi, _ := os.Stat(dir2); fi.Mode()&(fs.ModePerm|fs.ModeSetgid) != 0o770|fs.ModeSetgid {
t.Errorf("mode = %v, want 2770", fi.Mode())
}
}
+1 -1
View File
@@ -966,7 +966,7 @@ func applyTSIGPut(st *model.State, op model.Op, c *applyCtx) error {
if in.Name != nil {
name := model.NormalizeKeyName(*in.Name)
if err := dnsname.CheckZoneName(dnsname.Fqdn(name)); err != nil || name == "" || name == "." {
return errf(KindBadRequest, "invalid key name %q (key names are domain names, e.g. xfer-key or acme.example.com)", *in.Name)
return errf(KindBadRequest, "invalid key name %q (use letters, digits, hyphens and dots, e.g. xfer-key or ns2-xfer.example.com)", *in.Name)
}
if other := st.KeyByName(name); other != nil && other.ID != k.ID {
return errf(KindConflict, "TSIG key %s already exists", name)