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`.
This commit is contained in:
xboard
2026-03-15 09:49:11 +08:00
parent 1864223c9b
commit 010275b09e
47 changed files with 1314 additions and 223 deletions
+2 -2
View File
@@ -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();
+5 -6
View File
@@ -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);
});
}
}
+7 -8
View File
@@ -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();
});
}
}
+4 -4
View File
@@ -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();
@@ -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]);
}
@@ -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
{
+25 -35
View File
@@ -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);
@@ -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'),
@@ -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);
@@ -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' => '备注不能为空',
@@ -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);
}
@@ -0,0 +1,150 @@
<?php
namespace App\Http\Controllers\V2\Server;
use App\Http\Controllers\Controller;
use App\Jobs\UserAliveSyncJob;
use App\Services\UserService;
use App\Utils\CacheKey;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Http\JsonResponse;
use Log;
class ServerController extends Controller
{
/**
* server handshake api
*/
public function handshake(Request $request): JsonResponse
{
$websocket = ['enabled' => 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]);
}
}
+2
View File
@@ -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',
+3
View File
@@ -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',
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Routes\V2;
use App\Http\Controllers\V2\Client\AppController;
use Illuminate\Contracts\Routing\Registrar;
class ClientRoute
{
public function map(Registrar $router)
{
$router->group([
'prefix' => 'client',
'middleware' => 'client'
], function ($router) {
// App
$router->get('/app/getConfig', [AppController::class, 'getConfig']);
$router->get('/app/getVersion', [AppController::class, 'getVersion']);
});
}
}
+3
View File
@@ -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']);
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Jobs;
use App\Models\User;
use App\Services\NodeSyncService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class NodeUserSyncJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 2;
public $timeout = 10;
public function __construct(
private readonly int $userId,
private readonly string $action,
private readonly ?int $oldGroupId = null
) {
$this->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);
}
}
}
}
@@ -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);
+33 -1
View File
@@ -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;
}
);
}
/**
* 负载状态访问器
*/
+8
View File
@@ -147,6 +147,14 @@ class User extends Authenticatable
$this->plan_id !== null;
}
/**
* 检查用户是否可用节点流量且充足
*/
public function isAvailable(): bool
{
return $this->isActive() && $this->getRemainingTraffic() > 0;
}
/**
* 检查是否需要重置流量
*/
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Observers;
use App\Models\Plan;
use App\Models\User;
use App\Services\TrafficResetService;
class PlanObserver
{
/**
* reset user next_reset_at
*/
public function updated(Plan $plan): void
{
if (!$plan->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,
]);
});
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace App\Observers;
use App\Models\Server;
use App\Services\NodeSyncService;
class ServerObserver
{
public function updated(Server $server): void
{
if (
$server->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);
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Observers;
use App\Models\Server;
use App\Models\ServerRoute;
use App\Services\NodeSyncService;
class ServerRouteObserver
{
public function updated(ServerRoute $route): void
{
$this->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);
}
}
}
+33 -6
View File
@@ -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();
});
}
}
+66 -1
View File
@@ -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';
+87 -17
View File
@@ -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;
}
}
+146 -7
View File
@@ -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;
}
}
+12
View File
@@ -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);
}
}
+4
View File
@@ -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);
}
}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
namespace App\Services;
use Workerman\Connection\TcpConnection;
/**
* In-memory registry for active WebSocket node connections.
* Runs inside the Workerman process.
*/
class NodeRegistry
{
/** @var array<int, TcpConnection> 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);
}
}
+143
View File
@@ -0,0 +1,143 @@
<?php
namespace App\Services;
use App\Models\Server;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
class NodeSyncService
{
/**
* Check if node has active WS connection
*/
private static function isNodeOnline(int $nodeId): bool
{
return (bool) Cache::get("node_ws_alive:{$nodeId}");
}
/**
* Push node config update
*/
public static function notifyConfigUpdated(int $nodeId): void
{
if (!self::isNodeOnline($nodeId))
return;
$node = Server::find($nodeId);
if (!$node)
return;
self::push($nodeId, 'sync.config', ['config' => 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,
]);
}
}
}
+2 -1
View File
@@ -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;
}
+10 -2
View File
@@ -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 = [];
+128 -1
View File
@@ -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
+3 -1
View File
@@ -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;
}