Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
244b7c4199 | ||
|
|
a8ef887c56 |
@@ -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.
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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
@@ -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,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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 +
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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) == ""
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user