Decision (#157): the tripwire only alerted, so a symlink planted in the Syncthing-shared brain dir stayed readable until a human removed it. Watch now unlinks every symlink it finds through an os.Root on the brain dir (re-checking it is still a symlink; the target is never touched), and the default scan interval drops from 5 min to 30 s, so a planted link is readable for at most one interval. Each removal is an alert naming what was removed; a persistent finding (a link it could not remove, an unreadable dir, a scan error) alerts once per distinct state. Alerts queue until Notify succeeds, so an ntfy outage delays them without losing them, even though the next scan of the cleaned brain is clean. The startup check still refuses to start on any symlink. Refs #157 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
198 lines
6.2 KiB
Go
198 lines
6.2 KiB
Go
// Package symlinkguard is the hyperguild#157 tripwire. The brain directory is
|
|
// a Syncthing sendreceive folder and Syncthing syncs symlinks; several brain
|
|
// readers (search, vectorstore, context, graph) follow a symlink out of the
|
|
// brain and would index or return whatever it points to. Until every reader
|
|
// reads through an os.Root, a symlink inside the brain, or a directory the
|
|
// scan cannot read (which could hide one), is an incident: the server refuses
|
|
// to start on one (main), and Watch reports one that appears while it runs.
|
|
package symlinkguard
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io/fs"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// DefaultInterval is used when Watch is given a non-positive interval.
|
|
const DefaultInterval = 30 * time.Second
|
|
|
|
// Notifier is the out-of-band alert sink (audit.Notifier satisfies it).
|
|
type Notifier interface {
|
|
Notify(ctx context.Context, msg string) error
|
|
}
|
|
|
|
// Result is what a scan found: symlinks, and directories it could not read
|
|
// (each of which could hide a symlink). Paths are brain-relative.
|
|
type Result struct {
|
|
Symlinks []string
|
|
Unreadable []string
|
|
}
|
|
|
|
// Clean reports whether the scan found nothing to act on.
|
|
func (r Result) Clean() bool { return len(r.Symlinks) == 0 && len(r.Unreadable) == 0 }
|
|
|
|
// String describes the findings for a log line or alert. Paths are quoted, so
|
|
// a name containing a newline cannot blur two findings together.
|
|
func (r Result) String() string {
|
|
q := r.quoted
|
|
var parts []string
|
|
if len(r.Symlinks) > 0 {
|
|
parts = append(parts, fmt.Sprintf("%d symlink(s): %s", len(r.Symlinks), q(r.Symlinks)))
|
|
}
|
|
if len(r.Unreadable) > 0 {
|
|
parts = append(parts, fmt.Sprintf("%d unreadable dir(s): %s", len(r.Unreadable), q(r.Unreadable)))
|
|
}
|
|
return strings.Join(parts, "; ")
|
|
}
|
|
|
|
// quoted joins paths with each one strconv-quoted.
|
|
func (Result) quoted(xs []string) string {
|
|
out := make([]string, len(xs))
|
|
for i, x := range xs {
|
|
out[i] = strconv.Quote(x)
|
|
}
|
|
return strings.Join(out, ", ")
|
|
}
|
|
|
|
// Scan walks the brain dir and reports every symlink in it and every
|
|
// directory it could not read, continuing past both. Only the ROOT .git is
|
|
// skipped (it is brain-sync's, and no reader walks it); a nested .git is
|
|
// scanned like any other directory. brainDir itself may be a symlink (a
|
|
// volume mount); it is resolved first. The error is non-nil when the root
|
|
// cannot be resolved (wrapping os.ErrNotExist when it is missing) or cannot be
|
|
// listed.
|
|
func Scan(brainDir string) (Result, error) {
|
|
root, err := filepath.EvalSymlinks(brainDir)
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("resolve brain dir: %w", err)
|
|
}
|
|
var r Result
|
|
rootGit := filepath.Join(root, ".git")
|
|
walkErr := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
|
|
rel, _ := filepath.Rel(root, p)
|
|
rel = filepath.ToSlash(rel)
|
|
if err != nil {
|
|
if p == root {
|
|
return err
|
|
}
|
|
r.Unreadable = append(r.Unreadable, rel)
|
|
if d != nil && d.IsDir() {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if d.IsDir() && p == rootGit {
|
|
return filepath.SkipDir
|
|
}
|
|
if d.Type()&os.ModeSymlink != 0 {
|
|
r.Symlinks = append(r.Symlinks, rel)
|
|
}
|
|
return nil
|
|
})
|
|
sort.Strings(r.Symlinks)
|
|
sort.Strings(r.Unreadable)
|
|
if walkErr != nil {
|
|
// Only the root's own error reaches here (deeper errors are recorded
|
|
// as Unreadable). A root that cannot be listed cannot be shown clean
|
|
// (PR #159 round 2: discarding this reported clean while search
|
|
// served a planted symlink).
|
|
return r, fmt.Errorf("scan brain dir: %w", walkErr)
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
// Watch rescans every interval (DefaultInterval if non-positive) until ctx
|
|
// ends. Every symlink found is removed (the link, never its target:
|
|
// hyperguild#157 decision), so one is readable for at most one interval.
|
|
// Each removal is an alert; a persistent finding (a link it could not remove,
|
|
// an unreadable dir, a scan error) alerts once per distinct state. Alerts
|
|
// queue until Notify succeeds, so an outage delays them but loses none, even
|
|
// though the next scan of a cleaned brain is clean.
|
|
func Watch(ctx context.Context, brainDir string, interval time.Duration, n Notifier) {
|
|
if interval <= 0 {
|
|
interval = DefaultInterval
|
|
}
|
|
t := time.NewTicker(interval)
|
|
defer t.Stop()
|
|
var pending []string
|
|
reported := ""
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
}
|
|
r, err := Scan(brainDir)
|
|
key := ""
|
|
if err != nil {
|
|
key = "cannot scan the brain dir: " + err.Error()
|
|
} else {
|
|
removed, failed := removeSymlinks(brainDir, r.Symlinks)
|
|
if len(removed) > 0 {
|
|
msg := fmt.Sprintf("brain symlink tripwire (hyperguild#157): removed %d symlink(s) from the brain dir: %s",
|
|
len(removed), Result{Symlinks: removed}.quoted(removed))
|
|
slog.Error("brain symlink tripwire removed symlinks", "paths", removed)
|
|
pending = append(pending, msg)
|
|
}
|
|
key = Result{Symlinks: failed, Unreadable: r.Unreadable}.String()
|
|
}
|
|
if key != reported {
|
|
reported = key
|
|
if key != "" {
|
|
slog.Error("brain symlink tripwire", "finding", key)
|
|
pending = append(pending, "brain symlink tripwire (hyperguild#157): "+key+
|
|
". Readers may follow symlinks out of the brain; fix them.")
|
|
}
|
|
}
|
|
if n == nil {
|
|
pending = nil
|
|
continue
|
|
}
|
|
for len(pending) > 0 {
|
|
if err := n.Notify(ctx, pending[0]); err != nil {
|
|
slog.Warn("symlink tripwire notify failed; will retry", "err", err)
|
|
break
|
|
}
|
|
pending = pending[1:]
|
|
}
|
|
}
|
|
}
|
|
|
|
// removeSymlinks unlinks each brain-relative symlink through an os.Root on
|
|
// the brain dir, after re-checking it is still a symlink. Removing a symlink
|
|
// never touches its target. It returns what was removed and what was not.
|
|
func removeSymlinks(brainDir string, rels []string) (removed, failed []string) {
|
|
if len(rels) == 0 {
|
|
return nil, nil
|
|
}
|
|
resolved, err := filepath.EvalSymlinks(brainDir)
|
|
if err != nil {
|
|
return nil, rels
|
|
}
|
|
root, err := os.OpenRoot(resolved)
|
|
if err != nil {
|
|
return nil, rels
|
|
}
|
|
defer func() { _ = root.Close() }()
|
|
for _, rel := range rels {
|
|
fi, err := root.Lstat(rel)
|
|
if err != nil || fi.Mode()&os.ModeSymlink == 0 {
|
|
failed = append(failed, rel)
|
|
continue
|
|
}
|
|
if err := root.Remove(rel); err != nil {
|
|
failed = append(failed, rel)
|
|
continue
|
|
}
|
|
removed = append(removed, rel)
|
|
}
|
|
return removed, failed
|
|
}
|