Merge remote-tracking branch 'upstream/master'
# Conflicts: # app/Services/UserOnlineService.php # public/assets/admin
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
use App\Services\NodeSyncService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
class CheckTrafficExceeded extends Command
|
||||
{
|
||||
protected $signature = 'check:traffic-exceeded';
|
||||
protected $description = '检查流量超标用户并通知节点';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$count = Redis::scard('traffic:pending_check');
|
||||
if ($count <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$pendingUserIds = array_map('intval', Redis::spop('traffic:pending_check', $count));
|
||||
|
||||
$exceededUsers = User::toBase()
|
||||
->whereIn('id', $pendingUserIds)
|
||||
->whereRaw('u + d >= transfer_enable')
|
||||
->where('transfer_enable', '>', 0)
|
||||
->where('banned', 0)
|
||||
->select(['id', 'group_id'])
|
||||
->get();
|
||||
|
||||
if ($exceededUsers->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$groupedUsers = $exceededUsers->groupBy('group_id');
|
||||
$notifiedCount = 0;
|
||||
|
||||
foreach ($groupedUsers as $groupId => $users) {
|
||||
if (!$groupId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$userIdsInGroup = $users->pluck('id')->toArray();
|
||||
$servers = Server::whereJsonContains('group_ids', (string) $groupId)->get();
|
||||
|
||||
foreach ($servers as $server) {
|
||||
if (!NodeSyncService::isNodeOnline($server->id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
NodeSyncService::push($server->id, 'sync.user.delta', [
|
||||
'action' => 'remove',
|
||||
'users' => array_map(fn($id) => ['id' => $id], $userIdsInGroup),
|
||||
]);
|
||||
$notifiedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Checked " . count($pendingUserIds) . " users, notified {$notifiedCount} nodes for " . $exceededUsers->count() . " exceeded users.");
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class CleanupExpiredOnlineStatus extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'cleanup:expired-online-status';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Reset online_count to 0 for users stale for 5+ minutes';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
try {
|
||||
$affected = 0;
|
||||
User::query()
|
||||
->where('online_count', '>', 0)
|
||||
->where('last_online_at', '<', now()->subMinutes(5))
|
||||
->chunkById(1000, function ($users) use (&$affected) {
|
||||
if ($users->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
$count = User::whereIn('id', $users->pluck('id'))
|
||||
->update(['online_count' => 0]);
|
||||
$affected += $count;
|
||||
}, 'id');
|
||||
|
||||
$this->info("Expired online status cleaned. Affected: {$affected}");
|
||||
return self::SUCCESS;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('CleanupExpiredOnlineStatus failed', ['error' => $e->getMessage()]);
|
||||
$this->error('Cleanup failed: ' . $e->getMessage());
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,8 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Services\NodeSyncService;
|
||||
use App\Services\NodeRegistry;
|
||||
use App\Services\ServerService;
|
||||
use App\WebSocket\NodeWorker;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
use Workerman\Connection\TcpConnection;
|
||||
use Workerman\Timer;
|
||||
use Workerman\Worker;
|
||||
|
||||
class NodeWebSocketServer extends Command
|
||||
{
|
||||
@@ -24,18 +15,11 @@ class NodeWebSocketServer extends Command
|
||||
|
||||
protected $description = 'Start the WebSocket server for node-panel synchronization';
|
||||
|
||||
/** Auth timeout in seconds — close unauthenticated connections */
|
||||
private const AUTH_TIMEOUT = 10;
|
||||
|
||||
/** Ping interval in seconds */
|
||||
private const PING_INTERVAL = 55;
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
global $argv;
|
||||
$action = $this->argument('action');
|
||||
|
||||
// 重新构建 argv 供 Workerman 解析
|
||||
$argv[1] = $action;
|
||||
if ($this->option('d')) {
|
||||
$argv[2] = '-d';
|
||||
@@ -44,236 +28,7 @@ class NodeWebSocketServer extends Command
|
||||
$host = $this->option('host');
|
||||
$port = $this->option('port');
|
||||
|
||||
$worker = new Worker("websocket://{$host}:{$port}");
|
||||
$worker->count = 1;
|
||||
$worker->name = 'xboard-ws-server';
|
||||
|
||||
// 设置日志和 PID 文件路径
|
||||
$logPath = storage_path('logs');
|
||||
if (!is_dir($logPath)) {
|
||||
mkdir($logPath, 0777, true);
|
||||
}
|
||||
Worker::$logFile = $logPath . '/xboard-ws-server.log'; // 指向具体文件,避免某些环境 php://stdout 的 stat 失败
|
||||
Worker::$pidFile = $logPath . '/xboard-ws-server.pid';
|
||||
|
||||
$worker->onWorkerStart = function (Worker $worker) {
|
||||
$this->info("[WS] Worker started, pid={$worker->id}");
|
||||
$this->subscribeRedis();
|
||||
|
||||
// Periodic ping to detect dead connections
|
||||
Timer::add(self::PING_INTERVAL, function () {
|
||||
foreach (NodeRegistry::getConnectedNodeIds() as $nodeId) {
|
||||
$conn = NodeRegistry::get($nodeId);
|
||||
if ($conn) {
|
||||
$conn->send(json_encode(['event' => 'ping']));
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
$worker->onConnect = function (TcpConnection $conn) {
|
||||
// Set auth timeout — must authenticate within N seconds or get disconnected
|
||||
$conn->authTimer = Timer::add(self::AUTH_TIMEOUT, function () use ($conn) {
|
||||
if (empty($conn->nodeId)) {
|
||||
$conn->close(json_encode([
|
||||
'event' => 'error',
|
||||
'data' => ['message' => 'auth timeout'],
|
||||
]));
|
||||
}
|
||||
}, [], false);
|
||||
};
|
||||
|
||||
$worker->onWebSocketConnect = function (TcpConnection $conn, $httpMessage) {
|
||||
// Parse query string from the WebSocket upgrade request
|
||||
// In Workerman 4.x/5.x with onWebSocketConnect, the second arg can be a string or Request object
|
||||
$queryString = '';
|
||||
if (is_string($httpMessage)) {
|
||||
$queryString = parse_url($httpMessage, PHP_URL_QUERY) ?? '';
|
||||
} elseif ($httpMessage instanceof \Workerman\Protocols\Http\Request) {
|
||||
$queryString = $httpMessage->queryString();
|
||||
}
|
||||
|
||||
parse_str($queryString, $params);
|
||||
|
||||
$token = $params['token'] ?? '';
|
||||
$nodeId = (int) ($params['node_id'] ?? 0);
|
||||
|
||||
// Authenticate
|
||||
$serverToken = admin_setting('server_token', '');
|
||||
if ($token === '' || $serverToken === '' || !hash_equals($serverToken, $token)) {
|
||||
$conn->close(json_encode([
|
||||
'event' => 'error',
|
||||
'data' => ['message' => 'invalid token'],
|
||||
]));
|
||||
return;
|
||||
}
|
||||
|
||||
$node = Server::find($nodeId);
|
||||
if (!$node) {
|
||||
$conn->close(json_encode([
|
||||
'event' => 'error',
|
||||
'data' => ['message' => 'node not found'],
|
||||
]));
|
||||
return;
|
||||
}
|
||||
|
||||
// Auth passed — cancel timeout, register connection
|
||||
if (isset($conn->authTimer)) {
|
||||
Timer::del($conn->authTimer);
|
||||
}
|
||||
|
||||
$conn->nodeId = $nodeId;
|
||||
NodeRegistry::add($nodeId, $conn);
|
||||
Cache::put("node_ws_alive:{$nodeId}", true, 86400);
|
||||
|
||||
Log::debug("[WS] Node#{$nodeId} connected", [
|
||||
'remote' => $conn->getRemoteIp(),
|
||||
'total' => NodeRegistry::count(),
|
||||
]);
|
||||
|
||||
// Send auth success
|
||||
$conn->send(json_encode([
|
||||
'event' => 'auth.success',
|
||||
'data' => ['node_id' => $nodeId],
|
||||
]));
|
||||
|
||||
// Push full sync (config + users) immediately to this specific connection
|
||||
$this->pushFullSync($conn, $node);
|
||||
};
|
||||
|
||||
$worker->onMessage = function (TcpConnection $conn, $data) {
|
||||
$msg = json_decode($data, true);
|
||||
if (!is_array($msg)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$event = $msg['event'] ?? '';
|
||||
$nodeId = $conn->nodeId ?? null;
|
||||
|
||||
switch ($event) {
|
||||
case 'pong':
|
||||
// Heartbeat response — node is alive
|
||||
if ($nodeId) {
|
||||
Cache::put("node_ws_alive:{$nodeId}", true, 86400);
|
||||
}
|
||||
break;
|
||||
case 'node.status':
|
||||
if ($nodeId && isset($msg['data'])) {
|
||||
$this->handleNodeStatus($nodeId, $msg['data']);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// Future: handle other node-initiated messages if needed
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
$worker->onClose = function (TcpConnection $conn) {
|
||||
if (!empty($conn->nodeId)) {
|
||||
$nodeId = $conn->nodeId;
|
||||
NodeRegistry::remove($nodeId);
|
||||
Cache::forget("node_ws_alive:{$nodeId}");
|
||||
Log::debug("[WS] Node#{$nodeId} disconnected", [
|
||||
'total' => NodeRegistry::count(),
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
Worker::runAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle status data pushed from node via WebSocket
|
||||
*/
|
||||
private function handleNodeStatus(int $nodeId, array $data): void
|
||||
{
|
||||
$node = Server::find($nodeId);
|
||||
if (!$node) return;
|
||||
|
||||
$nodeType = strtoupper($node->type);
|
||||
|
||||
// Update last check-in cache
|
||||
Cache::put(\App\Utils\CacheKey::get('SERVER_' . $nodeType . '_LAST_CHECK_AT', $nodeId), time(), 3600);
|
||||
|
||||
// Update metrics cache via Service
|
||||
ServerService::updateMetrics($node, $data);
|
||||
|
||||
Log::debug("[WS] Node#{$nodeId} status updated via WebSocket");
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to Redis pub/sub channel for receiving push commands from Laravel.
|
||||
* Laravel app publishes to "node:push" channel, Workerman picks it up and forwards to the right node.
|
||||
*/
|
||||
private function subscribeRedis(): void
|
||||
{
|
||||
$host = config('database.redis.default.host', '127.0.0.1');
|
||||
$port = config('database.redis.default.port', 6379);
|
||||
|
||||
// Handle Unix Socket connection
|
||||
if (str_starts_with($host, '/')) {
|
||||
$redisUri = "unix://{$host}";
|
||||
} else {
|
||||
$redisUri = "redis://{$host}:{$port}";
|
||||
}
|
||||
|
||||
$redis = new \Workerman\Redis\Client($redisUri);
|
||||
|
||||
$password = config('database.redis.default.password');
|
||||
if ($password) {
|
||||
$redis->auth($password);
|
||||
}
|
||||
|
||||
// Get Laravel Redis prefix to match publish()
|
||||
$prefix = config('database.redis.options.prefix', '');
|
||||
$channel = $prefix . 'node:push';
|
||||
|
||||
$redis->subscribe([$channel], function ($chan, $message) {
|
||||
$payload = json_decode($message, true);
|
||||
if (!is_array($payload)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$nodeId = $payload['node_id'] ?? null;
|
||||
$event = $payload['event'] ?? '';
|
||||
$data = $payload['data'] ?? [];
|
||||
|
||||
if (!$nodeId || !$event) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sent = NodeRegistry::send((int) $nodeId, $event, $data);
|
||||
if ($sent) {
|
||||
Log::debug("[WS] Pushed {$event} to node#{$nodeId}");
|
||||
}
|
||||
});
|
||||
|
||||
$this->info("[WS] Subscribed to Redis channel: {$channel}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Push full config + users to a newly connected node.
|
||||
*/
|
||||
private function pushFullSync(TcpConnection $conn, Server $node): void
|
||||
{
|
||||
$nodeId = $conn->nodeId;
|
||||
// Push config
|
||||
$config = ServerService::buildNodeConfig($node);
|
||||
Log::debug("[WS] Node#{$nodeId} config: ", $config);
|
||||
$conn->send(json_encode([
|
||||
'event' => 'sync.config',
|
||||
'data' => ['config' => $config]
|
||||
]));
|
||||
|
||||
// Push users
|
||||
$users = ServerService::getAvailableUsers($node)->toArray();
|
||||
$conn->send(json_encode([
|
||||
'event' => 'sync.users',
|
||||
'data' => ['users' => $users]
|
||||
]));
|
||||
|
||||
Log::info("[WS] Full sync pushed to node#{$nodeId}", [
|
||||
'users' => count($users),
|
||||
]);
|
||||
$worker = new NodeWorker($host, $port);
|
||||
$worker->run();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ class ResetPassword extends Command
|
||||
public function handle()
|
||||
{
|
||||
$password = $this->argument('password') ;
|
||||
$user = User::where('email', $this->argument('email'))->first();
|
||||
$user = User::byEmail($this->argument('email'))->first();
|
||||
if (!$user) abort(500, '邮箱不存在');
|
||||
$password = $password ?? Helper::guid(false);
|
||||
$user->password = password_hash($password, PASSWORD_DEFAULT);
|
||||
|
||||
@@ -7,7 +7,6 @@ use App\Utils\CacheKey;
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use App\Services\UserOnlineService;
|
||||
|
||||
class Kernel extends ConsoleKernel
|
||||
{
|
||||
@@ -35,6 +34,7 @@ class Kernel extends ConsoleKernel
|
||||
$schedule->command('check:order')->everyMinute()->onOneServer()->withoutOverlapping(5);
|
||||
$schedule->command('check:commission')->everyMinute()->onOneServer()->withoutOverlapping(5);
|
||||
$schedule->command('check:ticket')->everyMinute()->onOneServer()->withoutOverlapping(5);
|
||||
$schedule->command('check:traffic-exceeded')->everyMinute()->onOneServer()->withoutOverlapping(10)->runInBackground();
|
||||
// reset
|
||||
$schedule->command('reset:traffic')->everyMinute()->onOneServer()->withoutOverlapping(10);
|
||||
$schedule->command('reset:log')->daily()->onOneServer();
|
||||
@@ -46,8 +46,6 @@ class Kernel extends ConsoleKernel
|
||||
// if (env('ENABLE_AUTO_BACKUP_AND_UPDATE', false)) {
|
||||
// $schedule->command('backup:database', ['true'])->daily()->onOneServer();
|
||||
// }
|
||||
$schedule->command('cleanup:expired-online-status')->everyMinute()->onOneServer()->withoutOverlapping(4);
|
||||
|
||||
app(PluginManager::class)->registerPluginSchedules($schedule);
|
||||
|
||||
}
|
||||
|
||||
@@ -80,7 +80,8 @@ class ClientController extends Controller
|
||||
'user' => $user,
|
||||
'servers' => $serversFiltered,
|
||||
'clientName' => $clientInfo['name'] ?? null,
|
||||
'clientVersion' => $clientInfo['version'] ?? null
|
||||
'clientVersion' => $clientInfo['version'] ?? null,
|
||||
'userAgent' => $clientInfo['flag'] ?? null
|
||||
]);
|
||||
|
||||
return $protocolInstance->handle();
|
||||
|
||||
@@ -29,7 +29,7 @@ class CommController extends Controller
|
||||
|
||||
// 检查白名单后缀限制
|
||||
if ((int) admin_setting('email_whitelist_enable', 0)) {
|
||||
$isRegisteredEmail = User::where('email', $email)->exists();
|
||||
$isRegisteredEmail = User::byEmail($email)->exists();
|
||||
if (!$isRegisteredEmail) {
|
||||
$allowedSuffixes = Helper::getEmailSuffix();
|
||||
$emailSuffix = substr(strrchr($email, '@'), 1);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace App\Http\Controllers\V1\Server;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\UserAliveSyncJob;
|
||||
use App\Services\DeviceStateService;
|
||||
use App\Services\NodeSyncService;
|
||||
use App\Services\ServerService;
|
||||
use App\Services\UserService;
|
||||
@@ -11,13 +11,12 @@ use App\Utils\CacheKey;
|
||||
use App\Utils\Helper;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use App\Services\UserOnlineService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class UniProxyController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserOnlineService $userOnlineService
|
||||
private readonly DeviceStateService $deviceStateService
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -103,13 +102,15 @@ class UniProxyController extends Controller
|
||||
return response($response)->header('ETag', "\"{$eTag}\"");
|
||||
}
|
||||
|
||||
// 获取在线用户数据(wyx2685
|
||||
// 获取在线用户数据
|
||||
public function alivelist(Request $request): JsonResponse
|
||||
{
|
||||
$node = $this->getNodeInfo($request);
|
||||
$deviceLimitUsers = ServerService::getAvailableUsers($node)
|
||||
->where('device_limit', '>', 0);
|
||||
$alive = $this->userOnlineService->getAliveList($deviceLimitUsers);
|
||||
|
||||
$alive = $this->deviceStateService->getAliveList(collect($deviceLimitUsers));
|
||||
|
||||
return response()->json(['alive' => (object) $alive]);
|
||||
}
|
||||
|
||||
@@ -123,7 +124,11 @@ class UniProxyController extends Controller
|
||||
'error' => 'Invalid online data'
|
||||
], 400);
|
||||
}
|
||||
UserAliveSyncJob::dispatch($data, $node->type, $node->id);
|
||||
|
||||
foreach ($data as $uid => $ips) {
|
||||
$this->deviceStateService->setDevices((int) $uid, $node->id, $ips);
|
||||
}
|
||||
|
||||
return response()->json(['data' => true]);
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,14 @@ class UserController extends Controller
|
||||
if (!$user->save()) {
|
||||
return $this->fail([400, __('Save failed')]);
|
||||
}
|
||||
|
||||
$currentToken = $user->currentAccessToken();
|
||||
if ($currentToken) {
|
||||
$user->tokens()->where('id', '!=', $currentToken->id)->delete();
|
||||
} else {
|
||||
$user->tokens()->delete();
|
||||
}
|
||||
|
||||
return $this->success(true);
|
||||
}
|
||||
|
||||
|
||||
@@ -199,7 +199,7 @@ class OrderController extends Controller
|
||||
public function assign(OrderAssign $request)
|
||||
{
|
||||
$plan = Plan::find($request->input('plan_id'));
|
||||
$user = User::where('email', $request->input('email'))->first();
|
||||
$user = User::byEmail($request->input('email'))->first();
|
||||
|
||||
if (!$user) {
|
||||
return $this->fail([400202, '该用户不存在']);
|
||||
|
||||
@@ -111,6 +111,94 @@ class ManageController extends Controller
|
||||
return $this->success(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除节点
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function batchDelete(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'ids' => 'required|array',
|
||||
'ids.*' => 'integer',
|
||||
]);
|
||||
|
||||
$ids = $request->input('ids');
|
||||
if (empty($ids)) {
|
||||
return $this->fail([400, '请选择要删除的节点']);
|
||||
}
|
||||
|
||||
try {
|
||||
$deleted = Server::whereIn('id', $ids)->delete();
|
||||
if ($deleted === false) {
|
||||
return $this->fail([500, '批量删除失败']);
|
||||
}
|
||||
return $this->success(true);
|
||||
} catch (\Exception $e) {
|
||||
Log::error($e);
|
||||
return $this->fail([500, '批量删除失败']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置节点流量
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function resetTraffic(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'id' => 'required|integer',
|
||||
]);
|
||||
|
||||
$server = Server::find($request->id);
|
||||
if (!$server) {
|
||||
return $this->fail([400202, '服务器不存在']);
|
||||
}
|
||||
|
||||
try {
|
||||
$server->u = 0;
|
||||
$server->d = 0;
|
||||
$server->save();
|
||||
|
||||
Log::info("Server {$server->id} ({$server->name}) traffic reset by admin");
|
||||
return $this->success(true);
|
||||
} catch (\Exception $e) {
|
||||
Log::error($e);
|
||||
return $this->fail([500, '重置失败']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量重置节点流量
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function batchResetTraffic(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'ids' => 'required|array',
|
||||
'ids.*' => 'integer',
|
||||
]);
|
||||
|
||||
$ids = $request->input('ids');
|
||||
if (empty($ids)) {
|
||||
return $this->fail([400, '请选择要重置的节点']);
|
||||
}
|
||||
|
||||
try {
|
||||
Server::whereIn('id', $ids)->update([
|
||||
'u' => 0,
|
||||
'd' => 0,
|
||||
]);
|
||||
|
||||
Log::info("Servers " . implode(',', $ids) . " traffic reset by admin");
|
||||
return $this->success(true);
|
||||
} catch (\Exception $e) {
|
||||
Log::error($e);
|
||||
return $this->fail([500, '批量重置失败']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制节点
|
||||
@@ -120,12 +208,17 @@ class ManageController extends Controller
|
||||
public function copy(Request $request)
|
||||
{
|
||||
$server = Server::find($request->input('id'));
|
||||
$server->show = 0;
|
||||
$server->code = null;
|
||||
if (!$server) {
|
||||
return $this->fail([400202, '服务器不存在']);
|
||||
}
|
||||
Server::create($server->toArray());
|
||||
|
||||
$copiedServer = $server->replicate();
|
||||
$copiedServer->show = 0;
|
||||
$copiedServer->code = null;
|
||||
$copiedServer->u = 0;
|
||||
$copiedServer->d = 0;
|
||||
$copiedServer->save();
|
||||
|
||||
return $this->success(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,10 +53,17 @@ class UserController extends Controller
|
||||
collect($request->input('filter'))->each(function ($filter) use ($builder) {
|
||||
$field = $filter['id'];
|
||||
$value = $filter['value'];
|
||||
$logic = strtolower($filter['logic'] ?? 'and');
|
||||
|
||||
$builder->where(function ($query) use ($field, $value) {
|
||||
$this->buildFilterQuery($query, $field, $value);
|
||||
});
|
||||
if ($logic === 'or') {
|
||||
$builder->orWhere(function ($query) use ($field, $value) {
|
||||
$this->buildFilterQuery($query, $field, $value);
|
||||
});
|
||||
} else {
|
||||
$builder->where(function ($query) use ($field, $value) {
|
||||
$this->buildFilterQuery($query, $field, $value);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -213,7 +220,7 @@ class UserController extends Controller
|
||||
return $this->fail([400202, '用户不存在']);
|
||||
}
|
||||
if (isset($params['email'])) {
|
||||
if (User::where('email', $params['email'])->first() && $user->email !== $params['email']) {
|
||||
if (User::byEmail($params['email'])->first() && $user->email !== $params['email']) {
|
||||
return $this->fail([400201, '邮箱已被使用']);
|
||||
}
|
||||
}
|
||||
@@ -233,7 +240,7 @@ class UserController extends Controller
|
||||
$params['group_id'] = $plan->group_id;
|
||||
}
|
||||
// 处理邀请用户
|
||||
if ($request->input('invite_user_email') && $inviteUser = User::where('email', $request->input('invite_user_email'))->first()) {
|
||||
if ($request->input('invite_user_email') && $inviteUser = User::byEmail($request->input('invite_user_email'))->first()) {
|
||||
$params['invite_user_id'] = $inviteUser->id;
|
||||
} else {
|
||||
$params['invite_user_id'] = null;
|
||||
@@ -356,9 +363,15 @@ class UserController extends Controller
|
||||
public function generate(UserGenerate $request)
|
||||
{
|
||||
if ($request->input('email_prefix')) {
|
||||
// If generate_count is specified with email_prefix, generate multiple users with incremented emails
|
||||
if ($request->input('generate_count')) {
|
||||
return $this->multiGenerateWithPrefix($request);
|
||||
}
|
||||
|
||||
// Single user generation with email_prefix
|
||||
$email = $request->input('email_prefix') . '@' . $request->input('email_suffix');
|
||||
|
||||
if (User::where('email', $email)->exists()) {
|
||||
if (User::byEmail($email)->exists()) {
|
||||
return $this->fail([400201, '邮箱已存在于系统中']);
|
||||
}
|
||||
|
||||
@@ -452,6 +465,87 @@ class UserController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
private function multiGenerateWithPrefix(Request $request)
|
||||
{
|
||||
$userService = app(UserService::class);
|
||||
$usersData = [];
|
||||
$emailPrefix = $request->input('email_prefix');
|
||||
$emailSuffix = $request->input('email_suffix');
|
||||
$generateCount = $request->input('generate_count');
|
||||
|
||||
// Check if any of the emails with prefix already exist
|
||||
for ($i = 1; $i <= $generateCount; $i++) {
|
||||
$email = $emailPrefix . '_' . $i . '@' . $emailSuffix;
|
||||
if (User::where('email', $email)->exists()) {
|
||||
return $this->fail([400201, '邮箱 ' . $email . ' 已存在于系统中']);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate user data for batch creation
|
||||
for ($i = 1; $i <= $generateCount; $i++) {
|
||||
$email = $emailPrefix . '_' . $i . '@' . $emailSuffix;
|
||||
$usersData[] = [
|
||||
'email' => $email,
|
||||
'password' => $request->input('password') ?? $email,
|
||||
'plan_id' => $request->input('plan_id'),
|
||||
'expired_at' => $request->input('expired_at'),
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
$users = [];
|
||||
foreach ($usersData as $userData) {
|
||||
$user = $userService->createUser($userData);
|
||||
$user->save();
|
||||
$users[] = $user;
|
||||
}
|
||||
DB::commit();
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
return $this->fail([500, '生成失败']);
|
||||
}
|
||||
|
||||
// 判断是否导出 CSV
|
||||
if ($request->input('download_csv')) {
|
||||
$headers = [
|
||||
'Content-Type' => 'text/csv',
|
||||
'Content-Disposition' => 'attachment; filename="users.csv"',
|
||||
];
|
||||
$callback = function () use ($users, $request) {
|
||||
$handle = fopen('php://output', 'w');
|
||||
fputcsv($handle, ['账号', '密码', '过期时间', 'UUID', '创建时间', '订阅地址']);
|
||||
foreach ($users as $user) {
|
||||
$user = $user->refresh();
|
||||
$expireDate = $user['expired_at'] === NULL ? '长期有效' : date('Y-m-d H:i:s', $user['expired_at']);
|
||||
$createDate = date('Y-m-d H:i:s', $user['created_at']);
|
||||
$password = $request->input('password') ?? $user['email'];
|
||||
$subscribeUrl = Helper::getSubscribeUrl($user['token']);
|
||||
fputcsv($handle, [$user['email'], $password, $expireDate, $user['uuid'], $createDate, $subscribeUrl]);
|
||||
}
|
||||
fclose($handle);
|
||||
};
|
||||
return response()->streamDownload($callback, 'users.csv', $headers);
|
||||
}
|
||||
|
||||
// 默认返回 JSON
|
||||
$data = collect($users)->map(function ($user) use ($request) {
|
||||
return [
|
||||
'email' => $user['email'],
|
||||
'password' => $request->input('password') ?? $user['email'],
|
||||
'expired_at' => $user['expired_at'] === NULL ? '长期有效' : date('Y-m-d H:i:s', $user['expired_at']),
|
||||
'uuid' => $user['uuid'],
|
||||
'created_at' => date('Y-m-d H:i:s', $user['created_at']),
|
||||
'subscribe_url' => Helper::getSubscribeUrl($user['token']),
|
||||
];
|
||||
});
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'message' => '批量生成成功',
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
public function sendMail(UserSendMail $request)
|
||||
{
|
||||
ini_set('memory_limit', '-1');
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V2\Client;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\ServerService;
|
||||
use App\Services\UserService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class AppController extends Controller
|
||||
{
|
||||
public function getConfig(Request $request)
|
||||
{
|
||||
$config = [
|
||||
'app_info' => [
|
||||
'app_name' => admin_setting('app_name', 'XB加速器'), // 应用名称
|
||||
'app_description' => admin_setting('app_description', '专业的网络加速服务'), // 应用描述
|
||||
'app_url' => admin_setting('app_url', 'https://app.example.com'), // 应用官网 URL
|
||||
'logo' => admin_setting('logo', 'https://example.com/logo.png'), // 应用 Logo URL
|
||||
'version' => admin_setting('app_version', '1.0.0'), // 应用版本号
|
||||
],
|
||||
'features' => [
|
||||
'enable_register' => (bool) admin_setting('app_enable_register', true), // 是否开启注册功能
|
||||
'enable_invite_system' => (bool) admin_setting('app_enable_invite_system', true), // 是否开启邀请系统
|
||||
'enable_telegram_bot' => (bool) admin_setting('telegram_bot_enable', false), // 是否开启 Telegram 机器人
|
||||
'enable_ticket_system' => (bool) admin_setting('app_enable_ticket_system', true), // 是否开启工单系统
|
||||
'ticket_must_wait_reply' => (bool) admin_setting('ticket_must_wait_reply', 0), // 工单是否需要等待管理员回复后才可继续发消息
|
||||
'enable_commission_system' => (bool) admin_setting('app_enable_commission_system', true), // 是否开启佣金系统
|
||||
'enable_traffic_log' => (bool) admin_setting('app_enable_traffic_log', true), // 是否开启流量日志
|
||||
'enable_knowledge_base' => (bool) admin_setting('app_enable_knowledge_base', true), // 是否开启知识库
|
||||
'enable_announcements' => (bool) admin_setting('app_enable_announcements', true), // 是否开启公告系统
|
||||
'enable_auto_renewal' => (bool) admin_setting('app_enable_auto_renewal', false), // 是否开启自动续费
|
||||
'enable_coupon_system' => (bool) admin_setting('app_enable_coupon_system', true), // 是否开启优惠券系统
|
||||
'enable_speed_test' => (bool) admin_setting('app_enable_speed_test', true), // 是否开启测速功能
|
||||
'enable_server_ping' => (bool) admin_setting('app_enable_server_ping', true), // 是否开启服务器延迟检测
|
||||
],
|
||||
'ui_config' => [
|
||||
'theme' => [
|
||||
'primary_color' => admin_setting('app_primary_color', '#00C851'), // 主色调 (十六进制)
|
||||
'secondary_color' => admin_setting('app_secondary_color', '#007E33'), // 辅助色 (十六进制)
|
||||
'accent_color' => admin_setting('app_accent_color', '#FF6B35'), // 强调色 (十六进制)
|
||||
'background_color' => admin_setting('app_background_color', '#F5F5F5'), // 背景色 (十六进制)
|
||||
'text_color' => admin_setting('app_text_color', '#333333'), // 文字色 (十六进制)
|
||||
],
|
||||
'home_screen' => [
|
||||
'show_speed_test' => (bool) admin_setting('app_show_speed_test', true), // 是否显示测速
|
||||
'show_traffic_chart' => (bool) admin_setting('app_show_traffic_chart', true), // 是否显示流量图表
|
||||
'show_server_ping' => (bool) admin_setting('app_show_server_ping', true), // 是否显示服务器延迟
|
||||
'default_server_sort' => admin_setting('app_default_server_sort', 'ping'), // 默认服务器排序方式
|
||||
'show_connection_status' => (bool) admin_setting('app_show_connection_status', true), // 是否显示连接状态
|
||||
],
|
||||
'server_list' => [
|
||||
'show_country_flags' => (bool) admin_setting('app_show_country_flags', true), // 是否显示国家旗帜
|
||||
'show_ping_values' => (bool) admin_setting('app_show_ping_values', true), // 是否显示延迟值
|
||||
'show_traffic_usage' => (bool) admin_setting('app_show_traffic_usage', true), // 是否显示流量使用
|
||||
'group_by_country' => (bool) admin_setting('app_group_by_country', false), // 是否按国家分组
|
||||
'show_server_status' => (bool) admin_setting('app_show_server_status', true), // 是否显示服务器状态
|
||||
],
|
||||
],
|
||||
'business_rules' => [
|
||||
'min_password_length' => (int) admin_setting('app_min_password_length', 8), // 最小密码长度
|
||||
'max_login_attempts' => (int) admin_setting('app_max_login_attempts', 5), // 最大登录尝试次数
|
||||
'session_timeout_minutes' => (int) admin_setting('app_session_timeout_minutes', 30), // 会话超时时间(分钟)
|
||||
'auto_disconnect_after_minutes' => (int) admin_setting('app_auto_disconnect_after_minutes', 60), // 自动断开连接时间(分钟)
|
||||
'max_concurrent_connections' => (int) admin_setting('app_max_concurrent_connections', 3), // 最大并发连接数
|
||||
'traffic_warning_threshold' => (float) admin_setting('app_traffic_warning_threshold', 0.8), // 流量警告阈值(0-1)
|
||||
'subscription_reminder_days' => admin_setting('app_subscription_reminder_days', [7, 3, 1]), // 订阅到期提醒天数
|
||||
'connection_timeout_seconds' => (int) admin_setting('app_connection_timeout_seconds', 10), // 连接超时时间(秒)
|
||||
'health_check_interval_seconds' => (int) admin_setting('app_health_check_interval_seconds', 30), // 健康检查间隔(秒)
|
||||
],
|
||||
'server_config' => [
|
||||
'default_kernel' => admin_setting('app_default_kernel', 'clash'), // 默认内核 (clash/singbox)
|
||||
'auto_select_fastest' => (bool) admin_setting('app_auto_select_fastest', true), // 是否自动选择最快服务器
|
||||
'fallback_servers' => admin_setting('app_fallback_servers', ['server1', 'server2']), // 备用服务器列表
|
||||
'enable_auto_switch' => (bool) admin_setting('app_enable_auto_switch', true), // 是否开启自动切换
|
||||
'switch_threshold_ms' => (int) admin_setting('app_switch_threshold_ms', 1000), // 切换阈值(毫秒)
|
||||
],
|
||||
'security_config' => [
|
||||
'tos_url' => admin_setting('tos_url', 'https://example.com/tos'), // 服务条款 URL
|
||||
'privacy_policy_url' => admin_setting('app_privacy_policy_url', 'https://example.com/privacy'), // 隐私政策 URL
|
||||
'is_email_verify' => (int) admin_setting('email_verify', 1), // 是否开启邮箱验证 (0/1)
|
||||
'is_invite_force' => (int) admin_setting('invite_force', 0), // 是否强制邀请码 (0/1)
|
||||
'email_whitelist_suffix' => (int) admin_setting('email_whitelist_suffix', 0), // 邮箱白名单后缀 (0/1)
|
||||
'is_captcha' => (int) admin_setting('captcha_enable', 1), // 是否开启验证码 (0/1)
|
||||
'captcha_type' => admin_setting('captcha_type', 'recaptcha'), // 验证码类型 (recaptcha/turnstile)
|
||||
'recaptcha_site_key' => admin_setting('recaptcha_site_key', '6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI'), // reCAPTCHA 站点密钥
|
||||
'recaptcha_v3_site_key' => admin_setting('recaptcha_v3_site_key', '6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI'), // reCAPTCHA v3 站点密钥
|
||||
'recaptcha_v3_score_threshold' => (float) admin_setting('recaptcha_v3_score_threshold', 0.5), // reCAPTCHA v3 分数阈值
|
||||
'turnstile_site_key' => admin_setting('turnstile_site_key', '0x4AAAAAAAABkMYinukE8nzUg'), // Turnstile 站点密钥
|
||||
],
|
||||
'payment_config' => [
|
||||
'currency' => admin_setting('currency', 'CNY'), // 货币类型
|
||||
'currency_symbol' => admin_setting('currency_symbol', '¥'), // 货币符号
|
||||
'withdraw_methods' => admin_setting('app_withdraw_methods', ['alipay', 'wechat', 'bank']), // 提现方式列表
|
||||
'min_withdraw_amount' => (int) admin_setting('app_min_withdraw_amount', 100), // 最小提现金额(分)
|
||||
'withdraw_fee_rate' => (float) admin_setting('app_withdraw_fee_rate', 0.01), // 提现手续费率
|
||||
],
|
||||
'notification_config' => [
|
||||
'enable_push_notifications' => (bool) admin_setting('app_enable_push_notifications', true), // 是否开启推送通知
|
||||
'enable_email_notifications' => (bool) admin_setting('app_enable_email_notifications', true), // 是否开启邮件通知
|
||||
'enable_sms_notifications' => (bool) admin_setting('app_enable_sms_notifications', false), // 是否开启短信通知
|
||||
'notification_schedule' => [
|
||||
'traffic_warning' => (bool) admin_setting('app_notification_traffic_warning', true), // 流量警告通知
|
||||
'subscription_expiry' => (bool) admin_setting('app_notification_subscription_expiry', true), // 订阅到期通知
|
||||
'server_maintenance' => (bool) admin_setting('app_notification_server_maintenance', true), // 服务器维护通知
|
||||
'promotional_offers' => (bool) admin_setting('app_notification_promotional_offers', false), // 促销优惠通知
|
||||
],
|
||||
],
|
||||
'cache_config' => [
|
||||
'config_cache_duration' => (int) admin_setting('app_config_cache_duration', 3600), // 配置缓存时长(秒)
|
||||
'server_list_cache_duration' => (int) admin_setting('app_server_list_cache_duration', 1800), // 服务器列表缓存时长(秒)
|
||||
'user_info_cache_duration' => (int) admin_setting('app_user_info_cache_duration', 900), // 用户信息缓存时长(秒)
|
||||
],
|
||||
'last_updated' => time(), // 最后更新时间戳
|
||||
];
|
||||
$config['config_hash'] = md5(json_encode($config)); // 配置哈希值(用于校验)
|
||||
|
||||
$config = $config ?? [];
|
||||
return response()->json(['data' => $config]);
|
||||
}
|
||||
|
||||
public function getVersion(Request $request)
|
||||
{
|
||||
if (
|
||||
strpos($request->header('user-agent'), 'tidalab/4.0.0') !== false
|
||||
|| strpos($request->header('user-agent'), 'tunnelab/4.0.0') !== false
|
||||
) {
|
||||
if (strpos($request->header('user-agent'), 'Win64') !== false) {
|
||||
$data = [
|
||||
'version' => admin_setting('windows_version'),
|
||||
'download_url' => admin_setting('windows_download_url')
|
||||
];
|
||||
} else {
|
||||
$data = [
|
||||
'version' => admin_setting('macos_version'),
|
||||
'download_url' => admin_setting('macos_download_url')
|
||||
];
|
||||
}
|
||||
} else {
|
||||
$data = [
|
||||
'windows_version' => admin_setting('windows_version'),
|
||||
'windows_download_url' => admin_setting('windows_download_url'),
|
||||
'macos_version' => admin_setting('macos_version'),
|
||||
'macos_download_url' => admin_setting('macos_download_url'),
|
||||
'android_version' => admin_setting('android_version'),
|
||||
'android_download_url' => admin_setting('android_download_url')
|
||||
];
|
||||
}
|
||||
return $this->success($data);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace App\Http\Controllers\V2\Server;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\UserAliveSyncJob;
|
||||
use App\Services\DeviceStateService;
|
||||
use App\Services\ServerService;
|
||||
use App\Services\UserService;
|
||||
use App\Utils\CacheKey;
|
||||
@@ -83,7 +83,10 @@ class ServerController extends Controller
|
||||
// handle alive data
|
||||
$alive = $request->input('alive');
|
||||
if (is_array($alive) && !empty($alive)) {
|
||||
UserAliveSyncJob::dispatch($alive, $nodeType, $nodeId);
|
||||
$deviceStateService = app(DeviceStateService::class);
|
||||
foreach ($alive as $uid => $ips) {
|
||||
$deviceStateService->setDevices((int) $uid, $nodeId, (array) $ips);
|
||||
}
|
||||
}
|
||||
|
||||
// handle active connections
|
||||
@@ -127,7 +130,7 @@ class ServerController extends Controller
|
||||
// handle node metrics (Metrics)
|
||||
$metrics = $request->input('metrics');
|
||||
if (is_array($metrics) && !empty($metrics)) {
|
||||
ServerService::updateMetrics($node, $metrics);
|
||||
ServerService::updateMetrics($node, $metrics);
|
||||
}
|
||||
|
||||
return response()->json(['data' => true]);
|
||||
|
||||
@@ -42,10 +42,17 @@ class ServerSave extends FormRequest
|
||||
'tls_settings.allow_insecure' => 'nullable|boolean',
|
||||
],
|
||||
'trojan' => [
|
||||
'tls' => 'nullable|integer',
|
||||
'network' => 'required|string',
|
||||
'network_settings' => 'nullable|array',
|
||||
'server_name' => 'nullable|string',
|
||||
'allow_insecure' => 'nullable|boolean',
|
||||
'reality_settings.allow_insecure' => 'nullable|boolean',
|
||||
'reality_settings.server_name' => 'nullable|string',
|
||||
'reality_settings.server_port' => 'nullable|integer',
|
||||
'reality_settings.public_key' => 'nullable|string',
|
||||
'reality_settings.private_key' => 'nullable|string',
|
||||
'reality_settings.short_id' => 'nullable|string',
|
||||
],
|
||||
'hysteria' => [
|
||||
'version' => 'required|integer',
|
||||
@@ -64,6 +71,10 @@ class ServerSave extends FormRequest
|
||||
'network' => 'required|string',
|
||||
'network_settings' => 'nullable|array',
|
||||
'flow' => 'nullable|string',
|
||||
'encryption' => 'nullable|array',
|
||||
'encryption.enabled' => 'nullable|boolean',
|
||||
'encryption.encryption' => 'nullable|string',
|
||||
'encryption.decryption' => 'nullable|string',
|
||||
'tls_settings.server_name' => 'nullable|string',
|
||||
'tls_settings.allow_insecure' => 'nullable|boolean',
|
||||
'reality_settings.allow_insecure' => 'nullable|boolean',
|
||||
@@ -121,6 +132,7 @@ class ServerSave extends FormRequest
|
||||
'rate_time_ranges.*.end' => 'required_with:rate_time_ranges|string|date_format:H:i',
|
||||
'rate_time_ranges.*.rate' => 'required_with:rate_time_ranges|numeric|min:0',
|
||||
'protocol_settings' => 'array',
|
||||
'transfer_enable' => 'nullable|integer|min:0',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -193,6 +205,8 @@ class ServerSave extends FormRequest
|
||||
'protocol_settings.*.string' => ':attribute 必须是字符串',
|
||||
'protocol_settings.*.integer' => ':attribute 必须是整数',
|
||||
'protocol_settings.*.in' => ':attribute 的值不合法',
|
||||
'transfer_enable.integer' => '流量上限必须是整数',
|
||||
'transfer_enable.min' => '流量上限不能小于0',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,9 @@ class AdminRoute
|
||||
$router->post('/drop', [ManageController::class, 'drop']);
|
||||
$router->post('/copy', [ManageController::class, 'copy']);
|
||||
$router->post('/sort', [ManageController::class, 'sort']);
|
||||
$router->post('/batchDelete', [ManageController::class, 'batchDelete']);
|
||||
$router->post('/resetTraffic', [ManageController::class, 'resetTraffic']);
|
||||
$router->post('/batchResetTraffic', [ManageController::class, 'batchResetTraffic']);
|
||||
});
|
||||
|
||||
// Order
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Models\StatServer;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
@@ -59,12 +61,23 @@ class StatServerJob implements ShouldQueue
|
||||
|
||||
try {
|
||||
$this->processServerStat($u, $d, $recordAt);
|
||||
$this->updateServerTraffic($u, $d);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('StatServerJob failed for server ' . $this->server['id'] . ': ' . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
protected function updateServerTraffic(int $u, int $d): void
|
||||
{
|
||||
DB::table('v2_server')
|
||||
->where('id', $this->server['id'])
|
||||
->incrementEach(
|
||||
['u' => $u, 'd' => $d],
|
||||
['updated_at' => Carbon::now()]
|
||||
);
|
||||
}
|
||||
|
||||
protected function processServerStat(int $u, int $d, int $recordAt): void
|
||||
{
|
||||
$driver = config('database.default');
|
||||
|
||||
@@ -90,8 +90,8 @@ class StatUserJob implements ShouldQueue
|
||||
|
||||
if ($existingRecord) {
|
||||
$existingRecord->update([
|
||||
'u' => $existingRecord->u + ($v[0] * $this->server['rate']),
|
||||
'd' => $existingRecord->d + ($v[1] * $this->server['rate']),
|
||||
'u' => $existingRecord->u + intval($v[0] * $this->server['rate']),
|
||||
'd' => $existingRecord->d + intval($v[1] * $this->server['rate']),
|
||||
'updated_at' => time(),
|
||||
]);
|
||||
} else {
|
||||
@@ -102,8 +102,8 @@ class StatUserJob implements ShouldQueue
|
||||
'server_type' => $serverType,
|
||||
'record_at' => $recordAt,
|
||||
'record_type' => $this->recordType,
|
||||
'u' => ($v[0] * $this->server['rate']),
|
||||
'd' => ($v[1] * $this->server['rate']),
|
||||
'u' => intval($v[0] * $this->server['rate']),
|
||||
'd' => intval($v[1] * $this->server['rate']),
|
||||
'created_at' => time(),
|
||||
'updated_at' => time(),
|
||||
]);
|
||||
@@ -124,8 +124,8 @@ class StatUserJob implements ShouldQueue
|
||||
'server_type' => $serverType,
|
||||
'record_at' => $recordAt,
|
||||
'record_type' => $this->recordType,
|
||||
'u' => ($v[0] * $this->server['rate']),
|
||||
'd' => ($v[1] * $this->server['rate']),
|
||||
'u' => intval($v[0] * $this->server['rate']),
|
||||
'd' => intval($v[1] * $this->server['rate']),
|
||||
'created_at' => time(),
|
||||
'updated_at' => time(),
|
||||
],
|
||||
|
||||
@@ -8,6 +8,7 @@ use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
class TrafficFetchJob implements ShouldQueue
|
||||
{
|
||||
@@ -19,11 +20,6 @@ class TrafficFetchJob implements ShouldQueue
|
||||
public $tries = 1;
|
||||
public $timeout = 20;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(array $server, array $data, $protocol, int $timestamp)
|
||||
{
|
||||
$this->onQueue('traffic_fetch');
|
||||
@@ -35,6 +31,8 @@ class TrafficFetchJob implements ShouldQueue
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$userIds = array_keys($this->data);
|
||||
|
||||
foreach ($this->data as $uid => $v) {
|
||||
User::where('id', $uid)
|
||||
->incrementEach(
|
||||
@@ -45,5 +43,9 @@ class TrafficFetchJob implements ShouldQueue
|
||||
['t' => time()]
|
||||
);
|
||||
}
|
||||
|
||||
if (!empty($userIds)) {
|
||||
Redis::sadd('traffic:pending_check', ...$userIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use App\Services\UserOnlineService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class UserAliveSyncJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
private const CACHE_PREFIX = 'ALIVE_IP_USER_';
|
||||
private const CACHE_TTL = 120;
|
||||
private const NODE_DATA_EXPIRY = 100;
|
||||
|
||||
public function __construct(
|
||||
private readonly array $data,
|
||||
private readonly string $nodeType,
|
||||
private readonly int $nodeId
|
||||
) {
|
||||
$this->onQueue('user_alive_sync');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
try {
|
||||
$updateAt = time();
|
||||
$nowTs = time();
|
||||
$now = now();
|
||||
$nodeKey = $this->nodeType . $this->nodeId;
|
||||
$userUpdates = [];
|
||||
|
||||
foreach ($this->data as $uid => $ips) {
|
||||
$cacheKey = self::CACHE_PREFIX . $uid;
|
||||
$ipsArray = Cache::get($cacheKey, []);
|
||||
$ipsArray = [
|
||||
...collect($ipsArray)
|
||||
->filter(fn(mixed $value): bool => is_array($value) && ($updateAt - ($value['lastupdateAt'] ?? 0) <= self::NODE_DATA_EXPIRY)),
|
||||
$nodeKey => [
|
||||
'aliveips' => $ips,
|
||||
'lastupdateAt' => $updateAt,
|
||||
],
|
||||
];
|
||||
|
||||
$count = UserOnlineService::calculateDeviceCount($ipsArray);
|
||||
$ipsArray['alive_ip'] = $count;
|
||||
Cache::put($cacheKey, $ipsArray, now()->addSeconds(self::CACHE_TTL));
|
||||
|
||||
$userUpdates[] = [
|
||||
'id' => (int) $uid,
|
||||
'count' => (int) $count,
|
||||
];
|
||||
}
|
||||
|
||||
if (!empty($userUpdates)) {
|
||||
$allIds = collect($userUpdates)
|
||||
->pluck('id')
|
||||
->filter()
|
||||
->map(fn($v) => (int) $v)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if (!empty($allIds)) {
|
||||
$existingIds = User::query()
|
||||
->whereIn('id', $allIds)
|
||||
->pluck('id')
|
||||
->map(fn($v) => (int) $v)
|
||||
->all();
|
||||
|
||||
if (!empty($existingIds)) {
|
||||
collect($userUpdates)
|
||||
->filter(fn($row) => in_array((int) ($row['id'] ?? 0), $existingIds, true))
|
||||
->chunk(1000)
|
||||
->each(function ($chunk) use ($now) {
|
||||
collect($chunk)->each(function ($update) use ($now) {
|
||||
$id = (int) ($update['id'] ?? 0);
|
||||
$count = (int) ($update['count'] ?? 0);
|
||||
if ($id > 0) {
|
||||
User::query()
|
||||
->whereKey($id)
|
||||
->update([
|
||||
'online_count' => $count,
|
||||
'last_online_at' => $now,
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('UserAliveSyncJob failed', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
$this->fail($e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
+31
-9
@@ -52,6 +52,10 @@ use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
* @property int|null $d 下行流量
|
||||
* @property int|null $total 总流量
|
||||
* @property-read array|null $load_status 负载状态(包含CPU、内存、交换区、磁盘信息)
|
||||
*
|
||||
* @property int $transfer_enable 流量上限,0或者null表示不限制
|
||||
* @property int $u 当前上传流量
|
||||
* @property int $d 当前下载流量
|
||||
*/
|
||||
class Server extends Model
|
||||
{
|
||||
@@ -124,6 +128,9 @@ class Server extends Model
|
||||
'updated_at' => 'timestamp',
|
||||
'rate_time_ranges' => 'array',
|
||||
'rate_time_enable' => 'boolean',
|
||||
'transfer_enable' => 'integer',
|
||||
'u' => 'integer',
|
||||
'd' => 'integer',
|
||||
];
|
||||
|
||||
private const MULTIPLEX_CONFIGURATION = [
|
||||
@@ -148,6 +155,20 @@ class Server extends Model
|
||||
]
|
||||
];
|
||||
|
||||
private const REALITY_CONFIGURATION = [
|
||||
'reality_settings' => [
|
||||
'type' => 'object',
|
||||
'fields' => [
|
||||
'server_name' => ['type' => 'string', 'default' => null],
|
||||
'server_port' => ['type' => 'string', 'default' => null],
|
||||
'public_key' => ['type' => 'string', 'default' => null],
|
||||
'private_key' => ['type' => 'string', 'default' => null],
|
||||
'short_id' => ['type' => 'string', 'default' => null],
|
||||
'allow_insecure' => ['type' => 'boolean', 'default' => false],
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
private const UTLS_CONFIGURATION = [
|
||||
'utls' => [
|
||||
'type' => 'object',
|
||||
@@ -160,10 +181,12 @@ class Server extends Model
|
||||
|
||||
private const PROTOCOL_CONFIGURATIONS = [
|
||||
self::TYPE_TROJAN => [
|
||||
'tls' => ['type' => 'integer', 'default' => 1],
|
||||
'network' => ['type' => 'string', 'default' => null],
|
||||
'network_settings' => ['type' => 'array', 'default' => null],
|
||||
'server_name' => ['type' => 'string', 'default' => null],
|
||||
'allow_insecure' => ['type' => 'boolean', 'default' => false],
|
||||
...self::REALITY_CONFIGURATION,
|
||||
...self::MULTIPLEX_CONFIGURATION,
|
||||
...self::UTLS_CONFIGURATION
|
||||
],
|
||||
@@ -180,19 +203,18 @@ class Server extends Model
|
||||
'tls' => ['type' => 'integer', 'default' => 0],
|
||||
'tls_settings' => ['type' => 'array', 'default' => null],
|
||||
'flow' => ['type' => 'string', 'default' => null],
|
||||
'network' => ['type' => 'string', 'default' => null],
|
||||
'network_settings' => ['type' => 'array', 'default' => null],
|
||||
'reality_settings' => [
|
||||
'encryption' => [
|
||||
'type' => 'object',
|
||||
'default' => null,
|
||||
'fields' => [
|
||||
'allow_insecure' => ['type' => 'boolean', 'default' => false],
|
||||
'server_port' => ['type' => 'string', 'default' => null],
|
||||
'server_name' => ['type' => 'string', 'default' => null],
|
||||
'public_key' => ['type' => 'string', 'default' => null],
|
||||
'private_key' => ['type' => 'string', 'default' => null],
|
||||
'short_id' => ['type' => 'string', 'default' => null]
|
||||
'enabled' => ['type' => 'boolean', 'default' => false],
|
||||
'encryption' => ['type' => 'string', 'default' => null], // 客户端公钥
|
||||
'decryption' => ['type' => 'string', 'default' => null], // 服务端私钥
|
||||
]
|
||||
],
|
||||
'network' => ['type' => 'string', 'default' => null],
|
||||
'network_settings' => ['type' => 'array', 'default' => null],
|
||||
...self::REALITY_CONFIGURATION,
|
||||
...self::MULTIPLEX_CONFIGURATION,
|
||||
...self::UTLS_CONFIGURATION
|
||||
],
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Utils\Helper;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -81,6 +83,20 @@ class User extends Authenticatable
|
||||
public const COMMISSION_TYPE_SYSTEM = 0;
|
||||
public const COMMISSION_TYPE_PERIOD = 1;
|
||||
public const COMMISSION_TYPE_ONETIME = 2;
|
||||
protected function email(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
set: fn (string $value) => strtolower(trim($value)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按邮箱查询(大小写不敏感,兼容所有数据库)
|
||||
*/
|
||||
public function scopeByEmail(Builder $query, string $email): Builder
|
||||
{
|
||||
return $query->where('email', strtolower(trim($email)));
|
||||
}
|
||||
|
||||
// 获取邀请人信息
|
||||
public function invite_user(): BelongsTo
|
||||
|
||||
@@ -199,8 +199,9 @@ class Clash extends AbstractProtocol
|
||||
|
||||
switch (data_get($protocol_settings, 'network')) {
|
||||
case 'tcp':
|
||||
$array['network'] = data_get($protocol_settings, 'network_settings.header.type');
|
||||
if (data_get($protocol_settings, 'network_settings.header.type', 'none') !== 'none') {
|
||||
$headerType = data_get($protocol_settings, 'network_settings.header.type', 'none');
|
||||
$array['network'] = ($headerType === 'http') ? 'http' : 'tcp';
|
||||
if ($headerType === 'http') {
|
||||
if ($httpOpts = array_filter([
|
||||
'headers' => data_get($protocol_settings, 'network_settings.header.request.headers'),
|
||||
'path' => data_get($protocol_settings, 'network_settings.header.request.path', ['/'])
|
||||
|
||||
@@ -271,8 +271,9 @@ class ClashMeta extends AbstractProtocol
|
||||
|
||||
switch (data_get($protocol_settings, 'network')) {
|
||||
case 'tcp':
|
||||
$array['network'] = data_get($protocol_settings, 'network_settings.header.type', 'tcp');
|
||||
if (data_get($protocol_settings, 'network_settings.header.type', 'none') !== 'none') {
|
||||
$headerType = data_get($protocol_settings, 'network_settings.header.type', 'none');
|
||||
$array['network'] = ($headerType === 'http') ? 'http' : 'tcp';
|
||||
if ($headerType === 'http') {
|
||||
if (
|
||||
$httpOpts = array_filter([
|
||||
'headers' => data_get($protocol_settings, 'network_settings.header.request.headers'),
|
||||
@@ -331,6 +332,10 @@ class ClashMeta extends AbstractProtocol
|
||||
'cipher' => 'auto',
|
||||
'udp' => true,
|
||||
'flow' => data_get($protocol_settings, 'flow'),
|
||||
'encryption' => match (data_get($protocol_settings, 'encryption.enabled')) {
|
||||
true => data_get($protocol_settings, 'encryption.encryption', 'none'),
|
||||
default => 'none'
|
||||
},
|
||||
'tls' => false
|
||||
];
|
||||
|
||||
@@ -420,10 +425,26 @@ class ClashMeta extends AbstractProtocol
|
||||
'port' => $server['port'],
|
||||
'password' => $password,
|
||||
'udp' => true,
|
||||
'skip-cert-verify' => (bool) data_get($protocol_settings, 'allow_insecure', false)
|
||||
];
|
||||
if ($serverName = data_get($protocol_settings, 'server_name')) {
|
||||
$array['sni'] = $serverName;
|
||||
|
||||
$tlsMode = (int) data_get($protocol_settings, 'tls', 1);
|
||||
switch ($tlsMode) {
|
||||
case 2: // Reality
|
||||
$array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'reality_settings.allow_insecure', false);
|
||||
if ($serverName = data_get($protocol_settings, 'reality_settings.server_name')) {
|
||||
$array['sni'] = $serverName;
|
||||
}
|
||||
$array['reality-opts'] = [
|
||||
'public-key' => data_get($protocol_settings, 'reality_settings.public_key'),
|
||||
'short-id' => data_get($protocol_settings, 'reality_settings.short_id'),
|
||||
];
|
||||
break;
|
||||
default: // Standard TLS
|
||||
$array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'allow_insecure', false);
|
||||
if ($serverName = data_get($protocol_settings, 'server_name')) {
|
||||
$array['sni'] = $serverName;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
self::appendUtls($array, $protocol_settings);
|
||||
|
||||
+61
-23
@@ -48,7 +48,9 @@ class General extends AbstractProtocol
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
return response(base64_encode($uri))->header('content-type', 'text/plain');
|
||||
return response(base64_encode($uri))
|
||||
->header('content-type', 'text/plain')
|
||||
->header('subscription-userinfo', "upload={$user['u']}; download={$user['d']}; total={$user['transfer_enable']}; expire={$user['expired_at']}");
|
||||
}
|
||||
|
||||
public static function buildShadowsocks($password, $server)
|
||||
@@ -59,14 +61,14 @@ class General extends AbstractProtocol
|
||||
$str = str_replace(
|
||||
['+', '/', '='],
|
||||
['-', '_', ''],
|
||||
base64_encode("{$protocol_settings['cipher']}:{$password}")
|
||||
base64_encode(data_get($protocol_settings, 'cipher') . ":{$password}")
|
||||
);
|
||||
$addr = Helper::wrapIPv6($server['host']);
|
||||
$plugin = data_get($protocol_settings, 'plugin');
|
||||
$plugin_opts = data_get($protocol_settings, 'plugin_opts');
|
||||
$url = "ss://{$str}@{$addr}:{$server['port']}";
|
||||
if ($plugin && $plugin_opts) {
|
||||
$url .= '/?' . 'plugin=' . $plugin . ';' . rawurlencode($plugin_opts);
|
||||
$url .= '/?' . 'plugin=' . rawurlencode($plugin . ';' . $plugin_opts);
|
||||
}
|
||||
$url .= "#{$name}\r\n";
|
||||
return $url;
|
||||
@@ -82,17 +84,20 @@ class General extends AbstractProtocol
|
||||
"port" => (string) $server['port'],
|
||||
"id" => $uuid,
|
||||
"aid" => '0',
|
||||
"net" => $server['protocol_settings']['network'],
|
||||
"net" => data_get($server, 'protocol_settings.network'),
|
||||
"type" => "none",
|
||||
"host" => "",
|
||||
"path" => "",
|
||||
"tls" => $protocol_settings['tls'] ? "tls" : "",
|
||||
"tls" => data_get($protocol_settings, 'tls') ? "tls" : "",
|
||||
];
|
||||
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) {
|
||||
$config['sni'] = $serverName;
|
||||
}
|
||||
if ($fp = Helper::getTlsFingerprint(data_get($protocol_settings, 'utls'))) {
|
||||
$config['fp'] = $fp;
|
||||
}
|
||||
|
||||
switch ($protocol_settings['network']) {
|
||||
switch (data_get($protocol_settings, 'network')) {
|
||||
case 'tcp':
|
||||
if (data_get($protocol_settings, 'network_settings.header.type', 'none') !== 'none') {
|
||||
$config['type'] = data_get($protocol_settings, 'network_settings.header.type', 'http');
|
||||
@@ -146,12 +151,15 @@ class General extends AbstractProtocol
|
||||
$config = [
|
||||
'mode' => 'multi', //grpc传输模式
|
||||
'security' => '', //传输层安全 tls/reality
|
||||
'encryption' => 'none', //加密方式
|
||||
'type' => $server['protocol_settings']['network'], //传输协议
|
||||
'flow' => $protocol_settings['flow'] ? $protocol_settings['flow'] : null,
|
||||
'encryption' => match (data_get($protocol_settings, 'encryption.enabled')) {
|
||||
true => data_get($protocol_settings, 'encryption.encryption', 'none'),
|
||||
default => 'none'
|
||||
},
|
||||
'type' => data_get($server, 'protocol_settings.network'), //传输协议
|
||||
'flow' => data_get($protocol_settings, 'flow'),
|
||||
];
|
||||
// 处理TLS
|
||||
switch ($server['protocol_settings']['tls']) {
|
||||
switch (data_get($server, 'protocol_settings.tls')) {
|
||||
case 1:
|
||||
$config['security'] = "tls";
|
||||
if ($fp = Helper::getTlsFingerprint(data_get($protocol_settings, 'utls'))) {
|
||||
@@ -160,6 +168,9 @@ class General extends AbstractProtocol
|
||||
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) {
|
||||
$config['sni'] = $serverName;
|
||||
}
|
||||
if (data_get($protocol_settings, 'tls_settings.allow_insecure')) {
|
||||
$config['allowInsecure'] = '1';
|
||||
}
|
||||
break;
|
||||
case 2: //reality
|
||||
$config['security'] = "reality";
|
||||
@@ -176,7 +187,7 @@ class General extends AbstractProtocol
|
||||
break;
|
||||
}
|
||||
// 处理传输协议
|
||||
switch ($server['protocol_settings']['network']) {
|
||||
switch (data_get($server, 'protocol_settings.network')) {
|
||||
case 'ws':
|
||||
if ($path = data_get($protocol_settings, 'network_settings.path'))
|
||||
$config['path'] = $path;
|
||||
@@ -224,12 +235,31 @@ class General extends AbstractProtocol
|
||||
$protocol_settings = $server['protocol_settings'];
|
||||
$name = rawurlencode($server['name']);
|
||||
$array = [];
|
||||
$array['allowInsecure'] = $protocol_settings['allow_insecure'];
|
||||
if ($serverName = data_get($protocol_settings, 'server_name')) {
|
||||
$array['peer'] = $serverName;
|
||||
$array['sni'] = $serverName;
|
||||
$tlsMode = (int) data_get($protocol_settings, 'tls', 1);
|
||||
|
||||
switch ($tlsMode) {
|
||||
case 2: // Reality
|
||||
$array['security'] = 'reality';
|
||||
$array['pbk'] = data_get($protocol_settings, 'reality_settings.public_key');
|
||||
$array['sid'] = data_get($protocol_settings, 'reality_settings.short_id');
|
||||
$array['sni'] = data_get($protocol_settings, 'reality_settings.server_name');
|
||||
if ($fp = Helper::getTlsFingerprint(data_get($protocol_settings, 'utls'))) {
|
||||
$array['fp'] = $fp;
|
||||
}
|
||||
break;
|
||||
default: // Standard TLS
|
||||
$array['allowInsecure'] = data_get($protocol_settings, 'allow_insecure', false);
|
||||
if ($serverName = data_get($protocol_settings, 'server_name')) {
|
||||
$array['peer'] = $serverName;
|
||||
$array['sni'] = $serverName;
|
||||
}
|
||||
if ($fp = Helper::getTlsFingerprint(data_get($protocol_settings, 'utls'))) {
|
||||
$array['fp'] = $fp;
|
||||
}
|
||||
break;
|
||||
}
|
||||
switch ($server['protocol_settings']['network']) {
|
||||
|
||||
switch (data_get($server, 'protocol_settings.network')) {
|
||||
case 'ws':
|
||||
$array['type'] = 'ws';
|
||||
if ($path = data_get($protocol_settings, 'network_settings.path'))
|
||||
@@ -299,8 +329,10 @@ class General extends AbstractProtocol
|
||||
$params['upmbps'] = $upMbps;
|
||||
if ($downMbps = data_get($protocol_settings, 'bandwidth.down'))
|
||||
$params['downmbps'] = $downMbps;
|
||||
if ($obfsPassword = data_get($protocol_settings, 'obfs.password'))
|
||||
if (data_get($protocol_settings, 'obfs.open') && ($obfsPassword = data_get($protocol_settings, 'obfs.password'))) {
|
||||
$params['obfs'] = 'xplus';
|
||||
$params['obfsParam'] = $obfsPassword;
|
||||
}
|
||||
|
||||
$query = http_build_query($params);
|
||||
$uri = "hysteria://{$addr}:{$server['port']}?{$query}#{$name}";
|
||||
@@ -309,8 +341,8 @@ class General extends AbstractProtocol
|
||||
|
||||
return $uri;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
public static function buildTuic($password, $server)
|
||||
{
|
||||
$protocol_settings = data_get($server, 'protocol_settings', []);
|
||||
@@ -344,6 +376,10 @@ class General extends AbstractProtocol
|
||||
$udpRelay = data_get($protocol_settings, 'udp_relay_mode', 'native');
|
||||
$queryParams['udp-relay-mode'] = $udpRelay;
|
||||
|
||||
if (data_get($protocol_settings, 'tls.allow_insecure')) {
|
||||
$queryParams['insecure'] = '1';
|
||||
}
|
||||
|
||||
$query = http_build_query($queryParams);
|
||||
|
||||
// 构造完整URI,格式:
|
||||
@@ -361,7 +397,7 @@ class General extends AbstractProtocol
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
public static function buildAnyTLS($password, $server)
|
||||
{
|
||||
@@ -372,16 +408,18 @@ class General extends AbstractProtocol
|
||||
'insecure' => data_get($protocol_settings, 'tls.allow_insecure')
|
||||
];
|
||||
$query = http_build_query($params);
|
||||
$uri = "anytls://{$password}@{$server['host']}:{$server['port']}?{$query}#{$name}";
|
||||
$addr = Helper::wrapIPv6($server['host']);
|
||||
$uri = "anytls://{$password}@{$addr}:{$server['port']}?{$query}#{$name}";
|
||||
$uri .= "\r\n";
|
||||
return $uri;
|
||||
}
|
||||
|
||||
|
||||
public static function buildSocks($password, $server)
|
||||
{
|
||||
$name = rawurlencode($server['name']);
|
||||
$credentials = base64_encode("{$password}:{$password}");
|
||||
return "socks://{$credentials}@{$server['host']}:{$server['port']}#{$name}\r\n";
|
||||
$addr = Helper::wrapIPv6($server['host']);
|
||||
return "socks://{$credentials}@{$addr}:{$server['port']}#{$name}\r\n";
|
||||
}
|
||||
|
||||
public static function buildHttp($password, $server)
|
||||
|
||||
+91
-11
@@ -15,10 +15,12 @@ class Loon extends AbstractProtocol
|
||||
Server::TYPE_TROJAN,
|
||||
Server::TYPE_HYSTERIA,
|
||||
Server::TYPE_VLESS,
|
||||
Server::TYPE_ANYTLS,
|
||||
];
|
||||
|
||||
protected $protocolRequirements = [
|
||||
'loon.hysteria.protocol_settings.version' => [2 => '637'],
|
||||
'loon.trojan.protocol_settings.tls' => [0 => '3.2.1', 1 => '3.2.1',2 => '999.9.9'],
|
||||
];
|
||||
|
||||
public function handle()
|
||||
@@ -46,6 +48,9 @@ class Loon extends AbstractProtocol
|
||||
if ($item['type'] === Server::TYPE_VLESS) {
|
||||
$uri .= self::buildVless($item['password'], $item);
|
||||
}
|
||||
if ($item['type'] === Server::TYPE_ANYTLS) {
|
||||
$uri .= self::buildAnyTLS($item['password'], $item);
|
||||
}
|
||||
}
|
||||
return response($uri)
|
||||
->header('content-type', 'text/plain')
|
||||
@@ -115,11 +120,10 @@ class Loon extends AbstractProtocol
|
||||
];
|
||||
|
||||
if (data_get($protocol_settings, 'tls')) {
|
||||
if (data_get($protocol_settings, 'network') === 'tcp')
|
||||
$config[] = 'over-tls=true';
|
||||
$config[] = 'over-tls=true';
|
||||
if (data_get($protocol_settings, 'tls_settings')) {
|
||||
$tls_settings = data_get($protocol_settings, 'tls_settings');
|
||||
$config[] = 'skip-cert-verify=' . ($tls_settings['allow_insecure'] ? 'true' : 'false');
|
||||
$config[] = 'skip-cert-verify=' . (data_get($tls_settings, 'allow_insecure') ? 'true' : 'false');
|
||||
if (data_get($tls_settings, 'server_name'))
|
||||
$config[] = "tls-name={$tls_settings['server_name']}";
|
||||
}
|
||||
@@ -150,8 +154,25 @@ class Loon extends AbstractProtocol
|
||||
if (data_get($wsSettings, key: 'headers.Host'))
|
||||
$config[] = "host={$wsSettings['headers']['Host']}";
|
||||
break;
|
||||
|
||||
|
||||
case 'grpc':
|
||||
$config[] = 'transport=grpc';
|
||||
if ($serviceName = data_get($protocol_settings, 'network_settings.serviceName'))
|
||||
$config[] = "grpc-service-name={$serviceName}";
|
||||
break;
|
||||
case 'h2':
|
||||
$config[] = 'transport=h2';
|
||||
if ($path = data_get($protocol_settings, 'network_settings.path'))
|
||||
$config[] = "path={$path}";
|
||||
if ($host = data_get($protocol_settings, 'network_settings.host'))
|
||||
$config[] = "host=" . (is_array($host) ? $host[0] : $host);
|
||||
break;
|
||||
case 'httpupgrade':
|
||||
$config[] = 'transport=httpupgrade';
|
||||
if ($path = data_get($protocol_settings, 'network_settings.path'))
|
||||
$config[] = "path={$path}";
|
||||
if ($host = data_get($protocol_settings, 'network_settings.headers.Host'))
|
||||
$config[] = "host={$host}";
|
||||
break;
|
||||
}
|
||||
|
||||
$uri = implode(',', $config);
|
||||
@@ -167,13 +188,45 @@ class Loon extends AbstractProtocol
|
||||
"{$server['host']}",
|
||||
"{$server['port']}",
|
||||
"{$password}",
|
||||
data_get($protocol_settings, 'server_name') ? "tls-name={$protocol_settings['server_name']}" : "",
|
||||
'fast-open=false',
|
||||
'udp=true'
|
||||
];
|
||||
if (!empty($protocol_settings['allow_insecure'])) {
|
||||
$config[] = data_get($protocol_settings, 'allow_insecure') ? 'skip-cert-verify=true' : 'skip-cert-verify=false';
|
||||
|
||||
$tlsMode = (int) data_get($protocol_settings, 'tls', 1);
|
||||
switch ($tlsMode) {
|
||||
case 2: // Reality
|
||||
if ($serverName = data_get($protocol_settings, 'reality_settings.server_name')) {
|
||||
$config[] = "tls-name={$serverName}";
|
||||
}
|
||||
if ($pubkey = data_get($protocol_settings, 'reality_settings.public_key')) {
|
||||
$config[] = "public-key={$pubkey}";
|
||||
}
|
||||
if ($shortid = data_get($protocol_settings, 'reality_settings.short_id')) {
|
||||
$config[] = "short-id={$shortid}";
|
||||
}
|
||||
$config[] = 'skip-cert-verify=' . (data_get($protocol_settings, 'reality_settings.allow_insecure', false) ? 'true' : 'false');
|
||||
break;
|
||||
default: // Standard TLS
|
||||
if ($serverName = data_get($protocol_settings, 'server_name')) {
|
||||
$config[] = "tls-name={$serverName}";
|
||||
}
|
||||
$config[] = 'skip-cert-verify=' . (data_get($protocol_settings, 'allow_insecure') ? 'true' : 'false');
|
||||
break;
|
||||
}
|
||||
|
||||
switch (data_get($protocol_settings, 'network', 'tcp')) {
|
||||
case 'ws':
|
||||
$config[] = 'transport=ws';
|
||||
if ($path = data_get($protocol_settings, 'network_settings.path'))
|
||||
$config[] = "path={$path}";
|
||||
if ($host = data_get($protocol_settings, 'network_settings.headers.Host'))
|
||||
$config[] = "host={$host}";
|
||||
break;
|
||||
case 'grpc':
|
||||
$config[] = 'transport=grpc';
|
||||
if ($serviceName = data_get($protocol_settings, 'network_settings.serviceName'))
|
||||
$config[] = "grpc-service-name={$serviceName}";
|
||||
break;
|
||||
}
|
||||
|
||||
$config = array_filter($config);
|
||||
$uri = implode(',', $config);
|
||||
$uri .= "\r\n";
|
||||
@@ -267,11 +320,38 @@ class Loon extends AbstractProtocol
|
||||
];
|
||||
if (data_get($protocol_settings, 'tls.allow_insecure'))
|
||||
$config[] = "skip-cert-verify=true";
|
||||
$config[] = "download-bandwidth=" . data_get($protocol_settings, 'bandwidth.download_bandwidth');
|
||||
if ($down = data_get($protocol_settings, 'bandwidth.down')) {
|
||||
$config[] = "download-bandwidth={$down}";
|
||||
}
|
||||
$config[] = "udp=true";
|
||||
$config = array_filter($config);
|
||||
$uri = implode(',', $config);
|
||||
$uri .= "\r\n";
|
||||
return $uri;
|
||||
}
|
||||
|
||||
public static function buildAnyTLS($password, $server)
|
||||
{
|
||||
$protocol_settings = data_get($server, 'protocol_settings', []);
|
||||
|
||||
$config = [
|
||||
"{$server['name']}=anytls",
|
||||
"{$server['host']}",
|
||||
"{$server['port']}",
|
||||
"{$password}",
|
||||
"udp=true"
|
||||
];
|
||||
|
||||
if ($serverName = data_get($protocol_settings, 'tls.server_name')) {
|
||||
$config[] = "sni={$serverName}";
|
||||
}
|
||||
// ✅ 跳过证书校验
|
||||
if (data_get($protocol_settings, 'tls.allow_insecure')) {
|
||||
$config[] = 'skip-cert-verify=true';
|
||||
}
|
||||
|
||||
$config = array_filter($config);
|
||||
|
||||
return implode(',', $config) . "\r\n";
|
||||
}
|
||||
}
|
||||
|
||||
+160
-71
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Protocols;
|
||||
|
||||
use App\Utils\Helper;
|
||||
use App\Support\AbstractProtocol;
|
||||
use App\Models\Server;
|
||||
|
||||
@@ -11,7 +12,10 @@ class QuantumultX extends AbstractProtocol
|
||||
public $allowedProtocols = [
|
||||
Server::TYPE_SHADOWSOCKS,
|
||||
Server::TYPE_VMESS,
|
||||
Server::TYPE_VLESS,
|
||||
Server::TYPE_TROJAN,
|
||||
Server::TYPE_SOCKS,
|
||||
Server::TYPE_HTTP,
|
||||
];
|
||||
|
||||
public function handle()
|
||||
@@ -20,15 +24,15 @@ class QuantumultX extends AbstractProtocol
|
||||
$user = $this->user;
|
||||
$uri = '';
|
||||
foreach ($servers as $item) {
|
||||
if ($item['type'] === Server::TYPE_SHADOWSOCKS) {
|
||||
$uri .= self::buildShadowsocks($item['password'], $item);
|
||||
}
|
||||
if ($item['type'] === Server::TYPE_VMESS) {
|
||||
$uri .= self::buildVmess($item['password'], $item);
|
||||
}
|
||||
if ($item['type'] === Server::TYPE_TROJAN) {
|
||||
$uri .= self::buildTrojan($item['password'], $item);
|
||||
}
|
||||
$uri .= match ($item['type']) {
|
||||
Server::TYPE_SHADOWSOCKS => self::buildShadowsocks($item['password'], $item),
|
||||
Server::TYPE_VMESS => self::buildVmess($item['password'], $item),
|
||||
Server::TYPE_VLESS => self::buildVless($item['password'], $item),
|
||||
Server::TYPE_TROJAN => self::buildTrojan($item['password'], $item),
|
||||
Server::TYPE_SOCKS => self::buildSocks5($item['password'], $item),
|
||||
Server::TYPE_HTTP => self::buildHttp($item['password'], $item),
|
||||
default => ''
|
||||
};
|
||||
}
|
||||
return response(base64_encode($uri))
|
||||
->header('content-type', 'text/plain')
|
||||
@@ -39,18 +43,16 @@ class QuantumultX extends AbstractProtocol
|
||||
{
|
||||
$protocol_settings = $server['protocol_settings'];
|
||||
$password = data_get($server, 'password', $password);
|
||||
$addr = Helper::wrapIPv6($server['host']);
|
||||
$config = [
|
||||
"shadowsocks={$server['host']}:{$server['port']}",
|
||||
"method={$protocol_settings['cipher']}",
|
||||
"shadowsocks={$addr}:{$server['port']}",
|
||||
"method=" . data_get($protocol_settings, 'cipher'),
|
||||
"password={$password}",
|
||||
'fast-open=true',
|
||||
'udp-relay=true',
|
||||
"tag={$server['name']}"
|
||||
];
|
||||
|
||||
if (data_get($protocol_settings, 'plugin') && data_get($protocol_settings, 'plugin_opts')) {
|
||||
$plugin = data_get($protocol_settings, 'plugin');
|
||||
$pluginOpts = data_get($protocol_settings, 'plugin_opts', '');
|
||||
// 解析插件选项
|
||||
$parsedOpts = collect(explode(';', $pluginOpts))
|
||||
->filter()
|
||||
->mapWithKeys(function ($pair) {
|
||||
@@ -61,83 +63,170 @@ class QuantumultX extends AbstractProtocol
|
||||
return [trim($key) => trim($value)];
|
||||
})
|
||||
->all();
|
||||
switch ($plugin) {
|
||||
case 'obfs':
|
||||
if ($plugin === 'obfs') {
|
||||
if (isset($parsedOpts['obfs'])) {
|
||||
$config[] = "obfs={$parsedOpts['obfs']}";
|
||||
if (isset($parsedOpts['obfs-host'])) {
|
||||
$config[] = "obfs-host={$parsedOpts['obfs-host']}";
|
||||
}
|
||||
if (isset($parsedOpts['path'])) {
|
||||
$config[] = "obfs-uri={$parsedOpts['path']}";
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (isset($parsedOpts['obfs-host'])) {
|
||||
$config[] = "obfs-host={$parsedOpts['obfs-host']}";
|
||||
}
|
||||
if (isset($parsedOpts['path'])) {
|
||||
$config[] = "obfs-uri={$parsedOpts['path']}";
|
||||
}
|
||||
}
|
||||
}
|
||||
$uri = implode(',', $config);
|
||||
$uri .= "\r\n";
|
||||
return $uri;
|
||||
|
||||
self::applyCommonSettings($config, $server);
|
||||
|
||||
return implode(',', array_filter($config)) . "\r\n";
|
||||
}
|
||||
|
||||
public static function buildVmess($uuid, $server)
|
||||
{
|
||||
$protocol_settings = $server['protocol_settings'];
|
||||
$addr = Helper::wrapIPv6($server['host']);
|
||||
$config = [
|
||||
"vmess={$server['host']}:{$server['port']}",
|
||||
'method=chacha20-poly1305',
|
||||
"vmess={$addr}:{$server['port']}",
|
||||
"method=" . data_get($protocol_settings, 'cipher', 'auto'),
|
||||
"password={$uuid}",
|
||||
'fast-open=true',
|
||||
'udp-relay=true',
|
||||
"tag={$server['name']}"
|
||||
];
|
||||
|
||||
if (data_get($protocol_settings, 'tls')) {
|
||||
if (data_get($protocol_settings, 'network') === 'tcp')
|
||||
array_push($config, 'obfs=over-tls');
|
||||
if (data_get($protocol_settings, 'tls_settings')) {
|
||||
if (data_get($protocol_settings, 'tls_settings.allow_insecure'))
|
||||
array_push($config, 'tls-verification=' . ($protocol_settings['tls_settings']['allow_insecure'] ? 'false' : 'true'));
|
||||
if (data_get($protocol_settings, 'tls_settings.server_name'))
|
||||
$host = data_get($protocol_settings, 'tls_settings.server_name');
|
||||
}
|
||||
}
|
||||
if (data_get($protocol_settings, 'network') === 'ws') {
|
||||
if (data_get($protocol_settings, 'tls'))
|
||||
array_push($config, 'obfs=wss');
|
||||
else
|
||||
array_push($config, 'obfs=ws');
|
||||
if (data_get($protocol_settings, 'network_settings')) {
|
||||
if (data_get($protocol_settings, 'network_settings.path'))
|
||||
array_push($config, "obfs-uri={$protocol_settings['network_settings']['path']}");
|
||||
if (data_get($protocol_settings, 'network_settings.headers.Host') && !isset($host))
|
||||
$host = data_get($protocol_settings, 'network_settings.headers.Host');
|
||||
}
|
||||
}
|
||||
if (isset($host)) {
|
||||
array_push($config, "obfs-host={$host}");
|
||||
self::applyTransportSettings($config, $protocol_settings);
|
||||
self::applyCommonSettings($config, $server);
|
||||
|
||||
return implode(',', array_filter($config)) . "\r\n";
|
||||
}
|
||||
|
||||
public static function buildVless($uuid, $server)
|
||||
{
|
||||
$protocol_settings = $server['protocol_settings'];
|
||||
$addr = Helper::wrapIPv6($server['host']);
|
||||
$config = [
|
||||
"vless={$addr}:{$server['port']}",
|
||||
'method=none',
|
||||
"password={$uuid}",
|
||||
];
|
||||
|
||||
self::applyTransportSettings($config, $protocol_settings);
|
||||
|
||||
if ($flow = data_get($protocol_settings, 'flow')) {
|
||||
$config[] = "vless-flow={$flow}";
|
||||
}
|
||||
|
||||
$uri = implode(',', $config);
|
||||
$uri .= "\r\n";
|
||||
return $uri;
|
||||
self::applyCommonSettings($config, $server);
|
||||
|
||||
return implode(',', array_filter($config)) . "\r\n";
|
||||
}
|
||||
|
||||
private static function applyTransportSettings(&$config, $settings, bool $nativeTls = false, ?array $tlsData = null)
|
||||
{
|
||||
$tlsMode = (int) data_get($settings, 'tls', 0);
|
||||
$network = data_get($settings, 'network', 'tcp');
|
||||
$host = null;
|
||||
$isWs = $network === 'ws';
|
||||
|
||||
switch ($network) {
|
||||
case 'ws':
|
||||
$config[] = $tlsMode ? 'obfs=wss' : 'obfs=ws';
|
||||
if ($path = data_get($settings, 'network_settings.path')) {
|
||||
$config[] = "obfs-uri={$path}";
|
||||
}
|
||||
$host = data_get($settings, 'network_settings.headers.Host');
|
||||
break;
|
||||
case 'tcp':
|
||||
$headerType = data_get($settings, 'network_settings.header.type', 'tcp');
|
||||
if ($headerType === 'http') {
|
||||
$config[] = 'obfs=http';
|
||||
$paths = data_get($settings, 'network_settings.header.request.path', ['/']);
|
||||
$config[] = 'obfs-uri=' . (is_array($paths) ? ($paths[0] ?? '/') : $paths);
|
||||
$hostVal = data_get($settings, 'network_settings.header.request.headers.Host');
|
||||
$host = is_array($hostVal) ? ($hostVal[0] ?? null) : $hostVal;
|
||||
} elseif ($tlsMode) {
|
||||
$config[] = $nativeTls ? 'over-tls=true' : 'obfs=over-tls';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
switch ($tlsMode) {
|
||||
case 2: // Reality
|
||||
$host = $host ?? data_get($settings, 'reality_settings.server_name');
|
||||
if ($pubKey = data_get($settings, 'reality_settings.public_key')) {
|
||||
$config[] = "reality-base64-pubkey={$pubKey}";
|
||||
}
|
||||
if ($shortId = data_get($settings, 'reality_settings.short_id')) {
|
||||
$config[] = "reality-hex-shortid={$shortId}";
|
||||
}
|
||||
break;
|
||||
case 1: // TLS
|
||||
$resolved = $tlsData ?? (array) data_get($settings, 'tls_settings', []);
|
||||
$allowInsecure = (bool) ($resolved['allow_insecure'] ?? false);
|
||||
$config[] = 'tls-verification=' . ($allowInsecure ? 'false' : 'true');
|
||||
$host = $host ?? ($resolved['server_name'] ?? null);
|
||||
break;
|
||||
}
|
||||
|
||||
if ($host) {
|
||||
$config[] = ($nativeTls && !$isWs) ? "tls-host={$host}" : "obfs-host={$host}";
|
||||
}
|
||||
}
|
||||
|
||||
private static function applyCommonSettings(&$config, $server)
|
||||
{
|
||||
$config[] = 'fast-open=true';
|
||||
if ($server['type'] !== Server::TYPE_HTTP) {
|
||||
$config[] = 'udp-relay=true';
|
||||
}
|
||||
$config[] = "tag={$server['name']}";
|
||||
}
|
||||
|
||||
public static function buildTrojan($password, $server)
|
||||
{
|
||||
$protocol_settings = $server['protocol_settings'];
|
||||
$addr = Helper::wrapIPv6($server['host']);
|
||||
$config = [
|
||||
"trojan={$server['host']}:{$server['port']}",
|
||||
"trojan={$addr}:{$server['port']}",
|
||||
"password={$password}",
|
||||
'over-tls=true',
|
||||
$protocol_settings['server_name'] ? "tls-host={$protocol_settings['server_name']}" : "",
|
||||
// Tips: allowInsecure=false = tls-verification=true
|
||||
$protocol_settings['allow_insecure'] ? 'tls-verification=false' : 'tls-verification=true',
|
||||
'fast-open=true',
|
||||
'udp-relay=true',
|
||||
"tag={$server['name']}"
|
||||
];
|
||||
$config = array_filter($config);
|
||||
$uri = implode(',', $config);
|
||||
$uri .= "\r\n";
|
||||
return $uri;
|
||||
|
||||
$tlsData = [
|
||||
'allow_insecure' => data_get($protocol_settings, 'allow_insecure', false),
|
||||
'server_name' => data_get($protocol_settings, 'server_name'),
|
||||
];
|
||||
self::applyTransportSettings($config, $protocol_settings, true, $tlsData);
|
||||
self::applyCommonSettings($config, $server);
|
||||
|
||||
return implode(',', array_filter($config)) . "\r\n";
|
||||
}
|
||||
|
||||
public static function buildSocks5($password, $server)
|
||||
{
|
||||
$protocol_settings = $server['protocol_settings'];
|
||||
$addr = Helper::wrapIPv6($server['host']);
|
||||
$config = [
|
||||
"socks5={$addr}:{$server['port']}",
|
||||
"username={$password}",
|
||||
"password={$password}",
|
||||
];
|
||||
|
||||
self::applyTransportSettings($config, $protocol_settings, true);
|
||||
self::applyCommonSettings($config, $server);
|
||||
|
||||
return implode(',', array_filter($config)) . "\r\n";
|
||||
}
|
||||
|
||||
public static function buildHttp($password, $server)
|
||||
{
|
||||
$protocol_settings = $server['protocol_settings'];
|
||||
$addr = Helper::wrapIPv6($server['host']);
|
||||
$config = [
|
||||
"http={$addr}:{$server['port']}",
|
||||
"username={$password}",
|
||||
"password={$password}",
|
||||
];
|
||||
|
||||
self::applyTransportSettings($config, $protocol_settings, true);
|
||||
self::applyCommonSettings($config, $server);
|
||||
|
||||
return implode(',', array_filter($config)) . "\r\n";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ class Shadowrocket extends AbstractProtocol
|
||||
$upload = round($user['u'] / (1024 * 1024 * 1024), 2);
|
||||
$download = round($user['d'] / (1024 * 1024 * 1024), 2);
|
||||
$totalTraffic = round($user['transfer_enable'] / (1024 * 1024 * 1024), 2);
|
||||
$expiredDate = date('Y-m-d', $user['expired_at']);
|
||||
$expiredDate = $user['expired_at'] === null ? 'N/A' : date('Y-m-d', $user['expired_at']);
|
||||
$uri .= "STATUS=🚀↑:{$upload}GB,↓:{$download}GB,TOT:{$totalTraffic}GB💡Expires:{$expiredDate}\r\n";
|
||||
foreach ($servers as $item) {
|
||||
if ($item['type'] === Server::TYPE_SHADOWSOCKS) {
|
||||
@@ -76,7 +76,7 @@ class Shadowrocket extends AbstractProtocol
|
||||
$str = str_replace(
|
||||
['+', '/', '='],
|
||||
['-', '_', ''],
|
||||
base64_encode("{$protocol_settings['cipher']}:{$password}")
|
||||
base64_encode(data_get($protocol_settings, 'cipher') . ":{$password}")
|
||||
);
|
||||
$addr = Helper::wrapIPv6($server['host']);
|
||||
|
||||
@@ -98,7 +98,7 @@ class Shadowrocket extends AbstractProtocol
|
||||
'remark' => $server['name'],
|
||||
'alterId' => 0
|
||||
];
|
||||
if ($protocol_settings['tls']) {
|
||||
if (data_get($protocol_settings, 'tls')) {
|
||||
$config['tls'] = 1;
|
||||
if (data_get($protocol_settings, 'tls_settings')) {
|
||||
if (!!data_get($protocol_settings, 'tls_settings.allow_insecure'))
|
||||
@@ -128,6 +128,25 @@ class Shadowrocket extends AbstractProtocol
|
||||
$config['path'] = data_get($protocol_settings, 'network_settings.serviceName');
|
||||
$config['host'] = data_get($protocol_settings, 'tls_settings.server_name') ?? $server['host'];
|
||||
break;
|
||||
case 'httpupgrade':
|
||||
$config['obfs'] = "httpupgrade";
|
||||
if ($path = data_get($protocol_settings, 'network_settings.path')) {
|
||||
$config['path'] = $path;
|
||||
}
|
||||
if ($host = data_get($protocol_settings, 'network_settings.host', $server['host'])) {
|
||||
$config['obfsParam'] = $host;
|
||||
}
|
||||
break;
|
||||
case 'h2':
|
||||
$config['obfs'] = "h2";
|
||||
if ($path = data_get($protocol_settings, 'network_settings.path')) {
|
||||
$config['path'] = $path;
|
||||
}
|
||||
if ($host = data_get($protocol_settings, 'network_settings.host')) {
|
||||
$config['obfsParam'] = $host[0] ?? $server['host'];
|
||||
$config['peer'] = $host [0] ?? $server['host'];
|
||||
}
|
||||
break;
|
||||
}
|
||||
$query = http_build_query($config, '', '&', PHP_QUERY_RFC3986);
|
||||
$uri = "vmess://{$userinfo}?{$query}";
|
||||
@@ -157,7 +176,6 @@ class Shadowrocket extends AbstractProtocol
|
||||
$config['xtls'] = $xtlsMap[data_get($protocol_settings, 'flow')];
|
||||
}
|
||||
}
|
||||
|
||||
switch (data_get($protocol_settings, 'tls')) {
|
||||
case 1:
|
||||
$config['tls'] = 1;
|
||||
@@ -211,6 +229,15 @@ class Shadowrocket extends AbstractProtocol
|
||||
}
|
||||
$config['type'] = data_get($protocol_settings, 'network_settings.header.type', 'none');
|
||||
break;
|
||||
case 'h2':
|
||||
$config['obfs'] = "h2";
|
||||
if ($path = data_get($protocol_settings, 'network_settings.path')) {
|
||||
$config['path'] = $path;
|
||||
}
|
||||
if ($host = data_get($protocol_settings, 'network_settings.host', $server['host'])) {
|
||||
$config['obfsParam'] = $host;
|
||||
}
|
||||
break;
|
||||
case 'httpupgrade':
|
||||
$config['obfs'] = "httpupgrade";
|
||||
if ($path = data_get($protocol_settings, 'network_settings.path')) {
|
||||
@@ -244,10 +271,24 @@ class Shadowrocket extends AbstractProtocol
|
||||
{
|
||||
$protocol_settings = $server['protocol_settings'];
|
||||
$name = rawurlencode($server['name']);
|
||||
$params['allowInsecure'] = data_get($protocol_settings, 'allow_insecure');
|
||||
if ($serverName = data_get($protocol_settings, 'server_name')) {
|
||||
$params['peer'] = $serverName;
|
||||
$params = [];
|
||||
$tlsMode = (int) data_get($protocol_settings, 'tls', 1);
|
||||
|
||||
switch ($tlsMode) {
|
||||
case 2: // Reality
|
||||
$params['security'] = 'reality';
|
||||
$params['pbk'] = data_get($protocol_settings, 'reality_settings.public_key');
|
||||
$params['sid'] = data_get($protocol_settings, 'reality_settings.short_id');
|
||||
$params['sni'] = data_get($protocol_settings, 'reality_settings.server_name');
|
||||
break;
|
||||
default: // Standard TLS
|
||||
$params['allowInsecure'] = data_get($protocol_settings, 'allow_insecure');
|
||||
if ($serverName = data_get($protocol_settings, 'server_name')) {
|
||||
$params['peer'] = $serverName;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
switch (data_get($protocol_settings, 'network')) {
|
||||
case 'grpc':
|
||||
$params['obfs'] = 'grpc';
|
||||
@@ -286,7 +327,7 @@ class Shadowrocket extends AbstractProtocol
|
||||
}
|
||||
if (data_get($protocol_settings, 'obfs.open')) {
|
||||
$params["obfs"] = "xplus";
|
||||
$params["obfsParam"] = data_get($protocol_settings, 'obfs_settings.password');
|
||||
$params["obfsParam"] = data_get($protocol_settings, 'obfs.password');
|
||||
}
|
||||
$params['insecure'] = data_get($protocol_settings, 'tls.allow_insecure');
|
||||
if (isset($server['ports']))
|
||||
@@ -311,7 +352,7 @@ class Shadowrocket extends AbstractProtocol
|
||||
}
|
||||
$params['insecure'] = data_get($protocol_settings, 'tls.allow_insecure');
|
||||
if (isset($protocol_settings['hop_interval'])) {
|
||||
$params['keepalive'] = $protocol_settings['hop_interval'];
|
||||
$params['keepalive'] = data_get($protocol_settings, 'hop_interval');
|
||||
}
|
||||
if (isset($server['ports'])) {
|
||||
$params['mport'] = $server['ports'];
|
||||
@@ -341,7 +382,8 @@ class Shadowrocket extends AbstractProtocol
|
||||
$params['password'] = $password;
|
||||
}
|
||||
$query = http_build_query($params);
|
||||
$uri = "tuic://{$server['host']}:{$server['port']}?{$query}#{$name}";
|
||||
$addr = Helper::wrapIPv6($server['host']);
|
||||
$uri = "tuic://{$addr}:{$server['port']}?{$query}#{$name}";
|
||||
$uri .= "\r\n";
|
||||
return $uri;
|
||||
}
|
||||
@@ -355,7 +397,8 @@ class Shadowrocket extends AbstractProtocol
|
||||
'insecure' => data_get($protocol_settings, 'tls.allow_insecure')
|
||||
];
|
||||
$query = http_build_query($params);
|
||||
$uri = "anytls://{$password}@{$server['host']}:{$server['port']}?{$query}#{$name}";
|
||||
$addr = Helper::wrapIPv6($server['host']);
|
||||
$uri = "anytls://{$password}@{$addr}:{$server['port']}?{$query}#{$name}";
|
||||
$uri .= "\r\n";
|
||||
return $uri;
|
||||
}
|
||||
@@ -364,7 +407,8 @@ class Shadowrocket extends AbstractProtocol
|
||||
{
|
||||
$protocol_settings = $server['protocol_settings'];
|
||||
$name = rawurlencode($server['name']);
|
||||
$uri = "socks://" . base64_encode("{$password}:{$password}@{$server['host']}:{$server['port']}") . "?method=auto#{$name}";
|
||||
$addr = Helper::wrapIPv6($server['host']);
|
||||
$uri = 'socks://' . base64_encode("{$password}:{$password}@{$addr}:{$server['port']}") . "?method=auto#{$name}";
|
||||
$uri .= "\r\n";
|
||||
return $uri;
|
||||
}
|
||||
|
||||
+233
-139
@@ -5,6 +5,7 @@ use App\Utils\Helper;
|
||||
use Illuminate\Support\Arr;
|
||||
use App\Support\AbstractProtocol;
|
||||
use App\Models\Server;
|
||||
use Log;
|
||||
|
||||
class SingBox extends AbstractProtocol
|
||||
{
|
||||
@@ -59,9 +60,6 @@ class SingBox extends AbstractProtocol
|
||||
'anytls' => [
|
||||
'base_version' => '1.12.0'
|
||||
],
|
||||
'mieru' => [
|
||||
'base_version' => '1.12.0'
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
@@ -133,10 +131,6 @@ class SingBox extends AbstractProtocol
|
||||
$httpConfig = $this->buildHttp($this->user['uuid'], $item);
|
||||
$proxies[] = $httpConfig;
|
||||
}
|
||||
if ($item['type'] === Server::TYPE_MIERU) {
|
||||
$mieruConfig = $this->buildMieru($this->user['uuid'], $item);
|
||||
$proxies[] = $mieruConfig;
|
||||
}
|
||||
}
|
||||
foreach ($outbounds as &$outbound) {
|
||||
if (in_array($outbound['type'], ['urltest', 'selector'])) {
|
||||
@@ -155,23 +149,12 @@ class SingBox extends AbstractProtocol
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据客户端版本自适应配置格式
|
||||
*
|
||||
* sing-box 版本断点:
|
||||
* - 1.8.0: rule_set 替代 geoip/geosite db, cache_file 替代 clash_api.cache_file
|
||||
* - 1.10.0: address 数组替代 inet4_address/inet6_address
|
||||
* - 1.11.0: 移除 endpoint_independent_nat, sniff_override_destination
|
||||
* 模板基准格式: 1.13.0+ (最新)
|
||||
*/
|
||||
protected function adaptConfigForVersion(): void
|
||||
{
|
||||
@@ -180,57 +163,190 @@ class SingBox extends AbstractProtocol
|
||||
return;
|
||||
}
|
||||
|
||||
// >= 1.11.0: 移除已废弃字段,避免 "配置已过时" 警告
|
||||
if (version_compare($coreVersion, '1.11.0', '>=')) {
|
||||
$this->removeDeprecatedFieldsV111();
|
||||
// >= 1.13.0: 移除已删除的 block/dns 出站
|
||||
if (version_compare($coreVersion, '1.13.0', '>=')) {
|
||||
$this->upgradeSpecialOutboundsToActions();
|
||||
}
|
||||
|
||||
// < 1.10.0: address 数组 → inet4_address/inet6_address
|
||||
// < 1.11.0: rule action 降级为旧出站; 恢复废弃字段
|
||||
if (version_compare($coreVersion, '1.11.0', '<')) {
|
||||
$this->downgradeActionsToSpecialOutbounds();
|
||||
$this->restoreDeprecatedInboundFields();
|
||||
}
|
||||
|
||||
// < 1.12.0: DNS type+server → 旧 address 格式
|
||||
if (version_compare($coreVersion, '1.12.0', '<')) {
|
||||
$this->convertDnsServersToLegacy();
|
||||
}
|
||||
|
||||
// < 1.10.0: tun address 数组 → inet4_address/inet6_address
|
||||
if (version_compare($coreVersion, '1.10.0', '<')) {
|
||||
$this->convertAddressToLegacy();
|
||||
$this->convertTunAddressToLegacy();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取实际 sing-box 核心版本
|
||||
*
|
||||
* sing-box 客户端直接报核心版本,hiddify/sfm 等 wrapper 客户端
|
||||
* 报的是 app 版本,需要映射到对应的 sing-box 核心版本
|
||||
* 获取核心版本 (Hiddify/SFM 等映射到内核版本)
|
||||
*/
|
||||
private function getSingBoxCoreVersion(): ?string
|
||||
{
|
||||
// 优先从 UA 提取核心版本
|
||||
if (!empty($this->userAgent)) {
|
||||
if (preg_match('/sing-box\s+v?(\d+(?:\.\d+){0,2})/i', $this->userAgent, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($this->clientVersion)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// sing-box 原生客户端,版本即核心版本
|
||||
if ($this->clientName === 'sing-box') {
|
||||
return $this->clientVersion;
|
||||
}
|
||||
|
||||
// Hiddify/SFM 等 wrapper 默认内置较新的 sing-box 核心
|
||||
// 保守策略: 直接按最新格式输出(移除废弃字段),因为这些客户端普遍内置 >= 1.11 的核心
|
||||
return '1.11.0';
|
||||
return '1.13.0';
|
||||
}
|
||||
|
||||
/**
|
||||
* sing-box >= 1.11.0: 移除废弃字段
|
||||
* sing-box >= 1.13.0: block/dns 出站升级为 action
|
||||
*/
|
||||
private function removeDeprecatedFieldsV111(): void
|
||||
private function upgradeSpecialOutboundsToActions(): void
|
||||
{
|
||||
$removedTags = [];
|
||||
$this->config['outbounds'] = array_values(array_filter(
|
||||
$this->config['outbounds'] ?? [],
|
||||
function ($outbound) use (&$removedTags) {
|
||||
if (in_array($outbound['type'] ?? '', ['block', 'dns'])) {
|
||||
$removedTags[$outbound['tag']] = $outbound['type'];
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
));
|
||||
|
||||
if (empty($removedTags)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isset($this->config['route']['rules'])) {
|
||||
foreach ($this->config['route']['rules'] as &$rule) {
|
||||
if (!isset($rule['outbound']) || !isset($removedTags[$rule['outbound']])) {
|
||||
continue;
|
||||
}
|
||||
$type = $removedTags[$rule['outbound']];
|
||||
unset($rule['outbound']);
|
||||
$rule['action'] = $type === 'dns' ? 'hijack-dns' : 'reject';
|
||||
}
|
||||
unset($rule);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* sing-box < 1.11.0: rule action 降级为旧 block/dns 出站
|
||||
*/
|
||||
private function downgradeActionsToSpecialOutbounds(): void
|
||||
{
|
||||
$needsDnsOutbound = false;
|
||||
$needsBlockOutbound = false;
|
||||
|
||||
if (isset($this->config['route']['rules'])) {
|
||||
foreach ($this->config['route']['rules'] as &$rule) {
|
||||
if (!isset($rule['action'])) {
|
||||
continue;
|
||||
}
|
||||
switch ($rule['action']) {
|
||||
case 'hijack-dns':
|
||||
unset($rule['action']);
|
||||
$rule['outbound'] = 'dns-out';
|
||||
$needsDnsOutbound = true;
|
||||
break;
|
||||
case 'reject':
|
||||
unset($rule['action']);
|
||||
$rule['outbound'] = 'block';
|
||||
$needsBlockOutbound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
unset($rule);
|
||||
}
|
||||
|
||||
if ($needsBlockOutbound) {
|
||||
$this->config['outbounds'][] = ['type' => 'block', 'tag' => 'block'];
|
||||
}
|
||||
if ($needsDnsOutbound) {
|
||||
$this->config['outbounds'][] = ['type' => 'dns', 'tag' => 'dns-out'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* sing-box < 1.11.0: 恢复废弃的入站字段
|
||||
*/
|
||||
private function restoreDeprecatedInboundFields(): void
|
||||
{
|
||||
if (!isset($this->config['inbounds'])) {
|
||||
return;
|
||||
}
|
||||
foreach ($this->config['inbounds'] as &$inbound) {
|
||||
unset($inbound['endpoint_independent_nat']);
|
||||
unset($inbound['sniff_override_destination']);
|
||||
if ($inbound['type'] === 'tun') {
|
||||
$inbound['endpoint_independent_nat'] = true;
|
||||
}
|
||||
if (!empty($inbound['sniff'])) {
|
||||
$inbound['sniff_override_destination'] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* sing-box < 1.12.0: 将新 DNS server type+server 格式转换为旧 address 格式
|
||||
*/
|
||||
private function convertDnsServersToLegacy(): void
|
||||
{
|
||||
if (!isset($this->config['dns']['servers'])) {
|
||||
return;
|
||||
}
|
||||
foreach ($this->config['dns']['servers'] as &$server) {
|
||||
if (!isset($server['type'])) {
|
||||
continue;
|
||||
}
|
||||
$type = $server['type'];
|
||||
$host = $server['server'] ?? null;
|
||||
switch ($type) {
|
||||
case 'https':
|
||||
$server['address'] = "https://{$host}/dns-query";
|
||||
break;
|
||||
case 'tls':
|
||||
$server['address'] = "tls://{$host}";
|
||||
break;
|
||||
case 'tcp':
|
||||
$server['address'] = "tcp://{$host}";
|
||||
break;
|
||||
case 'quic':
|
||||
$server['address'] = "quic://{$host}";
|
||||
break;
|
||||
case 'udp':
|
||||
$server['address'] = $host;
|
||||
break;
|
||||
case 'block':
|
||||
$server['address'] = 'rcode://refused';
|
||||
break;
|
||||
case 'rcode':
|
||||
$server['address'] = 'rcode://' . ($server['rcode'] ?? 'success');
|
||||
unset($server['rcode']);
|
||||
break;
|
||||
default:
|
||||
$server['address'] = $host;
|
||||
break;
|
||||
}
|
||||
unset($server['type'], $server['server']);
|
||||
}
|
||||
unset($server);
|
||||
}
|
||||
|
||||
/**
|
||||
* sing-box < 1.10.0: 将 tun address 数组转换为 inet4_address/inet6_address
|
||||
*/
|
||||
private function convertAddressToLegacy(): void
|
||||
private function convertTunAddressToLegacy(): void
|
||||
{
|
||||
if (!isset($this->config['inbounds'])) {
|
||||
return;
|
||||
@@ -297,42 +413,8 @@ class SingBox extends AbstractProtocol
|
||||
|
||||
$this->appendMultiplex($array, $protocol_settings);
|
||||
|
||||
$transport = match ($protocol_settings['network']) {
|
||||
'tcp' => data_get($protocol_settings, 'network_settings.header.type', 'none') !== 'none' ? [
|
||||
'type' => 'http',
|
||||
'path' => Arr::random(data_get($protocol_settings, 'network_settings.header.request.path', ['/'])),
|
||||
'host' => data_get($protocol_settings, 'network_settings.header.request.headers.Host', [])
|
||||
] : null,
|
||||
'ws' => array_filter([
|
||||
'type' => 'ws',
|
||||
'path' => data_get($protocol_settings, 'network_settings.path'),
|
||||
'headers' => ($host = data_get($protocol_settings, 'network_settings.headers.Host')) ? ['Host' => $host] : null,
|
||||
'max_early_data' => 2048,
|
||||
'early_data_header_name' => 'Sec-WebSocket-Protocol'
|
||||
]),
|
||||
'grpc' => [
|
||||
'type' => 'grpc',
|
||||
'service_name' => data_get($protocol_settings, 'network_settings.serviceName')
|
||||
],
|
||||
'h2' => [
|
||||
'type' => 'http',
|
||||
'host' => data_get($protocol_settings, 'network_settings.host'),
|
||||
'path' => data_get($protocol_settings, 'network_settings.path')
|
||||
],
|
||||
'httpupgrade' => [
|
||||
'type' => 'httpupgrade',
|
||||
'path' => data_get($protocol_settings, 'network_settings.path'),
|
||||
'host' => data_get($protocol_settings, 'network_settings.host', $server['host']),
|
||||
'headers' => data_get($protocol_settings, 'network_settings.headers')
|
||||
],
|
||||
'quic' => [
|
||||
'type' => 'quic'
|
||||
],
|
||||
default => null
|
||||
};
|
||||
|
||||
if ($transport) {
|
||||
$array['transport'] = array_filter($transport, fn($value) => !is_null($value));
|
||||
if ($transport = $this->buildTransport($protocol_settings, $server)) {
|
||||
$array['transport'] = $transport;
|
||||
}
|
||||
return $array;
|
||||
}
|
||||
@@ -347,18 +429,23 @@ class SingBox extends AbstractProtocol
|
||||
"server_port" => $server['port'],
|
||||
"uuid" => $password,
|
||||
"packet_encoding" => "xudp",
|
||||
'flow' => data_get($protocol_settings, 'flow', ''),
|
||||
];
|
||||
if ($flow = data_get($protocol_settings, 'flow')) {
|
||||
$array['flow'] = $flow;
|
||||
}
|
||||
|
||||
if ($protocol_settings['tls']) {
|
||||
if (data_get($protocol_settings, 'tls')) {
|
||||
$tlsMode = (int) data_get($protocol_settings, 'tls', 0);
|
||||
$tlsConfig = [
|
||||
'enabled' => true,
|
||||
'insecure' => (bool) data_get($protocol_settings, 'tls_settings.allow_insecure'),
|
||||
'insecure' => $tlsMode === 2
|
||||
? (bool) data_get($protocol_settings, 'reality_settings.allow_insecure', false)
|
||||
: (bool) data_get($protocol_settings, 'tls_settings.allow_insecure', false),
|
||||
];
|
||||
|
||||
$this->appendUtls($tlsConfig, $protocol_settings);
|
||||
|
||||
switch ($protocol_settings['tls']) {
|
||||
switch ($tlsMode) {
|
||||
case 1:
|
||||
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) {
|
||||
$tlsConfig['server_name'] = $serverName;
|
||||
@@ -379,41 +466,8 @@ class SingBox extends AbstractProtocol
|
||||
|
||||
$this->appendMultiplex($array, $protocol_settings);
|
||||
|
||||
$transport = match ($protocol_settings['network']) {
|
||||
'tcp' => data_get($protocol_settings, 'network_settings.header.type') == 'http' ? [
|
||||
'type' => 'http',
|
||||
'path' => Arr::random(data_get($protocol_settings, 'network_settings.header.request.path', ['/']))
|
||||
] : null,
|
||||
'ws' => array_filter([
|
||||
'type' => 'ws',
|
||||
'path' => data_get($protocol_settings, 'network_settings.path'),
|
||||
'headers' => ($host = data_get($protocol_settings, 'network_settings.headers.Host')) ? ['Host' => $host] : null,
|
||||
'max_early_data' => 2048,
|
||||
'early_data_header_name' => 'Sec-WebSocket-Protocol'
|
||||
], fn($value) => !is_null($value)),
|
||||
'grpc' => [
|
||||
'type' => 'grpc',
|
||||
'service_name' => data_get($protocol_settings, 'network_settings.serviceName')
|
||||
],
|
||||
'h2' => [
|
||||
'type' => 'http',
|
||||
'host' => data_get($protocol_settings, 'network_settings.host'),
|
||||
'path' => data_get($protocol_settings, 'network_settings.path')
|
||||
],
|
||||
'httpupgrade' => [
|
||||
'type' => 'httpupgrade',
|
||||
'path' => data_get($protocol_settings, 'network_settings.path'),
|
||||
'host' => data_get($protocol_settings, 'network_settings.host', $server['host']),
|
||||
'headers' => data_get($protocol_settings, 'network_settings.headers')
|
||||
],
|
||||
'quic' => [
|
||||
'type' => 'quic'
|
||||
],
|
||||
default => null
|
||||
};
|
||||
|
||||
if ($transport) {
|
||||
$array['transport'] = array_filter($transport, fn($value) => !is_null($value));
|
||||
if ($transport = $this->buildTransport($protocol_settings, $server)) {
|
||||
$array['transport'] = $transport;
|
||||
}
|
||||
|
||||
return $array;
|
||||
@@ -428,36 +482,36 @@ class SingBox extends AbstractProtocol
|
||||
'server' => $server['host'],
|
||||
'server_port' => $server['port'],
|
||||
'password' => $password,
|
||||
'tls' => [
|
||||
'enabled' => true,
|
||||
'insecure' => (bool) data_get($protocol_settings, 'allow_insecure', false),
|
||||
]
|
||||
];
|
||||
|
||||
$this->appendUtls($array['tls'], $protocol_settings);
|
||||
$tlsMode = (int) data_get($protocol_settings, 'tls', 1);
|
||||
$tlsConfig = ['enabled' => true];
|
||||
|
||||
if ($serverName = data_get($protocol_settings, 'server_name')) {
|
||||
$array['tls']['server_name'] = $serverName;
|
||||
switch ($tlsMode) {
|
||||
case 2: // Reality
|
||||
$tlsConfig['insecure'] = (bool) data_get($protocol_settings, 'reality_settings.allow_insecure', false);
|
||||
$tlsConfig['server_name'] = data_get($protocol_settings, 'reality_settings.server_name');
|
||||
$tlsConfig['reality'] = [
|
||||
'enabled' => true,
|
||||
'public_key' => data_get($protocol_settings, 'reality_settings.public_key'),
|
||||
'short_id' => data_get($protocol_settings, 'reality_settings.short_id'),
|
||||
];
|
||||
break;
|
||||
default: // Standard TLS
|
||||
$tlsConfig['insecure'] = (bool) data_get($protocol_settings, 'allow_insecure', false);
|
||||
if ($serverName = data_get($protocol_settings, 'server_name')) {
|
||||
$tlsConfig['server_name'] = $serverName;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
$this->appendUtls($tlsConfig, $protocol_settings);
|
||||
$array['tls'] = $tlsConfig;
|
||||
|
||||
$this->appendMultiplex($array, $protocol_settings);
|
||||
|
||||
$transport = match (data_get($protocol_settings, 'network')) {
|
||||
'grpc' => [
|
||||
'type' => 'grpc',
|
||||
'service_name' => data_get($protocol_settings, 'network_settings.serviceName')
|
||||
],
|
||||
'ws' => array_filter([
|
||||
'type' => 'ws',
|
||||
'path' => data_get($protocol_settings, 'network_settings.path'),
|
||||
'headers' => data_get($protocol_settings, 'network_settings.headers.Host') ? ['Host' => [data_get($protocol_settings, 'network_settings.headers.Host')]] : null,
|
||||
'max_early_data' => 2048,
|
||||
'early_data_header_name' => 'Sec-WebSocket-Protocol'
|
||||
]),
|
||||
default => null
|
||||
};
|
||||
if ($transport) {
|
||||
$array['transport'] = array_filter($transport, fn($value) => !is_null($value));
|
||||
if ($transport = $this->buildTransport($protocol_settings, $server)) {
|
||||
$array['transport'] = $transport;
|
||||
}
|
||||
return $array;
|
||||
}
|
||||
@@ -508,10 +562,9 @@ class SingBox extends AbstractProtocol
|
||||
]
|
||||
};
|
||||
|
||||
return array_merge(
|
||||
$baseConfig,
|
||||
$speedConfig,
|
||||
$versionConfig
|
||||
return array_filter(
|
||||
array_merge($baseConfig, $speedConfig, $versionConfig),
|
||||
fn($v) => !is_null($v)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -625,6 +678,47 @@ class SingBox extends AbstractProtocol
|
||||
return $array;
|
||||
}
|
||||
|
||||
protected function buildTransport(array $protocol_settings, array $server): ?array
|
||||
{
|
||||
$transport = match (data_get($protocol_settings, 'network')) {
|
||||
'tcp' => data_get($protocol_settings, 'network_settings.header.type') === 'http' ? [
|
||||
'type' => 'http',
|
||||
'path' => Arr::random(data_get($protocol_settings, 'network_settings.header.request.path', ['/'])),
|
||||
'host' => data_get($protocol_settings, 'network_settings.header.request.headers.Host', [])
|
||||
] : null,
|
||||
'ws' => [
|
||||
'type' => 'ws',
|
||||
'path' => data_get($protocol_settings, 'network_settings.path'),
|
||||
'headers' => ($host = data_get($protocol_settings, 'network_settings.headers.Host')) ? ['Host' => $host] : null,
|
||||
'max_early_data' => 0,
|
||||
// 'early_data_header_name' => 'Sec-WebSocket-Protocol'
|
||||
],
|
||||
'grpc' => [
|
||||
'type' => 'grpc',
|
||||
'service_name' => data_get($protocol_settings, 'network_settings.serviceName')
|
||||
],
|
||||
'h2' => [
|
||||
'type' => 'http',
|
||||
'host' => data_get($protocol_settings, 'network_settings.host'),
|
||||
'path' => data_get($protocol_settings, 'network_settings.path')
|
||||
],
|
||||
'httpupgrade' => [
|
||||
'type' => 'httpupgrade',
|
||||
'path' => data_get($protocol_settings, 'network_settings.path'),
|
||||
'host' => data_get($protocol_settings, 'network_settings.host', $server['host']),
|
||||
'headers' => data_get($protocol_settings, 'network_settings.headers')
|
||||
],
|
||||
'quic' => ['type' => 'quic'],
|
||||
default => null
|
||||
};
|
||||
|
||||
if (!$transport) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array_filter($transport, fn($v) => !is_null($v));
|
||||
}
|
||||
|
||||
protected function appendMultiplex(&$array, $protocol_settings)
|
||||
{
|
||||
if ($multiplex = data_get($protocol_settings, 'multiplex')) {
|
||||
|
||||
+90
-28
@@ -23,6 +23,19 @@ class Stash extends AbstractProtocol
|
||||
Server::TYPE_HTTP,
|
||||
];
|
||||
protected $protocolRequirements = [
|
||||
// Global rules applied regardless of client version (features Stash never supports)
|
||||
'*' => [
|
||||
'trojan' => [
|
||||
'protocol_settings.tls' => [
|
||||
'2' => '9999.0.0', // Trojan Reality not supported in Stash
|
||||
],
|
||||
],
|
||||
'vmess' => [
|
||||
'protocol_settings.network' => [
|
||||
'httpupgrade' => '9999.0.0', // httpupgrade not supported in Stash
|
||||
],
|
||||
],
|
||||
],
|
||||
'stash' => [
|
||||
'anytls' => [
|
||||
'base_version' => '3.3.0' // AnyTLS 协议在3.3.0版本中添加
|
||||
@@ -110,10 +123,10 @@ class Stash extends AbstractProtocol
|
||||
array_push($proxy, self::buildTuic($item['password'], $item));
|
||||
array_push($proxies, $item['name']);
|
||||
}
|
||||
// if ($item['type'] === 'anytls') {
|
||||
// array_push($proxy, self::buildAnyTLS($item['password'], $item));
|
||||
// array_push($proxies, $item['name']);
|
||||
// }
|
||||
if ($item['type'] === Server::TYPE_ANYTLS) {
|
||||
array_push($proxy, self::buildAnyTLS($item['password'], $item));
|
||||
array_push($proxies, $item['name']);
|
||||
}
|
||||
if ($item['type'] === Server::TYPE_SOCKS) {
|
||||
array_push($proxy, self::buildSocks5($item['password'], $item));
|
||||
array_push($proxies, $item['name']);
|
||||
@@ -237,8 +250,8 @@ class Stash extends AbstractProtocol
|
||||
$array['cipher'] = 'auto';
|
||||
$array['udp'] = true;
|
||||
|
||||
$array['tls'] = data_get($protocol_settings, 'tls');
|
||||
$array['skip-cert-verify'] = data_get($protocol_settings, 'tls_settings.allow_insecure');
|
||||
$array['tls'] = (bool) data_get($protocol_settings, 'tls');
|
||||
$array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'tls_settings.allow_insecure', false);
|
||||
if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) {
|
||||
$array['servername'] = $serverName;
|
||||
}
|
||||
@@ -266,6 +279,15 @@ class Stash extends AbstractProtocol
|
||||
$array['grpc-opts'] = [];
|
||||
$array['grpc-opts']['grpc-service-name'] = data_get($protocol_settings, 'network_settings.serviceName');
|
||||
break;
|
||||
case 'h2':
|
||||
$array['network'] = 'h2';
|
||||
$array['tls'] = true;
|
||||
$array['h2-opts'] = [];
|
||||
if ($path = data_get($protocol_settings, 'network_settings.path'))
|
||||
$array['h2-opts']['path'] = $path;
|
||||
if ($host = data_get($protocol_settings, 'network_settings.host'))
|
||||
$array['h2-opts']['host'] = is_array($host) ? $host : [$host];
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -297,6 +319,7 @@ class Stash extends AbstractProtocol
|
||||
break;
|
||||
case 2:
|
||||
$array['tls'] = true;
|
||||
$array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'reality_settings.allow_insecure', false);
|
||||
if ($serverName = data_get($protocol_settings, 'reality_settings.server_name')) {
|
||||
$array['servername'] = $serverName;
|
||||
$array['sni'] = $serverName;
|
||||
@@ -335,11 +358,14 @@ class Stash extends AbstractProtocol
|
||||
$array['network'] = 'grpc';
|
||||
$array['grpc-opts']['grpc-service-name'] = data_get($protocol_settings, 'network_settings.serviceName');
|
||||
break;
|
||||
// case 'h2':
|
||||
// $array['network'] = 'h2';
|
||||
// $array['h2-opts']['host'] = data_get($protocol_settings, 'network_settings.host');
|
||||
// $array['h2-opts']['path'] = data_get($protocol_settings, 'network_settings.path');
|
||||
// break;
|
||||
case 'h2':
|
||||
$array['network'] = 'h2';
|
||||
$array['h2-opts'] = [];
|
||||
if ($path = data_get($protocol_settings, 'network_settings.path'))
|
||||
$array['h2-opts']['path'] = $path;
|
||||
if ($host = data_get($protocol_settings, 'network_settings.host'))
|
||||
$array['h2-opts']['host'] = is_array($host) ? $host : [$host];
|
||||
break;
|
||||
}
|
||||
|
||||
return $array;
|
||||
@@ -348,13 +374,36 @@ class Stash extends AbstractProtocol
|
||||
public static function buildTrojan($password, $server)
|
||||
{
|
||||
$protocol_settings = $server['protocol_settings'];
|
||||
$array = [];
|
||||
$array['name'] = $server['name'];
|
||||
$array['type'] = 'trojan';
|
||||
$array['server'] = $server['host'];
|
||||
$array['port'] = $server['port'];
|
||||
$array['password'] = $password;
|
||||
$array['udp'] = true;
|
||||
$array = [
|
||||
'name' => $server['name'],
|
||||
'type' => 'trojan',
|
||||
'server' => $server['host'],
|
||||
'port' => $server['port'],
|
||||
'password' => $password,
|
||||
'udp' => true,
|
||||
];
|
||||
|
||||
$tlsMode = (int) data_get($protocol_settings, 'tls', 1);
|
||||
switch ($tlsMode) {
|
||||
case 2: // Reality
|
||||
$array['tls'] = true;
|
||||
$array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'reality_settings.allow_insecure', false);
|
||||
if ($serverName = data_get($protocol_settings, 'reality_settings.server_name')) {
|
||||
$array['sni'] = $serverName;
|
||||
}
|
||||
$array['reality-opts'] = [
|
||||
'public-key' => data_get($protocol_settings, 'reality_settings.public_key'),
|
||||
'short-id' => data_get($protocol_settings, 'reality_settings.short_id'),
|
||||
];
|
||||
break;
|
||||
default: // Standard TLS
|
||||
if ($serverName = data_get($protocol_settings, 'server_name')) {
|
||||
$array['sni'] = $serverName;
|
||||
}
|
||||
$array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'allow_insecure', false);
|
||||
break;
|
||||
}
|
||||
|
||||
switch (data_get($protocol_settings, 'network')) {
|
||||
case 'tcp':
|
||||
$headerType = data_get($protocol_settings, 'network_settings.header.type', 'tcp');
|
||||
@@ -370,11 +419,13 @@ class Stash extends AbstractProtocol
|
||||
$array['ws-opts']['headers'] = ['Host' => $host];
|
||||
}
|
||||
break;
|
||||
case 'grpc':
|
||||
$array['network'] = 'grpc';
|
||||
if ($serviceName = data_get($protocol_settings, 'network_settings.serviceName'))
|
||||
$array['grpc-opts']['grpc-service-name'] = $serviceName;
|
||||
break;
|
||||
}
|
||||
if ($serverName = data_get($protocol_settings, 'server_name')) {
|
||||
$array['sni'] = $serverName;
|
||||
}
|
||||
$array['skip-cert-verify'] = data_get($protocol_settings, 'allow_insecure');
|
||||
|
||||
return $array;
|
||||
}
|
||||
|
||||
@@ -398,12 +449,18 @@ class Stash extends AbstractProtocol
|
||||
$array['type'] = 'hysteria';
|
||||
$array['auth-str'] = $password;
|
||||
$array['protocol'] = 'udp';
|
||||
$array['obfs'] = data_get($protocol_settings, 'obfs.open') ? data_get($protocol_settings, 'obfs.type') : null;
|
||||
if (data_get($protocol_settings, 'obfs.open')) {
|
||||
$array['obfs'] = data_get($protocol_settings, 'obfs.password');
|
||||
}
|
||||
break;
|
||||
case 2:
|
||||
$array['type'] = 'hysteria2';
|
||||
$array['auth'] = $password;
|
||||
$array['fast-open'] = true;
|
||||
if (data_get($protocol_settings, 'obfs.open')) {
|
||||
$array['obfs'] = data_get($protocol_settings, 'obfs.type', 'salamander');
|
||||
$array['obfs-password'] = data_get($protocol_settings, 'obfs.password');
|
||||
}
|
||||
break;
|
||||
}
|
||||
return $array;
|
||||
@@ -417,8 +474,6 @@ class Stash extends AbstractProtocol
|
||||
'type' => 'tuic',
|
||||
'server' => $server['host'],
|
||||
'port' => $server['port'],
|
||||
'uuid' => $password,
|
||||
'password' => $password,
|
||||
'congestion-controller' => data_get($protocol_settings, 'congestion_control', 'cubic'),
|
||||
'udp-relay-mode' => data_get($protocol_settings, 'udp_relay_mode', 'native'),
|
||||
'alpn' => data_get($protocol_settings, 'alpn', ['h3']),
|
||||
@@ -430,6 +485,13 @@ class Stash extends AbstractProtocol
|
||||
'version' => data_get($protocol_settings, 'version', 5),
|
||||
];
|
||||
|
||||
if (data_get($protocol_settings, 'version') === 4) {
|
||||
$array['token'] = $password;
|
||||
} else {
|
||||
$array['uuid'] = $password;
|
||||
$array['password'] = $password;
|
||||
}
|
||||
|
||||
$array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'tls.allow_insecure', false);
|
||||
if ($serverName = data_get($protocol_settings, 'tls.server_name')) {
|
||||
$array['sni'] = $serverName;
|
||||
@@ -440,15 +502,15 @@ class Stash extends AbstractProtocol
|
||||
|
||||
public static function buildAnyTLS($password, $server)
|
||||
{
|
||||
$protocol_settings = $server['protocol_settings'];
|
||||
$protocol_settings = data_get($server, 'protocol_settings', []);
|
||||
$array = [
|
||||
'name' => $server['name'],
|
||||
'type' => 'anytls',
|
||||
'server' => $server['host'],
|
||||
'port' => $server['port'],
|
||||
'password' => $password,
|
||||
'sni' => data_get($protocol_settings, 'tls_settings.server_name'),
|
||||
'skip-cert-verify' => (bool) data_get($protocol_settings, 'tls_settings.allow_insecure', false),
|
||||
'sni' => data_get($protocol_settings, 'tls.server_name'),
|
||||
'skip-cert-verify' => (bool) data_get($protocol_settings, 'tls.allow_insecure', false),
|
||||
'udp' => true,
|
||||
];
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ class Surfboard extends AbstractProtocol
|
||||
Server::TYPE_SHADOWSOCKS,
|
||||
Server::TYPE_VMESS,
|
||||
Server::TYPE_TROJAN,
|
||||
Server::TYPE_ANYTLS,
|
||||
];
|
||||
const CUSTOM_TEMPLATE_FILE = 'resources/rules/custom.surfboard.conf';
|
||||
const DEFAULT_TEMPLATE_FILE = 'resources/rules/default.surfboard.conf';
|
||||
@@ -36,7 +37,10 @@ class Surfboard extends AbstractProtocol
|
||||
'aes-128-gcm',
|
||||
'aes-192-gcm',
|
||||
'aes-256-gcm',
|
||||
'chacha20-ietf-poly1305'
|
||||
'chacha20-ietf-poly1305',
|
||||
'2022-blake3-aes-128-gcm',
|
||||
'2022-blake3-aes-256-gcm',
|
||||
'2022-blake3-chacha20-poly1305'
|
||||
])
|
||||
) {
|
||||
// [Proxy]
|
||||
@@ -56,6 +60,10 @@ class Surfboard extends AbstractProtocol
|
||||
// [Proxy Group]
|
||||
$proxyGroup .= $item['name'] . ', ';
|
||||
}
|
||||
if ($item['type'] === Server::TYPE_ANYTLS) {
|
||||
$proxies .= self::buildAnyTLS($item['password'], $item);
|
||||
$proxyGroup .= $item['name'] . ', ';
|
||||
}
|
||||
}
|
||||
|
||||
$config = subscribe_template('surfboard');
|
||||
@@ -74,7 +82,7 @@ class Surfboard extends AbstractProtocol
|
||||
$totalTraffic = round($user['transfer_enable'] / (1024 * 1024 * 1024), 2);
|
||||
$unusedTraffic = $totalTraffic - $useTraffic;
|
||||
$expireDate = $user['expired_at'] === NULL ? '长期有效' : date('Y-m-d H:i:s', $user['expired_at']);
|
||||
$subscribeInfo = "title={$appName}订阅信息, content=上传流量:{$upload}GB\\n下载流量:{$download}GB\\n剩余流量: { $unusedTraffic }GB\\n套餐流量:{$totalTraffic}GB\\n到期时间:{$expireDate}";
|
||||
$subscribeInfo = "title={$appName}订阅信息, content=上传流量:{$upload}GB\\n下载流量:{$download}GB\\n剩余流量:{$unusedTraffic}GB\\n套餐流量:{$totalTraffic}GB\\n到期时间:{$expireDate}";
|
||||
$config = str_replace('$subscribe_info', $subscribeInfo, $config);
|
||||
|
||||
return response($config, 200)
|
||||
@@ -89,7 +97,7 @@ class Surfboard extends AbstractProtocol
|
||||
"{$server['name']}=ss",
|
||||
"{$server['host']}",
|
||||
"{$server['port']}",
|
||||
"encrypt-method={$protocol_settings['cipher']}",
|
||||
"encrypt-method=" . data_get($protocol_settings, 'cipher'),
|
||||
"password={$password}",
|
||||
'tfo=true',
|
||||
'udp-relay=true'
|
||||
@@ -146,10 +154,12 @@ class Surfboard extends AbstractProtocol
|
||||
array_push($config, 'tls=true');
|
||||
if (data_get($protocol_settings, 'tls_settings')) {
|
||||
$tlsSettings = data_get($protocol_settings, 'tls_settings');
|
||||
if (!!data_get($tlsSettings, 'allowInsecure'))
|
||||
array_push($config, 'skip-cert-verify=' . ($tlsSettings['allowInsecure'] ? 'true' : 'false'));
|
||||
if (!!data_get($tlsSettings, 'serverName'))
|
||||
array_push($config, "sni={$tlsSettings['serverName']}");
|
||||
if (data_get($tlsSettings, 'allow_insecure')) {
|
||||
array_push($config, 'skip-cert-verify=' . ($tlsSettings['allow_insecure'] ? 'true' : 'false'));
|
||||
}
|
||||
if ($sni = data_get($tlsSettings, 'server_name')) {
|
||||
array_push($config, "sni={$sni}");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (data_get($protocol_settings, 'network') === 'ws') {
|
||||
@@ -176,7 +186,7 @@ class Surfboard extends AbstractProtocol
|
||||
"{$server['host']}",
|
||||
"{$server['port']}",
|
||||
"password={$password}",
|
||||
$protocol_settings['server_name'] ? "sni={$protocol_settings['server_name']}" : "",
|
||||
data_get($protocol_settings, 'server_name') ? "sni=" . data_get($protocol_settings, 'server_name') : "",
|
||||
'tfo=true',
|
||||
'udp-relay=true'
|
||||
];
|
||||
@@ -188,4 +198,32 @@ class Surfboard extends AbstractProtocol
|
||||
$uri .= "\r\n";
|
||||
return $uri;
|
||||
}
|
||||
|
||||
public static function buildAnyTLS($password, $server)
|
||||
{
|
||||
$protocol_settings = data_get($server, 'protocol_settings', []);
|
||||
|
||||
$config = [
|
||||
"{$server['name']}=anytls",
|
||||
"{$server['host']}",
|
||||
"{$server['port']}",
|
||||
"password={$password}",
|
||||
"tfo=true",
|
||||
"udp-relay=true"
|
||||
];
|
||||
|
||||
// SNI
|
||||
if ($serverName = data_get($protocol_settings, 'tls.server_name')) {
|
||||
$config[] = "sni={$serverName}";
|
||||
}
|
||||
|
||||
// 跳过证书校验
|
||||
if (data_get($protocol_settings, 'tls.allow_insecure')) {
|
||||
$config[] = "skip-cert-verify=true";
|
||||
}
|
||||
|
||||
$config = array_filter($config);
|
||||
|
||||
return implode(',', $config) . "\r\n";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ class Surge extends AbstractProtocol
|
||||
$totalTraffic = round($user['transfer_enable'] / (1024 * 1024 * 1024), 2);
|
||||
$unusedTraffic = $totalTraffic - $useTraffic;
|
||||
$expireDate = $user['expired_at'] === NULL ? '长期有效' : date('Y-m-d H:i:s', $user['expired_at']);
|
||||
$subscribeInfo = "title={$appName}订阅信息, content=上传流量:{$upload}GB\\n下载流量:{$download}GB\\n剩余流量:{ $unusedTraffic }GB\\n套餐流量:{$totalTraffic}GB\\n到期时间:{$expireDate}";
|
||||
$subscribeInfo = "title={$appName}订阅信息, content=上传流量:{$upload}GB\\n下载流量:{$download}GB\\n剩余流量:{$unusedTraffic}GB\\n套餐流量:{$totalTraffic}GB\\n到期时间:{$expireDate}";
|
||||
$config = str_replace('$subscribe_info', $subscribeInfo, $config);
|
||||
|
||||
return response($config, 200)
|
||||
@@ -108,7 +108,7 @@ class Surge extends AbstractProtocol
|
||||
{
|
||||
$protocol_settings = $server['protocol_settings'];
|
||||
$config = [
|
||||
"{$server['name']}=ss",
|
||||
"{$server['name']} = ss",
|
||||
"{$server['host']}",
|
||||
"{$server['port']}",
|
||||
"encrypt-method={$protocol_settings['cipher']}",
|
||||
@@ -152,7 +152,7 @@ class Surge extends AbstractProtocol
|
||||
{
|
||||
$protocol_settings = $server['protocol_settings'];
|
||||
$config = [
|
||||
"{$server['name']}=vmess",
|
||||
"{$server['name']} = vmess",
|
||||
"{$server['host']}",
|
||||
"{$server['port']}",
|
||||
"username={$uuid}",
|
||||
@@ -191,11 +191,11 @@ class Surge extends AbstractProtocol
|
||||
{
|
||||
$protocol_settings = $server['protocol_settings'];
|
||||
$config = [
|
||||
"{$server['name']}=trojan",
|
||||
"{$server['name']} = trojan",
|
||||
"{$server['host']}",
|
||||
"{$server['port']}",
|
||||
"password={$password}",
|
||||
$protocol_settings['server_name'] ? "sni={$protocol_settings['server_name']}" : "",
|
||||
data_get($protocol_settings, 'server_name') ? "sni=" . data_get($protocol_settings, 'server_name') : "",
|
||||
'tfo=true',
|
||||
'udp-relay=true'
|
||||
];
|
||||
@@ -213,7 +213,7 @@ class Surge extends AbstractProtocol
|
||||
{
|
||||
$protocol_settings = data_get($server, 'protocol_settings', []);
|
||||
$config = [
|
||||
"{$server['name']}=anytls",
|
||||
"{$server['name']} = anytls",
|
||||
"{$server['host']}",
|
||||
"{$server['port']}",
|
||||
"password={$password}",
|
||||
@@ -237,7 +237,7 @@ class Surge extends AbstractProtocol
|
||||
if ($protocol_settings['version'] != 2)
|
||||
return '';
|
||||
$config = [
|
||||
"{$server['name']}=hysteria2",
|
||||
"{$server['name']} = hysteria2",
|
||||
"{$server['host']}",
|
||||
"{$server['port']}",
|
||||
"password={$password}",
|
||||
@@ -266,7 +266,7 @@ class Surge extends AbstractProtocol
|
||||
$protocol_settings = data_get($server, 'protocol_settings', []);
|
||||
$type = data_get($protocol_settings, 'tls') ? 'socks5-tls' : 'socks5';
|
||||
$config = [
|
||||
"{$server['name']}={$type}",
|
||||
"{$server['name']} = {$type}",
|
||||
"{$server['host']}",
|
||||
"{$server['port']}",
|
||||
"{$password}",
|
||||
@@ -295,7 +295,7 @@ class Surge extends AbstractProtocol
|
||||
$protocol_settings = data_get($server, 'protocol_settings', []);
|
||||
$type = data_get($protocol_settings, 'tls') ? 'https' : 'http';
|
||||
$config = [
|
||||
"{$server['name']}={$type}",
|
||||
"{$server['name']} = {$type}",
|
||||
"{$server['host']}",
|
||||
"{$server['port']}",
|
||||
"{$password}",
|
||||
|
||||
@@ -36,7 +36,7 @@ class LoginService
|
||||
}
|
||||
|
||||
// 查找用户
|
||||
$user = User::where('email', $email)->first();
|
||||
$user = User::byEmail($email)->first();
|
||||
if (!$user) {
|
||||
return [false, [400, __('Incorrect email or password')]];
|
||||
}
|
||||
@@ -99,7 +99,7 @@ class LoginService
|
||||
}
|
||||
|
||||
// 查找用户
|
||||
$user = User::where('email', $email)->first();
|
||||
$user = User::byEmail($email)->first();
|
||||
if (!$user) {
|
||||
return [false, [400, __('This email is not registered in the system')]];
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ class MailLinkService
|
||||
return [false, [429, __('Sending frequently, please try again later')]];
|
||||
}
|
||||
|
||||
$user = User::where('email', $email)->first();
|
||||
$user = User::byEmail($email)->first();
|
||||
if (!$user) {
|
||||
return [true, true]; // 成功但用户不存在,保护用户隐私
|
||||
}
|
||||
@@ -46,7 +46,7 @@ class MailLinkService
|
||||
|
||||
$this->sendMailLinkEmail($user, $link);
|
||||
|
||||
return [true, $link];
|
||||
return [true, true];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -91,8 +91,7 @@ class RegisterService
|
||||
}
|
||||
|
||||
// 检查邮箱是否存在
|
||||
$email = $request->input('email');
|
||||
$exist = User::where('email', $email)->first();
|
||||
$exist = User::byEmail($request->input('email'))->first();
|
||||
if ($exist) {
|
||||
return [false, [400201, __('Email already exists')]];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
class DeviceStateService
|
||||
{
|
||||
private const PREFIX = 'user_devices:';
|
||||
private const TTL = 300; // device state ttl
|
||||
private const DB_THROTTLE = 10; // update db throttle
|
||||
|
||||
/**
|
||||
* 移除 Redis key 的前缀
|
||||
*/
|
||||
private function removeRedisPrefix(string $key): string
|
||||
{
|
||||
$prefix = config('database.redis.options.prefix', '');
|
||||
return $prefix ? substr($key, strlen($prefix)) : $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量设置设备
|
||||
* 用于 HTTP /alive 和 WebSocket report.devices
|
||||
*/
|
||||
public function setDevices(int $userId, int $nodeId, array $ips): void
|
||||
{
|
||||
$key = self::PREFIX . $userId;
|
||||
$timestamp = time();
|
||||
|
||||
$this->removeNodeDevices($nodeId, $userId);
|
||||
|
||||
if (!empty($ips)) {
|
||||
$fields = [];
|
||||
foreach ($ips as $ip) {
|
||||
$fields["{$nodeId}:{$ip}"] = $timestamp;
|
||||
}
|
||||
Redis::hMset($key, $fields);
|
||||
Redis::expire($key, self::TTL);
|
||||
}
|
||||
|
||||
$this->notifyUpdate($userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取某节点的所有设备数据
|
||||
* 返回: {userId: [ip1, ip2, ...], ...}
|
||||
*/
|
||||
public function getNodeDevices(int $nodeId): array
|
||||
{
|
||||
$keys = Redis::keys(self::PREFIX . '*');
|
||||
$prefix = "{$nodeId}:";
|
||||
$result = [];
|
||||
foreach ($keys as $key) {
|
||||
$actualKey = $this->removeRedisPrefix($key);
|
||||
$uid = (int) substr($actualKey, strlen(self::PREFIX));
|
||||
$data = Redis::hgetall($actualKey);
|
||||
foreach ($data as $field => $timestamp) {
|
||||
if (str_starts_with($field, $prefix)) {
|
||||
$ip = substr($field, strlen($prefix));
|
||||
$result[$uid][] = $ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除某节点某用户的设备
|
||||
*/
|
||||
public function removeNodeDevices(int $nodeId, int $userId): void
|
||||
{
|
||||
$key = self::PREFIX . $userId;
|
||||
$prefix = "{$nodeId}:";
|
||||
|
||||
foreach (Redis::hkeys($key) as $field) {
|
||||
if (str_starts_with($field, $prefix)) {
|
||||
Redis::hdel($key, $field);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除节点所有设备数据(用于节点断开连接)
|
||||
*/
|
||||
public function clearAllNodeDevices(int $nodeId): array
|
||||
{
|
||||
$oldDevices = $this->getNodeDevices($nodeId);
|
||||
$prefix = "{$nodeId}:";
|
||||
|
||||
foreach ($oldDevices as $userId => $ips) {
|
||||
$key = self::PREFIX . $userId;
|
||||
foreach (Redis::hkeys($key) as $field) {
|
||||
if (str_starts_with($field, $prefix)) {
|
||||
Redis::hdel($key, $field);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_keys($oldDevices);
|
||||
}
|
||||
|
||||
/**
|
||||
* get user device count (deduplicated by IP, filter expired data)
|
||||
*/
|
||||
public function getDeviceCount(int $userId): int
|
||||
{
|
||||
$data = Redis::hgetall(self::PREFIX . $userId);
|
||||
$now = time();
|
||||
$ips = [];
|
||||
|
||||
foreach ($data as $field => $timestamp) {
|
||||
if ($now - $timestamp <= self::TTL) {
|
||||
$ips[] = substr($field, strpos($field, ':') + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return count(array_unique($ips));
|
||||
}
|
||||
|
||||
/**
|
||||
* get user device count (for alivelist interface)
|
||||
*/
|
||||
public function getAliveList(Collection $users): array
|
||||
{
|
||||
if ($users->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($users as $user) {
|
||||
$count = $this->getDeviceCount($user->id);
|
||||
if ($count > 0) {
|
||||
$result[$user->id] = $count;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* get devices of multiple users (for sync.devices, filter expired data)
|
||||
*/
|
||||
public function getUsersDevices(array $userIds): array
|
||||
{
|
||||
$result = [];
|
||||
$now = time();
|
||||
foreach ($userIds as $userId) {
|
||||
$data = Redis::hgetall(self::PREFIX . $userId);
|
||||
if (!empty($data)) {
|
||||
$ips = [];
|
||||
foreach ($data as $field => $timestamp) {
|
||||
if ($now - $timestamp <= self::TTL) {
|
||||
$ips[] = substr($field, strpos($field, ':') + 1);
|
||||
}
|
||||
}
|
||||
if (!empty($ips)) {
|
||||
$result[$userId] = array_unique($ips);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* notify update (throttle control)
|
||||
*/
|
||||
public function notifyUpdate(int $userId): void
|
||||
{
|
||||
$dbThrottleKey = "device:db_throttle:{$userId}";
|
||||
|
||||
// if (Redis::setnx($dbThrottleKey, 1)) {
|
||||
// Redis::expire($dbThrottleKey, self::DB_THROTTLE);
|
||||
|
||||
User::query()
|
||||
->whereKey($userId)
|
||||
->update([
|
||||
'online_count' => $this->getDeviceCount($userId),
|
||||
'last_online_at' => now(),
|
||||
]);
|
||||
// }
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ class NodeSyncService
|
||||
/**
|
||||
* Check if node has active WS connection
|
||||
*/
|
||||
private static function isNodeOnline(int $nodeId): bool
|
||||
public static function isNodeOnline(int $nodeId): bool
|
||||
{
|
||||
return (bool) Cache::get("node_ws_alive:{$nodeId}");
|
||||
}
|
||||
@@ -125,7 +125,7 @@ class NodeSyncService
|
||||
/**
|
||||
* Publish a push command to Redis — picked up by the Workerman WS server
|
||||
*/
|
||||
private static function push(int $nodeId, string $event, array $data): void
|
||||
public static function push(int $nodeId, string $event, array $data): void
|
||||
{
|
||||
try {
|
||||
Redis::publish('node:push', json_encode([
|
||||
|
||||
@@ -42,6 +42,11 @@ class ServerService
|
||||
{
|
||||
$servers = Server::whereJsonContains('group_ids', (string) $user->group_id)
|
||||
->where('show', true)
|
||||
->where(function ($query) {
|
||||
$query->whereNull('transfer_enable')
|
||||
->orWhere('transfer_enable', 0)
|
||||
->orWhereRaw('u + d < transfer_enable');
|
||||
})
|
||||
->orderBy('sort', 'ASC')
|
||||
->get()
|
||||
->append(['last_check_at', 'last_push_at', 'online', 'is_online', 'available_status', 'cache_key', 'server_key']);
|
||||
@@ -168,11 +173,20 @@ class ServerService
|
||||
'host' => $host,
|
||||
'server_name' => $protocolSettings['server_name'],
|
||||
'multiplex' => data_get($protocolSettings, 'multiplex'),
|
||||
'tls' => (int) $protocolSettings['tls'],
|
||||
'tls_settings' => match ((int) $protocolSettings['tls']) {
|
||||
2 => $protocolSettings['reality_settings'],
|
||||
default => null,
|
||||
},
|
||||
],
|
||||
'vless' => [
|
||||
...$baseConfig,
|
||||
'tls' => (int) $protocolSettings['tls'],
|
||||
'flow' => $protocolSettings['flow'],
|
||||
'decryption' => match (data_get($protocolSettings, 'encryption.enabled')) {
|
||||
true => data_get($protocolSettings, 'encryption.decryption'),
|
||||
default => null,
|
||||
},
|
||||
'tls_settings' => match ((int) $protocolSettings['tls']) {
|
||||
2 => $protocolSettings['reality_settings'],
|
||||
default => $protocolSettings['tls_settings'],
|
||||
@@ -239,10 +253,10 @@ class ServerService
|
||||
default => [],
|
||||
};
|
||||
|
||||
$response = array_filter(
|
||||
$response,
|
||||
static fn ($value) => $value !== null
|
||||
);
|
||||
// $response = array_filter(
|
||||
// $response,
|
||||
// static fn ($value) => $value !== null
|
||||
// );
|
||||
|
||||
if (!empty($node['route_ids'])) {
|
||||
$response['routes'] = self::getRoutes($node['route_ids']);
|
||||
@@ -256,7 +270,7 @@ class ServerService
|
||||
$response['custom_routes'] = $node['custom_routes'];
|
||||
}
|
||||
|
||||
if (!empty($node['cert_config'])) {
|
||||
if (!empty($node['cert_config']) && data_get($node['cert_config'],'cert_mode') !== 'none' ) {
|
||||
$response['cert_config'] = $node['cert_config'];
|
||||
}
|
||||
|
||||
@@ -269,7 +283,7 @@ class ServerService
|
||||
* @param string $serverType
|
||||
* @return Server|null
|
||||
*/
|
||||
public static function getServer($serverId, ?string $serverType)
|
||||
public static function getServer($serverId, ?string $serverType = null): Server | null
|
||||
{
|
||||
$baseQuery = Server::query()
|
||||
->when($serverType, function ($query) use ($serverType) {
|
||||
|
||||
@@ -26,6 +26,11 @@ abstract class AbstractProtocol
|
||||
*/
|
||||
protected $clientVersion;
|
||||
|
||||
/**
|
||||
* @var string|null 原始 User-Agent
|
||||
*/
|
||||
protected $userAgent;
|
||||
|
||||
/**
|
||||
* @var array 协议标识
|
||||
*/
|
||||
@@ -48,13 +53,15 @@ abstract class AbstractProtocol
|
||||
* @param array $servers 服务器信息
|
||||
* @param string|null $clientName 客户端名称
|
||||
* @param string|null $clientVersion 客户端版本
|
||||
* @param string|null $userAgent 原始 User-Agent
|
||||
*/
|
||||
public function __construct($user, $servers, $clientName = null, $clientVersion = null)
|
||||
public function __construct($user, $servers, $clientName = null, $clientVersion = null, $userAgent = null)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->servers = $servers;
|
||||
$this->clientName = $clientName;
|
||||
$this->clientVersion = $clientVersion;
|
||||
$this->userAgent = $userAgent;
|
||||
$this->protocolRequirements = $this->normalizeProtocolRequirements($this->protocolRequirements);
|
||||
$this->servers = HookManager::filter('protocol.servers.filtered', $this->filterServersByVersion());
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ class Helper
|
||||
case 'md5': return md5($password) === $hash;
|
||||
case 'sha256': return hash('sha256', $password) === $hash;
|
||||
case 'md5salt': return md5($password . $salt) === $hash;
|
||||
case 'sha256salt': return hash('sha256', $password . $salt) === $hash;
|
||||
default: return password_verify($password, $hash);
|
||||
}
|
||||
}
|
||||
@@ -229,4 +230,14 @@ class Helper
|
||||
{
|
||||
return $transfer_enable / 1073741824;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转义 Telegram Markdown 特殊字符
|
||||
* @param string $text
|
||||
* @return string
|
||||
*/
|
||||
public static function escapeMarkdown(string $text): string
|
||||
{
|
||||
return str_replace(['_', '*', '`', '['], ['\_', '\*', '\`', '\['], $text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
namespace App\WebSocket;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Services\DeviceStateService;
|
||||
use App\Services\NodeRegistry;
|
||||
use App\Services\ServerService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
use Workerman\Connection\TcpConnection;
|
||||
|
||||
class NodeEventHandlers
|
||||
{
|
||||
/**
|
||||
* Handle pong heartbeat
|
||||
*/
|
||||
public static function handlePong(TcpConnection $conn, int $nodeId, array $data = []): void
|
||||
{
|
||||
Cache::put("node_ws_alive:{$nodeId}", true, 86400);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle node status update
|
||||
*/
|
||||
public static function handleNodeStatus(TcpConnection $conn, int $nodeId, array $data): void
|
||||
{
|
||||
$node = Server::find($nodeId);
|
||||
if (!$node) return;
|
||||
|
||||
$nodeType = strtoupper($node->type);
|
||||
Cache::put(\App\Utils\CacheKey::get('SERVER_' . $nodeType . '_LAST_CHECK_AT', $nodeId), time(), 3600);
|
||||
ServerService::updateMetrics($node, $data);
|
||||
|
||||
Log::debug("[WS] Node#{$nodeId} status updated");
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle device report from node
|
||||
*
|
||||
* 数据格式: {"event": "report.devices", "data": {userId: [ip1, ip2, ...], ...}}
|
||||
*/
|
||||
public static function handleDeviceReport(TcpConnection $conn, int $nodeId, array $data): void
|
||||
{
|
||||
$service = app(DeviceStateService::class);
|
||||
|
||||
// Get old data
|
||||
$oldDevices = $service->getNodeDevices($nodeId);
|
||||
|
||||
// Calculate diff
|
||||
$removedUsers = array_diff_key($oldDevices, $data);
|
||||
$newDevices = [];
|
||||
|
||||
foreach ($data as $userId => $ips) {
|
||||
if (is_numeric($userId) && is_array($ips)) {
|
||||
$newDevices[(int) $userId] = $ips;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle removed users
|
||||
foreach ($removedUsers as $userId => $ips) {
|
||||
$service->removeNodeDevices($nodeId, $userId);
|
||||
$service->notifyUpdate($userId);
|
||||
}
|
||||
|
||||
// Handle new/updated users
|
||||
foreach ($newDevices as $userId => $ips) {
|
||||
$service->setDevices($userId, $nodeId, $ips);
|
||||
}
|
||||
|
||||
// Mark for push
|
||||
Redis::sadd('device:push_pending_nodes', $nodeId);
|
||||
|
||||
Log::debug("[WS] Node#{$nodeId} synced " . count($newDevices) . " users, removed " . count($removedUsers));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle device state request from node
|
||||
*/
|
||||
public static function handleDeviceRequest(TcpConnection $conn, int $nodeId, array $data = []): void
|
||||
{
|
||||
$node = Server::find($nodeId);
|
||||
if (!$node) return;
|
||||
|
||||
$users = ServerService::getAvailableUsers($node);
|
||||
$userIds = $users->pluck('id')->toArray();
|
||||
|
||||
$service = app(DeviceStateService::class);
|
||||
$devices = $service->getUsersDevices($userIds);
|
||||
|
||||
$conn->send(json_encode([
|
||||
'event' => 'sync.devices',
|
||||
'data' => ['users' => $devices],
|
||||
]));
|
||||
|
||||
Log::debug("[WS] Node#{$nodeId} requested devices, sent " . count($devices) . " users");
|
||||
}
|
||||
|
||||
/**
|
||||
* Push device state to node
|
||||
*/
|
||||
public static function pushDeviceStateToNode(int $nodeId, DeviceStateService $service): void
|
||||
{
|
||||
$node = Server::find($nodeId);
|
||||
if (!$node) return;
|
||||
|
||||
$users = ServerService::getAvailableUsers($node);
|
||||
$userIds = $users->pluck('id')->toArray();
|
||||
$devices = $service->getUsersDevices($userIds);
|
||||
|
||||
NodeRegistry::send($nodeId, 'sync.devices', [
|
||||
'users' => $devices
|
||||
]);
|
||||
|
||||
Log::debug("[WS] Pushed device state to node#{$nodeId}: " . count($devices) . " users");
|
||||
}
|
||||
|
||||
/**
|
||||
* Push full config + users to newly connected node
|
||||
*/
|
||||
public static function pushFullSync(TcpConnection $conn, Server $node): void
|
||||
{
|
||||
$nodeId = $conn->nodeId;
|
||||
|
||||
// Push config
|
||||
$config = ServerService::buildNodeConfig($node);
|
||||
$conn->send(json_encode([
|
||||
'event' => 'sync.config',
|
||||
'data' => ['config' => $config]
|
||||
]));
|
||||
|
||||
// Push users
|
||||
$users = ServerService::getAvailableUsers($node)->toArray();
|
||||
$conn->send(json_encode([
|
||||
'event' => 'sync.users',
|
||||
'data' => ['users' => $users]
|
||||
]));
|
||||
|
||||
Log::info("[WS] Full sync pushed to node#{$nodeId}", [
|
||||
'users' => count($users),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
namespace App\WebSocket;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Services\DeviceStateService;
|
||||
use App\Services\NodeRegistry;
|
||||
use App\Services\ServerService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
use Workerman\Connection\TcpConnection;
|
||||
use Workerman\Timer;
|
||||
use Workerman\Worker;
|
||||
|
||||
class NodeWorker
|
||||
{
|
||||
private const AUTH_TIMEOUT = 10;
|
||||
private const PING_INTERVAL = 55;
|
||||
|
||||
private Worker $worker;
|
||||
|
||||
private array $handlers = [
|
||||
'pong' => [NodeEventHandlers::class, 'handlePong'],
|
||||
'node.status' => [NodeEventHandlers::class, 'handleNodeStatus'],
|
||||
'report.devices' => [NodeEventHandlers::class, 'handleDeviceReport'],
|
||||
'request.devices' => [NodeEventHandlers::class, 'handleDeviceRequest'],
|
||||
];
|
||||
|
||||
public function __construct(string $host, int $port)
|
||||
{
|
||||
$this->worker = new Worker("websocket://{$host}:{$port}");
|
||||
$this->worker->count = 1;
|
||||
$this->worker->name = 'xboard-ws-server';
|
||||
}
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->setupLogging();
|
||||
$this->setupCallbacks();
|
||||
Worker::runAll();
|
||||
}
|
||||
|
||||
private function setupLogging(): void
|
||||
{
|
||||
$logPath = storage_path('logs');
|
||||
if (!is_dir($logPath)) {
|
||||
mkdir($logPath, 0777, true);
|
||||
}
|
||||
Worker::$logFile = $logPath . '/xboard-ws-server.log';
|
||||
Worker::$pidFile = $logPath . '/xboard-ws-server.pid';
|
||||
}
|
||||
|
||||
private function setupCallbacks(): void
|
||||
{
|
||||
$this->worker->onWorkerStart = [$this, 'onWorkerStart'];
|
||||
$this->worker->onConnect = [$this, 'onConnect'];
|
||||
$this->worker->onWebSocketConnect = [$this, 'onWebSocketConnect'];
|
||||
$this->worker->onMessage = [$this, 'onMessage'];
|
||||
$this->worker->onClose = [$this, 'onClose'];
|
||||
}
|
||||
|
||||
public function onWorkerStart(Worker $worker): void
|
||||
{
|
||||
Log::info("[WS] Worker started, pid={$worker->id}");
|
||||
$this->subscribeRedis();
|
||||
$this->setupTimers();
|
||||
}
|
||||
|
||||
private function setupTimers(): void
|
||||
{
|
||||
// Ping timer
|
||||
Timer::add(self::PING_INTERVAL, function () {
|
||||
foreach (NodeRegistry::getConnectedNodeIds() as $nodeId) {
|
||||
$conn = NodeRegistry::get($nodeId);
|
||||
if ($conn) {
|
||||
$conn->send(json_encode(['event' => 'ping']));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Device state push timer
|
||||
Timer::add(10, function () {
|
||||
$pendingNodeIds = Redis::spop('device:push_pending_nodes', 100);
|
||||
if (empty($pendingNodeIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$service = app(DeviceStateService::class);
|
||||
foreach ($pendingNodeIds as $nodeId) {
|
||||
$nodeId = (int) $nodeId;
|
||||
if (NodeRegistry::get($nodeId) !== null) {
|
||||
NodeEventHandlers::pushDeviceStateToNode($nodeId, $service);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function onConnect(TcpConnection $conn): void
|
||||
{
|
||||
$conn->authTimer = Timer::add(self::AUTH_TIMEOUT, function () use ($conn) {
|
||||
if (empty($conn->nodeId)) {
|
||||
$conn->close(json_encode([
|
||||
'event' => 'error',
|
||||
'data' => ['message' => 'auth timeout'],
|
||||
]));
|
||||
}
|
||||
}, [], false);
|
||||
}
|
||||
|
||||
public function onWebSocketConnect(TcpConnection $conn, $httpMessage): void
|
||||
{
|
||||
$queryString = '';
|
||||
if (is_string($httpMessage)) {
|
||||
$queryString = parse_url($httpMessage, PHP_URL_QUERY) ?? '';
|
||||
} elseif ($httpMessage instanceof \Workerman\Protocols\Http\Request) {
|
||||
$queryString = $httpMessage->queryString();
|
||||
}
|
||||
|
||||
parse_str($queryString, $params);
|
||||
$token = $params['token'] ?? '';
|
||||
$nodeId = (int) ($params['node_id'] ?? 0);
|
||||
|
||||
// Authenticate
|
||||
$serverToken = admin_setting('server_token', '');
|
||||
if ($token === '' || $serverToken === '' || !hash_equals($serverToken, $token)) {
|
||||
$conn->close(json_encode([
|
||||
'event' => 'error',
|
||||
'data' => ['message' => 'invalid token'],
|
||||
]));
|
||||
return;
|
||||
}
|
||||
|
||||
$node = ServerService::getServer($nodeId, null);
|
||||
if (!$node) {
|
||||
$conn->close(json_encode([
|
||||
'event' => 'error',
|
||||
'data' => ['message' => 'node not found'],
|
||||
]));
|
||||
return;
|
||||
}
|
||||
|
||||
// Auth passed
|
||||
if (isset($conn->authTimer)) {
|
||||
Timer::del($conn->authTimer);
|
||||
}
|
||||
|
||||
$conn->nodeId = $nodeId;
|
||||
NodeRegistry::add($nodeId, $conn);
|
||||
Cache::put("node_ws_alive:{$nodeId}", true, 86400);
|
||||
|
||||
// Clear old device data
|
||||
app(DeviceStateService::class)->clearAllNodeDevices($nodeId);
|
||||
|
||||
Log::debug("[WS] Node#{$nodeId} connected", [
|
||||
'remote' => $conn->getRemoteIp(),
|
||||
'total' => NodeRegistry::count(),
|
||||
]);
|
||||
|
||||
// Send auth success
|
||||
$conn->send(json_encode([
|
||||
'event' => 'auth.success',
|
||||
'data' => ['node_id' => $nodeId],
|
||||
]));
|
||||
|
||||
// Push full sync
|
||||
NodeEventHandlers::pushFullSync($conn, $node);
|
||||
}
|
||||
|
||||
public function onMessage(TcpConnection $conn, $data): void
|
||||
{
|
||||
$msg = json_decode($data, true);
|
||||
if (!is_array($msg)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$event = $msg['event'] ?? '';
|
||||
$nodeId = $conn->nodeId ?? null;
|
||||
|
||||
if (isset($this->handlers[$event]) && $nodeId) {
|
||||
$handler = $this->handlers[$event];
|
||||
$handler($conn, $nodeId, $msg['data'] ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
public function onClose(TcpConnection $conn): void
|
||||
{
|
||||
if (!empty($conn->nodeId)) {
|
||||
$nodeId = $conn->nodeId;
|
||||
NodeRegistry::remove($nodeId);
|
||||
Cache::forget("node_ws_alive:{$nodeId}");
|
||||
|
||||
$service = app(DeviceStateService::class);
|
||||
$affectedUserIds = $service->clearAllNodeDevices($nodeId);
|
||||
foreach ($affectedUserIds as $userId) {
|
||||
$service->notifyUpdate($userId);
|
||||
}
|
||||
|
||||
Log::debug("[WS] Node#{$nodeId} disconnected", [
|
||||
'total' => NodeRegistry::count(),
|
||||
'affected_users' => count($affectedUserIds),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function subscribeRedis(): void
|
||||
{
|
||||
$host = config('database.redis.default.host', '127.0.0.1');
|
||||
$port = config('database.redis.default.port', 6379);
|
||||
|
||||
if (str_starts_with($host, '/')) {
|
||||
$redisUri = "unix://{$host}";
|
||||
} else {
|
||||
$redisUri = "redis://{$host}:{$port}";
|
||||
}
|
||||
|
||||
$redis = new \Workerman\Redis\Client($redisUri);
|
||||
|
||||
$password = config('database.redis.default.password');
|
||||
if ($password) {
|
||||
$redis->auth($password);
|
||||
}
|
||||
|
||||
$prefix = config('database.redis.options.prefix', '');
|
||||
$channel = $prefix . 'node:push';
|
||||
|
||||
$redis->subscribe([$channel], function ($chan, $message) {
|
||||
$payload = json_decode($message, true);
|
||||
if (!is_array($payload)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$nodeId = $payload['node_id'] ?? null;
|
||||
$event = $payload['event'] ?? '';
|
||||
$data = $payload['data'] ?? [];
|
||||
|
||||
if (!$nodeId || !$event) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sent = NodeRegistry::send((int) $nodeId, $event, $data);
|
||||
if ($sent) {
|
||||
Log::debug("[WS] Pushed {$event} to node#{$nodeId}");
|
||||
}
|
||||
});
|
||||
|
||||
Log::info("[WS] Subscribed to Redis channel: {$channel}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user