@@ -670,18 +670,36 @@ impl Platform for AndroidPlatform {
670670 if engine != CoreEngine :: SingBox {
671671 return ;
672672 }
673- // The sing-box "system" stack can't grab tun connections in this root-binary
674- // data-path without sing-box's own nftables output redirect, which only
675- // catches network-bound sockets when strict_route is on. An Android specific,
676- // so it lives here, not the neutral builder. (gvisor needs neither.)
673+ // Android specifics the neutral builder must not assume, so they live here:
674+ // - The sing-box "system" stack can't grab tun connections in this
675+ // root-binary data-path without sing-box's own nftables output redirect,
676+ // which only catches network-bound sockets when strict_route is on.
677+ // (gvisor needs neither.)
678+ // - Root (uid 0) must bypass the tun: the daemon and the core itself run as
679+ // root, and this per-uid policy model spares root instead of marking
680+ // sockets. Prepended to every capture-all tun (one with an `include_uid`
681+ // allowlist can't capture root in the first place). Idempotent — a
682+ // config that already excludes root is left as-is.
677683 if let Some ( inbounds) = config. get_mut ( "inbounds" ) . and_then ( |v| v. as_array_mut ( ) ) {
678684 for ib in inbounds {
679- let is_system_tun = ib. get ( "type" ) . and_then ( Value :: as_str) == Some ( "tun" )
680- && ib. get ( "stack" ) . and_then ( Value :: as_str) == Some ( "system" ) ;
681- if is_system_tun {
685+ if ib. get ( "type" ) . and_then ( Value :: as_str) != Some ( "tun" ) {
686+ continue ;
687+ }
688+ if ib. get ( "stack" ) . and_then ( Value :: as_str) == Some ( "system" ) {
682689 ib[ "auto_redirect" ] = Value :: Bool ( true ) ;
683690 ib[ "strict_route" ] = Value :: Bool ( true ) ;
684691 }
692+ if ib. get ( "include_uid" ) . is_none ( ) {
693+ let mut uids = ib
694+ . get ( "exclude_uid" )
695+ . and_then ( Value :: as_array)
696+ . cloned ( )
697+ . unwrap_or_default ( ) ;
698+ if !uids. iter ( ) . any ( |v| v. as_i64 ( ) == Some ( 0 ) ) {
699+ uids. insert ( 0 , Value :: from ( 0i64 ) ) ;
700+ ib[ "exclude_uid" ] = Value :: Array ( uids) ;
701+ }
702+ }
685703 }
686704 }
687705 }
@@ -787,4 +805,54 @@ mod tests {
787805 assert_eq ! ( bin, SINGBOX_BIN ) ;
788806 assert ! ( cfg. ends_with( "singbox.json" ) ) ;
789807 }
808+
809+ #[ test]
810+ fn tune_config_excludes_root_from_capture_all_tuns ( ) {
811+ let platform = AndroidPlatform :: new ( ) ;
812+ // A neutral build: gvisor main tun with app-filter bypass uids, plus a
813+ // force tun with an include_uid allowlist.
814+ let mut cfg = serde_json:: json!( { "inbounds" : [
815+ { "type" : "tun" , "tag" : "tun-in" , "stack" : "gvisor" ,
816+ "exclude_uid" : [ 10001 ] } ,
817+ { "type" : "tun" , "tag" : "tun-force" , "stack" : "gvisor" ,
818+ "include_uid" : [ 10002 ] } ,
819+ { "type" : "mixed" , "tag" : "socks-in" } ,
820+ ] } ) ;
821+ platform. tune_config ( CoreEngine :: SingBox , & mut cfg) ;
822+ // Root heads the exclusion of the capture-all tun (the daemon and core run
823+ // as root); the allowlisted force tun and non-tun inbounds are untouched.
824+ assert_eq ! (
825+ cfg[ "inbounds" ] [ 0 ] [ "exclude_uid" ] ,
826+ serde_json:: json!( [ 0 , 10001 ] )
827+ ) ;
828+ assert ! ( cfg[ "inbounds" ] [ 1 ] . get( "exclude_uid" ) . is_none( ) ) ;
829+ assert ! ( cfg[ "inbounds" ] [ 2 ] . get( "exclude_uid" ) . is_none( ) ) ;
830+ // Tuning is idempotent: a second pass doesn't duplicate the root exclusion.
831+ platform. tune_config ( CoreEngine :: SingBox , & mut cfg) ;
832+ assert_eq ! (
833+ cfg[ "inbounds" ] [ 0 ] [ "exclude_uid" ] ,
834+ serde_json:: json!( [ 0 , 10001 ] )
835+ ) ;
836+
837+ // A capture-all tun without any app filter still gets the root exclusion.
838+ let mut cfg = serde_json:: json!( { "inbounds" : [
839+ { "type" : "tun" , "tag" : "tun-in" , "stack" : "gvisor" } ,
840+ ] } ) ;
841+ platform. tune_config ( CoreEngine :: SingBox , & mut cfg) ;
842+ assert_eq ! ( cfg[ "inbounds" ] [ 0 ] [ "exclude_uid" ] , serde_json:: json!( [ 0 ] ) ) ;
843+
844+ // The system stack additionally needs sing-box's own output redirect.
845+ let mut cfg = serde_json:: json!( { "inbounds" : [
846+ { "type" : "tun" , "tag" : "tun-in" , "stack" : "system" } ,
847+ ] } ) ;
848+ platform. tune_config ( CoreEngine :: SingBox , & mut cfg) ;
849+ assert_eq ! ( cfg[ "inbounds" ] [ 0 ] [ "auto_redirect" ] , true ) ;
850+ assert_eq ! ( cfg[ "inbounds" ] [ 0 ] [ "strict_route" ] , true ) ;
851+ assert_eq ! ( cfg[ "inbounds" ] [ 0 ] [ "exclude_uid" ] , serde_json:: json!( [ 0 ] ) ) ;
852+
853+ // Xray configs pass through untouched.
854+ let mut cfg = serde_json:: json!( { "inbounds" : [ { "type" : "tun" } ] } ) ;
855+ platform. tune_config ( CoreEngine :: Xray , & mut cfg) ;
856+ assert ! ( cfg[ "inbounds" ] [ 0 ] . get( "exclude_uid" ) . is_none( ) ) ;
857+ }
790858}
0 commit comments