feat(admin): test a domain's rule matches and route decision

The rules page can now answer 'what happens to this domain?': RuleSet
gains MatchRule, a linear scan that returns the first matching rule under
the same suffix semantics as the suffix tree, and GET /api/rules/test
(auth) reports per-category hits plus the route a connection would take
(block > direct > proxy priority; 'auto' with a note when no rule
matches, without performing live detection).

- Rules page gets a domain test input with route badge, per-category
  match rows, and the fallback note
- tests: MatchRule agrees with Match, fake + endpoint coverage
This commit is contained in:
wweir
2026-08-06 22:42:31 +08:00
parent 9b74f80cfe
commit 6023537319
10 changed files with 1592 additions and 1243 deletions
+36
View File
@@ -70,6 +70,7 @@ type RuleManager interface {
RuleAdd(category Category, rules ...string) error
RuleRemove(category Category, rule string) (bool, error)
RuleCount(category Category) uint64
TestDomain(domain string) (DomainTest, error)
}
// Options configures the admin server.
@@ -106,6 +107,7 @@ func NewServer(opts Options) *Server {
mux.HandleFunc("GET /api/rules", s.mutateGuard(s.auth(s.handleRulesList)))
mux.HandleFunc("POST /api/rules", s.mutateGuard(s.auth(s.handleRulesAdd)))
mux.HandleFunc("DELETE /api/rules", s.mutateGuard(s.auth(s.handleRulesRemove)))
mux.HandleFunc("GET /api/rules/test", s.mutateGuard(s.auth(s.handleRulesTest)))
mux.HandleFunc("GET /api/traffic", s.mutateGuard(s.auth(s.handleTraffic)))
mux.HandleFunc("GET /api/totals", s.mutateGuard(s.auth(s.handleTotals)))
mux.HandleFunc("GET /api/history", s.mutateGuard(s.auth(s.handleHistory)))
@@ -369,6 +371,40 @@ type rulesRequest struct {
Rules []string `json:"rules"`
}
// CategoryTest reports whether one rule category matched a tested domain and
// which retained rule did so.
type CategoryTest struct {
Category Category `json:"category"`
Matched bool `json:"matched"`
Rule string `json:"rule"`
}
// DomainTest is the result of testing a domain against the rule sets. Route
// is block, direct, or proxy when a rule decides it, or auto when no rule
// matched and the connection would fall through to detection / proxy.
type DomainTest struct {
Domain string `json:"domain"`
Route string `json:"route"`
Matches []CategoryTest `json:"matches"`
Note string `json:"note,omitempty"`
}
// handleRulesTest reports which rules match the queried domain and the
// resulting route decision, without performing any live detection.
func (s *Server) handleRulesTest(w http.ResponseWriter, r *http.Request) {
domain := strings.TrimSpace(r.URL.Query().Get("domain"))
if domain == "" {
writeError(w, http.StatusBadRequest, "domain is required")
return
}
res, err := s.opts.Rules.TestDomain(domain)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, res)
}
func (s *Server) handleRulesList(w http.ResponseWriter, r *http.Request) {
category := Category(r.URL.Query().Get("category"))
if !category.valid() {
+80
View File
@@ -70,6 +70,41 @@ func (f *fakeRules) RuleCount(c Category) uint64 {
return uint64(len(f.lists[c]))
}
func (f *fakeRules) TestDomain(domain string) (DomainTest, error) {
domain = strings.ToLower(strings.TrimSpace(strings.TrimSuffix(domain, ".")))
if domain == "" {
return DomainTest{}, fmt.Errorf("domain is required")
}
res := DomainTest{Domain: domain}
for _, c := range []Category{CategoryBlock, CategoryDirect, CategoryProxy} {
rule, ok := fakeRuleMatch(f.lists[c], domain)
res.Matches = append(res.Matches, CategoryTest{Category: c, Matched: ok, Rule: rule})
if ok && res.Route == "" {
res.Route = string(c)
}
}
if res.Route == "" {
res.Route = "auto"
res.Note = "no rule matched"
}
return res, nil
}
// fakeRuleMatch is the fake's linear matcher: exact match, "*", or "**." prefix.
func fakeRuleMatch(rules []string, item string) (string, bool) {
for _, rule := range rules {
switch {
case rule == "*":
return rule, true
case strings.HasPrefix(rule, "**.") && strings.HasSuffix(item, strings.TrimPrefix(rule, "**")):
return rule, true
case rule == item:
return rule, true
}
}
return "", false
}
func newTestServer(t *testing.T, rules RuleManager) *httptest.Server {
t.Helper()
s := NewServer(Options{Password: "secret", Version: "v1.2.3", Date: "2026-01-01", Rules: rules, Stats: newTestStats(t)})
@@ -425,6 +460,51 @@ func TestTotalsEndpoint(t *testing.T) {
}
}
func TestRulesTestEndpoint(t *testing.T) {
s := NewServer(Options{Password: "secret", Rules: newFakeRules()})
if err := s.opts.Rules.RuleAdd(CategoryBlock, "ads.example.com"); err != nil {
t.Fatal(err)
}
if err := s.opts.Rules.RuleAdd(CategoryProxy, "**.example.org"); err != nil {
t.Fatal(err)
}
ts := httptest.NewServer(s.http.Handler)
t.Cleanup(ts.Close)
cookie := login(t, ts, "secret")
resp := authedRequest(t, ts, http.MethodGet, "/api/rules/test?domain=ads.example.com", cookie, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
body := decodeBody(t, resp)
if body["domain"] != "ads.example.com" || body["route"] != "block" {
t.Fatalf("unexpected test result: %v", body)
}
matches := body["matches"].([]any)
first := matches[0].(map[string]any)
if first["category"] != "block" || first["matched"] != true || first["rule"] != "ads.example.com" {
t.Fatalf("unexpected block match: %v", first)
}
resp = authedRequest(t, ts, http.MethodGet, "/api/rules/test?domain=sub.example.org", cookie, "")
body = decodeBody(t, resp)
if body["route"] != "proxy" {
t.Fatalf("expected proxy route for wildcard match, got %v", body)
}
// no rules matched: auto with a note, block takes priority over proxy
resp = authedRequest(t, ts, http.MethodGet, "/api/rules/test?domain=unknown.test", cookie, "")
body = decodeBody(t, resp)
if body["route"] != "auto" {
t.Fatalf("expected auto route, got %v", body)
}
resp = authedRequest(t, ts, http.MethodGet, "/api/rules/test", cookie, "")
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 without domain, got %d", resp.StatusCode)
}
}
func TestTrafficEndpointRejectsMissingStats(t *testing.T) {
s := NewServer(Options{Password: "secret", Rules: newFakeRules()})
ts := httptest.NewServer(s.http.Handler)