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
+35
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"log/slog"
"net"
"strings"
"time"
"github.com/miekg/dns"
@@ -76,6 +77,40 @@ func (a adminRules) rules(category admin.Category) (*router.RuleSet, error) {
}
}
// TestDomain reports which rule sets match the domain and the route a
// connection to it would take. It mirrors DialSmart's rule priority
// (block > direct > proxy); when no rule matches it reports "auto" without
// performing live detection.
func (a adminRules) TestDomain(domain string) (admin.DomainTest, error) {
domain = strings.ToLower(strings.TrimSpace(strings.TrimSuffix(domain, ".")))
if domain == "" {
return admin.DomainTest{}, fmt.Errorf("domain is required")
}
blockRule, blockOK := a.r.BlockRule.MatchRule(domain)
directRule, directOK := a.r.DirectRule.MatchRule(domain)
proxyRule, proxyOK := a.r.ProxyRule.MatchRule(domain)
res := admin.DomainTest{
Domain: domain,
Matches: []admin.CategoryTest{
{Category: admin.CategoryBlock, Matched: blockOK, Rule: blockRule},
{Category: admin.CategoryDirect, Matched: directOK, Rule: directRule},
{Category: admin.CategoryProxy, Matched: proxyOK, Rule: proxyRule},
},
}
switch {
case blockOK:
res.Route = "block"
case directOK:
res.Route = "direct"
case proxyOK:
res.Route = "proxy"
default:
res.Route = "auto"
res.Note = "未命中任何规则,将按自动检测(本地站点/可直连)或默认代理路由"
}
return res, nil
}
// dnsStatsHandler counts DNS queries before delegating to the router.
type dnsStatsHandler struct {
dns.Handler
+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)
+51
View File
@@ -90,6 +90,57 @@ func (rs *RuleSet) Match(item string) bool {
return rs.tree.Match(item)
}
// MatchRule reports the first retained rule that matches item, mirroring the
// suffix semantics of Match: case-insensitive, trailing dot stripped, "*"
// matching one label and a trailing "**" any number. It is linear in the
// rule count and exists for the admin domain test; Match stays the fast path.
func (rs *RuleSet) MatchRule(item string) (string, bool) {
if rs == nil {
return "", false
}
rs.mu.RLock()
defer rs.mu.RUnlock()
for _, rule := range rs.rules {
if matchRule(rule, item) {
return rule, true
}
}
return "", false
}
// matchRule reports whether one rule pattern matches item. A "**" in the
// last label position matches any remaining labels (including none); in the
// middle it behaves like "*" (one label), matching the suffix-tree builder.
func matchRule(rule, item string) bool {
rule = strings.ToLower(strings.TrimSuffix(rule, "."))
item = strings.ToLower(strings.TrimSuffix(item, "."))
r := strings.Split(rule, ".")
i := strings.Split(item, ".")
ri, ii := len(r)-1, len(i)-1
for ri >= 0 {
switch r[ri] {
case "**":
if ri == 0 {
return true // trailing "**" matches any remaining labels
}
fallthrough // mid-rule "**" behaves like "*"
case "*":
if ii < 0 {
return false
}
ii--
default:
if ii < 0 || r[ri] != i[ii] {
return false
}
ii--
}
ri--
}
return ii < 0
}
// List returns a copy of the retained rules.
func (rs *RuleSet) List() []string {
rs.mu.RLock()
+43
View File
@@ -47,6 +47,49 @@ func TestRuleSetMatch(t *testing.T) {
}
}
func TestRuleSetMatchRule(t *testing.T) {
rs := NewRuleSet("example.com", "**.example.org", "ads.*.com")
cases := []struct {
item string
want string
}{
{"example.com", "example.com"},
{"EXAMPLE.COM.", "example.com"}, // normalized like Match
{"sub.example.com", ""},
{"sub.example.org", "**.example.org"},
{"a.b.example.org", "**.example.org"},
{"example.org", "**.example.org"},
{"ads.cdn.com", "ads.*.com"},
{"ads.a.b.com", ""}, // mid-rule * matches one label only
{"github.com", ""},
}
for _, tc := range cases {
got, ok := rs.MatchRule(tc.item)
if tc.want == "" {
if ok {
t.Fatalf("MatchRule(%q) = %q, want no match", tc.item, got)
}
continue
}
if !ok || got != tc.want {
t.Fatalf("MatchRule(%q) = %q, %v; want %q", tc.item, got, ok, tc.want)
}
}
// MatchRule agrees with Match on every case the tree sees
for _, item := range []string{"example.com", "sub.example.com", "sub.example.org", "anything"} {
if got, ok := rs.MatchRule(item); ok != rs.Match(item) {
t.Fatalf("MatchRule(%q) = %q, %v diverges from Match %v", item, got, ok, rs.Match(item))
}
}
var nilRS *RuleSet
if _, ok := nilRS.MatchRule("x"); ok {
t.Fatal("expected nil RuleSet MatchRule to miss")
}
}
func TestRuleSetRemoveRebuildsTree(t *testing.T) {
rs := NewRuleSet("example.com", "github.com", "**.cn")
+1231
View File
File diff suppressed because one or more lines are too long
-1233
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light" />
<title>Sower Admin</title>
<script type="module" crossorigin src="/assets/index-G9FsEZaz.js"></script>
<script type="module" crossorigin src="/assets/index-BlS6lEQQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C8oAZ1sn.css">
</head>
<body>
+15
View File
@@ -79,6 +79,19 @@ export interface RulesResponse {
limit: number;
}
export interface CategoryTest {
category: Category;
matched: boolean;
rule: string;
}
export interface DomainTest {
domain: string;
route: "block" | "direct" | "proxy" | "auto";
matches: CategoryTest[];
note?: string;
}
export interface RulesQuery {
q?: string;
offset?: number;
@@ -138,6 +151,8 @@ export const api = {
method: "DELETE",
body: JSON.stringify({ category, rules }),
}),
rulesTest: (domain: string) =>
request<DomainTest>(`/api/rules/test?domain=${encodeURIComponent(domain)}`),
traffic: (sort?: DomainSort, source?: Source, client?: string) => {
const params: string[] = [];
if (sort) params.push(`sort=${sort}`);
+100 -9
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { api, ApiError, type Category } from '$lib/api'
import { api, ApiError, type Category, type DomainTest } from '$lib/api'
import { formatCount } from '$lib/format'
import * as Card from '$lib/components/ui/card'
import * as Alert from '$lib/components/ui/alert'
@@ -135,6 +135,66 @@
}
}
// Domain routing test: report which rules match a domain and the route a
// connection to it would take, without live detection.
let testDomain = $state('')
let testResult = $state<DomainTest | null>(null)
let testing = $state(false)
let testError = $state('')
const routeLabels: Record<DomainTest['route'], string> = {
block: '拦截',
direct: '直连',
proxy: '代理',
auto: '自动检测',
}
type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline'
function routeVariant(route: DomainTest['route']): BadgeVariant {
switch (route) {
case 'block':
return 'destructive'
case 'direct':
return 'secondary'
case 'proxy':
return 'default'
default:
return 'outline'
}
}
function matchVariant(category: Category, matched: boolean): BadgeVariant {
if (!matched) return 'outline'
switch (category) {
case 'block':
return 'destructive'
case 'direct':
return 'secondary'
default:
return 'default'
}
}
async function runTest() {
const domain = testDomain.trim()
if (!domain || testing) return
testing = true
testError = ''
testResult = null
try {
testResult = await api.rulesTest(domain)
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
onUnauthorized()
return
}
testError = e instanceof Error ? e.message : 'test failed'
} finally {
testing = false
}
}
// Reset and reload whenever the breadcrumb-driven category changes.
$effect(() => {
category
@@ -167,15 +227,46 @@
<Card.Card class="mb-4">
<Card.CardContent class="grid gap-3 pt-6">
<Textarea
bind:value={draft}
rows={3}
placeholder={'One rule per line, e.g. example.com or **.cn\nRules take effect immediately and reset on restart.'}
/>
<div class="flex gap-2">
<Button onclick={addRules} disabled={busy || !draft.trim()}>Add rules</Button>
<Button variant="outline" onclick={() => load(true)} disabled={busy}>Refresh</Button>
<div class="flex items-center gap-2">
<Input
bind:value={testDomain}
placeholder="检测域名路由,如 example.com"
aria-label="检测域名"
onkeydown={(e) => {
if (e.key === 'Enter') void runTest()
}}
/>
<Button onclick={runTest} disabled={testing || !testDomain.trim()}>
{testing ? '检测中…' : '检测'}
</Button>
</div>
{#if testError}
<p class="text-sm text-destructive">{testError}</p>
{/if}
{#if testResult}
<div class="grid gap-2">
<div class="flex items-center gap-2 text-sm">
<span class="text-muted-foreground">路由</span>
<Badge variant={routeVariant(testResult.route)}>{routeLabels[testResult.route]}</Badge>
<code class="min-w-0 break-all font-mono">{testResult.domain}</code>
</div>
<div class="grid gap-1.5">
{#each testResult.matches as m}
<div class="flex items-center gap-2 text-sm">
<Badge variant={matchVariant(m.category, m.matched)}>
{m.category}{m.matched ? '' : ' 未命中'}
</Badge>
{#if m.matched}
<code class="min-w-0 break-all font-mono text-xs text-muted-foreground">{m.rule}</code>
{/if}
</div>
{/each}
</div>
{#if testResult.note}
<p class="text-xs text-muted-foreground">{testResult.note}</p>
{/if}
</div>
{/if}
</Card.CardContent>
</Card.Card>