middleware('auth:api'); $this->middleware('permission:platform.servers.view|resource.servers.use,api')->only(['index', 'show']); $this->middleware('permission:platform.servers.manage,api')->only(['store', 'update', 'destroy', 'syncUserPermissions', 'userPermissions']); } #[Apidoc\Title('资源列表'), Apidoc\Method('GET'), Apidoc\Url('/servers')] public function index(): JsonResponse { $this->syncAllResourcePermissions(); $query = ServerResource::query()->with('parent')->latest(); $user = auth('api')->user(); if ($user && ! $user->can('platform.servers.view')) { $pivotResourceIds = $user->serverResources() ->where(function ($pivotQuery) { $pivotQuery->where('can_ssh', true) ->orWhere('can_sftp', true) ->orWhere('can_rdp', true); }) ->pluck('server_resources.id') ->values(); $permissionResourceIds = $this->resolveResourceIdsFromPermissions($user); $resourceIds = $pivotResourceIds ->merge($permissionResourceIds) ->map(fn ($id): int => (int) $id) ->unique() ->values(); $parentIds = ServerResource::query() ->whereIn('id', $resourceIds) ->pluck('parent_id') ->filter() ->values(); $query->where(function ($scope) use ($resourceIds, $parentIds) { $scope->whereIn('id', $resourceIds)->orWhereIn('id', $parentIds); }); } return response()->json(['code' => 0, 'message' => 'ok', 'data' => $query->paginate(500)]); } private function resolveResourceIdsFromPermissions(User $user): Collection { $allPermissions = $user->getAllPermissions(); if ($allPermissions->contains(fn (Permission $permission): bool => $permission->name === 'resource.servers.use')) { return ServerResource::query() ->whereNotNull('parent_id') ->pluck('id') ->values(); } $resourceIds = collect(); foreach ($allPermissions as $permission) { $permissionName = (string) $permission->name; if (! str_starts_with($permissionName, 'resource.servers.use.')) { continue; } $description = (string) ($permission->description ?? ''); if (preg_match('/资源ID[::]\s*(\d+)/u', $description, $matches) === 1) { $resourceIds->push((int) $matches[1]); continue; } $nameParts = explode('.', $permissionName); if (count($nameParts) < 6) { continue; } $serverName = trim((string) ($nameParts[3] ?? '')); $resourceName = trim((string) ($nameParts[4] ?? '')); if ($serverName === '' || $resourceName === '') { continue; } $matchedIds = ServerResource::query() ->from('server_resources as resource') ->join('server_resources as server', 'server.id', '=', 'resource.parent_id') ->where('server.name', $serverName) ->where('resource.name', $resourceName) ->pluck('resource.id') ->map(fn ($id): int => (int) $id) ->all(); if (! empty($matchedIds)) { $resourceIds = $resourceIds->merge($matchedIds); } } return $resourceIds->unique()->values(); } #[Apidoc\Title('创建资源'), Apidoc\Method('POST'), Apidoc\Url('/servers')] public function store(StoreServerResourceRequest $request): JsonResponse { $data = $request->validated(); $isResource = ! empty($data['parent_id']); $targetParentId = $isResource ? (int) $data['parent_id'] : null; $this->ensureUniqueNameUnderParent((string) $data['name'], $targetParentId, null); if ($isResource) { $parent = ServerResource::query()->findOrFail($targetParentId); $data['internal_ip'] = $data['internal_ip'] ?? $parent->internal_ip; $data['protocols'] = [$this->resolveResourceProtocol((string) ($data['protocol'] ?? ''), null)]; $data['display_name'] = $data['display_name'] ?? $data['name']; $data['asset_id'] = $parent->asset_id; if (empty($data['asset_id'])) { throw ValidationException::withMessages([ 'parent_id' => ['所属服务器未配置 asset_id,请先配置服务器的 asset_id。'], ]); } if (empty($data['account_id'])) { throw ValidationException::withMessages([ 'account_id' => ['资源必须填写 account_id。'], ]); } } else { $data['protocols'] = []; $data['account_id'] = null; $data['display_name'] = $data['display_name'] ?? $data['name']; } unset($data['protocol']); $server = ServerResource::query()->create($data); $this->syncResourcePermission($server->load('parent')); $this->auditLog($request, $isResource ? 'resource_create' : 'server_create', ['server_resource_id' => $server->id]); return response()->json(['code' => 0, 'message' => 'ok', 'data' => $server], 201); } #[Apidoc\Title('资源详情'), Apidoc\Method('GET'), Apidoc\Url('/servers/{id}')] public function show(int $id): JsonResponse { return response()->json(['code' => 0, 'message' => 'ok', 'data' => ServerResource::query()->with('parent')->findOrFail($id)]); } #[Apidoc\Title('更新资源'), Apidoc\Method('PUT'), Apidoc\Url('/servers/{id}')] public function update(UpdateServerResourceRequest $request, int $id): JsonResponse { $server = ServerResource::query()->findOrFail($id); $server->load('children'); $data = $request->validated(); $isResource = ! empty($data['parent_id']) || ! empty($server->parent_id); $targetParentId = $isResource ? (int) ($data['parent_id'] ?? $server->parent_id) : null; $this->ensureUniqueNameUnderParent((string) $data['name'], $targetParentId, (int) $server->id); if ($isResource) { $parent = ServerResource::query()->findOrFail($targetParentId); $data['internal_ip'] = $data['internal_ip'] ?? $parent->internal_ip; $data['protocols'] = [$this->resolveResourceProtocol((string) ($data['protocol'] ?? ''), (string) ($server->protocols[0] ?? ''))]; $data['display_name'] = $data['display_name'] ?? ($server->display_name ?: $data['name']); $data['asset_id'] = $parent->asset_id; if (empty($data['asset_id'])) { throw ValidationException::withMessages([ 'parent_id' => ['所属服务器未配置 asset_id,请先配置服务器的 asset_id。'], ]); } if (empty($data['account_id'])) { throw ValidationException::withMessages([ 'account_id' => ['资源必须填写 account_id。'], ]); } } else { $data['protocols'] = []; $data['account_id'] = null; $data['display_name'] = $data['display_name'] ?? ($server->display_name ?: $data['name']); } unset($data['protocol']); $server->update($data); $server->refresh()->load(['parent', 'children.parent']); $this->syncResourcePermission($server); if (! $server->parent_id) { ServerResource::query() ->where('parent_id', $server->id) ->update(['asset_id' => $server->asset_id]); foreach ($server->children as $childResource) { $this->syncResourcePermission($childResource->load('parent')); } } $this->auditLog($request, $isResource ? 'resource_update' : 'server_update', ['server_resource_id' => $server->id]); return response()->json(['code' => 0, 'message' => 'ok', 'data' => $server]); } #[Apidoc\Title('删除资源'), Apidoc\Method('DELETE'), Apidoc\Url('/servers/{id}')] public function destroy(Request $request, int $id): JsonResponse { $server = ServerResource::query()->with('children')->findOrFail($id); $this->auditLog($request, $server->parent_id ? 'resource_delete' : 'server_delete', ['server_resource_id' => $server->id]); if (! $server->parent_id) { $descendantIds = $this->collectDescendantResourceIds((int) $server->id); foreach ($descendantIds as $descendantId) { $this->deleteResourcePermission($descendantId); } ServerResource::query() ->whereIn('id', $descendantIds) ->delete(); } $this->deleteResourcePermission((int) $server->id); $server->delete(); return response()->json(['code' => 0, 'message' => 'ok', 'data' => null]); } #[Apidoc\Title('资源用户权限'), Apidoc\Method('GET'), Apidoc\Url('/servers/{id}/user-permissions')] public function userPermissions(Request $request, int $id): JsonResponse { $server = ServerResource::query()->findOrFail($id); $assignedOnly = $request->boolean('assigned_only', false); $users = User::query()->select(['id', 'nickname', 'email'])->with(['serverResources' => function ($query) use ($id) { $query->where('server_resource_id', $id); }])->orderBy('id')->get()->map(function (User $user) { $pivot = $user->serverResources->first()?->pivot; return [ 'id' => $user->id, 'nickname' => $user->nickname, 'email' => $user->email, 'can_ssh' => (bool) ($pivot->can_ssh ?? false), 'can_sftp' => (bool) ($pivot->can_sftp ?? false), 'can_rdp' => (bool) ($pivot->can_rdp ?? false), ]; })->filter(function (array $userItem) use ($assignedOnly) { if (! $assignedOnly) { return true; } return (bool) ($userItem['can_ssh'] || $userItem['can_sftp'] || $userItem['can_rdp']); })->values(); return response()->json(['code' => 0, 'message' => 'ok', 'data' => ['server' => $server, 'users' => $users]]); } #[Apidoc\Title('同步资源用户权限'), Apidoc\Method('PUT'), Apidoc\Url('/servers/{id}/user-permissions')] public function syncUserPermissions(Request $request, int $id): JsonResponse { $validated = $request->validate([ 'users' => ['required', 'array'], 'users.*.id' => ['required', 'integer', 'exists:users,id'], 'users.*.can_ssh' => ['required', 'boolean'], 'users.*.can_sftp' => ['required', 'boolean'], 'users.*.can_rdp' => ['required', 'boolean'], 'partial' => ['sometimes', 'boolean'], ]); $server = ServerResource::query()->with('parent')->findOrFail($id); $syncData = []; foreach ($validated['users'] as $userItem) { $syncData[(int) $userItem['id']] = [ 'can_ssh' => (bool) $userItem['can_ssh'], 'can_sftp' => (bool) $userItem['can_sftp'], 'can_rdp' => (bool) $userItem['can_rdp'], ]; } $partial = (bool) ($validated['partial'] ?? false); if ($partial) { foreach ($syncData as $userId => $permissionItem) { $server->users()->updateExistingPivot($userId, $permissionItem); } } else { $server->users()->sync($syncData); } $permission = $this->syncResourcePermission($server); $this->syncDirectPermissionsByPivot($server, $permission, $syncData); $this->auditLog($request, 'resource_user_permissions_update', ['server_resource_id' => $server->id]); return response()->json(['code' => 0, 'message' => 'ok', 'data' => null]); } #[Apidoc\Title('使用服务器资源'), Apidoc\Method('POST'), Apidoc\Url('/servers/{id}/use')] public function useResource(Request $request, int $id): JsonResponse { $validated = $request->validate([ 'account_name' => ['required', 'string', 'max:255'], 'password' => ['required', 'string', 'max:255'], 'protocol' => ['required', 'string', 'max:64'], ], [ 'account_name.required' => '请输入访问用户名。', 'password.required' => '请输入访问密码。', ]); $resource = ServerResource::query()->with('parent')->findOrFail($id); if (! $resource->parent_id) { throw ValidationException::withMessages([ 'id' => ['请选择具体资源后再发起访问。'], ]); } if (! $resource->is_active) { throw ValidationException::withMessages([ 'id' => ['该资源已停用,无法访问。'], ]); } $assetId = (int) ($resource->asset_id ?: ($resource->parent?->asset_id ?: 0)); $accountId = (int) ($resource->account_id ?: 0); if ($assetId <= 0 || $accountId <= 0) { throw ValidationException::withMessages([ 'id' => ['该资源缺少 asset_id 或 account_id,无法访问。'], ]); } $requestedProtocol = (string) $validated['protocol']; $resourceProtocols = collect($resource->protocols ?? []) ->map(fn ($item): string => (string) $item) ->filter() ->mapWithKeys(fn (string $item): array => [mb_strtolower($item) => $item]) ->toArray(); $protocol = $resourceProtocols[mb_strtolower($requestedProtocol)] ?? ''; if ($protocol === '') { throw ValidationException::withMessages([ 'protocol' => ['该资源不支持所选协议。'], ]); } /** @var User|null $user */ $user = auth('api')->user(); if (! $user || ! $this->canUseResource($user, $resource, $protocol)) { return response()->json([ 'code' => 403, 'message' => '无权限使用该资源', 'data' => null, ], 403); } $bastionAccount = BastionAccount::query() ->where('is_active', true) ->whereNotNull('usm') ->where('usm', '!=', '') ->whereNotNull('usm_authentication') ->where('usm_authentication', '!=', '') ->inRandomOrder() ->first(); if (! $bastionAccount) { return response()->json([ 'code' => 422, 'message' => '当前没有可用的堡垒机授权账号,请先刷新账号 Token', 'data' => null, ], 422); } $baseUrl = (string) config('services.bastion_access.base_url', 'https://172.16.254.2'); $endpoint = (string) config('services.bastion_access.sso_endpoint', '/usmapi/v1/operation/custom/sso'); $timeout = (int) config('services.bastion_access.timeout', 30); $verifySsl = (bool) config('services.bastion_access.verify_ssl', false); $protocolId = $this->resolveProtocolId($protocol); $accountName = (string) ($validated['account_name'] ?? ''); $password = (string) ($validated['password'] ?? ''); try { $response = Http::baseUrl($baseUrl) ->acceptJson() ->timeout($timeout) ->retry(2, 300, throw: false) ->withOptions(['verify' => $verifySsl]) ->withHeaders([ 'Cookie' => sprintf( 'USM=%s; USM-AUTHENTICATION=%s', (string) $bastionAccount->usm, (string) $bastionAccount->usm_authentication ), ]) ->post($endpoint, [ 'accounts' => [[ 'account_name' => $accountName, 'protocol_id' => $protocolId, 'asset_id' => $assetId, 'account_id' => $accountId, 'rule_id' => 0, 'password' => $password, ]], 'op_type' => 'bs', 'description' => '', 'second_verify_token' => '', 'use_taibao_password' => 0, 'use_om_password_cache' => 0, ]); } catch (ConnectionException|RequestException $exception) { return response()->json([ 'code' => 502, 'message' => '堡垒机接口调用失败:'.$exception->getMessage(), 'data' => null, ], 502); } if (! $response->successful()) { return response()->json([ 'code' => 502, 'message' => '堡垒机接口返回异常', 'data' => [ 'status' => $response->status(), 'response' => $response->json(), ], ], 502); } $result = $response->json(); if (strtoupper((string) data_get($result, 'code')) !== 'SUCCESS') { return response()->json([ 'code' => 502, 'message' => '堡垒机接口返回失败:'.((string) data_get($result, 'msg', '未知错误')), 'data' => ['response' => $result], ], 502); } $ssoUrl = (string) data_get($result, 'data.items.0.url', ''); if ($ssoUrl === '') { return response()->json([ 'code' => 502, 'message' => '堡垒机未返回可用的 SSO 地址', 'data' => ['response' => $result], ], 502); } $tempPassword = null; if ((bool) $resource->allow_copy_temp_password) { $tempPassword = $this->extractSsoTokenFromUrl($ssoUrl); } AccessLog::query()->create([ 'user_id' => $user->id, 'server_resource_id' => $resource->id, 'bastion_account_id' => $bastionAccount->id, 'protocol' => $protocol, 'action' => 'resource_use', 'requested_at' => now(), 'metadata' => [ 'resource_name' => $resource->display_name ?: $resource->name, 'account_name' => $accountName, 'has_password' => $password !== '', 'protocol_id' => $protocolId, 'bastion_response_code' => data_get($result, 'code'), ], ]); $this->auditLog($request, 'resource_use', [ 'server_resource_id' => $resource->id, 'bastion_account_id' => $bastionAccount->id, 'metadata' => [ 'protocol' => $protocol, 'protocol_id' => $protocolId, 'account_name' => $accountName, ], ]); return response()->json([ 'code' => 0, 'message' => 'ok', 'data' => [ 'url' => $ssoUrl, 'protocol' => $protocol, 'resource_id' => $resource->id, 'resource_name' => $resource->display_name ?: $resource->name, 'bastion_account_id' => $bastionAccount->id, 'client_type' => (string) data_get($result, 'data.client_type', ''), 'response' => $result, 'allow_copy_temp_password' => (bool) $resource->allow_copy_temp_password, 'temp_password' => $tempPassword, ], ]); } private function extractSsoTokenFromUrl(string $ssoUrl): ?string { if (! str_starts_with($ssoUrl, 'sso://')) { return null; } $encoded = trim(substr($ssoUrl, strlen('sso://'))); $encoded = rtrim($encoded, '/'); if ($encoded === '') { return null; } $decoded = base64_decode($encoded, true); if ($decoded === false || $decoded === '') { return null; } $payload = json_decode($decoded, true); if (! is_array($payload)) { return null; } $token = data_get($payload, 'NODE_COMMON.SSOToken'); if (! is_string($token) || trim($token) === '') { return null; } return trim($token); } private function syncDirectPermissionsByPivot(ServerResource $resource, Permission $permission, array $syncData): void { if (! $resource->parent_id) { return; } foreach ($syncData as $userId => $permissionItem) { $canUseResource = (bool) ($permissionItem['can_ssh'] || $permissionItem['can_sftp'] || $permissionItem['can_rdp']); $user = User::query()->find((int) $userId); if (! $user) { continue; } if ($canUseResource) { if (! $user->hasDirectPermission($permission->name)) { $user->givePermissionTo($permission); } } else { $user->revokePermissionTo($permission->name); } } } public static function buildResourcePermissionName(ServerResource $resource): string { $serverName = $resource->parent_id ? (string) ($resource->parent?->name ?: 'unknown-server') : (string) ($resource->name ?: 'unknown-server'); $resourceName = (string) ($resource->name ?: 'unknown-resource'); return sprintf( 'resource.servers.use.%s.%s', trim($serverName), trim($resourceName) ); } public static function resourcePermissionDescription(ServerResource $resource): string { $resource->loadMissing('parent'); if (! $resource->parent_id) { $serverLabel = trim((string) ($resource->display_name ?: $resource->name ?: '未命名服务器')); return sprintf('服务器资源访问权限(%s,资源ID: %d)', $serverLabel, (int) $resource->id); } $serverLabel = trim((string) ($resource->parent?->display_name ?: $resource->parent?->name ?: '未命名服务器')); $resourceLabel = trim((string) ($resource->display_name ?: $resource->name ?: '未命名资源')); return sprintf('服务器资源访问权限(%s-%s,资源ID: %d)', $serverLabel, $resourceLabel, (int) $resource->id); } private function syncResourcePermission(ServerResource $resource): Permission { $description = self::resourcePermissionDescription($resource); $permission = Permission::query() ->where('guard_name', 'api') ->where('description', $description) ->first(); $basePermissionName = self::buildResourcePermissionName($resource); if (! $permission) { $permission = Permission::query()->firstOrCreate( [ 'guard_name' => 'api', 'name' => $this->resolvePermissionNameConflict($basePermissionName, null, (int) $resource->id), ], [ 'category' => '资源使用', 'description' => $description, ] ); } $permission->update([ 'name' => $this->resolvePermissionNameConflict($basePermissionName, (int) $permission->id, (int) $resource->id), 'category' => '资源使用', 'description' => $description, ]); return $permission; } private function deleteResourcePermission(int $resourceId): void { Permission::query() ->where('guard_name', 'api') ->where('description', 'like', '%资源ID: '.$resourceId.'%') ->delete(); UserServerPermission::query() ->where('server_resource_id', $resourceId) ->delete(); } private function ensureUniqueNameUnderParent(string $name, ?int $parentId, ?int $ignoreId): void { $query = ServerResource::query()->where('name', $name); if ($parentId === null) { $query->whereNull('parent_id'); } else { $query->where('parent_id', $parentId); } if ($ignoreId !== null) { $query->where('id', '!=', $ignoreId); } if ($query->exists()) { throw ValidationException::withMessages([ 'name' => ['同一服务器层级下名称必须唯一。'], ]); } } private function resolvePermissionNameConflict(string $baseName, ?int $ignorePermissionId, int $resourceId): string { $query = Permission::query() ->where('guard_name', 'api') ->where('name', $baseName); if ($ignorePermissionId !== null) { $query->where('id', '!=', $ignorePermissionId); } if (! $query->exists()) { return $baseName; } return $baseName.'.'.$resourceId; } private function syncAllResourcePermissions(): void { $resources = ServerResource::query()->with('parent')->get(); foreach ($resources as $resource) { $this->syncResourcePermission($resource); } } private function canUseResource(User $user, ServerResource $resource, string $protocol): bool { if ($user->can('platform.servers.view') || $user->can('resource.servers.use')) { return true; } $resourcePermission = Permission::query() ->where('guard_name', 'api') ->where('description', self::resourcePermissionDescription($resource)) ->first(); if ($resourcePermission && $user->hasPermissionTo($resourcePermission->name)) { return true; } $pivot = $resource->users() ->where('users.id', $user->id) ->first()?->pivot; if (! $pivot) { return false; } return match ($protocol) { 'SFTP' => (bool) $pivot->can_sftp, 'RDP' => (bool) $pivot->can_rdp, default => (bool) $pivot->can_ssh, }; } private function resolveProtocolId(string $protocol): int { $managed = OpsProtocol::query() ->where('name', $protocol) ->first(); if ($managed) { return (int) ($managed->bastion_protocol_id ?: 2); } return match (strtoupper($protocol)) { 'SFTP' => (int) config('services.bastion_access.protocol_ids.sftp', 4), 'RDP' => (int) config('services.bastion_access.protocol_ids.rdp', 3), default => (int) config('services.bastion_access.protocol_ids.ssh', 2), }; } /** * @return int[] */ private function collectDescendantResourceIds(int $parentId): array { $descendantIds = []; $queue = [$parentId]; while (! empty($queue)) { $children = ServerResource::query() ->whereIn('parent_id', $queue) ->pluck('id') ->map(fn (int $id): int => (int) $id) ->all(); if (empty($children)) { break; } $descendantIds = array_values(array_unique([...$descendantIds, ...$children])); $queue = $children; } return $descendantIds; } private function resolveResourceProtocol(string $requestedProtocol, ?string $fallbackProtocol): string { $normalizedRequested = trim($requestedProtocol); if ($normalizedRequested !== '') { $exists = OpsProtocol::query()->where('name', $normalizedRequested)->exists(); if (! $exists) { throw ValidationException::withMessages([ 'protocol' => ['协议不存在,请先在运维协议中配置。'], ]); } return $normalizedRequested; } $normalizedFallback = trim((string) $fallbackProtocol); if ($normalizedFallback !== '') { return $normalizedFallback; } $firstProtocol = (string) OpsProtocol::query() ->where('is_active', true) ->orderBy('sort') ->orderBy('id') ->value('name'); if ($firstProtocol === '') { throw ValidationException::withMessages([ 'protocol' => ['请先在运维协议中新增并启用至少一个协议。'], ]); } return $firstProtocol; } }