diff --git a/app/Http/Controllers/V1/Server/UniProxyController.php b/app/Http/Controllers/V1/Server/UniProxyController.php index 5901c4b..28b2f88 100644 --- a/app/Http/Controllers/V1/Server/UniProxyController.php +++ b/app/Http/Controllers/V1/Server/UniProxyController.php @@ -4,11 +4,7 @@ namespace App\Http\Controllers\V1\Server; use App\Http\Controllers\Controller; use App\Services\DeviceStateService; -use App\Services\NodeSyncService; use App\Services\ServerService; -use App\Services\UserService; -use App\Utils\CacheKey; -use App\Utils\Helper; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; use Illuminate\Http\JsonResponse; @@ -20,71 +16,42 @@ class UniProxyController extends Controller ) { } - /** - * 获取当前请求的节点信息 - */ private function getNodeInfo(Request $request) { return $request->attributes->get('node_info'); } - // 后端获取用户 public function user(Request $request) { ini_set('memory_limit', -1); $node = $this->getNodeInfo($request); - $nodeType = $node->type; - $nodeId = $node->id; - Cache::put(CacheKey::get('SERVER_' . strtoupper($nodeType) . '_LAST_CHECK_AT', $nodeId), time(), 3600); - $users = ServerService::getAvailableUsers($node); - $response['users'] = $users; + ServerService::touchNode($node); + + $response['users'] = ServerService::getAvailableUsers($node); $eTag = sha1(json_encode($response)); - if (strpos($request->header('If-None-Match', ''), $eTag) !== false) { + if (str_contains($request->header('If-None-Match', ''), $eTag)) { return response(null, 304); } return response($response)->header('ETag', "\"{$eTag}\""); } - // 后端提交数据 public function push(Request $request) { $res = json_decode(request()->getContent(), true); if (!is_array($res)) { return $this->fail([422, 'Invalid data format']); } - $data = array_filter($res, function ($item) { - return is_array($item) - && count($item) === 2 - && is_numeric($item[0]) - && is_numeric($item[1]); - }); - if (empty($data)) { - return $this->success(true); - } + $node = $this->getNodeInfo($request); - $nodeType = $node->type; - $nodeId = $node->id; - 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 - ); + ServerService::processTraffic($node, $res); - $userService = new UserService(); - $userService->trafficFetch($node, $nodeType, $data); return $this->success(true); } - // 后端获取配置 public function config(Request $request) { $node = $this->getNodeInfo($request); @@ -96,13 +63,12 @@ class UniProxyController extends Controller ]; $eTag = sha1(json_encode($response)); - if (strpos($request->header('If-None-Match', ''), $eTag) !== false) { + if (str_contains($request->header('If-None-Match', ''), $eTag)) { return response(null, 304); } return response($response)->header('ETag', "\"{$eTag}\""); } - // 获取在线用户数据 public function alivelist(Request $request): JsonResponse { $node = $this->getNodeInfo($request); @@ -114,25 +80,19 @@ class UniProxyController extends Controller return response()->json(['alive' => (object) $alive]); } - // 后端提交在线数据 public function alive(Request $request): JsonResponse { $node = $this->getNodeInfo($request); $data = json_decode(request()->getContent(), true); if ($data === null) { - return response()->json([ - 'error' => 'Invalid online data' - ], 400); + return response()->json(['error' => 'Invalid online data'], 400); } - foreach ($data as $uid => $ips) { - $this->deviceStateService->setDevices((int) $uid, $node->id, $ips); - } + ServerService::processAlive($node->id, $data); return response()->json(['data' => true]); } - // 提交节点负载状态 public function status(Request $request): JsonResponse { $node = $this->getNodeInfo($request); @@ -147,32 +107,8 @@ class UniProxyController extends Controller 'disk.used' => 'required|integer|min:0', ]); - $nodeType = $node->type; - $nodeId = $node->id; + ServerService::processStatus($node, $data); - $statusData = [ - 'cpu' => (float) $data['cpu'], - 'mem' => [ - 'total' => (int) $data['mem']['total'], - 'used' => (int) $data['mem']['used'], - ], - 'swap' => [ - 'total' => (int) $data['swap']['total'], - 'used' => (int) $data['swap']['used'], - ], - 'disk' => [ - 'total' => (int) $data['disk']['total'], - 'used' => (int) $data['disk']['used'], - ], - '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); - - return response()->json(['data' => true, "code" => 0, "message" => "success"]); + return response()->json(['data' => true, 'code' => 0, 'message' => 'success']); } } diff --git a/app/Http/Controllers/V2/Admin/Server/MachineController.php b/app/Http/Controllers/V2/Admin/Server/MachineController.php new file mode 100644 index 0000000..c84a293 --- /dev/null +++ b/app/Http/Controllers/V2/Admin/Server/MachineController.php @@ -0,0 +1,205 @@ +orderBy('id') + ->get() + ->map(function (ServerMachine $machine) { + return [ + 'id' => $machine->id, + 'name' => $machine->name, + 'notes' => $machine->notes, + 'is_active' => $machine->is_active, + 'last_seen_at' => $machine->last_seen_at, + 'load_status' => $machine->load_status, + 'servers_count' => $machine->servers_count, + 'created_at' => $machine->created_at, + 'updated_at' => $machine->updated_at, + ]; + }); + + return $this->success($machines); + } + + /** + * 创建 / 更新机器 + */ + public function save(Request $request) + { + $params = $request->validate([ + 'id' => 'nullable|integer|exists:v2_server_machine,id', + 'name' => 'required|string|max:255', + 'notes' => 'nullable|string', + 'is_active' => 'nullable|boolean', + ]); + + if (!empty($params['id'])) { + $machine = ServerMachine::find($params['id']); + $update = ['name' => $params['name']]; + if (array_key_exists('notes', $params)) { + $update['notes'] = $params['notes']; + } + if (array_key_exists('is_active', $params)) { + $update['is_active'] = $params['is_active']; + } + $machine->update($update); + return $this->success(true); + } + + $machine = ServerMachine::create([ + 'name' => $params['name'], + 'notes' => $params['notes'] ?? null, + 'is_active' => $params['is_active'] ?? true, + 'token' => ServerMachine::generateToken(), + ]); + + return $this->success([ + 'id' => $machine->id, + 'token' => $machine->token, + ]); + } + + /** + * 重置机器 Token + */ + public function resetToken(Request $request) + { + $params = $request->validate([ + 'id' => 'required|integer|exists:v2_server_machine,id', + ]); + + $machine = ServerMachine::find($params['id']); + $token = ServerMachine::generateToken(); + $machine->update(['token' => $token]); + + return $this->success(['token' => $token]); + } + + /** + * 获取机器 Token(仅展示一次,用于首次配置) + */ + public function getToken(Request $request) + { + $params = $request->validate([ + 'id' => 'required|integer|exists:v2_server_machine,id', + ]); + + $machine = ServerMachine::find($params['id']); + + return $this->success(['token' => $machine->token]); + } + + /** + * 获取机器模式一键安装命令 + */ + public function installCommand(Request $request) + { + $params = $request->validate([ + 'id' => 'required|integer|exists:v2_server_machine,id', + ]); + + $machine = ServerMachine::find($params['id']); + + return $this->success([ + 'command' => $this->buildInstallCommand($request, $machine), + ]); + } + + /** + * 删除机器(自动解除关联节点) + */ + public function drop(Request $request) + { + $params = $request->validate([ + 'id' => 'required|integer|exists:v2_server_machine,id', + ]); + + $machine = ServerMachine::find($params['id']); + $machineId = $machine->id; + + // Detach nodes first (sets machine_id = null), then delete and notify + Server::where('machine_id', $machineId)->update(['machine_id' => null]); + $machine->delete(); + + // Notify with empty node list so WS process cleans up registry + NodeSyncService::notifyMachineNodesChanged($machineId); + + return $this->success(true); + } + + /** + * 获取机器下的节点列表 + */ + public function nodes(Request $request) + { + $params = $request->validate([ + 'machine_id' => 'required|integer|exists:v2_server_machine,id', + ]); + + $nodes = Server::where('machine_id', $params['machine_id']) + ->orderBy('sort') + ->get(['id', 'name', 'type', 'host', 'port', 'show', 'enabled', 'sort']); + + return $this->success($nodes); + } + + /** + * 获取机器负载历史 + */ + public function history(Request $request) + { + $params = $request->validate([ + 'machine_id' => 'required|integer|exists:v2_server_machine,id', + 'limit' => 'nullable|integer|min:10|max:1440', + ]); + + $limit = (int) ($params['limit'] ?? 60); + + $history = ServerMachineLoadHistory::query() + ->where('machine_id', $params['machine_id']) + ->orderByDesc('recorded_at') + ->limit($limit) + ->get([ + 'cpu', + 'mem_total', + 'mem_used', + 'disk_total', + 'disk_used', + 'recorded_at', + ]) + ->reverse() + ->values(); + + return $this->success($history); + } + + private function buildInstallCommand(Request $request, ServerMachine $machine): string + { + $panelUrl = rtrim((string) (config('app.url') ?: $request->getSchemeAndHttpHost()), '/'); + $installerUrl = 'https://raw.githubusercontent.com/cedar2025/xboard-node/main/install.sh'; + + return sprintf( + 'bash <(curl -fsSL %s) --panel %s --token %s --machine-id %d --yes', + escapeshellarg($installerUrl), + escapeshellarg($panelUrl), + escapeshellarg($machine->token), + $machine->id + ); + } +} diff --git a/app/Http/Controllers/V2/Admin/Server/ManageController.php b/app/Http/Controllers/V2/Admin/Server/ManageController.php index 9b1507d..27af670 100644 --- a/app/Http/Controllers/V2/Admin/Server/ManageController.php +++ b/app/Http/Controllers/V2/Admin/Server/ManageController.php @@ -73,25 +73,36 @@ class ManageController extends Controller Log::error($e); return $this->fail([500, '创建失败']); } - - } public function update(Request $request) { - $request->validate([ + $params = $request->validate([ 'id' => 'required|integer', - 'show' => 'integer', + 'show' => 'nullable|integer', + 'machine_id' => 'nullable|integer', + 'enabled' => 'nullable|boolean', ]); $server = Server::find($request->id); if (!$server) { return $this->fail([400202, '服务器不存在']); } - $server->show = (int) $request->show; + + if (array_key_exists('show', $params)) { + $server->show = (int) $params['show']; + } + if (array_key_exists('machine_id', $params)) { + $server->machine_id = $params['machine_id'] ?: null; + } + if (array_key_exists('enabled', $params)) { + $server->enabled = (bool) $params['enabled']; + } + if (!$server->save()) { return $this->fail([500, '保存失败']); } + return $this->success(true); } @@ -105,9 +116,14 @@ class ManageController extends Controller $request->validate([ 'id' => 'required|integer', ]); - if (Server::where('id', $request->id)->delete() === false) { + $server = Server::find($request->id); + if (!$server) { + return $this->fail([400202, '服务器不存在']); + } + if ($server->delete() === false) { return $this->fail([500, '删除失败']); } + return $this->success(true); } @@ -200,6 +216,40 @@ class ManageController extends Controller } } + /** + * 批量更新节点属性(show等) + */ + public function batchUpdate(Request $request) + { + $params = $request->validate([ + 'ids' => 'required|array', + 'ids.*' => 'integer', + 'show' => 'nullable|integer|in:0,1', + ]); + + $ids = $params['ids']; + if (empty($ids)) { + return $this->fail([400, '请选择要更新的节点']); + } + + $update = []; + if (array_key_exists('show', $params) && $params['show'] !== null) { + $update['show'] = (int) $params['show']; + } + + if (empty($update)) { + return $this->fail([400, '没有可更新的字段']); + } + + try { + Server::whereIn('id', $ids)->update($update); + return $this->success(true); + } catch (\Exception $e) { + Log::error($e); + return $this->fail([500, '批量更新失败']); + } + } + /** * 复制节点 * @param \Illuminate\Http\Request $request @@ -221,4 +271,57 @@ class ManageController extends Controller return $this->success(true); } + + /** + * Generate ECH (Encrypted Client Hello) key pair. + * Returns PEM-encoded ECH key (server-side) and ECH config (client-side). + */ + public function generateEchKey(Request $request) + { + $publicName = $request->input('public_name', 'ech.example.com'); + if (strlen($publicName) < 1 || strlen($publicName) > 253) { + throw new ApiException('public_name must be a valid domain (1-253 bytes)'); + } + + // Generate X25519 key pair + $privateKey = random_bytes(32); + $publicKey = sodium_crypto_scalarmult_base($privateKey); + + $configId = random_int(0, 255); + + // Build ECHConfigContents (draft-ietf-tls-esni-18) + $contents = ''; + $contents .= pack('C', $configId); // config_id + $contents .= pack('n', 0x0020); // kem_id: DHKEM(X25519) + $contents .= pack('n', 32) . $publicKey; // public_key (length-prefixed) + // cipher_suites: 2 suites × 4 bytes = 8 bytes + $contents .= pack('n', 8); // cipher_suites byte length + $contents .= pack('nn', 0x0001, 0x0001); // HKDF-SHA256 + AES-128-GCM + $contents .= pack('nn', 0x0001, 0x0003); // HKDF-SHA256 + ChaCha20Poly1305 + $contents .= pack('C', 0); // max_name_length + $contents .= pack('C', strlen($publicName)) . $publicName; + $contents .= pack('n', 0); // extensions: empty + + // ECHConfig = version(2) + length(2) + contents + $echConfig = pack('n', 0xfe0d) . pack('n', strlen($contents)) . $contents; + + // ECHConfigList = total_length(2) + configs + $echConfigList = pack('n', strlen($echConfig)) . $echConfig; + + // ECH Keys = private_key_len(2) + key(32) + config_len(2) + config + $echKeysPayload = pack('n', 32) . $privateKey . pack('n', strlen($echConfig)) . $echConfig; + + $keyPem = "-----BEGIN ECH KEYS-----\n" + . chunk_split(base64_encode($echKeysPayload), 64, "\n") + . "-----END ECH KEYS-----"; + + $configPem = "-----BEGIN ECH CONFIGS-----\n" + . chunk_split(base64_encode($echConfigList), 64, "\n") + . "-----END ECH CONFIGS-----"; + + return $this->success([ + 'key' => $keyPem, + 'config' => $configPem, + ]); + } } diff --git a/app/Http/Controllers/V2/Server/MachineController.php b/app/Http/Controllers/V2/Server/MachineController.php new file mode 100644 index 0000000..a18afb4 --- /dev/null +++ b/app/Http/Controllers/V2/Server/MachineController.php @@ -0,0 +1,118 @@ +authenticateMachine($request); + + $nodes = ServerService::getMachineNodes($machine) + ->map(fn($node) => [ + 'id' => $node->id, + 'type' => $node->type, + 'name' => $node->name, + ])->values(); + + return response()->json([ + 'nodes' => $nodes, + 'base_config' => [ + 'push_interval' => (int) admin_setting('server_push_interval', 60), + 'pull_interval' => (int) admin_setting('server_pull_interval', 60), + ], + ]); + } + + /** + * report machine status + */ + public function status(Request $request): JsonResponse + { + $request->validate([ + 'cpu' => 'required|numeric|min:0|max:100', + 'mem.total' => 'required|integer|min:0', + 'mem.used' => 'required|integer|min:0', + 'swap.total' => 'nullable|integer|min:0', + 'swap.used' => 'nullable|integer|min:0', + 'disk.total' => 'nullable|integer|min:0', + 'disk.used' => 'nullable|integer|min:0', + ]); + + $machine = $this->authenticateMachine($request); + $recordedAt = now()->timestamp; + + $machine->forceFill([ + 'load_status' => [ + 'cpu' => (float) $request->input('cpu'), + 'mem' => [ + 'total' => (int) $request->input('mem.total'), + 'used' => (int) $request->input('mem.used'), + ], + 'swap' => [ + 'total' => (int) $request->input('swap.total', 0), + 'used' => (int) $request->input('swap.used', 0), + ], + 'disk' => [ + 'total' => (int) $request->input('disk.total', 0), + 'used' => (int) $request->input('disk.used', 0), + ], + 'updated_at' => $recordedAt, + ], + 'last_seen_at' => $recordedAt, + ])->save(); + + ServerMachineLoadHistory::create([ + 'machine_id' => $machine->id, + 'cpu' => (float) $request->input('cpu'), + 'mem_total' => (int) $request->input('mem.total'), + 'mem_used' => (int) $request->input('mem.used'), + 'disk_total' => (int) $request->input('disk.total', 0), + 'disk_used' => (int) $request->input('disk.used', 0), + 'recorded_at' => $recordedAt, + ]); + + // Time-based cleanup: keep 24h of data, runs on ~5% of requests + if (random_int(1, 20) === 1) { + ServerMachineLoadHistory::query() + ->where('machine_id', $machine->id) + ->where('recorded_at', '<', now()->subDay()->timestamp) + ->delete(); + } + + return response()->json(['data' => true]); + } + + private function authenticateMachine(Request $request): ServerMachine + { + $request->validate([ + 'machine_id' => 'required|integer', + 'token' => 'required|string', + ]); + + $machine = ServerMachine::where('id', $request->input('machine_id')) + ->where('token', $request->input('token')) + ->first(); + + if (!$machine || !$machine->is_active) { + abort(403, 'Machine not found or disabled'); + } + + $machine->forceFill(['last_seen_at' => now()->timestamp])->saveQuietly(); + + return $machine; + } +} diff --git a/app/Http/Controllers/V2/Server/ServerController.php b/app/Http/Controllers/V2/Server/ServerController.php index ff58951..97b23c8 100644 --- a/app/Http/Controllers/V2/Server/ServerController.php +++ b/app/Http/Controllers/V2/Server/ServerController.php @@ -3,14 +3,9 @@ namespace App\Http\Controllers\V2\Server; use App\Http\Controllers\Controller; -use App\Services\DeviceStateService; use App\Services\ServerService; -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 { @@ -43,91 +38,34 @@ class ServerController extends Controller } /** - * node report api - merge traffic + alive + status - * POST /api/v2/server/node/report + * node report api - merge traffic + alive + status + metrics */ 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); + ServerService::touchNode($node); - // 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); - } + ServerService::processTraffic($node, $traffic); } - // handle alive data $alive = $request->input('alive'); if (is_array($alive) && !empty($alive)) { - $deviceStateService = app(DeviceStateService::class); - foreach ($alive as $uid => $ips) { - $deviceStateService->setDevices((int) $uid, $nodeId, (array) $ips); - } + ServerService::processAlive($node->id, $alive); } - // 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); - } + ServerService::processOnline($node, $online); } - // 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, - 'kernel_status' => $status['kernel_status'] ?? null, - ]; - - $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); + ServerService::processStatus($node, $status); } - // handle node metrics (Metrics) $metrics = $request->input('metrics'); if (is_array($metrics) && !empty($metrics)) { ServerService::updateMetrics($node, $metrics); diff --git a/app/Http/Middleware/Server.php b/app/Http/Middleware/Server.php index 15dd494..e98e6d4 100644 --- a/app/Http/Middleware/Server.php +++ b/app/Http/Middleware/Server.php @@ -1,10 +1,10 @@ validateRequest($request); + // 优先尝试 machine token 认证,兜底走旧的 server token 认证 + if ($request->filled('machine_id')) { + $this->authenticateByMachine($request, $nodeType); + } else { + $this->authenticateByServerToken($request, $nodeType); + } + + return $next($request); + } + + /** + * 旧模式:全局 server_token + node_id + */ + private function authenticateByServerToken(Request $request, ?string $nodeType): void + { + $request->validate([ + 'token' => [ + 'string', 'required', + function ($attribute, $value, $fail) { + if ($value !== admin_setting('server_token')) { + $fail("Invalid {$attribute}"); + } + }, + ], + 'node_id' => 'required', + 'node_type' => [ + 'nullable', + function ($attribute, $value, $fail) use ($request) { + if ($value === 'v2node') { + $value = null; + } + if (!ServerModel::isValidType($value)) { + $fail('Invalid node type specified'); + return; + } + $request->merge([$attribute => ServerModel::normalizeType($value)]); + }, + ] + ]); + $nodeType = $request->input('node_type', $nodeType); $normalizedNodeType = ServerModel::normalizeType($nodeType); $serverInfo = ServerService::getServer( @@ -25,35 +64,55 @@ class Server } $request->attributes->set('node_info', $serverInfo); - return $next($request); } - private function validateRequest(Request $request): void + /** + * 新模式:machine_id + machine token + node_id + * + * machine 认证后,node_id 必须属于该 machine 下的已启用节点。 + * 下游控制器拿到的 node_info 与旧模式完全一致。 + */ + private function authenticateByMachine(Request $request, ?string $nodeType): void { + $isHandshake = $request->is('*/server/handshake') || $request->is('api/v2/server/handshake'); + $request->validate([ - 'token' => [ - 'string', - 'required', - function ($attribute, $value, $fail) { - if ($value !== admin_setting('server_token')) { - $fail("Invalid {$attribute}"); - } - }, - ], - 'node_id' => 'required', - 'node_type' => [ - 'nullable', - function ($attribute, $value, $fail) use ($request) { - if ($value === "v2node") { - $value = null; - } - if (!ServerModel::isValidType($value)) { - $fail("Invalid node type specified"); - return; - } - $request->merge([$attribute => ServerModel::normalizeType($value)]); - }, - ] + 'machine_id' => 'required|integer', + 'token' => 'required|string', + 'node_id' => $isHandshake ? 'nullable|integer' : 'required|integer', ]); + + $machine = ServerMachine::where('id', $request->input('machine_id')) + ->where('token', $request->input('token')) + ->first(); + + if (!$machine) { + throw new ApiException('Machine not found or invalid token', 401); + } + + if (!$machine->is_active) { + throw new ApiException('Machine is disabled', 403); + } + + $nodeId = (int) $request->input('node_id'); + $serverInfo = null; + + if ($nodeId > 0) { + $serverInfo = ServerModel::where('id', $nodeId) + ->where('machine_id', $machine->id) + ->where('enabled', true) + ->first(); + + if (!$serverInfo) { + throw new ApiException('Node not found on this machine'); + } + + $request->attributes->set('node_info', $serverInfo); + } + + // 更新机器心跳 + $machine->forceFill(['last_seen_at' => now()->timestamp])->saveQuietly(); + + $request->attributes->set('machine_info', $machine); } } diff --git a/app/Http/Requests/Admin/ServerSave.php b/app/Http/Requests/Admin/ServerSave.php index bd1f6b2..5aac365 100644 --- a/app/Http/Requests/Admin/ServerSave.php +++ b/app/Http/Requests/Admin/ServerSave.php @@ -25,6 +25,22 @@ class ServerSave extends FormRequest 'multiplex.brutal.down_mbps' => 'nullable|integer', ]; + private const ECH_RULES = [ + 'enabled' => 'nullable|boolean', + 'config' => 'nullable|string', + 'query_server_name' => 'nullable|string', + 'key' => 'nullable|string', + ]; + + private const REALITY_RULES = [ + 'reality_settings.allow_insecure' => 'nullable|boolean', + 'reality_settings.server_name' => 'nullable|string', + 'reality_settings.server_port' => 'nullable|integer', + 'reality_settings.public_key' => 'nullable|string', + 'reality_settings.private_key' => 'nullable|string', + 'reality_settings.short_id' => 'nullable|string', + ]; + private const PROTOCOL_RULES = [ 'shadowsocks' => [ 'cipher' => 'required|string', @@ -38,8 +54,6 @@ class ServerSave extends FormRequest 'tls' => 'required|integer', 'network' => 'required|string', 'network_settings' => 'nullable|array', - 'tls_settings.server_name' => 'nullable|string', - 'tls_settings.allow_insecure' => 'nullable|boolean', ], 'trojan' => [ 'tls' => 'nullable|integer', @@ -47,12 +61,6 @@ class ServerSave extends FormRequest 'network_settings' => 'nullable|array', 'server_name' => 'nullable|string', 'allow_insecure' => 'nullable|boolean', - 'reality_settings.allow_insecure' => 'nullable|boolean', - 'reality_settings.server_name' => 'nullable|string', - 'reality_settings.server_port' => 'nullable|integer', - 'reality_settings.public_key' => 'nullable|string', - 'reality_settings.private_key' => 'nullable|string', - 'reality_settings.short_id' => 'nullable|string', ], 'hysteria' => [ 'version' => 'required|integer', @@ -60,8 +68,6 @@ class ServerSave extends FormRequest 'obfs.open' => 'nullable|boolean', 'obfs.type' => 'string|nullable', 'obfs.password' => 'string|nullable', - 'tls.server_name' => 'nullable|string', - 'tls.allow_insecure' => 'nullable|boolean', 'bandwidth.up' => 'nullable|integer', 'bandwidth.down' => 'nullable|integer', 'hop_interval' => 'integer|nullable', @@ -73,30 +79,21 @@ class ServerSave extends FormRequest 'flow' => 'nullable|string', 'encryption' => 'nullable|array', 'encryption.enabled' => 'nullable|boolean', - 'encryption.encryption' => 'nullable|string', - 'encryption.decryption' => 'nullable|string', - 'tls_settings.server_name' => 'nullable|string', - 'tls_settings.allow_insecure' => 'nullable|boolean', - 'reality_settings.allow_insecure' => 'nullable|boolean', - 'reality_settings.server_name' => 'nullable|string', - 'reality_settings.server_port' => 'nullable|integer', - 'reality_settings.public_key' => 'nullable|string', - 'reality_settings.private_key' => 'nullable|string', - 'reality_settings.short_id' => 'nullable|string', + 'encryption.encryption' => 'nullable|string', + 'encryption.decryption' => 'nullable|string', ], 'socks' => [ + 'tls' => 'nullable|integer', ], 'naive' => [ 'tls' => 'required|integer', - 'tls_settings' => 'nullable|array', ], 'http' => [ 'tls' => 'required|integer', - 'tls_settings' => 'nullable|array', ], 'mieru' => [ 'transport' => 'required|string|in:TCP,UDP', - 'traffic_pattern' => 'string' + 'traffic_pattern' => 'string', ], 'anytls' => [ 'tls' => 'nullable|array', @@ -116,6 +113,8 @@ class ServerSave extends FormRequest 'group_ids' => 'nullable|array', 'route_ids' => 'nullable|array', 'parent_id' => 'nullable|integer', + 'machine_id' => 'nullable|integer', + 'enabled' => 'nullable|boolean', 'host' => 'required', 'port' => 'required', 'server_port' => 'required', @@ -136,15 +135,91 @@ class ServerSave extends FormRequest ]; } + private function getProtocolRules(string $type): array + { + $rules = self::PROTOCOL_RULES[$type] ?? []; + + return match ($type) { + 'vmess' => array_merge( + $rules, + $this->buildTlsSettingsRules(), + self::MULTIPLEX_RULES, + self::UTLS_RULES, + ), + 'trojan' => array_merge( + $rules, + $this->buildTlsSettingsRules(includeRoot: true), + self::REALITY_RULES, + self::MULTIPLEX_RULES, + self::UTLS_RULES, + ), + 'hysteria' => array_merge( + $rules, + $this->buildTlsObjectRules(), + ), + 'tuic' => array_merge( + $rules, + $this->buildTlsObjectRules(), + ), + 'vless' => array_merge( + $rules, + $this->buildTlsSettingsRules(), + self::REALITY_RULES, + self::MULTIPLEX_RULES, + self::UTLS_RULES, + ), + 'socks', 'naive', 'http' => array_merge( + $rules, + $this->buildTlsSettingsRules(includeRoot: $type !== 'socks'), + ), + 'anytls' => array_merge( + $rules, + $this->buildTlsObjectRules(includeRoot: true), + ), + default => $rules, + }; + } + + private function buildTlsSettingsRules(bool $includeRoot = false): array + { + return array_merge( + $includeRoot ? ['tls_settings' => 'nullable|array'] : [], + [ + 'tls_settings.server_name' => 'nullable|string', + 'tls_settings.allow_insecure' => 'nullable|boolean', + 'tls_settings.ech' => 'nullable|array', + ], + $this->prefixRules('tls_settings.ech.', self::ECH_RULES), + ); + } + + private function buildTlsObjectRules(bool $includeRoot = false): array + { + return array_merge( + $includeRoot ? ['tls' => 'nullable|array'] : [], + [ + 'tls.server_name' => 'nullable|string', + 'tls.allow_insecure' => 'nullable|boolean', + 'tls.ech' => 'nullable|array', + ], + $this->prefixRules('tls.ech.', self::ECH_RULES), + ); + } + + private function prefixRules(string $prefix, array $rules): array + { + $result = []; + foreach ($rules as $field => $rule) { + $result[$prefix . $field] = $rule; + } + return $result; + } + public function rules(): array { $type = $this->input('type'); $rules = $this->getBaseRules(); - - $protocolRules = self::PROTOCOL_RULES[$type] ?? []; - if (in_array($type, ['vmess', 'vless', 'trojan', 'mieru'])) { - $protocolRules = array_merge($protocolRules, self::MULTIPLEX_RULES, self::UTLS_RULES); - } + $protocolRules = $this->getProtocolRules($type); foreach ($protocolRules as $field => $rule) { $rules['protocol_settings.' . $field] = $rule; @@ -177,6 +252,14 @@ class ServerSave extends FormRequest 'protocol_settings.multiplex.brutal.down_mbps' => 'Brutal下行速率', 'protocol_settings.utls.enabled' => 'uTLS', 'protocol_settings.utls.fingerprint' => 'uTLS指纹', + 'protocol_settings.tls_settings.ech.enabled' => 'ECH', + 'protocol_settings.tls_settings.ech.config' => 'ECH配置', + 'protocol_settings.tls_settings.ech.query_server_name' => 'ECH查询域名', + 'protocol_settings.tls_settings.ech.key' => 'ECH密钥', + 'protocol_settings.tls.ech.enabled' => 'ECH', + 'protocol_settings.tls.ech.config' => 'ECH配置', + 'protocol_settings.tls.ech.query_server_name' => 'ECH查询域名', + 'protocol_settings.tls.ech.key' => 'ECH密钥', ]; } @@ -202,6 +285,7 @@ class ServerSave extends FormRequest 'tlsSettings.array' => 'tls配置有误', 'dnsSettings.array' => 'dns配置有误', 'protocol_settings.*.required' => ':attribute 不能为空', + 'protocol_settings.*.required_if' => ':attribute 不能为空', 'protocol_settings.*.string' => ':attribute 必须是字符串', 'protocol_settings.*.integer' => ':attribute 必须是整数', 'protocol_settings.*.in' => ':attribute 的值不合法', diff --git a/app/Http/Routes/V2/AdminRoute.php b/app/Http/Routes/V2/AdminRoute.php index 3d8c910..cc402ab 100644 --- a/app/Http/Routes/V2/AdminRoute.php +++ b/app/Http/Routes/V2/AdminRoute.php @@ -6,6 +6,7 @@ use App\Http\Controllers\V2\Admin\PlanController; use App\Http\Controllers\V2\Admin\Server\GroupController; use App\Http\Controllers\V2\Admin\Server\RouteController; use App\Http\Controllers\V2\Admin\Server\ManageController; +use App\Http\Controllers\V2\Admin\Server\MachineController; use App\Http\Controllers\V2\Admin\OrderController; use App\Http\Controllers\V2\Admin\UserController; use App\Http\Controllers\V2\Admin\StatController; @@ -66,25 +67,35 @@ class AdminRoute $router->post('/save', [RouteController::class, 'save']); $router->post('/drop', [RouteController::class, 'drop']); }); + // 节点管理接口 $router->group([ 'prefix' => 'server/manage' ], function ($router) { $router->get('/getNodes', [ManageController::class, 'getNodes']); - $router->post('/sort', [ManageController::class, 'sort']); - }); - - // 节点更新接口 - $router->group([ - 'prefix' => 'server/manage' - ], function ($router) { $router->post('/update', [ManageController::class, 'update']); $router->post('/save', [ManageController::class, 'save']); $router->post('/drop', [ManageController::class, 'drop']); $router->post('/copy', [ManageController::class, 'copy']); $router->post('/sort', [ManageController::class, 'sort']); $router->post('/batchDelete', [ManageController::class, 'batchDelete']); + $router->post('/batchUpdate', [ManageController::class, 'batchUpdate']); $router->post('/resetTraffic', [ManageController::class, 'resetTraffic']); $router->post('/batchResetTraffic', [ManageController::class, 'batchResetTraffic']); + $router->get('/generateEchKey', [ManageController::class, 'generateEchKey']); + }); + + // 机器管理接口 + $router->group([ + 'prefix' => 'server/machine' + ], function ($router) { + $router->get('/fetch', [MachineController::class, 'fetch']); + $router->post('/save', [MachineController::class, 'save']); + $router->post('/drop', [MachineController::class, 'drop']); + $router->post('/resetToken', [MachineController::class, 'resetToken']); + $router->get('/getToken', [MachineController::class, 'getToken']); + $router->get('/installCommand', [MachineController::class, 'installCommand']); + $router->get('/nodes', [MachineController::class, 'nodes']); + $router->get('/history', [MachineController::class, 'history']); }); // Order diff --git a/app/Http/Routes/V2/ServerRoute.php b/app/Http/Routes/V2/ServerRoute.php index 9742d31..f278334 100644 --- a/app/Http/Routes/V2/ServerRoute.php +++ b/app/Http/Routes/V2/ServerRoute.php @@ -5,13 +5,13 @@ 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 App\Http\Controllers\V2\Server\MachineController; use Illuminate\Contracts\Routing\Registrar; class ServerRoute { public function map(Registrar $router) { - $router->group([ 'prefix' => 'server', 'middleware' => 'server' @@ -25,5 +25,12 @@ class ServerRoute $route->get('alivelist', [UniProxyController::class, 'alivelist']); $route->post('status', [UniProxyController::class, 'status']); }); + + $router->group([ + 'prefix' => 'server/machine', + ], function ($route) { + $route->post('nodes', [MachineController::class, 'nodes']); + $route->post('status', [MachineController::class, 'status']); + }); } } diff --git a/app/Models/Server.php b/app/Models/Server.php index 19e2b5e..08a6586 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -124,6 +124,7 @@ class Server extends Model 'last_check_at' => 'integer', 'last_push_at' => 'integer', 'show' => 'boolean', + 'enabled' => 'boolean', 'created_at' => 'timestamp', 'updated_at' => 'timestamp', 'rate_time_ranges' => 'array', @@ -131,6 +132,7 @@ class Server extends Model 'transfer_enable' => 'integer', 'u' => 'integer', 'd' => 'integer', + 'machine_id' => 'integer', ]; private const MULTIPLEX_CONFIGURATION = [ @@ -179,6 +181,38 @@ class Server extends Model ] ]; + private const ECH_CONFIGURATION = [ + 'ech' => [ + 'type' => 'object', + 'fields' => [ + 'enabled' => ['type' => 'boolean', 'default' => false], + 'config' => ['type' => 'string', 'default' => null], + 'query_server_name' => ['type' => 'string', 'default' => null], + 'key' => ['type' => 'string', 'default' => null], + 'key_path' => ['type' => 'string', 'default' => null], + 'config_path' => ['type' => 'string', 'default' => null], + ] + ] + ]; + + private const TLS_SETTINGS_CONFIGURATION = [ + 'type' => 'object', + 'fields' => [ + 'server_name' => ['type' => 'string', 'default' => null], + 'allow_insecure' => ['type' => 'boolean', 'default' => false], + ...self::ECH_CONFIGURATION, + ] + ]; + + private const TLS_CONFIGURATION = [ + 'type' => 'object', + 'fields' => [ + 'server_name' => ['type' => 'string', 'default' => null], + 'allow_insecure' => ['type' => 'boolean', 'default' => false], + ...self::ECH_CONFIGURATION, + ] + ]; + private const PROTOCOL_CONFIGURATIONS = [ self::TYPE_TROJAN => [ 'tls' => ['type' => 'integer', 'default' => 1], @@ -186,6 +220,7 @@ class Server extends Model 'network_settings' => ['type' => 'array', 'default' => null], 'server_name' => ['type' => 'string', 'default' => null], 'allow_insecure' => ['type' => 'boolean', 'default' => false], + 'tls_settings' => self::TLS_SETTINGS_CONFIGURATION, ...self::REALITY_CONFIGURATION, ...self::MULTIPLEX_CONFIGURATION, ...self::UTLS_CONFIGURATION @@ -195,13 +230,13 @@ class Server extends Model 'network' => ['type' => 'string', 'default' => null], 'rules' => ['type' => 'array', 'default' => null], 'network_settings' => ['type' => 'array', 'default' => null], - 'tls_settings' => ['type' => 'array', 'default' => null], + 'tls_settings' => self::TLS_SETTINGS_CONFIGURATION, ...self::MULTIPLEX_CONFIGURATION, ...self::UTLS_CONFIGURATION ], self::TYPE_VLESS => [ 'tls' => ['type' => 'integer', 'default' => 0], - 'tls_settings' => ['type' => 'array', 'default' => null], + 'tls_settings' => self::TLS_SETTINGS_CONFIGURATION, 'flow' => ['type' => 'string', 'default' => null], 'encryption' => [ 'type' => 'object', @@ -242,13 +277,7 @@ class Server extends Model 'password' => ['type' => 'string', 'default' => null] ] ], - 'tls' => [ - 'type' => 'object', - 'fields' => [ - 'server_name' => ['type' => 'string', 'default' => null], - 'allow_insecure' => ['type' => 'boolean', 'default' => false] - ] - ], + 'tls' => self::TLS_CONFIGURATION, 'hop_interval' => ['type' => 'integer', 'default' => null] ], self::TYPE_TUIC => [ @@ -256,13 +285,7 @@ class Server extends Model 'congestion_control' => ['type' => 'string', 'default' => 'cubic'], 'alpn' => ['type' => 'array', 'default' => ['h3']], 'udp_relay_mode' => ['type' => 'string', 'default' => 'native'], - 'tls' => [ - 'type' => 'object', - 'fields' => [ - 'server_name' => ['type' => 'string', 'default' => null], - 'allow_insecure' => ['type' => 'boolean', 'default' => false] - ] - ] + 'tls' => self::TLS_CONFIGURATION ], self::TYPE_ANYTLS => [ 'padding_scheme' => [ @@ -279,36 +302,19 @@ class Server extends Model "7=500-1000" ] ], - 'tls' => [ - 'type' => 'object', - 'fields' => [ - 'server_name' => ['type' => 'string', 'default' => null], - 'allow_insecure' => ['type' => 'boolean', 'default' => false] - ] - ] + 'tls' => self::TLS_CONFIGURATION ], self::TYPE_SOCKS => [ 'tls' => ['type' => 'integer', 'default' => 0], - 'tls_settings' => [ - 'type' => 'object', - 'fields' => [ - 'allow_insecure' => ['type' => 'boolean', 'default' => false] - ] - ] + 'tls_settings' => self::TLS_SETTINGS_CONFIGURATION ], self::TYPE_NAIVE => [ 'tls' => ['type' => 'integer', 'default' => 0], - 'tls_settings' => ['type' => 'array', 'default' => null] + 'tls_settings' => self::TLS_SETTINGS_CONFIGURATION ], self::TYPE_HTTP => [ 'tls' => ['type' => 'integer', 'default' => 0], - 'tls_settings' => [ - 'type' => 'object', - 'fields' => [ - 'allow_insecure' => ['type' => 'boolean', 'default' => false], - 'server_name' => ['type' => 'string', 'default' => null] - ] - ] + 'tls_settings' => self::TLS_SETTINGS_CONFIGURATION ], self::TYPE_MIERU => [ 'transport' => ['type' => 'string', 'default' => 'TCP'], @@ -416,6 +422,11 @@ class Server extends Model return $this->hasMany(StatServer::class, 'server_id', 'id'); } + public function machine(): \Illuminate\Database\Eloquent\Relations\BelongsTo + { + return $this->belongsTo(ServerMachine::class, 'machine_id'); + } + public function groups() { return ServerGroup::whereIn('id', $this->group_ids)->get(); diff --git a/app/Models/ServerMachine.php b/app/Models/ServerMachine.php new file mode 100644 index 0000000..9e8254f --- /dev/null +++ b/app/Models/ServerMachine.php @@ -0,0 +1,65 @@ + $servers 关联的节点 + */ +class ServerMachine extends Model +{ + protected $table = 'v2_server_machine'; + + protected $guarded = ['id']; + + protected $casts = [ + 'is_active' => 'boolean', + 'last_seen_at' => 'integer', + 'load_status' => 'array', + 'created_at' => 'timestamp', + 'updated_at' => 'timestamp', + ]; + + protected $hidden = ['token']; + + public function servers(): HasMany + { + return $this->hasMany(Server::class, 'machine_id'); + } + + public function loadHistory(): HasMany + { + return $this->hasMany(ServerMachineLoadHistory::class, 'machine_id'); + } + + /** + * 生成新的随机 Token + */ + public static function generateToken(): string + { + return Str::random(32); + } + + /** + * 更新最后心跳时间 + */ + public function updateHeartbeat(): bool + { + return $this->forceFill(['last_seen_at' => now()->timestamp])->save(); + } +} diff --git a/app/Models/ServerMachineLoadHistory.php b/app/Models/ServerMachineLoadHistory.php new file mode 100644 index 0000000..2e639a0 --- /dev/null +++ b/app/Models/ServerMachineLoadHistory.php @@ -0,0 +1,29 @@ + 'float', + 'mem_total' => 'integer', + 'mem_used' => 'integer', + 'disk_total' => 'integer', + 'disk_used' => 'integer', + 'recorded_at' => 'integer', + 'created_at' => 'timestamp', + 'updated_at' => 'timestamp', + ]; + + public function machine(): BelongsTo + { + return $this->belongsTo(ServerMachine::class, 'machine_id'); + } +} diff --git a/app/Observers/ServerObserver.php b/app/Observers/ServerObserver.php index 0f1ce9d..f134ba5 100644 --- a/app/Observers/ServerObserver.php +++ b/app/Observers/ServerObserver.php @@ -7,31 +7,60 @@ use App\Services\NodeSyncService; class ServerObserver { + public bool $afterCommit = true; + + public function created(Server $server): void + { + $this->notifyMachineNodesChanged($server->machine_id); + } + 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', - ]) - ) { + if ($server->wasChanged('group_ids')) { + NodeSyncService::notifyFullSync($server->id); + } elseif ($server->wasChanged([ + 'server_port', + 'protocol_settings', + 'type', + 'route_ids', + 'custom_outbounds', + 'custom_routes', + 'cert_config', + ])) { NodeSyncService::notifyConfigUpdated($server->id); } + + if ($server->wasChanged(['machine_id', 'enabled'])) { + $this->notifyMachineChange( + $server->machine_id, + $server->getOriginal('machine_id') + ); + } } public function deleted(Server $server): void { - NodeSyncService::notifyConfigUpdated($server->id); + $this->notifyMachineChange(null, $server->getOriginal('machine_id') ?: $server->machine_id); + } + + private function notifyMachineChange(?int $newMachineId, ?int $oldMachineId): void + { + $notified = []; + + if ($newMachineId) { + NodeSyncService::notifyMachineNodesChanged($newMachineId); + $notified[] = $newMachineId; + } + + if ($oldMachineId && !in_array($oldMachineId, $notified, true)) { + NodeSyncService::notifyMachineNodesChanged($oldMachineId); + } + } + + private function notifyMachineNodesChanged(?int $machineId): void + { + if ($machineId) { + NodeSyncService::notifyMachineNodesChanged($machineId); + } } } diff --git a/app/Observers/UserObserver.php b/app/Observers/UserObserver.php index ffa74d0..975d87c 100644 --- a/app/Observers/UserObserver.php +++ b/app/Observers/UserObserver.php @@ -8,6 +8,8 @@ use App\Services\TrafficResetService; class UserObserver { + public bool $afterCommit = true; + public function __construct( private readonly TrafficResetService $trafficResetService ) { @@ -15,12 +17,17 @@ class UserObserver public function updated(User $user): void { - if ($user->isDirty(['plan_id', 'expired_at'])) { + // With $afterCommit = true, isDirty() is always false after commit. + // Use wasChanged() to detect what was actually modified. + $syncFields = ['group_id', 'uuid', 'speed_limit', 'device_limit', 'banned', 'expired_at', 'transfer_enable', 'u', 'd', 'plan_id']; + $needsSync = $user->wasChanged($syncFields); + $oldGroupId = $user->wasChanged('group_id') ? $user->getOriginal('group_id') : null; + + if ($user->wasChanged(['plan_id', 'expired_at'])) { $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; + if ($needsSync) { NodeUserSyncJob::dispatch($user->id, 'updated', $oldGroupId); } } diff --git a/app/Protocols/ClashMeta.php b/app/Protocols/ClashMeta.php index feda7d0..48434ae 100644 --- a/app/Protocols/ClashMeta.php +++ b/app/Protocols/ClashMeta.php @@ -58,6 +58,66 @@ class ClashMeta extends AbstractProtocol 'flclash.hysteria.protocol_settings.version' => [ 2 => '0.8.0', ], + 'meta.vmess.protocol_settings.tls_settings.ech.enabled' => [ + 1 => '1.19.9', + ], + 'meta.vless.protocol_settings.tls_settings.ech.enabled' => [ + 1 => '1.19.9', + ], + 'meta.trojan.protocol_settings.tls_settings.ech.enabled' => [ + 1 => '1.19.9', + ], + 'meta.anytls.protocol_settings.tls.ech.enabled' => [ + 1 => '1.19.9', + ], + 'verge.vmess.protocol_settings.tls_settings.ech.enabled' => [ + 1 => '1.19.9', + ], + 'verge.vless.protocol_settings.tls_settings.ech.enabled' => [ + 1 => '1.19.9', + ], + 'verge.trojan.protocol_settings.tls_settings.ech.enabled' => [ + 1 => '1.19.9', + ], + 'verge.anytls.protocol_settings.tls.ech.enabled' => [ + 1 => '1.19.9', + ], + 'flclash.vmess.protocol_settings.tls_settings.ech.enabled' => [ + 1 => '1.19.9', + ], + 'flclash.vless.protocol_settings.tls_settings.ech.enabled' => [ + 1 => '1.19.9', + ], + 'flclash.trojan.protocol_settings.tls_settings.ech.enabled' => [ + 1 => '1.19.9', + ], + 'flclash.anytls.protocol_settings.tls.ech.enabled' => [ + 1 => '1.19.9', + ], + 'nekobox.vmess.protocol_settings.tls_settings.ech.enabled' => [ + 1 => '1.19.9', + ], + 'nekobox.vless.protocol_settings.tls_settings.ech.enabled' => [ + 1 => '1.19.9', + ], + 'nekobox.trojan.protocol_settings.tls_settings.ech.enabled' => [ + 1 => '1.19.9', + ], + 'nekobox.anytls.protocol_settings.tls.ech.enabled' => [ + 1 => '1.19.9', + ], + 'clashmetaforandroid.vmess.protocol_settings.tls_settings.ech.enabled' => [ + 1 => '1.19.9', + ], + 'clashmetaforandroid.vless.protocol_settings.tls_settings.ech.enabled' => [ + 1 => '1.19.9', + ], + 'clashmetaforandroid.trojan.protocol_settings.tls_settings.ech.enabled' => [ + 1 => '1.19.9', + ], + 'clashmetaforandroid.anytls.protocol_settings.tls.ech.enabled' => [ + 1 => '1.19.9', + ], ]; public function handle() @@ -264,6 +324,7 @@ class ClashMeta extends AbstractProtocol $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'); + self::appendEch($array, data_get($protocol_settings, 'tls_settings.ech')); } self::appendUtls($array, $protocol_settings); @@ -346,6 +407,7 @@ class ClashMeta extends AbstractProtocol if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) { $array['servername'] = $serverName; } + self::appendEch($array, data_get($protocol_settings, 'tls_settings.ech')); self::appendUtls($array, $protocol_settings); break; case 2: @@ -440,10 +502,11 @@ class ClashMeta extends AbstractProtocol ]; break; default: // Standard TLS - $array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'allow_insecure', false); - if ($serverName = data_get($protocol_settings, 'server_name')) { + $array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'tls_settings.allow_insecure', data_get($protocol_settings, 'allow_insecure', false)); + if ($serverName = data_get($protocol_settings, 'tls_settings.server_name', data_get($protocol_settings, 'server_name'))) { $array['sni'] = $serverName; } + self::appendEch($array, data_get($protocol_settings, 'tls_settings.ech')); break; } @@ -584,6 +647,7 @@ class ClashMeta extends AbstractProtocol if ($allowInsecure = data_get($protocol_settings, 'tls.allow_insecure')) { $array['skip-cert-verify'] = (bool) $allowInsecure; } + self::appendEch($array, data_get($protocol_settings, 'tls.ech')); return $array; } @@ -705,4 +769,15 @@ class ClashMeta extends AbstractProtocol } } } + + protected static function appendEch(&$array, $ech): void + { + if ($normalized = Helper::normalizeEchSettings($ech)) { + $array['ech-opts'] = array_filter([ + 'enable' => true, + 'config' => Helper::toMihomoEchConfig(data_get($normalized, 'config')), + 'query-server-name' => data_get($normalized, 'query_server_name'), + ], fn($value) => $value !== null); + } + } } \ No newline at end of file diff --git a/app/Protocols/SingBox.php b/app/Protocols/SingBox.php index b412787..44b022e 100644 --- a/app/Protocols/SingBox.php +++ b/app/Protocols/SingBox.php @@ -37,16 +37,35 @@ class SingBox extends AbstractProtocol ], 'protocol_settings.tls' => [ '2' => '1.6.0' // Reality + ], + 'protocol_settings.tls_settings.ech.enabled' => [ + 1 => '1.5.0' + ] + ], + 'vmess' => [ + 'protocol_settings.tls_settings.ech.enabled' => [ + 1 => '1.5.0' + ] + ], + 'trojan' => [ + 'protocol_settings.tls_settings.ech.enabled' => [ + 1 => '1.5.0' ] ], 'hysteria' => [ 'base_version' => '1.5.0', 'protocol_settings.version' => [ '2' => '1.5.0' // Hysteria 2 + ], + 'protocol_settings.tls.ech.enabled' => [ + 1 => '1.5.0' ] ], 'tuic' => [ - 'base_version' => '1.5.0' + 'base_version' => '1.5.0', + 'protocol_settings.tls.ech.enabled' => [ + 1 => '1.5.0' + ] ], 'ssh' => [ 'base_version' => '1.8.0' @@ -58,7 +77,25 @@ class SingBox extends AbstractProtocol 'base_version' => '1.5.0' ], 'anytls' => [ - 'base_version' => '1.12.0' + 'base_version' => '1.12.0', + 'protocol_settings.tls.ech.enabled' => [ + 1 => '1.12.0' + ] + ], + 'socks' => [ + 'protocol_settings.tls_settings.ech.enabled' => [ + 1 => '1.5.0' + ] + ], + 'naive' => [ + 'protocol_settings.tls_settings.ech.enabled' => [ + 1 => '1.5.0' + ] + ], + 'http' => [ + 'protocol_settings.tls_settings.ech.enabled' => [ + 1 => '1.5.0' + ] ], ] ]; @@ -405,6 +442,7 @@ class SingBox extends AbstractProtocol ]; $this->appendUtls($array['tls'], $protocol_settings); + $this->appendEch($array['tls'], data_get($protocol_settings, 'tls_settings.ech')); if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) { $array['tls']['server_name'] = $serverName; @@ -447,6 +485,7 @@ class SingBox extends AbstractProtocol switch ($tlsMode) { case 1: + $this->appendEch($tlsConfig, data_get($protocol_settings, 'tls_settings.ech')); if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) { $tlsConfig['server_name'] = $serverName; } @@ -498,8 +537,9 @@ class SingBox extends AbstractProtocol ]; break; default: // Standard TLS - $tlsConfig['insecure'] = (bool) data_get($protocol_settings, 'allow_insecure', false); - if ($serverName = data_get($protocol_settings, 'server_name')) { + $tlsConfig['insecure'] = (bool) data_get($protocol_settings, 'tls_settings.allow_insecure', data_get($protocol_settings, 'allow_insecure', false)); + $this->appendEch($tlsConfig, data_get($protocol_settings, 'tls_settings.ech')); + if ($serverName = data_get($protocol_settings, 'tls_settings.server_name', data_get($protocol_settings, 'server_name'))) { $tlsConfig['server_name'] = $serverName; } break; @@ -541,6 +581,7 @@ class SingBox extends AbstractProtocol if ($serverName = data_get($protocol_settings, 'tls.server_name')) { $baseConfig['tls']['server_name'] = $serverName; } + $this->appendEch($baseConfig['tls'], data_get($protocol_settings, 'tls.ech')); $speedConfig = [ 'up_mbps' => data_get($protocol_settings, 'bandwidth.up'), 'down_mbps' => data_get($protocol_settings, 'bandwidth.down'), @@ -590,6 +631,7 @@ class SingBox extends AbstractProtocol if ($serverName = data_get($protocol_settings, 'tls.server_name')) { $array['tls']['server_name'] = $serverName; } + $this->appendEch($array['tls'], data_get($protocol_settings, 'tls.ech')); if (data_get($protocol_settings, 'version') === 4) { $array['token'] = $password; @@ -620,6 +662,7 @@ class SingBox extends AbstractProtocol if ($serverName = data_get($protocol_settings, 'tls.server_name')) { $array['tls']['server_name'] = $serverName; } + $this->appendEch($array['tls'], data_get($protocol_settings, 'tls.ech')); return $array; } @@ -673,6 +716,7 @@ class SingBox extends AbstractProtocol if ($serverName = data_get($protocol_settings, 'tls_settings.server_name')) { $array['tls']['server_name'] = $serverName; } + $this->appendEch($array['tls'], data_get($protocol_settings, 'tls_settings.ech')); } return $array; @@ -754,4 +798,16 @@ class SingBox extends AbstractProtocol } } } + + protected function appendEch(&$tlsConfig, $ech): void + { + if ($normalized = Helper::normalizeEchSettings($ech)) { + // Client outbound only needs the public ECH config, not the server's private key + $tlsConfig['ech'] = array_filter([ + 'enabled' => true, + 'config' => data_get($normalized, 'config') ? [data_get($normalized, 'config')] : null, + 'query_server_name' => data_get($normalized, 'query_server_name'), + ], fn($value) => $value !== null); + } + } } diff --git a/app/Services/NodeRegistry.php b/app/Services/NodeRegistry.php index c0f7d13..9700194 100644 --- a/app/Services/NodeRegistry.php +++ b/app/Services/NodeRegistry.php @@ -13,25 +13,55 @@ class NodeRegistry /** @var array nodeId → connection */ private static array $connections = []; + /** @var array machineId → connection */ + private static array $machineConnections = []; + public static function add(int $nodeId, TcpConnection $conn): void { - // Close existing connection for this node (if reconnecting) - if (isset(self::$connections[$nodeId])) { + if (isset(self::$connections[$nodeId]) && self::$connections[$nodeId] !== $conn) { self::$connections[$nodeId]->close(); } self::$connections[$nodeId] = $conn; } - public static function remove(int $nodeId): void + public static function addMachine(int $machineId, TcpConnection $conn): void { + if (isset(self::$machineConnections[$machineId]) && self::$machineConnections[$machineId] !== $conn) { + self::$machineConnections[$machineId]->close(); + } + self::$machineConnections[$machineId] = $conn; + } + + /** + * Remove a node mapping only if it still points to the given connection. + * Passing null removes unconditionally (backward compat for single-node mode). + */ + public static function remove(int $nodeId, ?TcpConnection $conn = null): void + { + if ($conn !== null && isset(self::$connections[$nodeId]) && self::$connections[$nodeId] !== $conn) { + return; // already replaced by a newer connection + } unset(self::$connections[$nodeId]); } + public static function removeMachine(int $machineId, ?TcpConnection $conn = null): void + { + if ($conn !== null && isset(self::$machineConnections[$machineId]) && self::$machineConnections[$machineId] !== $conn) { + return; + } + unset(self::$machineConnections[$machineId]); + } + public static function get(int $nodeId): ?TcpConnection { return self::$connections[$nodeId] ?? null; } + public static function getMachine(int $machineId): ?TcpConnection + { + return self::$machineConnections[$machineId] ?? null; + } + /** * Send a JSON message to a specific node. */ @@ -42,6 +72,55 @@ class NodeRegistry return false; } + // Machine-mode connections multiplex multiple node IDs through the same + // socket, so node-scoped events must carry node_id for the client mux. + if (!empty($conn->machineNodeIds) && $event !== 'sync.nodes' && !array_key_exists('node_id', $data)) { + $data['node_id'] = $nodeId; + } + + $payload = json_encode([ + 'event' => $event, + 'data' => $data, + 'timestamp' => time(), + ]); + + $conn->send($payload); + return true; + } + + /** + * Update in-memory registry when a machine's node set changes. + * Called from the WS process when a sync.nodes event is dispatched. + */ + public static function refreshMachineNodes(int $machineId, array $newNodeIds): void + { + $conn = self::getMachine($machineId); + if (!$conn) { + return; + } + + $oldNodeIds = $conn->machineNodeIds ?? []; + + // Remove nodes no longer on this machine + foreach (array_diff($oldNodeIds, $newNodeIds) as $removedId) { + self::remove($removedId, $conn); + } + + // Add newly assigned nodes (via add() to close any stale standalone connection) + foreach ($newNodeIds as $nodeId) { + self::add($nodeId, $conn); + } + + $conn->machineNodeIds = $newNodeIds; + } + + public static function sendMachine(int $machineId, string $event, array $data): bool + { + $conn = self::getMachine($machineId); + if (!$conn) { + return false; + } + $payload = json_encode([ 'event' => $event, 'data' => $data, @@ -74,4 +153,18 @@ class NodeRegistry { return count(self::$connections); } + + /** + * @return int[] + */ + public static function getConnectedMachineIds(): array + { + return array_keys(self::$machineConnections); + } + + public static function machineCount(): int + { + return count(self::$machineConnections); + } } + diff --git a/app/Services/NodeSyncService.php b/app/Services/NodeSyncService.php index ebab769..6d714e1 100644 --- a/app/Services/NodeSyncService.php +++ b/app/Services/NodeSyncService.php @@ -3,6 +3,7 @@ namespace App\Services; use App\Models\Server; +use App\Models\ServerMachine; use App\Models\User; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; @@ -30,7 +31,6 @@ class NodeSyncService if (!$node) return; - self::push($nodeId, 'sync.config', ['config' => ServerService::buildNodeConfig($node)]); } @@ -122,6 +122,28 @@ class NodeSyncService self::push($nodeId, 'sync.users', ['users' => $users]); } + /** + * Notify machine that its node set has changed. + * Always publishes via Redis so the WS process can update its in-memory registry. + */ + public static function notifyMachineNodesChanged(int $machineId): void + { + $machine = ServerMachine::find($machineId); + + $nodeList = []; + if ($machine) { + $nodes = ServerService::getMachineNodes($machine); + $nodeList = $nodes->map(fn($n) => [ + 'id' => $n->id, + 'type' => $n->type, + 'name' => $n->name, + ])->values()->toArray(); + } + + // Always publish via Redis so the WS process can update its in-memory registry + self::pushMachine($machineId, 'sync.nodes', ['nodes' => $nodeList]); + } + /** * Publish a push command to Redis — picked up by the Workerman WS server */ @@ -140,4 +162,23 @@ class NodeSyncService ]); } } + + /** + * Publish a machine-level push command to Redis — picked up by the Workerman WS server + */ + public static function pushMachine(int $machineId, string $event, array $data): void + { + try { + Redis::publish('node:push', json_encode([ + 'machine_id' => $machineId, + 'event' => $event, + 'data' => $data, + ])); + } catch (\Throwable $e) { + Log::warning("[NodePush] Redis machine publish failed: {$e->getMessage()}", [ + 'machine_id' => $machineId, + 'event' => $event, + ]); + } + } } diff --git a/app/Services/ServerService.php b/app/Services/ServerService.php index 0d12766..f25d19d 100644 --- a/app/Services/ServerService.php +++ b/app/Services/ServerService.php @@ -3,10 +3,13 @@ namespace App\Services; use App\Models\Server; +use App\Models\ServerMachine; use App\Models\ServerRoute; use App\Models\User; use App\Services\Plugin\HookManager; +use App\Utils\CacheKey; use App\Utils\Helper; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Collection; class ServerService @@ -33,6 +36,17 @@ class ServerService ]); } + /** + * 获取机器下所有已启用节点 + */ + public static function getMachineNodes(ServerMachine $machine): Collection + { + return Server::where('machine_id', $machine->id) + ->where('enabled', true) + ->orderBy('sort', 'ASC') + ->get(); + } + /** * 获取指定用户可用的服务器列表 * @param User $user @@ -75,8 +89,12 @@ class ServerService */ public static function getAvailableUsers(Server $node) { + $groupIds = $node->group_ids ?? []; + if (empty($groupIds)) { + return collect(); + } $users = User::toBase() - ->whereIn('group_id', $node->group_ids) + ->whereIn('group_id', $groupIds) ->whereRaw('u + d < transfer_enable') ->where(function ($query) { $query->where('expired_at', '>=', time()) @@ -100,6 +118,100 @@ class ServerService return $routes; } + /** + * 处理节点流量数据汇报 + */ + public static function processTraffic(Server $node, array $traffic): void + { + $data = array_filter($traffic, fn($item) => + is_array($item) && count($item) === 2 + && is_numeric($item[0]) && is_numeric($item[1]) + ); + + if (empty($data)) { + return; + } + + $nodeType = strtoupper($node->type); + $nodeId = $node->id; + + Cache::put(CacheKey::get("SERVER_{$nodeType}_ONLINE_USER", $nodeId), count($data), 3600); + Cache::put(CacheKey::get("SERVER_{$nodeType}_LAST_PUSH_AT", $nodeId), time(), 3600); + + (new UserService())->trafficFetch($node, $node->type, $data); + } + + /** + * 处理节点在线设备汇报 + */ + public static function processAlive(int $nodeId, array $alive): void + { + $service = app(DeviceStateService::class); + foreach ($alive as $uid => $ips) { + $service->setDevices((int) $uid, $nodeId, (array) $ips); + } + } + + /** + * 处理节点连接数汇报 + */ + public static function processOnline(Server $node, array $online): void + { + $cacheTime = max(300, (int) admin_setting('server_push_interval', 60) * 3); + $nodeType = $node->type; + $nodeId = $node->id; + + foreach ($online as $uid => $conn) { + $cacheKey = CacheKey::get("USER_ONLINE_CONN_{$nodeType}_{$nodeId}", $uid); + Cache::put($cacheKey, (int) $conn, $cacheTime); + } + } + + /** + * 处理节点负载状态汇报 + */ + public static function processStatus(Server $node, array $status): void + { + $nodeType = strtoupper($node->type); + $nodeId = $node->id; + + $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, + 'kernel_status' => $status['kernel_status'] ?? null, + ]; + + $cacheTime = max(300, (int) admin_setting('server_push_interval', 60) * 3); + cache([ + CacheKey::get("SERVER_{$nodeType}_LOAD_STATUS", $nodeId) => $statusData, + CacheKey::get("SERVER_{$nodeType}_LAST_LOAD_AT", $nodeId) => now()->timestamp, + ], $cacheTime); + } + + /** + * 标记节点心跳 + */ + public static function touchNode(Server $node): void + { + Cache::put( + CacheKey::get('SERVER_' . strtoupper($node->type) . '_LAST_CHECK_AT', $node->id), + time(), + 3600 + ); + } + /** * Update node metrics and load status */ @@ -129,8 +241,8 @@ class ServerService 'kernel_status' => (bool) ($metrics['kernel_status'] ?? false), ]; - \Illuminate\Support\Facades\Cache::put( - \App\Utils\CacheKey::get('SERVER_' . $nodeType . '_METRICS', $nodeId), + Cache::put( + CacheKey::get('SERVER_' . $nodeType . '_METRICS', $nodeId), $metricsData, $cacheTime ); @@ -166,17 +278,21 @@ class ServerService 'vmess' => [ ...$baseConfig, 'tls' => (int) $protocolSettings['tls'], + 'tls_settings' => $protocolSettings['tls_settings'], 'multiplex' => data_get($protocolSettings, 'multiplex'), ], 'trojan' => [ ...$baseConfig, 'host' => $host, - 'server_name' => $protocolSettings['server_name'], + 'server_name' => data_get($protocolSettings, 'tls_settings.server_name') ?? $protocolSettings['server_name'], 'multiplex' => data_get($protocolSettings, 'multiplex'), 'tls' => (int) $protocolSettings['tls'], 'tls_settings' => match ((int) $protocolSettings['tls']) { 2 => $protocolSettings['reality_settings'], - default => null, + default => array_merge($protocolSettings['tls_settings'] ?? [], [ + 'server_name' => data_get($protocolSettings, 'tls_settings.server_name') ?? $protocolSettings['server_name'], + 'allow_insecure' => data_get($protocolSettings, 'tls_settings.allow_insecure', $protocolSettings['allow_insecure']), + ]), }, ], 'vless' => [ @@ -199,6 +315,7 @@ class ServerService 'version' => (int) $protocolSettings['version'], 'host' => $host, 'server_name' => $protocolSettings['tls']['server_name'], + 'tls_settings' => $protocolSettings['tls'], 'up_mbps' => (int) $protocolSettings['bandwidth']['up'], 'down_mbps' => (int) $protocolSettings['bandwidth']['down'], ...match ((int) $protocolSettings['version']) { @@ -216,7 +333,7 @@ class ServerService 'server_port' => (int) $serverPort, 'server_name' => $protocolSettings['tls']['server_name'], 'congestion_control' => $protocolSettings['congestion_control'], - 'tls_settings' => data_get($protocolSettings, 'tls_settings'), + 'tls_settings' => $protocolSettings['tls'], 'auth_timeout' => '3s', 'zero_rtt_handshake' => false, 'heartbeat' => '3s', @@ -225,11 +342,14 @@ class ServerService ...$baseConfig, 'server_port' => (int) $serverPort, 'server_name' => $protocolSettings['tls']['server_name'], + 'tls_settings' => $protocolSettings['tls'], 'padding_scheme' => $protocolSettings['padding_scheme'], ], 'socks' => [ ...$baseConfig, 'server_port' => (int) $serverPort, + 'tls' => (int) data_get($protocolSettings, 'tls', 0), + 'tls_settings' => data_get($protocolSettings, 'tls_settings'), ], 'naive' => [ ...$baseConfig, @@ -248,16 +368,10 @@ class ServerService 'server_port' => (int) $serverPort, 'transport' => data_get($protocolSettings, 'transport', 'TCP'), 'traffic_pattern' => $protocolSettings['traffic_pattern'], - // 'multiplex' => data_get($protocolSettings, 'multiplex'), ], default => [], }; - // $response = array_filter( - // $response, - // static fn ($value) => $value !== null - // ); - if (!empty($node['route_ids'])) { $response['routes'] = self::getRoutes($node['route_ids']); } @@ -270,8 +384,16 @@ class ServerService $response['custom_routes'] = $node['custom_routes']; } - if (!empty($node['cert_config']) && data_get($node['cert_config'],'cert_mode') !== 'none' ) { - $response['cert_config'] = $node['cert_config']; + if (!empty($node['cert_config'])) { + $certConfig = $node['cert_config']; + // Normalize: accept both "mode" and "cert_mode" from the database + if (isset($certConfig['mode']) && !isset($certConfig['cert_mode'])) { + $certConfig['cert_mode'] = $certConfig['mode']; + unset($certConfig['mode']); + } + if (data_get($certConfig, 'cert_mode') !== 'none') { + $response['cert_config'] = $certConfig; + } } return $response; diff --git a/app/Utils/Helper.php b/app/Utils/Helper.php index f815151..82e8771 100644 --- a/app/Utils/Helper.php +++ b/app/Utils/Helper.php @@ -207,6 +207,53 @@ class Helper return Arr::random($fingerprints); } + public static function normalizeEchSettings($ech = null): ?array + { + if (!is_array($ech) && !is_object($ech)) { + return null; + } + + if (!data_get($ech, 'enabled')) { + return null; + } + + return array_filter([ + 'enabled' => true, + 'config' => self::trimToNull(data_get($ech, 'config')), + 'query_server_name' => self::trimToNull(data_get($ech, 'query_server_name')), + 'key' => self::trimToNull(data_get($ech, 'key')), + 'key_path' => self::trimToNull(data_get($ech, 'key_path')), + 'config_path' => self::trimToNull(data_get($ech, 'config_path')), + ], static fn($value) => $value !== null); + } + + public static function toMihomoEchConfig(?string $config): ?string + { + $config = self::trimToNull($config); + if (!$config) { + return null; + } + + if (str_starts_with($config, '-----BEGIN')) { + if (preg_match('/-----BEGIN ECH CONFIGS-----\s*(.*?)\s*-----END ECH CONFIGS-----/s', $config, $matches)) { + return preg_replace('/\s+/', '', $matches[1]); + } + return null; + } + + return preg_replace('/\s+/', '', $config); + } + + public static function trimToNull($value): ?string + { + if (!is_string($value)) { + return null; + } + + $value = trim($value); + return $value === '' ? null : $value; + } + public static function encodeURIComponent($str) { $revert = array('%21'=>'!', '%2A'=>'*', '%27'=>"'", '%28'=>'(', '%29'=>')'); return strtr(rawurlencode($str), $revert); diff --git a/app/WebSocket/NodeEventHandlers.php b/app/WebSocket/NodeEventHandlers.php index 75521f8..7dd4ae9 100644 --- a/app/WebSocket/NodeEventHandlers.php +++ b/app/WebSocket/NodeEventHandlers.php @@ -45,6 +45,10 @@ class NodeEventHandlers { $service = app(DeviceStateService::class); + if (isset($data['devices']) && is_array($data['devices'])) { + $data = $data['devices']; + } + // Get old data $oldDevices = $service->getNodeDevices($nodeId); @@ -89,10 +93,9 @@ class NodeEventHandlers $service = app(DeviceStateService::class); $devices = $service->getUsersDevices($userIds); - $conn->send(json_encode([ - 'event' => 'sync.devices', - 'data' => ['users' => $devices], - ])); + NodeRegistry::send($nodeId, 'sync.devices', [ + 'users' => $devices, + ]); Log::debug("[WS] Node#{$nodeId} requested devices, sent " . count($devices) . " users"); } @@ -121,21 +124,19 @@ class NodeEventHandlers */ public static function pushFullSync(TcpConnection $conn, Server $node): void { - $nodeId = $conn->nodeId; + $nodeId = (int) $node->id; // Push config $config = ServerService::buildNodeConfig($node); - $conn->send(json_encode([ - 'event' => 'sync.config', - 'data' => ['config' => $config] - ])); + NodeRegistry::send($nodeId, 'sync.config', [ + 'config' => $config, + ]); // Push users $users = ServerService::getAvailableUsers($node)->toArray(); - $conn->send(json_encode([ - 'event' => 'sync.users', - 'data' => ['users' => $users] - ])); + NodeRegistry::send($nodeId, 'sync.users', [ + 'users' => $users, + ]); Log::info("[WS] Full sync pushed to node#{$nodeId}", [ 'users' => count($users), diff --git a/app/WebSocket/NodeWorker.php b/app/WebSocket/NodeWorker.php index d45dd22..16586be 100644 --- a/app/WebSocket/NodeWorker.php +++ b/app/WebSocket/NodeWorker.php @@ -3,6 +3,7 @@ namespace App\WebSocket; use App\Models\Server; +use App\Models\ServerMachine; use App\Services\DeviceStateService; use App\Services\NodeRegistry; use App\Services\ServerService; @@ -69,17 +70,32 @@ class NodeWorker private function setupTimers(): void { - // Ping timer Timer::add(self::PING_INTERVAL, function () { + $seen = []; + foreach (NodeRegistry::getConnectedNodeIds() as $nodeId) { $conn = NodeRegistry::get($nodeId); if ($conn) { - $conn->send(json_encode(['event' => 'ping'])); + $oid = spl_object_id($conn); + if (!isset($seen[$oid])) { + $seen[$oid] = true; + $conn->send(json_encode(['event' => 'ping'])); + } + } + } + + foreach (NodeRegistry::getConnectedMachineIds() as $machineId) { + $conn = NodeRegistry::getMachine($machineId); + if ($conn) { + $oid = spl_object_id($conn); + if (!isset($seen[$oid])) { + $seen[$oid] = true; + $conn->send(json_encode(['event' => 'ping'])); + } } } }); - // Device state push timer Timer::add(10, function () { $pendingNodeIds = Redis::spop('device:push_pending_nodes', 100); if (empty($pendingNodeIds)) { @@ -99,7 +115,7 @@ class NodeWorker public function onConnect(TcpConnection $conn): void { $conn->authTimer = Timer::add(self::AUTH_TIMEOUT, function () use ($conn) { - if (empty($conn->nodeId)) { + if (empty($conn->nodeId) && empty($conn->machineNodeIds)) { $conn->close(json_encode([ 'event' => 'error', 'data' => ['message' => 'auth timeout'], @@ -118,10 +134,27 @@ class NodeWorker } parse_str($queryString, $params); + + if (isset($conn->authTimer)) { + Timer::del($conn->authTimer); + } + + // 判断认证模式 + if (!empty($params['machine_id'])) { + $this->authenticateMachine($conn, $params); + } else { + $this->authenticateNode($conn, $params); + } + } + + /** + * 旧模式:单节点认证 + */ + private function authenticateNode(TcpConnection $conn, array $params): void + { $token = $params['token'] ?? ''; $nodeId = (int) ($params['node_id'] ?? 0); - // Authenticate $serverToken = admin_setting('server_token', ''); if ($token === '' || $serverToken === '' || !hash_equals($serverToken, $token)) { $conn->close(json_encode([ @@ -140,16 +173,10 @@ class NodeWorker return; } - // Auth passed - if (isset($conn->authTimer)) { - Timer::del($conn->authTimer); - } - $conn->nodeId = $nodeId; NodeRegistry::add($nodeId, $conn); Cache::put("node_ws_alive:{$nodeId}", true, 86400); - // Clear old device data app(DeviceStateService::class)->clearAllNodeDevices($nodeId); Log::debug("[WS] Node#{$nodeId} connected", [ @@ -157,16 +184,73 @@ class NodeWorker 'total' => NodeRegistry::count(), ]); - // Send auth success $conn->send(json_encode([ 'event' => 'auth.success', 'data' => ['node_id' => $nodeId], ])); - // Push full sync NodeEventHandlers::pushFullSync($conn, $node); } + /** + * 新模式:机器认证,自动注册该机器下所有已启用节点 + */ + private function authenticateMachine(TcpConnection $conn, array $params): void + { + $machineId = (int) ($params['machine_id'] ?? 0); + $token = $params['token'] ?? ''; + + $machine = ServerMachine::where('id', $machineId) + ->where('token', $token) + ->first(); + + if (!$machine || !$machine->is_active) { + $conn->close(json_encode([ + 'event' => 'error', + 'data' => ['message' => 'invalid machine credentials'], + ])); + return; + } + + $nodes = ServerService::getMachineNodes($machine); + + $machine->forceFill(['last_seen_at' => now()->timestamp])->saveQuietly(); + NodeRegistry::addMachine($machineId, $conn); + + // 把同一个连接注册到该机器下所有节点 + $nodeIds = []; + $deviceService = app(DeviceStateService::class); + foreach ($nodes as $node) { + NodeRegistry::add($node->id, $conn); + Cache::put("node_ws_alive:{$node->id}", true, 86400); + $deviceService->clearAllNodeDevices($node->id); + $nodeIds[] = $node->id; + } + + // 连接上记录所属机器和节点列表 + $conn->machineId = $machineId; + $conn->machineNodeIds = $nodeIds; + + Log::debug("[WS] Machine#{$machineId} connected, nodes: " . implode(',', $nodeIds), [ + 'remote' => $conn->getRemoteIp(), + 'total' => NodeRegistry::count(), + 'machines' => NodeRegistry::machineCount(), + ]); + + $conn->send(json_encode([ + 'event' => 'auth.success', + 'data' => [ + 'machine_id' => $machineId, + 'node_ids' => $nodeIds, + ], + ])); + + // 为每个节点推送完整同步 + foreach ($nodes as $node) { + NodeEventHandlers::pushFullSync($conn, $node); + } + } + public function onMessage(TcpConnection $conn, $data): void { $msg = json_decode($data, true); @@ -175,8 +259,29 @@ class NodeWorker } $event = $msg['event'] ?? ''; - $nodeId = $conn->nodeId ?? null; + // 机器连接:从消息中读取 node_id 来分派到具体节点 + if (!empty($conn->machineNodeIds)) { + if ($event === 'pong') { + foreach ($conn->machineNodeIds as $nid) { + Cache::put("node_ws_alive:{$nid}", true, 86400); + } + return; + } + + $nodeId = (int) ($msg['data']['node_id'] ?? 0); + if ($nodeId <= 0 || !in_array($nodeId, $conn->machineNodeIds, true)) { + return; + } + if (isset($this->handlers[$event])) { + $handler = $this->handlers[$event]; + $handler($conn, $nodeId, $msg['data'] ?? []); + } + return; + } + + // 旧模式:单节点 + $nodeId = $conn->nodeId ?? null; if (isset($this->handlers[$event]) && $nodeId) { $handler = $this->handlers[$event]; $handler($conn, $nodeId, $msg['data'] ?? []); @@ -185,12 +290,39 @@ class NodeWorker public function onClose(TcpConnection $conn): void { + $service = app(DeviceStateService::class); + + // 机器模式:清理所有关联节点 + if (!empty($conn->machineNodeIds)) { + $machineId = $conn->machineId ?? 'unknown'; + foreach ($conn->machineNodeIds as $nodeId) { + NodeRegistry::remove($nodeId, $conn); + Cache::forget("node_ws_alive:{$nodeId}"); + + $affectedUserIds = $service->clearAllNodeDevices($nodeId); + foreach ($affectedUserIds as $userId) { + $service->notifyUpdate($userId); + } + } + + if (!empty($conn->machineId)) { + NodeRegistry::removeMachine((int) $conn->machineId, $conn); + } + + Log::debug("[WS] Machine#{$machineId} disconnected", [ + 'nodes' => $conn->machineNodeIds, + 'total' => NodeRegistry::count(), + 'machines' => NodeRegistry::machineCount(), + ]); + return; + } + + // 旧模式:单节点 if (!empty($conn->nodeId)) { $nodeId = $conn->nodeId; - NodeRegistry::remove($nodeId); + NodeRegistry::remove($nodeId, $conn); Cache::forget("node_ws_alive:{$nodeId}"); - $service = app(DeviceStateService::class); $affectedUserIds = $service->clearAllNodeDevices($nodeId); foreach ($affectedUserIds as $userId) { $service->notifyUpdate($userId); @@ -230,10 +362,27 @@ class NodeWorker return; } - $nodeId = $payload['node_id'] ?? null; $event = $payload['event'] ?? ''; $data = $payload['data'] ?? []; + // Machine-level events (e.g., sync.nodes) + $machineId = $payload['machine_id'] ?? null; + if ($machineId && $event) { + // Update server-side registry when node membership changes + if ($event === 'sync.nodes') { + $nodeIds = array_map('intval', array_column($data['nodes'] ?? [], 'id')); + NodeRegistry::refreshMachineNodes((int) $machineId, $nodeIds); + } + + $sent = NodeRegistry::sendMachine((int) $machineId, $event, $data); + if ($sent) { + Log::debug("[WS] Pushed {$event} to machine#{$machineId}"); + } + return; + } + + // Per-node events + $nodeId = $payload['node_id'] ?? null; if (!$nodeId || !$event) { return; } diff --git a/database/migrations/2026_04_11_000001_add_machine_support.php b/database/migrations/2026_04_11_000001_add_machine_support.php new file mode 100644 index 0000000..01f713c --- /dev/null +++ b/database/migrations/2026_04_11_000001_add_machine_support.php @@ -0,0 +1,53 @@ +id(); + $table->string('name'); + $table->string('token', 64)->unique(); + $table->text('notes')->nullable(); + $table->boolean('is_active')->default(true); + $table->unsignedInteger('last_seen_at')->nullable(); + $table->json('load_status')->nullable(); + $table->timestamps(); + }); + + Schema::create('v2_server_machine_load_history', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('machine_id'); + $table->float('cpu')->default(0); + $table->unsignedBigInteger('mem_total')->default(0); + $table->unsignedBigInteger('mem_used')->default(0); + $table->unsignedBigInteger('disk_total')->default(0); + $table->unsignedBigInteger('disk_used')->default(0); + $table->unsignedInteger('recorded_at'); + $table->timestamps(); + + $table->foreign('machine_id')->references('id')->on('v2_server_machine')->cascadeOnDelete(); + $table->index(['machine_id', 'recorded_at']); + }); + + Schema::table('v2_server', function (Blueprint $table) { + $table->unsignedBigInteger('machine_id')->nullable()->after('parent_id'); + $table->boolean('enabled')->default(true)->after('show'); + + $table->foreign('machine_id')->references('id')->on('v2_server_machine')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('v2_server', function (Blueprint $table) { + $table->dropForeign(['machine_id']); + $table->dropColumn(['machine_id', 'enabled']); + }); + Schema::dropIfExists('v2_server_machine_load_history'); + Schema::dropIfExists('v2_server_machine'); + } +}; diff --git a/public/assets/admin b/public/assets/admin index ee5c965..2d65083 160000 --- a/public/assets/admin +++ b/public/assets/admin @@ -1 +1 @@ -Subproject commit ee5c965558caa2efeb6b782de16dbe607b2edb85 +Subproject commit 2d650830ae2730dc3ad8b2f367ab583529b0b115 diff --git a/routes/console.php b/routes/console.php index da55196..b3d9bbc 100755 --- a/routes/console.php +++ b/routes/console.php @@ -1,19 +1 @@ comment(Inspiring::quote()); -})->describe('Display an inspiring quote');