mirror of
https://github.com/wweir/sower.git
synced 2026-10-19 16:14:33 +00:00
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:
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user