Skip to content

Rule Sets ​

Rule sets provide reusable collections of routing rules that can be loaded from inline definitions, local files, or remote URLs. They are the modern replacement for the legacy GeoIP/GeoSite databases.

Source: common/srs/, route/rule/rule_set.go, route/rule/rule_set_local.go, route/rule/rule_set_remote.go, option/rule_set.go

SRS Binary Format ​

The SRS (Sing-box Rule Set) format is a compact binary representation of rule sets, designed for efficient loading and reduced file size compared to JSON source files.

File Structure ​

+--------+--------+------------------------------+
| Magic  | Version| zlib-compressed rule data    |
| 3 bytes| 1 byte |                              |
+--------+--------+------------------------------+
go
var MagicBytes = [3]byte{0x53, 0x52, 0x53} // ASCII "SRS"

Version History ​

VersionConstantNew Features
1RuleSetVersion1Initial format
2RuleSetVersion2AdGuard domain rules
3RuleSetVersion3network_type, network_is_expensive, network_is_constrained
4RuleSetVersion4network_interface_address, default_interface_address

Reading Process ​

go
func Read(reader io.Reader, recover bool) (PlainRuleSetCompat, error) {
    // 1. Read and validate 3-byte magic header "SRS"
    // 2. Read 1-byte version number (big-endian uint8)
    // 3. Open zlib decompression reader
    // 4. Read uvarint for rule count
    // 5. Read each rule sequentially
}

The recover flag controls whether binary-optimized structures (like domain matchers and IP sets) are expanded back into their human-readable forms (string lists). This is used when decompiling .srs back to JSON.

Compressed Data Layout ​

After the 4-byte header, all subsequent data is zlib-compressed (best compression level). Inside the decompressed stream:

[uvarint: rule_count]
[rule_0]
[rule_1]
...
[rule_N]

Rule Encoding ​

Each rule begins with a uint8 rule type byte:

Type ByteMeaning
0Default rule (flat conditions)
1Logical rule (AND/OR of sub-rules)

Default Rule Items ​

A default rule is a sequence of typed items terminated by 0xFF:

[uint8: 0x00 (default rule)]
[uint8: item_type] [item_data...]
[uint8: item_type] [item_data...]
...
[uint8: 0xFF (final)]
[bool: invert]

Item type constants:

go
const (
    ruleItemQueryType              uint8 = 0   // []uint16 (big-endian)
    ruleItemNetwork                uint8 = 1   // []string
    ruleItemDomain                 uint8 = 2   // domain.Matcher binary
    ruleItemDomainKeyword          uint8 = 3   // []string
    ruleItemDomainRegex            uint8 = 4   // []string
    ruleItemSourceIPCIDR           uint8 = 5   // IPSet binary
    ruleItemIPCIDR                 uint8 = 6   // IPSet binary
    ruleItemSourcePort             uint8 = 7   // []uint16 (big-endian)
    ruleItemSourcePortRange        uint8 = 8   // []string
    ruleItemPort                   uint8 = 9   // []uint16 (big-endian)
    ruleItemPortRange              uint8 = 10  // []string
    ruleItemProcessName            uint8 = 11  // []string
    ruleItemProcessPath            uint8 = 12  // []string
    ruleItemPackageName            uint8 = 13  // []string
    ruleItemWIFISSID               uint8 = 14  // []string
    ruleItemWIFIBSSID              uint8 = 15  // []string
    ruleItemAdGuardDomain          uint8 = 16  // AdGuardMatcher binary (v2+)
    ruleItemProcessPathRegex       uint8 = 17  // []string
    ruleItemNetworkType            uint8 = 18  // []uint8 (v3+)
    ruleItemNetworkIsExpensive     uint8 = 19  // no data (v3+)
    ruleItemNetworkIsConstrained   uint8 = 20  // no data (v3+)
    ruleItemNetworkInterfaceAddress uint8 = 21 // TypedMap (v4+)
    ruleItemDefaultInterfaceAddress uint8 = 22 // []Prefix (v4+)
    ruleItemFinal                  uint8 = 0xFF
)

String Array Encoding ​

[uvarint: count]
  [uvarint: string_length] [bytes: string_data]
  ...

uint16 Array Encoding ​

[uvarint: count]
[uint16 big-endian] [uint16 big-endian] ...

IP Set Encoding ​

IP sets are stored as ranges rather than CIDR prefixes for compactness:

[uint8: version (must be 1)]
[uint64 big-endian: range_count]
  [uvarint: from_addr_length] [bytes: from_addr]
  [uvarint: to_addr_length]   [bytes: to_addr]
  ...

The implementation uses unsafe.Pointer to directly reinterpret the internal structure of netipx.IPSet (which stores IP ranges as {from, to} pairs). IPv4 addresses are 4 bytes; IPv6 addresses are 16 bytes.

IP Prefix Encoding ​

Individual prefixes (used in v4+ network interface address rules):

[uvarint: addr_byte_length]
[bytes: addr_bytes]
[uint8: prefix_bits]

Logical Rule Encoding ​

[uint8: 0x01 (logical rule)]
[uint8: mode]  // 0 = AND, 1 = OR
[uvarint: sub_rule_count]
[sub_rule_0]
[sub_rule_1]
...
[bool: invert]

Rule Set Types ​

