package engine import ( "crypto/sha256" "encoding/hex" "errors" "fmt" "io/fs" "os" "os/user" "path/filepath" "sort" "strconv" "strings" udiff "github.com/aymanbagabas/go-udiff" "jarl/internal/model" "jarl/internal/render" ) // Deployer writes rendered files to disk. type Deployer struct { // Group, if set, is the group generated files and directories belong // to (named must be able to read them; on Debian this is "bind"). Group string // NamedWritable lists directories (and their subdirectories) named // must be able to write to: zone files (for journals and DNSSEC // signing) and secondary zone files. Other directories are only // readable by the group. NamedWritable []string gid int } func (d *Deployer) dirMode(dir string) fs.FileMode { for _, w := range d.NamedWritable { if w != "" && (dir == w || strings.HasPrefix(dir, w+string(filepath.Separator))) { return 0o770 | fs.ModeSetgid } } return 0o750 | fs.ModeSetgid } func (d *Deployer) groupID() (int, error) { if d.Group == "" { return -1, nil } if d.gid != 0 { return d.gid, nil } if n, err := strconv.Atoi(d.Group); err == nil { d.gid = n return n, nil } g, err := user.LookupGroup(d.Group) if err != nil { return -1, fmt.Errorf("group %q: %w", d.Group, err) } n, _ := strconv.Atoi(g.Gid) d.gid = n return n, nil } type fileBackup struct { path string existed bool content []byte mode fs.FileMode } // Backup holds the previous contents of files changed by Install. type Backup struct { d *Deployer files []fileBackup } func readFile(path string) ([]byte, bool, error) { b, err := os.ReadFile(path) if errors.Is(err, fs.ErrNotExist) { return nil, false, nil } if err != nil { return nil, false, err } return b, true, nil } // EnsureDir creates dir (and missing parents) with mode and the deploy // group. Existing directories are left alone. func (d *Deployer) EnsureDir(dir string, mode fs.FileMode) error { if _, err := os.Stat(dir); err == nil { return nil } if parent := filepath.Dir(dir); parent != dir { if err := d.EnsureDir(parent, d.dirMode(parent)); err != nil { return err } } if err := os.Mkdir(dir, mode.Perm()); err != nil && !errors.Is(err, fs.ErrExist) { return err } gid, err := d.groupID() if err != nil { return err } if gid >= 0 { if err := os.Chown(dir, -1, gid); err != nil { return fmt.Errorf("chgrp %s: %w", dir, err) } } 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 { return err } tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".jarl-*") if err != nil { return err } name := tmp.Name() ok := false defer func() { if !ok { tmp.Close() os.Remove(name) } }() if _, err := tmp.Write(content); err != nil { return err } if err := tmp.Sync(); err != nil { return err } if err := tmp.Chmod(mode); err != nil { return err } gid, err := d.groupID() if err != nil { return err } if gid >= 0 { if err := tmp.Chown(-1, gid); err != nil { return fmt.Errorf("chgrp %s: %w", path, err) } } if err := tmp.Close(); err != nil { return err } if err := os.Rename(name, path); err != nil { return err } ok = true if df, err := os.Open(dir); err == nil { df.Sync() df.Close() } return nil } // writeNew creates a file that doesn't exist yet (fs.ErrExist if it // does), with the deploy group. func (d *Deployer) writeNew(path string, content []byte, mode fs.FileMode) error { dir := filepath.Dir(path) if err := d.EnsureDir(dir, d.dirMode(dir)); err != nil { return err } f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) if err != nil { return err } _, err = f.Write(content) if gid, gerr := d.groupID(); err == nil && gerr == nil && gid >= 0 { err = f.Chown(-1, gid) } if cerr := f.Close(); err == nil { err = cerr } if err != nil { os.Remove(path) } return err } // finish gives a temporary file written next to path its mode and the // deploy group, and moves it into place. func (d *Deployer) finish(tmp, path string, mode fs.FileMode) error { if err := os.Chmod(tmp, mode); err != nil { return err } gid, err := d.groupID() if err != nil { return err } if gid >= 0 { if err := os.Chown(tmp, -1, gid); err != nil { return fmt.Errorf("chgrp %s: %w", path, err) } } return os.Rename(tmp, path) } // Install writes files and removes paths, returning a backup that undoes // the changes. On error the files already written are restored. func (d *Deployer) Install(files []*render.File, remove []string, dirs []string) (*Backup, error) { b := &Backup{d: d} for _, dir := range dirs { // Directories named writes into (secondary zone files). if err := d.EnsureDir(dir, 0o770|fs.ModeSetgid); err != nil { return b, err } } save := func(path string) error { old, existed, err := readFile(path) if err != nil { return err } mode := fs.FileMode(0o640) if fi, err := os.Stat(path); err == nil { mode = fi.Mode().Perm() } b.files = append(b.files, fileBackup{path: path, existed: existed, content: old, mode: mode}) return nil } for _, f := range files { if err := save(f.Path); err != nil { b.Restore() return nil, err } if err := d.writeAtomic(f.Path, f.Content, f.Mode); err != nil { b.Restore() return nil, fmt.Errorf("writing %s: %w", f.Path, err) } } for _, p := range remove { if err := save(p); err != nil { b.Restore() return nil, err } if err := os.Remove(p); err != nil && !errors.Is(err, fs.ErrNotExist) { b.Restore() return nil, fmt.Errorf("removing %s: %w", p, err) } } return b, nil } // Restore puts back the previous file contents. func (b *Backup) Restore() error { if b == nil { return nil } var errs []error for i := len(b.files) - 1; i >= 0; i-- { f := b.files[i] if f.existed { if err := b.d.writeAtomic(f.path, f.content, f.mode); err != nil { errs = append(errs, err) } } else if err := os.Remove(f.path); err != nil && !errors.Is(err, fs.ErrNotExist) { errs = append(errs, err) } } return errors.Join(errs...) } func sha(b []byte) string { h := sha256.Sum256(b) return hex.EncodeToString(h[:]) } // DriftEntry is a managed file that was changed outside Jarl. type DriftEntry struct { Path string `json:"path"` Reason string `json:"reason"` // modified, missing } // CheckDrift compares managed files on disk with what Jarl last wrote. func CheckDrift(deployed map[string]*model.DeployedFile) []DriftEntry { var out []DriftEntry for path, f := range deployed { b, exists, err := readFile(path) switch { case err != nil: out = append(out, DriftEntry{Path: path, Reason: "unreadable: " + err.Error()}) case !exists: out = append(out, DriftEntry{Path: path, Reason: "missing"}) case sha(b) != f.SHA256: out = append(out, DriftEntry{Path: path, Reason: "modified"}) } } sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path }) return out } // fileDiff returns a unified diff from old to new for display. Secret // files only show that they changed. func fileDiff(path string, old []byte, existed bool, new []byte, removed, secret bool) string { from, to := path, path if !existed { from = "/dev/null" } if removed { to = "/dev/null" } if secret { return fmt.Sprintf("--- %s\n+++ %s\n@@ contents hidden: this file holds TSIG secrets @@\n", from, to) } d := udiff.Unified(from, to, string(old), string(new)) if d != "" && !strings.HasSuffix(d, "\n") { d += "\n" } return d }