RFC: wind ACL Intermediate Representation (acl-ir) Category: Informational Date: June 2026
wind ACL Intermediate Representation (acl-ir)¶
Status of This Memo¶
This document specifies the in-repository ACL intermediate representation used
by wind-acl. It is not an Internet standard and it does not define a wire
protocol. The normative implementation references are the wind-acl and
wind-core crates in this workspace.
Abstract¶
acl-ir is the internal routing program format used by wind-acl. It lowers
Clash/Mihomo rule lines (and any externally converted wind_rule::Rule
values, such as tuic-server's legacy ACL dialect) into a single Ruleset that
preserves first-match-wins routing, default outbound fallback, and the legacy
wind_rule::Rule matching semantics.
The IR is shaped like a small nftables-inspired engine: boolean match
expressions, set membership, verdict maps, ordered chains, statements, and
terminal verdicts. The v1 implementation deliberately keeps the compatibility
surface narrow. Optimizer-relevant leaves are represented as typed IR nodes
(domain exact/suffix/keyword, IP CIDR, source/destination port, and network
protocol); every other Mihomo rule type is carried as Match::Predicate and
delegates evaluation to wind_rule::Rule.
Table of Contents¶
- Introduction
- Conventions and Terminology
- Compilation Pipeline
- Data Model
- Evaluation Semantics
- Degenerate Embedding
- Surface Dialect Lowering
- Order-Preserving Optimization
- Implementation Scope and Extensions
- Security Considerations
- References
1. Introduction¶
An ACL router answers one question: given a connection context, which outbound
should serve it, or should it be rejected? Historically, wind used a flat
Vec<wind_rule::Rule> evaluated in declaration order. That model is
simple and compatible with Clash/Mihomo syntax, but it is hard to optimize and
does not give converted rules (e.g. tuic-server's legacy ACL) a structured
target.
acl-ir provides that structured target. It has three goals:
- preserve existing routing decisions exactly for rules that already worked in the legacy engine;
- expose enough typed structure to build safe sets and verdict maps;
- leave explicit extension points for richer routing constructs without forcing all of them into the initial implementation.
The IR borrows its engine shape from nftables, but it is not an nftables
frontend. It runs inside wind, reads wind_rule::MatchContext, and keeps
proxy-specific layer-7 concepts such as domains, process identity, inbound
metadata, GeoIP/GeoSite lookups, and rule-set placeholders.
2. Conventions and Terminology¶
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 [RFC2119] [RFC8174] when, and only when, they appear in all capitals.
- Match: a boolean expression evaluated against a
MatchContext. - Predicate: a
wind_rule::Ruleembedded in the IR as an opaque matcher. - Statement: a non-terminal action associated with a matching rule.
- Verdict: a routing or control-flow decision: forward, reject, drop, return, jump, goto, or verdict-map lookup.
- Rule: one match expression, zero or more statements, and one verdict.
- Chain: an ordered list of rules. The entry chain also has the observable fallback policy.
- Set: an unordered lookup table used by
Match::InSet. - Verdict map: an unordered lookup table from a key range to a verdict.
- Degenerate embedding: the single-chain IR form that is equivalent to the legacy flat rule engine.
- First-match-wins: the first rule in declaration order whose match is true decides the route.
Rust snippets in this document are illustrative, but they follow the public
types in crates/wind-acl/src/model.rs.
3. Compilation Pipeline¶
AclEngineBuilder builds an engine in this order:
- Parse real Hysteria 2 (apernet) ACL entries through
syntax::apernetand convert them towind_rule::Rulevalues withapernet::acl_to_rules. - Parse Clash/Mihomo rule lines through
syntax::metacubex. - Concatenate apernet-derived rules before Clash/Mihomo rules.
- Build the degenerate
RulesetwithRuleset::from_rules. - Run
compile, the order-preserving optimizer. - At route time, build a
MatchContextfromTargetAddr, protocol, and any configured static inbound metadata, then evaluate theRuleset.
The apernet-before-Clash ordering is normative for AclEngine: if both
surfaces produce a rule matching the same connection, the apernet-derived rule
wins.
Callers with other rule sources convert them to wind_rule::Rule
themselves and route those values directly (via wind_acl::AclEngine or the
degenerate embedding). tuic-server does this for its space-separated legacy
dialect: it lowers entries with tuic_server::legacy::acl_to_rules and
concatenates the converted rules before its Clash/Mihomo rules.
AclEngine::route currently fills only the fields available at that call site:
destination domain or IP, destination port, network protocol, optional inbound
name, and optional inbound type. Source IP, source port, inbound user, process
metadata, and external GeoIP/ASN/GeoSite lookup functions are absent unless a
caller evaluates a Ruleset directly with a richer MatchContext.
4. Data Model¶
4.1. Matches¶
enum Side {
Dst,
Src,
}
enum Match {
All(Vec<Match>),
Any(Vec<Match>),
Not(Box<Match>),
Always,
Ip { side: Side, net: IpNet },
Port { side: Side, range: RangeInclusive<u16> },
Proto (NetworkType),
Domain (DomainTest),
InSet { side: Side, set: usize },
Predicate(Arc<wind_rule::Rule>),
}
enum DomainTest {
Exact(String),
Suffix(String),
Keyword(String),
}
All([]) is true and Any([]) is false by normal boolean convention, but
lowering code SHOULD avoid constructing empty logical nodes. Always is the IR
form of MATCH.
DomainTest::Suffix matches both the exact suffix and subdomains of that
suffix. Exact and suffix comparisons are ASCII case-insensitive. Keyword
matching is also ASCII case-insensitive.
Predicate is the compatibility escape hatch. It MUST evaluate by calling
Rule::matches(ctx), so opaque rules keep the exact behavior of
wind_rule, including RULE-SET currently matching false and
SUB-RULE currently using the legacy contained-rule semantics.
4.2. Sets¶
struct NamedSet {
data: SetData,
}
enum SetData {
Domains(DomainSet),
Ips(Vec<IpNet>),
Ports(Vec<RangeInclusive<u16>>),
}
struct DomainSet {
exact: Vec<String>,
suffix: Vec<String>,
keyword: Vec<String>,
}
The implementation stores sets in Ruleset::sets and refers to them by table
index. The term "NamedSet" is retained for the conceptual role; a future
serialized form MAY assign stable names.
Membership is type-directed:
Domainsreadsctx.domainand ignoresside;Ipsreadsctx.dst_iporctx.src_ipaccording toside;Portsreadsctx.dst_portorctx.src_portaccording toside.
4.3. Statements and Verdicts¶
enum Statement {
Counter,
Log(String),
Mark(u32),
Dnat(String),
}
enum Verdict {
Forward(String),
Reject(String),
Drop,
Return,
Jump(String),
Goto(String),
Map(usize),
}
Statements are non-terminal. An implementation that exposes statement side
effects MUST execute them in rule order before applying the rule's verdict. The
current RouteAction API observes only the routing decision, so the built-in
evaluator ignores statement side effects. The degenerate embedding never emits
statements.
Forward selects a named outbound. Reject rejects with a reason string.
Drop is available in the IR, but wind_core::RouteAction currently has no
drop variant; the built-in evaluator reports Drop as a rejection with the
reason "dropped".
4.4. Verdict Maps, Chains, and Rulesets¶
enum MapField {
Port,
}
struct VerdictMap {
side: Side,
field: MapField,
entries: Vec<(RangeInclusive<u16>, Verdict)>,
default: Option<Verdict>,
}
struct IrRule {
matches: Match,
stmts: Vec<Statement>,
verdict: Verdict,
}
struct Chain {
name: String,
policy: Verdict,
rules: Vec<IrRule>,
}
struct Ruleset {
sets: Vec<NamedSet>,
maps: Vec<VerdictMap>,
chains: Vec<Chain>,
entry: usize,
}
In v1, verdict maps key only on source or destination port ranges. The optimizer only creates maps whose ranges are pairwise disjoint.
entry is an index into chains; evaluation always starts there.
5. Evaluation Semantics¶
Evaluation starts at Ruleset::entry and scans the entry chain from top to
bottom.
For each rule:
- Evaluate
rule.matchesagainst the suppliedMatchContext. - If the match is false, continue to the next rule.
- If the match is true, process
rule.stmts, then applyrule.verdict.
Terminal verdicts behave as follows:
Forward(outbound)terminates withRouteAction::Forward(outbound).Reject(reason)terminates withRouteAction::Reject(reason).Dropterminates as a rejection in the current public API.
Control-flow verdicts behave as follows:
Returnproduces fallthrough to the caller.Jump(name)evaluates the named chain. If that chain produces a terminal verdict, the terminal verdict wins. If it falls through, evaluation resumes at the next rule after the jump.Goto(name)evaluates the named chain without establishing a semantic return point. In the current evaluator, a non-terminal result from the target chain is still represented as fallthrough at the call site. Configurations SHOULD use explicit terminal rules inGototargets until stricter tail-call semantics are implemented.Map(index)looks up the current key inRuleset::maps[index]. A hit applies the entry verdict. A miss withdefaultapplies the default verdict. A miss withoutdefaultfalls through to the next rule.
If the entry chain ultimately falls through, Ruleset::route applies the entry
chain policy. Non-entry chain policies are reserved for future multi-base-chain
semantics; v1 callers SHOULD use explicit terminal fallback rules inside
subchains.
Implementations MUST prevent unbounded chain recursion. The current evaluator uses a maximum chain depth of 64 and treats excess depth as fallthrough.
6. Degenerate Embedding¶
Ruleset::from_rules(rules, default_outbound) embeds legacy rules as a
single-chain ruleset:
Ruleset {
sets: vec![],
maps: vec![],
entry: 0,
chains: vec![Chain {
name: "main".to_string(),
policy: Verdict::Forward(default_outbound),
rules: rules.into_iter().map(rule_to_ir).collect(),
}],
}
This embedding is normative: before optimization, routing MUST match the legacy
first-match-wins engine for the same MatchContext. After optimization,
routing MUST still match it.
The following rule types become typed IR leaves:
wind_rule::RuleType |
IR match |
|---|---|
Domain |
Domain(Exact) |
DomainSuffix |
Domain(Suffix) |
DomainKeyword |
Domain(Keyword) |
IpCidr, IpSuffix |
Ip { side: Dst } |
IpCidr6 |
Ip { side: Dst } |
SrcIpCidr, SrcIpSuffix |
Ip { side: Src } |
DstPort, DstPortRange |
Port { side: Dst } |
SrcPort, SrcPortRange |
Port { side: Src } |
Network |
Proto |
Match |
Always |
All other rule types are embedded as Predicate(Arc<Rule>).
Targets are mapped as follows:
reject,block, anddeny, case-insensitively, becomeVerdict::Rejectwith a canonical reason string;- every other target becomes
Verdict::Forward(target), preserving the target spelling.
The canonical reject reason is not a routing semantic. Tests compare rejection as a decision, not as a string payload.
7. Surface Dialect Lowering¶
7.1. Clash/Mihomo¶
Clash/Mihomo lines are parsed by wind_rule::Rule::parse. Blank lines
and # comments are skipped by multiline helpers.
The shared rule model supports the following broad classes:
- domain rules:
DOMAIN,DOMAIN-SUFFIX,DOMAIN-KEYWORD,DOMAIN-WILDCARD,DOMAIN-REGEX,GEOSITE; - destination IP rules:
IP-CIDR,IP-CIDR6,IP-SUFFIX,IP-ASN,GEOIP; - source IP rules:
SRC-IP-CIDR,SRC-IP-SUFFIX,SRC-IP-ASN,SRC-GEOIP; - ports:
DST-PORT,SRC-PORT, including inclusive ranges; - inbound metadata:
IN-PORT,IN-TYPE,IN-USER,IN-NAME; - process and user identity:
PROCESS-PATH,PROCESS-PATH-REGEX,PROCESS-NAME,PROCESS-NAME-REGEX,UID; - protocol and traffic metadata:
NETWORK,DSCP; - compounds and catch-all:
AND,OR,NOT,SUB-RULE,RULE-SET,MATCH.
Only the subset listed in Section 6 is typed in the IR today. The rest remains
semantically correct through Predicate.
7.2. tuic-server legacy ACL¶
The tuic-server legacy ACL is a space-separated dialect specific to tuic-server
(it is not Hysteria's ACL, which uses a outbound(address, proto/port,
hijack) function-call form). Its parser and lowering live in the tuic-server
crate's legacy module, not in wind-acl; this section documents the lowering
because its output is embedded through Section 6. Lines have the shape:
Lowering first converts each AclRule to one or more wind_rule::Rule
values (tuic_server::legacy::acl_to_rules), then embeds those rules through
Section 6.
Address lowering:
| legacy address | Lowered rule type |
|---|---|
omitted or * |
MATCH |
| IPv4 literal | IP-CIDR host route /32 |
| IPv6 literal | IP-CIDR host route /128 |
| CIDR | IP-CIDR |
| domain | DOMAIN |
*.example.com |
DOMAIN-SUFFIX,example.com |
suffix:example.com |
DOMAIN-SUFFIX,example.com |
localhost |
127.0.0.0/8 and ::1/128 |
private |
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8, 169.254.0.0/16, ::1/128, fc00::/7, fe80::/10 |
Port lowering:
- an omitted port list adds no port condition;
80becomesDST-PORT,80;1000-2000becomesDST-PORT,1000-2000;tcp/443orudp/53becomesAND(NETWORK, DST-PORT).
When an address condition and a port condition are both present, lowering emits
an AND(address, port) rule for each combination.
Outbound lowering:
allowanddefaultnormalize to the outbound namedefault;- every other outbound string is preserved until target-to-verdict mapping.
hijack is parsed and retained on AclRule, but it is not currently honored.
Statement::Dnat is the intended IR home for future redirect support.
7.3. apernet ACL (real Hysteria 2)¶
The apernet dialect is the genuine Hysteria 2 ACL — a function-call form,
outbound(address[, proto/port[, hijack]]) — parsed by syntax::apernet in
wind-acl, mirroring apernet/hysteria's extras/outbounds/acl parser. Lowering
converts each AclRule to one or more wind_rule::Rule values
(apernet::acl_to_rules), then embeds them through Section 6.
Address dispatch is ordered and structural (first match wins, after lower-casing and trailing-dot trimming):
| apernet address | Lowered rule type |
|---|---|
all or * |
MATCH |
| IPv4 literal | IP-CIDR host route /32 |
| IPv6 literal | IP-CIDR host route /128 |
| CIDR (v4/v6) | IP-CIDR |
geoip:<cc> |
GEOIP,<cc> |
geosite:<name>[@attr…] |
GEOSITE,<name> (attributes dropped — see below) |
suffix:<domain> |
DOMAIN-SUFFIX,<domain> |
*-bearing domain (*.example.com, *.google.*) |
DOMAIN-WILDCARD,<pattern> |
| exact domain | DOMAIN,<domain> |
suffix: matches the apex and subdomains; an exact domain matches only itself; a
*-bearing pattern is a glob whose * spans label boundaries (so *.example.com
matches subdomains but not the bare apex).
Proto/port lowering (<proto> ∈ {tcp, udp, *}; <port> ∈ {*, single,
lo-hi}):
- omitted,
*, or*/*add no port condition (both protocols, all ports); tcp/tcp/*(and theudpforms) becomeNETWORK,<proto>(no port);*/<port>becomesDST-PORT,<port>/DST-PORT,lo-hi(no protocol);tcp/<port>(andudp) becomesAND(NETWORK, DST-PORT);- a resulting start port of
0is apernet's "any port" sentinel and adds no port condition.
When an address condition and a port condition are both present, lowering emits
an AND(address, port) rule for each combination (an all/* address is
match-everything, so only the port conditions are emitted).
Outbound lowering: the outbound name is passed through verbatim. The reject
keywords (reject/block/deny, case-insensitive) become a reject verdict via
Section 6; every other name (direct, default, or a custom outbound) is a
forward target.
Two apernet forms are faithful but not fully representable in the v1 IR:
- geosite attributes (
geosite:google@ads) have no slot inGeoSite(String), so they are dropped during lowering (retained on the parsedAclRule) with a warning; - hijack (the optional IP third argument) cannot be expressed in a
RuleType; it is parsed and retained but dropped during lowering with a warning.Statement::Dnatis the intended future home.
The dialect is deliberately stricter than upstream on degenerate input (it
rejects empty addresses, whitespace-only arguments, and arguments containing a
literal )), and it differs in two benign ways that only affect non-DNS input:
? in a *-bearing pattern is a single-character wildcard (upstream matches ?
literally), and matching applies no IDNA ToUnicode to the host (punycode xn--
hosts compare verbatim) with ASCII-only case folding.
8. Order-Preserving Optimization¶
The ordered chain is the ground truth. Sets and verdict maps are unordered lookup structures, so the optimizer MAY introduce them only when doing so cannot change first-match-wins behavior.
The current optimizer runs only on the entry chain. Other chains are passed through unchanged.
8.1. Pass 1: contiguous same-verdict bucketing¶
The optimizer finds the longest contiguous run starting at the current rule for
which every rule has identical (stmts, verdict).
Such a run MAY always be replaced by one rule because every matching member produces the same observable routing decision, and no unrelated rule is moved across the run boundary.
Within the replacement rule:
- domain exact/suffix/keyword leaves become one
SetData::Domainsset; - destination and source IP leaves become separate
SetData::Ipssets; - destination and source port leaves become separate
SetData::Portssets; - non-settable leaves, including
Proto,Predicate,Always, compound expressions, and existingInSetnodes, are kept as alternatives.
The replacement match is either the single alternative or Match::Any(alts).
8.2. Pass 2: port verdict maps¶
If Pass 1 does not consume the current position, the optimizer looks for the
longest contiguous run of single Port leaves on the same side.
That run MAY become a VerdictMap only if:
- every rule has an empty statement list;
- every key is an inclusive port range;
- all ranges are pairwise disjoint.
If any two ranges overlap, the run MUST remain ordered. This preserves cases such as:
Port 1500 must still route to proxy, because the first rule wins.
8.3. No Other Reordering¶
The v1 optimizer does not perform non-adjacent hoisting, IP verdict maps, domain verdict maps, or cross-chain optimization. These are future extensions and MUST preserve the same order-invariance rule if added.
9. Implementation Scope and Extensions¶
The v1 implementation intentionally distinguishes between IR capacity and engine behavior:
RULE-SETis still awind_rule::RuleType::RuleSetpredicate and therefore currently matches false.SUB-RULEis still evaluated through the legacyRuleType::SubRulesemantics when carried byPredicate.- GeoIP, ASN, and GeoSite rules require lookup functions in
MatchContext.AclEngine::routedoes not currently supply those functions. - Source IP, source port, inbound user, process fields, and UID require the
caller to provide them in
MatchContext. Dnatexists in the IR, but the legacy ACLhijackfield is not yet emitted or executed.Dropexists in the IR, but the publicRouteActioncurrently reports it as rejection.- sing-box route-rule parsing is not part of v1. The IR can grow typed leaves for sing-box-style environment matchers later.
Any future extension MUST keep the degenerate embedding equivalent to the legacy rule engine and MUST keep optimization semantics order-preserving.
10. Security Considerations¶
- Optimizer safety. If a transformation cannot prove that order is unobservable, it MUST leave rules ordered. This fail-closed rule prevents optimizations from silently changing routing or unblocking traffic.
- Missing context. A rule that reads an absent
MatchContextfield does not match. Deployments relying on source, process, inbound-user, GeoIP, ASN, or GeoSite rules MUST ensure those fields or lookup functions are populated. - Guard behavior. Loopback/private guards run before IR evaluation. If a guard is enabled, a resolver is REQUIRED at build time so domain targets can be resolved before the guard decision.
- Redirect behavior. The legacy ACL
hijackfield is parsed but not honored. EnablingDnatin the future changes traffic destination and SHOULD be explicit and observable in logs. - Chain cycles. Implementations MUST bound chain recursion. The current depth limit is 64.
- Reject keywords. The strings
reject,block, anddenyare reserved rejection targets, matched case-insensitively.
11. References¶
- [RFC2119] Bradner, S., "Key words for use in RFCs to Indicate Requirement Levels", BCP 14, RFC 2119, March 1997.
- [RFC8174] Leiba, B., "Ambiguity of Uppercase vs Lowercase in RFC 2119 Key Words", BCP 14, RFC 8174, May 2017.
crates/wind-acl/src/model.rs,embed.rs,eval.rs, andoptimize.rs.crates/wind-core/src/rule.rs.- MetaCubeX/Mihomo rule syntax.
- apernet/hysteria ACL syntax (
wind-aclcrate,syntax::apernetmodule). - tuic-server legacy ACL syntax (
tuic-servercrate,legacymodule). - nftables concepts: sets, maps, chains, statements, and verdicts.