feat(sing-box): include/exclude outbound tags with safe regex and fallback (#916)

- Add optional include/exclude patterns on selector/urltest outbounds (stripped before client)
- Safe preg_match: bare patterns use ~ delimiter with escape; delimited regex supported; invalid patterns logged, no throw
- Add fallback when filtering yields empty outbounds (string or ordered list: exact tag or pattern)
- Prevents broken routing when dynamic node sets match no filter

Made-with: Cursor

Co-authored-by: socksprox <info@shadowfly.net>
This commit is contained in:
socksprox 2026-05-03 04:06:30 +02:00 committed by GitHub
parent 689dc5ffa8
commit f2ac5db4bd

View File

@ -179,16 +179,129 @@ class SingBox extends AbstractProtocol
}
}
foreach ($outbounds as &$outbound) {
if (in_array($outbound['type'], ['urltest', 'selector'])) {
array_push($outbound['outbounds'], ...array_column($proxies, 'tag'));
if (!in_array($outbound['type'], ['urltest', 'selector'])) {
continue;
}
$include = $outbound['include'] ?? null;
$exclude = $outbound['exclude'] ?? null;
$fallback = $outbound['fallback'] ?? null;
unset($outbound['include'], $outbound['exclude'], $outbound['fallback']);
$allTags = array_column($proxies, 'tag');
$tags = $allTags;
if ($include !== null && $include !== '') {
$tags = array_values(array_filter(
$tags,
fn($tag) => $this->matchesPattern($include, $tag)
));
}
if ($exclude !== null && $exclude !== '') {
$tags = array_values(array_filter(
$tags,
fn($tag) => !$this->matchesPattern($exclude, $tag)
));
}
if (empty($tags) && $fallback !== null) {
$tags = $this->resolveFallback($fallback, $allTags, $outbounds, $outbound['tag'] ?? '');
}
if (!empty($tags)) {
array_push($outbound['outbounds'], ...$tags);
}
}
unset($outbound);
$outbounds = array_merge($outbounds, $proxies);
$this->config['outbounds'] = $outbounds;
return $outbounds;
}
/**
* Safely match a user-supplied pattern against a node tag.
*
* Accepts either:
* - a bare pattern (e.g. "HK|香港") — wrapped with `~...~ui` delimiters
* - a fully-delimited pattern (e.g. "/foo/i", "#bar#u") — used as-is
*
* Uses `~` as the default delimiter (rare in node names) and escapes any
* literal `~` in bare patterns. Invalid patterns never throw; they log a
* warning and return false so the outbound simply stays empty rather than
* breaking subscription generation.
*/
protected function matchesPattern(string $pattern, string $subject): bool
{
static $cache = [];
if (!isset($cache[$pattern])) {
$trimmed = trim($pattern);
$first = $trimmed !== '' ? $trimmed[0] : '';
$looksDelimited = in_array($first, ['/', '#', '~', '@', '%'], true)
&& preg_match('/^(.)(.*)\1[a-zA-Z]*$/us', $trimmed) === 1;
$cache[$pattern] = $looksDelimited
? $trimmed
: '~' . str_replace('~', '\~', $pattern) . '~ui';
}
$compiled = $cache[$pattern];
$result = @preg_match($compiled, $subject);
if ($result === false) {
$err = preg_last_error_msg();
Log::warning("[SingBox] invalid outbound pattern {$pattern}: {$err}");
$cache[$pattern] = '~(*FAIL)~';
return false;
}
return $result === 1;
}
/**
* Resolve a fallback value into a list of usable outbound tags.
*
* Accepted shapes (string or array, mixed freely):
* - "direct" → built-in outbound tag (direct/block/...)
* - "Singapore-03" → exact node tag
* - "节点选择" → another group's tag defined in template
* - "JP|日本" → pattern matched against available node tags
* - ["HK-01", "JP-02"] → list, each resolved in order, first hit wins
* - ["JP|日本", "direct"] → pattern first, then hard fallback
*
* Returns the first non-empty resolution. Logs if nothing resolves.
*/
protected function resolveFallback($fallback, array $allTags, array $outbounds, string $groupTag): array
{
$candidates = is_array($fallback) ? $fallback : [$fallback];
$templateTags = array_column($outbounds, 'tag');
foreach ($candidates as $candidate) {
if (!is_string($candidate) || $candidate === '') {
continue;
}
if (in_array($candidate, $allTags, true) || in_array($candidate, $templateTags, true)) {
return [$candidate];
}
$matched = array_values(array_filter(
$allTags,
fn($tag) => $this->matchesPattern($candidate, $tag)
));
if (!empty($matched)) {
return $matched;
}
}
Log::warning("[SingBox] outbound group '{$groupTag}' fallback unresolved; group left empty");
return [];
}
/**
* Build rule
*/