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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
Vendored
+1231
File diff suppressed because one or more lines are too long
Vendored
-1233
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user