Compare commits

..
Author SHA1 Message Date
omarandClaude Opus 5 244b7c4199 feat(panel): a rule's destination is a ruleset picker, nothing else
release / aarch64_cortex-a53 (push) Successful in 3m21s
release / x86_64 (push) Successful in 3m19s
release / apk aarch64_cortex-a53 (push) Successful in 2m38s
release / apk x86_64 (push) Successful in 2m35s
release / release (push) Successful in 9s
release / release apk (push) Successful in 6s
Follows the schema-v2 model change: `Rule.DstDomain` and `Rule.DstIP` are
gone from api.ts, so the Routing page loses the two controls that wrote them.

The add form's Match picker (rulesets / ip / port) collapses to a plain
Port(s) field beside the ruleset checkboxes — with no inline address list
there was nothing left to choose between. The edit form drops its "Domain(s)
— legacy" and "IP / CIDR(s)" fields; it now shows exactly what the add form
shows, which is the honest shape of a rule that carries one destination
mechanism.

The destination picker renders even when the config has no rulesets yet, and
says where to get one. Hiding it (the old behaviour when the list was empty)
would leave the rule form with no destination control at all, at precisely
the moment the user needs to know one exists. It is checkboxes and nothing
more: creating and filling a list stays in the Rulesets panel, so a list is
authored in one place and its naming and entry rules cannot drift between two
editors.

isCatchAll() drops the same two fields as model.IsCatchAll, so the "never
applies" badge and the daemon's apply warning keep agreeing about which rule
is the default; the matcher chips lose their `dns` and `ip` rows for the same
reason. The mock backend's reachability shim follows.

Rendered against `?mock` in both themes; `.rt-field-wide`, the only rule the
removed wide inputs used, is deleted rather than left dangling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 13:57:16 +03:00
omarandClaude Opus 5 a8ef887c56 feat(routing)!: a rule's destination is a rule-set, and nothing else
`config rule` carried THREE ways to say where traffic is going: `dst_domain`
(an inline domain list), `dst_ip` (an inline CIDR list) and `dst_ruleset` (a
reference to a `config ruleset`). Three mechanisms meant three sets of
semantics to keep straight, and the inline pair was the worse half of the
trade: re-parsed per rule instead of compiled once into a .srs, unshareable
between rules, and — invisibly — already disagreeing with the rule-set
vocabulary about what a bare entry means.

`dst_domain` and `dst_ip` are removed (schema v2). `dst_ruleset` is the only
destination matcher. `Src` (the client side), `dst_port` and `proto` are
untouched: they are not lists of destinations and have no rule-set form.

THE BARE-ENTRY TRAP, and why the migration is not a copy

A bare `example.com` was an EXACT host in a routing rule (classified with
bareIsSuffix=false) and is the host AND its subdomains inside a rule-set
(bareIsSuffix=true). Copying entries across verbatim would silently widen
every such rule to every subdomain, so migrate1to2 rewrites a bare entry as
`full:example.com`. Everything else already means the same on both sides and
is copied byte-for-byte: `full:`, `suffix:`, `keyword:`, `regexp:` and a
leading dot (a synonym of `suffix:`).

`geosite:`/`geoip:` entries are copied UNCHANGED rather than promoted to a
`source=geosite` rule-set. They have been inert since the engine dropped the
route-rule geosite/geoip fields, and an unrecognised marker is equally inert
inside a rule-set — so their meaning is preserved exactly, and a dead matcher
does not start routing traffic because someone upgraded. The text is kept so
the operator can see it and convert it deliberately.

`regexp:` had no rule-set form at all, which would have made the move lossy,
so inline rule-sets learn it: peelDomainRegexes validates each pattern with
regexp.Compile before it reaches DomainRegex, because
route/rule.NewDomainRegexItem errors on an uncompilable one and that aborts
box.New for the whole config. A bare `regexp:` is dropped too — it compiles
fine and matches every host.

THE MIGRATION (schema v1 -> v2, run by `shaterd migrate` on service start and
at package install)

Per rule still carrying a legacy list: create an inline `config ruleset`
named `rule-<rule name>` (domains) and/or `rule-<rule name>-ip` (addresses),
move the entries across with the conversion above, append the new name to
`dst_ruleset`, delete the old option LAST. It is idempotent; it resumes an
interrupted run by reusing a rule-set the rule already references; and it
never overwrites a hand-written list that owns the generated name (it takes
`rule-<name>-2`). The uci sequence — `uci add` capturing the section id, then
set/add_list/delete — was verified against BananaWRT 25.12.1 in a throwaway
package.

Verified against the live router's config (4 rules, 26 entries, all
`suffix:`): every entry lands in its rule-set, every rule gains exactly one
reference, the `default` rule stays condition-less so B1's RuleReachability
still reads it as the catch-all.

ONE DELIBERATE SEMANTIC CHANGE, stated out loud: a rule that used BOTH lists
matched them with AND (an engine route rule ANDs its matcher fields), which
is almost never what "these sites and these networks" meant. The two
generated rule-sets are ORed, because `rule_set: [a, b]` matches when either
matches. Only configs that used both fields at once are affected.

Also fixed here, because schema v2 routes EVERY destination list through
inlineRulesetRule and the gap widens accordingly: a marker-only entry (".",
"full:", "keyword:") was dropped by the shared classifier SILENTLY on that
path, where the routing rule used to warn. An empty domain token aborts
box.New and an empty keyword is strings.Contains(host, "") — every host — so
the drop is right and the silence was not.

untunnelable stays honest: buildUntunnelablePlan already resolves `rule_set`
addresses through the running engine (inline sets are LocalRuleSets and
implement ExtractIPSet), and apply runs eng.Apply before building the plan.
A migrated `dst_ip` therefore resolves exactly as before; with the engine
down the walk truncates and denies, which is the conservative direction and
the state in which the netplane is fail-closed anyway.

