____ _ _______ __
/ __ \ | /| / / __/ |/ /
/ /_/ / |/ |/ / _// /
\____/|__/|__/___/_||_/
owenclave / app/src/main/java/io/nekohasekai/sagernet/group/ClashRoutingParser.kt
/******************************************************************************
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see . *
* *
******************************************************************************/
package io.nekohasekai.sagernet.group
import io.nekohasekai.sagernet.database.RuleEntity
import io.nekohasekai.sagernet.ktx.Logs
/**
* Best-effort translator from mihomo/clash-meta `proxy-groups` + `rules` (as used by
* Remnawave-style subscriptions, see owenclave#6) into owenclave's native routing
* primitives (v2ray/xray RoutingObject rules, expressed as [RuleEntity] rows).
*
* This is intentionally NOT a clash rule engine reimplementation. owenclave's engine has
* no concept of a user-selectable, multi-candidate proxy group (mihomo `select`/`fallback`/
* `url-test` groups) — a rule's outbound is always exactly one of: the subscription's proxy,
* DIRECT, REJECT, or one specific proxy entity. So every mihomo proxy-group is *collapsed*
* to one of those three outcomes by [resolveOutbound]: if any reachable member of the group
* (including nested groups, and Remnawave's `remnawave.include-proxies` extension) is a real
* proxy, the whole group routes through the proxy — never silently DIRECT, to avoid leaking
* traffic that the source config intended to tunnel. Only a group whose every branch resolves
* to `DIRECT` becomes bypass, and only a group whose every branch is `REJECT`/`REJECT-DROP`
* becomes block.
*
* What's translated:
* - `rules:` entries of type DOMAIN, DOMAIN-SUFFIX, DOMAIN-KEYWORD, IP-CIDR/IP-CIDR6, DST-PORT,
* MATCH, and RULE-SET references to *inline* `rule-providers` (expanded in place).
*
* What's explicitly skipped (collected in [ParsedRouting.warnings], not silently dropped):
* - `rule-providers` of `type: http` (remote geosite/geoip .mrs/.yaml/.lst downloads) — mapping
* these to owenclave's `geosite:`/`geoip:` categories would require guessing category names
* that may not exist in the user's geosite.dat/geoip.dat, which would silently misroute
* traffic. Left for the user to recreate manually with `geosite:`/`geoip:` rules if desired.
* - PROCESS-NAME-REGEX, IP-ASN, AND/OR/NOT logical combinators, and any other rule type without
* a direct native equivalent.
* - `dns:`, `tun:`, `sniffer:` and other network-level knobs — those are process-wide app
* settings in owenclave, not per-subscription, so auto-applying them here would have
* surprising side effects on unrelated profiles. Not attempted.
*/
object ClashRoutingParser {
data class ParsedRouting(
val rules: List,
val warnings: List,
)
private const val OUTBOUND_PROXY = 0L
private const val OUTBOUND_BYPASS = -1L
private const val OUTBOUND_BLOCK = -2L
@Suppress("UNCHECKED_CAST")
fun parse(yaml: Map<*, *>, groupId: Long, namePrefix: String): ParsedRouting {
val warnings = ArrayList()
val out = ArrayList()
val rules = yaml["rules"] as? List<*> ?: return ParsedRouting(emptyList(), emptyList())
// name -> "direct" | "reject" | "proxy" (anything else defined under `proxies:`)
val proxyKinds = HashMap()
(yaml["proxies"] as? List<*>)?.forEach { entry ->
val proxy = entry as? Map ?: return@forEach
val name = proxy["name"] as? String ?: return@forEach
proxyKinds[name] = when ((proxy["type"] as? String)?.lowercase()) {
"direct" -> "direct"
"reject", "reject-drop" -> "reject"
else -> "proxy"
}
}
// name -> group definition, for recursive resolution
data class ClashGroup(val proxies: List, val includeProxies: Boolean)
val groups = HashMap()
(yaml["proxy-groups"] as? List<*>)?.forEach { entry ->
val group = entry as? Map ?: return@forEach
val name = group["name"] as? String ?: return@forEach
val members = (group["proxies"] as? List<*>)?.mapNotNull { it as? String } ?: emptyList()
val remnawave = group["remnawave"] as? Map<*, *>
val includeProxies = (remnawave?.get("include-proxies") as? Boolean) ?: false
groups[name] = ClashGroup(members, includeProxies)
}
fun resolveOutbound(name: String, visited: MutableSet = HashSet()): Long {
when (name.uppercase()) {
"DIRECT" -> return OUTBOUND_BYPASS
"REJECT", "REJECT-DROP", "PASS" -> return OUTBOUND_BLOCK
}
proxyKinds[name]?.let {
return when (it) {
"direct" -> OUTBOUND_BYPASS
"reject" -> OUTBOUND_BLOCK
else -> OUTBOUND_PROXY
}
}
val group = groups[name] ?: return OUTBOUND_PROXY // unknown reference: never silently leak direct
if (!visited.add(name)) return OUTBOUND_PROXY // cycle guard
if (group.includeProxies) return OUTBOUND_PROXY
var sawProxy = false
var sawDirect = false
var sawBlock = false
for (member in group.proxies) {
when (resolveOutbound(member, visited)) {
OUTBOUND_PROXY -> sawProxy = true
OUTBOUND_BYPASS -> sawDirect = true
OUTBOUND_BLOCK -> sawBlock = true
}
}
return when {
sawProxy -> OUTBOUND_PROXY
sawDirect -> OUTBOUND_BYPASS
sawBlock -> OUTBOUND_BLOCK
else -> OUTBOUND_PROXY // empty group: never silently leak direct
}
}
// inline rule-providers: name -> list of "TYPE,payload" tokens (same syntax as `rules:`, minus target)
val inlineProviders = HashMap>()
val httpProviderNames = HashSet()
(yaml["rule-providers"] as? Map)?.forEach { (providerName, definition) ->
val provider = definition as? Map ?: return@forEach
when (provider["type"] as? String) {
"inline" -> {
val payload = (provider["payload"] as? List<*>)?.mapNotNull { it as? String } ?: emptyList()
inlineProviders[providerName] = payload
}
"http" -> httpProviderNames.add(providerName)
else -> {}
}
}
var userOrder = 0L
var skipped = 0
val skippedTypes = LinkedHashSet()
val skippedHttpProviders = LinkedHashSet()
fun addRule(type: String, payload: String, target: String) {
val entity = RuleEntity(name = "$namePrefix ${type.lowercase()}:$payload".take(255), userOrder = userOrder++, enabled = true)
when (type) {
"DOMAIN" -> entity.domains = "full:$payload"
"DOMAIN-SUFFIX" -> entity.domains = "domain:$payload"
"DOMAIN-KEYWORD" -> entity.domains = "keyword:$payload"
"IP-CIDR", "IP-CIDR6" -> entity.ip = payload
"DST-PORT" -> entity.port = payload
"MATCH" -> {} // no matcher fields: matches everything
else -> {
skipped++; skippedTypes.add(type); return
}
}
entity.outbound = resolveOutbound(target)
out.add(entity)
}
fun handleLine(line: String) {
val tokens = line.split(",").map { it.trim() }
if (tokens.isEmpty()) return
val type = tokens[0]
when (type) {
"MATCH" -> {
if (tokens.size >= 2) addRule("MATCH", "", tokens[1])
}
"RULE-SET" -> {
if (tokens.size < 3) return
val providerName = tokens[1]
val target = tokens[2]
when {
inlineProviders.containsKey(providerName) -> {
inlineProviders[providerName]!!.forEach { payloadLine ->
val payloadTokens = payloadLine.split(",").map { it.trim() }
if (payloadTokens.size >= 2) {
addRule(payloadTokens[0], payloadTokens[1], target)
}
}
}
httpProviderNames.contains(providerName) -> skippedHttpProviders.add(providerName)
else -> {
skipped++; skippedTypes.add("RULE-SET:$providerName")
}
}
}
"DOMAIN", "DOMAIN-SUFFIX", "DOMAIN-KEYWORD", "IP-CIDR", "IP-CIDR6", "DST-PORT" -> {
if (tokens.size >= 3) addRule(type, tokens[1], tokens[2])
}
else -> {
skipped++
skippedTypes.add(type)
}
}
}
rules.forEach { entry ->
val line = entry as? String ?: return@forEach
handleLine(line)
}
if (skipped > 0) {
warnings.add("Skipped $skipped unsupported rule(s): ${skippedTypes.joinToString(", ")}")
}
if (skippedHttpProviders.isNotEmpty()) {
warnings.add(
"Skipped remote rule-provider(s) (no reliable geosite/geoip category mapping): " +
skippedHttpProviders.joinToString(", ")
)
}
if (warnings.isNotEmpty()) {
Logs.i("ClashRoutingParser: ${warnings.joinToString(" | ")}")
out.add(0, RuleEntity(
name = "$namePrefix ${warnings.size} warning(s), see logcat".take(255),
userOrder = -1,
enabled = false,
))
}
return ParsedRouting(out, warnings)
}
}