diff --git a/app/Http/Controllers/V1/Client/ClientController.php b/app/Http/Controllers/V1/Client/ClientController.php index 467131f..5dd83f1 100644 --- a/app/Http/Controllers/V1/Client/ClientController.php +++ b/app/Http/Controllers/V1/Client/ClientController.php @@ -8,136 +8,65 @@ use App\Services\ServerService; use App\Services\UserService; use App\Utils\Helper; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Log; class ClientController extends Controller { + + // 支持hy2 的客户端版本列表 + const SupportedHy2ClientVersions = [ + 'NekoBox' => '1.2.7', + 'sing-box' => '1.5.0', + 'stash' => '2.5.0', + 'Shadowrocket' => '1993', + 'ClashMetaForAndroid' => '2.9.0', + 'Nekoray' => '3.24', + 'verge' => '1.3.8', + 'ClashX Meta' => '1.3.5', + 'Hiddify' => '0.1.0', + 'loon' => '637', + 'v2rayN' => '6.31', + 'surge' => '2398' + ]; + // allowed types + const AllowedTypes = ['vmess', 'vless', 'trojan', 'hysteria', 'shadowsocks']; + public function subscribe(Request $request) { - // 节点类型筛选 - $allowedTypes = ['vmess', 'vless', 'trojan', 'hysteria', 'hysteria2', 'shadowsocks']; - $types = $request->input('types', "vmess|vless|trojan|hysteria|shadowsocks"); - if ($types === "all") $types = implode('|', $allowedTypes); - $typesArr = $types ? collect(explode('|', str_replace(['|','|',','], "|" , $types)))->reject(function($type) use ($allowedTypes){ - return !in_array($type, $allowedTypes); - })->values()->all() : []; - - // 节点关键词筛选字段获取 - $filterArr = (mb_strlen($request->input('filter')) > 20) ? null : explode("|" ,str_replace(['|','|',','], "|" , $request->input('filter'))); - - $flag = $request->input('flag') ?? $request->header('User-Agent', ''); - $flag = strtolower($flag); - $ip = $request->input('ip') ?? $request->ip(); - - preg_match('/\/v?(\d+(\.\d+){0,2})/', $flag, $matches); - $version = $matches[1]??null; - $supportedClientVersions = [ - 'NekoBox' => '1.2.7', - 'sing-box' => '1.5.0', - 'stash' => '2.5.0', - 'Shadowrocket' => '1993', - 'ClashMetaForAndroid' => '2.9.0', - 'Nekoray' => '3.24', - 'verge' => '1.3.8', - 'ClashX Meta' => '1.3.5', - 'Hiddify' => '0.1.0', - 'loon' => '637', - 'v2rayN' => '6.31', - 'surge' => '2398' - ]; - $supportHy2 = true; - if ($version) { - $supportHy2 = collect($supportedClientVersions) - ->contains(function ($minVersion, $client) use ($flag, $version) { - return stripos($flag, $client) !== false && $this->versionCompare($version, $minVersion); - }); - } + // filter types + $types = $request->input('types', 'all'); + $typesArr = $types === 'all' ? self::AllowedTypes : array_values(array_intersect(explode('|', str_replace(['|', '|', ','], "|", $types)), self::AllowedTypes)); + // filter keyword + $filterArr = mb_strlen($filter = $request->input('filter')) > 20 ? null : explode("|", str_replace(['|', '|', ','], "|", $filter)); + $flag = strtolower($request->input('flag') ?? $request->header('User-Agent', '')); + $ip = $request->input('ip', $request->ip()); + // get client version + $version = preg_match('/\/v?(\d+(\.\d+){0,2})/', $flag, $matches) ? $matches[1] : null; + $supportHy2 = $version + && collect(self::SupportedHy2ClientVersions) + ->contains(fn($minVersion, $client) => stripos($flag, $client) !== false && $this->versionCompare($version, $minVersion)); $user = $request->user; // account not expired and is not banned. $userService = new UserService(); if ($userService->isAvailable($user)) { - // 获取IP地址信息 + // get ip location $ip2region = new \Ip2Region(); - $geo = filter_var($ip,FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) ? $ip2region->memorySearch($ip) : []; - $region = $geo['region'] ?? null; - - // 获取服务器列表 + $region = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) ? ($ip2region->memorySearch($ip)['region'] ?? null) : null; + // get available servers $servers = ServerService::getAvailableServers($user); - - // 判断不满足,不满足的直接过滤掉 - $serversFiltered = collect($servers)->reject(function ($server) use ($typesArr, $filterArr, $region, $supportHy2){ - // 过滤类型 - if($typesArr){ - // 默认过滤掉hysteria2 线路 - if($server['type'] == "hysteria" && $server['version'] == 2 && !in_array('hysteria2', $typesArr) - && !$supportHy2 - ){ - return true; - } - if(!in_array($server['type'], $typesArr) && !($server['type'] == "hysteria" && $server['version'] == 2 && in_array('hysteria2', $typesArr))) return true; - } - // 过滤关键词 - if($filterArr){ - $rejectFlag = true; - foreach($filterArr as $filter){ - if(stripos($server['name'],$filter) !== false - || in_array($filter, $server['tags'] ?? []) - ) $rejectFlag = false; - } - if($rejectFlag) return true; - } - // 过滤地区 - if(strpos($region, '中国') !== false){ - $excludes = $server['excludes']; - if(blank($excludes)) return false; - foreach($excludes as $v){ - $excludeList = explode("|",str_replace(["|",","," ",","],"|",$v)); - $rejectFlag = false; - foreach($excludeList as $needle){ - if(stripos($region, $needle) !== false){ - return true; - } - } - }; - } - })->values()->all(); + // filter servers + $serversFiltered = $this->serverFilter($servers, $typesArr, $filterArr, $region, $supportHy2); $this->setSubscribeInfoToServers($serversFiltered, $user, count($servers) - count($serversFiltered)); - $servers = $serversFiltered; - - // 线路名称增加协议类型 - if (admin_setting('show_protocol_to_server_enable')){ - $typePrefixes = [ - 'hysteria' => [1 => '[Hy]', 2 => '[Hy2]'], - 'vless' => '[vless]', - 'shadowsocks' => '[ss]', - 'vmess' => '[vmess]', - 'trojan' => '[trojan]', - ]; - $servers = collect($servers)->map(function($server)use ($typePrefixes){ - if (isset($typePrefixes[$server['type']])) { - // 如果是 hysteria 类型,根据版本选择前缀 - $prefix = is_array($typePrefixes[$server['type']]) ? $typePrefixes[$server['type']][$server['version']] : $typePrefixes[$server['type']]; - // 设置服务器名称 - $server['name'] = $prefix . $server['name']; - } - return $server; - })->toArray(); - } + $this->addPrefixToServerName($servers); if ($flag) { foreach (array_reverse(glob(app_path('Protocols') . '/*.php')) as $file) { $file = 'App\\Protocols\\' . basename($file, '.php'); $class = new $file($user, $servers); $classFlags = explode(',', $class->flag); - $isMatch = function() use ($classFlags, $flag){ - foreach ($classFlags as $classFlag){ - if(stripos($flag, $classFlag) !== false) return true; + foreach ($classFlags as $classFlag) { + if (stripos($flag, $classFlag) !== false) { + return $class->handle(); } - return false; - }; - // 判断是否匹配 - if ($isMatch()) { - return $class->handle(); } } } @@ -145,16 +74,93 @@ class ClientController extends Controller return $class->handle(); } } + /** + * Summary of serverFilter + * @param mixed $typesArr + * @param mixed $filterArr + * @param mixed $region + * @param mixed $supportHy2 + * @return array + */ + private function serverFilter($servers, $typesArr, $filterArr, $region, $supportHy2) + { + return collect($servers)->reject(function ($server) use ($typesArr, $filterArr, $region, $supportHy2) { + if ( + $typesArr && ( + ($server['type'] == "hysteria" && $server['version'] == 2 && !in_array('hysteria2', $typesArr) && !$supportHy2) || + (!in_array($server['type'], $typesArr) && !($server['type'] == "hysteria" && $server['version'] == 2 && in_array('hysteria2', $typesArr))) + ) + ) { + return true; + } + if ($filterArr) { + foreach ($filterArr as $filter) { + if (stripos($server['name'], $filter) !== false || in_array($filter, $server['tags'] ?? [])) { + return false; + } + } + return true; + } + + if (strpos($region, '中国') !== false) { + $excludes = $server['excludes'] ?? []; + if (empty($excludes)) { + return false; + } + foreach ($excludes as $v) { + $excludeList = explode("|", str_replace(["|", ",", " ", ","], "|", $v)); + foreach ($excludeList as $needle) { + if (stripos($region, $needle) !== false) { + return true; + } + } + } + } + })->values()->all(); + } + /* + * add prefix to server name + */ + private function addPrefixToServerName(&$servers) + { + // 线路名称增加协议类型 + if (admin_setting('show_protocol_to_server_enable')) { + $typePrefixes = [ + 'hysteria' => [1 => '[Hy]', 2 => '[Hy2]'], + 'vless' => '[vless]', + 'shadowsocks' => '[ss]', + 'vmess' => '[vmess]', + 'trojan' => '[trojan]', + ]; + $servers = collect($servers)->map(function ($server) use ($typePrefixes) { + if (isset($typePrefixes[$server['type']])) { + $prefix = is_array($typePrefixes[$server['type']]) ? $typePrefixes[$server['type']][$server['version']] : $typePrefixes[$server['type']]; + $server['name'] = $prefix . $server['name']; + } + return $server; + })->toArray(); + } + } + + /** + * Summary of setSubscribeInfoToServers + * @param mixed $servers + * @param mixed $user + * @param mixed $rejectServerCount + * @return void + */ private function setSubscribeInfoToServers(&$servers, $user, $rejectServerCount = 0) { - if (!isset($servers[0])) return; - if($rejectServerCount > 0){ + if (!isset($servers[0])) + return; + if ($rejectServerCount > 0) { array_unshift($servers, array_merge($servers[0], [ - 'name' => "去除{$rejectServerCount}条不合适线路", + 'name' => "过滤掉{$rejectServerCount}条线路", ])); } - if (!(int)admin_setting('show_info_to_server_enable', 0)) return; + if (!(int) admin_setting('show_info_to_server_enable', 0)) + return; $useTraffic = $user['u'] + $user['d']; $totalTraffic = $user['transfer_enable']; $remainingTraffic = Helper::trafficConvert($totalTraffic - $useTraffic); @@ -180,7 +186,8 @@ class ClientController extends Controller * 判断版本号 */ - function versionCompare($version1, $version2) { + function versionCompare($version1, $version2) + { if (!preg_match('/^\d+(\.\d+){0,2}/', $version1) || !preg_match('/^\d+(\.\d+){0,2}/', $version2)) { return false; } @@ -190,8 +197,8 @@ class ClientController extends Controller $maxParts = max(count($v1Parts), count($v2Parts)); for ($i = 0; $i < $maxParts; $i++) { - $part1 = isset($v1Parts[$i]) ? (int)$v1Parts[$i] : 0; - $part2 = isset($v2Parts[$i]) ? (int)$v2Parts[$i] : 0; + $part1 = isset($v1Parts[$i]) ? (int) $v1Parts[$i] : 0; + $part2 = isset($v2Parts[$i]) ? (int) $v2Parts[$i] : 0; if ($part1 < $part2) { return false; diff --git a/app/Protocols/Clash.php b/app/Protocols/Clash.php index b87daa5..2374063 100644 --- a/app/Protocols/Clash.php +++ b/app/Protocols/Clash.php @@ -2,6 +2,7 @@ namespace App\Protocols; +use App\Utils\Helper; use phpDocumentor\Reflection\Types\Self_; use Symfony\Component\Yaml\Yaml; @@ -32,11 +33,6 @@ class Clash $proxy = []; $proxies = []; - // 增加不支持提示 - // array_push($proxy, [ "name" => "您的客户端不支持", "type" => "vmess", "server" => "1.1.1.1", "port" => 80, "uuid" => "aaaaaaaa-bbbb-cccc-cccc-dddddddddddd", "alterId" => 0, "cipher" => "auto", "udp" => false, "tls" => false]); - // array_push($proxies, "您的客户端不支持"); - // array_push($proxy, [ "name" => "请使用clash Meta内核的客户端", "type" => "vmess", "server" => "1.1.1.1", "port" => 80, "uuid" => "aaaaaaaa-bbbb-cccc-cccc-dddddddddddd", "alterId" => 0, "cipher" => "auto", "udp" => false, "tls" => false]); - // array_push($proxies, "请使用clash Meta内核的客户端"); foreach ($servers as $item) { if ($item['type'] === 'shadowsocks' @@ -83,11 +79,9 @@ class Clash return $group['proxies']; }); $config['proxy-groups'] = array_values($config['proxy-groups']); - // Force the current subscription domain to be a direct rule - $subsDomain = request()->header('Host'); - if ($subsDomain) { - array_unshift($config['rules'], "DOMAIN,{$subsDomain},DIRECT"); - } + + $config = $this->buildRules($config); + $yaml = Yaml::dump($config, 2, 4, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE); $yaml = str_replace('$app_name', admin_setting('app_name', 'XBoard'), $yaml); @@ -98,6 +92,27 @@ class Clash ->header('profile-web-page-url', admin_setting('app_url')); } + /** + * Build the rules for Clash. + */ + public function buildRules($config) + { + // Force the current subscription domain to be a direct rule + $subsDomain = request()->header('Host'); + if ($subsDomain) { + array_unshift($config['rules'], "DOMAIN,{$subsDomain},DIRECT"); + } + // Force the nodes ip to be a direct rule + collect($this->servers)->pluck('host')->map(function($host){ + $host = trim($host); + return filter_var($host, FILTER_VALIDATE_IP) ? [$host] : Helper::getIpByDomainName($host); + })->flatten()->unique()->each(function($nodeIP) use ( &$config ) { + array_unshift($config['rules'], "IP-CIDR,{$nodeIP}/32,DIRECT,no-resolve"); + }); + + return $config; + } + public static function buildShadowsocks($uuid, $server) { $array = []; diff --git a/app/Protocols/ClashMeta.php b/app/Protocols/ClashMeta.php index a212e12..aff8982 100644 --- a/app/Protocols/ClashMeta.php +++ b/app/Protocols/ClashMeta.php @@ -81,11 +81,7 @@ class ClashMeta return $group['proxies']; }); $config['proxy-groups'] = array_values($config['proxy-groups']); - // Force the current subscription domain to be a direct rule - $subsDomain = request()->header('Host'); - if ($subsDomain) { - array_unshift($config['rules'], "DOMAIN,{$subsDomain},DIRECT"); - } + $config = $this->buildRules($config); $yaml = Yaml::dump($config, 2, 4, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE); $yaml = str_replace('$app_name', admin_setting('app_name', 'XBoard'), $yaml); @@ -95,6 +91,27 @@ class ClashMeta ->header('content-disposition', 'attachment;filename*=UTF-8\'\'' . rawurlencode($appName)); } + /** + * Build the rules for Clash. + */ + public function buildRules($config) + { + // Force the current subscription domain to be a direct rule + $subsDomain = request()->header('Host'); + if ($subsDomain) { + array_unshift($config['rules'], "DOMAIN,{$subsDomain},DIRECT"); + } + // Force the nodes ip to be a direct rule + collect($this->servers)->pluck('host')->map(function($host){ + $host = trim($host); + return filter_var($host, FILTER_VALIDATE_IP) ? [$host] : Helper::getIpByDomainName($host); + })->flatten()->unique()->each(function($nodeIP) use ( &$config ) { + array_unshift($config['rules'], "IP-CIDR,{$nodeIP}/32,DIRECT,no-resolve"); + }); + + return $config; + } + public static function buildShadowsocks($password, $server) { $array = []; diff --git a/app/Protocols/SingBox.php b/app/Protocols/SingBox.php index 7de8e13..a0e1e18 100644 --- a/app/Protocols/SingBox.php +++ b/app/Protocols/SingBox.php @@ -21,12 +21,13 @@ class SingBox $appName = admin_setting('app_name', 'XBoard'); $this->config = $this->loadConfig(); $this->buildOutbounds(); + $this->buildRule(); $user = $this->user; - return response($this->config, 200) + return response() + ->json($this->config) ->header('subscription-userinfo', "upload={$user['u']}; download={$user['d']}; total={$user['transfer_enable']}; expire={$user['expired_at']}") - ->header('profile-update-interval', '24') - ->header('content-disposition', 'attachment;filename*=UTF-8\'\'' . rawurlencode($appName)); + ->header('profile-update-interval', '24'); } protected function loadConfig() @@ -75,6 +76,21 @@ class SingBox return $outbounds; } + /** + * Build rule + */ + protected function buildRule(){ + $rules = $this->config['route']['rules']; + // Force the nodes ip to be a direct rule + array_unshift($rules, [ + 'ip_cidr' => collect($this->servers)->pluck('host')->map(function($host){ + return filter_var($host, FILTER_VALIDATE_IP) ? [$host] : Helper::getIpByDomainName($host); + })->flatten()->unique()->values(), + 'outbound' => 'direct', + ]); + $this->config['route']['rules'] = $rules; + } + protected function buildShadowsocks($password, $server) { $array = []; diff --git a/app/Utils/Helper.php b/app/Utils/Helper.php index 3b3921f..c3e60f2 100644 --- a/app/Utils/Helper.php +++ b/app/Utils/Helper.php @@ -129,25 +129,34 @@ class Helper } /** - * 替换字符串中的 [num1-num2] 格式为介于 num1 和 num2 之间的随机数字 + * 根据规则替换域名中对应的字符串 * * @param string $input 用户输入的字符串 * @return string 替换后的字符串 */ - public static function replaceRandomNumber($input) { - // 匹配 [1-4999] 格式的正则表达式 - $pattern = '/\[(\d+)-(\d+)\]/'; - - // 使用 preg_replace_callback 替换匹配到的内容 - $result = preg_replace_callback($pattern, function ($matches) { - // 提取最小和最大值 - $min = intval($matches[1]); - $max = intval($matches[2]); - // 生成随机数 - $randomNumber = rand($min, $max); - return $randomNumber; - }, $input); - - return $result; + public static function replaceByPattern($input) + { + $patterns = [ + '/\[(\d+)-(\d+)\]/' => function ($matches) { + $min = intval($matches[1]); + $max = intval($matches[2]); + if ($min > $max) { + list($min, $max) = [$max, $min]; + } + $randomNumber = rand($min, $max); + return $randomNumber; + }, + '/\[uuid\]/' => function () { + return self::guid(true); + } + ]; + foreach ($patterns as $pattern => $callback) { + $input = preg_replace_callback($pattern, $callback, $input); + } + return $input; + } + + public static function getIpByDomainName($domain) { + return gethostbynamel($domain) ?: []; } }