Tests: migration coverage (real-router fixture, mixed prefixes, CIDRs,
idempotence, interrupted-run resume, name collision, geo markers stay inert,
absent config), and every matcher-classification test that used to live on
`dst_domain`/`dst_ip` moved to the inline rule-set rather than deleted —
including the new `regexp:` path and the inverted bare-entry convention. The
model tests grow a real in-memory uci emulator so a second migration run
actually sees its own writes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 13:57:16 +03:00
31 changed files with 1701 additions and 546 deletions
+65
View File
@@ -424,3 +424,68 @@ on the next render.
Consequence: all delay numbers are comparable (least ping ranks apples against
apples), and group settings lose two footgun fields while Settings keeps the
two that actually govern every check.
## D21 — A rule's destination is a rule-set, and nothing else
Decided 2026-07-25 (product owner). `config rule` carried THREE ways to say
where traffic is going: `dst_domain` (an inline domain list), `dst_ip` (an inline
CIDR list) and `dst_ruleset` (a reference to a `config ruleset`). Three
mechanisms meant three sets of semantics to learn and keep straight, and the
inline ones were the worse half of the trade: they are re-parsed per rule instead
of being compiled once into a `.srs`, they cannot be shared between rules, and
their matcher vocabulary had drifted from the rule-set one in a way nobody could
see (below).
**Decision: `dst_domain` and `dst_ip` are removed (schema v2). `dst_ruleset` is
the only destination matcher.** `Src`, `dst_port` and `proto` are untouched —
they are not lists of destinations and have no rule-set form.
- **Rejected: keep the inline lists as a shorthand.** "One obvious way" is the
whole point; a shorthand that quietly means something different from the long
form (see the bare-entry trap) is worse than no shorthand.
- **Rejected: promote inline lists to rule-sets lazily at generate time.** The
config on disk would then not say what the router does, and the panel would
have to render a list the user cannot find or edit.
### The bare-entry trap, and how the migration handles it
The two contexts already disagreed about exactly one spelling, silently:
| entry | in a rule (`dst_domain`) | in a rule-set (`entry`) | migrated to |
|--------------------|--------------------------|-------------------------|--------------|
| `example.com` | **exact host** | **host + subdomains** | `full:example.com` |
| `full:example.com` | exact host | exact host | unchanged |
| `suffix:example.com` / `.example.com` | host + subdomains | host + subdomains | unchanged |
| `keyword:ads` | substring | substring | unchanged |
| `regexp:^ads\.` | pattern | pattern *(added here)* | unchanged |
| `geosite:x` / `geoip:x` | inert (engine field removed) | inert (unknown prefix) | unchanged |
`shaterd migrate` (schema v1→v2, `shater/model/migrate.go`) creates one inline
`config ruleset` per rule that still carries a legacy list — `rule-<rule name>`
for domains, `rule-<rule name>-ip` for addresses — moves the entries across with
the conversion above, appends the new name to `dst_ruleset`, and deletes the old
option. It is idempotent, it resumes an interrupted run, and it never overwrites
a hand-written rule-set that already owns the generated name (it picks
`rule-<name>-2`). `regexp:` support was added to inline rule-sets in the same
change precisely so the move can be lossless.
`geosite:`/`geoip:` entries are copied VERBATIM rather than promoted to a
`source=geosite` rule-set: those matchers have been inert since the engine
dropped the route-rule geosite/geoip fields, and turning a dead matcher live
during an upgrade would be a behaviour change, not a migration. The text is kept
so the operator can see it and convert it deliberately.
**One deliberate semantic change, called out:** a rule that used BOTH lists
matched them with AND (an engine route rule ANDs its matcher fields), which is
almost never what "these sites and these networks" meant. The two generated
rule-sets are ORed, because `rule_set: [a, b]` matches when either matches. Such
a rule matches more after the migration than before; it affects only configs that
used both fields at once.
Consequence: one destination mechanism, one vocabulary, one place a list is
edited; every list is compiled once and reused. The panel's rule editor drops its
Domain(s) and IP/CIDR(s) fields; its destination control is a checkbox list of
the rulesets that already exist, and nothing more. Creating and filling a list
stays in the Rulesets panel — **rejected: a "create a list from here" shortcut in
the rule editor**, because a second place to author a list is a second place for
its semantics and its duplicate-name rules to drift, and the whole point of this
decision was to stop having two.
+6 -2
View File
@@ -13,8 +13,12 @@ usable release, **[T1]** next, **[T2]** later. Phases refer to `ROADMAP.md`.
- **[MVP]** TPROXY transparent proxy for multiple LAN interfaces (TCP + UDP), SNI/
Host/QUIC sniffing.
- **[MVP]** First-match routing rules by source (IP/CIDR/MAC/interface/zone),
destination (domain/suffix/keyword/geosite), reusable domain/IP lists, port,
proto → target (outbound/selector/chain/direct/block) + egress.
destination, port, proto → target (outbound/selector/chain/direct/block) + egress.
A rule names its **destination through a rule-set only** — a reusable named list
(inline domains/CIDRs, a local or remote file, or a geosite/geoip category) that is
compiled once into a `.srs` and shared by every rule that references it. Domain
entries take `full:` (exact), `suffix:` / a leading dot (host + subdomains),
`keyword:` (substring) and `regexp:`; a bare entry means host + subdomains.
- **[MVP]** Node groups with balancer/observatory (least-ping/failover/round-robin).
- **[T1]** Multi-hop chains (L1→Ln); per-rule egress selection; egress via any
interface/tunnel (e.g. an AmneziaWG tunnel).
+7 -3
View File
@@ -195,7 +195,7 @@ type Chain struct { Name string; Hops []string } // "group:<n>" | "node:<n>", L1
type Egress struct { Name,Type,Interface,Target string } // interface|proxy|direct|block
type Rule struct {
Name string; Enabled bool; Order int
Src []string; DstDomain,DstRuleset,DstIP []string; DstPort,Proto string
Src []string; DstRuleset []string; DstPort,Proto string // dst = ruleset only (v0.2 schema v2)
Target string // chain:|group:|node:|direct|block
Egress,Kill string
SchedEnabled bool; SchedDays []string; SchedStart,SchedEnd string; SchedUTCOffset int
@@ -257,8 +257,12 @@ Apply/rollback: `apSnapshot` (run→last-good, nft→last-good.nft, route marks)
- `config node`: name, enabled, uri, mux, mux_concurrency, xudp_concurrency, xudp_udp443, sockopt_mark, tcp_fast_open, tcp_keepalive_idle.
- `config group`: name, source, subscription, list node, strategy, include/exclude/filter_proto/filter_country, dedup, probe_url, probe_interval.
- `config chain`: name, list hop. `config egress`: name, type, interface, target.
- `config ruleset`: name, type(domain|ipcidr), source(inline|file|url), url, path, format, update_interval, list entry.
- `config rule`: name, enabled, order, list src/dst_domain/dst_ruleset/dst_ip, dst_port, proto, target, egress, kill, sched_enabled, list sched_day, sched_start/end/tz.
- `config ruleset`: name, type(domain|ipcidr), source(inline|file|url|geosite|geoip), url, path, format, update_interval, list category, list entry.
- `config rule`: name, enabled, order, list src, list dst_ruleset, dst_port, proto, target, egress, kill, sched_enabled, list sched_day, sched_start/end, sched_utc_offset.
v0.1 carried `dst_domain`/`dst_ip` on the rule itself; **schema v2 removed both** — a
destination is a `config ruleset` and nothing else. `shaterd migrate` folds each legacy
list into a generated `rule-<name>` (and `rule-<name>-ip`) inline ruleset; see
`DECISIONS.md` D21 for the entry-by-entry conversion table.
- `config preset`: name, enabled, order, target. `config profile`: name, enabled, priority, list match_iface, probe_url, probe_mode, sched_*, list enable_rule/disable_rule, default_target, default_egress.
- `config resolver`: name, type, address, detour, pool. `config dns_rule`: order, list match_domain/match_src, resolver.
+29 -2
View File
@@ -62,7 +62,29 @@ config inbound
# list node 'my-node'
#
# A routing rule. target: chain:<n>|group:<n>|node:<n>|egress:<n>|direct|block.
# Match on src / dst_domain / dst_ruleset / dst_ip / dst_port / proto.
# Match on src / dst_ruleset / dst_port / proto. A rule with NO matcher at all is
# the default route for everything that reached it.
#
# WHERE the traffic is going is named ONLY by dst_ruleset — one or more
# `config ruleset` names; the rule matches when ANY of them matches. There is no
# inline domain or address list on a rule (`dst_domain`/`dst_ip` were removed in
# schema v2): a destination list is written once as a ruleset, compiled into a
# .srs and shared by every rule that references it. `shaterd migrate` converts
# older configs automatically, creating a `rule-<name>` ruleset per rule.
#config ruleset
# option name 'blocked-video'
# option type 'domain'
# option source 'inline'
# list entry 'youtube.com'
# list entry 'suffix:googlevideo.com'
#
#config rule
# option name 'video-via-main'
# option enabled '1'
# option order '50'
# list dst_ruleset 'blocked-video'
# option target 'group:main'
#
#config rule
# option name 'all-via-main'
# option enabled '1'
@@ -83,11 +105,16 @@ config inbound
# option type 'direct'
# option dpi 'fragment'
#
#config ruleset
# option name 'youtube'
# option source 'geosite'
# list category 'youtube'
#
#config rule
# option name 'youtube-fragment'
# option enabled '1'
# option order '50'
# list dst_domain 'geosite:youtube'
# list dst_ruleset 'youtube'
# option target 'egress:frag'
#
# A DNS resolver (type: doh|dot|plain|local|fakeip). `detour` routes its queries
+10 -2
View File
@@ -809,9 +809,17 @@ export interface Rule {
Enabled: boolean
Order: number
Src?: string[] | null
DstDomain?: string[] | null
/**
* WHERE the traffic is going — the rule's only destination matcher. Each entry
* names a {@link Ruleset}; the rule matches when ANY of them matches.
*
* There is no inline domain or address list on a rule. `dst_domain`/`dst_ip`
* were removed in schema v2, and `shaterd migrate` folds every existing one
* into a generated `rule-<name>` ruleset, so a destination list is written and
* edited in exactly one place and compiled once into a .srs that every rule
* referencing it shares.
*/
DstRuleset?: string[] | null
DstIP?: string[] | null
DstPort?: string
/**
* Narrow the rule to one transport or one sniffed application protocol. A
-2
View File
@@ -318,9 +318,7 @@ export async function getRulesReachability(): Promise<RulesReachability> {
}))
const conditionless = (r: (typeof rules)[number]): boolean =>
!(r.Src ?? []).length &&
!(r.DstDomain ?? []).length &&
!(r.DstRuleset ?? []).length &&
!(r.DstIP ?? []).length &&
!String(r.DstPort ?? '').trim() &&
!String(r.Proto ?? '').trim()
const target = (r: (typeof rules)[number]): string =>
+1 -6
View File
@@ -396,9 +396,6 @@
gap: 5px;
min-width: 0;
}
.rt-field-wide {
grid-column: span 2;
}
.rt-flabel {
font-family: var(--font-mono);
font-size: 9px;
@@ -566,6 +563,7 @@ select.rt-input {
color: var(--faint);
}
/* textarea shares the input skin but grows vertically for a list of entries */
.rt-textarea {
min-height: 84px;
@@ -837,9 +835,6 @@ select.rt-input {
justify-content: flex-start;
align-self: start;
}
.rt-field-wide {
grid-column: auto;
}
.rt-rs-row {
grid-template-columns: 1fr;
row-gap: 10px;
+58 -111
View File
@@ -22,9 +22,7 @@ import type { Model, Rule, RuleReach, Ruleset, RulesetStatus } from '../api'
// ---------------------------------------------------------------------------
type RRule = Rule & {
Src?: string[] | null
DstDomain?: string[] | null
DstRuleset?: string[] | null
DstIP?: string[] | null
DstPort?: string
Proto?: string
Kill?: string
@@ -97,7 +95,6 @@ function ProtoOptions({ value }: { value: string }) {
const len = (a: unknown[] | null | undefined): number => (a ? a.length : 0)
const byOrder = (a: RRule, b: RRule): number => a.Order - b.Order
const csv = (s: string): string[] => s.split(',').map((x) => x.trim()).filter(Boolean)
// --- ruleset helpers --------------------------------------------------------
// A `config ruleset` (api.ts Ruleset) is a named domain/ipcidr list a rule
@@ -213,13 +210,13 @@ function everyLabel(sec: number): string {
return `every ${sec}s`
}
/** A rule with no matcher of any kind is the effective catch-all (route Final). */
/** A rule with no matcher of any kind is the effective catch-all (route Final).
* Mirrors model.IsCatchAll on the daemon side — the two must agree or the
* "never applies" badge lands on a different row than the apply warning. */
function isCatchAll(r: RRule): boolean {
return (
len(r.Src) === 0 &&
len(r.DstDomain) === 0 &&
len(r.DstRuleset) === 0 &&
len(r.DstIP) === 0 &&
!(r.DstPort && r.DstPort.trim()) &&
!(r.Proto && r.Proto.trim())
)
@@ -263,16 +260,15 @@ interface TargetGroups {
nodes: TargetOpt[] // node:<n> (huge — rendered last)
}
// Free-text destination matchers offered by the ADD form. Domains are NOT one of
// them (the user's call): domain matching goes through named rulesets — that's
// what they exist for. 'none' = the rule matches by rulesets/source/proto alone.
// (Legacy rules that already carry DstDomain stay editable in the edit form.)
type MatchKind = 'none' | 'ip' | 'port'
// The add form's fields. WHERE traffic is going is a ruleset choice and nothing
// else — a rule has no inline domain or address list any more, so the old
// Match-kind picker (rulesets / ip / port) collapsed into a plain Port field
// beside the ruleset picker. Adding one domain is still one step: the picker can
// build a list on the spot (RulesetPicker's "New list").
interface AddForm {
name: string
src: string[]
matchKind: MatchKind
matchValue: string
port: string
rulesets: string[]
proto: string
target: string
@@ -284,8 +280,7 @@ interface AddForm {
const EMPTY_FORM: AddForm = {
name: '',
src: [],
matchKind: 'none',
matchValue: '',
port: '',
rulesets: [],
proto: '',
target: 'direct',
@@ -674,18 +669,15 @@ export default function Routing() {
return
}
setFormError(null)
const mv = form.matchValue.trim()
const rule: RRule = {
Name: name,
Enabled: true,
Order: 0,
Src: form.src,
// Domains are matched via rulesets only — the add form has no free-text
// domain matcher by design.
DstDomain: [],
// Destination = rulesets, always. Domains and addresses live in a
// `config ruleset` so one list serves every rule that needs it.
DstRuleset: form.rulesets,
DstIP: form.matchKind === 'ip' ? csv(mv) : [],
DstPort: form.matchKind === 'port' ? mv : '',
DstPort: form.port.trim(),
Proto: form.proto,
Target: form.target,
Egress: '',
@@ -789,7 +781,7 @@ export default function Routing() {
{rules.length === 0 ? (
<div className="rt-empty">
<p>No rules — all traffic follows the default route.</p>
<p className="rt-empty-sub">Add a rule below to steer a domain, address, or port.</p>
<p className="rt-empty-sub">Add a rule below to steer a destination list, source, or port.</p>
</div>
) : (
<ol className="rt-list" aria-label="Routing rules in first-match order">
@@ -1012,9 +1004,7 @@ function Matchers({ rule }: { rule: RRule }): ReactNode {
)
}
listChip('src', rule.Src, 'src')
listChip('dns', rule.DstDomain, 'dom')
listChip('ruleset', rule.DstRuleset, 'rs')
listChip('ip', rule.DstIP, 'ip')
if (rule.DstPort && rule.DstPort.trim()) {
chips.push(
<span className="rt-chip" key="port">
@@ -1130,7 +1120,16 @@ function TargetOptions({ targets, current }: { targets: TargetGroups; current?:
)
}
/** The dst_ruleset checkbox group. Renders nothing when no rulesets exist. */
/**
* The destination picker: which rulesets this rule matches (dst_ruleset).
*
* Checkboxes and nothing else. This is the ONLY way a rule names a destination,
* so it renders even when the config has no lists yet — an empty picker that says
* where lists come from is the honest answer, and hiding it would leave the rule
* form with no destination control at all. Building and filling a list is the
* Rulesets panel's job, deliberately kept out of the rule editor so a list is
* created in exactly one place.
*/
function RulesetPicker({
options,
selected,
@@ -1142,24 +1141,26 @@ function RulesetPicker({
busy: boolean
onToggle: (name: string) => void
}): ReactNode {
if (options.length === 0) return null
return (
<div className="rt-rsel">
<span className="rt-flabel">Match rulesets — dst_ruleset</span>
<div className="rt-rsel-opts" role="group" aria-label="Match these rulesets">
{options.map((n) => {
const on = selected.includes(n)
return (
<label key={n} className={on ? 'rt-rsel-opt on' : 'rt-rsel-opt'}>
<input type="checkbox" checked={on} onChange={() => onToggle(n)} disabled={busy} />
<span className="mono">{n}</span>
</label>
)
})}
</div>
<span className="rt-flabel">Destination — dst_ruleset</span>
{options.length > 0 && (
<div className="rt-rsel-opts" role="group" aria-label="Match these rulesets">
{options.map((n) => {
const on = selected.includes(n)
return (
<label key={n} className={on ? 'rt-rsel-opt on' : 'rt-rsel-opt'}>
<input type="checkbox" checked={on} onChange={() => onToggle(n)} disabled={busy} />
<span className="mono">{n}</span>
</label>
)
})}
</div>
)}
<p className="rt-rsel-hint">
The rule also matches any traffic in the checked list(s). Combine with a domain, address, or
port, or use a ruleset on its own.
{options.length === 0
? 'No rulesets yet. Add one under Rulesets below, then come back and check it here — a rule matches a destination through a ruleset only.'
: 'The rule matches traffic in ANY checked list. Narrow it further with a source, port or protocol.'}
</p>
</div>
)
@@ -1285,7 +1286,6 @@ function AddRule({
set('rulesets', form.rulesets.includes(n) ? form.rulesets.filter((x) => x !== n) : [...form.rulesets, n])
const toggleDay = (d: string) =>
set('schedDays', form.schedDays.includes(d) ? form.schedDays.filter((x) => x !== d) : [...form.schedDays, d])
const matchPlaceholder = form.matchKind === 'ip' ? '10.0.0.0/8, 100.64.0.0/10' : '443, 8080-8090'
return (
<form className="rt-add" onSubmit={onSubmit} aria-label="Add a routing rule">
@@ -1318,32 +1318,17 @@ function AddRule({
</label>
<label className="rt-field">
<span className="rt-flabel">Match</span>
<select
<span className="rt-flabel">Port(s)</span>
<input
className="rt-input mono"
value={form.matchKind}
onChange={(e) => set('matchKind', e.target.value as MatchKind)}
>
<option value="none">rulesets only</option>
<option value="ip">ip / cidr</option>
<option value="port">port</option>
</select>
value={form.port}
onChange={(e) => set('port', e.target.value)}
placeholder="443, 8080-8090"
autoComplete="off"
spellCheck={false}
/>
</label>
{form.matchKind !== 'none' && (
<label className="rt-field rt-field-wide">
<span className="rt-flabel">{form.matchKind === 'port' ? 'Port(s)' : 'Address(es)'}</span>
<input
className="rt-input mono"
value={form.matchValue}
onChange={(e) => set('matchValue', e.target.value)}
placeholder={matchPlaceholder}
autoComplete="off"
spellCheck={false}
/>
</label>
)}
<label className="rt-field">
<span className="rt-flabel">Proto</span>
<select
@@ -1401,11 +1386,11 @@ function AddRule({
}
// --- edit-a-rule plate (inline, replaces the row it edits) ------------------
// Unlike AddRule, editing exposes all three destination matchers at once
// (Domain(s) / IP-CIDR(s) / Port) rather than a single Match picker — a real rule
// can carry several matcher kinds simultaneously and none may be silently dropped.
// The full original rule is spread into the result on save, so Order / Enabled /
// Kill / Egress (and anything else off-form) survive untouched.
// Same fields as AddRule, on purpose: a rule carries exactly one destination
// mechanism (rulesets) plus port/proto/source, so there is nothing an edit can
// reveal that the add form hides. The full original rule is spread into the
// result on save, so Order / Enabled / Kill / Egress (and anything else off-form)
// survive untouched.
function RuleEditForm({
initial,
names,
@@ -1425,8 +1410,6 @@ function RuleEditForm({
}) {
const [name, setName] = useState(initial.Name)
const [src, setSrc] = useState<string[]>([...(initial.Src ?? [])])
const [domain, setDomain] = useState((initial.DstDomain ?? []).join(', '))
const [ip, setIp] = useState((initial.DstIP ?? []).join(', '))
const [port, setPort] = useState(initial.DstPort ?? '')
const [proto, setProto] = useState(initial.Proto ?? '')
const [target, setTarget] = useState(effectiveTarget(initial))
@@ -1444,12 +1427,7 @@ function RuleEditForm({
// A rule with no matcher of any kind is a catch-all — legal, but worth flagging.
const noMatchers =
src.length === 0 &&
csv(domain).length === 0 &&
csv(ip).length === 0 &&
port.trim() === '' &&
rulesets.length === 0 &&
proto.trim() === ''
src.length === 0 && port.trim() === '' && rulesets.length === 0 && proto.trim() === ''
const submit = (e: FormEvent) => {
e.preventDefault()
@@ -1471,8 +1449,6 @@ function RuleEditForm({
...initial,
Name: nm,
Src: src,
DstDomain: csv(domain),
DstIP: csv(ip),
DstPort: port.trim(),
DstRuleset: rulesets,
Proto: proto,
@@ -1491,7 +1467,9 @@ function RuleEditForm({
<form className="rt-add rt-edit-form" onSubmit={submit} aria-label={`Edit rule ${initial.Name}`}>
<div className="rt-add-hd">
<span className="rt-add-title">Edit {initial.Name}</span>
<span className="rt-add-sub">Empty a field to drop that matcher. Save, then Apply.</span>
<span className="rt-add-sub">
Uncheck a list or clear a field to drop that matcher. Save, then Apply.
</span>
</div>
<div className="rt-fields">
@@ -1521,37 +1499,6 @@ function RuleEditForm({
/>
</label>
{/* Domains are matched via rulesets by design — this legacy field only
appears when the rule ALREADY carries free-text domains, so they
stay visible and clearable rather than silently preserved. */}
{(initial.DstDomain ?? []).length > 0 && (
<label className="rt-field rt-field-wide">
<span className="rt-flabel">Domain(s) — legacy</span>
<input
className="rt-input mono"
value={domain}
onChange={(e) => setDomain(e.target.value)}
placeholder="youtube.com, *.googlevideo.com"
autoComplete="off"
spellCheck={false}
disabled={busy}
/>
</label>
)}
<label className="rt-field rt-field-wide">
<span className="rt-flabel">IP / CIDR(s)</span>
<input
className="rt-input mono"
value={ip}
onChange={(e) => setIp(e.target.value)}
placeholder="10.0.0.0/8, 100.64.0.0/10"
autoComplete="off"
spellCheck={false}
disabled={busy}
/>
</label>
<label className="rt-field">
<span className="rt-flabel">Port(s)</span>
<input
+22 -8
View File
@@ -22,6 +22,16 @@ func tunnelModel() *model.Model {
return m
}
// pinnedIPSet declares the inline `type=ipcidr` rule-set a rule pins its
// destination addresses with. Since schema v2 a routing rule has no dst_ip of its
// own: addresses are a `config ruleset`, so the generated rule carries a rule_set
// reference and the plan resolves the actual prefixes through the lookup below —
// i.e. through the RUNNING engine in production (engine.RuleSetIPCIDRs), not out
// of the config text. planFor's table stands in for that.
func pinnedIPSet(name string, cidrs ...string) model.Ruleset {
return model.Ruleset{Name: name, Type: "ipcidr", Source: "inline", Entries: cidrs}
}
// planFor generates m and builds the untunnelable plan, resolving rule-set tags
// from the supplied table. A tag absent from the table reports "not loaded".
func planFor(t *testing.T, m *model.Model, sets map[string][]string) *netplane.UntunnelablePlan {
@@ -58,11 +68,12 @@ func renderPlan(t *testing.T, m *model.Model, plan *netplane.UntunnelablePlan) s
// only 8.8.8.8 may be un-pingable and the rest of the internet must answer.
func TestOnlyPinnedAddressIsTunnelled(t *testing.T) {
m := tunnelModel()
m.Rulesets = []model.Ruleset{pinnedIPSet("pin", "8.8.8.8/32")}
m.Rules = []model.Rule{
{Name: "pin", Enabled: true, Order: 10, DstIP: []string{"8.8.8.8/32"}, Target: "group:auto"},
{Name: "pin", Enabled: true, Order: 10, DstRuleset: []string{"pin"}, Target: "group:auto"},
{Name: "rest", Enabled: true, Order: 99, Target: "direct"},
}
plan := planFor(t, m, nil)
plan := planFor(t, m, map[string][]string{"rs-pin": {"8.8.8.8/32"}})
if !plan.DefaultAllow {
t.Fatalf("a catch-all `direct` rule must make the default ALLOW; plan=%+v", plan)
@@ -224,11 +235,12 @@ func TestDomainRuleDoesNotAffectUntunnelable(t *testing.T) {
// TestBlockedRuleDenies: an explicitly blocked destination stays dropped.
func TestBlockedRuleDenies(t *testing.T) {
m := tunnelModel()
m.Rulesets = []model.Ruleset{pinnedIPSet("bad", "203.0.113.0/24")}
m.Rules = []model.Rule{
{Name: "bad", Enabled: true, Order: 10, DstIP: []string{"203.0.113.0/24"}, Target: "block"},
{Name: "bad", Enabled: true, Order: 10, DstRuleset: []string{"bad"}, Target: "block"},
{Name: "rest", Enabled: true, Order: 99, Target: "direct"},
}
plan := planFor(t, m, nil)
plan := planFor(t, m, map[string][]string{"rs-bad": {"203.0.113.0/24"}})
if len(plan.Matches) != 1 || plan.Matches[0].Allow {
t.Fatalf("a blocked destination must deny untunnelable traffic too: %+v", plan.Matches)
}
@@ -361,11 +373,12 @@ func TestPlanNeverAcceptsTCPOrUDP(t *testing.T) {
m := tunnelModel()
m.Globals.IPv6 = true
m.Globals.Untunnelable = policy
m.Rulesets = []model.Ruleset{pinnedIPSet("pin", "8.8.8.8/32")}
m.Rules = []model.Rule{
{Name: "pin", Enabled: true, Order: 10, DstIP: []string{"8.8.8.8/32"}, Target: "group:auto"},
{Name: "pin", Enabled: true, Order: 10, DstRuleset: []string{"pin"}, Target: "group:auto"},
{Name: "rest", Enabled: true, Order: 99, Target: "direct"},
}
fwd := renderPlan(t, m, planFor(t, m, nil))
fwd := renderPlan(t, m, planFor(t, m, map[string][]string{"rs-pin": {"8.8.8.8/32"}}))
// Only the lines the untunnelable policy emits are in scope: the tproxy
// diverts in prerouting legitimately match TCP/UDP, which is their job.
@@ -424,12 +437,13 @@ func TestLocalPlaneSurvivesEveryPlan(t *testing.T) {
// only grant those clients, not everyone.
func TestSourceScopedRuleNarrowsTheAllow(t *testing.T) {
m := tunnelModel()
m.Rulesets = []model.Ruleset{pinnedIPSet("lab", "198.51.100.0/24")}
m.Rules = []model.Rule{
{Name: "lab", Enabled: true, Order: 10, Src: []string{"192.168.9.0/24"},
DstIP: []string{"198.51.100.0/24"}, Target: "direct"},
DstRuleset: []string{"lab"}, Target: "direct"},
{Name: "dflt", Enabled: true, Order: 99, Target: "group:auto"},
}
plan := planFor(t, m, nil)
plan := planFor(t, m, map[string][]string{"rs-lab": {"198.51.100.0/24"}})
if len(plan.Matches) != 1 {
t.Fatalf("expected one step, got %+v", plan.Matches)
}
+29 -21
View File
@@ -610,44 +610,52 @@ func splitDomainMarker(e string) (marker, value string, ok bool) {
// reader can tell a deliberate difference from an oversight (R9.3). Verified
// against the code on both sides, not against upstream docs — this is a fork.
//
// marker domain lists (this file) routing rules (ruleMatchers, route.go)
// There are now only TWO contexts, not three. A routing rule no longer classifies
// domains at all: `dst_domain` was removed in schema v2 and a rule names its
// destination through a `config ruleset`, so every domain entry in the system —
// filter list, device list, inline rule-set — arrives at classifyDomainEntries
// below. The one caller that adds something on top is inlineRulesetRule
// (ruleset.go), which peels `regexp:` off first.
//
// marker DNS filter / device lists inline rule-sets (inlineRulesetRule)
// ---------- ---------------------------- --------------------------------------
// full: yes — exact domain yes — exact domain
// suffix: yes — domain + subdomains yes — domain + subdomains
// .example SAME AS suffix: (dot stripped) SAME AS suffix: (dot stripped)
// keyword: yes — substring yes — substring
// (bare) SUFFIX in filter/device/ EXACT domain
// inline-ruleset lists
// (bare) SUFFIX (domain + subdomains) SUFFIX (domain + subdomains)
// regexp: NO — warns yes — DomainRegex (pattern validated)
// geosite: NO — warns NO — warns, rule matcher omitted
// geosite: NO — warns NO — warns, entry dropped
//
// Two corrections this table used to get wrong, both of the "claimed a behaviour
// that does not exist" kind:
// The bare-entry row is now the SAME on both sides, and that uniformity is the
// point of schema v2 — a routing rule used to read a bare entry as an EXACT host
// while every list read it as a suffix, a difference nothing in the UI showed.
// model.migrate1to2 is what preserves the old meaning of existing configs: it
// rewrites a rule's bare `dst_domain` entry as `full:` when it moves it into the
// generated rule-set.
//
// - `.example.com` was documented as "subdomains only" on both sides. It is not:
// Corrections this table used to get wrong, all of the "claimed a behaviour that
// does not exist" kind:
//
// - `.example.com` was documented as "subdomains only". It is not:
// classifyDomainEntries strips the dot, so it is a synonym of `suffix:` and
// matches the apex too. See the leading-dot branch above for why the synonym
// is kept rather than the distinction implemented.
// - `geosite:` was documented as a working routing matcher. It is not: the
// route-rule geosite field was REMOVED from this engine, so route.go warns and
// omits the matcher (a rule left with no other matcher is skipped entirely).
// Use a `config ruleset` with source=geosite.
// route-rule geosite field was REMOVED from this engine. Use a `config
// ruleset` with source=geosite and category chips.
//
// The two absences in the domain-list column are DELIBERATE, not gaps:
// The two absences in the FILTER column are DELIBERATE, not gaps:
//
// - regexp: an invalid regular expression is only detected when the rule is
// built, where it aborts box.New and takes the whole config down — the exact
// fail-open violation this audit spent its time removing. Supporting it here
// would require compiling and validating every pattern at generate time.
// Warning is the honest answer until that is done.
// - geosite: filter lists and rulesets already express geosite properly, via
// - regexp: an invalid regular expression aborts box.New and takes the whole
// config down, so it may only be accepted where every pattern is compiled and
// validated at generate time. Inline rule-sets do exactly that
// (peelDomainRegexes, ruleset.go), which is why the right-hand column says
// yes; the DNS-filter path has no such validation and warns instead.
// - geosite: filter lists and rule-sets already express geosite properly, via
// `source=geosite` plus category chips, which fetches the official compiled
// .srs. A `geosite:` entry inside an inline list would be a second, weaker
// path to the same feature.
//
// The BARE-entry difference is also deliberate and long-standing: a blocklist
// entry is meant to cover subdomains, while a routing rule's bare entry is an
// exact host. Both are documented at their call sites.
// toASCIIDomain punycodes a unicode domain entry so it can match the punycoded
// names that actually arrive in DNS queries. Lenient by design: an entry idna
+2 -1
View File
@@ -289,8 +289,9 @@ func TestFailoverWarnsOnceAcrossChainCopy(t *testing.T) {
m := twoNodeGroupModel("failover")
m.Nodes = append(m.Nodes, model.Node{Name: "hop", Enabled: true, URI: ss("203.0.113.9")})
m.Chains = []model.Chain{{Name: "ch", Hops: []string{"node:hop", "group:g"}}}
m.Rulesets = []model.Ruleset{inlineDomainSet("ex", "example.com")}
m.Rules = []model.Rule{
{Name: "r", Enabled: true, DstDomain: []string{"example.com"}, Target: "chain:ch"},
{Name: "r", Enabled: true, DstRuleset: []string{"ex"}, Target: "chain:ch"},
}
_, warns, err := GenerateWithWarnings(m)
if err != nil {
+14 -9
View File
@@ -506,16 +506,21 @@ func validPortRange(s string) bool {
// the classifier actually drops.
//
// `suffix:` belongs here. The list used to omit it on the theory that suffix:/
// regexp: are route-rule-only and are peeled off by ruleMatchers before the shared
// classifier sees them. That is true of route.go — which handles a bare `suffix:`
// in its own switch and warns there, so this list cannot double-report — but NOT
// of the classifier itself: classifyDomainEntries has a `case "suffix"`, and every
// other caller (devices.go, dnsfilter.go) reaches it directly. A lone `suffix:`
// arriving that way was dropped by add()'s empty-value guard and reported by
// nobody, which is the one outcome this pair of functions exists to prevent.
// regexp: were route-rule-only and were peeled off before the shared classifier
// saw them. classifyDomainEntries has a `case "suffix"`, and every caller
// (devices.go, dnsfilter.go, inlineRulesetRule) reaches it directly, so a lone
// `suffix:` was dropped by add()'s empty-value guard and reported by nobody —
// the one outcome this pair of functions exists to prevent. (The route-rule half
// of that old reasoning is gone entirely: `dst_domain` was removed in schema v2,
// so route.go classifies no domains at all and there is no second warner to
// double-report with.)
//
// `regexp:` is correctly absent: the classifier has no case for it, so a bare
// `regexp:` is an UNRECOGNISED prefix and is reported by unrecognisedDomainPrefix.
// `regexp:` is correctly absent, for two different reasons depending on the
// caller. In a filter/device list the classifier has no case for it, so a bare
// `regexp:` is an UNRECOGNISED prefix reported by unrecognisedDomainPrefix. In an
// inline rule-set peelDomainRegexes (ruleset.go) strips every `regexp:` entry
// BEFORE this predicate runs and reports a valueless one itself, so it can never
// reach here either way.
var domainMarkers = []string{"keyword:", "full:", "suffix:", "."}
// isDomainMarkerOnly reports whether an entry is a bare classification marker
+4 -2
View File
@@ -323,8 +323,9 @@ func TestEgressDPISpoofValidates(t *testing.T) {
Globals: model.DefaultGlobals(),
Inbounds: []model.Inbound{{Name: "lan", Enabled: true, Type: "tproxy", TproxyPort: 12366, TCP: true, UDP: true}},
Egresses: []model.Egress{{Name: "spf", Type: "direct", DPI: "spoof"}},
Rulesets: []model.Ruleset{inlineDomainSet("blocked", "blocked.example")},
Rules: []model.Rule{
{Name: "spoof-rule", Enabled: true, Order: 10, DstDomain: []string{"blocked.example"}, Target: "egress:spf"},
{Name: "spoof-rule", Enabled: true, Order: 10, DstRuleset: []string{"blocked"}, Target: "egress:spf"},
},
}
opts, warns, changed := applyAndClose(t, m)
@@ -346,8 +347,9 @@ func TestByedpiEgressValidates(t *testing.T) {
Globals: model.DefaultGlobals(),
Inbounds: []model.Inbound{{Name: "lan", Enabled: true, Type: "tproxy", TproxyPort: 12367, TCP: true, UDP: true}},
Egresses: []model.Egress{{Name: "bd", Type: "byedpi", Port: 1080}},
Rulesets: []model.Ruleset{inlineDomainSet("blocked", "blocked.example")},
Rules: []model.Rule{
{Name: "desync", Enabled: true, Order: 10, DstDomain: []string{"blocked.example"}, Target: "egress:bd"},
{Name: "desync", Enabled: true, Order: 10, DstRuleset: []string{"blocked"}, Target: "egress:bd"},
},
}
opts, warns, changed := applyAndClose(t, m)
+3 -2
View File
@@ -21,9 +21,10 @@ func TestProfileAppliesCleanly(t *testing.T) {
Globals: g,
Inbounds: []model.Inbound{{Name: "lan", Enabled: true, Type: "tproxy", TproxyPort: 12370, TCP: true, UDP: true}},
Nodes: []model.Node{{Name: "ss1", Enabled: true, URI: "ss://aes-256-gcm:secret@203.0.113.5:8388#ss1"}},
Rulesets: []model.Ruleset{inlineDomainSet("ads", "ads.example")},
Rules: []model.Rule{
{Name: "lan-proxy", Enabled: true, Order: 10, Src: []string{"192.168.1.0/24"}, Target: "node:ss1"},
{Name: "adblock", Enabled: true, Order: 20, DstDomain: []string{"ads.example"}, Target: "block"},
{Name: "adblock", Enabled: true, Order: 20, DstRuleset: []string{"ads"}, Target: "block"},
},
Profiles: []model.Profile{
{Name: "home", Enabled: true, Priority: 1, DisableRules: []string{"adblock"}},
@@ -35,7 +36,7 @@ func TestProfileAppliesCleanly(t *testing.T) {
t.Fatalf("expected Apply changed==true (warnings: %v)", warns)
}
// Profile-disabled 'adblock' rule must be absent.
if opts.Route == nil || hasDomainRule(opts.Route, "ads.example") {
if opts.Route == nil || hasRulesetRule(opts.Route, "ads") {
t.Fatalf("profile-disabled rule 'adblock' should not be emitted")
}
// The lan-proxy rule still routes to ss1.
+41 -23
View File
@@ -49,9 +49,13 @@ func TestNoProfilesUnchanged(t *testing.T) {
m := &model.Model{
Globals: model.DefaultGlobals(), // kill-switch closed => Final "block"
Nodes: []model.Node{{Name: "ss1", Enabled: true, URI: "ss://aes-256-gcm:secret@203.0.113.5:8388#ss1"}},
Rulesets: []model.Ruleset{
inlineDomainSet("a", "a.example"),
inlineDomainSet("b", "b.example"),
},
Rules: []model.Rule{
{Name: "a", Enabled: true, Order: 10, DstDomain: []string{"a.example"}, Target: "direct"},
{Name: "b", Enabled: true, Order: 20, DstDomain: []string{"b.example"}, Target: "node:ss1"},
{Name: "a", Enabled: true, Order: 10, DstRuleset: []string{"a"}, Target: "direct"},
{Name: "b", Enabled: true, Order: 20, DstRuleset: []string{"b"}, Target: "node:ss1"},
},
}
rt, b := buildRouteAt(m, wed12UTC)
@@ -70,7 +74,7 @@ func TestNoProfilesUnchanged(t *testing.T) {
if len(rt.Rules) != 4 {
t.Fatalf("route rule count = %d, want 4 (sniff+hijack+2 user)", len(rt.Rules))
}
if !hasDomainRule(rt, "a.example") || !hasDomainRule(rt, "b.example") {
if !hasRulesetRule(rt, "a") || !hasRulesetRule(rt, "b") {
t.Fatalf("both user rules should survive unchanged")
}
}
@@ -83,9 +87,13 @@ func TestActiveProfileDisablesRule(t *testing.T) {
m := &model.Model{
Globals: g,
Nodes: []model.Node{{Name: "ss1", Enabled: true, URI: "ss://aes-256-gcm:secret@203.0.113.5:8388#ss1"}},
Rulesets: []model.Ruleset{
inlineDomainSet("blocked", "blocked.example"),
inlineDomainSet("keep", "keep.example"),
},
Rules: []model.Rule{
{Name: "blockme", Enabled: true, Order: 10, DstDomain: []string{"blocked.example"}, Target: "block"},
{Name: "keep", Enabled: true, Order: 20, DstDomain: []string{"keep.example"}, Target: "direct"},
{Name: "blockme", Enabled: true, Order: 10, DstRuleset: []string{"blocked"}, Target: "block"},
{Name: "keep", Enabled: true, Order: 20, DstRuleset: []string{"keep"}, Target: "direct"},
},
Profiles: []model.Profile{
// Manual pin: honored regardless of conditions. Disables "blockme".
@@ -94,10 +102,10 @@ func TestActiveProfileDisablesRule(t *testing.T) {
}
rt, b := buildRouteAt(m, wed12UTC)
if hasDomainRule(rt, "blocked.example") {
if hasRulesetRule(rt, "blocked") {
t.Fatalf("profile disabled rule 'blockme' but its matcher is still emitted")
}
if !hasDomainRule(rt, "keep.example") {
if !hasRulesetRule(rt, "keep") {
t.Fatalf("rule 'keep' should be untouched")
}
if rt.Final != tagBlock {
@@ -115,9 +123,13 @@ func TestActiveProfileDisablesRule(t *testing.T) {
func TestAutoSelectHighestPriority(t *testing.T) {
m := &model.Model{
Globals: model.DefaultGlobals(),
Rulesets: []model.Ruleset{
inlineDomainSet("lo", "lo.example"),
inlineDomainSet("hi", "hi.example"),
},
Rules: []model.Rule{
{Name: "rLo", Enabled: true, Order: 10, DstDomain: []string{"lo.example"}, Target: "direct"},
{Name: "rHi", Enabled: true, Order: 20, DstDomain: []string{"hi.example"}, Target: "direct"},
{Name: "rLo", Enabled: true, Order: 10, DstRuleset: []string{"lo"}, Target: "direct"},
{Name: "rHi", Enabled: true, Order: 20, DstRuleset: []string{"hi"}, Target: "direct"},
},
Profiles: []model.Profile{
{Name: "lo", Enabled: true, Priority: 5, DisableRules: []string{"rLo"}},
@@ -125,10 +137,10 @@ func TestAutoSelectHighestPriority(t *testing.T) {
},
}
rt, _ := buildRouteAt(m, wed12UTC)
if hasDomainRule(rt, "hi.example") {
if hasRulesetRule(rt, "hi") {
t.Fatalf("the highest-priority profile must win and disable rHi")
}
if !hasDomainRule(rt, "lo.example") {
if !hasRulesetRule(rt, "lo") {
t.Fatalf("only the winning profile applies (rLo should survive)")
}
}
@@ -142,9 +154,13 @@ func TestIfaceProfileSkippedByAutoButHonoredNamed(t *testing.T) {
g.ActiveProfile = active
return &model.Model{
Globals: g,
Rulesets: []model.Ruleset{
inlineDomainSet("hi", "hi.example"),
inlineDomainSet("if", "if.example"),
},
Rules: []model.Rule{
{Name: "rHi", Enabled: true, Order: 10, DstDomain: []string{"hi.example"}, Target: "direct"},
{Name: "rIf", Enabled: true, Order: 20, DstDomain: []string{"if.example"}, Target: "direct"},
{Name: "rHi", Enabled: true, Order: 10, DstRuleset: []string{"hi"}, Target: "direct"},
{Name: "rIf", Enabled: true, Order: 20, DstRuleset: []string{"if"}, Target: "direct"},
},
Profiles: []model.Profile{
{Name: "plain", Enabled: true, Priority: 10, DisableRules: []string{"rHi"}},
@@ -156,19 +172,19 @@ func TestIfaceProfileSkippedByAutoButHonoredNamed(t *testing.T) {
// Auto-select (no pin): iface profile skipped (the watcher owns it); 'plain' wins.
rt, _ := buildRouteAt(mk(""), wed12UTC)
if hasDomainRule(rt, "hi.example") {
if hasRulesetRule(rt, "hi") {
t.Fatalf("auto-select should apply 'plain' and disable rHi")
}
if !hasDomainRule(rt, "if.example") {
if !hasRulesetRule(rt, "if") {
t.Fatalf("iface profile 'wwan' must be skipped by auto-select (rIf should survive)")
}
// Named explicitly: iface profile honored regardless of its iface condition.
rt, _ = buildRouteAt(mk("wwan"), wed12UTC)
if hasDomainRule(rt, "if.example") {
if hasRulesetRule(rt, "if") {
t.Fatalf("explicitly named iface profile must be honored and disable rIf")
}
if !hasDomainRule(rt, "hi.example") {
if !hasRulesetRule(rt, "hi") {
t.Fatalf("only the named profile applies; rHi should survive")
}
}
@@ -179,14 +195,15 @@ func TestUnknownActiveProfileFallsBackToAuto(t *testing.T) {
g := model.DefaultGlobals()
g.ActiveProfile = "ghost"
m := &model.Model{
Globals: g,
Rules: []model.Rule{{Name: "rX", Enabled: true, Order: 10, DstDomain: []string{"x.example"}, Target: "direct"}},
Globals: g,
Rulesets: []model.Ruleset{inlineDomainSet("x", "x.example")},
Rules: []model.Rule{{Name: "rX", Enabled: true, Order: 10, DstRuleset: []string{"x"}, Target: "direct"}},
Profiles: []model.Profile{
{Name: "auto", Enabled: true, Priority: 1, DisableRules: []string{"rX"}},
},
}
rt, b := buildRouteAt(m, wed12UTC)
if hasDomainRule(rt, "x.example") {
if hasRulesetRule(rt, "x") {
t.Fatalf("fallback auto-select should apply 'auto' and disable rX")
}
if !hasWarning(b, "falling back to auto-select") {
@@ -208,16 +225,17 @@ func TestUnknownActiveProfileFallsBackToAuto(t *testing.T) {
// suppress it or to warn about it.
func TestPlainProfileIsSelectableAfterProbeRemoval(t *testing.T) {
m := &model.Model{
Globals: model.DefaultGlobals(),
Globals: model.DefaultGlobals(),
Rulesets: []model.Ruleset{inlineDomainSet("hi", "hi.example")},
Rules: []model.Rule{
{Name: "rHi", Enabled: true, Order: 10, DstDomain: []string{"hi.example"}, Target: "direct"},
{Name: "rHi", Enabled: true, Order: 10, DstRuleset: []string{"hi"}, Target: "direct"},
},
Profiles: []model.Profile{
{Name: "plain", Enabled: true, Priority: 10, DisableRules: []string{"rHi"}},
},
}
rt, b := buildRouteAt(m, wed12UTC)
if hasDomainRule(rt, "hi.example") {
if hasRulesetRule(rt, "hi") {
t.Fatalf("a plain profile must apply and disable rHi")
}
// No leftover diagnostic: warning about an option the parser no longer reads
+8 -110
View File
@@ -1,8 +1,6 @@
package generate
import (
"fmt"
"regexp"
"sort"
"strings"
@@ -321,114 +319,14 @@ func (b *builder) ruleMatchers(r model.Rule) (raw option.RawDefaultRule, matched
matched = true
}
// Destination domains. The route-rule-specific prefixes (geosite:/regexp:/
// suffix:) are peeled off here; everything else goes through the shared
// classifier in dnsfilter.go with bareIsSuffix=FALSE — a bare entry in a
// ROUTING rule is an exact domain, unlike the DNS/filter lists where it means
// "and all subdomains". classifyDomainEntries is also what drops marker-only
// entries ("." / "full:" / "keyword:"), which must never reach the engine:
// an empty domain/domain_suffix aborts box.New, and an empty domain_keyword
// is strings.Contains(host, "") — a silent match on EVERY host.
var explicitSuffix []string
var plain []string
for _, d := range r.DstDomain {
d = strings.TrimSpace(d)
if d == "" {
continue
}
switch {
case strings.HasPrefix(d, "geosite:"):
// A `geosite:` matcher is NOT emittable: the route-rule geosite field was
// removed in this engine and route/rule.NewDefaultRule hard-errors on it,
// which aborts box.New for the WHOLE config. Mirroring the geoip handling
// below, it is treated as inert: warned and omitted, so a legacy/UCI rule
// carrying one degrades to "this rule does nothing" instead of taking the
// entire tunnel down. Use a `config ruleset` with source=geosite instead.
b.warnf("rule %q: geosite matcher %q is removed from this engine — use a ruleset with source=geosite instead, omitted (inert)", r.Name, d)
case strings.HasPrefix(d, "regexp:"):
// route/rule.NewDomainRegexItem hard-errors on an uncompilable pattern and
// takes box.New with it; validate here and drop the bad one with a warning.
// A BARE "regexp:" compiles fine but matches every host — same silent
// match-all hazard as an empty keyword, so it is dropped too.
re := strings.TrimSpace(strings.TrimPrefix(d, "regexp:"))
if re == "" {
b.warnf("rule %q: %q is a bare matcher marker with no value, omitted (an empty regexp matches EVERY host)", r.Name, d)
break
}
if _, err := regexp.Compile(re); err != nil {
b.warnf("rule %q: domain regexp %q is invalid (%v), omitted", r.Name, re, err)
break
}
raw.DomainRegex = append(raw.DomainRegex, re)
matched = true
case strings.HasPrefix(d, "suffix:"):
// Label-aware suffix (apex + subdomains): sing-box domain_suffix stored
// in bare form matches both "example.com" and "*.example.com" (see
// sing/common/domain matcher). A leading-dot entry, by contrast, matches
// subdomains ONLY, so the explicit `suffix:` form is how presets/rules
// ask for apex-inclusive suffix matching.
if s := strings.TrimSpace(strings.TrimPrefix(d, "suffix:")); s != "" {
explicitSuffix = append(explicitSuffix, s)
} else {
b.warnf("rule %q: %q is a bare matcher marker with no value, omitted (an empty domain token aborts box.New)", r.Name, d)
}
default:
plain = append(plain, d)
}
}
domain, suffix, keyword := classifyDomainEntries(plain, false)
for _, d := range plain {
if isDomainMarkerOnly(d) {
b.warnf("rule %q: %q is a bare matcher marker with no value, omitted (an empty domain token aborts box.New; an empty keyword matches EVERY host)", r.Name, d)
}
}
// An unrecognised `word:` prefix is DROPPED by classifyDomainEntries (a domain
// cannot contain ":", so keeping it would load a provably unmatchable literal).
// It has to be reported here or the rule silently loses that destination — the
// v0.1/xray spelling `domain:example.com` is exactly what someone migrating
// writes, and it used to disappear without a trace. geosite:/regexp:/suffix:
// were already peeled off above, so `plain` carries only the shared markers.
b.warnUnrecognisedPrefixes(fmt.Sprintf("rule %q", r.Name), plain)
suffix = append(explicitSuffix, suffix...)
if len(domain) > 0 {
raw.Domain = badoption.Listable[string](domain)
matched = true
}
if len(suffix) > 0 {
raw.DomainSuffix = badoption.Listable[string](suffix)
matched = true
}
if len(keyword) > 0 {
raw.DomainKeyword = badoption.Listable[string](keyword)
matched = true
}
// Destination IPs. A `geoip:<code>` entry is NOT a CIDR: route-rule geoip was
// removed in this engine (box.New hard-errors on it), so — mirroring how the
// ruleset geoip source is handled — it is treated as inert: warned and omitted
// (never emitted as an ip_cidr, which would also abort box.New). This keeps a
// geoip-driven rule/preset (e.g. the ru-bypass pack) fail-open instead of fatal.
var ipcidr []string
for _, ip := range r.DstIP {
ip = strings.TrimSpace(ip)
if ip == "" {
continue
}
if strings.HasPrefix(strings.ToLower(ip), "geoip:") {
b.warnf("rule %q: geoip matcher %q is removed from this engine — use a ruleset with source=geoip instead, omitted (inert)", r.Name, ip)
continue
}
ipcidr = append(ipcidr, ip)
}
// Same fail-open validation as the source list: an unparseable ip_cidr aborts
// box.New for the whole config, so drop it with a warning instead.
ipcidr, badIP := validPrefixes(ipcidr)
for _, s := range badIP {
b.warnf("rule %q: destination %q is not a valid IP/CIDR, omitted", r.Name, s)
}
if len(ipcidr) > 0 {
raw.IPCIDR = badoption.Listable[string](ipcidr)
matched = true
}
// Destination: a rule's ONLY destination matcher is DstRuleset (schema v2).
// The inline `dst_domain` / `dst_ip` lists that used to be classified here are
// gone. A destination list is a `config ruleset` — compiled once into a .srs and
// shared by every rule that references it — so the prefix vocabulary
// (full:/suffix:/keyword:/regexp:, a leading dot) and the geosite/geoip sources
// live in exactly one place (generate/ruleset.go inlineRulesetRule). Existing
// configs were rewritten by model.migrate1to2, which preserves each entry's
// meaning 1:1.
// DstRuleset -> reference the rs-<name> rule-sets materialised by
// buildRoutingRuleSets. A dst_ruleset naming an UNDEFINED ruleset is warned +
+88 -69
View File
@@ -22,26 +22,27 @@ func killModel(kill string) *model.Model {
g := model.DefaultGlobals()
g.KillSwitch = "closed"
return &model.Model{
Globals: g,
Nodes: []model.Node{{Name: "n1", Enabled: false, URI: "ss://aes-256-gcm:secret@203.0.113.1:8388#n1"}},
Groups: []model.Group{{Name: "grp", Strategy: "leastping", Nodes: []string{"n1"}}},
Globals: g,
Nodes: []model.Node{{Name: "n1", Enabled: false, URI: "ss://aes-256-gcm:secret@203.0.113.1:8388#n1"}},
Groups: []model.Group{{Name: "grp", Strategy: "leastping", Nodes: []string{"n1"}}},
Rulesets: []model.Ruleset{inlineDomainSet("social", "social.example")},
Rules: []model.Rule{
{Name: "social", Enabled: true, Order: 10, DstDomain: []string{"social.example"}, Target: "group:grp", Kill: kill},
{Name: "social", Enabled: true, Order: 10, DstRuleset: []string{"social"}, Target: "group:grp", Kill: kill},
{Name: "catch-tcp", Enabled: true, Order: 20, Proto: "tcp", Target: "direct"},
},
}
}
// domainRuleTarget returns the outbound the rule matching domain routes to.
func domainRuleTarget(rt *option.RouteOptions, domain string) (string, bool) {
for _, r := range generalRules(rt) {
for _, d := range r.DefaultOptions.RawDefaultRule.Domain {
if d == domain {
return r.DefaultOptions.RuleAction.RouteOptions.Outbound, true
}
}
// rulesetRuleTarget returns the outbound the rule referencing the rule-set named
// name routes to. It is the post-schema-v2 replacement for looking a rule up by
// its dst-domain matcher: a rule's destination is a rule_set reference now, so
// the matcher that identifies it is the rs-<name> tag.
func rulesetRuleTarget(rt *option.RouteOptions, name string) (string, bool) {
dr := findRouteRuleWithRuleSet(rt, routeRulesetTagPrefix+name)
if dr == nil {
return "", false
}
return "", false
return dr.RuleAction.RouteOptions.Outbound, true
}
// TestRuleKillDefaultBlocks: kill="" / "default" is fail-closed — the rule is
@@ -54,7 +55,7 @@ func TestRuleKillDefaultBlocks(t *testing.T) {
if err != nil {
t.Fatalf("%q: Generate: %v", kill, err)
}
got, ok := domainRuleTarget(opts.Route, "social.example")
got, ok := rulesetRuleTarget(opts.Route, "social")
if !ok {
t.Fatalf("%q: rule must be emitted routing to block, but it was dropped", kill)
}
@@ -76,7 +77,7 @@ func TestRuleKillClosedBlocksHere(t *testing.T) {
if err != nil {
t.Fatalf("Generate: %v", err)
}
got, ok := domainRuleTarget(opts.Route, "social.example")
got, ok := rulesetRuleTarget(opts.Route, "social")
if !ok {
t.Fatalf("kill=closed must still emit the rule (warnings %v)", warns)
}
@@ -103,7 +104,7 @@ func TestRuleKillOpenGoesDirect(t *testing.T) {
if err != nil {
t.Fatalf("Generate: %v", err)
}
got, ok := domainRuleTarget(opts.Route, "social.example")
got, ok := rulesetRuleTarget(opts.Route, "social")
if !ok {
t.Fatalf("kill=open must still emit the rule (warnings %v)", warns)
}
@@ -122,7 +123,7 @@ func TestRuleKillUnknownBlocks(t *testing.T) {
if err != nil {
t.Fatalf("Generate: %v", err)
}
got, ok := domainRuleTarget(opts.Route, "social.example")
got, ok := rulesetRuleTarget(opts.Route, "social")
if !ok || got != tagBlock {
t.Fatalf("unknown kill policy must block, got %q (ok=%v)", got, ok)
}
@@ -145,7 +146,7 @@ func TestRuleKillPreservesFailClosedInvariant(t *testing.T) {
if opts.Route.Final != tagBlock {
t.Fatalf("%q: Final = %q, want block", kill, opts.Route.Final)
}
if tgt, ok := domainRuleTarget(opts.Route, "social.example"); ok && tgt == tagDirect {
if tgt, ok := rulesetRuleTarget(opts.Route, "social"); ok && tgt == tagDirect {
t.Fatalf("%q: dead group leaked direct", kill)
}
}
@@ -158,17 +159,18 @@ func TestRuleKillOnlyAppliesToUnresolvedTargets(t *testing.T) {
g := model.DefaultGlobals()
g.KillSwitch = "closed"
m := &model.Model{
Globals: g,
Nodes: []model.Node{{Name: "n1", Enabled: true, URI: "ss://aes-256-gcm:secret@203.0.113.1:8388#n1"}},
Globals: g,
Nodes: []model.Node{{Name: "n1", Enabled: true, URI: "ss://aes-256-gcm:secret@203.0.113.1:8388#n1"}},
Rulesets: []model.Ruleset{inlineDomainSet("social", "social.example")},
Rules: []model.Rule{
{Name: "ok", Enabled: true, Order: 10, DstDomain: []string{"social.example"}, Target: "node:n1", Kill: kill},
{Name: "ok", Enabled: true, Order: 10, DstRuleset: []string{"social"}, Target: "node:n1", Kill: kill},
},
}
opts, _, err := GenerateWithWarnings(m)
if err != nil {
t.Fatalf("%q: Generate: %v", kill, err)
}
got, ok := domainRuleTarget(opts.Route, "social.example")
got, ok := rulesetRuleTarget(opts.Route, "social")
if !ok || got != "n1" {
t.Fatalf("%q: healthy target must win, got %q (ok=%v)", kill, got, ok)
}
@@ -176,20 +178,27 @@ func TestRuleKillOnlyAppliesToUnresolvedTargets(t *testing.T) {
}
// --- R4: marker-only domain entries ------------------------------------------
//
// R4 moved with the destination list itself: a rule's domains are an inline
// `config ruleset` now (schema v2), so the marker-only guard has to hold in
// inlineRulesetRule rather than in ruleMatchers. The hazard is unchanged.
// TestRuleMarkerOnlyDomainEntriesDropped: an entry that is nothing but its marker
// must never reach the engine. An empty domain/domain_suffix makes NewDomainItem
// return "empty item is not allowed" and aborts box.New (whole LAN down from one
// stray "."); an empty domain_keyword is SILENT and matches every host.
func TestRuleMarkerOnlyDomainEntriesDropped(t *testing.T) {
// TestRulesetMarkerOnlyDomainEntriesDropped: an entry that is nothing but its
// marker must never reach the engine. An empty domain/domain_suffix makes
// NewDomainItem return "empty item is not allowed" and aborts box.New (whole LAN
// down from one stray "."); an empty domain_keyword is SILENT and matches every
// host. As the sole entry it also leaves the rule-set with nothing usable, so the
// list is skipped and the rule referencing it is not emitted — never promoted to
// "matches everything".
func TestRulesetMarkerOnlyDomainEntriesDropped(t *testing.T) {
for _, entry := range []string{".", "full:", "keyword:", "suffix:", "regexp:", " keyword: "} {
rt, warns := genRules(t, model.Rule{
Name: "m", Enabled: true, Order: 10,
DstDomain: []string{entry}, Target: "node:n1",
})
for _, r := range rt.Rules {
raw := r.DefaultOptions.RawDefaultRule
for _, list := range [][]string{raw.Domain, raw.DomainSuffix, raw.DomainKeyword, raw.DomainRegex} {
rt, warns := genRulesWithSets(t,
[]model.Ruleset{inlineDomainSet("m", entry)},
model.Rule{Name: "m", Enabled: true, Order: 10, DstRuleset: []string{"m"}, Target: "node:n1"},
)
for _, rs := range rt.RuleSet {
hr := rs.InlineOptions.Rules[0].DefaultOptions
for _, list := range [][]string{hr.Domain, hr.DomainSuffix, hr.DomainKeyword, hr.DomainRegex} {
for _, v := range list {
if strings.TrimSpace(v) == "" {
t.Fatalf("%q: emitted an EMPTY matcher token", entry)
@@ -197,8 +206,9 @@ func TestRuleMarkerOnlyDomainEntriesDropped(t *testing.T) {
}
}
}
// Sole entry => nothing left to match on => the rule must be skipped, never
// silently promoted to "matches everything".
if _, ok := ruleSetByTag(rt, "rs-m"); ok {
t.Fatalf("%q: a marker-only list must not materialise a rule-set", entry)
}
if n := len(generalRules(rt)); n != 0 {
t.Fatalf("%q: marker-only sole entry must skip the rule, got %d rules", entry, n)
}
@@ -208,46 +218,55 @@ func TestRuleMarkerOnlyDomainEntriesDropped(t *testing.T) {
}
}
// TestRuleMarkerOnlyBesideRealEntriesKeepsTheRest: the real entries must survive
// the marker-only ones.
func TestRuleMarkerOnlyBesideRealEntriesKeepsTheRest(t *testing.T) {
rt, warns := genRules(t, model.Rule{
Name: "m", Enabled: true, Order: 10,
DstDomain: []string{".", "keyword:", "exact.example", "keyword:ads", ".sub.example", "suffix:apex.example"},
Target: "node:n1",
})
gen := generalRules(rt)
if len(gen) != 1 {
t.Fatalf("want 1 rule, got %d (warnings %v)", len(gen), warns)
// TestRulesetMarkerOnlyBesideRealEntriesKeepsTheRest: the real entries must
// survive the marker-only ones.
func TestRulesetMarkerOnlyBesideRealEntriesKeepsTheRest(t *testing.T) {
rt, warns := genRulesWithSets(t,
[]model.Ruleset{inlineDomainSet("m",
".", "keyword:", "full:exact.example", "keyword:ads", ".sub.example", "suffix:apex.example")},
model.Rule{Name: "m", Enabled: true, Order: 10, DstRuleset: []string{"m"}, Target: "node:n1"},
)
rs, ok := ruleSetByTag(rt, "rs-m")
if !ok {
t.Fatalf("the usable entries must keep the list alive (warnings %v)", warns)
}
raw := gen[0].DefaultOptions.RawDefaultRule
if len(raw.Domain) != 1 || raw.Domain[0] != "exact.example" {
t.Fatalf("domain = %v, want [exact.example] (bare entry in a ROUTING rule is exact)", raw.Domain)
hr := rs.InlineOptions.Rules[0].DefaultOptions
if len(hr.Domain) != 1 || hr.Domain[0] != "exact.example" {
t.Fatalf("domain = %v, want [exact.example]", hr.Domain)
}
if len(raw.DomainKeyword) != 1 || raw.DomainKeyword[0] != "ads" {
t.Fatalf("keyword = %v, want [ads]", raw.DomainKeyword)
if len(hr.DomainKeyword) != 1 || hr.DomainKeyword[0] != "ads" {
t.Fatalf("keyword = %v, want [ads]", hr.DomainKeyword)
}
if len(raw.DomainSuffix) != 2 {
t.Fatalf("suffix = %v, want both apex.example and sub.example", raw.DomainSuffix)
if len(hr.DomainSuffix) != 2 {
t.Fatalf("suffix = %v, want both apex.example and sub.example", hr.DomainSuffix)
}
if findRouteRuleWithRuleSet(rt, "rs-m") == nil {
t.Fatalf("the rule must be emitted referencing rs-m; rules=%+v", rt.Rules)
}
}
// TestRuleBareEntryIsExactDomain pins the routing convention (bare == exact),
// which deliberately differs from the DNS/filter lists (bare == suffix).
func TestRuleBareEntryIsExactDomain(t *testing.T) {
rt, _ := genRules(t, model.Rule{
Name: "b", Enabled: true, Order: 10,
DstDomain: []string{"example.com"}, Target: "node:n1",
})
gen := generalRules(rt)
if len(gen) != 1 {
t.Fatalf("want 1 rule, got %d", len(gen))
// TestRulesetBareEntryIsDomainSuffix pins the convention a destination list now
// follows — and it is the OPPOSITE of the one the old dst_domain used.
//
// A bare entry in a routing rule meant one EXACT domain; a bare entry in a
// rule-set means the domain AND its subdomains (the DNS/filter/device convention,
// classifyDomainEntries with bareIsSuffix=true). `full:` is how an exact match is
// written now. model.migrate1to2 rewrites old dst_domain entries accordingly, so
// this asymmetry is the thing that migration has to get right.
func TestRulesetBareEntryIsDomainSuffix(t *testing.T) {
rt, _ := genRulesWithSets(t,
[]model.Ruleset{inlineDomainSet("b", "example.com", "full:exact.example")},
model.Rule{Name: "b", Enabled: true, Order: 10, DstRuleset: []string{"b"}, Target: "node:n1"},
)
rs, ok := ruleSetByTag(rt, "rs-b")
if !ok {
t.Fatalf("rs-b not emitted; route=%+v", rt)
}
raw := gen[0].DefaultOptions.RawDefaultRule
if len(raw.Domain) != 1 || raw.Domain[0] != "example.com" {
t.Fatalf("bare entry must be an exact Domain, got domain=%v suffix=%v", raw.Domain, raw.DomainSuffix)
hr := rs.InlineOptions.Rules[0].DefaultOptions
if len(hr.DomainSuffix) != 1 || hr.DomainSuffix[0] != "example.com" {
t.Fatalf("bare entry must become a domain_suffix, got suffix=%v domain=%v", hr.DomainSuffix, hr.Domain)
}
if len(raw.DomainSuffix) != 0 {
t.Fatalf("bare entry must NOT become a suffix, got %v", raw.DomainSuffix)
if len(hr.Domain) != 1 || hr.Domain[0] != "exact.example" {
t.Fatalf("full: must be the exact form, got domain=%v", hr.Domain)
}
}
+35 -14
View File
@@ -18,10 +18,13 @@ import (
)
// TestMalformedMatchersStillApply drives ONE model carrying every previously
// fatal matcher — a geosite: domain, a bad ip_cidr, a bad source cidr, an
// fatal matcher — a geosite: entry, a bad ip_cidr, a bad source cidr, an
// uncompilable regexp and a malformed port range — plus a healthy rule, through
// engine.Apply. It must come up, the healthy rule must survive, and the
// kill-switch backstop must stay closed.
// kill-switch backstop must stay closed. The destination lists are inline
// `config ruleset`s (schema v2), which is where the domain/IP guards live now;
// a rule-set left with no usable entry is skipped, and so is the rule whose only
// matcher it was.
func TestMalformedMatchersStillApply(t *testing.T) {
g := model.DefaultGlobals()
g.KillSwitch = "closed"
@@ -29,13 +32,19 @@ func TestMalformedMatchersStillApply(t *testing.T) {
Globals: g,
Inbounds: []model.Inbound{{Name: "lan", Enabled: true, Type: "tproxy", TproxyPort: 12395, TCP: true, UDP: true}},
Nodes: []model.Node{{Name: "n1", Enabled: true, URI: "ss://aes-256-gcm:secret@203.0.113.1:8388#n1"}},
Rulesets: []model.Ruleset{
inlineDomainSet("geosite", "geosite:youtube"),
inlineIPSet("badip", "999.1.1.1/24"),
inlineDomainSet("badre", "regexp:*broken("),
inlineDomainSet("ok", "ok.example"),
},
Rules: []model.Rule{
{Name: "geosite", Enabled: true, Order: 1, DstDomain: []string{"geosite:youtube"}, Target: "node:n1"},
{Name: "badip", Enabled: true, Order: 2, DstIP: []string{"999.1.1.1/24"}, Target: "node:n1"},
{Name: "geosite", Enabled: true, Order: 1, DstRuleset: []string{"geosite"}, Target: "node:n1"},
{Name: "badip", Enabled: true, Order: 2, DstRuleset: []string{"badip"}, Target: "node:n1"},
{Name: "badsrc", Enabled: true, Order: 3, Src: []string{"192.168.0.0/99"}, Target: "node:n1"},
{Name: "badre", Enabled: true, Order: 4, DstDomain: []string{"regexp:*broken("}, Target: "node:n1"},
{Name: "badre", Enabled: true, Order: 4, DstRuleset: []string{"badre"}, Target: "node:n1"},
{Name: "badport", Enabled: true, Order: 5, DstPort: "a-b", Target: "node:n1"},
{Name: "healthy", Enabled: true, Order: 6, DstDomain: []string{"ok.example"}, DstPort: "443", Target: "node:n1"},
{Name: "healthy", Enabled: true, Order: 6, DstRuleset: []string{"ok"}, DstPort: "443", Target: "node:n1"},
},
}
@@ -46,14 +55,21 @@ func TestMalformedMatchersStillApply(t *testing.T) {
if opts.Route.Final != tagBlock {
t.Fatalf("Final = %q, want block", opts.Route.Final)
}
if !hasDomainRule(opts.Route, "ok.example") {
if !hasRulesetRule(opts.Route, "ok") {
t.Fatalf("the healthy rule must survive alongside the malformed ones")
}
if !hasRouteToOutbound(opts, "n1") {
t.Fatalf("expected a route rule to n1")
}
// Every malformed matcher reported itself rather than failing silently.
for _, want := range []string{"geosite matcher", "is not a valid IP/CIDR", "domain regexp", "is not a valid port/range"} {
for _, want := range []string{
"unrecognised prefix", // geosite: in a domain list
"no usable entries", // ...leaving that list empty
"bad ip_cidr entry", // 999.1.1.1/24
"is not a valid IP/CIDR", // the source cidr
"domain regexp", // regexp:*broken(
"is not a valid port/range", // a-b
} {
if !routeWarnsHave(warns, want) {
t.Fatalf("missing diagnostic %q in %v", want, warns)
}
@@ -155,10 +171,15 @@ func TestRuleKillPoliciesApply(t *testing.T) {
Inbounds: []model.Inbound{{Name: "lan", Enabled: true, Type: "tproxy", TproxyPort: 12398, TCP: true, UDP: true}},
Nodes: []model.Node{{Name: "dead", Enabled: false, URI: "ss://aes-256-gcm:secret@203.0.113.1:8388#dead"}},
Groups: []model.Group{{Name: "grp", Strategy: "leastping", Nodes: []string{"dead"}}},
Rulesets: []model.Ruleset{
inlineDomainSet("k-closed", "closed.example"),
inlineDomainSet("k-open", "open.example"),
inlineDomainSet("k-default", "default.example"),
},
Rules: []model.Rule{
{Name: "k-closed", Enabled: true, Order: 1, DstDomain: []string{"closed.example"}, Target: "group:grp", Kill: "closed"},
{Name: "k-open", Enabled: true, Order: 2, DstDomain: []string{"open.example"}, Target: "group:grp", Kill: "open"},
{Name: "k-default", Enabled: true, Order: 3, DstDomain: []string{"default.example"}, Target: "group:grp"},
{Name: "k-closed", Enabled: true, Order: 1, DstRuleset: []string{"k-closed"}, Target: "group:grp", Kill: "closed"},
{Name: "k-open", Enabled: true, Order: 2, DstRuleset: []string{"k-open"}, Target: "group:grp", Kill: "open"},
{Name: "k-default", Enabled: true, Order: 3, DstRuleset: []string{"k-default"}, Target: "group:grp"},
},
}
@@ -166,13 +187,13 @@ func TestRuleKillPoliciesApply(t *testing.T) {
if !changed {
t.Fatalf("expected Apply changed==true (warnings: %v)", warns)
}
if got, ok := domainRuleTarget(opts.Route, "closed.example"); !ok || got != tagBlock {
if got, ok := rulesetRuleTarget(opts.Route, "k-closed"); !ok || got != tagBlock {
t.Fatalf("kill=closed must emit a rule routed to block, got %q (ok=%v)", got, ok)
}
if got, ok := domainRuleTarget(opts.Route, "open.example"); !ok || got != tagDirect {
if got, ok := rulesetRuleTarget(opts.Route, "k-open"); !ok || got != tagDirect {
t.Fatalf("kill=open must emit a rule routed to direct, got %q (ok=%v)", got, ok)
}
if got, ok := domainRuleTarget(opts.Route, "default.example"); !ok || got != tagBlock {
if got, ok := rulesetRuleTarget(opts.Route, "k-default"); !ok || got != tagBlock {
t.Fatalf("kill=default must emit a rule routed to block, got %q (ok=%v)", got, ok)
}
if opts.Route.Final != tagBlock {
+209 -85
View File
@@ -45,6 +45,40 @@ func genRules(t *testing.T, rules ...model.Rule) (*option.RouteOptions, []string
return opts.Route, warns
}
// ruleModelWithSets is ruleModel plus the `config ruleset` definitions the rules'
// DstRuleset entries point at. A rule's ONLY destination matcher is a rule-set
// (schema v2), so every case below that just needs "some destination the engine
// can match on" declares one here rather than writing an inline domain/IP list.
func ruleModelWithSets(sets []model.Ruleset, rules ...model.Rule) *model.Model {
m := ruleModel(rules...)
m.Rulesets = sets
return m
}
// genRulesWithSets is genRules for a model that also carries rule-sets.
func genRulesWithSets(t *testing.T, sets []model.Ruleset, rules ...model.Rule) (*option.RouteOptions, []string) {
t.Helper()
opts, warns, err := GenerateWithWarnings(ruleModelWithSets(sets, rules...))
if err != nil {
t.Fatalf("Generate: %v", err)
}
return opts.Route, warns
}
// inlineDomainSet builds an inline DOMAIN `config ruleset`.
//
// Mind the convention the move to rule-sets brought with it: a BARE entry here is
// a DomainSuffix (the apex AND its subdomains), whereas the routing rule's old
// dst_domain read a bare entry as an EXACT domain. `full:` is the exact form.
func inlineDomainSet(name string, entries ...string) model.Ruleset {
return model.Ruleset{Name: name, Type: "domain", Source: "inline", Entries: entries}
}
// inlineIPSet builds an inline IP-RANGE `config ruleset`.
func inlineIPSet(name string, entries ...string) model.Ruleset {
return model.Ruleset{Name: name, Type: "ipcidr", Source: "inline", Entries: entries}
}
func routeWarnsHave(warns []string, substr string) bool {
for _, w := range warns {
if strings.Contains(w, substr) {
@@ -68,69 +102,117 @@ func generalRules(rt *option.RouteOptions) []option.Rule {
// --- geosite: the landmine ---------------------------------------------------
// TestGeositeMatcherIsInertNotFatal: route-rule `geosite` was REMOVED from this
// engine — route/rule.NewDefaultRule returns "geosite database is deprecated ...
// removed in sing-box 1.12.0" for a non-empty Geosite list, and that error aborts
// box.New for the whole config. A `geosite:` entry must therefore be warned and
// omitted (exactly like the geoip matcher below), never emitted.
func TestGeositeMatcherIsInertNotFatal(t *testing.T) {
rt, warns := genRules(t, model.Rule{
Name: "geo", Enabled: true, Order: 10,
DstDomain: []string{"geosite:youtube"}, Target: "node:n1",
})
// TestGeositeEntryInRulesetIsInertNotFatal: route-rule `geosite` was REMOVED from
// this engine — route/rule.NewDefaultRule returns "geosite database is deprecated
// ... removed in sing-box 1.12.0" for a non-empty Geosite list, and that error
// aborts box.New for the whole config. Destinations now live in a `config
// ruleset`, so a `geosite:` entry lands in an inline domain list, where it is an
// unrecognised `word:` prefix: dropped by the classifier, reported, and — being
// the list's only entry — leaving the rule-set with nothing to match, so it is
// skipped and the rule that referenced it is not emitted either. Nothing about
// that path may ever put a value in RawDefaultRule.Geosite. (`source=geosite` on
// the ruleset itself is the working way to route a category; see
// TestRoutingRuleSetGeositeCategory.)
func TestGeositeEntryInRulesetIsInertNotFatal(t *testing.T) {
rt, warns := genRulesWithSets(t,
[]model.Ruleset{inlineDomainSet("geo", "geosite:youtube")},
model.Rule{Name: "geo", Enabled: true, Order: 10, DstRuleset: []string{"geo"}, Target: "node:n1"},
)
for _, r := range rt.Rules {
if len(r.DefaultOptions.RawDefaultRule.Geosite) > 0 {
t.Fatalf("geosite must never be emitted (aborts box.New), got %v", r.DefaultOptions.RawDefaultRule.Geosite)
}
}
if !routeWarnsHave(warns, "geosite matcher") {
t.Fatalf("expected an inert-geosite warning, got %v", warns)
if _, ok := ruleSetByTag(rt, "rs-geo"); ok {
t.Fatalf("a rule-set with no usable entry must not be emitted (an empty list would match everything)")
}
if findRouteRuleWithRuleSet(rt, "rs-geo") != nil {
t.Fatalf("no rule may reference the skipped rule-set")
}
if !routeWarnsHave(warns, "unrecognised prefix") {
t.Fatalf("expected an unrecognised-prefix warning for geosite:, got %v", warns)
}
if !routeWarnsHave(warns, "no usable entries") {
t.Fatalf("expected a no-usable-entries warning, got %v", warns)
}
}
// TestGeositeMixedWithRealDomainKeepsTheRest: a rule carrying BOTH a geosite entry
// and a real domain keeps the real matcher and still routes — only the geosite
// part is dropped.
// TestGeositeMixedWithRealDomainKeepsTheRest: a rule-set carrying BOTH a geosite
// entry and a real domain keeps the real matcher, and the rule referencing it
// still routes — only the geosite entry is dropped.
func TestGeositeMixedWithRealDomainKeepsTheRest(t *testing.T) {
rt, warns := genRules(t, model.Rule{
Name: "mixed", Enabled: true, Order: 10,
DstDomain: []string{"geosite:youtube", "example.com"}, Target: "node:n1",
})
gen := generalRules(rt)
if len(gen) != 1 {
t.Fatalf("want 1 general rule, got %d (warnings %v)", len(gen), warns)
rt, warns := genRulesWithSets(t,
[]model.Ruleset{inlineDomainSet("mixed", "geosite:youtube", "full:example.com")},
model.Rule{Name: "mixed", Enabled: true, Order: 10, DstRuleset: []string{"mixed"}, Target: "node:n1"},
)
rs, ok := ruleSetByTag(rt, "rs-mixed")
if !ok {
t.Fatalf("rs-mixed must survive the geosite entry (warnings %v)", warns)
}
raw := gen[0].DefaultOptions.RawDefaultRule
if len(raw.Geosite) != 0 {
t.Fatalf("geosite leaked: %v", raw.Geosite)
hr := rs.InlineOptions.Rules[0].DefaultOptions
if len(hr.Domain) != 1 || hr.Domain[0] != "example.com" {
t.Fatalf("real domain matcher lost: %+v", hr)
}
if len(raw.Domain) != 1 || raw.Domain[0] != "example.com" {
t.Fatalf("real domain matcher lost: %+v", raw.Domain)
if len(hr.DomainSuffix)+len(hr.DomainKeyword)+len(hr.DomainRegex) != 0 {
t.Fatalf("geosite: must be dropped, not reinterpreted: %+v", hr)
}
if got := gen[0].DefaultOptions.RuleAction.RouteOptions.Outbound; got != "n1" {
dr := findRouteRuleWithRuleSet(rt, "rs-mixed")
if dr == nil {
t.Fatalf("the rule must be emitted referencing rs-mixed; rules=%+v", rt.Rules)
}
if got := dr.RuleAction.RouteOptions.Outbound; got != "n1" {
t.Fatalf("target = %q, want n1", got)
}
}
// TestGeoipEntryInRulesetIsInertNotFatal is the IP-side twin: model.migrate1to2
// moves an old `dst_ip geoip:ru` verbatim into an inline type=ipcidr rule-set
// (deliberately — see TestMigrate1to2KeepsGeoMarkersInert: it must not silently
// become a working geoip source, because the operator never asked to download
// anything). Here it is an unparseable prefix, so it must be dropped LOUDLY
// rather than reach NewIPCIDRItem, which errors and aborts box.New.
func TestGeoipEntryInRulesetIsInertNotFatal(t *testing.T) {
rt, warns := genRulesWithSets(t,
[]model.Ruleset{inlineIPSet("geo", "geoip:ru")},
model.Rule{Name: "geo", Enabled: true, Order: 10, DstRuleset: []string{"geo"}, Target: "node:n1"},
)
for _, r := range rt.Rules {
if len(r.DefaultOptions.RawDefaultRule.GeoIP) > 0 {
t.Fatalf("geoip must never be emitted, got %v", r.DefaultOptions.RawDefaultRule.GeoIP)
}
}
if _, ok := ruleSetByTag(rt, "rs-geo"); ok {
t.Fatalf("a list whose only entry is unparseable must not materialise")
}
if !routeWarnsHave(warns, `bad ip_cidr entry "geoip:ru"`) {
t.Fatalf("expected a bad-ip_cidr warning naming the entry, got %v", warns)
}
}
// --- malformed matchers that used to abort box.New ---------------------------
// TestBadDstCIDRWarnsAndSkips: an unparseable ip_cidr makes
// route/rule.NewIPCIDRItem error, which aborts box.New. It must be dropped.
func TestBadDstCIDRWarnsAndSkips(t *testing.T) {
rt, warns := genRules(t, model.Rule{
Name: "bad", Enabled: true, Order: 10,
DstIP: []string{"999.1.1.1/24", "198.51.100.0/24"}, Target: "node:n1",
})
gen := generalRules(rt)
if len(gen) != 1 {
t.Fatalf("want 1 general rule, got %d", len(gen))
// TestRulesetBadIPCIDREntryWarnsAndSkips: an unparseable ip_cidr makes
// route/rule.NewIPCIDRItem error, which aborts box.New. Destination addresses are
// an inline `type=ipcidr` rule-set now, so the guard lives in inlineRulesetRule:
// the typo is dropped, the valid entry survives and the rule still routes.
func TestRulesetBadIPCIDREntryWarnsAndSkips(t *testing.T) {
rt, warns := genRulesWithSets(t,
[]model.Ruleset{inlineIPSet("bad", "999.1.1.1/24", "198.51.100.0/24")},
model.Rule{Name: "bad", Enabled: true, Order: 10, DstRuleset: []string{"bad"}, Target: "node:n1"},
)
rs, ok := ruleSetByTag(rt, "rs-bad")
if !ok {
t.Fatalf("one bad entry must not take the whole list down (warnings %v)", warns)
}
raw := gen[0].DefaultOptions.RawDefaultRule
if len(raw.IPCIDR) != 1 || raw.IPCIDR[0] != "198.51.100.0/24" {
t.Fatalf("ip_cidr = %v, want only the valid entry", raw.IPCIDR)
got := rs.InlineOptions.Rules[0].DefaultOptions.IPCIDR
if len(got) != 1 || got[0] != "198.51.100.0/24" {
t.Fatalf("ip_cidr = %v, want only the valid entry", got)
}
if !routeWarnsHave(warns, `destination "999.1.1.1/24" is not a valid IP/CIDR`) {
t.Fatalf("expected a bad-destination warning, got %v", warns)
if !routeWarnsHave(warns, `bad ip_cidr entry "999.1.1.1/24"`) {
t.Fatalf("expected a bad-ip_cidr warning, got %v", warns)
}
if findRouteRuleWithRuleSet(rt, "rs-bad") == nil {
t.Fatalf("the rule must still be emitted referencing rs-bad; rules=%+v", rt.Rules)
}
}
@@ -152,24 +234,30 @@ func TestBadSrcCIDRWarnsAndSkips(t *testing.T) {
}
}
// TestBadDomainRegexWarnsAndSkips: an uncompilable `regexp:` pattern makes
// route/rule.NewDomainRegexItem error and abort box.New.
func TestBadDomainRegexWarnsAndSkips(t *testing.T) {
rt, warns := genRules(t, model.Rule{
Name: "bad", Enabled: true, Order: 10,
DstDomain: []string{"regexp:*broken(", `regexp:^ok\.example$`}, Target: "node:n1",
})
gen := generalRules(rt)
if len(gen) != 1 {
t.Fatalf("want 1 general rule, got %d", len(gen))
// TestRulesetBadDomainRegexWarnsAndSkips: an uncompilable `regexp:` pattern makes
// route/rule.NewDomainRegexItem error and abort box.New. The pattern vocabulary
// moved into the inline rule-set with the rest of the destination list, so the
// validation moved with it (peelDomainRegexes): the broken pattern is dropped and
// the compilable one survives.
func TestRulesetBadDomainRegexWarnsAndSkips(t *testing.T) {
rt, warns := genRulesWithSets(t,
[]model.Ruleset{inlineDomainSet("bad", "regexp:*broken(", `regexp:^ok\.example$`)},
model.Rule{Name: "bad", Enabled: true, Order: 10, DstRuleset: []string{"bad"}, Target: "node:n1"},
)
rs, ok := ruleSetByTag(rt, "rs-bad")
if !ok {
t.Fatalf("one broken pattern must not take the whole list down (warnings %v)", warns)
}
got := gen[0].DefaultOptions.RawDefaultRule.DomainRegex
got := rs.InlineOptions.Rules[0].DefaultOptions.DomainRegex
if len(got) != 1 || got[0] != `^ok\.example$` {
t.Fatalf("domain_regex = %v, want only the compilable one", got)
}
if !routeWarnsHave(warns, "domain regexp") {
t.Fatalf("expected a bad-regexp warning, got %v", warns)
}
if findRouteRuleWithRuleSet(rt, "rs-bad") == nil {
t.Fatalf("the rule must still be emitted referencing rs-bad; rules=%+v", rt.Rules)
}
}
// TestBadPortRangeWarnsAndSkips: a malformed range reaches
@@ -911,10 +999,12 @@ func TestRuleProtoKnownValuesAreSilent(t *testing.T) {
// that would break existing configs either open or closed), but the widening is
// now reported with its consequence.
func TestIfaceOnlySourceRuleIsNotSilentlyNetworkWide(t *testing.T) {
rt, warns := genRules(t, model.Rule{
Name: "guest", Enabled: true, Order: 10,
Src: []string{"iface:guest"}, DstDomain: []string{"youtube.com"}, Target: "block",
})
rt, warns := genRulesWithSets(t,
[]model.Ruleset{inlineDomainSet("yt", "youtube.com")},
model.Rule{
Name: "guest", Enabled: true, Order: 10,
Src: []string{"iface:guest"}, DstRuleset: []string{"yt"}, Target: "block",
})
if !routeWarnsHave(warns, "applies to EVERY client") {
t.Fatalf("expected a rule-widening warning, got %v", warns)
}
@@ -930,11 +1020,13 @@ func TestIfaceOnlySourceRuleIsNotSilentlyNetworkWide(t *testing.T) {
// TestMixedSourceRuleReportsTheDroppedHalf: with one usable IP source alongside
// an unmatchable one, the rule narrows to the IP source only.
func TestMixedSourceRuleReportsTheDroppedHalf(t *testing.T) {
_, warns := genRules(t, model.Rule{
Name: "mixed", Enabled: true, Order: 10,
Src: []string{"iface:guest", "aa:bb:cc:dd:ee:ff", "192.168.5.0/24"},
DstDomain: []string{"youtube.com"}, Target: "block",
})
_, warns := genRulesWithSets(t,
[]model.Ruleset{inlineDomainSet("yt", "youtube.com")},
model.Rule{
Name: "mixed", Enabled: true, Order: 10,
Src: []string{"iface:guest", "aa:bb:cc:dd:ee:ff", "192.168.5.0/24"},
DstRuleset: []string{"yt"}, Target: "block",
})
if !routeWarnsHave(warns, "could not be used") {
t.Fatalf("expected a dropped-source warning, got %v", warns)
}
@@ -1041,41 +1133,73 @@ func TestRuleTargetKindsResolve(t *testing.T) {
}
}
// TestRuleDomainUnrecognisedPrefixWarns: an unknown `word:` prefix is dropped by
// the shared classifier (a domain cannot contain ":"), so the rule silently lost
// that destination. `domain:example.com` is the v0.1/xray spelling a migrating
// user writes, and it must not disappear without a trace.
func TestRuleDomainUnrecognisedPrefixWarns(t *testing.T) {
// TestRulesetDomainUnrecognisedPrefixWarns: an unknown `word:` prefix is dropped
// by the shared classifier (a domain cannot contain ":"), so the list silently
// lost that destination. `domain:example.com` is the v0.1/xray spelling a
// migrating user writes, and it must not disappear without a trace — the more so
// now that a destination list is ALWAYS a rule-set, i.e. the one place a typo can
// hide.
func TestRulesetDomainUnrecognisedPrefixWarns(t *testing.T) {
for _, entry := range []string{"domain:example.com", "regex:example.com", "ext:foo.dat:cn"} {
rt, warns := genRules(t, model.Rule{
Name: "mig", Enabled: true, Order: 10,
DstDomain: []string{entry, "keep.example"}, Target: "block",
})
rt, warns := genRulesWithSets(t,
[]model.Ruleset{inlineDomainSet("mig", entry, "keep.example")},
model.Rule{Name: "mig", Enabled: true, Order: 10, DstRuleset: []string{"mig"}, Target: "block"},
)
if !routeWarnsHave(warns, "unrecognised prefix") {
t.Fatalf("%q: expected an unrecognised-prefix warning, got %v", entry, warns)
}
gen := generalRules(rt)
if len(gen) != 1 {
t.Fatalf("%q: want 1 rule, got %d", entry, len(gen))
rs, ok := ruleSetByTag(rt, "rs-mig")
if !ok {
t.Fatalf("%q: the usable entry must keep the rule-set alive; warnings %v", entry, warns)
}
for _, d := range gen[0].DefaultOptions.RawDefaultRule.Domain {
if strings.Contains(d, ":") {
t.Fatalf("%q: a prefixed literal reached the matcher: %q", entry, d)
hr := rs.InlineOptions.Rules[0].DefaultOptions
for _, list := range [][]string{hr.Domain, hr.DomainSuffix, hr.DomainKeyword, hr.DomainRegex} {
for _, d := range list {
if strings.Contains(d, ":") {
t.Fatalf("%q: a prefixed literal reached the matcher: %q", entry, d)
}
}
}
if findRouteRuleWithRuleSet(rt, "rs-mig") == nil {
t.Fatalf("%q: the rule must still be emitted; rules=%+v", entry, rt.Rules)
}
}
}
// TestRuleDomainKnownPrefixesAreSilent guards the warning against false
// positives on the vocabulary routing rules really support.
func TestRuleDomainKnownPrefixesAreSilent(t *testing.T) {
_, warns := genRules(t, model.Rule{
Name: "ok", Enabled: true, Order: 10, Target: "block",
DstDomain: []string{"full:a.example", "suffix:b.example", "keyword:c", `regexp:^d\.`, ".e.example", "f.example"},
})
// TestRulesetDomainKnownPrefixesAreSilent guards the warning against false
// positives on the vocabulary an inline domain rule-set really supports.
//
// `regexp:` is the load-bearing case: the shared classifier has no branch for it,
// so it WOULD be reported as an unknown prefix — inlineRulesetRule peels the
// regexes off first (peelDomainRegexes) precisely so it is not. A regression there
// would both warn about a working matcher and drop it.
func TestRulesetDomainKnownPrefixesAreSilent(t *testing.T) {
rt, warns := genRulesWithSets(t,
[]model.Ruleset{inlineDomainSet("ok",
"full:a.example", "suffix:b.example", "keyword:c", `regexp:^d\.`, ".e.example", "f.example")},
model.Rule{Name: "ok", Enabled: true, Order: 10, DstRuleset: []string{"ok"}, Target: "block"},
)
if routeWarnsHave(warns, "unrecognised prefix") {
t.Fatalf("the supported prefixes must not warn: %v", warns)
}
rs, ok := ruleSetByTag(rt, "rs-ok")
if !ok {
t.Fatalf("rs-ok not emitted; warnings %v", warns)
}
hr := rs.InlineOptions.Rules[0].DefaultOptions
if len(hr.Domain) != 1 || hr.Domain[0] != "a.example" {
t.Fatalf("full: must be an exact Domain, got %v", hr.Domain)
}
if len(hr.DomainRegex) != 1 || hr.DomainRegex[0] != `^d\.` {
t.Fatalf("regexp: must survive as a domain_regex matcher, got %v", hr.DomainRegex)
}
// suffix:, the leading dot and the BARE entry all collapse to domain_suffix.
if len(hr.DomainSuffix) != 3 {
t.Fatalf("domain_suffix = %v, want b/e/f.example (bare entry is a suffix in a rule-set)", hr.DomainSuffix)
}
if len(hr.DomainKeyword) != 1 || hr.DomainKeyword[0] != "c" {
t.Fatalf("domain_keyword = %v, want [c]", hr.DomainKeyword)
}
}
// TestDeviceDomainUnrecognisedPrefixWarns is the same guarantee in the place it
+63 -3
View File
@@ -25,6 +25,7 @@ import (
"net/netip"
"os"
"path/filepath"
"regexp"
"runtime/debug"
"strings"
"sync"
@@ -1163,7 +1164,8 @@ func (b *builder) buildRoutingRuleSetRaw(rs model.Ruleset) ([]option.RuleSet, []
// its Entries, keyed by Type: an ipcidr ruleset fills ip_cidr; a domain ruleset
// (the default) is classified with inlineDomainRule — bare entry => DomainSuffix
// (so subdomains match), full: => Domain, keyword: => DomainKeyword, . => suffix
// — the same classification the DNS filter uses. ok=false when nothing usable.
// — the same classification the DNS filter uses, plus `regexp:` (see
// peelDomainRegexes). ok=false when nothing usable.
func (b *builder) inlineRulesetRule(rs model.Ruleset) (option.DefaultHeadlessRule, bool) {
b.warnUnknownRuleSetType(fmt.Sprintf("ruleset %q", rs.Name), rs.Type)
if ruleSetTypeIsIPCIDR(rs.Type) {
@@ -1190,10 +1192,68 @@ func (b *builder) inlineRulesetRule(rs model.Ruleset) (option.DefaultHeadlessRul
return option.DefaultHeadlessRule{IPCIDR: badoption.Listable[string](cidrs)}, true
}
// "domain" (and empty, and anything unrecognised => domain, warned above).
// `regexp:` is peeled off first: it is a routing-rule matcher the DNS-filter
// classifier does not know, and it must not be reported as an unknown prefix.
diag := fmt.Sprintf("ruleset %q", rs.Name)
rest, regexes := b.peelDomainRegexes(diag, rs.Entries)
// R4, on the path every destination list now takes. classifyDomainEntries
// DROPS an entry that is nothing but its marker (".", "full:", "keyword:"),
// silently — and the silence is the dangerous half: an empty domain token
// aborts box.New for the whole config, and an empty keyword is
// strings.Contains(host, "") i.e. EVERY host. The drop is right; not saying so
// is not. (devices.go reports the same class for a device's own lists; the bare
// `regexp:` form is reported by peelDomainRegexes above, which is why it is
// peeled off before this loop and cannot be double-reported.)
for _, e := range rest {
if isDomainMarkerOnly(e) {
b.warnf("%s: entry %q is a bare matcher marker with no value, omitted (an empty domain token aborts box.New; an empty keyword would match EVERY host)", diag, strings.TrimSpace(e))
}
}
// Only the DOMAIN branch reports unknown `word:` prefixes — the ipcidr branch
// above is full of legitimate colons (IPv6) and must never be checked (R9.2).
b.warnUnrecognisedPrefixes(fmt.Sprintf("ruleset %q", rs.Name), rs.Entries)
return inlineDomainRule(rs.Entries)
b.warnUnrecognisedPrefixes(diag, rest)
hr, ok := inlineDomainRule(rest)
if len(regexes) > 0 {
hr.DomainRegex = badoption.Listable[string](regexes)
ok = true
}
return hr, ok
}
// peelDomainRegexes splits `regexp:<pattern>` entries out of a domain rule-set's
// entry list, returning the remaining entries and the validated patterns.
//
// It exists because a destination list is now ALWAYS a rule-set (schema v2), so
// every matcher a `dst_domain` used to express has to be expressible here —
// including the regex form, which the shared DNS-filter classifier
// (classifyDomainEntries) deliberately does not know about. Validation mirrors
// what the routing rule did before the move, and for the same reason:
// route/rule.NewDomainRegexItem returns an error for an uncompilable pattern and
// that aborts box.New for the WHOLE config, so a bad pattern must degrade to a
// warning. A BARE `regexp:` compiles fine but matches every host — the same
// silent match-all hazard as an empty keyword — so it is dropped too.
func (b *builder) peelDomainRegexes(diag string, entries []string) (rest, regexes []string) {
for _, e := range entries {
e = strings.TrimSpace(e)
if e == "" {
continue
}
if !strings.HasPrefix(strings.ToLower(e), "regexp:") {
rest = append(rest, e)
continue
}
re := strings.TrimSpace(e[len("regexp:"):])
if re == "" {
b.warnf("%s: %q is a bare matcher marker with no value, omitted (an empty regexp matches EVERY host)", diag, e)
continue
}
if _, err := regexp.Compile(re); err != nil {
b.warnf("%s: domain regexp %q is invalid (%v), omitted", diag, re, err)
continue
}
regexes = append(regexes, re)
}
return rest, regexes
}
// Ruleset.Type — the two shapes a rule-set can match, and the accepted spellings.
+52
View File
@@ -33,6 +33,14 @@ func findRouteRuleWithRuleSet(rt *option.RouteOptions, tag string) *option.Defau
return nil
}
// hasRulesetRule reports whether any emitted route rule references the rule-set
// named name (tag rs-<name>). Since schema v2 a rule's destination is ALWAYS a
// rule-set reference, so this is how a test says "that rule was emitted" — the
// former "does any rule carry this dst domain" question has no answer any more.
func hasRulesetRule(rt *option.RouteOptions, name string) bool {
return findRouteRuleWithRuleSet(rt, routeRulesetTagPrefix+name) != nil
}
func ruleSetByTag(rt *option.RouteOptions, tag string) (option.RuleSet, bool) {
if rt == nil {
return option.RuleSet{}, false
@@ -117,6 +125,50 @@ func TestRoutingRuleSetInlineDomain(t *testing.T) {
}
}
// TestRoutingRuleSetInlineDomainRegex: `regexp:` is a matcher the shared domain
// classifier does NOT know — it belongs to the routing plane, and it used to be
// peeled off inside ruleMatchers, which no longer sees any domains at all. It
// therefore had to move into the inline rule-set with the rest of the destination
// vocabulary (peelDomainRegexes), or every migrated `regexp:` entry would have
// been reported as an unknown prefix and silently dropped: a routing rule that
// looks configured and matches nothing.
func TestRoutingRuleSetInlineDomainRegex(t *testing.T) {
m := &model.Model{
Globals: model.DefaultGlobals(),
Rulesets: []model.Ruleset{
{Name: "ads", Type: "domain", Source: "inline", Entries: []string{`regexp:^ads\.`}},
},
Rules: []model.Rule{
{Name: "block-ads", Enabled: true, Order: 10, DstRuleset: []string{"ads"}, Target: "block"},
},
}
opts, warns, err := GenerateWithWarnings(m)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if len(warns) != 0 {
t.Fatalf("a valid regexp: entry must not warn: %v", warns)
}
rs, ok := ruleSetByTag(opts.Route, "rs-ads")
if !ok {
t.Fatalf("a regexp-only rule-set must still materialise; route=%+v", opts.Route)
}
hr := rs.InlineOptions.Rules[0].DefaultOptions
if len(hr.DomainRegex) != 1 || hr.DomainRegex[0] != `^ads\.` {
t.Fatalf("domain_regex = %+v, want [^ads\\.]", hr.DomainRegex)
}
if len(hr.Domain)+len(hr.DomainSuffix)+len(hr.DomainKeyword)+len(hr.IPCIDR) != 0 {
t.Fatalf("the regexp entry must not leak into another matcher: %+v", hr)
}
dr := findRouteRuleWithRuleSet(opts.Route, "rs-ads")
if dr == nil {
t.Fatalf("no route rule references rs-ads; rules=%+v", opts.Route.Rules)
}
if dr.RuleAction.RouteOptions.Outbound != tagBlock {
t.Fatalf("route rule must route to %q, got %+v", tagBlock, dr.RuleAction)
}
}
// TestRoutingRuleSetInlineIPCIDR: an ipcidr ruleset fills ip_cidr (not domain*),
// and the route rule references it.
func TestRoutingRuleSetInlineIPCIDR(t *testing.T) {
+21 -28
View File
@@ -12,47 +12,36 @@ import (
"testing"
"time"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing-box/shater/model"
)
// scheduledDomain is the distinctive dst-domain matcher used to detect whether a
// scheduled rule survived into the generated route rules.
const scheduledDomain = "sched.example"
// scheduledSet is the distinctive destination rule-set used to detect whether a
// scheduled rule survived into the generated route rules. Since schema v2 a
// rule's destination is a rule-set reference, so this doubles as a check that
// buildRoutingRuleSets honours the same schedule gate buildRoute does: outside
// the window neither the rule nor its rs- rule-set may be emitted.
const scheduledSet = "sched"
// emittedAt reports whether the given scheduled rule is present in the route
// rules when generate's clock is `now`.
func emittedAt(now time.Time, r model.Rule) bool {
b := newBuilder(&model.Model{Globals: model.DefaultGlobals(), Rules: []model.Rule{r}})
b := newBuilder(&model.Model{
Globals: model.DefaultGlobals(),
Rulesets: []model.Ruleset{{Name: scheduledSet, Type: "domain", Source: "inline", Entries: []string{"sched.example"}}},
Rules: []model.Rule{r},
})
b.now = now
return hasDomainRule(b.buildRoute(), scheduledDomain)
return hasRulesetRule(b.buildRoute(), scheduledSet)
}
// hasDomainRule reports whether any route rule carries the given exact dst-domain
// matcher.
func hasDomainRule(rt *option.RouteOptions, domain string) bool {
if rt == nil {
return false
}
for _, r := range rt.Rules {
for _, d := range r.DefaultOptions.RawDefaultRule.Domain {
if d == domain {
return true
}
}
}
return false
}
// schedRule builds a scheduled dst-domain rule (target direct) from the schedule
// fields. It always carries the scheduledDomain matcher so emittedAt can find it.
// schedRule builds a scheduled destination rule (target direct) from the schedule
// fields. It always references the scheduledSet rule-set so emittedAt can find it.
func schedRule(days []string, start, end string) model.Rule {
return model.Rule{
Name: "sched",
Enabled: true,
Order: 10,
DstDomain: []string{scheduledDomain},
DstRuleset: []string{scheduledSet},
Target: "direct",
SchedEnabled: true,
SchedDays: days,
@@ -127,9 +116,13 @@ func TestScheduleAllDayWeekend(t *testing.T) {
// warning instead.
func TestScheduleInvalidTimeIsAlwaysOn(t *testing.T) {
rule := schedRule(nil, "9am", "17:00") // "9am" is not HH:MM
b := newBuilder(&model.Model{Globals: model.DefaultGlobals(), Rules: []model.Rule{rule}})
b := newBuilder(&model.Model{
Globals: model.DefaultGlobals(),
Rulesets: []model.Ruleset{{Name: scheduledSet, Type: "domain", Source: "inline", Entries: []string{"sched.example"}}},
Rules: []model.Rule{rule},
})
b.now = time.Date(2026, 7, 15, 3, 0, 0, 0, time.UTC) // 03:00 — would be OUTSIDE a 09–17 window
if !hasDomainRule(b.buildRoute(), scheduledDomain) {
if !hasRulesetRule(b.buildRoute(), scheduledSet) {
t.Errorf("invalid start time should fail OPEN (rule emitted always-on)")
}
if len(b.warnings) == 0 {
+259 -1
View File
@@ -14,17 +14,31 @@ import (
// CurrentSchemaVersion is the schema this build understands. Bump it when adding
// a migration step below.
const CurrentSchemaVersion = 1
const CurrentSchemaVersion = 2
// uciRunner abstracts uci get/set/delete/commit/import so migrations AND the
// config-write path (WriteUCI) are unit-testable. Import feeds `uci export`-format
// text to `uci import <pkg>` on stdin, replacing the package's staged sections.
//
// Export/Add/AddList exist for migrations that have to READ the config they are
// rewriting and GROW it. migrate1to2 needs both: it reads options the current
// Model no longer parses (`dst_domain`/`dst_ip` were removed from Rule) and adds
// the `config ruleset` sections it folds them into. Add returns the generated
// section id — the anonymous-index drift trap is real (`@ruleset[3]` means
// something different after one more add), so every write goes through the id.
type uciRunner interface {
Get(key string) (string, bool)
Set(key, val string) error
Delete(key string) error
Commit(pkg string) error
Import(pkg, text string) error
// Export returns the package in `uci export` format. ok=false when the package
// does not exist (nothing to migrate), which is NOT an error.
Export(pkg string) (text string, ok bool)
// Add appends an anonymous `config <secType>` and returns its section id.
Add(pkg, secType string) (id string, err error)
// AddList appends one value to a list option (`uci add_list <key>=<val>`).
AddList(key, val string) error
}
type execUCI struct{}
@@ -46,6 +60,30 @@ func (execUCI) Import(pkg, text string) error {
return cmd.Run()
}
func (execUCI) Export(pkg string) (string, bool) {
out, err := exec.Command("uci", "-q", "export", pkg).Output()
if err != nil {
return "", false
}
return string(out), true
}
func (execUCI) Add(pkg, secType string) (string, error) {
out, err := exec.Command("uci", "add", pkg, secType).Output()
if err != nil {
return "", fmt.Errorf("uci add %s %s: %w", pkg, secType, err)
}
id := strings.TrimSpace(string(out))
if id == "" {
return "", fmt.Errorf("uci add %s %s: no section id returned", pkg, secType)
}
return id, nil
}
func (execUCI) AddList(k, v string) error {
return exec.Command("uci", "add_list", k+"="+v).Run()
}
// uci is the active runner (overridable in tests).
var uci uciRunner = execUCI{}
@@ -56,6 +94,7 @@ type migration struct {
var migrations = []migration{
{from: 0, to: 1, apply: migrate0to1},
{from: 1, to: 2, apply: migrate1to2},
}
func readSchemaVersion(u uciRunner) int {
@@ -123,3 +162,222 @@ func migrate0to1(u uciRunner) error {
}
return u.Commit("shater")
}
// --- v1 -> v2: a rule's destination is a rule-set, never an inline list ------
//
// WHAT CHANGED. `config rule` lost `dst_domain` and `dst_ip`. A rule now names
// its destination through `dst_ruleset` only, so there is ONE destination
// mechanism, one matcher vocabulary, and one place a list is edited — and the
// list is compiled once into a .srs that every referencing rule shares.
//
// WHAT THIS STEP DOES. For every rule that still carries one of the two options
// it creates an inline `config ruleset` named `rule-<rule name>` (and
// `rule-<rule name>-ip` for the address list, because a rule-set is EITHER a
// domain list or an ip_cidr list), moves the entries into it, appends the new
// name to the rule's `dst_ruleset`, and deletes the legacy option. Nothing is
// dropped and nothing is guessed: a rule with both lists gets both rule-sets.
//
// ENTRY SEMANTICS ARE PRESERVED 1:1, and that needs one real conversion. The two
// contexts disagree about exactly one form: a BARE domain is an EXACT match in a
// routing rule (generate/route.go classified `dst_domain` with bareIsSuffix=false)
// and a SUFFIX match inside a rule-set (inlineDomainRule, bareIsSuffix=true).
// Copying `example.com` across verbatim would therefore silently widen the rule to
// every subdomain, so a bare entry is rewritten as `full:example.com`. Every other
// form already means the same thing on both sides and is copied byte-for-byte:
// `full:`, `suffix:`, `keyword:`, `regexp:` (see generate.peelDomainRegexes, added
// with this change so the regex form survives the move) and a leading dot, which
// is a synonym of `suffix:` in both. `geosite:`/`geoip:` entries are copied
// unchanged too: they are INERT in a routing rule on this engine (warned and
// omitted — the route-rule geosite/geoip fields no longer exist), and an
// unrecognised marker is equally inert inside a rule-set, so their meaning is
// unchanged and the operator's text is not thrown away. Converting them into a
// `source=geosite` rule-set would have made a dead matcher start routing traffic
// during an upgrade — a behaviour change, not a migration.
//
// ONE DELIBERATE SEMANTIC IMPROVEMENT, stated out loud: a rule that carried BOTH
// a domain list and an address list matched them with AND (an engine route rule
// ANDs its matcher fields), which is almost never what "these sites and these
// networks" was meant to say. The two generated rule-sets are ORed, because
// `rule_set: [a, b]` matches when EITHER matches. Such a rule matches more after
// the migration than before — it is called out here, in the docs, and it only
// affects configs that used both fields at once.
//
// IDEMPOTENCE. The legacy options are deleted as the last step per rule, so a
// second run finds nothing to do. A run interrupted between "create the rule-set"
// and "delete the option" is also safe: a rule that ALREADY references a rule-set
// of the expected name reuses it instead of creating `rule-<name>-2`.
func migrate1to2(u uciRunner) error {
text, ok := u.Export("shater")
if !ok || strings.TrimSpace(text) == "" {
return nil // no config yet (fresh install): nothing to migrate
}
secs, err := parseSections(text)
if err != nil {
return fmt.Errorf("read the current config: %w", err)
}
// Every rule-set name already in use, so a generated one can never collide with
// a hand-written list (which would make `uci` hold two `config ruleset` blocks
// claiming the same name, and the generator drops one as a duplicate tag).
taken := map[string]bool{}
for _, s := range secs {
if s.Type == "ruleset" {
if n := firstNonEmpty(s.opt("name"), s.Name); n != "" {
taken[n] = true
}
}
}
ruleIdx := -1
for _, s := range secs {
if s.Type != "rule" {
continue
}
// Anonymous sections are addressed positionally and the index is PER TYPE, so
// it counts rules only. Appending `config ruleset` sections below cannot shift
// it (a new section goes to the end, and it is not a rule).
ruleIdx++
domains := s.list("dst_domain")
ips := s.list("dst_ip")
if len(domains) == 0 && len(ips) == 0 {
continue
}
rulePath := fmt.Sprintf("shater.@rule[%d]", ruleIdx)
base := rulesetBaseName(firstNonEmpty(s.opt("name"), s.Name), ruleIdx)
refs := s.list("dst_ruleset")
if len(domains) > 0 {
entries := make([]string, 0, len(domains))
for _, d := range domains {
if e := migrateDomainEntry(d); e != "" {
entries = append(entries, e)
}
}
if err := ensureMigratedRuleset(u, rulePath, base, "domain", entries, refs, taken); err != nil {
return err
}
}
if len(ips) > 0 {
entries := make([]string, 0, len(ips))
for _, ip := range ips {
if v := strings.TrimSpace(ip); v != "" {
entries = append(entries, v)
}
}
if err := ensureMigratedRuleset(u, rulePath, base+"-ip", "ipcidr", entries, refs, taken); err != nil {
return err
}
}
// Last, so an interrupted run still has the legacy list to redo the work from.
_ = u.Delete(rulePath + ".dst_domain")
_ = u.Delete(rulePath + ".dst_ip")
}
return u.Commit("shater")
}
// ensureMigratedRuleset creates the inline `config ruleset` holding entries and
// points rulePath's dst_ruleset at it, unless the rule already references a
// rule-set of that name (a re-run after an interrupted migration). want is the
// preferred name; a collision with an existing list picks want-2, want-3, ...
// rsType is "domain" or "ipcidr". An entry list that came out empty creates
// nothing: an empty inline rule-set matches nothing and the generator would skip
// it, so a dangling reference would be pure noise.
func ensureMigratedRuleset(u uciRunner, rulePath, want, rsType string, entries, existingRefs []string, taken map[string]bool) error {
if len(entries) == 0 {
return nil
}
if taken[want] && containsString(existingRefs, want) {
return nil // already migrated (interrupted run); nothing to add
}
name := want
for i := 2; taken[name]; i++ {
name = fmt.Sprintf("%s-%d", want, i)
}
taken[name] = true
id, err := u.Add("shater", "ruleset")
if err != nil {
return err
}
sec := "shater." + id
if err := u.Set(sec+".name", name); err != nil {
return err
}
if err := u.Set(sec+".type", rsType); err != nil {
return err
}
if err := u.Set(sec+".source", "inline"); err != nil {
return err
}
for _, e := range entries {
if err := u.AddList(sec+".entry", e); err != nil {
return err
}
}
if containsString(existingRefs, name) {
return nil
}
return u.AddList(rulePath+".dst_ruleset", name)
}
// rulesetBaseName builds `rule-<name>` from a rule's name, reduced to characters
// that are safe in a rule-set name (it becomes an engine rule-set TAG, `rs-<name>`,
// and a UCI option value). An unnamed rule falls back to its position so two of
// them cannot produce the same base.
func rulesetBaseName(ruleName string, idx int) string {
var b strings.Builder
prevDash := false
for _, r := range strings.TrimSpace(ruleName) {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '.':
b.WriteRune(r)
prevDash = false
default:
if !prevDash && b.Len() > 0 {
b.WriteByte('-')
prevDash = true
}
}
}
slug := strings.Trim(b.String(), "-.")
if slug == "" {
slug = strconv.Itoa(idx)
}
return "rule-" + slug
}
// migrateDomainEntry rewrites ONE `dst_domain` entry into the rule-set spelling
// with the same meaning. Only the bare form differs between the two contexts
// (exact in a rule, suffix in a rule-set), so only it is rewritten; see the
// migrate1to2 doc comment for the full table and the reasoning.
func migrateDomainEntry(e string) string {
v := strings.TrimSpace(e)
if v == "" {
return ""
}
// A leading dot already means `suffix:` on both sides.
if strings.HasPrefix(v, ".") {
return v
}
// Any `word:` marker — recognised (full/suffix/keyword/regexp) or not
// (geosite/geoip/typos) — carries its meaning across unchanged. A domain label
// cannot contain a colon, so this cannot misfire on a real host name.
if strings.Contains(v, ":") {
return v
}
return "full:" + v
}
// containsString reports whether list holds want (trimmed comparison).
func containsString(list []string, want string) bool {
for _, v := range list {
if strings.TrimSpace(v) == want {
return true
}
}
return false
}
+636 -21
View File
@@ -1,53 +1,668 @@
package model
import "testing"
import (
"fmt"
"strconv"
"strings"
"testing"
)
// fakeUCI is an in-memory uciRunner for the migration + write tests (no router
// needed). imported records the last `uci import` text so WriteUCI can be
// asserted without a device.
// fakeUCI is an in-memory stand-in for the `uci` CLI: enough of a section model
// that a migration can EXPORT the config, rewrite it, and export it again and see
// its own writes. That is what makes the idempotence assertions below real —
// against a flat key/value map a second migration run would re-read the original
// text and "prove" nothing.
//
// Addressing mirrors uci: `shater.globals.opt` (named section), `shater.@rule[2]`
// (positional, index is PER TYPE), `shater.cfg001.opt` (the id `uci add` returns).
type fakeUCI struct {
kv map[string]string
secs []*fakeSection
imported string
commits int
deleted []string
nextID int
// missing makes Export report "no such package", the fresh-install case.
missing bool
}
func (f *fakeUCI) Get(k string) (string, bool) { v, ok := f.kv[k]; return v, ok }
func (f *fakeUCI) Set(k, v string) error { f.kv[k] = v; return nil }
func (f *fakeUCI) Delete(k string) error { f.deleted = append(f.deleted, k); delete(f.kv, k); return nil }
func (f *fakeUCI) Commit(string) error { f.commits++; return nil }
func (f *fakeUCI) Import(pkg, text string) error { f.imported = text; return nil }
type fakeSection struct {
id string
typ string
name string // "" for an anonymous section
opts map[string]string
oKeys []string // option order, so the rendered export is deterministic
lists map[string][]string
lKeys []string
}
func newFakeUCI(export string) *fakeUCI {
f := &fakeUCI{}
if strings.TrimSpace(export) == "" {
return f
}
secs, err := parseSections(export)
if err != nil {
panic("fakeUCI fixture: " + err.Error())
}
for _, s := range secs {
sec := f.newSection(s.Type, s.Name)
for k, v := range s.Options {
sec.setOpt(k, v)
}
for k, vs := range s.Lists {
for _, v := range vs {
sec.addList(k, v)
}
}
}
return f
}
func (f *fakeUCI) newSection(typ, name string) *fakeSection {
sec := &fakeSection{
id: fmt.Sprintf("cfg%03d", f.nextID),
typ: typ,
name: name,
opts: map[string]string{},
lists: map[string][]string{},
}
f.nextID++
f.secs = append(f.secs, sec)
return sec
}
func (s *fakeSection) setOpt(k, v string) {
if _, seen := s.opts[k]; !seen {
s.oKeys = append(s.oKeys, k)
}
s.opts[k] = v
}
func (s *fakeSection) addList(k, v string) {
if _, seen := s.lists[k]; !seen {
s.lKeys = append(s.lKeys, k)
}
s.lists[k] = append(s.lists[k], v)
}
// resolve finds the section a `<pkg>.<sel>` selector names.
func (f *fakeUCI) resolve(sel string) *fakeSection {
if strings.HasPrefix(sel, "@") && strings.HasSuffix(sel, "]") {
open := strings.IndexByte(sel, '[')
if open < 0 {
return nil
}
typ := sel[1:open]
idx, err := strconv.Atoi(sel[open+1 : len(sel)-1])
if err != nil {
return nil
}
n := 0
for _, s := range f.secs {
if s.typ != typ {
continue
}
if n == idx {
return s
}
n++
}
return nil
}
for _, s := range f.secs {
if s.name == sel || s.id == sel {
return s
}
}
return nil
}
// splitKey cuts "shater.@rule[0].dst_domain" into ("@rule[0]", "dst_domain").
// A key with no option part yields opt == "".
func splitKey(key string) (sel, opt string) {
rest := strings.TrimPrefix(key, "shater")
rest = strings.TrimPrefix(rest, ".")
if rest == "" {
return "", ""
}
// The selector may contain a dot only inside a name, which the fixtures never
// use, so a plain LastIndex is enough — except for `@type[i]`, where the index
// brackets hold no dots either.
if i := strings.LastIndexByte(rest, '.'); i >= 0 {
return rest[:i], rest[i+1:]
}
return rest, ""
}
func (f *fakeUCI) Get(k string) (string, bool) {
sel, opt := splitKey(k)
sec := f.resolve(sel)
if sec == nil {
return "", false
}
if opt == "" {
return sec.typ, true
}
v, ok := sec.opts[opt]
return v, ok
}
func (f *fakeUCI) Set(k, v string) error {
sel, opt := splitKey(k)
sec := f.resolve(sel)
if sec == nil {
if opt != "" {
return fmt.Errorf("uci set %s: no such section", k)
}
// `uci set shater.globals=globals` creates the named section.
f.newSection(v, sel)
return nil
}
if opt == "" {
sec.typ = v
return nil
}
sec.setOpt(opt, v)
return nil
}
func (f *fakeUCI) Delete(k string) error {
f.deleted = append(f.deleted, k)
if k == "shater" {
f.secs = nil
return nil
}
sel, opt := splitKey(k)
sec := f.resolve(sel)
if sec == nil {
return nil // `uci -q delete` on an absent key is a no-op
}
if opt == "" {
for i, s := range f.secs {
if s == sec {
f.secs = append(f.secs[:i], f.secs[i+1:]...)
break
}
}
return nil
}
delete(sec.opts, opt)
delete(sec.lists, opt)
return nil
}
func (f *fakeUCI) Commit(string) error { f.commits++; return nil }
func (f *fakeUCI) Import(_, text string) error {
f.imported = text
secs, err := parseSections(text)
if err != nil {
return err
}
for _, s := range secs {
sec := f.newSection(s.Type, s.Name)
for k, v := range s.Options {
sec.setOpt(k, v)
}
for k, vs := range s.Lists {
for _, v := range vs {
sec.addList(k, v)
}
}
}
return nil
}
func (f *fakeUCI) Export(string) (string, bool) {
if f.missing {
return "", false
}
var b strings.Builder
b.WriteString("package shater\n")
for _, s := range f.secs {
b.WriteString("\nconfig " + s.typ)
if s.name != "" {
b.WriteString(" '" + s.name + "'")
}
b.WriteString("\n")
for _, k := range s.oKeys {
if v, ok := s.opts[k]; ok {
b.WriteString("\toption " + k + " '" + v + "'\n")
}
}
for _, k := range s.lKeys {
for _, v := range s.lists[k] {
b.WriteString("\tlist " + k + " '" + v + "'\n")
}
}
}
return b.String(), true
}
func (f *fakeUCI) Add(_, secType string) (string, error) {
return f.newSection(secType, "").id, nil
}
func (f *fakeUCI) AddList(k, v string) error {
sel, opt := splitKey(k)
sec := f.resolve(sel)
if sec == nil {
return fmt.Errorf("uci add_list %s: no such section", k)
}
sec.addList(opt, v)
return nil
}
// ruleset returns the `config ruleset` section carrying option name == name.
func (f *fakeUCI) ruleset(name string) *fakeSection {
for _, s := range f.secs {
if s.typ == "ruleset" && s.opts["name"] == name {
return s
}
}
return nil
}
// rule returns the n-th `config rule` section.
func (f *fakeUCI) rule(idx int) *fakeSection { return f.resolve(fmt.Sprintf("@rule[%d]", idx)) }
func (f *fakeUCI) rulesetNames() []string {
var out []string
for _, s := range f.secs {
if s.typ == "ruleset" {
out = append(out, s.opts["name"])
}
}
return out
}
func eqStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func TestMigrate0to1TransformsFixture(t *testing.T) {
f := &fakeUCI{kv: map[string]string{"shater.globals.kill": "open"}}
f := newFakeUCI("package shater\n\nconfig globals 'globals'\n\toption kill 'open'\n")
if err := migrateWith(f); err != nil {
t.Fatalf("migrate: %v", err)
}
if f.kv["shater.globals.kill_switch"] != "open" {
t.Fatalf("kill_switch = %q", f.kv["shater.globals.kill_switch"])
if v, _ := f.Get("shater.globals.kill_switch"); v != "open" {
t.Fatalf("kill_switch = %q", v)
}
if _, ok := f.kv["shater.globals.kill"]; ok {
if _, ok := f.Get("shater.globals.kill"); ok {
t.Fatal("legacy kill not removed")
}
if f.kv["shater.globals.schema_version"] != "1" {
t.Fatalf("schema_version = %q", f.kv["shater.globals.schema_version"])
if v, _ := f.Get("shater.globals.schema_version"); v != strconv.Itoa(CurrentSchemaVersion) {
t.Fatalf("schema_version = %q, want %d", v, CurrentSchemaVersion)
}
}
func TestMigrateIdempotent(t *testing.T) {
f := &fakeUCI{kv: map[string]string{"shater.globals.schema_version": "1"}}
before := len(f.kv)
f := newFakeUCI("package shater\n\nconfig globals 'globals'\n\toption schema_version '" +
strconv.Itoa(CurrentSchemaVersion) + "'\n")
before, _ := f.Export("shater")
if err := migrateWith(f); err != nil {
t.Fatalf("migrate: %v", err)
}
if len(f.kv) != before {
t.Fatal("idempotent migrate changed state")
after, _ := f.Export("shater")
if before != after {
t.Fatalf("idempotent migrate changed state:\n--- before\n%s\n--- after\n%s", before, after)
}
}
func TestMigrateRefusesNewer(t *testing.T) {
f := &fakeUCI{kv: map[string]string{"shater.globals.schema_version": "99"}}
f := newFakeUCI("package shater\n\nconfig globals 'globals'\n\toption schema_version '99'\n")
if err := migrateWith(f); err == nil {
t.Fatal("expected refusal of newer schema")
}
}
// --- v1 -> v2: dst_domain / dst_ip fold into generated rule-sets -------------
// legacyConfig is the shape a v1 config has: rules matching destinations inline.
// The `ru-direct` rule is copied verbatim from the live router this change was
// written against (BananaWRT 25.12.1, shater 0.2.7-r1).
const legacyConfig = `package shater
config globals 'globals'
option schema_version '1'
config rule
option name 'ru-direct'
option enabled '1'
option order '10'
list dst_domain 'suffix:ru'
list dst_domain 'suffix:yandex.net'
list dst_domain 'full:vk.com'
list dst_domain 'keyword:sberbank'
list dst_domain '.gosuslugi.ru'
list dst_domain 'plain.example'
option target 'direct'
config rule
option name 'corp nets!'
option enabled '1'
option order '20'
list dst_ip '10.0.0.0/8'
list dst_ip '192.168.44.0/24'
option target 'group:corp'
config rule
option name 'default'
option enabled '1'
option order '100'
option target 'direct'
`
func TestMigrate1to2FoldsDomainsIntoARuleset(t *testing.T) {
f := newFakeUCI(legacyConfig)
if err := migrateWith(f); err != nil {
t.Fatalf("migrate: %v", err)
}
rs := f.ruleset("rule-ru-direct")
if rs == nil {
t.Fatalf("no rule-ru-direct ruleset; got %v", f.rulesetNames())
}
if rs.opts["type"] != "domain" || rs.opts["source"] != "inline" {
t.Fatalf("ruleset type/source = %q/%q, want domain/inline", rs.opts["type"], rs.opts["source"])
}
// Every entry keeps its meaning. Only the BARE one is rewritten: bare means
// EXACT in a rule and SUFFIX in a rule-set, so it becomes `full:`.
want := []string{
"suffix:ru", "suffix:yandex.net", "full:vk.com",
"keyword:sberbank", ".gosuslugi.ru", "full:plain.example",
}
if !eqStrings(rs.lists["entry"], want) {
t.Fatalf("entries = %q\nwant %q", rs.lists["entry"], want)
}
r0 := f.rule(0)
if !eqStrings(r0.lists["dst_ruleset"], []string{"rule-ru-direct"}) {
t.Fatalf("dst_ruleset = %q", r0.lists["dst_ruleset"])
}
if _, ok := r0.lists["dst_domain"]; ok {
t.Fatal("dst_domain survived the migration")
}
// Order and every other option are untouched.
if r0.opts["order"] != "10" || r0.opts["target"] != "direct" || r0.opts["name"] != "ru-direct" {
t.Fatalf("rule 0 mangled: %v", r0.opts)
}
}
func TestMigrate1to2FoldsCIDRsIntoAnIPRuleset(t *testing.T) {
f := newFakeUCI(legacyConfig)
if err := migrateWith(f); err != nil {
t.Fatalf("migrate: %v", err)
}
// The rule name is slugged: a rule-set name becomes an engine tag.
rs := f.ruleset("rule-corp-nets-ip")
if rs == nil {
t.Fatalf("no rule-corp-nets-ip ruleset; got %v", f.rulesetNames())
}
if rs.opts["type"] != "ipcidr" {
t.Fatalf("ruleset type = %q, want ipcidr", rs.opts["type"])
}
if !eqStrings(rs.lists["entry"], []string{"10.0.0.0/8", "192.168.44.0/24"}) {
t.Fatalf("entries = %q", rs.lists["entry"])
}
r1 := f.rule(1)
if !eqStrings(r1.lists["dst_ruleset"], []string{"rule-corp-nets-ip"}) {
t.Fatalf("dst_ruleset = %q", r1.lists["dst_ruleset"])
}
if _, ok := r1.lists["dst_ip"]; ok {
t.Fatal("dst_ip survived the migration")
}
}
// A rule with no destination at all stays a catch-all — the B1 reachability
// analysis (model.RuleReachability) keys off exactly that, so the migration must
// not hand it a rule-set it never asked for.
func TestMigrate1to2LeavesCatchAllAlone(t *testing.T) {
f := newFakeUCI(legacyConfig)
if err := migrateWith(f); err != nil {
t.Fatalf("migrate: %v", err)
}
r2 := f.rule(2)
if len(r2.lists["dst_ruleset"]) != 0 {
t.Fatalf("catch-all gained a ruleset: %q", r2.lists["dst_ruleset"])
}
text, _ := f.Export("shater")
m, err := ParseUCIExport(text)
if err != nil {
t.Fatalf("parse migrated config: %v", err)
}
if len(m.Rules) != 3 {
t.Fatalf("rules = %d, want 3", len(m.Rules))
}
if !IsCatchAll(m.Rules[2]) {
t.Fatalf("rule %q stopped being a catch-all after the migration", m.Rules[2].Name)
}
for _, i := range []int{0, 1} {
if IsCatchAll(m.Rules[i]) {
t.Fatalf("rule %q became a catch-all — its destination was lost", m.Rules[i].Name)
}
}
reach := RuleReachability(m.Rules)
for i, v := range reach {
if v.Unreachable {
t.Fatalf("rule %d (%q) reported unreachable after the migration: %s", i, v.Name, v.Reason)
}
}
}
// Running the migration twice must not duplicate rule-sets or references. The
// second run goes through migrate1to2 directly, because migrateWith is gated by
// schema_version and would (correctly) do nothing at all.
func TestMigrate1to2IsIdempotent(t *testing.T) {
f := newFakeUCI(legacyConfig)
if err := migrateWith(f); err != nil {
t.Fatalf("migrate: %v", err)
}
first, _ := f.Export("shater")
if err := migrate1to2(f); err != nil {
t.Fatalf("second migrate1to2: %v", err)
}
second, _ := f.Export("shater")
if first != second {
t.Fatalf("re-running the migration changed the config:\n--- first\n%s\n--- second\n%s", first, second)
}
// And a full migrateWith re-run (schema already at CurrentSchemaVersion) is a
// no-op too.
if err := migrateWith(f); err != nil {
t.Fatalf("third migrate: %v", err)
}
third, _ := f.Export("shater")
if third != second {
t.Fatalf("re-running migrateWith changed the config:\n%s", third)
}
}
// An interrupted run — the rule-set was created and referenced, but the legacy
// option was not deleted yet — must reuse the rule-set rather than make a second.
func TestMigrate1to2ResumesAnInterruptedRun(t *testing.T) {
f := newFakeUCI(`package shater
config globals 'globals'
option schema_version '1'
config ruleset
option name 'rule-half'
option type 'domain'
option source 'inline'
list entry 'full:a.example'
config rule
option name 'half'
list dst_domain 'a.example'
list dst_ruleset 'rule-half'
option target 'direct'
`)
if err := migrateWith(f); err != nil {
t.Fatalf("migrate: %v", err)
}
if names := f.rulesetNames(); !eqStrings(names, []string{"rule-half"}) {
t.Fatalf("rulesets = %q, want just rule-half", names)
}
r := f.rule(0)
if !eqStrings(r.lists["dst_ruleset"], []string{"rule-half"}) {
t.Fatalf("dst_ruleset = %q", r.lists["dst_ruleset"])
}
if _, ok := r.lists["dst_domain"]; ok {
t.Fatal("dst_domain survived")
}
}
// A hand-written rule-set already owning the generated name must not be
// clobbered: two `config ruleset` blocks with one name collide on the engine tag
// and one of them is dropped.
func TestMigrate1to2AvoidsNameCollisions(t *testing.T) {
f := newFakeUCI(`package shater
config globals 'globals'
option schema_version '1'
config ruleset
option name 'rule-ads'
option type 'domain'
option source 'url'
option url 'https://example.invalid/list.txt'
config rule
option name 'ads'
list dst_domain 'ads.example'
option target 'block'
`)
if err := migrateWith(f); err != nil {
t.Fatalf("migrate: %v", err)
}
names := f.rulesetNames()
if !eqStrings(names, []string{"rule-ads", "rule-ads-2"}) {
t.Fatalf("rulesets = %q, want rule-ads + rule-ads-2", names)
}
if got := f.ruleset("rule-ads").opts["source"]; got != "url" {
t.Fatalf("the hand-written list was overwritten (source = %q)", got)
}
if !eqStrings(f.rule(0).lists["dst_ruleset"], []string{"rule-ads-2"}) {
t.Fatalf("dst_ruleset = %q", f.rule(0).lists["dst_ruleset"])
}
}
// A rule carrying BOTH lists gets both rule-sets, and keeps every entry.
func TestMigrate1to2SplitsMixedRuleIntoTwoRulesets(t *testing.T) {
f := newFakeUCI(`package shater
config globals 'globals'
option schema_version '1'
config rule
option name 'mixed'
list dst_domain 'regexp:^ads\.'
list dst_ip '203.0.113.0/24'
option target 'block'
`)
if err := migrateWith(f); err != nil {
t.Fatalf("migrate: %v", err)
}
dom := f.ruleset("rule-mixed")
ip := f.ruleset("rule-mixed-ip")
if dom == nil || ip == nil {
t.Fatalf("want rule-mixed + rule-mixed-ip, got %q", f.rulesetNames())
}
// `regexp:` crosses over untouched (generate.peelDomainRegexes reads it).
if !eqStrings(dom.lists["entry"], []string{`regexp:^ads\.`}) {
t.Fatalf("domain entries = %q", dom.lists["entry"])
}
if !eqStrings(ip.lists["entry"], []string{"203.0.113.0/24"}) {
t.Fatalf("ip entries = %q", ip.lists["entry"])
}
if !eqStrings(f.rule(0).lists["dst_ruleset"], []string{"rule-mixed", "rule-mixed-ip"}) {
t.Fatalf("dst_ruleset = %q", f.rule(0).lists["dst_ruleset"])
}
}
// An inert `geosite:` matcher is copied verbatim rather than promoted to a
// source=geosite list: it matched nothing before the upgrade (the engine's
// route-rule geosite field is gone) and must not start routing traffic because of
// one. The text is kept so the operator can see and convert it.
func TestMigrate1to2KeepsGeoMarkersInert(t *testing.T) {
f := newFakeUCI(`package shater
config globals 'globals'
option schema_version '1'
config rule
option name 'geo'
list dst_domain 'geosite:youtube'
list dst_ip 'geoip:ru'
option target 'direct'
`)
if err := migrateWith(f); err != nil {
t.Fatalf("migrate: %v", err)
}
if got := f.ruleset("rule-geo").lists["entry"]; !eqStrings(got, []string{"geosite:youtube"}) {
t.Fatalf("domain entries = %q", got)
}
if got := f.ruleset("rule-geo-ip").lists["entry"]; !eqStrings(got, []string{"geoip:ru"}) {
t.Fatalf("ip entries = %q", got)
}
for _, s := range f.secs {
if s.typ == "ruleset" && s.opts["source"] != "inline" {
t.Fatalf("ruleset %q got source %q — a geo marker was promoted", s.opts["name"], s.opts["source"])
}
}
}
// A fresh install has no config to export; the migration must succeed silently
// rather than refuse to boot.
func TestMigrate1to2NoConfig(t *testing.T) {
f := &fakeUCI{missing: true}
if err := migrate1to2(f); err != nil {
t.Fatalf("migrate on an absent package: %v", err)
}
}
func TestRulesetBaseName(t *testing.T) {
cases := []struct{ in, want string }{
{"ru-direct", "rule-ru-direct"},
{"corp nets!", "rule-corp-nets"},
{" spaced name ", "rule-spaced-name"},
{"Ünïcode", "rule-n-code"}, // non-ASCII is not tag-safe; dropped, leaving a separator
{"", "rule-7"},
{"!!!", "rule-7"},
}
for _, c := range cases {
if got := rulesetBaseName(c.in, 7); got != c.want {
t.Errorf("rulesetBaseName(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestMigrateDomainEntry(t *testing.T) {
cases := []struct{ in, want string }{
{"example.com", "full:example.com"}, // bare: exact in a rule, suffix in a list
{"full:example.com", "full:example.com"},
{"suffix:example.com", "suffix:example.com"},
{"keyword:ads", "keyword:ads"},
{`regexp:^a\.b$`, `regexp:^a\.b$`},
{".example.com", ".example.com"},
{"geosite:youtube", "geosite:youtube"},
{" spaced.example ", "full:spaced.example"},
{" ", ""},
}
for _, c := range cases {
if got := migrateDomainEntry(c.in); got != c.want {
t.Errorf("migrateDomainEntry(%q) = %q, want %q", c.in, got, c.want)
}
}
}
+10 -2
View File
@@ -633,14 +633,22 @@ type Ruleset struct {
}
// Rule is a `config rule` (ordered, first-match).
//
// DESTINATION IS ALWAYS A RULE-SET. A rule names WHERE traffic is going only
// through DstRuleset — there are no inline domain or IP lists on a rule any
// more (`dst_domain` / `dst_ip` were removed in schema v2; migrate1to2 folds
// every existing one into a generated `config ruleset` and rewrites the rule to
// point at it). One destination mechanism means one set of matcher semantics to
// learn, one place a list is edited, and a list that is compiled once into a
// .srs and shared by every rule that references it instead of being re-parsed
// per rule. Src (the CLIENT side), DstPort and Proto are unaffected: they are
// not lists of destinations and have no rule-set form.
type Rule struct {
Name string
Enabled bool
Order int
Src []string
DstDomain []string
DstRuleset []string
DstIP []string
DstPort string
Proto string
Target string // chain:|group:|node:|direct|block
+9 -5
View File
@@ -40,16 +40,20 @@ import (
"strings"
)
// IsCatchAll reports whether a rule carries NO matcher of any kind (src, dst
// domain/ip/ruleset, port, proto). Such a rule is the default egress: generate
// points route `Final` at it rather than emitting a match-all rule.
// IsCatchAll reports whether a rule carries NO matcher of any kind (src,
// dst_ruleset, port, proto). Such a rule is the default egress: generate points
// route `Final` at it rather than emitting a match-all rule.
//
// The destination side is now exactly one field — a rule names where traffic is
// going through DstRuleset alone (schema v2; see model.Rule). A rule that was
// catch-all before the migration is still catch-all after it, and a rule that
// carried `dst_domain`/`dst_ip` is not, because the migration gives it a
// DstRuleset in their place.
//
// generate.isCatchAll and the panel's isCatchAll() are the same predicate; this
// is the one the Go side shares.
func IsCatchAll(r Rule) bool {
return len(r.Src) == 0 &&
len(r.DstDomain) == 0 &&
len(r.DstIP) == 0 &&
len(r.DstRuleset) == 0 &&
strings.TrimSpace(r.DstPort) == "" &&
strings.TrimSpace(r.Proto) == ""
+2 -2
View File
@@ -216,8 +216,8 @@ func TestReachabilityUnsortedInputIsJudgedByOrder(t *testing.T) {
func TestReachabilityMatcherKindsAreNotCatchAll(t *testing.T) {
conditional := []Rule{
{Name: "by-src", Enabled: true, Order: 10, Target: "direct", Src: []string{"192.168.1.0/24"}},
{Name: "by-domain", Enabled: true, Order: 11, Target: "direct", DstDomain: []string{"example.com"}},
{Name: "by-ip", Enabled: true, Order: 12, Target: "direct", DstIP: []string{"1.1.1.1/32"}},
// The destination side is one field now (schema v2): a domain list and an
// address list are both `config ruleset`s a rule points dst_ruleset at.
{Name: "by-ruleset", Enabled: true, Order: 13, Target: "direct", DstRuleset: []string{"ads"}},
{Name: "by-port", Enabled: true, Order: 14, Target: "direct", DstPort: "443"},
{Name: "by-proto", Enabled: true, Order: 15, Target: "direct", Proto: "quic"},
+4 -2
View File
@@ -211,9 +211,11 @@ func RenderUCIExport(m *Model) string {
w.boolOpt("enabled", r.Enabled)
w.intOpt("order", r.Order)
w.listOpt("src", r.Src)
w.listOpt("dst_domain", r.DstDomain)
// dst_domain / dst_ip are NOT emitted (removed in schema v2). Their absence
// here is also how a legacy option drains out of a config that was migrated:
// migrate1to2 deletes them explicitly, and any that survived a hand-edit
// disappear the next time the panel writes the model back.
w.listOpt("dst_ruleset", r.DstRuleset)
w.listOpt("dst_ip", r.DstIP)
w.strOpt("dst_port", r.DstPort)
w.strOpt("proto", r.Proto)
w.strOpt("target", r.Target)
+2 -3
View File
@@ -84,8 +84,7 @@ func richModel() *Model {
}},
Rules: []Rule{{
Name: "pc", Enabled: true, Order: 10,
Src: []string{"192.168.1.1/32"}, DstDomain: []string{"geosite:telegram"},
DstRuleset: []string{"ads"}, DstIP: []string{"1.1.1.1/32"},
Src: []string{"192.168.1.1/32"}, DstRuleset: []string{"ads"},
DstPort: "443", Proto: "tcp,udp", Target: "chain:triple", Egress: "frag",
Kill: "default", SchedEnabled: true, SchedDays: []string{"mon", "tue"},
SchedStart: "08:00", SchedEnd: "22:00", SchedUTCOffset: 180,
@@ -384,7 +383,7 @@ func TestRenderSkipsSubCacheNodes(t *testing.T) {
// and COMMITs — and the imported text re-parses to the original Model.
func TestWriteUCIReplaces(t *testing.T) {
m := richModel()
f := &fakeUCI{kv: map[string]string{}}
f := newFakeUCI("")
if err := writeUCIWith(f, m); err != nil {
t.Fatalf("writeUCIWith: %v", err)
}
+11 -6
View File
@@ -175,13 +175,18 @@ func ParseUCIExport(text string) (*Model, error) {
})
case "rule":
m.Rules = append(m.Rules, Rule{
Name: firstNonEmpty(s.opt("name"), s.Name),
Enabled: s.optBool("enabled", true),
Order: parseInt(s.opt("order"), 0),
Src: s.list("src"),
DstDomain: s.list("dst_domain"),
Name: firstNonEmpty(s.opt("name"), s.Name),
Enabled: s.optBool("enabled", true),
Order: parseInt(s.opt("order"), 0),
Src: s.list("src"),
// No dst_domain / dst_ip: removed in schema v2. A rule's destination
// is a rule-set reference and nothing else; migrate1to2 (migrate.go)
// converts any legacy inline list into a `config ruleset` and points
// dst_ruleset at it, so by the time this parser runs there is nothing
// left to read. A config that somehow still carries them (hand-edited
// after a downgrade) simply ignores them — the migration is re-run on
// every load, so it will have been rewritten first.
DstRuleset: s.list("dst_ruleset"),
DstIP: s.list("dst_ip"),
DstPort: s.opt("dst_port"),
Proto: s.opt("proto"),
Target: s.opt("target"),
+1 -1
View File
@@ -59,7 +59,7 @@ config rule
option enabled '1'
option order '10'
list src '192.168.11.14/32'
list dst_domain 'geosite:telegram'
list dst_ruleset 'ads'
option dst_port '443'
option proto 'tcp,udp'
option target 'chain:triple'