@@ -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