Skip to content

Commit ee2c12e

Browse files
socksproxsocksprox
andauthored
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>
1 parent 19b9753 commit ee2c12e

1 file changed

Lines changed: 115 additions & 2 deletions

File tree

app/Protocols/SingBox.php

Lines changed: 115 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,16 +179,129 @@ protected function buildOutbounds()
179179
}
180180
}
181181
foreach ($outbounds as &$outbound) {
182-
if (in_array($outbound['type'], ['urltest', 'selector'])) {
183-
array_push($outbound['outbounds'], ...array_column($proxies, 'tag'));
182+
if (!in_array($outbound['type'], ['urltest', 'selector'])) {
183+
continue;
184+
}
185+
186+
$include = $outbound['include'] ?? null;
187+
$exclude = $outbound['exclude'] ?? null;
188+
$fallback = $outbound['fallback'] ?? null;
189+
unset($outbound['include'], $outbound['exclude'], $outbound['fallback']);
190+
191+
$allTags = array_column($proxies, 'tag');
192+
$tags = $allTags;
193+
194+
if ($include !== null && $include !== '') {
195+
$tags = array_values(array_filter(
196+
$tags,
197+
fn($tag) => $this->matchesPattern($include, $tag)
198+
));
199+
}
200+
201+
if ($exclude !== null && $exclude !== '') {
202+
$tags = array_values(array_filter(
203+
$tags,
204+
fn($tag) => !$this->matchesPattern($exclude, $tag)
205+
));
206+
}
207+
208+
if (empty($tags) && $fallback !== null) {
209+
$tags = $this->resolveFallback($fallback, $allTags, $outbounds, $outbound['tag'] ?? '');
210+
}
211+
212+
if (!empty($tags)) {
213+
array_push($outbound['outbounds'], ...$tags);
184214
}
185215
}
216+
unset($outbound);
186217

187218
$outbounds = array_merge($outbounds, $proxies);
188219
$this->config['outbounds'] = $outbounds;
189220
return $outbounds;
190221
}
191222

223+
/**
224+
* Safely match a user-supplied pattern against a node tag.
225+
*
226+
* Accepts either:
227+
* - a bare pattern (e.g. "HK|香港") — wrapped with `~...~ui` delimiters
228+
* - a fully-delimited pattern (e.g. "/foo/i", "#bar#u") — used as-is
229+
*
230+
* Uses `~` as the default delimiter (rare in node names) and escapes any
231+
* literal `~` in bare patterns. Invalid patterns never throw; they log a
232+
* warning and return false so the outbound simply stays empty rather than
233+
* breaking subscription generation.
234+
*/
235+
protected function matchesPattern(string $pattern, string $subject): bool
236+
{
237+
static $cache = [];
238+
239+
if (!isset($cache[$pattern])) {
240+
$trimmed = trim($pattern);
241+
$first = $trimmed !== '' ? $trimmed[0] : '';
242+
$looksDelimited = in_array($first, ['/', '#', '~', '@', '%'], true)
243+
&& preg_match('/^(.)(.*)\1[a-zA-Z]*$/us', $trimmed) === 1;
244+
245+
$cache[$pattern] = $looksDelimited
246+
? $trimmed
247+
: '~' . str_replace('~', '\~', $pattern) . '~ui';
248+
}
249+
250+
$compiled = $cache[$pattern];
251+
252+
$result = @preg_match($compiled, $subject);
253+
254+
if ($result === false) {
255+
$err = preg_last_error_msg();
256+
Log::warning("[SingBox] invalid outbound pattern {$pattern}: {$err}");
257+
$cache[$pattern] = '~(*FAIL)~';
258+
return false;
259+
}
260+
261+
return $result === 1;
262+
}
263+
264+
/**
265+
* Resolve a fallback value into a list of usable outbound tags.
266+
*
267+
* Accepted shapes (string or array, mixed freely):
268+
* - "direct" → built-in outbound tag (direct/block/...)
269+
* - "Singapore-03" → exact node tag
270+
* - "节点选择" → another group's tag defined in template
271+
* - "JP|日本" → pattern matched against available node tags
272+
* - ["HK-01", "JP-02"] → list, each resolved in order, first hit wins
273+
* - ["JP|日本", "direct"] → pattern first, then hard fallback
274+
*
275+
* Returns the first non-empty resolution. Logs if nothing resolves.
276+
*/
277+
protected function resolveFallback($fallback, array $allTags, array $outbounds, string $groupTag): array
278+
{
279+
$candidates = is_array($fallback) ? $fallback : [$fallback];
280+
$templateTags = array_column($outbounds, 'tag');
281+
282+
foreach ($candidates as $candidate) {
283+
if (!is_string($candidate) || $candidate === '') {
284+
continue;
285+
}
286+
287+
if (in_array($candidate, $allTags, true) || in_array($candidate, $templateTags, true)) {
288+
return [$candidate];
289+
}
290+
291+
$matched = array_values(array_filter(
292+
$allTags,
293+
fn($tag) => $this->matchesPattern($candidate, $tag)
294+
));
295+
296+
if (!empty($matched)) {
297+
return $matched;
298+
}
299+
}
300+
301+
Log::warning("[SingBox] outbound group '{$groupTag}' fallback unresolved; group left empty");
302+
return [];
303+
}
304+
192305
/**
193306
* Build rule
194307
*/

0 commit comments

Comments
 (0)