From 010275b09ea27f1cefaa6a5605b6ff05c64d9a2e Mon Sep 17 00:00:00 2001 From: xboard Date: Sun, 15 Mar 2026 09:49:11 +0800 Subject: [PATCH] feat: introduce WebSocket sync for XBoard nodes - Implement Workerman-based `xboard:ws-server` for real-time node synchronization. - Support custom routes, outbounds, and certificate configurations via JSON. - Optimize scheduled tasks with `lazyById` to minimize memory footprint. - Enhance reactivity using Observers for `Plan`, `Server`, and `ServerRoute`. - Expand protocol support for `httpupgrade`, `h2`, and `mieru`. --- .docker/supervisor/supervisord.conf | 19 ++- .env.example | 2 +- Dockerfile | 3 +- app/Console/Commands/CheckCommission.php | 4 +- app/Console/Commands/CheckOrder.php | 11 +- app/Console/Commands/CheckTicket.php | 15 +- app/Console/Kernel.php | 8 +- .../V1/Server/UniProxyController.php | 111 +------------ .../Controllers/V1/User/OrderController.php | 1 + .../Controllers/V1/User/UserController.php | 60 +++---- .../Controllers/V2/Admin/ConfigController.php | 2 + .../V2/Admin/Server/ManageController.php | 7 +- .../V2/Admin/Server/RouteController.php | 2 +- .../Controllers/V2/Admin/UserController.php | 3 +- .../V2/Server/ServerController.php | 150 +++++++++++++++++ app/Http/Requests/Admin/ConfigSave.php | 2 + app/Http/Requests/Admin/ServerSave.php | 3 + app/Http/Routes/V2/ClientRoute.php | 20 +++ app/Http/Routes/V2/ServerRoute.php | 3 + app/Jobs/NodeUserSyncJob.php | 45 ++++++ ...eAliveDataJob.php => UserAliveSyncJob.php} | 6 +- app/Models/Server.php | 34 +++- app/Models/User.php | 8 + app/Observers/PlanObserver.php | 35 ++++ app/Observers/ServerObserver.php | 37 +++++ app/Observers/ServerRouteObserver.php | 31 ++++ app/Observers/UserObserver.php | 39 ++++- app/Protocols/ClashMeta.php | 67 +++++++- app/Protocols/General.php | 104 ++++++++++-- app/Protocols/SingBox.php | 153 +++++++++++++++++- app/Providers/EventServiceProvider.php | 12 ++ app/Providers/SettingServiceProvider.php | 4 + app/Services/NodeRegistry.php | 77 +++++++++ app/Services/NodeSyncService.php | 143 ++++++++++++++++ app/Services/OrderService.php | 3 +- app/Services/PaymentService.php | 12 +- app/Services/ServerService.php | 129 ++++++++++++++- app/Utils/CacheKey.php | 4 +- compose.sample.yaml | 10 ++ composer.json | 3 + config/horizon.php | 41 ++++- config/octane.php | 16 +- ...tom_config_and_cert_to_v2_server_table.php | 30 ++++ docs/en/installation/1panel.md | 15 +- docs/en/installation/aapanel-docker.md | 15 +- docs/en/installation/aapanel.md | 36 ++++- public/assets/admin | 2 +- 47 files changed, 1314 insertions(+), 223 deletions(-) create mode 100644 app/Http/Controllers/V2/Server/ServerController.php create mode 100644 app/Http/Routes/V2/ClientRoute.php create mode 100644 app/Jobs/NodeUserSyncJob.php rename app/Jobs/{UpdateAliveDataJob.php => UserAliveSyncJob.php} (95%) create mode 100644 app/Observers/PlanObserver.php create mode 100644 app/Observers/ServerObserver.php create mode 100644 app/Observers/ServerRouteObserver.php create mode 100644 app/Services/NodeRegistry.php create mode 100644 app/Services/NodeSyncService.php create mode 100644 database/migrations/2026_03_15_060035_add_custom_config_and_cert_to_v2_server_table.php diff --git a/.docker/supervisor/supervisord.conf b/.docker/supervisor/supervisord.conf index 768921d..e93eb2b 100644 --- a/.docker/supervisor/supervisord.conf +++ b/.docker/supervisor/supervisord.conf @@ -61,4 +61,21 @@ stopwaitsecs=3 stopsignal=TERM stopasgroup=true killasgroup=true -priority=300 \ No newline at end of file +priority=300 + +[program:ws-server] +process_name=%(program_name)s_%(process_num)02d +command=php /www/artisan xboard:ws-server start +autostart=%(ENV_ENABLE_WS_SERVER)s +autorestart=true +user=www +redirect_stderr=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stdout_logfile_backups=0 +numprocs=1 +stopwaitsecs=5 +stopsignal=SIGINT +stopasgroup=true +killasgroup=true +priority=400 \ No newline at end of file diff --git a/.env.example b/.env.example index 1125ee1..251858b 100755 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ APP_NAME=XBoard -APP_ENV=local +APP_ENV=production APP_KEY=base64:PZXk5vTuTinfeEVG5FpYv2l6WEhLsyvGpiWK7IgJJ60= APP_DEBUG=false APP_URL=http://localhost diff --git a/Dockerfile b/Dockerfile index b482199..26dd681 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,7 +40,8 @@ RUN composer install --no-cache --no-dev \ ENV ENABLE_WEB=true \ ENABLE_HORIZON=true \ - ENABLE_REDIS=false + ENABLE_REDIS=false \ + ENV_ENABLE_WS_SERVER=false EXPOSE 7001 CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] \ No newline at end of file diff --git a/app/Console/Commands/CheckCommission.php b/app/Console/Commands/CheckCommission.php index f9e5518..0eabc74 100644 --- a/app/Console/Commands/CheckCommission.php +++ b/app/Console/Commands/CheckCommission.php @@ -104,9 +104,9 @@ class CheckCommission extends Command $commissionBalance = $order->commission_balance * ($commissionShareLevels[$l] / 100); if (!$commissionBalance) continue; if ((int)admin_setting('withdraw_close_enable', 0)) { - $inviter->balance = $inviter->balance + $commissionBalance; + $inviter->increment('balance', $commissionBalance); } else { - $inviter->commission_balance = $inviter->commission_balance + $commissionBalance; + $inviter->increment('commission_balance', $commissionBalance); } if (!$inviter->save()) { DB::rollBack(); diff --git a/app/Console/Commands/CheckOrder.php b/app/Console/Commands/CheckOrder.php index 6fb208c..7d03f58 100755 --- a/app/Console/Commands/CheckOrder.php +++ b/app/Console/Commands/CheckOrder.php @@ -43,12 +43,11 @@ class CheckOrder extends Command */ public function handle() { - ini_set('memory_limit', -1); - $orders = Order::whereIn('status', [Order::STATUS_PENDING, Order::STATUS_PROCESSING]) + Order::whereIn('status', [Order::STATUS_PENDING, Order::STATUS_PROCESSING]) ->orderBy('created_at', 'ASC') - ->get(); - foreach ($orders as $order) { - OrderHandleJob::dispatch($order->trade_no); - } + ->lazyById(200) + ->each(function ($order) { + OrderHandleJob::dispatch($order->trade_no); + }); } } diff --git a/app/Console/Commands/CheckTicket.php b/app/Console/Commands/CheckTicket.php index 123e633..51d4539 100644 --- a/app/Console/Commands/CheckTicket.php +++ b/app/Console/Commands/CheckTicket.php @@ -38,15 +38,14 @@ class CheckTicket extends Command */ public function handle() { - ini_set('memory_limit', -1); - $tickets = Ticket::where('status', 0) + Ticket::where('status', 0) ->where('updated_at', '<=', time() - 24 * 3600) ->where('reply_status', 0) - ->get(); - foreach ($tickets as $ticket) { - if ($ticket->user_id === $ticket->last_reply_user_id) continue; - $ticket->status = Ticket::STATUS_CLOSED; - $ticket->save(); - } + ->lazyById(200) + ->each(function ($ticket) { + if ($ticket->user_id === $ticket->last_reply_user_id) return; + $ticket->status = Ticket::STATUS_CLOSED; + $ticket->save(); + }); } } diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index d1071c5..710d9b7 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -32,11 +32,11 @@ class Kernel extends ConsoleKernel // v2board $schedule->command('xboard:statistics')->dailyAt('0:10')->onOneServer(); // check - $schedule->command('check:order')->everyMinute()->onOneServer(); - $schedule->command('check:commission')->everyMinute()->onOneServer(); - $schedule->command('check:ticket')->everyMinute()->onOneServer(); + $schedule->command('check:order')->everyMinute()->onOneServer()->withoutOverlapping(5); + $schedule->command('check:commission')->everyMinute()->onOneServer()->withoutOverlapping(5); + $schedule->command('check:ticket')->everyMinute()->onOneServer()->withoutOverlapping(5); // reset - $schedule->command('reset:traffic')->everyMinute()->onOneServer(); + $schedule->command('reset:traffic')->everyMinute()->onOneServer()->withoutOverlapping(10); $schedule->command('reset:log')->daily()->onOneServer(); // send $schedule->command('send:remindMail', ['--force'])->dailyAt('11:30')->onOneServer(); diff --git a/app/Http/Controllers/V1/Server/UniProxyController.php b/app/Http/Controllers/V1/Server/UniProxyController.php index b31489d..a557f36 100644 --- a/app/Http/Controllers/V1/Server/UniProxyController.php +++ b/app/Http/Controllers/V1/Server/UniProxyController.php @@ -3,7 +3,8 @@ namespace App\Http\Controllers\V1\Server; use App\Http\Controllers\Controller; -use App\Jobs\UpdateAliveDataJob; +use App\Jobs\UserAliveSyncJob; +use App\Services\NodeSyncService; use App\Services\ServerService; use App\Services\UserService; use App\Utils\CacheKey; @@ -88,117 +89,13 @@ class UniProxyController extends Controller public function config(Request $request) { $node = $this->getNodeInfo($request); - $nodeType = $node->type; - $protocolSettings = $node->protocol_settings; - - $serverPort = $node->server_port; - $host = $node->host; - - $baseConfig = [ - 'protocol' => $nodeType, - 'listen_ip' => '0.0.0.0', - 'server_port' => (int) $serverPort, - 'network' => data_get($protocolSettings, 'network'), - 'networkSettings' => data_get($protocolSettings, 'network_settings') ?: null, - ]; - - $response = match ($nodeType) { - 'shadowsocks' => [ - ...$baseConfig, - 'cipher' => $protocolSettings['cipher'], - 'plugin' => $protocolSettings['plugin'], - 'plugin_opts' => $protocolSettings['plugin_opts'], - 'server_key' => match ($protocolSettings['cipher']) { - '2022-blake3-aes-128-gcm' => Helper::getServerKey($node->created_at, 16), - '2022-blake3-aes-256-gcm' => Helper::getServerKey($node->created_at, 32), - default => null - } - ], - 'vmess' => [ - ...$baseConfig, - 'tls' => (int) $protocolSettings['tls'] - ], - 'trojan' => [ - ...$baseConfig, - 'host' => $host, - 'server_name' => $protocolSettings['server_name'], - ], - 'vless' => [ - ...$baseConfig, - 'tls' => (int) $protocolSettings['tls'], - 'flow' => $protocolSettings['flow'], - 'tls_settings' => - match ((int) $protocolSettings['tls']) { - 2 => $protocolSettings['reality_settings'], - default => $protocolSettings['tls_settings'] - } - ], - 'hysteria' => [ - ...$baseConfig, - 'server_port' => (int) $serverPort, - 'version' => (int) $protocolSettings['version'], - 'host' => $host, - 'server_name' => $protocolSettings['tls']['server_name'], - 'up_mbps' => (int) $protocolSettings['bandwidth']['up'], - 'down_mbps' => (int) $protocolSettings['bandwidth']['down'], - ...match ((int) $protocolSettings['version']) { - 1 => ['obfs' => $protocolSettings['obfs']['password'] ?? null], - 2 => [ - 'obfs' => $protocolSettings['obfs']['open'] ? $protocolSettings['obfs']['type'] : null, - 'obfs-password' => $protocolSettings['obfs']['password'] ?? null - ], - default => [] - } - ], - 'tuic' => [ - ...$baseConfig, - 'version' => (int) $protocolSettings['version'], - 'server_port' => (int) $serverPort, - 'server_name' => $protocolSettings['tls']['server_name'], - 'congestion_control' => $protocolSettings['congestion_control'], - 'auth_timeout' => '3s', - 'zero_rtt_handshake' => false, - 'heartbeat' => "3s", - ], - 'anytls' => [ - ...$baseConfig, - 'server_port' => (int) $serverPort, - 'server_name' => $protocolSettings['tls']['server_name'], - 'padding_scheme' => $protocolSettings['padding_scheme'], - ], - 'socks' => [ - ...$baseConfig, - 'server_port' => (int) $serverPort, - ], - 'naive' => [ - ...$baseConfig, - 'server_port' => (int) $serverPort, - 'tls' => (int) $protocolSettings['tls'], - 'tls_settings' => $protocolSettings['tls_settings'] - ], - 'http' => [ - ...$baseConfig, - 'server_port' => (int) $serverPort, - 'tls' => (int) $protocolSettings['tls'], - 'tls_settings' => $protocolSettings['tls_settings'] - ], - 'mieru' => [ - ...$baseConfig, - 'server_port' => (string) $serverPort, - 'protocol' => (int) $protocolSettings['protocol'], - ], - default => [] - }; + $response = ServerService::buildNodeConfig($node); $response['base_config'] = [ 'push_interval' => (int) admin_setting('server_push_interval', 60), 'pull_interval' => (int) admin_setting('server_pull_interval', 60) ]; - if (!empty($node['route_ids'])) { - $response['routes'] = ServerService::getRoutes($node['route_ids']); - } - $eTag = sha1(json_encode($response)); if (strpos($request->header('If-None-Match', ''), $eTag) !== false) { return response(null, 304); @@ -226,7 +123,7 @@ class UniProxyController extends Controller 'error' => 'Invalid online data' ], 400); } - UpdateAliveDataJob::dispatch($data, $node->type, $node->id); + UserAliveSyncJob::dispatch($data, $node->type, $node->id); return response()->json(['data' => true]); } diff --git a/app/Http/Controllers/V1/User/OrderController.php b/app/Http/Controllers/V1/User/OrderController.php index 9f90da1..7b28128 100755 --- a/app/Http/Controllers/V1/User/OrderController.php +++ b/app/Http/Controllers/V1/User/OrderController.php @@ -16,6 +16,7 @@ use App\Services\PaymentService; use App\Services\PlanService; use App\Services\UserService; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; class OrderController extends Controller { diff --git a/app/Http/Controllers/V1/User/UserController.php b/app/Http/Controllers/V1/User/UserController.php index 3b39360..c9fb361 100755 --- a/app/Http/Controllers/V1/User/UserController.php +++ b/app/Http/Controllers/V1/User/UserController.php @@ -18,6 +18,7 @@ use App\Utils\CacheKey; use App\Utils\Helper; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\DB; class UserController extends Controller { @@ -31,20 +32,14 @@ class UserController extends Controller public function getActiveSession(Request $request) { - $user = User::find($request->user()->id); - if (!$user) { - return $this->fail([400, __('The user does not exist')]); - } + $user = $request->user(); $authService = new AuthService($user); return $this->success($authService->getSessions()); } public function removeActiveSession(Request $request) { - $user = User::find($request->user()->id); - if (!$user) { - return $this->fail([400, __('The user does not exist')]); - } + $user = $request->user(); $authService = new AuthService($user); return $this->success($authService->removeSession($request->input('session_id'))); } @@ -62,10 +57,7 @@ class UserController extends Controller public function changePassword(UserChangePassword $request) { - $user = User::find($request->user()->id); - if (!$user) { - return $this->fail([400, __('The user does not exist')]); - } + $user = $request->user(); if ( !Helper::multiPasswordVerify( $user->password_algo, @@ -163,10 +155,7 @@ class UserController extends Controller public function resetSecurity(Request $request) { - $user = User::find($request->user()->id); - if (!$user) { - return $this->fail([400, __('The user does not exist')]); - } + $user = $request->user(); $user->uuid = Helper::guid(true); $user->token = Helper::guid(); if (!$user->save()) { @@ -182,10 +171,7 @@ class UserController extends Controller 'remind_traffic' ]); - $user = User::find($request->user()->id); - if (!$user) { - return $this->fail([400, __('The user does not exist')]); - } + $user = $request->user(); try { $user->update($updateData); } catch (\Exception $e) { @@ -197,27 +183,31 @@ class UserController extends Controller public function transfer(UserTransfer $request) { - $user = User::find($request->user()->id); - if (!$user) { - return $this->fail([400, __('The user does not exist')]); - } - if ($request->input('transfer_amount') > $user->commission_balance) { - return $this->fail([400, __('Insufficient commission balance')]); - } - $user->commission_balance = $user->commission_balance - $request->input('transfer_amount'); - $user->balance = $user->balance + $request->input('transfer_amount'); - if (!$user->save()) { - return $this->fail([400, __('Transfer failed')]); + $amount = $request->input('transfer_amount'); + try { + DB::transaction(function () use ($request, $amount) { + $user = User::lockForUpdate()->find($request->user()->id); + if (!$user) { + throw new \Exception(__('The user does not exist')); + } + if ($amount > $user->commission_balance) { + throw new \Exception(__('Insufficient commission balance')); + } + $user->commission_balance -= $amount; + $user->balance += $amount; + if (!$user->save()) { + throw new \Exception(__('Transfer failed')); + } + }); + } catch (\Exception $e) { + return $this->fail([400, $e->getMessage()]); } return $this->success(true); } public function getQuickLoginUrl(Request $request) { - $user = User::find($request->user()->id); - if (!$user) { - return $this->fail([400, __('The user does not exist')]); - } + $user = $request->user(); $url = $this->loginService->generateQuickLoginUrl($user, $request->input('redirect')); return $this->success($url); diff --git a/app/Http/Controllers/V2/Admin/ConfigController.php b/app/Http/Controllers/V2/Admin/ConfigController.php index 4923874..81c5570 100644 --- a/app/Http/Controllers/V2/Admin/ConfigController.php +++ b/app/Http/Controllers/V2/Admin/ConfigController.php @@ -143,6 +143,8 @@ class ConfigController extends Controller 'server_pull_interval' => admin_setting('server_pull_interval', 60), 'server_push_interval' => admin_setting('server_push_interval', 60), 'device_limit_mode' => (int) admin_setting('device_limit_mode', 0), + 'server_ws_enable' => (bool) admin_setting('server_ws_enable', 1), + 'server_ws_url' => admin_setting('server_ws_url', ''), ], 'email' => [ 'email_template' => admin_setting('email_template', 'default'), diff --git a/app/Http/Controllers/V2/Admin/Server/ManageController.php b/app/Http/Controllers/V2/Admin/Server/ManageController.php index 762ecd9..f350e57 100644 --- a/app/Http/Controllers/V2/Admin/Server/ManageController.php +++ b/app/Http/Controllers/V2/Admin/Server/ManageController.php @@ -84,7 +84,12 @@ class ManageController extends Controller 'show' => 'integer', ]); - if (!Server::where('id', $request->id)->update(['show' => $request->show])) { + $server = Server::find($request->id); + if (!$server) { + return $this->fail([400202, '服务器不存在']); + } + $server->show = (int) $request->show; + if (!$server->save()) { return $this->fail([500, '保存失败']); } return $this->success(true); diff --git a/app/Http/Controllers/V2/Admin/Server/RouteController.php b/app/Http/Controllers/V2/Admin/Server/RouteController.php index 4187b5f..7f155ec 100644 --- a/app/Http/Controllers/V2/Admin/Server/RouteController.php +++ b/app/Http/Controllers/V2/Admin/Server/RouteController.php @@ -23,7 +23,7 @@ class RouteController extends Controller $params = $request->validate([ 'remarks' => 'required', 'match' => 'required|array', - 'action' => 'required|in:block,dns', + 'action' => 'required|in:block,direct,dns,proxy', 'action_value' => 'nullable' ], [ 'remarks.required' => '备注不能为空', diff --git a/app/Http/Controllers/V2/Admin/UserController.php b/app/Http/Controllers/V2/Admin/UserController.php index 3e5e341..29233ca 100644 --- a/app/Http/Controllers/V2/Admin/UserController.php +++ b/app/Http/Controllers/V2/Admin/UserController.php @@ -10,6 +10,7 @@ use App\Jobs\SendEmailJob; use App\Models\Plan; use App\Models\User; use App\Services\AuthService; +use App\Services\NodeSyncService; use App\Services\UserService; use App\Traits\QueryOperators; use App\Utils\Helper; @@ -482,7 +483,7 @@ class UserController extends Controller Log::error($e); return $this->fail([500, '处理失败']); } - + NodeSyncService::notifyUsersUpdated(); return $this->success(true); } diff --git a/app/Http/Controllers/V2/Server/ServerController.php b/app/Http/Controllers/V2/Server/ServerController.php new file mode 100644 index 0000000..c90c2e0 --- /dev/null +++ b/app/Http/Controllers/V2/Server/ServerController.php @@ -0,0 +1,150 @@ + false]; + + if ((bool) admin_setting('server_ws_enable', 1)) { + $customUrl = trim((string) admin_setting('server_ws_url', '')); + + if ($customUrl !== '') { + $wsUrl = rtrim($customUrl, '/'); + } else { + $wsScheme = $request->isSecure() ? 'wss' : 'ws'; + $wsUrl = "{$wsScheme}://{$request->getHost()}:8076"; + } + + $websocket = [ + 'enabled' => true, + 'ws_url' => $wsUrl, + ]; + } + + return response()->json([ + 'websocket' => $websocket + ]); + } + + /** + * node report api - merge traffic + alive + status + * POST /api/v2/server/node/report + */ + public function report(Request $request): JsonResponse + { + $node = $request->attributes->get('node_info'); + $nodeType = $node->type; + $nodeId = $node->id; + + Cache::put(CacheKey::get('SERVER_' . strtoupper($nodeType) . '_LAST_CHECK_AT', $nodeId), time(), 3600); + + // hanle traffic data + $traffic = $request->input('traffic'); + if (is_array($traffic) && !empty($traffic)) { + $data = array_filter($traffic, function ($item) { + return is_array($item) + && count($item) === 2 + && is_numeric($item[0]) + && is_numeric($item[1]); + }); + + if (!empty($data)) { + Cache::put( + CacheKey::get('SERVER_' . strtoupper($nodeType) . '_ONLINE_USER', $nodeId), + count($data), + 3600 + ); + Cache::put( + CacheKey::get('SERVER_' . strtoupper($nodeType) . '_LAST_PUSH_AT', $nodeId), + time(), + 3600 + ); + $userService = new UserService(); + $userService->trafficFetch($node, $nodeType, $data); + } + } + + // handle alive data + $alive = $request->input('alive'); + if (is_array($alive) && !empty($alive)) { + UserAliveSyncJob::dispatch($alive, $nodeType, $nodeId); + } + + // handle active connections + $online = $request->input('online'); + if (is_array($online) && !empty($online)) { + $cacheTime = max(300, (int) admin_setting('server_push_interval', 60) * 3); + foreach ($online as $uid => $conn) { + $cacheKey = CacheKey::get("USER_ONLINE_CONN_{$nodeType}_{$nodeId}", $uid); + Cache::put($cacheKey, (int) $conn, $cacheTime); + } + } + + // handle node status + $status = $request->input('status'); + if (is_array($status) && !empty($status)) { + $statusData = [ + 'cpu' => (float) ($status['cpu'] ?? 0), + 'mem' => [ + 'total' => (int) ($status['mem']['total'] ?? 0), + 'used' => (int) ($status['mem']['used'] ?? 0), + ], + 'swap' => [ + 'total' => (int) ($status['swap']['total'] ?? 0), + 'used' => (int) ($status['swap']['used'] ?? 0), + ], + 'disk' => [ + 'total' => (int) ($status['disk']['total'] ?? 0), + 'used' => (int) ($status['disk']['used'] ?? 0), + ], + 'updated_at' => now()->timestamp, + ]; + + $cacheTime = max(300, (int) admin_setting('server_push_interval', 60) * 3); + cache([ + CacheKey::get('SERVER_' . strtoupper($nodeType) . '_LOAD_STATUS', $nodeId) => $statusData, + CacheKey::get('SERVER_' . strtoupper($nodeType) . '_LAST_LOAD_AT', $nodeId) => now()->timestamp, + ], $cacheTime); + } + + // handle node metrics (Metrics) + $metrics = $request->input('metrics'); + if (is_array($metrics) && !empty($metrics)) { + $metricsData = [ + 'uptime' => (int) ($metrics['uptime'] ?? 0), + 'inbound_speed' => (int) ($metrics['inbound_speed'] ?? 0), + 'outbound_speed' => (int) ($metrics['outbound_speed'] ?? 0), + 'active_connections' => (int) ($metrics['active_connections'] ?? 0), + 'total_connections' => (int) ($metrics['total_connections'] ?? 0), + 'speed_limiter' => $metrics['speed_limiter'] ?? [], + 'cpu_per_core' => $metrics['cpu_per_core'] ?? [], + 'gc' => $metrics['gc'] ?? [], + 'api' => $metrics['api'] ?? [], + 'updated_at' => now()->timestamp, + ]; + $cacheTime = max(300, (int) admin_setting('server_push_interval', 60) * 3); + Cache::put( + CacheKey::get('SERVER_' . strtoupper($nodeType) . '_METRICS', $nodeId), + $metricsData, + $cacheTime + ); + } + + return response()->json(['data' => true]); + } +} diff --git a/app/Http/Requests/Admin/ConfigSave.php b/app/Http/Requests/Admin/ConfigSave.php index 49d6a63..bec69a6 100755 --- a/app/Http/Requests/Admin/ConfigSave.php +++ b/app/Http/Requests/Admin/ConfigSave.php @@ -51,6 +51,8 @@ class ConfigSave extends FormRequest 'server_pull_interval' => 'integer', 'server_push_interval' => 'integer', 'device_limit_mode' => 'integer', + 'server_ws_enable' => 'boolean', + 'server_ws_url' => 'nullable|url', // frontend 'frontend_theme' => '', 'frontend_theme_sidebar' => 'nullable|in:dark,light', diff --git a/app/Http/Requests/Admin/ServerSave.php b/app/Http/Requests/Admin/ServerSave.php index 1ac433f..df385dd 100644 --- a/app/Http/Requests/Admin/ServerSave.php +++ b/app/Http/Requests/Admin/ServerSave.php @@ -97,6 +97,9 @@ class ServerSave extends FormRequest 'rate' => 'required|numeric', 'rate_time_enable' => 'nullable|boolean', 'rate_time_ranges' => 'nullable|array', + 'custom_outbounds' => 'nullable|array', + 'custom_routes' => 'nullable|array', + 'cert_config' => 'nullable|array', 'rate_time_ranges.*.start' => 'required_with:rate_time_ranges|string|date_format:H:i', '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', diff --git a/app/Http/Routes/V2/ClientRoute.php b/app/Http/Routes/V2/ClientRoute.php new file mode 100644 index 0000000..693a40d --- /dev/null +++ b/app/Http/Routes/V2/ClientRoute.php @@ -0,0 +1,20 @@ +group([ + 'prefix' => 'client', + 'middleware' => 'client' + ], function ($router) { + // App + $router->get('/app/getConfig', [AppController::class, 'getConfig']); + $router->get('/app/getVersion', [AppController::class, 'getVersion']); + }); + } +} diff --git a/app/Http/Routes/V2/ServerRoute.php b/app/Http/Routes/V2/ServerRoute.php index db6c31f..9742d31 100644 --- a/app/Http/Routes/V2/ServerRoute.php +++ b/app/Http/Routes/V2/ServerRoute.php @@ -4,6 +4,7 @@ namespace App\Http\Routes\V2; use App\Http\Controllers\V1\Server\ShadowsocksTidalabController; use App\Http\Controllers\V1\Server\TrojanTidalabController; use App\Http\Controllers\V1\Server\UniProxyController; +use App\Http\Controllers\V2\Server\ServerController; use Illuminate\Contracts\Routing\Registrar; class ServerRoute @@ -15,6 +16,8 @@ class ServerRoute 'prefix' => 'server', 'middleware' => 'server' ], function ($route) { + $route->post('handshake', [ServerController::class, 'handshake']); + $route->post('report', [ServerController::class, 'report']); $route->get('config', [UniProxyController::class, 'config']); $route->get('user', [UniProxyController::class, 'user']); $route->post('push', [UniProxyController::class, 'push']); diff --git a/app/Jobs/NodeUserSyncJob.php b/app/Jobs/NodeUserSyncJob.php new file mode 100644 index 0000000..45e8542 --- /dev/null +++ b/app/Jobs/NodeUserSyncJob.php @@ -0,0 +1,45 @@ +onQueue('notification'); + } + + public function handle(): void + { + $user = User::find($this->userId); + + if ($this->action === 'updated' || $this->action === 'created') { + if ($this->oldGroupId) { + NodeSyncService::notifyUserRemovedFromGroup($this->userId, $this->oldGroupId); + } + if ($user) { + NodeSyncService::notifyUserChanged($user); + } + } elseif ($this->action === 'deleted') { + if ($this->oldGroupId) { + NodeSyncService::notifyUserRemovedFromGroup($this->userId, $this->oldGroupId); + } + } + } +} diff --git a/app/Jobs/UpdateAliveDataJob.php b/app/Jobs/UserAliveSyncJob.php similarity index 95% rename from app/Jobs/UpdateAliveDataJob.php rename to app/Jobs/UserAliveSyncJob.php index b152800..0cff19d 100644 --- a/app/Jobs/UpdateAliveDataJob.php +++ b/app/Jobs/UserAliveSyncJob.php @@ -12,7 +12,7 @@ use Illuminate\Support\Facades\Cache; use App\Services\UserOnlineService; use Illuminate\Support\Facades\Log; -class UpdateAliveDataJob implements ShouldQueue +class UserAliveSyncJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; @@ -25,7 +25,7 @@ class UpdateAliveDataJob implements ShouldQueue private readonly string $nodeType, private readonly int $nodeId ) { - $this->onQueue('online_sync'); + $this->onQueue('user_alive_sync'); } public function handle(): void @@ -97,7 +97,7 @@ class UpdateAliveDataJob implements ShouldQueue } } } catch (\Throwable $e) { - Log::error('UpdateAliveDataJob failed', [ + Log::error('UserAliveSyncJob failed', [ 'error' => $e->getMessage(), ]); $this->fail($e); diff --git a/app/Models/Server.php b/app/Models/Server.php index 5b3b930..fdd0ae4 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -41,6 +41,8 @@ use Illuminate\Database\Eloquent\Casts\Attribute; * @property-read int|null $last_check_at 最后检查时间(Unix时间戳) * @property-read int|null $last_push_at 最后推送时间(Unix时间戳) * @property-read int $online 在线用户数 + * @property-read int $online_conn 在线连接数 + * @property-read array|null $metrics 节点指标指标 * @property-read int $is_online 是否在线(1在线 0离线) * @property-read string $available_status 可用状态描述 * @property-read string $cache_key 缓存键 @@ -112,6 +114,9 @@ class Server extends Model 'route_ids' => 'array', 'tags' => 'array', 'protocol_settings' => 'array', + 'custom_outbounds' => 'array', + 'custom_routes' => 'array', + 'cert_config' => 'array', 'last_check_at' => 'integer', 'last_push_at' => 'integer', 'show' => 'boolean', @@ -240,7 +245,8 @@ class Server extends Model 'tls_settings' => [ 'type' => 'object', 'fields' => [ - 'allow_insecure' => ['type' => 'boolean', 'default' => false] + 'allow_insecure' => ['type' => 'boolean', 'default' => false], + 'server_name' => ['type' => 'string', 'default' => null] ] ] ], @@ -440,6 +446,32 @@ class Server extends Model ); } + /** + * 指标指标访问器 + */ + protected function metrics(): Attribute + { + return Attribute::make( + get: function () { + $type = strtoupper($this->type); + $serverId = $this->parent_id ?: $this->id; + return Cache::get(CacheKey::get("SERVER_{$type}_METRICS", $serverId)); + } + ); + } + + /** + * 在线连接数访问器 + */ + protected function onlineConn(): Attribute + { + return Attribute::make( + get: function () { + return $this->metrics['active_connections'] ?? 0; + } + ); + } + /** * 负载状态访问器 */ diff --git a/app/Models/User.php b/app/Models/User.php index b5b7fba..4d6aad2 100755 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -147,6 +147,14 @@ class User extends Authenticatable $this->plan_id !== null; } + /** + * 检查用户是否可用节点流量且充足 + */ + public function isAvailable(): bool + { + return $this->isActive() && $this->getRemainingTraffic() > 0; + } + /** * 检查是否需要重置流量 */ diff --git a/app/Observers/PlanObserver.php b/app/Observers/PlanObserver.php new file mode 100644 index 0000000..19dcef4 --- /dev/null +++ b/app/Observers/PlanObserver.php @@ -0,0 +1,35 @@ +isDirty('reset_traffic_method')) { + return; + } + $trafficResetService = app(TrafficResetService::class); + User::where('plan_id', $plan->id) + ->where('banned', 0) + ->where(function ($query) { + $query->where('expired_at', '>', time()) + ->orWhereNull('expired_at'); + }) + ->lazyById(500) + ->each(function (User $user) use ($trafficResetService) { + $nextResetTime = $trafficResetService->calculateNextResetTime($user); + $user->update([ + 'next_reset_at' => $nextResetTime?->timestamp, + ]); + }); + } +} + diff --git a/app/Observers/ServerObserver.php b/app/Observers/ServerObserver.php new file mode 100644 index 0000000..0f1ce9d --- /dev/null +++ b/app/Observers/ServerObserver.php @@ -0,0 +1,37 @@ +isDirty([ + 'group_ids', + ]) + ) { + NodeSyncService::notifyUsersUpdatedByGroup($server->id); + } else if ( + $server->isDirty([ + 'server_port', + 'protocol_settings', + 'type', + 'route_ids', + 'custom_outbounds', + 'custom_routes', + 'cert_config', + ]) + ) { + NodeSyncService::notifyConfigUpdated($server->id); + } + } + + public function deleted(Server $server): void + { + NodeSyncService::notifyConfigUpdated($server->id); + } +} diff --git a/app/Observers/ServerRouteObserver.php b/app/Observers/ServerRouteObserver.php new file mode 100644 index 0000000..f8457d7 --- /dev/null +++ b/app/Observers/ServerRouteObserver.php @@ -0,0 +1,31 @@ +notifyAffectedNodes($route->id); + } + + public function deleted(ServerRoute $route): void + { + $this->notifyAffectedNodes($route->id); + } + + private function notifyAffectedNodes(int $routeId): void + { + $servers = Server::where('show', 1)->get()->filter( + fn ($s) => in_array($routeId, $s->route_ids ?? []) + ); + + foreach ($servers as $server) { + NodeSyncService::notifyConfigUpdated($server->id); + } + } +} diff --git a/app/Observers/UserObserver.php b/app/Observers/UserObserver.php index 1408748..ffa74d0 100644 --- a/app/Observers/UserObserver.php +++ b/app/Observers/UserObserver.php @@ -2,6 +2,7 @@ namespace App\Observers; +use App\Jobs\NodeUserSyncJob; use App\Models\User; use App\Services\TrafficResetService; @@ -15,12 +16,38 @@ class UserObserver public function updated(User $user): void { if ($user->isDirty(['plan_id', 'expired_at'])) { - $user->refresh(); - User::withoutEvents(function () use ($user) { - $nextResetTime = $this->trafficResetService->calculateNextResetTime($user); - $user->next_reset_at = $nextResetTime?->timestamp; - $user->save(); - }); + $this->recalculateNextResetAt($user); + } + + if ($user->isDirty(['group_id', 'uuid', 'speed_limit', 'device_limit', 'banned', 'expired_at', 'transfer_enable', 'u', 'd', 'plan_id'])) { + $oldGroupId = $user->isDirty('group_id') ? $user->getOriginal('group_id') : null; + NodeUserSyncJob::dispatch($user->id, 'updated', $oldGroupId); } } + + public function created(User $user): void + { + $this->recalculateNextResetAt($user); + NodeUserSyncJob::dispatch($user->id, 'created'); + } + + public function deleted(User $user): void + { + if ($user->group_id) { + NodeUserSyncJob::dispatch($user->id, 'deleted', $user->group_id); + } + } + + /** + * 根据当前用户状态重新计算 next_reset_at + */ + private function recalculateNextResetAt(User $user): void + { + $user->refresh(); + User::withoutEvents(function () use ($user) { + $nextResetTime = $this->trafficResetService->calculateNextResetTime($user); + $user->next_reset_at = $nextResetTime?->timestamp; + $user->save(); + }); + } } \ No newline at end of file diff --git a/app/Protocols/ClashMeta.php b/app/Protocols/ClashMeta.php index a1b17db..03f675f 100644 --- a/app/Protocols/ClashMeta.php +++ b/app/Protocols/ClashMeta.php @@ -35,6 +35,7 @@ class ClashMeta extends AbstractProtocol 'grpc' => '0.0.0', 'http' => '0.0.0', 'h2' => '0.0.0', + 'httpupgrade' => '0.0.0', ], 'strict' => true, ], @@ -246,7 +247,7 @@ class ClashMeta extends AbstractProtocol ]; if (data_get($protocol_settings, 'tls')) { - $array['tls'] = true; + $array['tls'] = (bool) data_get($protocol_settings, 'tls'); $array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'tls_settings.allow_insecure', false); $array['servername'] = data_get($protocol_settings, 'tls_settings.server_name'); } @@ -275,6 +276,22 @@ class ClashMeta extends AbstractProtocol if ($serviceName = data_get($protocol_settings, 'network_settings.serviceName')) $array['grpc-opts']['grpc-service-name'] = $serviceName; 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; + case 'httpupgrade': + $array['network'] = 'ws'; + $array['ws-opts'] = ['v2ray-http-upgrade' => true]; + if ($path = data_get($protocol_settings, 'network_settings.path')) + $array['ws-opts']['path'] = $path; + if ($host = data_get($protocol_settings, 'network_settings.host')) + $array['ws-opts']['headers'] = ['Host' => $host]; + break; default: break; } @@ -322,6 +339,19 @@ class ClashMeta extends AbstractProtocol } switch (data_get($protocol_settings, 'network')) { + case 'tcp': + $array['network'] = 'tcp'; + $headerType = data_get($protocol_settings, 'network_settings.header.type', 'none'); + if ($headerType === 'http') { + $array['network'] = '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', ['/']) + ])) { + $array['http-opts'] = $httpOpts; + } + } + break; case 'ws': $array['network'] = 'ws'; if ($path = data_get($protocol_settings, 'network_settings.path')) @@ -334,6 +364,22 @@ class ClashMeta extends AbstractProtocol if ($serviceName = data_get($protocol_settings, 'network_settings.serviceName')) $array['grpc-opts']['grpc-service-name'] = $serviceName; 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; + case 'httpupgrade': + $array['network'] = 'ws'; + $array['ws-opts'] = ['v2ray-http-upgrade' => true]; + if ($path = data_get($protocol_settings, 'network_settings.path')) + $array['ws-opts']['path'] = $path; + if ($host = data_get($protocol_settings, 'network_settings.host')) + $array['ws-opts']['headers'] = ['Host' => $host]; + break; default: break; } @@ -373,6 +419,22 @@ class ClashMeta extends AbstractProtocol if ($serviceName = data_get($protocol_settings, 'network_settings.serviceName')) $array['grpc-opts']['grpc-service-name'] = $serviceName; 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; + case 'httpupgrade': + $array['network'] = 'ws'; + $array['ws-opts'] = ['v2ray-http-upgrade' => true]; + if ($path = data_get($protocol_settings, 'network_settings.path')) + $array['ws-opts']['path'] = $path; + if ($host = data_get($protocol_settings, 'network_settings.host')) + $array['ws-opts']['headers'] = ['Host' => $host]; + break; default: $array['network'] = 'tcp'; break; @@ -396,6 +458,9 @@ class ClashMeta extends AbstractProtocol if (isset($server['ports'])) { $array['ports'] = $server['ports']; } + if ($hopInterval = data_get($protocol_settings, 'hop_interval')) { + $array['hop-interval'] = (int) $hopInterval; + } switch (data_get($protocol_settings, 'version')) { case 1: $array['type'] = 'hysteria'; diff --git a/app/Protocols/General.php b/app/Protocols/General.php index 0144e79..3ac519b 100644 --- a/app/Protocols/General.php +++ b/app/Protocols/General.php @@ -20,6 +20,7 @@ class General extends AbstractProtocol Server::TYPE_ANYTLS, Server::TYPE_SOCKS, Server::TYPE_TUIC, + Server::TYPE_HTTP, ]; protected $protocolRequirements = [ @@ -43,6 +44,7 @@ class General extends AbstractProtocol Server::TYPE_ANYTLS => self::buildAnyTLS($item['password'], $item), Server::TYPE_SOCKS => self::buildSocks($item['password'], $item), Server::TYPE_TUIC => self::buildTuic($item['password'], $item), + Server::TYPE_HTTP => self::buildHttp($item['password'], $item), default => '', }; } @@ -113,6 +115,21 @@ class General extends AbstractProtocol if ($path = data_get($protocol_settings, 'network_settings.serviceName')) $config['path'] = $path; break; + case 'h2': + $config['net'] = 'h2'; + $config['type'] = '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) ? implode(',', $host) : $host; + break; + case 'httpupgrade': + $config['net'] = 'httpupgrade'; + $config['type'] = 'httpupgrade'; + if ($path = data_get($protocol_settings, 'network_settings.path')) + $config['path'] = $path; + $config['host'] = data_get($protocol_settings, 'network_settings.host', $server['host']); + break; default: break; } @@ -166,6 +183,13 @@ class General extends AbstractProtocol if ($path = data_get($protocol_settings, 'network_settings.serviceName')) $config['serviceName'] = $path; break; + case 'h2': + $config['type'] = 'http'; + if ($path = data_get($protocol_settings, 'network_settings.path')) + $config['path'] = $path; + if ($h2Host = data_get($protocol_settings, 'network_settings.host')) + $config['host'] = is_array($h2Host) ? implode(',', $h2Host) : $h2Host; + break; case 'kcp': if ($path = data_get($protocol_settings, 'network_settings.seed')) $config['path'] = $path; @@ -215,6 +239,19 @@ class General extends AbstractProtocol if ($serviceName = data_get($protocol_settings, 'network_settings.serviceName')) $array['serviceName'] = $serviceName; break; + case 'h2': + $array['type'] = 'http'; + if ($path = data_get($protocol_settings, 'network_settings.path')) + $array['path'] = $path; + if ($host = data_get($protocol_settings, 'network_settings.host')) + $array['host'] = is_array($host) ? implode(',', $host) : $host; + break; + case 'httpupgrade': + $array['type'] = 'httpupgrade'; + if ($path = data_get($protocol_settings, 'network_settings.path')) + $array['path'] = $path; + $array['host'] = data_get($protocol_settings, 'network_settings.host', $server['host']); + break; default: break; } @@ -230,31 +267,40 @@ class General extends AbstractProtocol { $protocol_settings = $server['protocol_settings']; $params = []; - // Return empty if version is not 2 - if ($server['protocol_settings']['version'] !== 2) { - return ''; - } + $version = data_get($protocol_settings, 'version', 2); if ($serverName = data_get($protocol_settings, 'tls.server_name')) { $params['sni'] = $serverName; - $params['security'] = 'tls'; } + $params['insecure'] = data_get($protocol_settings, 'tls.allow_insecure') ? '1' : '0'; - if (data_get($protocol_settings, 'obfs.open')) { - $params['obfs'] = 'salamander'; - $params['obfs-password'] = data_get($protocol_settings, 'obfs.password'); - } - if (isset($server['ports'])) { - $params['mport'] = $server['ports']; - } - - $params['insecure'] = data_get($protocol_settings, 'tls.allow_insecure'); - - $query = http_build_query($params); $name = rawurlencode($server['name']); $addr = Helper::wrapIPv6($server['host']); - $uri = "hysteria2://{$password}@{$addr}:{$server['port']}?{$query}#{$name}"; + if ($version === 2) { + if (data_get($protocol_settings, 'obfs.open')) { + $params['obfs'] = 'salamander'; + $params['obfs-password'] = data_get($protocol_settings, 'obfs.password'); + } + if (isset($server['ports'])) { + $params['mport'] = $server['ports']; + } + + $query = http_build_query($params); + $uri = "hysteria2://{$password}@{$addr}:{$server['port']}?{$query}#{$name}"; + } else { + $params['protocol'] = 'udp'; + $params['auth'] = $password; + if ($upMbps = data_get($protocol_settings, 'bandwidth.up')) + $params['upmbps'] = $upMbps; + if ($downMbps = data_get($protocol_settings, 'bandwidth.down')) + $params['downmbps'] = $downMbps; + if ($obfsPassword = data_get($protocol_settings, 'obfs.password')) + $params['obfsParam'] = $obfsPassword; + + $query = http_build_query($params); + $uri = "hysteria://{$addr}:{$server['port']}?{$query}#{$name}"; + } $uri .= "\r\n"; return $uri; @@ -333,4 +379,28 @@ class General extends AbstractProtocol $credentials = base64_encode("{$password}:{$password}"); return "socks://{$credentials}@{$server['host']}:{$server['port']}#{$name}\r\n"; } + + public static function buildHttp($password, $server) + { + $protocol_settings = data_get($server, 'protocol_settings', []); + $name = rawurlencode($server['name']); + $addr = Helper::wrapIPv6($server['host']); + $credentials = base64_encode("{$password}:{$password}"); + + $params = []; + if (data_get($protocol_settings, 'tls')) { + $params['security'] = 'tls'; + if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) { + $params['sni'] = $serverName; + } + $params['allowInsecure'] = data_get($protocol_settings, 'tls_settings.allow_insecure') ? '1' : '0'; + } + + $uri = "http://{$credentials}@{$addr}:{$server['port']}"; + if (!empty($params)) { + $uri .= '?' . http_build_query($params); + } + $uri .= "#{$name}\r\n"; + return $uri; + } } diff --git a/app/Protocols/SingBox.php b/app/Protocols/SingBox.php index ce4aaba..4eb803e 100644 --- a/app/Protocols/SingBox.php +++ b/app/Protocols/SingBox.php @@ -20,6 +20,7 @@ class SingBox extends AbstractProtocol Server::TYPE_ANYTLS, Server::TYPE_SOCKS, Server::TYPE_HTTP, + Server::TYPE_MIERU, ]; private $config; const CUSTOM_TEMPLATE_FILE = 'resources/rules/custom.sing-box.json'; @@ -62,6 +63,9 @@ class SingBox extends AbstractProtocol ], 'anytls' => [ 'base_version' => '1.12.0' + ], + 'mieru' => [ + 'base_version' => '1.12.0' ] ] ]; @@ -72,6 +76,7 @@ class SingBox extends AbstractProtocol $this->config = $this->loadConfig(); $this->buildOutbounds(); $this->buildRule(); + $this->adaptConfigForVersion(); $user = $this->user; return response() @@ -133,6 +138,10 @@ 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'])) { @@ -161,6 +170,91 @@ class SingBox extends AbstractProtocol $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 + */ + protected function adaptConfigForVersion(): void + { + $coreVersion = $this->getSingBoxCoreVersion(); + if (empty($coreVersion)) { + return; + } + + // >= 1.11.0: 移除已废弃字段,避免 "配置已过时" 警告 + if (version_compare($coreVersion, '1.11.0', '>=')) { + $this->removeDeprecatedFieldsV111(); + } + + // < 1.10.0: address 数组 → inet4_address/inet6_address + if (version_compare($coreVersion, '1.10.0', '<')) { + $this->convertAddressToLegacy(); + } + } + + /** + * 获取实际 sing-box 核心版本 + * + * sing-box 客户端直接报核心版本,hiddify/sfm 等 wrapper 客户端 + * 报的是 app 版本,需要映射到对应的 sing-box 核心版本 + */ + private function getSingBoxCoreVersion(): ?string + { + 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'; + } + + /** + * sing-box >= 1.11.0: 移除废弃字段 + */ + private function removeDeprecatedFieldsV111(): void + { + if (!isset($this->config['inbounds'])) { + return; + } + foreach ($this->config['inbounds'] as &$inbound) { + unset($inbound['endpoint_independent_nat']); + unset($inbound['sniff_override_destination']); + } + } + + /** + * sing-box < 1.10.0: 将 tun address 数组转换为 inet4_address/inet6_address + */ + private function convertAddressToLegacy(): void + { + if (!isset($this->config['inbounds'])) { + return; + } + foreach ($this->config['inbounds'] as &$inbound) { + if ($inbound['type'] !== 'tun' || !isset($inbound['address'])) { + continue; + } + foreach ($inbound['address'] as $addr) { + if (str_contains($addr, ':')) { + $inbound['inet6_address'] = $addr; + } else { + $inbound['inet4_address'] = $addr; + } + } + unset($inbound['address']); + } + } + protected function buildShadowsocks($password, $server) { $protocol_settings = data_get($server, 'protocol_settings'); @@ -191,14 +285,16 @@ class SingBox extends AbstractProtocol 'uuid' => $uuid, 'security' => 'auto', 'alter_id' => 0, - 'transport' => [], - 'tls' => $protocol_settings['tls'] ? [ + ]; + + if ($protocol_settings['tls']) { + $array['tls'] = [ 'enabled' => true, 'insecure' => (bool) data_get($protocol_settings, 'tls_settings.allow_insecure'), - ] : null - ]; - if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) { - $array['tls']['server_name'] = $serverName; + ]; + if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) { + $array['tls']['server_name'] = $serverName; + } } $transport = match ($protocol_settings['network']) { @@ -218,6 +314,20 @@ class SingBox extends AbstractProtocol '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 }; @@ -296,6 +406,9 @@ class SingBox extends AbstractProtocol 'host' => data_get($protocol_settings, 'network_settings.host', $server['host']), 'headers' => data_get($protocol_settings, 'network_settings.headers') ], + 'quic' => [ + 'type' => 'quic' + ], default => null }; @@ -337,7 +450,9 @@ class SingBox extends AbstractProtocol ]), default => null }; - $array['transport'] = $transport; + if ($transport) { + $array['transport'] = array_filter($transport, fn($value) => !is_null($value)); + } return $array; } @@ -503,4 +618,28 @@ class SingBox extends AbstractProtocol return $array; } + + protected function buildMieru($password, $server): array + { + $protocol_settings = data_get($server, 'protocol_settings', []); + $array = [ + 'type' => 'mieru', + 'tag' => $server['name'], + 'server' => $server['host'], + 'server_port' => $server['port'], + 'username' => $password, + 'password' => $password, + 'transport' => strtolower(data_get($protocol_settings, 'transport', 'tcp')), + ]; + + if (isset($server['ports'])) { + $array['server_port_range'] = [$server['ports']]; + } + + if ($multiplexing = data_get($protocol_settings, 'multiplexing')) { + $array['multiplexing'] = $multiplexing; + } + + return $array; + } } diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index acae60a..4803967 100755 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -2,9 +2,16 @@ namespace App\Providers; +use App\Models\Server; +use App\Models\ServerRoute; +use App\Models\Plan; use App\Models\User; +use App\Observers\PlanObserver; +use App\Observers\ServerObserver; +use App\Observers\ServerRouteObserver; use App\Observers\UserObserver; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; +use Illuminate\Support\Facades\Event; class EventServiceProvider extends ServiceProvider { @@ -24,5 +31,10 @@ class EventServiceProvider extends ServiceProvider parent::boot(); User::observe(UserObserver::class); + Plan::observe(PlanObserver::class); + Server::observe(ServerObserver::class); + ServerRoute::observe(ServerRouteObserver::class); + + } } diff --git a/app/Providers/SettingServiceProvider.php b/app/Providers/SettingServiceProvider.php index fbf59a2..f8f861a 100644 --- a/app/Providers/SettingServiceProvider.php +++ b/app/Providers/SettingServiceProvider.php @@ -3,6 +3,7 @@ namespace App\Providers; use App\Support\Setting; +use Illuminate\Support\Facades\URL; use Illuminate\Support\ServiceProvider; use Illuminate\Contracts\Foundation\Application; use Illuminate\Support\Facades\Log; @@ -29,5 +30,8 @@ class SettingServiceProvider extends ServiceProvider */ public function boot() { + if ($appUrl = admin_setting('app_url')) { + URL::forceRootUrl($appUrl); + } } } diff --git a/app/Services/NodeRegistry.php b/app/Services/NodeRegistry.php new file mode 100644 index 0000000..c0f7d13 --- /dev/null +++ b/app/Services/NodeRegistry.php @@ -0,0 +1,77 @@ + nodeId → connection */ + private static array $connections = []; + + public static function add(int $nodeId, TcpConnection $conn): void + { + // Close existing connection for this node (if reconnecting) + if (isset(self::$connections[$nodeId])) { + self::$connections[$nodeId]->close(); + } + self::$connections[$nodeId] = $conn; + } + + public static function remove(int $nodeId): void + { + unset(self::$connections[$nodeId]); + } + + public static function get(int $nodeId): ?TcpConnection + { + return self::$connections[$nodeId] ?? null; + } + + /** + * Send a JSON message to a specific node. + */ + public static function send(int $nodeId, string $event, array $data): bool + { + $conn = self::get($nodeId); + if (!$conn) { + return false; + } + + $payload = json_encode([ + 'event' => $event, + 'data' => $data, + 'timestamp' => time(), + ]); + + $conn->send($payload); + return true; + } + + /** + * Get the connection for a node by ID, checking if it's still alive. + */ + public static function isOnline(int $nodeId): bool + { + $conn = self::get($nodeId); + return $conn !== null && $conn->getStatus() === TcpConnection::STATUS_ESTABLISHED; + } + + /** + * Get all connected node IDs. + * @return int[] + */ + public static function getConnectedNodeIds(): array + { + return array_keys(self::$connections); + } + + public static function count(): int + { + return count(self::$connections); + } +} diff --git a/app/Services/NodeSyncService.php b/app/Services/NodeSyncService.php new file mode 100644 index 0000000..9dace84 --- /dev/null +++ b/app/Services/NodeSyncService.php @@ -0,0 +1,143 @@ + ServerService::buildNodeConfig($node)]); + } + + /** + * Push all users to all nodes in the group + */ + public static function notifyUsersUpdatedByGroup(int $groupId): void + { + $servers = Server::whereJsonContains('group_ids', (string) $groupId) + ->get(); + + foreach ($servers as $server) { + if (!self::isNodeOnline($server->id)) + continue; + + $users = ServerService::getAvailableUsers($server)->toArray(); + self::push($server->id, 'sync.users', ['users' => $users]); + } + } + + /** + * Push user changes (add/remove) to affected nodes + */ + public static function notifyUserChanged(User $user): void + { + if (!$user->group_id) + return; + + $servers = Server::whereJsonContains('group_ids', (string) $user->group_id)->get(); + foreach ($servers as $server) { + if (!self::isNodeOnline($server->id)) + continue; + + if ($user->isAvailable()) { + self::push($server->id, 'sync.user.delta', [ + 'action' => 'add', + 'users' => [ + [ + 'id' => $user->id, + 'uuid' => $user->uuid, + 'speed_limit' => $user->speed_limit, + 'device_limit' => $user->device_limit, + ] + ], + ]); + } else { + self::push($server->id, 'sync.user.delta', [ + 'action' => 'remove', + 'users' => [['id' => $user->id]], + ]); + } + } + } + + /** + * Push user removal from a specific group's nodes + */ + public static function notifyUserRemovedFromGroup(int $userId, int $groupId): void + { + $servers = Server::whereJsonContains('group_ids', (string) $groupId) + ->get(); + + foreach ($servers as $server) { + if (!self::isNodeOnline($server->id)) + continue; + + self::push($server->id, 'sync.user.delta', [ + 'action' => 'remove', + 'users' => [['id' => $userId]], + ]); + } + } + + /** + * Full sync: push config + users to a node + */ + public static function notifyFullSync(int $nodeId): void + { + if (!self::isNodeOnline($nodeId)) + return; + + $node = Server::find($nodeId); + if (!$node) + return; + + self::push($nodeId, 'sync.config', ['config' => ServerService::buildNodeConfig($node)]); + + $users = ServerService::getAvailableUsers($node)->toArray(); + self::push($nodeId, 'sync.users', ['users' => $users]); + } + + /** + * Publish a push command to Redis — picked up by the Workerman WS server + */ + private static function push(int $nodeId, string $event, array $data): void + { + try { + Redis::publish('node:push', json_encode([ + 'node_id' => $nodeId, + 'event' => $event, + 'data' => $data, + ])); + } catch (\Throwable $e) { + Log::warning("[NodePush] Redis publish failed: {$e->getMessage()}", [ + 'node_id' => $nodeId, + 'event' => $event, + ]); + } + } +} diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index 9cbeb8e..900be59 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -95,13 +95,14 @@ class OrderService public function open(): void { $order = $this->order; - $this->user = User::find($order->user_id); $plan = Plan::find($order->plan_id); HookManager::call('order.open.before', $order); DB::transaction(function () use ($order, $plan) { + $this->user = User::lockForUpdate()->find($order->user_id); + if ($order->refund_amount) { $this->user->balance += $order->refund_amount; } diff --git a/app/Services/PaymentService.php b/app/Services/PaymentService.php index 249be8a..c496d38 100644 --- a/app/Services/PaymentService.php +++ b/app/Services/PaymentService.php @@ -25,10 +25,18 @@ class PaymentService } if ($id) { - $payment = Payment::find($id)->toArray(); + $paymentModel = Payment::find($id); + if (!$paymentModel) { + throw new ApiException('payment not found'); + } + $payment = $paymentModel->toArray(); } if ($uuid) { - $payment = Payment::where('uuid', $uuid)->first()->toArray(); + $paymentModel = Payment::where('uuid', $uuid)->first(); + if (!$paymentModel) { + throw new ApiException('payment not found'); + } + $payment = $paymentModel->toArray(); } $this->config = []; diff --git a/app/Services/ServerService.php b/app/Services/ServerService.php index 01271dd..13a1680 100644 --- a/app/Services/ServerService.php +++ b/app/Services/ServerService.php @@ -27,7 +27,9 @@ class ServerService 'is_online', 'available_status', 'cache_key', - 'load_status' + 'load_status', + 'metrics', + 'online_conn' ]); } @@ -93,6 +95,131 @@ class ServerService return $routes; } + /** + * Build node config data + */ + public static function buildNodeConfig(Server $node): array + { + $nodeType = $node->type; + $protocolSettings = $node->protocol_settings; + $serverPort = $node->server_port; + $host = $node->host; + + $baseConfig = [ + 'protocol' => $nodeType, + 'listen_ip' => '0.0.0.0', + 'server_port' => (int) $serverPort, + 'network' => data_get($protocolSettings, 'network'), + 'networkSettings' => data_get($protocolSettings, 'network_settings') ?: null, + ]; + + $response = match ($nodeType) { + 'shadowsocks' => [ + ...$baseConfig, + 'cipher' => $protocolSettings['cipher'], + 'plugin' => $protocolSettings['plugin'], + 'plugin_opts' => $protocolSettings['plugin_opts'], + 'server_key' => match ($protocolSettings['cipher']) { + '2022-blake3-aes-128-gcm' => Helper::getServerKey($node->created_at, 16), + '2022-blake3-aes-256-gcm' => Helper::getServerKey($node->created_at, 32), + default => null, + }, + ], + 'vmess' => [ + ...$baseConfig, + 'tls' => (int) $protocolSettings['tls'], + ], + 'trojan' => [ + ...$baseConfig, + 'host' => $host, + 'server_name' => $protocolSettings['server_name'], + ], + 'vless' => [ + ...$baseConfig, + 'tls' => (int) $protocolSettings['tls'], + 'flow' => $protocolSettings['flow'], + 'tls_settings' => match ((int) $protocolSettings['tls']) { + 2 => $protocolSettings['reality_settings'], + default => $protocolSettings['tls_settings'], + }, + ], + 'hysteria' => [ + ...$baseConfig, + 'server_port' => (int) $serverPort, + 'version' => (int) $protocolSettings['version'], + 'host' => $host, + 'server_name' => $protocolSettings['tls']['server_name'], + 'up_mbps' => (int) $protocolSettings['bandwidth']['up'], + 'down_mbps' => (int) $protocolSettings['bandwidth']['down'], + ...match ((int) $protocolSettings['version']) { + 1 => ['obfs' => $protocolSettings['obfs']['password'] ?? null], + 2 => [ + 'obfs' => $protocolSettings['obfs']['open'] ? $protocolSettings['obfs']['type'] : null, + 'obfs-password' => $protocolSettings['obfs']['password'] ?? null, + ], + default => [], + }, + ], + 'tuic' => [ + ...$baseConfig, + 'version' => (int) $protocolSettings['version'], + 'server_port' => (int) $serverPort, + 'server_name' => $protocolSettings['tls']['server_name'], + 'congestion_control' => $protocolSettings['congestion_control'], + 'tls_settings' => data_get($protocolSettings, 'tls_settings'), + 'auth_timeout' => '3s', + 'zero_rtt_handshake' => false, + 'heartbeat' => '3s', + ], + 'anytls' => [ + ...$baseConfig, + 'server_port' => (int) $serverPort, + 'server_name' => $protocolSettings['tls']['server_name'], + 'padding_scheme' => $protocolSettings['padding_scheme'], + ], + 'socks' => [ + ...$baseConfig, + 'server_port' => (int) $serverPort, + ], + 'naive' => [ + ...$baseConfig, + 'server_port' => (int) $serverPort, + 'tls' => (int) $protocolSettings['tls'], + 'tls_settings' => $protocolSettings['tls_settings'], + ], + 'http' => [ + ...$baseConfig, + 'server_port' => (int) $serverPort, + 'tls' => (int) $protocolSettings['tls'], + 'tls_settings' => $protocolSettings['tls_settings'], + ], + 'mieru' => [ + ...$baseConfig, + 'server_port' => (string) $serverPort, + 'protocol' => (int) $protocolSettings['protocol'], + ], + default => [], + }; + + if (!empty($node['route_ids'])) { + $response['routes'] = self::getRoutes($node['route_ids']); + } + + if (!empty($node['custom_outbounds'])) { + $response['custom_outbounds'] = $node['custom_outbounds']; + } + + if (!empty($node['custom_routes'])) { + $response['custom_routes'] = $node['custom_routes']; + } + + if (!empty($node['cert_config'])) { + $response['cert_config'] = $node['cert_config']; + } + + return $response; + } + /** * 根据协议类型和标识获取服务器 * @param int $serverId diff --git a/app/Utils/CacheKey.php b/app/Utils/CacheKey.php index bff8499..6795c6c 100644 --- a/app/Utils/CacheKey.php +++ b/app/Utils/CacheKey.php @@ -26,6 +26,8 @@ class CacheKey 'SERVER_*_LAST_PUSH_AT', // 节点最后推送时间 'SERVER_*_LOAD_STATUS', // 节点负载状态 'SERVER_*_LAST_LOAD_AT', // 节点最后负载提交时间 + 'SERVER_*_METRICS', // 节点指标数据 + 'USER_ONLINE_CONN_*_*', // 用户在线连接数 (特定节点类型_ID) ]; /** @@ -57,7 +59,7 @@ class CacheKey private static function matchesPattern(string $key): bool { foreach (self::ALLOWED_PATTERNS as $pattern) { - $regex = '/^' . str_replace('*', '[A-Z_]+', $pattern) . '$/'; + $regex = '/^' . str_replace('*', '[A-Za-z0-9_]+', $pattern) . '$/'; if (preg_match($regex, $key)) { return true; } diff --git a/compose.sample.yaml b/compose.sample.yaml index c55ff8b..bc891ce 100644 --- a/compose.sample.yaml +++ b/compose.sample.yaml @@ -21,6 +21,16 @@ services: command: php artisan horizon depends_on: - redis + ws-server: + image: ghcr.io/cedar2025/xboard:new + volumes: + - ./.docker/.data/redis/:/data/ + - ./:/www/ + restart: always + network_mode: host + command: php artisan xboard:ws-server start + depends_on: + - redis redis: image: redis:7-alpine command: redis-server --unixsocket /data/redis.sock --unixsocketperm 777 diff --git a/composer.json b/composer.json index eb069a5..ce7762d 100755 --- a/composer.json +++ b/composer.json @@ -31,6 +31,9 @@ "symfony/http-client": "^7.0", "symfony/mailgun-mailer": "^7.0", "symfony/yaml": "*", + "webmozart/assert": "*", + "workerman/redis": "^2.0", + "workerman/workerman": "^5.1", "zoujingli/ip2region": "^2.0" }, "require-dev": { diff --git a/config/horizon.php b/config/horizon.php index a6ba801..3959444 100644 --- a/config/horizon.php +++ b/config/horizon.php @@ -155,7 +155,7 @@ return [ | */ - 'memory_limit' => 64, + 'memory_limit' => 256, /* |-------------------------------------------------------------------------- @@ -169,22 +169,57 @@ return [ */ 'environments' => [ + 'production' => [ + 'data-pipeline' => [ + 'connection' => 'redis', + 'queue' => ['traffic_fetch', 'stat', 'user_alive_sync'], + 'balance' => 'auto', + 'autoScalingStrategy' => 'time', + 'minProcesses' => 1, + 'maxProcesses' => 8, + 'balanceCooldown' => 1, + 'tries' => 3, + 'timeout' => 30, + ], + 'business' => [ + 'connection' => 'redis', + 'queue' => ['default', 'order_handle'], + 'balance' => 'simple', + 'minProcesses' => 1, + 'maxProcesses' => 3, + 'tries' => 3, + 'timeout' => 30, + ], + 'notification' => [ + 'connection' => 'redis', + 'queue' => ['send_email', 'send_telegram', 'send_email_mass'], + 'balance' => 'auto', + 'autoScalingStrategy' => 'size', + 'minProcesses' => 1, + 'maxProcesses' => 3, + 'tries' => 3, + 'timeout' => 60, + 'backoff' => [3, 10, 30], + ], + ], 'local' => [ 'Xboard' => [ 'connection' => 'redis', 'queue' => [ + 'default', 'order_handle', 'traffic_fetch', 'stat', 'send_email', 'send_email_mass', 'send_telegram', - 'online_sync' + 'user_alive_sync', ], 'balance' => 'auto', 'minProcesses' => 1, - 'maxProcesses' => 20, + 'maxProcesses' => 5, 'tries' => 1, + 'timeout' => 60, 'balanceCooldown' => 3, ], ], diff --git a/config/octane.php b/config/octane.php index f2cc571..01190fb 100644 --- a/config/octane.php +++ b/config/octane.php @@ -79,7 +79,7 @@ return [ ], RequestTerminated::class => [ - // FlushUploadedFiles::class, + FlushUploadedFiles::class, ], TaskReceived::class => [ @@ -102,8 +102,8 @@ return [ OperationTerminated::class => [ FlushTemporaryContainerInstances::class, - // DisconnectFromDatabases::class, - // CollectGarbage::class, + DisconnectFromDatabases::class, + CollectGarbage::class, ], WorkerErrorOccurred::class => [ @@ -132,7 +132,7 @@ return [ ], 'flush' => [ - // + \App\Services\Plugin\HookManager::class, ], /* @@ -147,8 +147,8 @@ return [ */ 'cache' => [ - 'rows' => 1000, - 'bytes' => 10000, + 'rows' => 5000, + 'bytes' => 20000, ], /* @@ -203,7 +203,7 @@ return [ | */ - 'garbage' => 50, + 'garbage' => 128, /* |-------------------------------------------------------------------------- @@ -216,6 +216,6 @@ return [ | */ - 'max_execution_time' => 30, + 'max_execution_time' => 60, ]; diff --git a/database/migrations/2026_03_15_060035_add_custom_config_and_cert_to_v2_server_table.php b/database/migrations/2026_03_15_060035_add_custom_config_and_cert_to_v2_server_table.php new file mode 100644 index 0000000..6f67ff7 --- /dev/null +++ b/database/migrations/2026_03_15_060035_add_custom_config_and_cert_to_v2_server_table.php @@ -0,0 +1,30 @@ +json('custom_outbounds')->nullable()->after('protocol_settings'); + $table->json('custom_routes')->nullable()->after('custom_outbounds'); + $table->json('cert_config')->nullable()->after('custom_routes'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('v2_server', function (Blueprint $table) { + $table->dropColumn(['custom_outbounds', 'custom_routes', 'cert_config']); + }); + } +}; diff --git a/docs/en/installation/1panel.md b/docs/en/installation/1panel.md index 627dc4a..8a44f4b 100644 --- a/docs/en/installation/1panel.md +++ b/docs/en/installation/1panel.md @@ -33,6 +33,16 @@ sudo bash quick_start.sh 2. Configure Reverse Proxy: ```nginx +location /ws/ { + proxy_pass http://127.0.0.1:8076; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 60s; +} + location ^~ / { proxy_pass http://127.0.0.1:7001; proxy_http_version 1.1; @@ -49,6 +59,7 @@ location ^~ / { proxy_cache off; } ``` +> The `/ws/` location enables WebSocket real-time node synchronization via `xboard:ws-server`. This service is enabled by default and can be toggled in Admin Panel > System Settings > Server. 3. Install Xboard: ```bash @@ -175,4 +186,6 @@ docker compose up -d - ⚠️ Ensure firewall is enabled to prevent port 7001 exposure to public - Service restart is required after code modifications -- SSL certificate configuration is recommended for secure access +- SSL certificate configuration is recommended for secure access + +> The node will automatically detect WebSocket availability during handshake. No extra configuration is needed on the node side. diff --git a/docs/en/installation/aapanel-docker.md b/docs/en/installation/aapanel-docker.md index 9366d27..5ffa6a1 100644 --- a/docs/en/installation/aapanel-docker.md +++ b/docs/en/installation/aapanel-docker.md @@ -84,6 +84,16 @@ docker compose up -d #### 3.4 Configure Reverse Proxy Add the following content to your site configuration: ```nginx +location /ws/ { + proxy_pass http://127.0.0.1:8076; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 60s; +} + location ^~ / { proxy_pass http://127.0.0.1:7001; proxy_http_version 1.1; @@ -100,6 +110,7 @@ location ^~ / { proxy_cache off; } ``` +> The `/ws/` location enables real-time node synchronization via `xboard:ws-server`. This service is enabled by default and can be toggled in Admin Panel > System Settings > Server. ## Maintenance Guide @@ -134,4 +145,6 @@ If you encounter any issues during installation or operation, please check: 2. All required ports are available 3. Docker services are running properly 4. Nginx configuration is correct -5. Check logs for detailed error messages +5. Check logs for detailed error messages + +> The node will automatically detect WebSocket availability during handshake. No extra configuration is needed on the node side. diff --git a/docs/en/installation/aapanel.md b/docs/en/installation/aapanel.md index d2cb3bd..06a1f26 100644 --- a/docs/en/installation/aapanel.md +++ b/docs/en/installation/aapanel.md @@ -172,4 +172,38 @@ sh update.sh 1. Changes to admin path require service restart to take effect 2. Any code changes after enabling Octane require restart to take effect 3. When PHP extension installation fails, check if PHP version is correct -4. For database connection failures, check database configuration and permissions +4. For database connection failures, check database configuration and permissions + +## Enable WebSocket Real-time Sync (Optional) + +WebSocket enables real-time synchronization of configurations and user changes to nodes. + +### 1. Start WS Server + +Add a WebSocket daemon process in aaPanel Supervisor: +- Name: `Xboard-WS` +- Run User: `www` +- Running Directory: Site directory +- Start Command: `php artisan xboard:ws-server start` +- Process Count: 1 + +### 2. Configure Nginx + +Add the WebSocket location **before** the main `location ^~ /` block in your site's Nginx configuration: +```nginx +location /ws/ { + proxy_pass http://127.0.0.1:8076; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 60s; +} +``` + +### 3. Restart Services + +Restart the Octane and WS Server processes in Supervisor. + +> The node will automatically detect WebSocket availability during handshake. No extra configuration is needed on the node side. diff --git a/public/assets/admin b/public/assets/admin index 6a48a43..83ead5a 160000 --- a/public/assets/admin +++ b/public/assets/admin @@ -1 +1 @@ -Subproject commit 6a48a43374f9f8035329a4ba87af957edf26e6f9 +Subproject commit 83ead5a8a35b0c63f1177086b33f82a4a1a7d2a4