Files
wweir 0f67081022 feat(admin): rule hit stats, config console, restart, and session persistence
Rule listings gain hit counts, last-seen timestamps, and sort by rule
text, hits, or recency; per-rule and rule-miss stats are tracked in
bounded maps fed by router observers. The config console exposes
whitelisted editable fields (immediate for log level and DNS upstreams,
restart-mode for the rest), persisted as pointer overrides. The restart
endpoint closes listeners and execs the process in place (same PID).
Session persistence is best-effort with configurable options, and admin
state/session config gained disable and cookie-secure switches.
2026-08-09 19:20:26 +08:00

328 lines
9.7 KiB
Go

package admin
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"slices"
"sync"
"testing"
)
func testBaseline() map[Category][]string {
return map[Category][]string{
CategoryBlock: {"ads.example.com", "tracker.example.com"},
CategoryDirect: {"internal.example.com"},
CategoryProxy: {"**.google.com"},
}
}
func stateFilePath(t *testing.T) string {
t.Helper()
return filepath.Join(t.TempDir(), "admin-state.json")
}
func readStateFile(t *testing.T, path string) State {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read state file: %v", err)
}
var s State
if err := json.Unmarshal(data, &s); err != nil {
t.Fatalf("unmarshal state file: %v", err)
}
return s
}
func TestStateStoreRuleAddAndRemoveAlgebra(t *testing.T) {
t.Parallel()
path := stateFilePath(t)
st := LoadStateStore(path)
st.SetBaseline(testBaseline())
// New rule: goes to Add and must enter the runtime set.
add, err := st.RuleAdd(CategoryProxy, "**.example.com")
if err != nil || !slices.Equal(add, []string{"**.example.com"}) {
t.Fatalf("add new rule: runtimeAdd=%v err=%v", add, err)
}
// Duplicate add is a no-op.
add, err = st.RuleAdd(CategoryProxy, "**.example.com")
if err != nil || add != nil {
t.Fatalf("duplicate add: runtimeAdd=%v err=%v", add, err)
}
// Baseline rule without tombstone is a no-op.
add, err = st.RuleAdd(CategoryBlock, "ads.example.com")
if err != nil || add != nil {
t.Fatalf("add existing baseline rule: runtimeAdd=%v err=%v", add, err)
}
// Tombstone a baseline rule.
found, err := st.RuleRemove(CategoryBlock, "ads.example.com")
if err != nil || !found {
t.Fatalf("tombstone baseline rule: found=%v err=%v", found, err)
}
// Re-adding the tombstoned baseline rule clears the tombstone and
// returns it for runtime re-add.
add, err = st.RuleAdd(CategoryBlock, "ads.example.com")
if err != nil || !slices.Equal(add, []string{"ads.example.com"}) {
t.Fatalf("restore tombstoned rule: runtimeAdd=%v err=%v", add, err)
}
// Removing an admin-added rule drops it from Add.
found, err = st.RuleRemove(CategoryProxy, "**.example.com")
if err != nil || !found {
t.Fatalf("remove admin-added rule: found=%v err=%v", found, err)
}
// Unknown rule and double tombstone are not found.
found, err = st.RuleRemove(CategoryProxy, "**.nonexistent.com")
if err != nil || found {
t.Fatalf("remove unknown rule: found=%v err=%v", found, err)
}
changes := st.Changes()
if !changes.Persistent {
t.Fatal("expected persistent store")
}
if d := changes.Rules[CategoryBlock]; len(d.Add) != 0 || len(d.Remove) != 0 {
t.Fatalf("unexpected block delta after restore: %+v", d)
}
if d := changes.Rules[CategoryProxy]; len(d.Add) != 0 || len(d.Remove) != 0 {
t.Fatalf("unexpected proxy delta after remove: %+v", d)
}
// The file on disk reflects the final state.
onDisk := readStateFile(t, path)
if len(onDisk.Rules) != 0 {
t.Fatalf("expected empty deltas on disk, got %+v", onDisk.Rules)
}
if onDisk.Revision == 0 {
t.Fatal("expected revision to bump on mutations")
}
}
func TestStateStoreRestartRestoresDeltas(t *testing.T) {
t.Parallel()
path := stateFilePath(t)
st := LoadStateStore(path)
st.SetBaseline(testBaseline())
if _, err := st.RuleAdd(CategoryProxy, "**.example.com"); err != nil {
t.Fatal(err)
}
if _, err := st.RuleRemove(CategoryBlock, "ads.example.com"); err != nil {
t.Fatal(err)
}
// Simulate a restart: reload from disk, same baseline.
st2 := LoadStateStore(path)
st2.SetBaseline(testBaseline())
if d := st2.Delta(CategoryProxy); !slices.Equal(d.Add, []string{"**.example.com"}) {
t.Fatalf("restored proxy add: %+v", d)
}
if d := st2.Delta(CategoryBlock); !slices.Equal(d.Remove, []string{"ads.example.com"}) {
t.Fatalf("restored block remove: %+v", d)
}
}
func TestStateStoreGCCollectsStaleDeltas(t *testing.T) {
t.Parallel()
path := stateFilePath(t)
st := LoadStateStore(path)
st.SetBaseline(testBaseline())
if _, err := st.RuleAdd(CategoryProxy, "**.example.com"); err != nil {
t.Fatal(err)
}
if _, err := st.RuleRemove(CategoryBlock, "ads.example.com"); err != nil {
t.Fatal(err)
}
// New baseline: the admin-added rule entered the file, the tombstoned
// rule left it. Both deltas are now stale no-ops and must be dropped.
st2 := LoadStateStore(path)
st2.SetBaseline(map[Category][]string{
CategoryBlock: {"tracker.example.com"},
CategoryDirect: {"internal.example.com"},
CategoryProxy: {"**.google.com", "**.example.com"},
})
changes := st2.Changes()
if d := changes.Rules[CategoryProxy]; len(d.Add) != 0 {
t.Fatalf("stale add not collected: %+v", d)
}
if d := changes.Rules[CategoryBlock]; len(d.Remove) != 0 {
t.Fatalf("stale tombstone not collected: %+v", d)
}
onDisk := readStateFile(t, path)
if len(onDisk.Rules) != 0 {
t.Fatalf("GC result not persisted: %+v", onDisk.Rules)
}
}
func TestStateStorePersistFailureLeavesStateUnchanged(t *testing.T) {
t.Parallel()
// A state path under a regular file cannot be created, so every persist
// fails deterministically (even as root).
blocker := filepath.Join(t.TempDir(), "blocker")
if err := os.WriteFile(blocker, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
st := LoadStateStore(filepath.Join(blocker, "admin-state.json"))
st.SetBaseline(testBaseline())
add, err := st.RuleAdd(CategoryProxy, "**.example.com")
if err == nil {
t.Fatal("expected persist error")
}
if add != nil {
t.Fatalf("runtime add must be nil on persist failure, got %v", add)
}
if d := st.Delta(CategoryProxy); len(d.Add) != 0 {
t.Fatalf("state mutated despite persist failure: %+v", d)
}
if err := st.RuleReset(CategoryProxy); err != nil {
t.Fatalf("reset with no delta must be a no-op: %v", err)
}
}
func TestStateStoreCorruptFileIsQuarantined(t *testing.T) {
t.Parallel()
path := stateFilePath(t)
if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil {
t.Fatal(err)
}
st := LoadStateStore(path)
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("corrupt file should be moved aside, stat err=%v", err)
}
matches, err := filepath.Glob(path + ".corrupt-*")
if err != nil || len(matches) != 1 {
t.Fatalf("expected one quarantine file, got %v err=%v", matches, err)
}
// The store works from empty state.
if _, err := st.RuleAdd(CategoryProxy, "**.example.com"); err != nil {
t.Fatal(err)
}
if d := st.Delta(CategoryProxy); !slices.Equal(d.Add, []string{"**.example.com"}) {
t.Fatalf("unexpected delta after quarantine: %+v", d)
}
}
func TestStateStoreApplyConfigRevision(t *testing.T) {
t.Parallel()
path := stateFilePath(t)
st := LoadStateStore(path)
strPtr := func(s string) *string { return &s }
if _, err := st.ApplyConfig(ConfigOverrides{LogLevel: strPtr("debug")}, 1); !errors.Is(err, ErrRevisionMismatch) {
t.Fatalf("stale revision must fail: %v", err)
}
rev, err := st.ApplyConfig(ConfigOverrides{LogLevel: strPtr("debug")}, 0)
if err != nil || rev != 1 {
t.Fatalf("apply config: rev=%d err=%v", rev, err)
}
if got := st.ConfigOverrides(); got.LogLevel == nil || *got.LogLevel != "debug" {
t.Fatalf("unexpected overrides: %+v", got)
}
// Overrides survive a reload.
st2 := LoadStateStore(path)
if got := st2.ConfigOverrides(); got.LogLevel == nil || *got.LogLevel != "debug" {
t.Fatalf("overrides not persisted: %+v", got)
}
if st2.Revision() != 1 {
t.Fatalf("revision not persisted: %d", st2.Revision())
}
}
func TestStateStoreConcurrentMutations(t *testing.T) {
t.Parallel()
path := stateFilePath(t)
st := LoadStateStore(path)
st.SetBaseline(testBaseline())
var wg sync.WaitGroup
for i := range 50 {
wg.Add(2)
go func() {
defer wg.Done()
rule := "**.rule" + string(rune('a'+i%26)) + ".com"
if _, err := st.RuleAdd(CategoryProxy, rule); err != nil {
t.Error(err)
}
}()
go func() {
defer wg.Done()
_ = st.Changes()
_ = st.Revision()
}()
}
wg.Wait()
onDisk := readStateFile(t, path)
if onDisk.Revision != st.Revision() {
t.Fatalf("disk revision %d != memory revision %d", onDisk.Revision, st.Revision())
}
}
func TestStateStoreInMemoryWhenPathEmpty(t *testing.T) {
t.Parallel()
st := LoadStateStore("")
if st.Persistent() {
t.Fatal("empty path must not be persistent")
}
if _, err := st.RuleAdd(CategoryProxy, "**.example.com"); err != nil {
t.Fatal(err)
}
changes := st.Changes()
if changes.Persistent {
t.Fatal("in-memory store must report persistent=false")
}
if d := changes.Rules[CategoryProxy]; !slices.Equal(d.Add, []string{"**.example.com"}) {
t.Fatalf("unexpected delta: %+v", d)
}
}
func TestStateStoreRuleRemoveBatchIsAtomic(t *testing.T) {
t.Parallel()
st := LoadStateStore(stateFilePath(t))
st.SetBaseline(testBaseline())
if _, err := st.RuleAdd(CategoryProxy, "**.custom.example"); err != nil {
t.Fatal(err)
}
before := st.Revision()
removed, err := st.RuleRemoveBatch(CategoryProxy,
"**.google.com", "**.custom.example", "**.google.com", "**.unknown.example")
if err != nil {
t.Fatal(err)
}
if !slices.Equal(removed, []string{"**.google.com", "**.custom.example"}) {
t.Fatalf("unexpected runtime removals: %v", removed)
}
if st.Revision() != before+1 {
t.Fatalf("batch must bump revision once: before=%d after=%d", before, st.Revision())
}
changes := st.Changes().Rules[CategoryProxy]
if len(changes.Add) != 0 || !slices.Equal(changes.Remove, []string{"**.google.com"}) {
t.Fatalf("unexpected persisted batch delta: %+v", changes)
}
// Duplicates and unknown rules are a no-op and do not write again.
before = st.Revision()
removed, err = st.RuleRemoveBatch(CategoryProxy, "**.google.com", "**.unknown.example")
if err != nil || removed != nil {
t.Fatalf("expected no-op batch, removed=%v err=%v", removed, err)
}
if st.Revision() != before {
t.Fatalf("no-op batch changed revision: before=%d after=%d", before, st.Revision())
}
}