jarld downloads blocklists (Adblock, hosts or plain domains), checks them daily and reloads named only when a list changed; your own block and allow rules take precedence, and the most specific rule wins. Lists and rules apply per view (default: every view that offers recursion). Web UI Blocking page with name checks, jarl blocklist and jarl block, the /blocking API, blocked counts in the statistics. Database schema 6. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
340 lines
8.3 KiB
Go
340 lines
8.3 KiB
Go
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
|
|
}
|