mirror of
https://github.com/tnb-labs/panel.git
synced 2026-10-16 07:21:35 +08:00
694 lines
15 KiB
Go
694 lines
15 KiB
Go
//go:build linux
|
|
|
|
package tamper
|
|
|
|
import (
|
|
"fmt"
|
|
"io/fs"
|
|
"log/slog"
|
|
"maps"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/fsnotify/fsnotify"
|
|
"golang.org/x/sys/unix"
|
|
)
|
|
|
|
const rescanInterval = 5 * time.Minute
|
|
|
|
type fileEntry struct {
|
|
path string
|
|
dev uint64
|
|
inode uint64
|
|
isDir bool
|
|
exts []string // 目录条目的规则扩展名,空=整树
|
|
}
|
|
|
|
// sameObject 指向同一个内核对象,保护集合按 (dev, inode) 下发,路径相同但 inode 变了就是另一个对象
|
|
func (e fileEntry) sameObject(other fileEntry) bool {
|
|
return e.dev == other.dev && e.inode == other.inode
|
|
}
|
|
|
|
type fileKey struct {
|
|
dev uint64
|
|
inode uint64
|
|
}
|
|
|
|
type engine interface {
|
|
apply(entries []fileEntry) error
|
|
remove(entries []fileEntry) error
|
|
start() error
|
|
events() <-chan Event
|
|
close() error
|
|
}
|
|
|
|
type Manager struct {
|
|
cfg Config
|
|
log *slog.Logger
|
|
eng engine
|
|
isEBPF bool
|
|
|
|
watcher *fsnotify.Watcher
|
|
|
|
mu sync.RWMutex
|
|
entries map[string]fileEntry // key=path
|
|
inodePath map[fileKey]string // eBPF 事件回填路径
|
|
nFiles int
|
|
nWatchDirs int
|
|
running bool
|
|
|
|
out chan Event
|
|
closed chan struct{}
|
|
once sync.Once
|
|
}
|
|
|
|
func NewManager(cfg Config, log *slog.Logger) (*Manager, error) {
|
|
m := &Manager{
|
|
cfg: cfg,
|
|
log: log,
|
|
entries: make(map[string]fileEntry),
|
|
inodePath: make(map[fileKey]string),
|
|
out: make(chan Event, 256),
|
|
closed: make(chan struct{}),
|
|
}
|
|
|
|
switch cfg.Mode {
|
|
case ModeEBPF:
|
|
st := DetectEBPF()
|
|
if !st.Available {
|
|
return nil, fmt.Errorf("eBPF mode unavailable: %s", st.Reason)
|
|
}
|
|
var exts []string
|
|
for _, r := range cfg.Rules {
|
|
exts = append(exts, r.Exts...)
|
|
}
|
|
eng, err := newEBPFEngine(log, cfg.BlockNewFiles, exts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
m.eng = eng
|
|
m.isEBPF = true
|
|
case ModeChattr:
|
|
m.eng = newChattrEngine()
|
|
default:
|
|
return nil, fmt.Errorf("unknown tamper mode: %s", cfg.Mode)
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
type mountDev struct {
|
|
point string
|
|
prefix string // 带尾斜杠的挂载点,逐路径比较时不必反复拼
|
|
dev uint64
|
|
}
|
|
|
|
var mountUnescape = strings.NewReplacer(`\040`, " ", `\011`, "\t", `\012`, "\n", `\134`, `\`)
|
|
|
|
// 短 TTL 缓存,handleCreate 突发时避免每 event 都读整个 mountinfo
|
|
var (
|
|
mountsMu sync.Mutex
|
|
mounts []mountDev
|
|
mountsAt time.Time
|
|
)
|
|
|
|
const mountsTTL = time.Second
|
|
|
|
func cachedMountDevs() []mountDev {
|
|
mountsMu.Lock()
|
|
if time.Since(mountsAt) > mountsTTL {
|
|
mounts = loadMountDevs()
|
|
mountsAt = time.Now()
|
|
}
|
|
m := mounts
|
|
mountsMu.Unlock()
|
|
return m
|
|
}
|
|
|
|
func refreshMountDevs() {
|
|
mountsMu.Lock()
|
|
mounts = loadMountDevs()
|
|
mountsAt = time.Now()
|
|
mountsMu.Unlock()
|
|
}
|
|
|
|
// loadMountDevs 从 mountinfo 第三列取内核 s_dev
|
|
// btrfs 等文件系统的 st_dev 是每子卷匿名设备,与 eBPF 侧 i_sb->s_dev 不一致会静默漏保护
|
|
func loadMountDevs() []mountDev {
|
|
data, err := os.ReadFile("/proc/self/mountinfo")
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var mounts []mountDev
|
|
for line := range strings.Lines(string(data)) {
|
|
f := strings.Fields(line)
|
|
if len(f) < 5 {
|
|
continue
|
|
}
|
|
majS, minS, ok := strings.Cut(f[2], ":")
|
|
if !ok {
|
|
continue
|
|
}
|
|
maj, err1 := strconv.ParseUint(majS, 10, 32)
|
|
minNum, err2 := strconv.ParseUint(minS, 10, 32)
|
|
if err1 != nil || err2 != nil {
|
|
continue
|
|
}
|
|
point := mountUnescape.Replace(f[4])
|
|
mounts = append(mounts, mountDev{point: point, prefix: strings.TrimRight(point, "/") + "/", dev: maj<<20 | minNum&0xfffff})
|
|
}
|
|
// 按挂载点由深到浅,查找时首个命中即最深的那个
|
|
slices.SortFunc(mounts, func(a, b mountDev) int { return len(b.point) - len(a.point) })
|
|
|
|
return mounts
|
|
}
|
|
|
|
// 同点位后挂载遮蔽先挂载,故最长前缀并列取靠后者
|
|
func devOfPath(mounts []mountDev, path string, st *syscall.Stat_t) uint64 {
|
|
for _, m := range mounts {
|
|
if path == m.point || strings.HasPrefix(path, m.prefix) {
|
|
return m.dev
|
|
}
|
|
}
|
|
return uint64(unix.Major(st.Dev))<<20 | uint64(unix.Minor(st.Dev))&0xfffff
|
|
}
|
|
|
|
func statOf(mounts []mountDev, path string) (dev, ino uint64) {
|
|
var st syscall.Stat_t
|
|
if err := syscall.Lstat(path, &st); err != nil {
|
|
return 0, 0
|
|
}
|
|
return devOfPath(mounts, path, &st), st.Ino
|
|
}
|
|
|
|
// 目录恒产出携带规则扩展名,文件按 rule.Exts 过滤(空=全部)
|
|
func walkRule(mounts []mountDev, root string, rule *Rule, emit func(fileEntry)) {
|
|
_ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return nil //nolint:nilerr
|
|
}
|
|
if isExcluded(path, rule.Excludes) {
|
|
if d.IsDir() {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if d.IsDir() {
|
|
dev, ino := statOf(mounts, path)
|
|
emit(fileEntry{path: path, dev: dev, inode: ino, isDir: true, exts: rule.Exts})
|
|
return nil
|
|
}
|
|
if MatchExt(path, rule.Exts) {
|
|
dev, ino := statOf(mounts, path)
|
|
emit(fileEntry{path: path, dev: dev, inode: ino, isDir: false})
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// 多规则覆盖同一目录时合并扩展名:整树优先,否则并集
|
|
func (m *Manager) scan() []fileEntry {
|
|
mounts := cachedMountDevs()
|
|
var entries []fileEntry
|
|
seen := make(map[string]bool)
|
|
dirIdx := make(map[string]int)
|
|
|
|
for _, rule := range m.cfg.Rules {
|
|
for _, root := range rule.Paths {
|
|
walkRule(mounts, root, &rule, func(e fileEntry) {
|
|
if !e.isDir {
|
|
if seen[e.path] {
|
|
return
|
|
}
|
|
seen[e.path] = true
|
|
entries = append(entries, e)
|
|
return
|
|
}
|
|
i, ok := dirIdx[e.path]
|
|
if !ok {
|
|
dirIdx[e.path] = len(entries)
|
|
entries = append(entries, e)
|
|
return
|
|
}
|
|
if len(entries[i].exts) == 0 || len(e.exts) == 0 {
|
|
entries[i].exts = nil
|
|
return
|
|
}
|
|
merged := slices.Clone(entries[i].exts) // 避免 append 污染 rule.Exts
|
|
|
|
for _, x := range e.exts {
|
|
if !slices.Contains(merged, x) {
|
|
merged = append(merged, x)
|
|
}
|
|
}
|
|
entries[i].exts = merged
|
|
})
|
|
}
|
|
}
|
|
|
|
return entries
|
|
}
|
|
|
|
func (m *Manager) Start() error {
|
|
refreshMountDevs() // 显式登记入口强制取最新 mountinfo
|
|
entries := m.scan()
|
|
|
|
m.mu.Lock()
|
|
clear(m.entries)
|
|
for _, e := range entries {
|
|
m.entries[e.path] = e
|
|
m.inodePath[fileKey{e.dev, e.inode}] = e.path
|
|
}
|
|
m.recount()
|
|
m.running = true
|
|
m.mu.Unlock()
|
|
|
|
if err := m.eng.apply(entries); err != nil {
|
|
return fmt.Errorf("failed to apply protection: %w", err)
|
|
}
|
|
if err := m.eng.start(); err != nil {
|
|
return fmt.Errorf("failed to start protection: %w", err)
|
|
}
|
|
|
|
if ch := m.eng.events(); ch != nil {
|
|
go m.forwardEvents(ch)
|
|
}
|
|
|
|
// chattr 靠 fsnotify 补新建监控;eBPF 由创建类 LSM hook 全内核处理
|
|
if !m.isEBPF {
|
|
if err := m.startWatcher(); err != nil {
|
|
m.log.Warn("failed to start tamper file watcher, new file interception unavailable", slog.Any("err", err))
|
|
}
|
|
}
|
|
|
|
go m.rescanLoop()
|
|
|
|
st := m.Stats()
|
|
m.log.Info("tamper protection enabled",
|
|
slog.String("mode", string(m.cfg.Mode)),
|
|
slog.Int("files", st.ProtectedFiles),
|
|
slog.Int("dirs", st.ProtectedDirs))
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) rescanLoop() {
|
|
ticker := time.NewTicker(rescanInterval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-m.closed:
|
|
return
|
|
case <-ticker.C:
|
|
m.rescan()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *Manager) rescan() {
|
|
m.mu.RLock()
|
|
running := m.running
|
|
m.mu.RUnlock()
|
|
if !running {
|
|
return
|
|
}
|
|
|
|
refreshMountDevs()
|
|
entries := m.scan()
|
|
current := make(map[string]fileEntry, len(entries))
|
|
for _, e := range entries {
|
|
current[e.path] = e
|
|
}
|
|
|
|
m.mu.RLock()
|
|
var added, removed []fileEntry
|
|
for path, e := range current {
|
|
if old, ok := m.entries[path]; !ok || !old.sameObject(e) {
|
|
added = append(added, e)
|
|
}
|
|
}
|
|
for path, e := range m.entries {
|
|
if c, ok := current[path]; !ok || !c.sameObject(e) {
|
|
removed = append(removed, e)
|
|
}
|
|
}
|
|
m.mu.RUnlock()
|
|
|
|
if len(added) == 0 && len(removed) == 0 {
|
|
return
|
|
}
|
|
|
|
// 先摘后加:同一路径换了 inode 时,旧 inode 必须先离开集合
|
|
if len(removed) > 0 {
|
|
if err := m.eng.remove(removed); err != nil {
|
|
m.log.Warn("failed to drop stale tamper entries", slog.Any("err", err))
|
|
}
|
|
m.forget(removed)
|
|
}
|
|
if len(added) > 0 {
|
|
if err := m.eng.apply(added); err != nil {
|
|
m.log.Warn("failed to protect new tamper entries", slog.Any("err", err))
|
|
}
|
|
m.remember(added)
|
|
}
|
|
|
|
st := m.Stats()
|
|
m.log.Debug("tamper protection rescanned",
|
|
slog.Int("added", len(added)), slog.Int("removed", len(removed)),
|
|
slog.Int("files", st.ProtectedFiles), slog.Int("dirs", st.ProtectedDirs))
|
|
}
|
|
|
|
func (m *Manager) startWatcher() error {
|
|
w, err := fsnotify.NewWatcher()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m.watcher = w
|
|
|
|
dirs := make(map[string]bool)
|
|
for _, rule := range m.cfg.Rules {
|
|
for _, root := range rule.Paths {
|
|
_ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil || !d.IsDir() {
|
|
return nil //nolint:nilerr
|
|
}
|
|
if isExcluded(path, rule.Excludes) {
|
|
return filepath.SkipDir
|
|
}
|
|
dirs[path] = true
|
|
return nil
|
|
})
|
|
}
|
|
}
|
|
failed := 0
|
|
for dir := range dirs {
|
|
if err := w.Add(dir); err != nil {
|
|
failed++
|
|
}
|
|
}
|
|
if failed > 0 {
|
|
// 常见原因 fs.inotify.max_user_watches 耗尽
|
|
m.log.Warn("failed to watch some tamper directories, new files may be missed",
|
|
slog.Int("failed", failed), slog.Int("total", len(dirs)))
|
|
}
|
|
|
|
m.mu.Lock()
|
|
m.nWatchDirs = len(dirs) - failed
|
|
m.mu.Unlock()
|
|
|
|
go m.watchLoop()
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) watchLoop() {
|
|
for {
|
|
select {
|
|
case <-m.closed:
|
|
return
|
|
case ev, ok := <-m.watcher.Events:
|
|
if !ok {
|
|
return
|
|
}
|
|
if ev.Op&fsnotify.Create != 0 {
|
|
m.onCreate(ev.Name)
|
|
}
|
|
case err, ok := <-m.watcher.Errors:
|
|
if !ok {
|
|
return
|
|
}
|
|
m.log.Warn("tamper watcher error", slog.Any("err", err))
|
|
}
|
|
}
|
|
}
|
|
|
|
// 新目录补扫竞态窗口内已落入的条目
|
|
func (m *Manager) onCreate(path string) {
|
|
info, err := os.Lstat(path)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
if info.IsDir() {
|
|
if m.watcher != nil {
|
|
if err := m.watcher.Add(path); err == nil {
|
|
m.mu.Lock()
|
|
m.nWatchDirs++
|
|
m.mu.Unlock()
|
|
}
|
|
}
|
|
if items, err := os.ReadDir(path); err == nil {
|
|
for _, it := range items {
|
|
m.onCreate(filepath.Join(path, it.Name()))
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
rule := m.ruleOf(path)
|
|
if rule == nil || isExcluded(path, rule.Excludes) || !MatchExt(path, rule.Exts) {
|
|
return
|
|
}
|
|
|
|
dev, ino := statOf(cachedMountDevs(), path)
|
|
ev := Event{
|
|
Path: path,
|
|
Dev: dev,
|
|
Inode: ino,
|
|
Op: OpCreate,
|
|
OpStr: OpCreate.String(),
|
|
Time: time.Now(),
|
|
}
|
|
|
|
if m.cfg.BlockNewFiles {
|
|
// chattr 整树模式下父目录带 +a,删除新文件需临时解除
|
|
if err := os.Remove(path); err != nil && len(rule.Exts) == 0 {
|
|
if _, ok := m.eng.(*chattrEngine); ok {
|
|
parent := []fileEntry{{path: filepath.Dir(path), isDir: true}}
|
|
_ = m.eng.remove(parent)
|
|
_ = os.Remove(path)
|
|
_ = m.eng.apply(parent)
|
|
}
|
|
}
|
|
} else {
|
|
m.remember([]fileEntry{{path: path, dev: dev, inode: ino}})
|
|
if e, ok := m.eng.(*chattrEngine); ok {
|
|
e.lockOne(path, false)
|
|
}
|
|
}
|
|
|
|
m.emit(ev)
|
|
}
|
|
|
|
func (m *Manager) ruleOf(path string) *Rule {
|
|
for i := range m.cfg.Rules {
|
|
for _, root := range m.cfg.Rules[i].Paths {
|
|
if UnderRoot(path, root) {
|
|
return &m.cfg.Rules[i]
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 内核已拒绝的直接上报,放行的(观察/软命中)交 handleCreate 异步纳保
|
|
func (m *Manager) forwardEvents(ch <-chan Event) {
|
|
for {
|
|
select {
|
|
case <-m.closed:
|
|
return
|
|
case ev, ok := <-ch:
|
|
if !ok {
|
|
return
|
|
}
|
|
m.mu.RLock()
|
|
ev.Path = m.inodePath[fileKey{ev.Dev, ev.Inode}]
|
|
m.mu.RUnlock()
|
|
ev.Time = time.Now()
|
|
if ev.Op == OpCreate {
|
|
if ev.Path == "" || ev.Name == "" {
|
|
continue
|
|
}
|
|
ev.Path = filepath.Join(ev.Path, ev.Name)
|
|
if ev.Denied {
|
|
m.emit(ev)
|
|
} else {
|
|
go m.handleCreate(ev)
|
|
}
|
|
continue
|
|
}
|
|
m.emit(ev)
|
|
}
|
|
}
|
|
}
|
|
|
|
// hook 早于实际落盘,stat 需重试;新目录整树补扫
|
|
func (m *Manager) handleCreate(ev Event) {
|
|
var entries []fileEntry
|
|
for range 5 {
|
|
if entries = statEntries([]string{ev.Path}); len(entries) > 0 {
|
|
break
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
if len(entries) == 0 {
|
|
return
|
|
}
|
|
|
|
// 内核不感知排除规则,被排除路径的新建不纳保不上报
|
|
rule := m.ruleOf(ev.Path)
|
|
if rule == nil || isExcluded(ev.Path, rule.Excludes) {
|
|
return
|
|
}
|
|
if entries[0].isDir {
|
|
entries = entries[:0]
|
|
walkRule(cachedMountDevs(), ev.Path, rule, func(e fileEntry) { entries = append(entries, e) })
|
|
} else if !MatchExt(ev.Path, rule.Exts) {
|
|
return
|
|
}
|
|
m.remember(entries)
|
|
_ = m.eng.apply(entries)
|
|
m.emit(ev)
|
|
}
|
|
|
|
func (m *Manager) emit(ev Event) {
|
|
select {
|
|
case m.out <- ev:
|
|
case <-m.closed:
|
|
default:
|
|
}
|
|
}
|
|
|
|
func (m *Manager) Events() <-chan Event { return m.out }
|
|
|
|
func (m *Manager) Stats() Stats {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
return Stats{
|
|
Mode: m.cfg.Mode,
|
|
Running: m.running,
|
|
ProtectedFiles: m.nFiles,
|
|
ProtectedDirs: m.nWatchDirs,
|
|
}
|
|
}
|
|
|
|
// 现场 stat,移动/替换后 inode 可能变化
|
|
func statEntries(paths []string) []fileEntry {
|
|
mounts := cachedMountDevs()
|
|
entries := make([]fileEntry, 0, len(paths))
|
|
for _, p := range paths {
|
|
info, err := os.Lstat(p)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
dev, ino := statOf(mounts, p)
|
|
entries = append(entries, fileEntry{path: p, dev: dev, inode: ino, isDir: info.IsDir()})
|
|
}
|
|
return entries
|
|
}
|
|
|
|
func (m *Manager) remember(entries []fileEntry) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
for _, e := range entries {
|
|
m.inodePath[fileKey{e.dev, e.inode}] = e.path
|
|
m.entries[e.path] = e
|
|
}
|
|
m.recount()
|
|
}
|
|
|
|
func (m *Manager) forget(entries []fileEntry) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
for _, e := range entries {
|
|
delete(m.inodePath, fileKey{e.dev, e.inode})
|
|
delete(m.entries, e.path)
|
|
}
|
|
m.recount()
|
|
}
|
|
|
|
// 调用方持锁;chattr 由 fsnotify 按监控口径另计
|
|
func (m *Manager) recount() {
|
|
m.nFiles = 0
|
|
dirs := 0
|
|
for _, e := range m.entries {
|
|
if e.isDir {
|
|
dirs++
|
|
} else {
|
|
m.nFiles++
|
|
}
|
|
}
|
|
if m.isEBPF {
|
|
m.nWatchDirs = dirs
|
|
}
|
|
}
|
|
|
|
func (m *Manager) Unlock(paths []string) {
|
|
entries := statEntries(paths)
|
|
m.forget(entries)
|
|
_ = m.eng.remove(entries)
|
|
}
|
|
|
|
// Relock 移动/替换后需重新登记新路径与新 inode
|
|
func (m *Manager) Relock(paths []string) {
|
|
refreshMountDevs()
|
|
entries := statEntries(paths)
|
|
for i := range entries {
|
|
if entries[i].isDir {
|
|
if r := m.ruleOf(entries[i].path); r != nil {
|
|
entries[i].exts = r.Exts
|
|
}
|
|
}
|
|
}
|
|
m.dropReplaced(entries)
|
|
m.remember(entries)
|
|
_ = m.eng.apply(entries)
|
|
}
|
|
|
|
// dropReplaced 摘掉这些路径上已经换掉的旧对象
|
|
func (m *Manager) dropReplaced(entries []fileEntry) {
|
|
m.mu.RLock()
|
|
var stale []fileEntry
|
|
for _, e := range entries {
|
|
if old, ok := m.entries[e.path]; ok && !old.sameObject(e) {
|
|
stale = append(stale, old)
|
|
}
|
|
}
|
|
m.mu.RUnlock()
|
|
|
|
if len(stale) == 0 {
|
|
return
|
|
}
|
|
if err := m.eng.remove(stale); err != nil {
|
|
m.log.Warn("failed to drop replaced tamper entries", slog.Any("err", err))
|
|
return
|
|
}
|
|
m.forget(stale)
|
|
}
|
|
|
|
func (m *Manager) Stop() error {
|
|
m.once.Do(func() { close(m.closed) })
|
|
|
|
if m.watcher != nil {
|
|
_ = m.watcher.Close()
|
|
}
|
|
|
|
// eBPF 保护随引擎关闭销毁
|
|
if !m.isEBPF {
|
|
m.mu.RLock()
|
|
entries := slices.Collect(maps.Values(m.entries))
|
|
m.mu.RUnlock()
|
|
_ = m.eng.remove(entries)
|
|
}
|
|
|
|
m.mu.Lock()
|
|
m.running = false
|
|
m.mu.Unlock()
|
|
|
|
return m.eng.close()
|
|
}
|