Factory Function ​

go
func NewRuleSet(ctx, logger, options) (adapter.RuleSet, error) {
    switch options.Type {
    case "inline", "local", "":
        return NewLocalRuleSet(ctx, logger, options)
    case "remote":
        return NewRemoteRuleSet(ctx, logger, options), nil
    }
}

Local Rule Set ​

LocalRuleSet handles both inline rules (embedded in config JSON) and file-based rule sets.

go
type LocalRuleSet struct {
    ctx        context.Context
    logger     logger.Logger
    tag        string
    access     sync.RWMutex
    rules      []adapter.HeadlessRule
    metadata   adapter.RuleSetMetadata
    fileFormat string              // "source" (JSON) or "binary" (SRS)
    watcher    *fswatch.Watcher    // file change watcher
    callbacks  list.List[adapter.RuleSetUpdateCallback]
    refs       atomic.Int32        // reference counting
}

Key behaviors:

  • Inline mode: Rules are parsed from options.InlineOptions.Rules at construction time
  • File mode: Rules are loaded from options.LocalOptions.Path and an fswatch.Watcher is set up for automatic reloading on file changes
  • Format auto-detection: File extension .json selects source format; .srs selects binary format
  • Hot reload: When the watcher detects changes, reloadFile() re-reads and re-parses the file, then notifies all registered callbacks

Remote Rule Set ​

RemoteRuleSet downloads rule sets from a URL with periodic auto-update.

go
type RemoteRuleSet struct {
    ctx            context.Context
    cancel         context.CancelFunc
    logger         logger.ContextLogger
    outbound       adapter.OutboundManager
    options        option.RuleSet
    updateInterval time.Duration    // default: 24 hours
    dialer         N.Dialer
    access         sync.RWMutex
    rules          []adapter.HeadlessRule
    metadata       adapter.RuleSetMetadata
    lastUpdated    time.Time
    lastEtag       string           // HTTP ETag for conditional requests
    updateTicker   *time.Ticker
    cacheFile      adapter.CacheFile
    pauseManager   pause.Manager
    callbacks      list.List[adapter.RuleSetUpdateCallback]
    refs           atomic.Int32
}

Key behaviors:

  • Cache persistence: On startup, loads cached content from adapter.CacheFile (bbolt database). If cached data exists, uses it immediately instead of downloading
  • ETag support: Uses HTTP If-None-Match / 304 Not Modified to avoid re-downloading unchanged rule sets
  • Download detour: Can route download traffic through a specified outbound (e.g., to use a proxy to fetch rule sets)
  • Update loop: After PostStart(), runs loopUpdate() in a goroutine that checks for updates at updateInterval
  • Memory management: After updating, if refs == 0 (no active rule references), the parsed rules are set to nil to free memory, with runtime.GC() called explicitly

Reference Counting ​

Both LocalRuleSet and RemoteRuleSet implement reference counting via atomic.Int32:

go
func (s *LocalRuleSet) IncRef()  { s.refs.Add(1) }
func (s *LocalRuleSet) DecRef()  {
    if s.refs.Add(-1) < 0 {
        panic("rule-set: negative refs")
    }
}
func (s *LocalRuleSet) Cleanup() {
    if s.refs.Load() == 0 {
        s.rules = nil  // free memory when no references
    }
}

This allows the router to track which rule sets are actively used by routing rules and free memory for unused ones.

Rule Set Metadata ​

After loading rules, metadata is computed to determine what kinds of lookups are needed:

go
type RuleSetMetadata struct {
    ContainsProcessRule bool  // needs process searcher
    ContainsWIFIRule    bool  // needs WIFI state
    ContainsIPCIDRRule  bool  // needs resolved IP addresses
}

These flags allow the router to skip expensive operations (like process name lookup or DNS resolution) when no rule set requires them.

IP Set Extraction ​

Rule sets support extracting all IP CIDR items into netipx.IPSet values via ExtractIPSet(). This is used for system-level optimizations like TUN routing table configuration, where IP-based rules need to be applied at the network stack level rather than per-connection.

Configuration ​

json
{
  "route": {
    "rule_set": [
      {
        "type": "local",
        "tag": "geoip-cn",
        "format": "binary",
        "path": "geoip-cn.srs"
      },
      {
        "type": "remote",
        "tag": "geosite-category-ads",
        "format": "binary",
        "url": "https://example.com/geosite-category-ads.srs",
        "download_detour": "proxy",
        "update_interval": "24h"
      },
      {
        "tag": "my-rules",
        "rules": [
          {
            "domain_suffix": [".example.com"]
          }
        ]
      }
    ]
  }
}

Reimplementation Notes ​

  1. The SRS binary format uses Go's encoding/binary with big-endian byte order and binary.ReadUvarint/varbin.WriteUvarint for variable-length integers
  2. Domain matching uses sing/common/domain.Matcher which has its own binary serialization format -- this is a dependency you must implement or import
  3. The IP set binary format uses unsafe.Pointer to directly manipulate netipx.IPSet internals -- a reimplementation should use proper IP range serialization instead
  4. zlib compression uses zlib.BestCompression level for writing
  5. Format auto-detection checks file extensions: .json = source, .srs = binary
  6. The ETag-based caching for remote rule sets must handle both 200 OK (new content) and 304 Not Modified (update timestamp only)