diff --git a/.gitignore b/.gitignore index 52e73a3..3ecac5b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ Thumbs.db /.idea /.vscode /vendor +/.secret_encryption.key /.settings /.buildpath /.project @@ -15,3 +16,8 @@ Thumbs.db package.json package-lock.json static/tailwindcss-browser.js + +# Local runtime data +/data/ +*.sqlite* +monitor.db diff --git a/AliyunService.php b/AliyunService.php index 1bd37c5..16d55c6 100644 --- a/AliyunService.php +++ b/AliyunService.php @@ -6,6 +6,10 @@ use AlibabaCloud\Client\Exception\ServerException; class AliyunService { + private $regionCache = []; + private $managedTagKey = 'ecs-controller-managed'; + private $managedTagValue = 'true'; + /** * 智能重试执行器 * 自动处理网络抖动、超时和服务端临时错误 @@ -73,6 +77,23 @@ class AliyunService private $trafficCache = []; + private function setDefaultClient($key, $secret, $regionId) + { + AlibabaCloud::accessKeyClient($key, $secret) + ->regionId($regionId) + ->asDefaultClient(); + } + + private function ecsHost($regionId) + { + return "ecs.{$regionId}.aliyuncs.com"; + } + + private function vpcHost($regionId) + { + return "vpc.{$regionId}.aliyuncs.com"; + } + /** * 判断是否为海外区域 * 国内区域:cn-* (排除 cn-hongkong) @@ -164,6 +185,170 @@ class AliyunService throw new \Exception("API 响应缺少 TrafficDetails 字段"); } + /** + * 获取 ECS 实例公网出口分钟带宽点,并换算为字节增量。 + * 阿里云 ECS 公网按出方向流量计费;这里优先使用 VPC 公网 IP 维度指标,经典网络回退到实例维度指标。 + * + * @return array ['bytes' => float, 'lastSampleMs' => int, 'points' => int, 'metric' => string] + * @throws \Exception + */ + public function getInstanceOutboundTrafficDelta($account, $startMs, $endMs) + { + if (empty($account['instance_id'])) { + throw new \Exception('未配置 Instance ID'); + } + + if ($endMs <= $startMs) { + return [ + 'bytes' => 0.0, + 'lastSampleMs' => (int) $startMs, + 'points' => 0, + 'metric' => '' + ]; + } + + $metricCandidates = []; + $publicIp = trim((string) ($account['public_ip'] ?? '')); + if ($publicIp !== '') { + $metricCandidates[] = [ + 'name' => 'VPC_PublicIP_InternetOutRate', + 'dimensions' => [[ + 'instanceId' => $account['instance_id'], + 'ip' => $publicIp + ]] + ]; + } + + $metricCandidates[] = [ + 'name' => 'InternetOutRate', + 'dimensions' => [[ + 'instanceId' => $account['instance_id'] + ]] + ]; + + $lastException = null; + foreach ($metricCandidates as $candidate) { + try { + $result = $this->queryMetricRateAsBytes( + $account['access_key_id'], + $account['access_key_secret'], + $candidate['name'], + $candidate['dimensions'], + $startMs, + $endMs + ); + + if ($result['points'] > 0 || $candidate['name'] === 'InternetOutRate') { + $result['metric'] = $candidate['name']; + return $result; + } + } catch (\Exception $e) { + $lastException = $e; + } + } + + if ($lastException) { + throw $lastException; + } + + return [ + 'bytes' => 0.0, + 'lastSampleMs' => (int) $startMs, + 'points' => 0, + 'metric' => '' + ]; + } + + private function queryMetricRateAsBytes($key, $secret, $metricName, array $dimensions, $startMs, $endMs) + { + $period = 60; + $chunkMs = 24 * 3600 * 1000; + $cursor = (int) $startMs; + $totalBytes = 0.0; + $lastSampleMs = (int) $startMs; + $pointCount = 0; + + while ($cursor < $endMs) { + $chunkEnd = min($cursor + $chunkMs, (int) $endMs); + $nextToken = null; + + do { + $query = [ + 'Namespace' => 'acs_ecs_dashboard', + 'MetricName' => $metricName, + 'Period' => (string) $period, + 'StartTime' => (string) $cursor, + 'EndTime' => (string) $chunkEnd, + 'Dimensions' => json_encode($dimensions, JSON_UNESCAPED_SLASHES), + 'Length' => '1440' + ]; + + if (!empty($nextToken)) { + $query['NextToken'] = $nextToken; + } + + $result = $this->executeWithRetry(function () use ($key, $secret, $query) { + AlibabaCloud::accessKeyClient($key, $secret) + ->regionId('cn-hangzhou') + ->asDefaultClient(); + + return AlibabaCloud::rpc() + ->product('Cms') + ->scheme('https') + ->version('2019-01-01') + ->action('DescribeMetricList') + ->method('POST') + ->host('metrics.aliyuncs.com') + ->options([ + 'query' => $query, + 'connect_timeout' => 5.0, + 'timeout' => 15.0 + ]) + ->request(); + }, 'queryMetricRateAsBytes'); + + $datapoints = $result['Datapoints'] ?? '[]'; + if (is_string($datapoints)) { + $datapoints = json_decode($datapoints, true); + } + if (!is_array($datapoints)) { + $datapoints = []; + } + + usort($datapoints, function ($a, $b) { + return ((int) ($a['timestamp'] ?? 0)) <=> ((int) ($b['timestamp'] ?? 0)); + }); + + foreach ($datapoints as $point) { + $timestamp = (int) ($point['timestamp'] ?? 0); + if ($timestamp <= $startMs || $timestamp > $endMs) { + continue; + } + + $rateBitsPerSecond = (float) ($point['Average'] ?? $point['Maximum'] ?? $point['Minimum'] ?? 0); + if ($rateBitsPerSecond < 0) { + $rateBitsPerSecond = 0; + } + + $totalBytes += ($rateBitsPerSecond * $period) / 8; + $lastSampleMs = max($lastSampleMs, $timestamp); + $pointCount++; + } + + $nextToken = $result['NextToken'] ?? null; + } while (!empty($nextToken)); + + $cursor = $chunkEnd; + } + + return [ + 'bytes' => $totalBytes, + 'lastSampleMs' => $lastSampleMs, + 'points' => $pointCount, + 'metric' => $metricName + ]; + } + /** * 获取实例状态 * @throws \Exception @@ -183,7 +368,8 @@ class AliyunService ]; if (!empty($account['instance_id'])) { - $options['query']['InstanceId'] = $account['instance_id']; + // 修改:阿里云 RPC 风格接口对于列表类参数(如 InstanceId.N)需要明确的索引 + $options['query']['InstanceId.1'] = $account['instance_id']; } $result = AlibabaCloud::rpc() @@ -196,14 +382,107 @@ class AliyunService ->options($options) ->request(); - if (isset($result['InstanceStatuses']['InstanceStatus'][0]['Status'])) { - return $result['InstanceStatuses']['InstanceStatus'][0]['Status']; + $statuses = $result['InstanceStatuses']['InstanceStatus'] ?? []; + foreach ($statuses as $item) { + if (($item['InstanceId'] ?? '') === $account['instance_id']) { + return $item['Status']; + } } - throw new \Exception("API 响应未找到实例状态 (请检查 Instance ID)"); + // 如果没找到匹配的 ID,且返回了列表(说明过滤参数没生效),且当前账号只有一个实例 ID,则抛出异常 + if (empty($statuses) || count($statuses) > 1) { + throw new \Exception("API 响应未找到匹配的实例状态 (ID: {$account['instance_id']})"); + } + + return 'Unknown'; }, 'getInstanceStatus'); } + /** + * 获取实例详细健康状态 (用于识别操作系统启动中等状态) + */ + public function getInstanceFullStatus($account) + { + return $this->executeWithRetry(function () use ($account) { + AlibabaCloud::accessKeyClient($account['access_key_id'], $account['access_key_secret']) + ->regionId($account['region_id']) + ->asDefaultClient(); + + $options = [ + 'query' => [ + 'RegionId' => $account['region_id'], + 'InstanceId.1' => $account['instance_id'] + ], + 'connect_timeout' => 5.0, + 'timeout' => 10.0 + ]; + + $result = AlibabaCloud::rpc() + ->product('Ecs') + ->scheme('https') + ->version('2014-05-26') + ->action('DescribeInstancesFullStatus') + ->method('POST') + ->host("ecs.{$account['region_id']}.aliyuncs.com") + ->options($options) + ->request(); + + $statusSet = $result['InstanceFullStatusSet']['InstanceFullStatus'][0] ?? null; + if ($statusSet && ($statusSet['InstanceId'] ?? '') === $account['instance_id']) { + return [ + 'status' => $statusSet['Status']['Name'] ?? 'Unknown', + 'healthStatus' => $statusSet['HealthStatus']['Name'] ?? 'Unknown', + ]; + } + + return null; + }, 'getInstanceFullStatus'); + } + + /** + * 释放(删除)实例 + * @throws \Exception + */ + public function deleteInstance($account, $forceStop = false) + { + if (empty($account['instance_id'])) { + throw new \Exception("未配置 Instance ID"); + } + + try { + return $this->executeWithRetry(function () use ($account) { + AlibabaCloud::accessKeyClient($account['access_key_id'], $account['access_key_secret']) + ->regionId($account['region_id']) + ->asDefaultClient(); + + AlibabaCloud::rpc() + ->product('Ecs') + ->scheme('https') + ->version('2014-05-26') + ->action('DeleteInstance') + ->method('POST') + ->host("ecs.{$account['region_id']}.aliyuncs.com") + ->options([ + 'query' => [ + 'RegionId' => $account['region_id'], + 'InstanceId' => $account['instance_id'], + 'Force' => true, + ], + 'connect_timeout' => 5.0, + 'timeout' => 15.0 + ]) + ->request(); + + return true; + }, 'deleteInstance'); + } catch (ServerException $e) { + $code = $e->getErrorCode(); + if (stripos($code, 'NotFound') !== false || stripos($code, 'InvalidInstanceId') !== false) { + return true; + } + throw $e; + } + } /** * 控制实例开关机 * @throws \Exception @@ -247,6 +526,1020 @@ class AliyunService }, 'controlInstance'); } + /** + * 获取当前账号可访问的地域列表 + * @param string $key + * @param string $secret + * @return array + * @throws \Exception + */ + public function getRegions($key, $secret) + { + $cacheKey = md5($key); + if (isset($this->regionCache[$cacheKey])) { + return $this->regionCache[$cacheKey]; + } + + $result = $this->executeWithRetry(function () use ($key, $secret) { + AlibabaCloud::accessKeyClient($key, $secret) + ->regionId('cn-hangzhou') + ->asDefaultClient(); + + return AlibabaCloud::rpc() + ->product('Ecs') + ->scheme('https') + ->version('2014-05-26') + ->action('DescribeRegions') + ->method('POST') + ->host('ecs.cn-hangzhou.aliyuncs.com') + ->options([ + 'connect_timeout' => 5.0, + 'timeout' => 10.0 + ]) + ->request(); + }, 'getRegions'); + + $regions = []; + foreach (($result['Regions']['Region'] ?? []) as $region) { + if (empty($region['RegionId'])) { + continue; + } + + $regions[] = [ + 'regionId' => $region['RegionId'], + 'localName' => $region['LocalName'] ?? $region['RegionId'] + ]; + } + + $this->regionCache[$cacheKey] = $regions; + return $regions; + } + + /** + * 列出当前账号下所有 ECS 实例 + * @param string $key + * @param string $secret + * @return array + * @throws \Exception + */ + public function getInstances($key, $secret, $targetRegionId = null) + { + $regions = $this->getRegions($key, $secret); + if (!empty($targetRegionId)) { + $matchedRegion = null; + foreach ($regions as $region) { + if (($region['regionId'] ?? '') === $targetRegionId) { + $matchedRegion = $region; + break; + } + } + + $regions = [[ + 'regionId' => $targetRegionId, + 'localName' => $matchedRegion['localName'] ?? $targetRegionId + ]]; + } + + $instances = []; + + foreach ($regions as $region) { + $pageNumber = 1; + + try { + do { + $result = $this->executeWithRetry(function () use ($key, $secret, $region, $pageNumber) { + AlibabaCloud::accessKeyClient($key, $secret) + ->regionId($region['regionId']) + ->asDefaultClient(); + + return AlibabaCloud::rpc() + ->product('Ecs') + ->scheme('https') + ->version('2014-05-26') + ->action('DescribeInstances') + ->method('POST') + ->host("ecs.{$region['regionId']}.aliyuncs.com") + ->options([ + 'query' => [ + 'RegionId' => $region['regionId'], + 'PageSize' => 100, + 'PageNumber' => $pageNumber + ], + 'connect_timeout' => 8.0, + 'timeout' => 20.0 + ]) + ->request(); + }, 'getInstances'); + + $items = $result['Instances']['Instance'] ?? []; + foreach ($items as $instance) { + $instances[] = [ + 'instanceId' => $instance['InstanceId'] ?? '', + 'instanceName' => $instance['InstanceName'] ?? '', + 'status' => $instance['Status'] ?? 'Unknown', + 'regionId' => $region['regionId'], + 'regionName' => $region['localName'], + 'instanceType' => $instance['InstanceType'] ?? '', + 'cpu' => $instance['Cpu'] ?? 0, + 'memory' => $instance['Memory'] ?? 0, + 'internetMaxBandwidthOut' => (int) ($instance['InternetMaxBandwidthOut'] ?? 0), + 'osName' => $instance['OSName'] ?? '', + 'publicIp' => $instance['PublicIpAddress']['IpAddress'][0] ?? $instance['EipAddress']['IpAddress'] ?? '', + 'privateIp' => $instance['VpcAttributes']['PrivateIpAddress']['IpAddress'][0] ?? '', + 'stoppedMode' => $instance['StoppedMode'] ?? '', + 'chargeType' => $instance['InstanceChargeType'] ?? '' + ]; + } + + $totalCount = (int) ($result['TotalCount'] ?? count($items)); + $pageSize = (int) ($result['PageSize'] ?? 100); + $pageNumber++; + } while ($totalCount > 0 && (($pageNumber - 1) * $pageSize) < $totalCount); + } catch (\Exception $e) { + if (!empty($targetRegionId)) { + throw $e; + } + continue; + } + } + + usort($instances, function ($a, $b) { + $regionCompare = strcmp($a['regionId'], $b['regionId']); + if ($regionCompare !== 0) { + return $regionCompare; + } + + return strcmp($a['instanceId'], $b['instanceId']); + }); + + return $instances; + } + + public function buildEcsCreatePreview($account, array $request, $clientIp = '') + { + $regionId = trim((string) ($request['regionId'] ?? $account['region_id'] ?? '')); + $instanceType = trim((string) ($request['instanceType'] ?? '')) ?: 'ecs.e-c4m1.large'; + $osKey = trim((string) ($request['osKey'] ?? 'ubuntu_22')); + $instanceName = trim((string) ($request['instanceName'] ?? '')); + if ($instanceName === '') { + $instanceName = 'launch-' . date('Ymd-His'); + } + $requestedDiskSize = (int) ($request['systemDiskSize'] ?? 20); + + if ($regionId === '') { + throw new \Exception('请选择区域'); + } + + $key = $account['access_key_id']; + $secret = $account['access_key_secret']; + + $zone = $this->selectAvailableZone($key, $secret, $regionId, $instanceType); + $instanceTypeInfo = $this->describeInstanceType($key, $secret, $regionId, $instanceType); + $image = $this->selectSystemImage($key, $secret, $regionId, $osKey, $instanceTypeInfo['CpuArchitecture'] ?? ''); + $diskCategory = $this->selectDiskCategory($zone); + $diskRange = $this->getSystemDiskSizeRange($key, $secret, $regionId, $zone['zoneId'], $instanceType, $diskCategory); + $diskSize = $this->normalizeSystemDiskSize($requestedDiskSize, $diskRange); + $bandwidth = $this->estimateMaxBandwidthOut($instanceType, $regionId); + $loginPort = ($image['osType'] ?? 'linux') === 'windows' ? 3389 : 22; + $loginUser = ($image['osType'] ?? 'linux') === 'windows' ? 'Administrator' : 'root'; + $securityRule = '默认全开:允许 0.0.0.0/0 入方向 TCP/UDP/ICMP'; + + return [ + 'account' => [ + 'groupKey' => $account['group_key'] ?? '', + 'label' => trim((string) ($account['remark'] ?? '')) ?: substr($key, 0, 7) . '***' + ], + 'regionId' => $regionId, + 'zoneId' => $zone['zoneId'], + 'instanceType' => $instanceType, + 'instanceName' => $instanceName, + 'osKey' => $osKey, + 'osLabel' => $image['label'], + 'imageId' => $image['imageId'], + 'imageSize' => (int) ($image['size'] ?? 0), + 'loginUser' => $loginUser, + 'loginPort' => $loginPort, + 'clientCidrIp' => '0.0.0.0/0', + 'chargeType' => 'PostPaid', + 'internetChargeType' => 'PayByTraffic', + 'internetMaxBandwidthOut' => $bandwidth, + 'systemDisk' => [ + 'category' => $diskCategory, + 'size' => $diskSize, + 'min' => $diskRange['min'], + 'max' => $diskRange['max'], + 'unit' => $diskRange['unit'] + ], + 'network' => [ + 'vpc' => [ + 'mode' => 'auto', + 'name' => "ecs-controller-vpc-{$regionId}", + 'cidr' => '172.31.0.0/16' + ], + 'vswitch' => [ + 'mode' => 'auto', + 'name' => "ecs-controller-vsw-{$zone['zoneId']}", + 'cidr' => $this->cidrForZone($zone['zoneId']) + ], + 'securityGroup' => [ + 'mode' => 'auto', + 'name' => "ecs-controller-sg-{$regionId}", + 'rules' => [$securityRule] + ] + ], + 'cdtCompatible' => true, + 'backupEnabled' => false, + 'pricing' => [ + 'available' => false, + 'currency' => ($account['site_type'] ?? 'international') === 'international' ? 'USD' : 'CNY', + 'message' => '费用预估暂不可用。实例按量计费,公网按实际出口流量计费,最终以阿里云账单为准。', + 'trafficNote' => '公网按使用流量计费,并按 CDT 兼容方式创建。' + ], + 'warnings' => array_values(array_filter([ + '公网带宽峰值会自动尝试最高可用值,若账号配额或规格限制不支持,会自动降级重试。', + "系统盘将严格按 {$diskSize} GB 创建;当前 API 返回范围为 {$diskRange['min']}-{$diskRange['max']} {$diskRange['unit']},超出范围会直接报错。", + '文件备份默认不启用;如需备份,请创建后在阿里云控制台单独开启。', + '安全组默认全开,便于测试和交付;生产环境建议创建后收紧来源 IP 和端口。' + ])) + ]; + } + + public function createManagedEcsFromPreview($account, array $preview, callable $progress = null) + { + $key = $account['access_key_id']; + $secret = $account['access_key_secret']; + $regionId = $preview['regionId']; + $zoneId = $preview['zoneId']; + $instanceType = $preview['instanceType']; + $password = $this->generateInstancePassword(); + + $this->emitProgress($progress, '准备 VPC'); + $vpc = $this->ensureVpc($key, $secret, $regionId, $preview['network']['vpc']['name'], $preview['network']['vpc']['cidr']); + + $this->emitProgress($progress, '准备交换机'); + $vswitch = $this->ensureVSwitch( + $key, + $secret, + $regionId, + $zoneId, + $vpc['VpcId'], + $preview['network']['vswitch']['name'], + $preview['network']['vswitch']['cidr'] + ); + + $this->emitProgress($progress, '准备安全组'); + $securityGroup = $this->ensureSecurityGroup($key, $secret, $regionId, $vpc['VpcId'], $preview['network']['securityGroup']['name']); + $this->authorizeOpenSecurityGroupRules($key, $secret, $regionId, $securityGroup['SecurityGroupId']); + + $bandwidthCandidates = $this->bandwidthCandidates((int) ($preview['internetMaxBandwidthOut'] ?? 100)); + $diskCategories = array_unique(array_filter([ + $preview['systemDisk']['category'] ?? 'cloud_essd', + 'cloud_auto', + 'cloud_essd', + 'cloud_efficiency', + 'cloud' + ])); + // 系统盘成本敏感,严格使用用户确认的值;若阿里云拒绝,不自动放大。 + $diskSize = $this->normalizeSystemDiskSize($preview['systemDisk']['size'] ?? 20, $preview['systemDisk'] ?? []); + $lastError = null; + + foreach ($bandwidthCandidates as $bandwidth) { + foreach ($diskCategories as $diskCategory) { + try { + $this->emitProgress($progress, "创建 ECS({$bandwidth} Mbps / {$diskCategory})"); + $instanceIds = $this->runInstance( + $key, + $secret, + $regionId, + [ + 'zoneId' => $zoneId, + 'instanceType' => $instanceType, + 'imageId' => $preview['imageId'], + 'securityGroupId' => $securityGroup['SecurityGroupId'], + 'vSwitchId' => $vswitch['VSwitchId'], + 'instanceName' => $preview['instanceName'], + 'password' => $password, + 'internetMaxBandwidthOut' => $bandwidth, + 'systemDiskCategory' => $diskCategory, + 'systemDiskSize' => $diskSize + ] + ); + + $instanceId = $instanceIds[0] ?? ''; + if ($instanceId === '') { + throw new \Exception('RunInstances 未返回 InstanceId'); + } + + $this->emitProgress($progress, '等待实例启动'); + $instance = $this->waitInstanceReady($key, $secret, $regionId, $instanceId); + + return [ + 'instanceId' => $instanceId, + 'publicIp' => $instance['publicIp'] ?? '', + 'privateIp' => $instance['privateIp'] ?? '', + 'status' => $instance['status'] ?? 'Unknown', + 'instanceName' => $preview['instanceName'], + 'instanceType' => $instanceType, + 'vpcId' => $vpc['VpcId'], + 'vswitchId' => $vswitch['VSwitchId'], + 'securityGroupId' => $securityGroup['SecurityGroupId'], + 'internetMaxBandwidthOut' => $bandwidth, + 'systemDiskCategory' => $diskCategory, + 'systemDiskSize' => $diskSize, + 'loginUser' => $preview['loginUser'] ?? 'root', + 'loginPassword' => $password + ]; + } catch (\Exception $e) { + $lastError = $e; + $message = $e->getMessage(); + if ($this->isDiskSizeError($message)) { + throw new \Exception("系统盘 {$diskSize} GB 不被当前镜像或实例规格支持,请手动调整系统盘大小后重新创建。阿里云返回:" . $message); + } + if (stripos($message, 'InvalidInstanceType.NotSupported') !== false || stripos($message, 'image architecture') !== false) { + $instanceTypeInfo = $this->describeInstanceType($key, $secret, $regionId, $instanceType); + $image = $this->selectSystemImage($key, $secret, $regionId, $preview['osKey'] ?? 'ubuntu_22', $instanceTypeInfo['CpuArchitecture'] ?? ''); + $preview['imageId'] = $image['imageId']; + $preview['osLabel'] = $image['label']; + } + continue; + } + } + } + + throw new \Exception($lastError ? $lastError->getMessage() : 'ECS 创建失败'); + } + + private function selectAvailableZone($key, $secret, $regionId, $instanceType) + { + $result = $this->executeWithRetry(function () use ($key, $secret, $regionId, $instanceType) { + $this->setDefaultClient($key, $secret, $regionId); + + return AlibabaCloud::rpc() + ->product('Ecs') + ->scheme('https') + ->version('2014-05-26') + ->action('DescribeZones') + ->method('POST') + ->host($this->ecsHost($regionId)) + ->options([ + 'query' => [ + 'RegionId' => $regionId, + 'InstanceType' => $instanceType, + 'AvailableResourceCreation.1' => 'Instance' + ], + 'connect_timeout' => 5.0, + 'timeout' => 15.0 + ]) + ->request(); + }, 'selectAvailableZone'); + + $zones = $result['Zones']['Zone'] ?? []; + foreach ($zones as $zone) { + if (!empty($zone['ZoneId'])) { + return [ + 'zoneId' => $zone['ZoneId'], + 'raw' => $zone + ]; + } + } + + throw new \Exception("当前区域 {$regionId} 下未找到规格 {$instanceType} 的可用区库存"); + } + + private function describeInstanceType($key, $secret, $regionId, $instanceType) + { + $result = $this->executeWithRetry(function () use ($key, $secret, $regionId, $instanceType) { + $this->setDefaultClient($key, $secret, $regionId); + + return AlibabaCloud::rpc() + ->product('Ecs') + ->scheme('https') + ->version('2014-05-26') + ->action('DescribeInstanceTypes') + ->method('POST') + ->host($this->ecsHost($regionId)) + ->options([ + 'query' => [ + 'RegionId' => $regionId, + 'InstanceTypes' => json_encode([$instanceType]) + ], + 'connect_timeout' => 5.0, + 'timeout' => 15.0 + ]) + ->request(); + }, 'describeInstanceType'); + + $types = $result['InstanceTypes']['InstanceType'] ?? []; + foreach ($types as $type) { + if (($type['InstanceTypeId'] ?? '') === $instanceType) { + return $type; + } + } + + return $types[0] ?? []; + } + + private function selectSystemImage($key, $secret, $regionId, $osKey, $cpuArchitecture = '') + { + $profiles = [ + 'alibaba_cloud_linux_3' => ['label' => 'Alibaba Cloud Linux 3', 'osType' => 'linux', 'platform' => 'Aliyun', 'patterns' => ['aliyun_3', 'alibaba cloud linux 3']], + 'ubuntu_22' => ['label' => 'Ubuntu 22.04', 'osType' => 'linux', 'platform' => 'Ubuntu', 'patterns' => ['ubuntu_22', 'ubuntu 22', '22_04']], + 'ubuntu_24' => ['label' => 'Ubuntu 24.04', 'osType' => 'linux', 'platform' => 'Ubuntu', 'patterns' => ['ubuntu_24', 'ubuntu 24', '24_04']], + 'debian_12' => ['label' => 'Debian 12', 'osType' => 'linux', 'platform' => 'Debian', 'patterns' => ['debian_12', 'debian 12']], + 'centos_stream_9' => ['label' => 'CentOS Stream 9', 'osType' => 'linux', 'platform' => 'CentOS', 'patterns' => ['centos_stream_9', 'centos stream 9']], + 'windows_2022' => ['label' => 'Windows Server 2022', 'osType' => 'windows', 'platform' => 'Windows Server', 'patterns' => ['win2022', 'windows server 2022']] + ]; + $profile = $profiles[$osKey] ?? $profiles['ubuntu_22']; + $architecture = $this->normalizeImageArchitecture($cpuArchitecture); + + $result = $this->executeWithRetry(function () use ($key, $secret, $regionId, $profile, $architecture) { + $this->setDefaultClient($key, $secret, $regionId); + + $query = [ + 'RegionId' => $regionId, + 'ImageOwnerAlias' => 'system', + 'OSType' => $profile['osType'], + 'Status' => 'Available', + 'PageSize' => 100 + ]; + + if (!empty($profile['platform'])) { + $query['Platform'] = $profile['platform']; + } + if ($architecture !== '') { + $query['Architecture'] = $architecture; + } + + return AlibabaCloud::rpc() + ->product('Ecs') + ->scheme('https') + ->version('2014-05-26') + ->action('DescribeImages') + ->method('POST') + ->host($this->ecsHost($regionId)) + ->options([ + 'query' => $query, + 'connect_timeout' => 5.0, + 'timeout' => 15.0 + ]) + ->request(); + }, 'selectSystemImage'); + + $images = $result['Images']['Image'] ?? []; + usort($images, function ($a, $b) { + return strcmp((string) ($b['CreationTime'] ?? ''), (string) ($a['CreationTime'] ?? '')); + }); + + foreach ($images as $image) { + $haystack = strtolower(($image['ImageId'] ?? '') . ' ' . ($image['ImageName'] ?? '') . ' ' . ($image['Description'] ?? '')); + foreach ($profile['patterns'] as $pattern) { + if (strpos($haystack, strtolower($pattern)) !== false) { + return [ + 'imageId' => $image['ImageId'], + 'label' => $profile['label'], + 'osType' => $profile['osType'], + 'size' => (int) ($image['Size'] ?? 0) + ]; + } + } + } + + throw new \Exception("当前区域未找到可用系统镜像:{$profile['label']}"); + } + + private function normalizeImageArchitecture($cpuArchitecture) + { + $value = strtolower((string) $cpuArchitecture); + if (strpos($value, 'arm') !== false || strpos($value, 'aarch64') !== false) { + return 'arm64'; + } + if (strpos($value, 'x86') !== false || strpos($value, 'amd64') !== false || strpos($value, 'i386') !== false) { + return 'x86_64'; + } + return ''; + } + + private function ensureVpc($key, $secret, $regionId, $name, $cidr) + { + $existing = $this->describeManagedVpcs($key, $secret, $regionId); + if (!empty($existing)) { + return $existing[0]; + } + + $result = $this->executeWithRetry(function () use ($key, $secret, $regionId, $name, $cidr) { + $this->setDefaultClient($key, $secret, $regionId); + + return AlibabaCloud::rpc() + ->product('Vpc') + ->scheme('https') + ->version('2016-04-28') + ->action('CreateVpc') + ->method('POST') + ->host($this->vpcHost($regionId)) + ->options([ + 'query' => [ + 'RegionId' => $regionId, + 'VpcName' => $name, + 'CidrBlock' => $cidr, + 'Tag.1.Key' => $this->managedTagKey, + 'Tag.1.Value' => $this->managedTagValue + ], + 'connect_timeout' => 5.0, + 'timeout' => 15.0 + ]) + ->request(); + }, 'ensureVpc'); + + return ['VpcId' => $result['VpcId'] ?? '', 'VpcName' => $name, 'CidrBlock' => $cidr]; + } + + private function ensureVSwitch($key, $secret, $regionId, $zoneId, $vpcId, $name, $cidr) + { + $existing = $this->describeManagedVSwitches($key, $secret, $regionId, $vpcId, $zoneId); + if (!empty($existing)) { + return $existing[0]; + } + + $result = $this->executeWithRetry(function () use ($key, $secret, $regionId, $zoneId, $vpcId, $name, $cidr) { + $this->setDefaultClient($key, $secret, $regionId); + + return AlibabaCloud::rpc() + ->product('Vpc') + ->scheme('https') + ->version('2016-04-28') + ->action('CreateVSwitch') + ->method('POST') + ->host($this->vpcHost($regionId)) + ->options([ + 'query' => [ + 'RegionId' => $regionId, + 'ZoneId' => $zoneId, + 'VpcId' => $vpcId, + 'VSwitchName' => $name, + 'CidrBlock' => $cidr, + 'Tag.1.Key' => $this->managedTagKey, + 'Tag.1.Value' => $this->managedTagValue + ], + 'connect_timeout' => 5.0, + 'timeout' => 20.0 + ]) + ->request(); + }, 'ensureVSwitch'); + + return ['VSwitchId' => $result['VSwitchId'] ?? '', 'VSwitchName' => $name, 'ZoneId' => $zoneId, 'CidrBlock' => $cidr]; + } + + private function ensureSecurityGroup($key, $secret, $regionId, $vpcId, $name) + { + $existing = $this->describeManagedSecurityGroups($key, $secret, $regionId, $vpcId); + if (!empty($existing)) { + return $existing[0]; + } + + $result = $this->executeWithRetry(function () use ($key, $secret, $regionId, $vpcId, $name) { + $this->setDefaultClient($key, $secret, $regionId); + + return AlibabaCloud::rpc() + ->product('Ecs') + ->scheme('https') + ->version('2014-05-26') + ->action('CreateSecurityGroup') + ->method('POST') + ->host($this->ecsHost($regionId)) + ->options([ + 'query' => [ + 'RegionId' => $regionId, + 'VpcId' => $vpcId, + 'SecurityGroupName' => $name, + 'Description' => 'Managed by CDT Monitor', + 'SecurityGroupType' => 'normal', + 'Tag.1.Key' => $this->managedTagKey, + 'Tag.1.Value' => $this->managedTagValue + ], + 'connect_timeout' => 5.0, + 'timeout' => 15.0 + ]) + ->request(); + }, 'ensureSecurityGroup'); + + return ['SecurityGroupId' => $result['SecurityGroupId'] ?? '', 'SecurityGroupName' => $name]; + } + + private function authorizeSecurityGroupRule($key, $secret, $regionId, $securityGroupId, $port, $sourceCidrIp) + { + try { + $this->executeWithRetry(function () use ($key, $secret, $regionId, $securityGroupId, $port, $sourceCidrIp) { + $this->setDefaultClient($key, $secret, $regionId); + + return AlibabaCloud::rpc() + ->product('Ecs') + ->scheme('https') + ->version('2014-05-26') + ->action('AuthorizeSecurityGroup') + ->method('POST') + ->host($this->ecsHost($regionId)) + ->options([ + 'query' => [ + 'RegionId' => $regionId, + 'SecurityGroupId' => $securityGroupId, + 'IpProtocol' => 'tcp', + 'PortRange' => "{$port}/{$port}", + 'SourceCidrIp' => $sourceCidrIp, + 'Policy' => 'accept', + 'Priority' => '1', + 'Description' => 'CDT Monitor managed remote access' + ], + 'connect_timeout' => 5.0, + 'timeout' => 15.0 + ]) + ->request(); + }, 'authorizeSecurityGroupRule'); + } catch (\Exception $e) { + if (stripos($e->getMessage(), 'InvalidPermission.Duplicate') === false) { + throw $e; + } + } + } + + private function authorizeOpenSecurityGroupRules($key, $secret, $regionId, $securityGroupId) + { + $rules = [ + ['protocol' => 'tcp', 'port' => '1/65535'], + ['protocol' => 'udp', 'port' => '1/65535'], + ['protocol' => 'icmp', 'port' => '-1/-1'] + ]; + + foreach ($rules as $rule) { + try { + $this->executeWithRetry(function () use ($key, $secret, $regionId, $securityGroupId, $rule) { + $this->setDefaultClient($key, $secret, $regionId); + + return AlibabaCloud::rpc() + ->product('Ecs') + ->scheme('https') + ->version('2014-05-26') + ->action('AuthorizeSecurityGroup') + ->method('POST') + ->host($this->ecsHost($regionId)) + ->options([ + 'query' => [ + 'RegionId' => $regionId, + 'SecurityGroupId' => $securityGroupId, + 'IpProtocol' => $rule['protocol'], + 'PortRange' => $rule['port'], + 'SourceCidrIp' => '0.0.0.0/0', + 'Policy' => 'accept', + 'Priority' => '1', + 'Description' => 'CDT Monitor open access' + ], + 'connect_timeout' => 5.0, + 'timeout' => 15.0 + ]) + ->request(); + }, 'authorizeOpenSecurityGroupRules'); + } catch (\Exception $e) { + if (stripos($e->getMessage(), 'InvalidPermission.Duplicate') === false) { + throw $e; + } + } + } + } + + private function runInstance($key, $secret, $regionId, array $params) + { + $result = $this->executeWithRetry(function () use ($key, $secret, $regionId, $params) { + $this->setDefaultClient($key, $secret, $regionId); + + return AlibabaCloud::rpc() + ->product('Ecs') + ->scheme('https') + ->version('2014-05-26') + ->action('RunInstances') + ->method('POST') + ->host($this->ecsHost($regionId)) + ->options([ + 'query' => [ + 'RegionId' => $regionId, + 'ZoneId' => $params['zoneId'], + 'InstanceType' => $params['instanceType'], + 'ImageId' => $params['imageId'], + 'SecurityGroupId' => $params['securityGroupId'], + 'VSwitchId' => $params['vSwitchId'], + 'InstanceName' => $params['instanceName'], + 'HostName' => preg_replace('/[^a-zA-Z0-9-]/', '-', strtolower($params['instanceName'])), + 'Password' => $params['password'], + 'InstanceChargeType' => 'PostPaid', + 'InternetChargeType' => 'PayByTraffic', + 'InternetMaxBandwidthOut' => (int) $params['internetMaxBandwidthOut'], + 'SystemDisk.Category' => $params['systemDiskCategory'], + 'SystemDisk.Size' => (int) $params['systemDiskSize'], + 'DeletionProtection' => 'false', + 'IoOptimized' => 'optimized', + 'Amount' => 1, + 'Tag.1.Key' => $this->managedTagKey, + 'Tag.1.Value' => $this->managedTagValue + ], + 'connect_timeout' => 5.0, + 'timeout' => 25.0 + ]) + ->request(); + }, 'runInstance', 1); + + return $result['InstanceIdSets']['InstanceIdSet'] ?? []; + } + + private function waitInstanceReady($key, $secret, $regionId, $instanceId) + { + $last = null; + for ($i = 0; $i < 18; $i++) { + sleep($i === 0 ? 2 : 5); + $instances = $this->describeInstancesByIds($key, $secret, $regionId, [$instanceId]); + if (!empty($instances)) { + $last = $instances[0]; + if (in_array($last['status'], ['Running', 'Stopped'], true)) { + return $last; + } + } + } + + return $last ?: ['status' => 'Unknown']; + } + + private function describeInstancesByIds($key, $secret, $regionId, array $instanceIds) + { + $result = $this->executeWithRetry(function () use ($key, $secret, $regionId, $instanceIds) { + $this->setDefaultClient($key, $secret, $regionId); + + return AlibabaCloud::rpc() + ->product('Ecs') + ->scheme('https') + ->version('2014-05-26') + ->action('DescribeInstances') + ->method('POST') + ->host($this->ecsHost($regionId)) + ->options([ + 'query' => [ + 'RegionId' => $regionId, + 'InstanceIds' => json_encode(array_values($instanceIds)) + ], + 'connect_timeout' => 5.0, + 'timeout' => 15.0 + ]) + ->request(); + }, 'describeInstancesByIds'); + + $items = $result['Instances']['Instance'] ?? []; + return array_map(function ($instance) { + return [ + 'instanceId' => $instance['InstanceId'] ?? '', + 'instanceName' => $instance['InstanceName'] ?? '', + 'status' => $instance['Status'] ?? 'Unknown', + 'instanceType' => $instance['InstanceType'] ?? '', + 'internetMaxBandwidthOut' => (int) ($instance['InternetMaxBandwidthOut'] ?? 0), + 'publicIp' => $instance['PublicIpAddress']['IpAddress'][0] ?? $instance['EipAddress']['IpAddress'] ?? '', + 'privateIp' => $instance['VpcAttributes']['PrivateIpAddress']['IpAddress'][0] ?? '' + ]; + }, $items); + } + + private function describeManagedVpcs($key, $secret, $regionId) + { + $result = $this->executeWithRetry(function () use ($key, $secret, $regionId) { + $this->setDefaultClient($key, $secret, $regionId); + + return AlibabaCloud::rpc() + ->product('Vpc') + ->scheme('https') + ->version('2016-04-28') + ->action('DescribeVpcs') + ->method('POST') + ->host($this->vpcHost($regionId)) + ->options([ + 'query' => [ + 'RegionId' => $regionId, + 'Tag.1.Key' => $this->managedTagKey, + 'Tag.1.Value' => $this->managedTagValue + ], + 'connect_timeout' => 5.0, + 'timeout' => 15.0 + ]) + ->request(); + }, 'describeManagedVpcs'); + + return $result['Vpcs']['Vpc'] ?? []; + } + + private function describeManagedVSwitches($key, $secret, $regionId, $vpcId, $zoneId) + { + $result = $this->executeWithRetry(function () use ($key, $secret, $regionId, $vpcId, $zoneId) { + $this->setDefaultClient($key, $secret, $regionId); + + return AlibabaCloud::rpc() + ->product('Vpc') + ->scheme('https') + ->version('2016-04-28') + ->action('DescribeVSwitches') + ->method('POST') + ->host($this->vpcHost($regionId)) + ->options([ + 'query' => [ + 'RegionId' => $regionId, + 'VpcId' => $vpcId, + 'ZoneId' => $zoneId, + 'Tag.1.Key' => $this->managedTagKey, + 'Tag.1.Value' => $this->managedTagValue + ], + 'connect_timeout' => 5.0, + 'timeout' => 15.0 + ]) + ->request(); + }, 'describeManagedVSwitches'); + + return $result['VSwitches']['VSwitch'] ?? []; + } + + private function describeManagedSecurityGroups($key, $secret, $regionId, $vpcId) + { + $result = $this->executeWithRetry(function () use ($key, $secret, $regionId, $vpcId) { + $this->setDefaultClient($key, $secret, $regionId); + + return AlibabaCloud::rpc() + ->product('Ecs') + ->scheme('https') + ->version('2014-05-26') + ->action('DescribeSecurityGroups') + ->method('POST') + ->host($this->ecsHost($regionId)) + ->options([ + 'query' => [ + 'RegionId' => $regionId, + 'VpcId' => $vpcId, + 'Tag.1.Key' => $this->managedTagKey, + 'Tag.1.Value' => $this->managedTagValue + ], + 'connect_timeout' => 5.0, + 'timeout' => 15.0 + ]) + ->request(); + }, 'describeManagedSecurityGroups'); + + return $result['SecurityGroups']['SecurityGroup'] ?? []; + } + + private function defaultMinSystemDiskSize($osKey) + { + return 20; + } + + private function getSystemDiskSizeRange($key, $secret, $regionId, $zoneId, $instanceType, $diskCategory) + { + $result = $this->executeWithRetry(function () use ($key, $secret, $regionId, $zoneId, $instanceType, $diskCategory) { + $this->setDefaultClient($key, $secret, $regionId); + + return AlibabaCloud::rpc() + ->product('Ecs') + ->scheme('https') + ->version('2014-05-26') + ->action('DescribeAvailableResource') + ->method('POST') + ->host($this->ecsHost($regionId)) + ->options([ + 'query' => [ + 'RegionId' => $regionId, + 'ZoneId' => $zoneId, + 'DestinationResource' => 'SystemDisk', + 'ResourceType' => 'instance', + 'InstanceType' => $instanceType, + 'SystemDiskCategory' => $diskCategory, + 'IoOptimized' => 'optimized', + 'NetworkCategory' => 'vpc', + 'InstanceChargeType' => 'PostPaid' + ], + 'connect_timeout' => 5.0, + 'timeout' => 15.0 + ]) + ->request(); + }, 'getSystemDiskSizeRange'); + + $zones = $result['AvailableZones']['AvailableZone'] ?? []; + foreach ($zones as $zone) { + $resources = $zone['AvailableResources']['AvailableResource'] ?? []; + foreach ($resources as $resource) { + if (($resource['Type'] ?? '') !== 'SystemDisk') { + continue; + } + + $supported = $resource['SupportedResources']['SupportedResource'] ?? []; + foreach ($supported as $item) { + $value = $item['Value'] ?? ''; + if ($value !== '' && $value !== $diskCategory) { + continue; + } + + return [ + 'min' => max(1, (int) ($item['Min'] ?? 20)), + 'max' => max(1, (int) ($item['Max'] ?? 2048)), + 'unit' => $item['Unit'] ?? 'GiB', + 'status' => $item['Status'] ?? '', + 'statusCategory' => $item['StatusCategory'] ?? '' + ]; + } + } + } + + throw new \Exception("当前可用区/规格/磁盘类型未返回系统盘容量范围,请更换磁盘类型或实例规格后重试"); + } + + private function normalizeSystemDiskSize($value, array $range = []) + { + $size = (int) $value; + $min = (int) ($range['min'] ?? 20); + $max = (int) ($range['max'] ?? 2048); + $unit = $range['unit'] ?? 'GiB'; + + if ($size < $min || $size > $max) { + throw new \Exception("系统盘大小必须在当前 API 返回范围 {$min}-{$max} {$unit} 之间"); + } + return $size; + } + + private function isDiskSizeError($message) + { + $message = strtolower((string) $message); + return strpos($message, 'systemdisk.size') !== false + || strpos($message, 'invalidsystemdisksize') !== false + || (strpos($message, 'disk') !== false && strpos($message, 'size') !== false); + } + + private function selectDiskCategory($zone) + { + $raw = $zone['raw']['AvailableDiskCategories']['DiskCategories'] ?? $zone['raw']['AvailableDiskCategories']['DiskCategory'] ?? []; + $categories = is_array($raw) ? $raw : []; + foreach (['cloud_essd', 'cloud_auto', 'cloud_efficiency', 'cloud'] as $preferred) { + if (empty($categories) || in_array($preferred, $categories, true)) { + return $preferred; + } + } + return 'cloud_essd'; + } + + private function estimateMaxBandwidthOut($instanceType, $regionId) + { + return 200; + } + + private function bandwidthCandidates($max) + { + $base = [200, 100, 50, 30, 20, 10, 5, 1]; + $candidates = array_values(array_filter($base, function ($value) use ($max) { + return $value <= max(1, $max); + })); + if (!in_array($max, $candidates, true)) { + array_unshift($candidates, $max); + } + return array_values(array_unique($candidates)); + } + + private function normalizePublicCidr($ip) + { + $ip = trim((string) $ip); + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { + return $ip . '/32'; + } + return ''; + } + + private function cidrForZone($zoneId) + { + $hash = abs(crc32($zoneId)); + $third = 1 + ($hash % 200); + return "172.31.{$third}.0/24"; + } + + private function generateInstancePassword() + { + $lower = 'abcdefghijkmnopqrstuvwxyz'; + $upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; + $digits = '23456789'; + $symbols = '!@#%^*'; + $all = $lower . $upper . $digits . $symbols; + $password = $lower[random_int(0, strlen($lower) - 1)] + . $upper[random_int(0, strlen($upper) - 1)] + . $digits[random_int(0, strlen($digits) - 1)] + . $symbols[random_int(0, strlen($symbols) - 1)]; + for ($i = strlen($password); $i < 16; $i++) { + $password .= $all[random_int(0, strlen($all) - 1)]; + } + return str_shuffle($password); + } + + private function emitProgress($progress, $step) + { + if (is_callable($progress)) { + $progress($step); + } + } + // ==================== BSS 费用中心 API ==================== private $balanceCache = []; @@ -416,4 +1709,4 @@ class AliyunService 'Products' => $products ]; } -} \ No newline at end of file +} diff --git a/AliyunTrafficCheck.php b/AliyunTrafficCheck.php index be55517..223aa6f 100644 --- a/AliyunTrafficCheck.php +++ b/AliyunTrafficCheck.php @@ -5,6 +5,7 @@ require_once 'Database.php'; require_once 'ConfigManager.php'; require_once 'AliyunService.php'; require_once 'NotificationService.php'; +require_once 'DdnsService.php'; use AlibabaCloud\Client\Exception\ClientException; use AlibabaCloud\Client\Exception\ServerException; @@ -15,6 +16,7 @@ class AliyunTrafficCheck private $configManager; private $aliyunService; private $notificationService; + private $ddnsService; private $initError = null; @@ -26,6 +28,7 @@ class AliyunTrafficCheck $this->configManager = new ConfigManager($this->db); $this->aliyunService = new AliyunService(); $this->notificationService = new NotificationService(); + $this->ddnsService = new DdnsService($this->configManager->getAllSettings()); // 注入配置到通知服务 $this->notificationService->setConfig($this->configManager->getAllSettings()); @@ -52,6 +55,16 @@ class AliyunTrafficCheck return $this->configManager->get('admin_password', ''); } + public function getMonitorKey() + { + $key = $this->configManager->get('monitor_key', ''); + if (empty($key)) { + $key = bin2hex(random_bytes(32)); + $this->configManager->saveMonitorKey($key); + } + return $key; + } + public function login($password) { $ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; @@ -62,7 +75,7 @@ class AliyunTrafficCheck $attempts = $this->db->getRecentFailedAttempts($ip, 900); if ($attempts >= 5) { - $this->db->addLog('warning', "登录被锁定: IP {$ip} 尝试次数过多"); + $this->db->addLog('warning', "登录被锁定: 地址 {$ip} 尝试次数过多"); throw new Exception("错误次数过多,请 15 分钟后再试。"); } @@ -70,17 +83,81 @@ class AliyunTrafficCheck if (empty($adminPass)) return false; - if (hash_equals((string) $adminPass, (string) $password)) { + $passwordValid = false; + + if ($this->isPasswordHashed($adminPass)) { + $passwordValid = password_verify($password, $adminPass); + } else { + $passwordValid = hash_equals($adminPass, $password); + if ($passwordValid) { + $this->configManager->upgradePasswordHash($password); + } + } + + if ($passwordValid) { $this->db->clearLoginAttempts($ip); - $this->db->addLog('info', "管理员登录成功 [IP: {$ip}]"); + $this->db->addLog('info', "管理员登录成功 [地址: {$ip}]"); return true; } $this->db->recordLoginAttempt($ip); - $this->db->addLog('warning', "管理员登录失败 [IP: {$ip}]"); + $this->db->addLog('warning', "管理员登录失败 [地址: {$ip}]"); return false; } + private function isPasswordHashed($password) + { + return preg_match('/^\$2[aby]?\$/', $password) === 1 || preg_match('/^\$argon2[aid]\$/', $password) === 1; + } + + private function getAccountLogLabel($account) + { + $remark = trim((string) ($account['remark'] ?? '')); + if ($remark !== '') { + return $remark; + } + + $instanceName = trim((string) ($account['instance_name'] ?? '')); + if ($instanceName !== '') { + return $instanceName; + } + + $instanceId = trim((string) ($account['instance_id'] ?? '')); + if ($instanceId !== '') { + return $instanceId; + } + + return substr((string) ($account['access_key_id'] ?? ''), 0, 7) . '***'; + } + + private function resolveSecretFromDatabase($accessKeyId, $regionId) + { + $pdo = $this->db->getPdo(); + $stmt = $pdo->prepare("SELECT access_key_secret FROM accounts WHERE access_key_id = ? AND region_id = ? LIMIT 1"); + $stmt->execute([$accessKeyId, $regionId]); + $row = $stmt->fetch(); + + if ($row && !empty($row['access_key_secret'])) { + $secret = $this->configManager->decryptAccountSecret($row['access_key_secret']); + if (!empty($secret)) { + return $secret; + } + } + + foreach ($this->configManager->getAccountGroups() as $group) { + if ( + ($group['AccessKeyId'] ?? '') === $accessKeyId + && ($group['regionId'] ?? '') === $regionId + && !empty($group['AccessKeySecret']) + && $group['AccessKeySecret'] !== '********' + ) { + return $group['AccessKeySecret']; + } + } + + throw new Exception('无法读取该账号的AK Secret,请重新输入后保存'); + } + public function setup($data) { if ($this->initError) @@ -105,15 +182,18 @@ class AliyunTrafficCheck return []; $settings = $this->configManager->getAllSettings(); - $accounts = $this->configManager->getAccounts(); + $accountGroups = $this->configManager->getAccountGroups(); + $groupMetrics = $this->configManager->getAccountGroupMetrics(); + $billingMetrics = $this->getAccountGroupBillingMetrics(); $config = [ - 'admin_password' => $settings['admin_password'] ?? '', + 'admin_password' => !empty($settings['admin_password']) ? '********' : '', + 'admin_password_set' => !empty($settings['admin_password']), 'traffic_threshold' => (int) ($settings['traffic_threshold'] ?? 95), - 'enable_schedule_email' => ($settings['enable_schedule_email'] ?? '0') === '1', 'shutdown_mode' => $settings['shutdown_mode'] ?? 'KeepCharging', 'threshold_action' => $settings['threshold_action'] ?? 'stop_and_notify', 'keep_alive' => ($settings['keep_alive'] ?? '0') === '1', + 'monthly_auto_start' => ($settings['monthly_auto_start'] ?? '0') === '1', 'api_interval' => (int) ($settings['api_interval'] ?? 600), 'enable_billing' => ($settings['enable_billing'] ?? '0') === '1', 'Notification' => [ @@ -122,18 +202,18 @@ class AliyunTrafficCheck 'host' => $settings['notify_host'] ?? '', 'port' => $settings['notify_port'] ?? 465, 'username' => $settings['notify_username'] ?? '', - 'password' => $settings['notify_password'] ?? '', + 'password' => !empty($settings['notify_password']) ? '********' : '', 'secure' => $settings['notify_secure'] ?? 'ssl', 'telegram' => [ 'enabled' => ($settings['notify_tg_enabled'] ?? '0') === '1', - 'token' => $settings['notify_tg_token'] ?? '', + 'token' => !empty($settings['notify_tg_token']) ? '********' : '', 'chat_id' => $settings['notify_tg_chat_id'] ?? '', 'proxy_type' => $settings['notify_tg_proxy_type'] ?? 'none', 'proxy_url' => $settings['notify_tg_proxy_url'] ?? '', 'proxy_ip' => $settings['notify_tg_proxy_ip'] ?? '', 'proxy_port' => $settings['notify_tg_proxy_port'] ?? '', 'proxy_user' => $settings['notify_tg_proxy_user'] ?? '', - 'proxy_pass' => $settings['notify_tg_proxy_pass'] ?? '' + 'proxy_pass' => !empty($settings['notify_tg_proxy_pass']) ? '********' : '' ], 'webhook' => [ 'enabled' => ($settings['notify_wh_enabled'] ?? '0') === '1', @@ -144,23 +224,49 @@ class AliyunTrafficCheck 'body' => $settings['notify_wh_body'] ?? '' ] ], + 'Ddns' => [ + 'enabled' => ($settings['ddns_enabled'] ?? '0') === '1', + 'provider' => $settings['ddns_provider'] ?? 'cloudflare', + 'domain' => $settings['ddns_domain'] ?? '', + 'cloudflare' => [ + 'zone_id' => $settings['ddns_cf_zone_id'] ?? '', + 'token' => !empty($settings['ddns_cf_token']) ? '********' : '', + 'proxied' => ($settings['ddns_cf_proxied'] ?? '0') === '1' + ] + ], 'Accounts' => [] ]; - foreach ($accounts as $row) { + foreach ($accountGroups as $row) { + $metrics = $groupMetrics[$row['groupKey']] ?? [ + 'usageUsed' => 0, + 'usageRemaining' => (float) ($row['maxTraffic'] ?? 0), + 'usagePercent' => 0, + 'instanceCount' => 0, + 'lastUpdated' => 0 + ]; $config['Accounts'][] = [ - 'AccessKeyId' => $row['access_key_id'], - 'AccessKeySecret' => $row['access_key_secret'], - 'regionId' => $row['region_id'], - 'instanceId' => $row['instance_id'], - 'maxTraffic' => (float) $row['max_traffic'], - 'schedule' => [ - 'enabled' => $row['schedule_enabled'] == 1, - 'startTime' => $row['start_time'], - 'stopTime' => $row['stop_time'] - ], + 'AccessKeyId' => $row['AccessKeyId'], + 'AccessKeySecret' => '********', + 'AccessKeySecretSet' => !empty($row['AccessKeySecret']), + 'regionId' => $row['regionId'], + 'maxTraffic' => (float) $row['maxTraffic'], 'remark' => $row['remark'] ?? '', - 'siteType' => $row['site_type'] ?? 'china' + 'siteType' => $row['siteType'] ?? 'international', + 'groupKey' => $row['groupKey'] ?? '', + 'usageUsed' => round((float) ($metrics['usageUsed'] ?? 0), 6), + 'usageRemaining' => round((float) ($metrics['usageRemaining'] ?? 0), 6), + 'usagePercent' => round((float) ($metrics['usagePercent'] ?? 0), 2), + 'instanceCount' => (int) ($metrics['instanceCount'] ?? 0), + 'usageLastUpdated' => !empty($metrics['lastUpdated']) ? date('Y-m-d H:i:s', (int) $metrics['lastUpdated']) : '', + 'billing' => $billingMetrics[$row['groupKey']] ?? [ + 'enabled' => ($settings['enable_billing'] ?? '0') === '1', + 'monthly_cost' => null, + 'balance' => null, + 'currency' => ($row['siteType'] ?? 'international') === 'international' ? 'USD' : 'CNY', + 'last_updated' => null, + 'error' => null + ] ]; } @@ -183,8 +289,25 @@ class AliyunTrafficCheck // 仅返回最近 20 条 $logs = $this->db->getLogsByTypes($types, 20); + $accounts = $this->configManager->getAccounts(); + $accessKeyMap = []; + + foreach ($accounts as $account) { + $label = $this->getAccountLogLabel($account); + $accessKeyId = trim((string) ($account['access_key_id'] ?? '')); + if ($accessKeyId === '') { + continue; + } + + $accessKeyMap[$accessKeyId] = $label; + $accessKeyMap[substr($accessKeyId, 0, 7) . '***'] = $label; + } foreach ($logs as &$log) { + foreach ($accessKeyMap as $key => $label) { + $log['message'] = str_replace("[$key]", "[$label]", $log['message']); + $log['message'] = str_replace($key, $label, $log['message']); + } $log['time_str'] = date('Y-m-d H:i:s', $log['created_at']); } return $logs; @@ -220,9 +343,6 @@ class AliyunTrafficCheck if (!$account) return ['error' => 'Account not found']; - if (!$account) - return ['error' => 'Account not found']; - // Use account ID for stats query $rawHourly = $this->db->getHourlyStats($id); $chartHourly = []; @@ -254,7 +374,7 @@ class AliyunTrafficCheck public function monitor() { if ($this->initError) - return "Error: " . $this->initError; + return "错误: " . $this->initError; // 优化:分级清理日志 // 普通/重要日志保留 30 天,高频心跳日志仅保留 3 天 @@ -271,56 +391,28 @@ class AliyunTrafficCheck } $logs = []; - $currentUserTime = date('H:i'); $currentTime = time(); $threshold = (int) $this->configManager->get('traffic_threshold', 95); $shutdownMode = $this->configManager->get('shutdown_mode', 'KeepCharging'); $thresholdAction = $this->configManager->get('threshold_action', 'stop_and_notify'); $keepAlive = $this->configManager->get('keep_alive', '0') === '1'; + $monthlyAutoStart = $this->configManager->get('monthly_auto_start', '0') === '1'; $userInterval = (int) $this->configManager->get('api_interval', 600); $accounts = $this->configManager->getAccounts(); foreach ($accounts as $account) { - $logPrefix = "[{$account['access_key_id']}]"; + $accountLabel = $this->getAccountLogLabel($account); + $logPrefix = "[{$accountLabel}]"; $actions = []; $forceRefresh = false; - $statusTransformed = false; - // 1. 定时任务 - if ($account['schedule_enabled'] == 1) { - if ($account['start_time'] && $currentUserTime === $account['start_time']) { - if ($this->safeControlInstance($account, 'start')) { - $actions[] = "定时启动"; - $this->db->addLog('info', "执行定时启动 [{$account['access_key_id']}]"); - - $mailRes = $this->notificationService->notifySchedule("定时启动", $account, "计划任务已触发,实例正在启动。"); - $this->logNotificationResult($mailRes, $account['access_key_id']); - - $forceRefresh = true; - $statusTransformed = true; - } - } - if ($account['stop_time'] && $currentUserTime === $account['stop_time']) { - if ($this->safeControlInstance($account, 'stop', $shutdownMode)) { - $actions[] = "定时停止({$shutdownMode})"; - $this->db->addLog('info', "执行定时停止 [{$account['access_key_id']}]"); - - $mailRes = $this->notificationService->notifySchedule("定时停止", $account, "计划任务已触发,实例已停止。"); - $this->logNotificationResult($mailRes, $account['access_key_id']); - - $forceRefresh = true; - $statusTransformed = true; - } - } - } - - // 2. 自适应心跳 + // 1. 自适应心跳 $lastUpdate = $account['updated_at'] ?? 0; $cachedStatus = $account['instance_status'] ?? 'Unknown'; $isTransientState = in_array($cachedStatus, ['Starting', 'Stopping', 'Pending', 'Unknown']); - $currentInterval = ($isTransientState || $statusTransformed) ? 60 : $userInterval; + $currentInterval = $isTransientState ? 60 : $userInterval; $shouldCheckApi = $forceRefresh || (($currentTime - $lastUpdate) > $currentInterval); @@ -341,7 +433,7 @@ class AliyunTrafficCheck if ($newTraffic < 0) { $traffic = $account['traffic_used']; - $apiStatusLog = "流量API异常"; + $apiStatusLog = "流量接口异常"; $newUpdateTime = $lastUpdate; } else { $traffic = $newTraffic; @@ -358,6 +450,7 @@ class AliyunTrafficCheck $apiStatusLog .= in_array($status, ['Starting', 'Stopping', 'Pending']) ? " [过渡态]" : " [稳定态]"; } + $this->notifyStatusChangeIfNeeded($account, $cachedStatus, $status, '系统同步检测到实例状态变化。'); $this->configManager->updateAccountStatus($account['id'], $traffic, $status, $newUpdateTime); } else { $traffic = $account['traffic_used']; @@ -367,59 +460,84 @@ class AliyunTrafficCheck } $maxTraffic = $account['max_traffic']; - $usagePercent = ($maxTraffic > 0) ? round(($traffic / $maxTraffic) * 100, 2) : 0; - $trafficDesc = "流量:{$usagePercent}%"; + $accountTraffic = $this->getGroupTrafficUsed($account); + $usagePercent = ($maxTraffic > 0) ? round(($accountTraffic / $maxTraffic) * 100, 2) : 0; + $trafficDesc = "账号出口流量:{$usagePercent}%"; $isOverThreshold = $usagePercent >= $threshold; + $isHardLimitExceeded = $maxTraffic > 0 && $accountTraffic >= $maxTraffic; + $requiresTrafficProtection = $isOverThreshold || $isHardLimitExceeded; - // 3. 流量熔断 - if ($isOverThreshold) { - $trafficDesc .= "[警告]"; - if ($shouldCheckApi) { - if ($thresholdAction === 'stop_and_notify') { - if ($status !== 'Stopped') { - if ($this->safeControlInstance($account, 'stop', $shutdownMode)) { - $actions[] = "超限关机"; - $this->db->addLog('warning', "流量超限自动关机 [{$account['access_key_id']}] 使用率:{$usagePercent}%"); - $this->configManager->updateAccountStatus($account['id'], $traffic, 'Stopping', $currentTime); - $status = 'Stopping'; - } - } - } else { - $actions[] = "超限告警"; - $this->db->addLog('warning', "流量超限触发告警 [{$account['access_key_id']}] 使用率:{$usagePercent}%"); - } + // 2. 流量熔断 + if ($requiresTrafficProtection) { + $trafficDesc .= $isHardLimitExceeded ? "[已超出上限]" : "[接近上限]"; - $mailRes = $this->notificationService->sendTrafficWarning($account['access_key_id'], $traffic, $usagePercent, implode(',', $actions), $threshold); - $this->logNotificationResult($mailRes, $account['access_key_id']); - } - } + if ($thresholdAction === 'stop_and_notify') { + $canAttemptStop = !in_array($status, ['Stopped', 'Stopping', 'Released'], true); - // 4. 保活逻辑 (跳过已被定时任务操作的实例) - if ($keepAlive && !$isOverThreshold && !$statusTransformed) { - if ($account['schedule_enabled'] == 0 || $this->isTimeInRange($currentUserTime, $account['start_time'], $account['stop_time'])) { - if ($status === 'Stopped') { - if ($this->safeControlInstance($account, 'start')) { - $actions[] = "保活启动"; - $this->db->addLog('info', "执行保活启动 [{$account['access_key_id']}]"); - - $mailRes = $this->notificationService->notifySchedule("保活启动", $account, "检测到实例在工作时段非预期关机,已尝试自动启动。"); - $this->logNotificationResult($mailRes, $account['access_key_id']); - - $this->configManager->updateAccountStatus($account['id'], $traffic, 'Starting', $currentTime); - $status = 'Starting'; + // 达到账号流量上限后必须立即保护,不再等待下一次接口刷新窗口。 + if ($canAttemptStop) { + if ($this->safeControlInstance($account, 'stop', $shutdownMode)) { + $previousStatus = $status; + $actions[] = $isHardLimitExceeded ? "已超量自动停机" : "接近上限自动停机"; + $this->db->addLog('warning', "账号出口流量达到保护线,已自动停机 [{$accountLabel}] 当前使用率:{$usagePercent}%"); + $this->configManager->updateAccountStatus($account['id'], $traffic, 'Stopping', $currentTime); + $this->notifyStatusChangeIfNeeded($account, $previousStatus, 'Stopping', '流量达到保护线,已自动停机。'); + $status = 'Stopping'; } else { - $apiStatusLog .= " [保活启动失败,下次重试]"; + $actions[] = "自动停机失败"; + $this->db->addLog('error', "账号出口流量达到保护线,但自动停机失败 [{$accountLabel}] 当前使用率:{$usagePercent}%"); } } + } elseif ($shouldCheckApi) { + $actions[] = "超量提醒"; + $this->db->addLog('warning', "账号出口流量超限触发提醒 [{$accountLabel}] 当前使用率:{$usagePercent}%"); + } + + if (!empty($actions)) { + $mailRes = $this->notificationService->sendTrafficWarning($accountLabel, $accountTraffic, $usagePercent, implode(',', $actions), $threshold); + $this->logNotificationResult($mailRes, $accountLabel); + } + } + + // 3. 每月自动开机:只在每月 1 号执行,且不会启动已经触发流量保护的实例。 + $autoStartBlocked = !empty($account['auto_start_blocked']); + if ($monthlyAutoStart && !$autoStartBlocked && !$requiresTrafficProtection && date('j', $currentTime) === '1') { + $lastMonthlyStart = (int) ($account['last_keep_alive_at'] ?? 0); + if ($status === 'Stopped' && !$this->isSameMonth($lastMonthlyStart, $currentTime)) { + if ($this->safeControlInstance($account, 'start')) { + $actions[] = "月初自动开机"; + $this->db->addLog('info', "执行月初自动开机 [{$accountLabel}]"); + $this->configManager->updateAccountStatus($account['id'], $traffic, 'Starting', $currentTime); + $this->configManager->updateLastKeepAlive($account['id'], $currentTime); + $this->notifyStatusChangeIfNeeded($account, 'Stopped', 'Starting', '每月 1 号自动开机已执行。'); + $status = 'Starting'; + } else { + $apiStatusLog .= " [月初自动开机失败,下次重试]"; + } } } - if ($statusTransformed) { - $tempStatus = in_array("定时启动", $actions) ? 'Starting' : 'Stopping'; - $this->configManager->updateAccountStatus($account['id'], $traffic, $tempStatus, $currentTime); - $apiStatusLog .= " -> 强制过渡态"; + // 4. 保活逻辑 + if ($keepAlive && !$autoStartBlocked && !$requiresTrafficProtection) { + if ($status === 'Stopped') { + if ($this->safeControlInstance($account, 'start')) { + $actions[] = "保活启动"; + $this->db->addLog('info', "执行保活启动 [{$accountLabel}]"); + + $mailRes = $this->notificationService->notifySchedule("保活启动", $account, "检测到实例非预期关机,已尝试自动启动。"); + $this->logNotificationResult($mailRes, $accountLabel); + + $this->configManager->updateAccountStatus($account['id'], $traffic, 'Starting', $currentTime); + $this->configManager->updateLastKeepAlive($account['id'], $currentTime); + $this->notifyStatusChangeIfNeeded($account, 'Stopped', 'Starting', '检测到实例非预期关机,保活已尝试自动启动。'); + $status = 'Starting'; + } else { + $apiStatusLog .= " [保活启动失败,下次重试]"; + } + } } + $actionLog = empty($actions) ? "无动作" : implode(", ", $actions); $logLine = sprintf("%s %s | %s | %s | %s", $logPrefix, $actionLog, $trafficDesc, $status, $apiStatusLog); @@ -430,88 +548,44 @@ class AliyunTrafficCheck $this->configManager->updateLastRunTime(time()); + // 执行异步彻底销毁循环 + $this->processPendingReleases(); + return implode(PHP_EOL, $logs); } - public function getStatusForFrontend() + public function getStatusForFrontend($includeSensitive = false) { if ($this->initError) return ['error' => $this->initError]; + $this->configManager->syncAccountGroups(); + $data = []; $threshold = (int) $this->configManager->get('traffic_threshold', 95); $userInterval = (int) $this->configManager->get('api_interval', 600); $billingEnabled = $this->configManager->get('enable_billing', '0') === '1'; - - $currentTime = time(); - $accounts = $this->configManager->getAccounts(); - $billingCycle = date('Y-m'); + $accounts = array_values(array_filter($this->configManager->getAccounts(), function ($account) { + return !empty($account['instance_id']); + })); foreach ($accounts as $account) { - $lastUpdate = $account['updated_at'] ?? 0; - $cachedStatus = $account['instance_status'] ?? 'Unknown'; - $newUpdateTime = $currentTime; + $data[] = $this->buildInstanceSnapshot($account, $threshold, $userInterval, $billingEnabled, $includeSensitive); + } - $isTransientState = in_array($cachedStatus, ['Starting', 'Stopping', 'Pending', 'Unknown']); - $checkInterval = $isTransientState ? 60 : $userInterval; - - if (($currentTime - $lastUpdate) > $checkInterval) { - $newTraffic = $this->safeGetTraffic($account); - $status = $this->safeGetInstanceStatus($account); - - if ($status === 'Unknown') { - usleep(500000); - $status = $this->safeGetInstanceStatus($account); - } - - if ($newTraffic < 0) { - $traffic = $account['traffic_used']; - $newUpdateTime = $lastUpdate; - } else { - $traffic = $newTraffic; - $this->db->addHourlyStat($account['id'], $traffic); - $this->db->addDailyStat($account['id'], $traffic); - } - - if ($status === 'Unknown') { - $newUpdateTime = $lastUpdate; - } - - $this->configManager->updateAccountStatus($account['id'], $traffic, $status, $newUpdateTime); - } else { - $traffic = $account['traffic_used']; - $status = $account['instance_status']; - } - - $usagePercent = ($account['max_traffic'] > 0) ? round(($traffic / $account['max_traffic']) * 100, 2) : 0; - $isFull = $usagePercent >= $threshold; - - $item = [ - 'id' => $account['id'], - 'account' => substr($account['access_key_id'], 0, 7) . '***', - 'flow_total' => (float) $account['max_traffic'], - 'flow_used' => round($traffic, 2), - 'percentageOfUse' => $usagePercent, - 'region' => $account['region_id'], - 'regionName' => $this->getRegionName($account['region_id']), - 'rate95' => $isFull, - 'threshold' => $threshold, - 'instanceStatus' => $status, - 'lastUpdated' => date('Y-m-d H:i:s', $lastUpdate > 0 ? $lastUpdate : $currentTime), - 'remark' => $account['remark'] ?? '' - ]; - - // 注入费用数据 (如果启用) - if ($billingEnabled) { - $item['cost'] = $this->safeGetBillingInfo($account, $billingCycle); - } - - $data[] = $item; + $pendingAccounts = $this->configManager->getPendingReleaseAccounts(); + foreach ($pendingAccounts as $account) { + $snap = $this->buildInstanceSnapshot($account, $threshold, $userInterval, $billingEnabled, $includeSensitive); + $snap['instanceStatus'] = 'Releasing'; + $snap['status'] = 'Releasing'; + $data[] = $snap; } return [ 'data' => $data, - 'system_last_run' => $this->configManager->getLastRunTime() + 'system_last_run' => $this->configManager->getLastRunTime(), + 'sync_interval' => $userInterval, + 'sensitive_visible' => $includeSensitive ]; } @@ -535,9 +609,10 @@ class AliyunTrafficCheck $this->db->addDailyStat($targetAccount['id'], $traffic); } + $this->notifyStatusChangeIfNeeded($targetAccount, $targetAccount['instance_status'] ?? 'Unknown', $status, '手动同步检测到实例状态变化。'); $this->configManager->updateAccountStatus($id, $traffic, $status, $currentTime); - // 刷新账单数据:仅在启用费用监控 且 无有效缓存时调用 BSS API + // 刷新账单数据:仅在启用费用监控 且 无有效缓存时调用 费用中心 接口 $billingError = null; $billingEnabled = $this->configManager->get('enable_billing', '0') === '1'; if ($billingEnabled) { @@ -579,13 +654,355 @@ class AliyunTrafficCheck } if ($billingError) { - $this->db->addLog('warning', "账单刷新异常 [{$targetAccount['access_key_id']}]: {$billingError}"); + $this->db->addLog('warning', "账单刷新异常 [{$this->getAccountLogLabel($targetAccount)}]: {$billingError}"); return ['success' => true, 'billing_error' => $billingError]; } return true; } + public function fetchInstances($accessKeyId, $accessKeySecret, $regionId = '') + { + if ($this->initError) { + throw new Exception($this->initError); + } + + if (empty($accessKeyId) || empty($accessKeySecret)) { + throw new Exception('请先填写AK ID和AK Secret'); + } + + try { + $instances = $this->aliyunService->getInstances($accessKeyId, $accessKeySecret, $regionId ?: null); + $maskedKey = substr($accessKeyId, 0, 7) . '***'; + $this->db->addLog('info', "实例列表获取成功 [{$maskedKey}] 共 " . count($instances) . " 台"); + return $instances; + } catch (ClientException $e) { + $this->db->addLog('warning', "实例列表获取失败: 鉴权错误"); + throw new Exception('阿里云鉴权失败,请检查AK权限或密钥是否正确'); + } catch (ServerException $e) { + $this->db->addLog('warning', "实例列表获取失败: " . $e->getErrorCode() . " - " . strip_tags($e->getErrorMessage())); + throw new Exception('阿里云接口错误 [' . $e->getErrorCode() . ']: ' . $e->getErrorMessage()); + } catch (\Exception $e) { + $this->db->addLog('warning', "实例列表获取失败: " . strip_tags($e->getMessage())); + throw $e; + } + } + + public function testAccountCredentials($account) + { + if ($this->initError) { + throw new Exception($this->initError); + } + + $accessKeyId = trim((string) ($account['AccessKeyId'] ?? '')); + $accessKeySecret = trim((string) ($account['AccessKeySecret'] ?? '')); + $regionId = trim((string) ($account['regionId'] ?? '')); + $maxTraffic = (float) ($account['maxTraffic'] ?? 0); + $accountLabel = trim((string) ($account['remark'] ?? '')) ?: (substr($accessKeyId, 0, 7) . '***'); + + if ($accessKeyId === '' || $accessKeySecret === '' || $regionId === '') { + throw new Exception('请先填写完整的AK、区域和账号流量'); + } + + if ($accessKeySecret === '********') { + $accessKeySecret = $this->resolveSecretFromDatabase($accessKeyId, $regionId); + } + + try { + $regions = $this->aliyunService->getRegions($accessKeyId, $accessKeySecret); + $regionIds = array_column($regions, 'regionId'); + if (!in_array($regionId, $regionIds, true)) { + throw new Exception('当前AK无法访问所选区域,请检查权限范围'); + } + + $instances = $this->aliyunService->getInstances($accessKeyId, $accessKeySecret); + $regionInstances = array_values(array_filter($instances, function ($instance) use ($regionId) { + return ($instance['regionId'] ?? '') === $regionId; + })); + $instanceCount = count($regionInstances); + + $monitorWarning = ''; + $monitorChecked = false; + if (!empty($regionInstances)) { + $probe = $regionInstances[0]; + $endMs = (int) (floor((time() - 90) / 60) * 60 * 1000); + $startMs = max($endMs - (10 * 60 * 1000), 0); + try { + $this->aliyunService->getInstanceOutboundTrafficDelta([ + 'access_key_id' => $accessKeyId, + 'access_key_secret' => $accessKeySecret, + 'instance_id' => $probe['instanceId'] ?? '', + 'public_ip' => $probe['publicIp'] ?? '' + ], $startMs, $endMs); + $monitorChecked = true; + } catch (\Exception $metricException) { + $monitorWarning = '云监控流量探测未通过:' . strip_tags($metricException->getMessage()); + $this->db->addLog('warning', "账号云监控探测异常 [{$accountLabel}]: {$monitorWarning}"); + } + } + + $trafficUsed = (float) ($account['usageUsed'] ?? 0); + $trafficRemaining = max(round($maxTraffic - $trafficUsed, 2), 0); + $trafficPercent = $maxTraffic > 0 ? min(round(($trafficUsed / $maxTraffic) * 100, 2), 100) : 0; + $this->db->addLog('info', "账号测试成功 [{$accountLabel}] {$regionId} 实例 {$instanceCount} 台"); + $message = 'AK可用,ECS API已接通'; + if ($monitorWarning !== '') { + $message .= ';' . $monitorWarning; + } elseif ($monitorChecked) { + $message .= ',云监控 接口 已接通'; + } else { + $message .= ';当前区域暂无实例,未执行云监控流量探测'; + } + + return [ + 'success' => true, + 'message' => $message, + 'monitorWarning' => $monitorWarning, + 'usageUsed' => $trafficUsed, + 'usageRemaining' => $trafficRemaining, + 'usagePercent' => $trafficPercent, + 'instanceCount' => $instanceCount + ]; + } catch (ClientException $e) { + $message = '鉴权失败,请检查AK ID和AK Secret是否正确,或确认是否具备ECS 权限'; + $this->db->addLog('warning', "账号测试失败: {$message}"); + throw new Exception($message); + } catch (ServerException $e) { + $message = '阿里云接口错误 [' . $e->getErrorCode() . ']: ' . $e->getErrorMessage(); + $this->db->addLog('warning', "账号测试失败: {$message}"); + throw new Exception($message); + } catch (Exception $e) { + $this->db->addLog('warning', "账号测试失败: " . strip_tags($e->getMessage())); + throw $e; + } + } + + public function previewEcsCreate($data) + { + if ($this->initError) { + throw new Exception($this->initError); + } + + $groupKey = trim((string) ($data['accountGroupKey'] ?? '')); + if ($groupKey === '') { + throw new Exception('请选择用于创建 ECS 的账号'); + } + + $account = $this->resolveAccountGroupForCreate($groupKey, $data['regionId'] ?? ''); + $preview = $this->aliyunService->buildEcsCreatePreview($account, $data, $this->detectClientPublicIp()); + $previewId = 'preview_' . bin2hex(random_bytes(12)); + + $this->db->addLog('info', "ECS 创建预检完成 [{$preview['account']['label']}] {$preview['regionId']} {$preview['instanceType']}"); + + return [ + 'success' => true, + 'previewId' => $previewId, + 'summary' => $preview, + 'pricing' => $preview['pricing'], + 'warnings' => $preview['warnings'] + ]; + } + + public function createEcsFromPreview($previewId, array $preview) + { + if ($this->initError) { + throw new Exception($this->initError); + } + + if (empty($preview['account']['groupKey'])) { + throw new Exception('创建预检已失效,请重新预检'); + } + + $groupKey = $preview['account']['groupKey']; + $account = $this->resolveAccountGroupForCreate($groupKey, $preview['regionId'] ?? ''); + $taskId = 'ecs_' . bin2hex(random_bytes(10)); + + // 创建新 ECS 不应顺手拉起客户已有的停机实例。先把当前已停机实例视为“有意停机”,保活逻辑会跳过它们。 + $this->configManager->blockCurrentlyStoppedInstances(); + + $this->db->createEcsCreateTask( + $taskId, + $previewId, + $groupKey, + $preview['regionId'], + $preview['instanceType'], + $preview + ); + + $progress = function ($step) use ($taskId) { + $this->db->updateEcsCreateTask($taskId, ['step' => $step]); + }; + + try { + $result = $this->aliyunService->createManagedEcsFromPreview($account, $preview, $progress); + $this->db->updateEcsCreateTask($taskId, [ + 'zone_id' => $preview['zoneId'] ?? '', + 'image_id' => $preview['imageId'] ?? '', + 'os_label' => $preview['osLabel'] ?? '', + 'instance_name' => $preview['instanceName'] ?? '', + 'vpc_id' => $result['vpcId'] ?? '', + 'vswitch_id' => $result['vswitchId'] ?? '', + 'security_group_id' => $result['securityGroupId'] ?? '', + 'internet_max_bandwidth_out' => $result['internetMaxBandwidthOut'] ?? 0, + 'system_disk_category' => $result['systemDiskCategory'] ?? '', + 'system_disk_size' => $result['systemDiskSize'] ?? 0, + 'instance_id' => $result['instanceId'] ?? '', + 'public_ip' => $result['publicIp'] ?? '', + 'login_user' => $result['loginUser'] ?? '', + 'login_password' => '', + 'status' => 'success', + 'step' => '创建完成' + ]); + + $this->configManager->syncAccountGroups(true); + $this->configManager->load(); + $this->syncDdnsForAccounts($this->configManager->getAccounts(), "ECS 创建后"); + $this->db->addLog('info', "一键创建 ECS成功 [{$this->getAccountLogLabel($account)}] {$result['instanceId']} {$preview['instanceType']} {$preview['regionId']} {$result['internetMaxBandwidthOut']}Mbps"); + $notifyResult = $this->notificationService->notifyEcsCreated($this->getAccountLogLabel($account), $result, $preview); + $this->logNotificationResult($notifyResult, $this->getAccountLogLabel($account)); + + return [ + 'success' => true, + 'taskId' => $taskId, + 'data' => $result + ]; + } catch (Exception $e) { + $this->db->updateEcsCreateTask($taskId, [ + 'status' => 'failed', + 'step' => '创建失败', + 'error_message' => strip_tags($e->getMessage()) + ]); + $this->db->addLog('error', "一键创建 ECS 失败 [{$this->getAccountLogLabel($account)}]: " . strip_tags($e->getMessage())); + throw $e; + } + } + + public function syncAccountGroup($groupKey) + { + if ($this->initError) { + throw new Exception($this->initError); + } + + $groupKey = trim((string) $groupKey); + if ($groupKey === '') { + throw new Exception('缺少账号组标识'); + } + + $groups = $this->configManager->getAccountGroups(); + $targetGroup = null; + foreach ($groups as $group) { + if (($group['groupKey'] ?? '') === $groupKey) { + $targetGroup = $group; + break; + } + } + + if (!$targetGroup) { + throw new Exception('账号组不存在,请刷新页面后重试'); + } + + // syncAccountGroups reconciles the full configured set, so use all groups here + // and filter refresh work to the clicked group afterwards. + $accountsBeforeSync = $this->configManager->getAccounts(); + $this->configManager->syncAccountGroups(true); + $this->configManager->load(); + + $threshold = (int) ($this->configManager->get('traffic_threshold', 95) ?? 95); + $userInterval = (int) ($this->configManager->get('api_interval', 600) ?? 600); + $billingEnabled = $this->configManager->get('enable_billing', '0') === '1'; + $instanceCount = 0; + + foreach ($this->configManager->getAccounts() as $account) { + $accountGroupKey = $account['group_key'] ?: substr(sha1($account['access_key_id'] . '|' . $account['region_id']), 0, 16); + if ($accountGroupKey !== $groupKey || empty($account['instance_id'])) { + continue; + } + + $this->buildInstanceSnapshot($account, $threshold, $userInterval, $billingEnabled, true, true); + $instanceCount++; + } + + if ($billingEnabled) { + $this->getAccountGroupBillingMetrics(true); + } + + $this->configManager->load(); + $this->reconcileDdnsAfterAccountSync($accountsBeforeSync, $this->configManager->getAccounts(), '账号同步'); + $this->db->addLog('info', "账号同步完成 [{$targetGroup['remark']}] {$targetGroup['regionId']} 实例 {$instanceCount} 台"); + + return [ + 'success' => true, + 'message' => "已同步 {$instanceCount} 台实例,流量和消费情况已刷新", + 'instanceCount' => $instanceCount + ]; + } + + public function getEcsCreateTask($taskId) + { + if ($this->initError) { + return null; + } + + return $this->db->getEcsCreateTask($taskId); + } + + private function resolveAccountGroupForCreate($groupKey, $regionId = '') + { + $groups = $this->configManager->getAccountGroups(); + foreach ($groups as $group) { + if (($group['groupKey'] ?? '') !== $groupKey) { + continue; + } + + $resolvedRegion = trim((string) $regionId) ?: ($group['regionId'] ?? ''); + return [ + 'id' => 0, + 'access_key_id' => $group['AccessKeyId'], + 'access_key_secret' => $group['AccessKeySecret'], + 'region_id' => $resolvedRegion, + 'group_key' => $group['groupKey'], + 'remark' => $group['remark'] ?? '', + 'site_type' => $group['siteType'] ?? 'international', + 'max_traffic' => (float) ($group['maxTraffic'] ?? 200), + 'instance_id' => '', + 'instance_name' => '' + ]; + } + + throw new Exception('未找到对应账号,请先在账号管理中保存账号'); + } + + private function detectClientPublicIp() + { + $candidates = []; + foreach (['HTTP_CF_CONNECTING_IP', 'HTTP_X_REAL_IP', 'REMOTE_ADDR'] as $key) { + if (!empty($_SERVER[$key])) { + $candidates[] = trim((string) $_SERVER[$key]); + } + } + + if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { + foreach (explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']) as $item) { + $candidates[] = trim($item); + } + } + + foreach ($candidates as $ip) { + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { + return $ip; + } + } + + $context = stream_context_create(['http' => ['timeout' => 3]]); + $externalIp = @file_get_contents('https://api.ipify.org', false, $context); + $externalIp = trim((string) $externalIp); + if (filter_var($externalIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { + return $externalIp; + } + + return ''; + } + public function sendTestEmail($to) { return $this->notificationService->sendTestEmail($to); @@ -610,41 +1027,161 @@ class AliyunTrafficCheck } } + private function notifyStatusChangeIfNeeded($account, $fromStatus, $toStatus, $reason = '') + { + $fromStatus = (string) ($fromStatus ?: 'Unknown'); + $toStatus = (string) ($toStatus ?: 'Unknown'); + + // 核心过滤: + // 1. 状态未变则不通知 + // 2. 只通知进入稳定态(Running/Stopped)的变化 + // 3. 过滤瞬态跳转,例如从 Starting 到 Running 是预期行为,但如果是在同步中由于 API 抖动导致的跳变则需谨慎 + if ($fromStatus === $toStatus || !in_array($toStatus, ['Running', 'Stopped'], true)) { + return; + } + + // 初次发现 (Unknown) 不通知,避免重启程序时大量刷屏 + // ECS 创建完成后的首次状态同步不通过此逻辑通知(已有专门的 notifyEcsCreated) + if ($fromStatus === 'Unknown' || $this->isRecentlyCreatedInstance($account)) { + return; + } + + // 避免从过渡态到其目标态的冗余通知 + // 例如:刚刚手动触发了 Start,状态变为了 Starting,然后 API 检测到 Running。 + // 这时通常用户已经在界面看到了,或者已有操作成功的提示,可根据需要决定是否通知。 + // 这里保留过渡态到稳定态的通知,但过滤从一个稳定态快速切换到另一个稳定态(如通过脚本极速重启)时的中间干扰。 + + $accountLabel = $this->getAccountLogLabel($account); + $result = $this->notificationService->notifyInstanceStatusChanged($accountLabel, $account, $fromStatus, $toStatus, $reason); + $this->logNotificationResult($result, $accountLabel); + } + + private function isRecentlyCreatedInstance(array $account) + { + $instanceId = trim((string) ($account['instance_id'] ?? '')); + if ($instanceId === '') { + return false; + } + + try { + $stmt = $this->db->getPdo()->prepare(" + SELECT updated_at + FROM ecs_create_tasks + WHERE instance_id = ? + AND status = 'success' + ORDER BY updated_at DESC + LIMIT 1 + "); + $stmt->execute([$instanceId]); + $updatedAt = (int) $stmt->fetchColumn(); + return $updatedAt > 0 && (time() - $updatedAt) < 900; + } catch (Exception $e) { + return false; + } + } + + private function isSameMonth($timestamp, $currentTime) + { + if (empty($timestamp)) { + return false; + } + return date('Y-m', (int) $timestamp) === date('Y-m', (int) $currentTime); + } + private function safeGetTraffic($account) { try { - return $this->aliyunService->getTraffic($account['access_key_id'], $account['access_key_secret'], $account['region_id']); + return $this->getMeteredOutboundTraffic($account); } catch (ClientException $e) { $code = $e->getErrorCode(); - $this->db->addLog('error', "流量查询配置错误: " . ($code ?: "鉴权失败")); + $this->db->addLog('error', "公网出口流量查询配置错误 [{$this->getAccountLogLabel($account)}]: " . ($code ?: "鉴权失败") . ",请确认AK拥有云监控流量查询权限"); return -1; } catch (ServerException $e) { - $this->db->addLog('error', "流量查询失败: 阿里云接口超时"); + $this->db->addLog('error', "公网出口流量查询失败 [{$this->getAccountLogLabel($account)}]: " . $e->getErrorCode() . " - " . $e->getErrorMessage()); return -1; } catch (\Exception $e) { if (strpos($e->getMessage(), 'cURL error') !== false) { - $this->db->addLog('error', "流量查询失败: 网络连接超时"); + $this->db->addLog('error', "公网出口流量查询失败 [{$this->getAccountLogLabel($account)}]: 网络连接超时"); } else { - $this->db->addLog('error', "流量查询失败: 系统未知错误"); + $this->db->addLog('error', "公网出口流量查询失败 [{$this->getAccountLogLabel($account)}]: " . strip_tags($e->getMessage())); } return -1; } } + private function getGroupTrafficUsed($account) + { + $pdo = $this->db->getPdo(); + $groupKey = trim((string) ($account['group_key'] ?? '')); + $billingMonth = date('Y-m'); + + if ($groupKey !== '') { + $stmt = $pdo->prepare("SELECT COALESCE(SUM(traffic_used), 0) FROM accounts WHERE group_key = ? AND traffic_billing_month = ?"); + $stmt->execute([$groupKey, $billingMonth]); + return (float) $stmt->fetchColumn(); + } + + $stmt = $pdo->prepare("SELECT COALESCE(SUM(traffic_used), 0) FROM accounts WHERE access_key_id = ? AND region_id = ? AND traffic_billing_month = ?"); + $stmt->execute([$account['access_key_id'] ?? '', $account['region_id'] ?? '', $billingMonth]); + return (float) $stmt->fetchColumn(); + } + + private function getMeteredOutboundTraffic($account) + { + if (empty($account['id']) || empty($account['instance_id'])) { + throw new Exception('缺少账号 ID 或 Instance ID,无法按实例统计公网出口流量'); + } + + $billingMonth = date('Y-m'); + $monthStartMs = strtotime($billingMonth . '-01 00:00:00') * 1000; + $record = $this->db->getInstanceTrafficUsage($account['id'], $account['instance_id'], $billingMonth); + + $trafficBytes = $record ? (float) ($record['traffic_bytes'] ?? 0) : 0.0; + $lastSampleMs = $record ? (int) ($record['last_sample_ms'] ?? 0) : 0; + if ($lastSampleMs < $monthStartMs) { + $lastSampleMs = $monthStartMs; + $trafficBytes = 0.0; + } + + // 云监控分钟点有轻微延迟,只同步到上一个完整分钟,避免把未收敛的数据点算进去。 + $safeEndSeconds = max(strtotime($billingMonth . '-01 00:00:00'), time() - 90); + $endMs = (int) (floor($safeEndSeconds / 60) * 60 * 1000); + + if ($endMs > $lastSampleMs) { + $delta = $this->aliyunService->getInstanceOutboundTrafficDelta($account, $lastSampleMs, $endMs); + $trafficBytes += (float) ($delta['bytes'] ?? 0); + $lastSampleMs = max($lastSampleMs, (int) ($delta['lastSampleMs'] ?? $lastSampleMs)); + } + + $this->db->upsertInstanceTrafficUsage( + (int) $account['id'], + $account['instance_id'], + $billingMonth, + $trafficBytes, + $lastSampleMs + ); + + return $trafficBytes / 1024 / 1024 / 1024; + } + private function safeGetInstanceStatus($account) { try { return $this->aliyunService->getInstanceStatus($account); } catch (\Exception $e) { - if (strpos($e->getMessage(), 'cURL error') !== false) { - } elseif ($e instanceof ClientException) { - $this->db->addLog('error', "实例状态查询配置错误: 鉴权失败"); - } else { - } return 'Unknown'; } } + private function safeGetInstanceFullStatus($account) + { + try { + return $this->aliyunService->getInstanceFullStatus($account); + } catch (\Exception $e) { + return null; + } + } + private function safeControlInstance($account, $action, $shutdownMode = 'KeepCharging') { try { @@ -653,25 +1190,14 @@ class AliyunTrafficCheck $this->db->addLog('error', "实例操作失败 [{$action}]: 权限不足或配置错误"); return false; } catch (ServerException $e) { - $this->db->addLog('error', "实例操作失败 [{$action}]: 阿里云服务无响应"); + $this->db->addLog('error', "实例操作失败 [{$action}]: " . $e->getErrorCode() . " - " . $e->getErrorMessage()); return false; } catch (\Exception $e) { - $this->db->addLog('error', "实例操作失败 [{$action}]: 无法连接API"); + $this->db->addLog('error', "实例操作失败 [{$action}]: 无法连接接口"); return false; } } - private function isTimeInRange($current, $start, $end) - { - if (!$start || !$end) - return false; - if ($start < $end) { - return $current >= $start && $current < $end; - } else { - return $current >= $start || $current < $end; - } - } - private function getRegionName($regionId) { $regions = [ @@ -750,7 +1276,7 @@ class AliyunTrafficCheck $this->db->setBillingCache($account['id'], 'instance_bill', $billingCycle, $bill); } catch (\Exception $e) { if ($costInfo['error']) { - $costInfo['error'] = 'BSS权限不足'; + $costInfo['error'] = '费用中心权限不足'; } else { $costInfo['error'] = '账单查询失败'; } @@ -762,6 +1288,470 @@ class AliyunTrafficCheck return $costInfo; } + private function getAccountGroupBillingMetrics($forceRefresh = false) + { + if ($this->configManager->get('enable_billing', '0') !== '1') { + return []; + } + + $billingCycle = date('Y-m'); + $groups = $this->configManager->getAccountGroups(); + $accounts = $this->configManager->getAccounts(); + $accountsByGroup = []; + + foreach ($accounts as $account) { + $groupKey = $account['group_key'] ?: ($account['access_key_id'] . '@' . $account['region_id']); + if (!isset($accountsByGroup[$groupKey])) { + $accountsByGroup[$groupKey] = []; + } + $accountsByGroup[$groupKey][] = $account; + } + + $metrics = []; + + foreach ($groups as $group) { + $groupKey = $group['groupKey'] ?? ''; + $row = $accountsByGroup[$groupKey][0] ?? null; + $currency = ($group['siteType'] ?? 'international') === 'international' ? 'USD' : 'CNY'; + $summary = [ + 'enabled' => true, + 'monthly_cost' => null, + 'balance' => null, + 'currency' => $currency, + 'last_updated' => null, + 'error' => null + ]; + + if (!$row) { + $summary['error'] = '尚未同步实例'; + $metrics[$groupKey] = $summary; + continue; + } + + try { + $balanceCache = $forceRefresh ? null : $this->db->getBillingCache($row['id'], 'balance', '', 21600); + if ($balanceCache) { + $summary['balance'] = $balanceCache['AvailableAmount'] ?? null; + $summary['currency'] = $balanceCache['Currency'] ?? $currency; + } else { + $balance = $this->aliyunService->getAccountBalance( + $row['access_key_id'], + $row['access_key_secret'], + $row['site_type'] ?? ($group['siteType'] ?? 'international') + ); + $summary['balance'] = $balance['AvailableAmount'] ?? null; + $summary['currency'] = $balance['Currency'] ?? $currency; + $this->db->setBillingCache($row['id'], 'balance', '', $balance); + } + } catch (\Exception $e) { + $summary['error'] = '余额查询失败'; + } + + try { + $overviewCache = $forceRefresh ? null : $this->db->getBillingCache($row['id'], 'bill_overview', $billingCycle, 21600); + if ($overviewCache) { + $summary['monthly_cost'] = $overviewCache['TotalCost'] ?? null; + } else { + $overview = $this->aliyunService->getBillOverview( + $row['access_key_id'], + $row['access_key_secret'], + $billingCycle, + $row['site_type'] ?? ($group['siteType'] ?? 'international') + ); + $summary['monthly_cost'] = $overview['TotalCost'] ?? null; + $this->db->setBillingCache($row['id'], 'bill_overview', $billingCycle, $overview); + } + } catch (\Exception $e) { + $summary['error'] = $summary['error'] ? '费用中心权限不足' : '账单查询失败'; + } + + $summary['last_updated'] = date('Y-m-d H:i:s'); + $metrics[$groupKey] = $summary; + } + + return $metrics; + } + + public function controlInstanceAction($accountId, $action, $shutdownMode = 'KeepCharging') + { + if ($this->initError) + return false; + + $targetAccount = $this->configManager->getAccountById($accountId); + if (!$targetAccount) + return false; + + try { + $result = $this->aliyunService->controlInstance($targetAccount, $action, $shutdownMode); + if ($result) { + $this->db->addLog('info', "实例操作 [{$action}] 成功 [{$this->getAccountLogLabel($targetAccount)}] {$targetAccount['instance_id']}"); + $newStatus = $action === 'stop' ? 'Stopping' : 'Starting'; + $this->configManager->updateAccountStatus($accountId, $targetAccount['traffic_used'], $newStatus, time()); + $this->configManager->updateAutoStartBlocked($accountId, $action === 'stop'); + if ($action === 'start') { + sleep(8); + $this->configManager->syncAccountGroups(true); + $this->configManager->load(); + $syncedAccount = $this->configManager->getAccountById($accountId); + if (($syncedAccount['instance_status'] ?? '') === 'Running') { + $this->notifyStatusChangeIfNeeded($syncedAccount, $targetAccount['instance_status'] ?? 'Unknown', 'Running', '用户手动启动成功。'); + } + $this->syncDdnsForAccounts($this->configManager->getAccounts(), '实例启动后'); + } + } + return true; + } catch (ClientException $e) { + $this->db->addLog('error', "实例操作失败 [{$action}]: 权限不足或配置错误"); + return false; + } catch (ServerException $e) { + $this->db->addLog('error', "实例操作失败 [{$action}]: " . $e->getErrorCode() . " - " . $e->getErrorMessage()); + return false; + } catch (\Exception $e) { + $this->db->addLog('error', "实例操作失败 [{$action}]: 无法连接接口"); + return false; + } + } + + public function deleteInstanceAction($accountId, $forceStop = false) + { + if ($this->initError) + return false; + + $targetAccount = $this->configManager->getAccountById($accountId); + if (!$targetAccount) + return false; + + // 异步方案:仅标记为删除并记录日志 + $this->db->addLog('warning', "操作成功:秒级标记释放指令已提交,后台安全队列正在接管 [{$this->getAccountLogLabel($targetAccount)}] {$targetAccount['instance_id']}"); + $this->configManager->markAccountAsDeleted($accountId); + + return true; + } + + private function processPendingReleases() + { + $pendingAccounts = $this->configManager->getPendingReleaseAccounts(); + foreach ($pendingAccounts as $account) { + $accountLabel = $this->getAccountLogLabel($account); + try { + $status = $this->aliyunService->getInstanceStatus($account); + } catch (\Exception $e) { + if (stripos($e->getMessage(), 'NotFound') !== false || stripos($e->getMessage(), 'InvalidInstanceId') !== false) { + $status = 'NotFound'; + } else { + $this->db->addLog('error', "后台异步释放引擎探测异常 [{$accountLabel}]: " . $e->getMessage()); + continue; + } + } + + try { + if ($status === 'Stopped') { + $result = $this->aliyunService->deleteInstance($account, false); + if ($result) { + $this->db->addLog('warning', "后台异步彻底销毁成功 [{$accountLabel}] {$account['instance_id']}"); + $releaseNotifyResult = $this->notificationService->notifyInstanceReleased( + $accountLabel, + $account, + '用户前端提交指令后,后台成功执行安全彻底销毁。' + ); + $this->logNotificationResult($releaseNotifyResult, $accountLabel); + + $accountsBeforeDelete = $this->configManager->getAccounts(); + $this->deleteDdnsForAccount($account, $accountsBeforeDelete, '后台实例彻底释放'); + $this->configManager->physicallyDeleteAccount($account['id']); + $this->reconcileDdnsAfterAccountSync($accountsBeforeDelete, $this->configManager->getAccounts(), '异步释放后同步'); + } + } elseif ($status === 'NotFound' || $status === 'Unknown') { + $this->db->addLog('warning', "待释放实例云端已灭迹,自动擦除本地账本 [{$accountLabel}]"); + $this->configManager->physicallyDeleteAccount($account['id']); + } elseif (!in_array($status, ['Stopping'])) { + $this->db->addLog('info', "后台异步释放引擎:向活跃实例下发强制离线指令 [{$accountLabel}]"); + // 仅调用 stop 并允许返回,不产生同步堵塞死循环 + $this->aliyunService->controlInstance($account, 'stop'); + } + } catch (\Exception $e) { + // 如果 DeleteInstance 等遇到暂时性 API 禁止,让它下一分钟随 Cron 重新再轮询一次,不需要人工介入 + $this->db->addLog('error', "后台异步释放行动异常,将于下一分钟轮询重试 [{$accountLabel}]: " . $e->getMessage()); + } + } + } + + /** + * 获取所有已配置账号的实例列表(合并去重) + */ + public function getAllManagedInstances($sync = false) + { + if ($this->initError) + return []; + + if ($sync) { + $accountsBeforeSync = $this->configManager->getAccounts(); + $this->configManager->syncAccountGroups(true); + $this->configManager->load(); + $this->reconcileDdnsAfterAccountSync($accountsBeforeSync, $this->configManager->getAccounts(), '实例手动同步'); + } else { + $this->configManager->load(); + } + + $threshold = (int) ($this->configManager->get('traffic_threshold', 95) ?? 95); + $userInterval = (int) ($this->configManager->get('api_interval', 600) ?? 600); + $accounts = array_values(array_filter($this->configManager->getAccounts(), function ($account) { + return !empty($account['instance_id']); + })); + $allInstances = []; + + foreach ($accounts as $account) { + $allInstances[] = $this->buildInstanceSnapshot($account, $threshold, $userInterval, false, true, $sync); + } + + $pendingAccounts = $this->configManager->getPendingReleaseAccounts(); + foreach ($pendingAccounts as $account) { + $snap = $this->buildInstanceSnapshot($account, $threshold, $userInterval, false, true, $sync); + $snap['instanceStatus'] = 'Releasing'; + $snap['status'] = 'Releasing'; + $allInstances[] = $snap; + } + + return $allInstances; + } + + private function syncDdnsForAccounts(array $accounts, $source = '同步') + { + if (!$this->ddnsService || !$this->ddnsService->isEnabled()) { + return; + } + + $groupCounts = $this->getDdnsGroupCounts($accounts); + + foreach ($accounts as $account) { + if (empty($account['instance_id']) || empty($account['public_ip'])) { + continue; + } + + try { + $recordName = $this->buildDdnsRecordNameForAccount($account, $groupCounts); + + $result = $this->ddnsService->syncARecord($recordName, $account['public_ip']); + if (!empty($result['success']) && empty($result['skipped'])) { + $this->db->addLog('info', "DDNS 已同步 [{$this->getAccountLogLabel($account)}] {$recordName} -> {$account['public_ip']} ({$source})"); + } elseif (empty($result['success'])) { + $this->db->addLog('warning', "DDNS 同步失败 [{$this->getAccountLogLabel($account)}]: " . strip_tags($result['message'] ?? '未知错误')); + } + } catch (Exception $e) { + $this->db->addLog('warning', "DDNS 同步失败 [{$this->getAccountLogLabel($account)}]: " . strip_tags($e->getMessage())); + } + } + } + + private function reconcileDdnsAfterAccountSync(array $beforeAccounts, array $afterAccounts, $source = '同步') + { + if (!$this->ddnsService || !$this->ddnsService->isEnabled()) { + return; + } + + $beforeRecords = $this->getDdnsRecordNamesForAccounts($beforeAccounts); + $afterRecords = $this->getDdnsRecordNamesForAccounts($afterAccounts); + + foreach ($beforeRecords as $instanceId => $recordName) { + if ($recordName === '' || in_array($recordName, $afterRecords, true)) { + continue; + } + $this->deleteDdnsRecord($recordName, $source . '清理'); + } + + $this->syncDdnsForAccounts($afterAccounts, $source); + } + + private function deleteDdnsForAccount(array $account, array $accountsBeforeDelete, $source = '释放') + { + if (!$this->ddnsService || !$this->ddnsService->isEnabled()) { + return; + } + + try { + $recordName = $this->buildDdnsRecordNameForAccount($account, $this->getDdnsGroupCounts($accountsBeforeDelete)); + $this->deleteDdnsRecord($recordName, $source); + } catch (Exception $e) { + $this->db->addLog('warning', "DDNS 清理失败 [{$this->getAccountLogLabel($account)}]: " . strip_tags($e->getMessage())); + } + } + + private function deleteDdnsRecord($recordName, $source = '清理') + { + try { + $result = $this->ddnsService->deleteARecord($recordName); + if (!empty($result['success']) && empty($result['skipped'])) { + $this->db->addLog('info', "DDNS 已删除 {$recordName} ({$source})"); + } elseif (empty($result['success'])) { + $this->db->addLog('warning', "DDNS 删除失败 {$recordName}: " . strip_tags($result['message'] ?? '未知错误')); + } + } catch (Exception $e) { + $this->db->addLog('warning', "DDNS 删除失败 {$recordName}: " . strip_tags($e->getMessage())); + } + } + + private function getDdnsRecordNamesForAccounts(array $accounts) + { + $groupCounts = $this->getDdnsGroupCounts($accounts); + $records = []; + + foreach ($accounts as $account) { + if (empty($account['instance_id'])) { + continue; + } + + try { + $records[$account['instance_id']] = $this->buildDdnsRecordNameForAccount($account, $groupCounts); + } catch (Exception $e) { + $this->db->addLog('warning', "DDNS 记录名生成失败 [{$this->getAccountLogLabel($account)}]: " . strip_tags($e->getMessage())); + } + } + + return $records; + } + + private function buildDdnsRecordNameForAccount(array $account, array $groupCounts) + { + $groupKey = $this->getDdnsGroupKey($account); + return $this->ddnsService->buildRecordName([ + 'account_remark' => $this->resolveGroupRemark($account), + 'remark' => $account['remark'] ?? '', + 'instance_name' => $account['instance_name'] ?? '', + 'instance_id' => $account['instance_id'] ?? '' + ], $groupCounts[$groupKey] ?? 1); + } + + private function getDdnsGroupCounts(array $accounts) + { + $groupCounts = []; + + foreach ($accounts as $account) { + if (empty($account['instance_id'])) { + continue; + } + $groupKey = $this->getDdnsGroupKey($account); + $groupCounts[$groupKey] = ($groupCounts[$groupKey] ?? 0) + 1; + } + + return $groupCounts; + } + + private function getDdnsGroupKey(array $account) + { + return $account['group_key'] ?: (($account['access_key_id'] ?? '') . '|' . ($account['region_id'] ?? '')); + } + + private function resolveGroupRemark(array $account) + { + $groupKey = trim((string) ($account['group_key'] ?? '')); + if ($groupKey !== '') { + foreach ($this->configManager->getAccountGroups() as $group) { + if (($group['groupKey'] ?? '') === $groupKey) { + return trim((string) ($group['remark'] ?? '')); + } + } + } + + return trim((string) ($account['remark'] ?? '')); + } + + private function buildInstanceSnapshot($account, $threshold, $userInterval, $billingEnabled, $includeSensitive = true, $forceRefresh = false) + { + $currentTime = time(); + $lastUpdate = (int) ($account['updated_at'] ?? 0); + $cachedStatus = $account['instance_status'] ?? 'Unknown'; + $newUpdateTime = $currentTime; + + $isTransientState = in_array($cachedStatus, ['Starting', 'Stopping', 'Pending', 'Unknown'], true); + $checkInterval = $isTransientState ? 60 : $userInterval; + + if ($forceRefresh || ($currentTime - $lastUpdate) > $checkInterval) { + $newTraffic = $this->safeGetTraffic($account); + $status = $this->safeGetInstanceStatus($account); + + if ($status === 'Unknown') { + $status = $cachedStatus; + } + + if ($newTraffic < 0) { + $traffic = (float) ($account['traffic_used'] ?? 0); + $newUpdateTime = $lastUpdate; + } else { + $traffic = $newTraffic; + $this->db->addHourlyStat($account['id'], $traffic); + $this->db->addDailyStat($account['id'], $traffic); + } + + if ($newUpdateTime <= 0) { + $newUpdateTime = $currentTime; + } + + $this->notifyStatusChangeIfNeeded($account, $cachedStatus, $status, '页面刷新检测到实例状态变化。'); + + $metadata = []; + // 如果处于运行中且健康状态未知或非 OK,尝试获取详细状态以识别“操作系统启动中” + if ($status === 'Running' && ($account['health_status'] ?? '') !== 'OK') { + $full = $this->safeGetInstanceFullStatus($account); + if ($full) { + $metadata['health_status'] = $full['healthStatus']; + } + } + + $this->configManager->updateAccountStatus($account['id'], $traffic, $status, $newUpdateTime, $metadata); + $lastUpdate = $newUpdateTime; + } else { + $traffic = (float) ($account['traffic_used'] ?? 0); + $status = $cachedStatus; + } + + $maxTraffic = (float) ($account['max_traffic'] ?? 0); + $usagePercent = $maxTraffic > 0 ? round(($traffic / $maxTraffic) * 100, 2) : 0; + $instanceName = $account['instance_name'] ?? ''; + $remark = $account['remark'] ?? ''; + + $accountDisplayLabel = $this->getAccountLogLabel($account); + + $item = [ + 'id' => (int) $account['id'], + 'accountId' => (int) $account['id'], + 'groupKey' => $account['group_key'] ?? '', + 'account' => substr($account['access_key_id'], 0, 7) . '***', + 'accountMasked' => substr($account['access_key_id'], 0, 7) . '***', + 'accountLabel' => $accountDisplayLabel . ' / ' . $this->getRegionName($account['region_id']), + 'flow_total' => $maxTraffic, + 'flow_used' => round($traffic, 6), + 'percentageOfUse' => $usagePercent, + 'region' => $account['region_id'], + 'regionId' => $account['region_id'], + 'regionName' => $this->getRegionName($account['region_id']), + 'rate95' => $usagePercent >= $threshold, + 'threshold' => $threshold, + 'instanceStatus' => $status, + 'status' => $status, + 'healthStatus' => $account['health_status'] ?? 'Unknown', + 'stoppedMode' => $account['stopped_mode'] ?? 'KeepCharging', + 'cpu' => (int) ($account['cpu'] ?? 0), + 'memory' => (int) ($account['memory'] ?? 0), + 'lastUpdated' => date('Y-m-d H:i:s', $lastUpdate > 0 ? $lastUpdate : $currentTime), + 'remark' => $remark !== '' ? $remark : ($instanceName !== '' ? $instanceName : ($account['instance_id'] ?? '')), + 'instanceId' => $account['instance_id'] ?? '', + 'instanceName' => $instanceName, + 'instanceType' => $account['instance_type'] ?? '', + 'osName' => $account['os_name'] ?? '', + 'internetMaxBandwidthOut' => (int) ($account['internet_max_bandwidth_out'] ?? 0), + 'publicIp' => $includeSensitive ? ($account['public_ip'] ?? '') : '', + 'privateIp' => $includeSensitive ? ($account['private_ip'] ?? '') : '', + 'maxTraffic' => $maxTraffic, + 'siteType' => $account['site_type'] ?? 'international' + ]; + + if ($billingEnabled) { + $item['cost'] = $this->safeGetBillingInfo($account, date('Y-m')); + } + + return $item; + } + public function renderTemplate() { if (!file_exists('template.html')) diff --git a/ConfigManager.php b/ConfigManager.php index 112e67e..ec42778 100644 --- a/ConfigManager.php +++ b/ConfigManager.php @@ -2,25 +2,110 @@ class ConfigManager { + private $database; private $db; private $configCache = []; private $accountsCache = []; + private $encryptionKey = null; public function __construct(Database $db) { + $this->database = $db; $this->db = $db->getPdo(); + $this->encryptionKey = $this->getEncryptionKey(); $this->load(); } + private function getEncryptionKey() + { + $keyDir = __DIR__ . '/data'; + $keyFile = $keyDir . '/.secret_encryption.key'; + + if (!is_dir($keyDir)) { + @mkdir($keyDir, 0755, true); + } + + if (@file_exists($keyFile)) { + $key = @file_get_contents($keyFile); + if ($key !== false && strlen($key) === SODIUM_CRYPTO_SECRETBOX_KEYBYTES) { + return $key; + } + } + + $key = random_bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES); + @file_put_contents($keyFile, $key, LOCK_EX); + @chmod($keyFile, 0600); + return $key; + } + + private function encryptValue($value) + { + if (!function_exists('sodium_crypto_secretbox') || empty($value)) { + return $value; + } + $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + $encrypted = sodium_crypto_secretbox($value, $nonce, $this->encryptionKey); + return 'ENC1' . base64_encode($nonce . $encrypted); + } + + private function decryptValue($value) + { + if (!function_exists('sodium_crypto_secretbox') || empty($value) || strlen($value) < 8 || substr($value, 0, 4) !== 'ENC1') { + return $value; + } + $raw = base64_decode(substr($value, 4)); + if ($raw === false) { + return $value; + } + $nonce = substr($raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + $ciphertext = substr($raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + $decrypted = sodium_crypto_secretbox_open($ciphertext, $nonce, $this->encryptionKey); + return $decrypted !== false ? $decrypted : $value; + } + + private function isEncryptedValue($value) + { + return strlen($value) >= 8 && substr($value, 0, 4) === 'ENC1'; + } + public function load() { + $this->configCache = []; $stmt = $this->db->query("SELECT key, value FROM settings"); while ($row = $stmt->fetch()) { $this->configCache[$row['key']] = $row['value']; } - $stmt = $this->db->query("SELECT * FROM accounts ORDER BY id ASC"); + $this->resetMonthlyTrafficCacheIfNeeded(); + + $stmt = $this->db->query("SELECT * FROM accounts WHERE is_deleted = 0 ORDER BY region_id ASC, remark ASC, id ASC"); $this->accountsCache = $stmt->fetchAll(); + + foreach ($this->accountsCache as &$row) { + if (!empty($row['access_key_secret']) && $this->isEncryptedValue($row['access_key_secret'])) { + $row['access_key_secret'] = $this->decryptValue($row['access_key_secret']); + } + } + unset($row); + } + + private function resetMonthlyTrafficCacheIfNeeded() + { + $currentMonth = date('Y-m'); + + // 首次升级时,已有缓存默认视为当前月,避免上线当天误清空。 + $stmt = $this->db->prepare("UPDATE accounts SET traffic_billing_month = ? WHERE traffic_billing_month IS NULL OR traffic_billing_month = ''"); + $stmt->execute([$currentMonth]); + + // 阿里云 CDT/公网流量按自然月结算。月切换后,展示值和熔断判断都必须从当月重新开始。 + $stmt = $this->db->prepare(" + UPDATE accounts + SET traffic_used = 0, + traffic_billing_month = ?, + updated_at = 0 + WHERE traffic_billing_month <> ? + "); + $stmt->execute([$currentMonth, $currentMonth]); } public function get($key, $default = null) @@ -41,12 +126,84 @@ class ConfigManager public function getAccountById($id) { foreach ($this->accountsCache as $acc) { - if ($acc['id'] == $id) + if ((int) $acc['id'] === (int) $id) { return $acc; + } } return null; } + public function decryptAccountSecret($secretFromDb) + { + if (empty($secretFromDb)) { + return ''; + } + return $this->decryptValue($secretFromDb); + } + + public function getAccountGroups() + { + $raw = $this->configCache['account_groups'] ?? ''; + if (!empty($raw)) { + $decoded = json_decode($raw, true); + if (is_array($decoded)) { + return $this->normalizeAccountGroups($decoded, true); + } + } + + return $this->deriveAccountGroupsFromAccounts(); + } + + public function getAccountGroupMetrics() + { + $groups = $this->getAccountGroups(); + $metrics = []; + + foreach ($groups as $group) { + $groupKey = $group['groupKey']; + $maxTraffic = (float) ($group['maxTraffic'] ?? 0); + $metrics[$groupKey] = [ + 'usageUsed' => 0.0, + 'usageRemaining' => $maxTraffic, + 'usagePercent' => 0.0, + 'instanceCount' => 0, + 'lastUpdated' => 0 + ]; + } + + foreach ($this->accountsCache as $row) { + $groupKey = $row['group_key'] ?: $this->buildGroupKey($row['access_key_id'], $row['region_id']); + if (!isset($metrics[$groupKey])) { + $maxTraffic = (float) ($row['max_traffic'] ?? 0); + $metrics[$groupKey] = [ + 'usageUsed' => 0.0, + 'usageRemaining' => $maxTraffic, + 'usagePercent' => 0.0, + 'instanceCount' => 0, + 'lastUpdated' => 0 + ]; + } + + if (!empty($row['instance_id'])) { + $metrics[$groupKey]['instanceCount']++; + } + + $isCurrentMonthTraffic = ($row['traffic_billing_month'] ?? '') === date('Y-m'); + $metrics[$groupKey]['usageUsed'] += $isCurrentMonthTraffic ? (float) ($row['traffic_used'] ?? 0) : 0.0; + $metrics[$groupKey]['lastUpdated'] = max($metrics[$groupKey]['lastUpdated'], (int) ($row['updated_at'] ?? 0)); + } + + foreach ($groups as $group) { + $groupKey = $group['groupKey']; + $maxTraffic = (float) ($group['maxTraffic'] ?? 0); + $used = (float) ($metrics[$groupKey]['usageUsed'] ?? 0); + $metrics[$groupKey]['usageRemaining'] = max($maxTraffic - $used, 0); + $metrics[$groupKey]['usagePercent'] = $maxTraffic > 0 ? min(round(($used / $maxTraffic) * 100, 2), 100) : 0; + } + + return $metrics; + } + public function isInitialized() { return !empty($this->configCache['admin_password']); @@ -59,7 +216,21 @@ class ConfigManager $this->configCache[$key] = $value; } - // --- 新增:心跳时间管理 --- + public function upgradePasswordHash($plainPassword) + { + $hashed = password_hash($plainPassword, PASSWORD_BCRYPT); + $this->saveSetting('admin_password', $hashed); + } + + public function getMonitorKey() + { + return $this->get('monitor_key', ''); + } + + public function saveMonitorKey($key) + { + $this->saveSetting('monitor_key', $key); + } public function updateLastRunTime($time) { @@ -71,177 +242,525 @@ class ConfigManager return (int) ($this->configCache['last_monitor_run'] ?? 0); } - // ------------------------ + public function updateLastInstanceSyncTime($time) + { + $this->saveSetting('last_instance_sync', $time); + } + + public function getLastInstanceSyncTime() + { + return (int) ($this->configCache['last_instance_sync'] ?? 0); + } public function updateConfig($data) { try { $this->db->beginTransaction(); - // 1. 保存全局设置 - $this->saveSetting('admin_password', $data['admin_password']); - $this->saveSetting('traffic_threshold', $data['traffic_threshold']); - $this->saveSetting('enable_schedule_email', $data['enable_schedule_email'] ? '1' : '0'); - $this->saveSetting('shutdown_mode', $data['shutdown_mode']); - $this->saveSetting('threshold_action', $data['threshold_action']); - $this->saveSetting('keep_alive', isset($data['keep_alive']) && $data['keep_alive'] ? '1' : '0'); + $adminPassword = $data['admin_password'] ?? ''; + if (!empty($adminPassword) && $adminPassword !== '********') { + if (!preg_match('/^\$2[aby]?\$/', $adminPassword) && !preg_match('/^\$argon2[aid]\$/', $adminPassword)) { + $adminPassword = password_hash($adminPassword, PASSWORD_BCRYPT); + } + $this->saveSetting('admin_password', $adminPassword); + } + $this->saveSetting('traffic_threshold', $data['traffic_threshold'] ?? 95); + $this->saveSetting('shutdown_mode', $data['shutdown_mode'] ?? 'KeepCharging'); + $this->saveSetting('threshold_action', $data['threshold_action'] ?? 'stop_and_notify'); + $this->saveSetting('keep_alive', !empty($data['keep_alive']) ? '1' : '0'); + $this->saveSetting('monthly_auto_start', !empty($data['monthly_auto_start']) ? '1' : '0'); $this->saveSetting('api_interval', $data['api_interval'] ?? 600); - $this->saveSetting('enable_billing', isset($data['enable_billing']) && $data['enable_billing'] ? '1' : '0'); + $this->saveSetting('enable_billing', !empty($data['enable_billing']) ? '1' : '0'); + $this->saveDdnsSettings($data['Ddns'] ?? []); if (isset($data['Notification'])) { - // Email - $this->saveSetting('notify_email_enabled', isset($data['Notification']['email_enabled']) && $data['Notification']['email_enabled'] ? '1' : '0'); - $this->saveSetting('notify_email', $data['Notification']['email'] ?? ''); - $this->saveSetting('notify_host', $data['Notification']['host'] ?? ''); - $this->saveSetting('notify_port', $data['Notification']['port'] ?? 465); - $this->saveSetting('notify_username', $data['Notification']['username'] ?? ''); - $this->saveSetting('notify_password', $data['Notification']['password'] ?? ''); - $this->saveSetting('notify_secure', $data['Notification']['secure'] ?? 'ssl'); - - // Telegram - if (isset($data['Notification']['telegram'])) { - $tg = $data['Notification']['telegram']; - $this->saveSetting('notify_tg_enabled', isset($tg['enabled']) && $tg['enabled'] ? '1' : '0'); - $this->saveSetting('notify_tg_token', $tg['token'] ?? ''); - $this->saveSetting('notify_tg_chat_id', $tg['chat_id'] ?? ''); - $this->saveSetting('notify_tg_proxy_type', $tg['proxy_type'] ?? 'none'); - $this->saveSetting('notify_tg_proxy_url', $tg['proxy_url'] ?? ''); - $this->saveSetting('notify_tg_proxy_ip', $tg['proxy_ip'] ?? ''); - $this->saveSetting('notify_tg_proxy_port', $tg['proxy_port'] ?? ''); - $this->saveSetting('notify_tg_proxy_user', $tg['proxy_user'] ?? ''); - $this->saveSetting('notify_tg_proxy_pass', $tg['proxy_pass'] ?? ''); - } - - // Webhook - if (isset($data['Notification']['webhook'])) { - $wh = $data['Notification']['webhook']; - $this->saveSetting('notify_wh_enabled', isset($wh['enabled']) && $wh['enabled'] ? '1' : '0'); - $this->saveSetting('notify_wh_url', $wh['url'] ?? ''); - $this->saveSetting('notify_wh_method', $wh['method'] ?? 'GET'); - $this->saveSetting('notify_wh_request_type', $wh['request_type'] ?? 'JSON'); - $this->saveSetting('notify_wh_headers', $wh['headers'] ?? ''); - $this->saveSetting('notify_wh_body', $wh['body'] ?? ''); - } + $this->saveNotificationSettings($data['Notification']); } - // 2. 账号增量同步 - $newAccounts = $data['Accounts'] ?? []; - $stmt = $this->db->query("SELECT id, access_key_id, region_id, instance_id FROM accounts"); - $existingMap = []; - while ($row = $stmt->fetch()) { - // Use composite key for deduplication: AK + Region + InstanceID - $compositeKey = $row['access_key_id'] . '|' . $row['region_id'] . '|' . ($row['instance_id'] ?? ''); - $existingMap[$compositeKey] = $row['id']; - } - - $keptIds = []; - $insertStmt = $this->db->prepare("INSERT INTO accounts (access_key_id, access_key_secret, region_id, instance_id, max_traffic, schedule_enabled, start_time, stop_time, remark, site_type, traffic_used, instance_status, updated_at, last_keep_alive_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 'Unknown', 0, 0)"); - $updateStmt = $this->db->prepare("UPDATE accounts SET access_key_secret = ?, region_id = ?, instance_id = ?, max_traffic = ?, schedule_enabled = ?, start_time = ?, stop_time = ?, remark = ?, site_type = ? WHERE id = ?"); - - foreach ($newAccounts as $acc) { - $key = $acc['AccessKeyId']; - $region = $acc['regionId']; - $instance = $acc['instanceId'] ?? ''; - $compositeKey = $key . '|' . $region . '|' . $instance; - - $params = [ - $acc['AccessKeySecret'], - $region, - $instance, - $acc['maxTraffic'], - ($acc['schedule']['enabled'] ?? false) ? 1 : 0, - $acc['schedule']['startTime'] ?? '', - $acc['schedule']['stopTime'] ?? '', - $acc['remark'] ?? '', - $acc['siteType'] ?? 'china' - ]; - - if (isset($existingMap[$compositeKey])) { - $id = $existingMap[$compositeKey]; - $params[] = $id; - $updateStmt->execute($params); - $keptIds[] = $id; - } else { - $insertParams = [$key]; - array_push($insertParams, ...$params); - $insertStmt->execute($insertParams); - // For new inserts, we need to track the ID to avoid deleting it if user sends duplicate valid entries in one request? - // But assume frontend sends unique list. If not, this logic might add duplicates. - // Ideally we should track inserted IDs too but here we just rely on existingMap keys. - // Actually, if we just inserted, we can't easily get the ID back without lastInsertId but we don't strictly need it for the delete logic below if we assume input list is unique. - // However, to be safe against deleting effectively "new" accounts just added, let's just trust input list defines the "desired state". - // Wait, $idsToDelete is calculated from existingMap vs keptIds. If it's a new insert, it wasn't in existingMap, so it won't be in idsToDelete anyway. - } - } - - // 3. 删除移除的账号 - $idsToDelete = array_diff(array_values($existingMap), $keptIds); - if (!empty($idsToDelete)) { - $placeholders = implode(',', array_fill(0, count($idsToDelete), '?')); - $deleteStmt = $this->db->prepare("DELETE FROM accounts WHERE id IN ($placeholders)"); - $deleteStmt->execute(array_values($idsToDelete)); - } + $groups = $this->normalizeAccountGroups($data['Accounts'] ?? []); + $this->saveSetting('account_groups', json_encode($groups, JSON_UNESCAPED_UNICODE)); + $this->syncAccountGroups(true, $groups); $this->db->commit(); - - // 4. 重排 ID - $this->reorderIds(); - - // 5. 刷新缓存 $this->load(); return true; } catch (Exception $e) { - if ($this->db->inTransaction()) + if ($this->db->inTransaction()) { $this->db->rollBack(); + } + $this->database->addLog('error', "配置保存失败: " . strip_tags($e->getMessage())); return false; } } - private function reorderIds() + public function syncAccountGroups($force = false, $groups = null) { - try { - $this->db->beginTransaction(); - $stmt = $this->db->query("SELECT * FROM accounts ORDER BY id ASC"); - $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + $lastSync = $this->getLastInstanceSyncTime(); + if (!$force && (time() - $lastSync) < 60) { + $this->load(); + return; + } - if (!empty($rows)) { - $this->db->exec("DELETE FROM accounts"); - $this->db->exec("DELETE FROM sqlite_sequence WHERE name='accounts'"); + $groups = $groups === null ? $this->getAccountGroups() : $this->normalizeAccountGroups($groups, true); - $insertStmt = $this->db->prepare("INSERT INTO accounts (id, access_key_id, access_key_secret, region_id, instance_id, max_traffic, schedule_enabled, start_time, stop_time, remark, site_type, traffic_used, instance_status, updated_at, last_keep_alive_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); + $existingRows = $this->db->query("SELECT * FROM accounts ORDER BY id ASC")->fetchAll(PDO::FETCH_ASSOC); + $existingByGroup = []; + $existingByComposite = []; - $newId = 1; - foreach ($rows as $row) { + foreach ($existingRows as $row) { + $groupKey = $row['group_key'] ?: $this->buildGroupKey($row['access_key_id'], $row['region_id']); + $existingByGroup[$groupKey][] = $row; + $existingByComposite[$groupKey . '|' . $row['instance_id']] = $row; + } + + $configuredGroupKeys = []; + + $insertStmt = $this->db->prepare(" + INSERT INTO accounts ( + access_key_id, + access_key_secret, + region_id, + instance_id, + max_traffic, + schedule_enabled, + start_time, + stop_time, + traffic_used, + traffic_billing_month, + instance_status, + updated_at, + last_keep_alive_at, + remark, + site_type, + group_key, + instance_name, + instance_type, + internet_max_bandwidth_out, + public_ip, + private_ip, + cpu, + memory, + os_name, + stopped_mode + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + "); + + $updateStmt = $this->db->prepare(" + UPDATE accounts + SET access_key_id = ?, + access_key_secret = ?, + region_id = ?, + instance_id = ?, + max_traffic = ?, + schedule_enabled = ?, + start_time = ?, + stop_time = ?, + instance_status = ?, + remark = ?, + site_type = ?, + group_key = ?, + instance_name = ?, + instance_type = ?, + internet_max_bandwidth_out = ?, + public_ip = ?, + private_ip = ?, + cpu = ?, + memory = ?, + os_name = ?, + stopped_mode = ? + WHERE id = ? + "); + + foreach ($groups as $group) { + $configuredGroupKeys[] = $group['groupKey']; + + try { + $service = new AliyunService(); + $instances = $service->getInstances($group['AccessKeyId'], $group['AccessKeySecret'], $group['regionId']); + } catch (Exception $e) { + $maskedKey = substr($group['AccessKeyId'], 0, 7) . '***'; + $this->database->addLog('warning', "实例同步失败 [{$maskedKey}] {$group['regionId']}: " . strip_tags($e->getMessage())); + $this->updateGroupBaseSettings($group['groupKey'], $group); + continue; + } + + $remoteInstanceIds = []; + + foreach ($instances as $instance) { + $remoteInstanceIds[] = $instance['instanceId']; + $compositeKey = $group['groupKey'] . '|' . $instance['instanceId']; + $existingRow = $existingByComposite[$compositeKey] ?? null; + $remark = $this->resolveRemark($group, $instance, $existingRow); + + if ($existingRow) { + $updateStmt->execute([ + $group['AccessKeyId'], + $this->encryptValue($group['AccessKeySecret']), + $group['regionId'], + $instance['instanceId'], + $group['maxTraffic'], + 0, + '', + '', + $instance['status'] ?: ($existingRow['instance_status'] ?? 'Unknown'), + $remark, + $group['siteType'], + $group['groupKey'], + $instance['instanceName'] ?? '', + $instance['instanceType'] ?? '', + (int) ($instance['internetMaxBandwidthOut'] ?? 0), + $instance['publicIp'] ?? '', + $instance['privateIp'] ?? '', + (int) ($instance['cpu'] ?? 0), + (int) ($instance['memory'] ?? 0), + $instance['osName'] ?? '', + $instance['stoppedMode'] ?? '', + $existingRow['id'] + ]); + } else { $insertStmt->execute([ - $newId++, - $row['access_key_id'], - $row['access_key_secret'], - $row['region_id'], - $row['instance_id'], - $row['max_traffic'], - $row['schedule_enabled'], - $row['start_time'], - $row['stop_time'], - $row['remark'] ?? '', - $row['site_type'] ?? 'china', - $row['traffic_used'], - $row['instance_status'], - $row['updated_at'], - $row['last_keep_alive_at'] + $group['AccessKeyId'], + $this->encryptValue($group['AccessKeySecret']), + $group['regionId'], + $instance['instanceId'], + $group['maxTraffic'], + 0, + '', + '', + date('Y-m'), + $instance['status'] ?? 'Unknown', + $remark, + $group['siteType'], + $group['groupKey'], + $instance['instanceName'] ?? '', + $instance['instanceType'] ?? '', + (int) ($instance['internetMaxBandwidthOut'] ?? 0), + $instance['publicIp'] ?? '', + $instance['privateIp'] ?? '', + (int) ($instance['cpu'] ?? 0), + (int) ($instance['memory'] ?? 0), + $instance['osName'] ?? '', + $instance['stoppedMode'] ?? '' ]); } } - $this->db->commit(); - } catch (Exception $e) { - if ($this->db->inTransaction()) - $this->db->rollBack(); + + if (!empty($existingByGroup[$group['groupKey']])) { + foreach ($existingByGroup[$group['groupKey']] as $row) { + if (!in_array($row['instance_id'], $remoteInstanceIds, true)) { + $deleteStmt = $this->db->prepare("DELETE FROM accounts WHERE id = ?"); + $deleteStmt->execute([$row['id']]); + } + } + } } + + if (!empty($existingRows)) { + foreach ($existingRows as $row) { + $groupKey = $row['group_key'] ?: $this->buildGroupKey($row['access_key_id'], $row['region_id']); + if (!in_array($groupKey, $configuredGroupKeys, true)) { + $deleteStmt = $this->db->prepare("DELETE FROM accounts WHERE id = ?"); + $deleteStmt->execute([$row['id']]); + } + } + } + + $this->updateLastInstanceSyncTime(time()); + $this->load(); } - public function updateAccountStatus($id, $traffic, $status, $updatedAt) + private function saveNotificationSettings($notification) { - $stmt = $this->db->prepare("UPDATE accounts SET traffic_used = ?, instance_status = ?, updated_at = ? WHERE id = ?"); - return $stmt->execute([$traffic, $status, $updatedAt, $id]); + $this->saveSetting('notify_email_enabled', !empty($notification['email_enabled']) ? '1' : '0'); + $this->saveSetting('notify_email', $notification['email'] ?? ''); + $this->saveSetting('notify_host', $notification['host'] ?? ''); + $this->saveSetting('notify_port', $notification['port'] ?? 465); + $this->saveSetting('notify_username', $notification['username'] ?? ''); + + if (isset($notification['password']) && $notification['password'] !== '********') { + $this->saveSetting('notify_password', $notification['password'] ?? ''); + } + + $this->saveSetting('notify_secure', $notification['secure'] ?? 'ssl'); + + $telegram = $notification['telegram'] ?? []; + $this->saveSetting('notify_tg_enabled', !empty($telegram['enabled']) ? '1' : '0'); + + if (isset($telegram['token']) && $telegram['token'] !== '********') { + $this->saveSetting('notify_tg_token', $telegram['token'] ?? ''); + } + + $this->saveSetting('notify_tg_chat_id', $telegram['chat_id'] ?? ''); + $this->saveSetting('notify_tg_proxy_type', $telegram['proxy_type'] ?? 'none'); + $this->saveSetting('notify_tg_proxy_url', $telegram['proxy_url'] ?? ''); + $this->saveSetting('notify_tg_proxy_ip', $telegram['proxy_ip'] ?? ''); + $this->saveSetting('notify_tg_proxy_port', $telegram['proxy_port'] ?? ''); + $this->saveSetting('notify_tg_proxy_user', $telegram['proxy_user'] ?? ''); + + if (isset($telegram['proxy_pass']) && $telegram['proxy_pass'] !== '********') { + $this->saveSetting('notify_tg_proxy_pass', $telegram['proxy_pass'] ?? ''); + } + + $webhook = $notification['webhook'] ?? []; + $this->saveSetting('notify_wh_enabled', !empty($webhook['enabled']) ? '1' : '0'); + $this->saveSetting('notify_wh_url', $webhook['url'] ?? ''); + $this->saveSetting('notify_wh_method', $webhook['method'] ?? 'GET'); + $this->saveSetting('notify_wh_request_type', $webhook['request_type'] ?? 'JSON'); + $this->saveSetting('notify_wh_headers', $webhook['headers'] ?? ''); + $this->saveSetting('notify_wh_body', $webhook['body'] ?? ''); + } + + private function saveDdnsSettings($ddns) + { + $cloudflare = is_array($ddns['cloudflare'] ?? null) ? $ddns['cloudflare'] : []; + $this->saveSetting('ddns_enabled', !empty($ddns['enabled']) ? '1' : '0'); + $this->saveSetting('ddns_provider', $ddns['provider'] ?? 'cloudflare'); + $this->saveSetting('ddns_domain', trim((string) ($ddns['domain'] ?? ''))); + $this->saveSetting('ddns_cf_zone_id', trim((string) ($cloudflare['zone_id'] ?? ''))); + + $token = $cloudflare['token'] ?? ''; + if ($token !== '********') { + $this->saveSetting('ddns_cf_token', trim((string) $token)); + } + + $this->saveSetting('ddns_cf_proxied', !empty($cloudflare['proxied']) ? '1' : '0'); + } + + private function normalizeAccountGroups(array $groups, $allowEmpty = false) + { + $normalized = []; + + foreach ($groups as $group) { + $accessKeyId = trim((string) ($group['AccessKeyId'] ?? '')); + $accessKeySecret = trim((string) ($group['AccessKeySecret'] ?? '')); + $regionId = trim((string) ($group['regionId'] ?? '')); + + $isPlaceholder = $accessKeySecret === '********'; + if ($isPlaceholder) { + $accessKeySecret = $this->resolveExistingSecret($accessKeyId, $regionId); + } + + if (!$allowEmpty && $accessKeyId === '' && $accessKeySecret === '' && $regionId === '') { + continue; + } + + if ($accessKeyId === '' || $accessKeySecret === '' || $regionId === '') { + if ($isPlaceholder && !$allowEmpty) { + throw new Exception('账号配置缺少 AccessKeySecret'); + } + if ($allowEmpty) { + continue; + } + throw new Exception('账号配置缺少必填项'); + } + + $groupKey = trim((string) ($group['groupKey'] ?? '')); + if ($groupKey === '') { + $groupKey = $this->buildGroupKey($accessKeyId, $regionId); + } + + $normalized[] = [ + 'groupKey' => $groupKey, + 'AccessKeyId' => $accessKeyId, + 'AccessKeySecret' => $accessKeySecret, + 'regionId' => $regionId, + 'siteType' => $group['siteType'] ?? $this->inferSiteType($regionId), + 'maxTraffic' => (float) ($group['maxTraffic'] ?? 200), + 'remark' => trim((string) ($group['remark'] ?? '')) + ]; + } + + return array_values($normalized); + } + + private function resolveExistingSecret($accessKeyId, $regionId) + { + $accessKeyId = trim((string) $accessKeyId); + $regionId = trim((string) $regionId); + + foreach ($this->accountsCache as $row) { + if ($row['access_key_id'] === $accessKeyId && $row['region_id'] === $regionId) { + return $row['access_key_secret']; + } + } + + $groupKey = $this->buildGroupKey($accessKeyId, $regionId); + foreach ($this->accountsCache as $row) { + if (($row['group_key'] === $groupKey) && !empty($row['access_key_secret'])) { + return $row['access_key_secret']; + } + } + + $rawGroups = json_decode((string) ($this->configCache['account_groups'] ?? ''), true); + if (is_array($rawGroups)) { + foreach ($rawGroups as $group) { + $savedAccessKeyId = trim((string) ($group['AccessKeyId'] ?? '')); + $savedRegionId = trim((string) ($group['regionId'] ?? '')); + $savedSecret = trim((string) ($group['AccessKeySecret'] ?? '')); + + if ( + $savedAccessKeyId === $accessKeyId + && $savedRegionId === $regionId + && $savedSecret !== '' + && $savedSecret !== '********' + ) { + return $savedSecret; + } + } + } + + return ''; + } + + private function deriveAccountGroupsFromAccounts() + { + $groups = []; + + foreach ($this->accountsCache as $row) { + $accessKeyId = trim((string) ($row['access_key_id'] ?? '')); + $regionId = trim((string) ($row['region_id'] ?? '')); + if ($accessKeyId === '' || $regionId === '') { + continue; + } + + $groupKey = $row['group_key'] ?: $this->buildGroupKey($accessKeyId, $regionId); + if (isset($groups[$groupKey])) { + continue; + } + + $groups[$groupKey] = [ + 'groupKey' => $groupKey, + 'AccessKeyId' => $accessKeyId, + 'AccessKeySecret' => $row['access_key_secret'] ?? '', + 'regionId' => $regionId, + 'siteType' => $row['site_type'] ?? $this->inferSiteType($regionId), + 'maxTraffic' => (float) ($row['max_traffic'] ?? 200), + 'remark' => $row['remark'] ?? '' + ]; + } + + return array_values($groups); + } + + private function buildGroupKey($accessKeyId, $regionId) + { + return substr(sha1($accessKeyId . '|' . $regionId), 0, 16); + } + + private function inferSiteType($regionId) + { + if (strpos($regionId, 'cn-') === 0 && $regionId !== 'cn-hongkong') { + return 'china'; + } + return 'international'; + } + + private function resolveRemark($group, $instance, $existingRow = null) + { + if (!empty($group['remark'])) { + return $group['remark']; + } + + if ($existingRow) { + $existingRemark = trim((string) ($existingRow['remark'] ?? '')); + $existingName = trim((string) ($existingRow['instance_name'] ?? '')); + if ($existingRemark !== '' && $existingRemark !== $existingName) { + return $existingRemark; + } + } + + if (!empty($instance['instanceName'])) { + return $instance['instanceName']; + } + + return $instance['instanceId'] ?? ''; + } + + private function updateGroupBaseSettings($groupKey, $group) + { + $stmt = $this->db->prepare(" + UPDATE accounts + SET access_key_id = ?, + access_key_secret = ?, + region_id = ?, + max_traffic = ?, + schedule_enabled = ?, + start_time = ?, + stop_time = ?, + site_type = ?, + group_key = ? + WHERE group_key = ? + "); + + $stmt->execute([ + $group['AccessKeyId'], + $this->encryptValue($group['AccessKeySecret']), + $group['regionId'], + $group['maxTraffic'], + 0, + '', + '', + $group['siteType'], + $groupKey, + $groupKey + ]); + } + + public function deleteAccountById($id) + { + $stmt = $this->db->prepare("DELETE FROM accounts WHERE id = ?"); + $stmt->execute([$id]); + $this->load(); + } + + public function markAccountAsDeleted($id) + { + $stmt = $this->db->prepare("UPDATE accounts SET is_deleted = 1 WHERE id = ?"); + $stmt->execute([$id]); + $this->load(); + } + + public function getPendingReleaseAccounts() + { + $stmt = $this->db->query("SELECT * FROM accounts WHERE is_deleted = 1"); + $accounts = $stmt->fetchAll(); + foreach ($accounts as &$row) { + if (!empty($row['access_key_secret']) && $this->isEncryptedValue($row['access_key_secret'])) { + $row['access_key_secret'] = $this->decryptValue($row['access_key_secret']); + } + } + return $accounts; + } + + public function physicallyDeleteAccount($id) + { + // 软删除机制:标记为 2 (彻底销毁完毕状态)。 + // 目的:阻断刚执行完释放后,阿里云 API 缓存尚未更新,导致同步器误认为这是"新冒出来的机器"从而执行重新插入。 + // 此尸体记录会在下一次或下下一次定时同步时,由于彻底在阿里云失联,被同步器的 deleteStmt 收尸清理。 + $stmt = $this->db->prepare("UPDATE accounts SET is_deleted = 2, instance_status = 'Released' WHERE id = ?"); + $stmt->execute([$id]); + // 不强制更新 cache,后台无声息处理。 + } + + public function updateAccountStatus($id, $traffic, $status, $updatedAt, $metadata = []) + { + $sql = "UPDATE accounts SET traffic_used = ?, traffic_billing_month = ?, instance_status = ?, updated_at = ?"; + $params = [$traffic, date('Y-m'), $status, $updatedAt]; + + if (isset($metadata['health_status'])) { + $sql .= ", health_status = ?"; + $params[] = $metadata['health_status']; + } + if (isset($metadata['stopped_mode'])) { + $sql .= ", stopped_mode = ?"; + $params[] = $metadata['stopped_mode']; + } + + $sql .= " WHERE id = ?"; + $params[] = $id; + + $stmt = $this->db->prepare($sql); + return $stmt->execute($params); } public function updateLastKeepAlive($id, $time) @@ -249,4 +768,17 @@ class ConfigManager $stmt = $this->db->prepare("UPDATE accounts SET last_keep_alive_at = ? WHERE id = ?"); return $stmt->execute([$time, $id]); } -} \ No newline at end of file + + public function updateAutoStartBlocked($id, $blocked) + { + $stmt = $this->db->prepare("UPDATE accounts SET auto_start_blocked = ? WHERE id = ?"); + return $stmt->execute([$blocked ? 1 : 0, $id]); + } + + public function blockCurrentlyStoppedInstances() + { + $stmt = $this->db->prepare("UPDATE accounts SET auto_start_blocked = 1 WHERE instance_status = 'Stopped'"); + $stmt->execute(); + $this->load(); + } +} diff --git a/Database.php b/Database.php index 72b0148..a6d8a84 100644 --- a/Database.php +++ b/Database.php @@ -92,9 +92,17 @@ class Database traffic_used REAL DEFAULT 0, instance_status TEXT DEFAULT 'Unknown', updated_at INTEGER DEFAULT 0, - last_keep_alive_at INTEGER DEFAULT 0 + last_keep_alive_at INTEGER DEFAULT 0, + is_deleted INTEGER DEFAULT 0 )"); + // 向下兼容:自动补充新增字段 + try { + $this->pdo->exec("ALTER TABLE accounts ADD COLUMN is_deleted INTEGER DEFAULT 0"); + } catch (PDOException $e) { + // 忽略字段已存在错误 + } + $this->pdo->exec("CREATE TABLE IF NOT EXISTS logs (id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT, message TEXT, created_at INTEGER)"); $this->pdo->exec("CREATE TABLE IF NOT EXISTS login_attempts ( @@ -132,12 +140,66 @@ class Database UNIQUE(account_id, cache_type, billing_cycle) )"); + // ECS 公网出口流量累计账本。CDT/账单接口有延迟且偏账号聚合,这里按实例和自然月保存云监控分钟采样的累计结果。 + $this->pdo->exec("CREATE TABLE IF NOT EXISTS instance_traffic_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + instance_id TEXT NOT NULL, + billing_month TEXT NOT NULL, + traffic_bytes REAL DEFAULT 0, + last_sample_ms INTEGER DEFAULT 0, + updated_at INTEGER NOT NULL, + UNIQUE(account_id, instance_id, billing_month) + )"); + + $this->pdo->exec("CREATE TABLE IF NOT EXISTS ecs_create_tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT UNIQUE NOT NULL, + preview_id TEXT DEFAULT '', + account_group_key TEXT NOT NULL, + region_id TEXT NOT NULL, + zone_id TEXT DEFAULT '', + instance_type TEXT NOT NULL, + image_id TEXT DEFAULT '', + os_label TEXT DEFAULT '', + instance_name TEXT DEFAULT '', + vpc_id TEXT DEFAULT '', + vswitch_id TEXT DEFAULT '', + security_group_id TEXT DEFAULT '', + internet_max_bandwidth_out INTEGER DEFAULT 0, + system_disk_category TEXT DEFAULT '', + system_disk_size INTEGER DEFAULT 0, + instance_id TEXT DEFAULT '', + public_ip TEXT DEFAULT '', + login_user TEXT DEFAULT '', + login_password TEXT DEFAULT '', + status TEXT NOT NULL, + step TEXT DEFAULT '', + error_message TEXT DEFAULT '', + payload TEXT DEFAULT '', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )"); + $this->ensureColumn('accounts', 'traffic_used', 'REAL DEFAULT 0'); + $this->ensureColumn('accounts', 'traffic_billing_month', "TEXT DEFAULT ''"); $this->ensureColumn('accounts', 'instance_status', "TEXT DEFAULT 'Unknown'"); $this->ensureColumn('accounts', 'updated_at', 'INTEGER DEFAULT 0'); $this->ensureColumn('accounts', 'last_keep_alive_at', 'INTEGER DEFAULT 0'); + $this->ensureColumn('accounts', 'auto_start_blocked', 'INTEGER DEFAULT 0'); $this->ensureColumn('accounts', 'remark', "TEXT DEFAULT ''"); - $this->ensureColumn('accounts', 'site_type', "TEXT DEFAULT 'china'"); + $this->ensureColumn('accounts', 'site_type', "TEXT DEFAULT 'international'"); + $this->ensureColumn('accounts', 'group_key', "TEXT DEFAULT ''"); + $this->ensureColumn('accounts', 'instance_name', "TEXT DEFAULT ''"); + $this->ensureColumn('accounts', 'instance_type', "TEXT DEFAULT ''"); + $this->ensureColumn('accounts', 'internet_max_bandwidth_out', 'INTEGER DEFAULT 0'); + $this->ensureColumn('accounts', 'public_ip', "TEXT DEFAULT ''"); + $this->ensureColumn('accounts', 'private_ip', "TEXT DEFAULT ''"); + $this->ensureColumn('accounts', 'cpu', "INTEGER DEFAULT 0"); + $this->ensureColumn('accounts', 'memory', "INTEGER DEFAULT 0"); + $this->ensureColumn('accounts', 'os_name', "TEXT DEFAULT ''"); + $this->ensureColumn('accounts', 'stopped_mode', "TEXT DEFAULT ''"); + $this->ensureColumn('accounts', 'health_status', "TEXT DEFAULT 'Unknown'"); $this->migrateStatsToAccountId(); } @@ -383,6 +445,41 @@ class Database $dayLimit = time() - (60 * 86400); $this->pdo->exec("DELETE FROM traffic_daily WHERE recorded_at < $dayLimit"); + + $monthLimit = date('Y-m', strtotime('-4 months')); + $stmt = $this->pdo->prepare("DELETE FROM instance_traffic_usage WHERE billing_month < ?"); + $stmt->execute([$monthLimit]); + } + + public function getInstanceTrafficUsage($accountId, $instanceId, $billingMonth) + { + $stmt = $this->pdo->prepare("SELECT * FROM instance_traffic_usage WHERE account_id = ? AND instance_id = ? AND billing_month = ? LIMIT 1"); + $stmt->execute([$accountId, $instanceId, $billingMonth]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + return $row ?: null; + } + + public function upsertInstanceTrafficUsage($accountId, $instanceId, $billingMonth, $trafficBytes, $lastSampleMs) + { + $stmt = $this->pdo->prepare(" + INSERT INTO instance_traffic_usage (account_id, instance_id, billing_month, traffic_bytes, last_sample_ms, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(account_id, instance_id, billing_month) + DO UPDATE SET + traffic_bytes = excluded.traffic_bytes, + last_sample_ms = excluded.last_sample_ms, + updated_at = excluded.updated_at + "); + + return $stmt->execute([ + $accountId, + $instanceId, + $billingMonth, + max(0, (float) $trafficBytes), + max(0, (int) $lastSampleMs), + time() + ]); } // --- 账单缓存相关方法 --- @@ -421,4 +518,68 @@ class Database $limit = time() - (90 * 86400); $this->pdo->exec("DELETE FROM billing_cache WHERE updated_at < $limit"); } -} \ No newline at end of file + + public function createEcsCreateTask($taskId, $previewId, $groupKey, $regionId, $instanceType, $payload) + { + $stmt = $this->pdo->prepare(" + INSERT INTO ecs_create_tasks ( + task_id, preview_id, account_group_key, region_id, instance_type, status, step, payload, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + "); + + $now = time(); + $stmt->execute([ + $taskId, + $previewId, + $groupKey, + $regionId, + $instanceType, + 'running', + '初始化创建任务', + json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + $now, + $now + ]); + } + + public function updateEcsCreateTask($taskId, array $fields) + { + if (empty($fields)) { + return false; + } + + $fields['updated_at'] = time(); + $allowed = [ + 'zone_id', 'image_id', 'os_label', 'instance_name', 'vpc_id', 'vswitch_id', + 'security_group_id', 'internet_max_bandwidth_out', 'system_disk_category', + 'system_disk_size', 'instance_id', 'public_ip', 'login_user', 'login_password', + 'status', 'step', 'error_message', 'payload', 'updated_at' + ]; + + $sets = []; + $values = []; + foreach ($fields as $key => $value) { + if (!in_array($key, $allowed, true)) { + continue; + } + $sets[] = "$key = ?"; + $values[] = is_array($value) ? json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : $value; + } + + if (empty($sets)) { + return false; + } + + $values[] = $taskId; + $stmt = $this->pdo->prepare("UPDATE ecs_create_tasks SET " . implode(', ', $sets) . " WHERE task_id = ?"); + return $stmt->execute($values); + } + + public function getEcsCreateTask($taskId) + { + $stmt = $this->pdo->prepare("SELECT * FROM ecs_create_tasks WHERE task_id = ? LIMIT 1"); + $stmt->execute([$taskId]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return $row ?: null; + } +} diff --git a/DdnsService.php b/DdnsService.php new file mode 100644 index 0000000..09d77e6 --- /dev/null +++ b/DdnsService.php @@ -0,0 +1,252 @@ +config = $config; + } + + public function isEnabled() + { + return ($this->config['ddns_enabled'] ?? '0') === '1' + && ($this->config['ddns_provider'] ?? 'cloudflare') === 'cloudflare' + && !empty($this->config['ddns_cf_token']) + && !empty($this->config['ddns_domain']); + } + + public function buildRecordName(array $account, $sameGroupInstanceCount = 1) + { + $domain = $this->normalizeDomain($this->config['ddns_domain'] ?? ''); + if ($domain === '') { + throw new Exception('请先填写 DDNS 根域名'); + } + + $accountSlug = $this->slug($account['account_remark'] ?? $account['remark'] ?? ''); + $instanceSlug = $this->slug($account['instance_name'] ?? ''); + $instanceId = trim((string) ($account['instance_id'] ?? '')); + $shortId = $this->slug($instanceId !== '' ? preg_replace('/^i-/', '', $instanceId) : ''); + + if ($accountSlug === '') { + $accountSlug = $instanceSlug ?: $shortId; + } + if ($accountSlug === '') { + throw new Exception('DDNS 记录名生成失败,请检查账号备注或实例名称'); + } + + $subdomain = $accountSlug; + if ((int) $sameGroupInstanceCount > 1) { + $suffix = $instanceSlug ?: $shortId; + if ($suffix !== '' && $suffix !== $accountSlug) { + $subdomain .= '-' . $suffix; + } + } + + $subdomain = trim($subdomain, '-'); + return $subdomain . '.' . $domain; + } + + public function syncARecord($recordName, $ip) + { + if (!$this->isEnabled()) { + return ['success' => true, 'skipped' => true, 'message' => 'DDNS 未启用']; + } + + $recordName = strtolower(trim((string) $recordName)); + $ip = trim((string) $ip); + if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { + return ['success' => false, 'message' => '公网 IP 为空或不是公网 IPv4']; + } + + $existing = $this->findRecord($recordName); + $payload = [ + 'type' => 'A', + 'name' => $recordName, + 'content' => $ip, + 'ttl' => 1, + 'proxied' => ($this->config['ddns_cf_proxied'] ?? '0') === '1', + 'comment' => 'Managed by CDT Monitor' + ]; + + if ($existing) { + $response = $this->request('PUT', '/dns_records/' . $existing['id'], $payload); + } else { + $response = $this->request('POST', '/dns_records', $payload); + } + + if (empty($response['success'])) { + return ['success' => false, 'message' => $this->formatErrors($response)]; + } + + return [ + 'success' => true, + 'record' => $recordName, + 'ip' => $ip, + 'action' => $existing ? 'updated' : 'created' + ]; + } + + public function deleteARecord($recordName) + { + if (!$this->isEnabled()) { + return ['success' => true, 'skipped' => true, 'message' => 'DDNS 未启用']; + } + + $recordName = strtolower(trim((string) $recordName)); + if ($recordName === '') { + return ['success' => false, 'message' => 'DDNS 记录名为空']; + } + + $existing = $this->findRecord($recordName); + if (!$existing) { + return ['success' => true, 'skipped' => true, 'record' => $recordName, 'message' => '记录不存在']; + } + + $response = $this->request('DELETE', '/dns_records/' . $existing['id']); + if (empty($response['success'])) { + return ['success' => false, 'message' => $this->formatErrors($response)]; + } + + return [ + 'success' => true, + 'record' => $recordName, + 'action' => 'deleted' + ]; + } + + private function findRecord($recordName) + { + $response = $this->request('GET', '/dns_records?type=A&name=' . rawurlencode($recordName)); + if (empty($response['success'])) { + throw new Exception('Cloudflare 查询记录失败: ' . $this->formatErrors($response)); + } + + $records = $response['result'] ?? []; + return $records[0] ?? null; + } + + private function request($method, $path, array $payload = null) + { + $zoneId = $this->resolveZoneId(); + $token = trim((string) ($this->config['ddns_cf_token'] ?? '')); + $url = 'https://api.cloudflare.com/client/v4/zones/' . rawurlencode($zoneId) . $path; + + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 15); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Authorization: Bearer ' . $token, + 'Content-Type: application/json' + ]); + + if ($payload !== null) { + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + } + + $body = curl_exec($ch); + $error = curl_error($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($error) { + throw new Exception('Cloudflare 网络请求失败: ' . $error); + } + + $decoded = json_decode((string) $body, true); + if (!is_array($decoded)) { + throw new Exception("Cloudflare 响应解析失败,状态码 {$httpCode}"); + } + + return $decoded; + } + + private function resolveZoneId() + { + $zoneId = trim((string) ($this->config['ddns_cf_zone_id'] ?? '')); + if ($zoneId !== '') { + return $zoneId; + } + + $domain = $this->normalizeDomain($this->config['ddns_domain'] ?? ''); + if ($domain === '') { + throw new Exception('请先填写 DDNS 根域名'); + } + + $token = trim((string) ($this->config['ddns_cf_token'] ?? '')); + $url = 'https://api.cloudflare.com/client/v4/zones?name=' . rawurlencode($domain) . '&status=active'; + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 15); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Authorization: Bearer ' . $token, + 'Content-Type: application/json' + ]); + $body = curl_exec($ch); + $error = curl_error($ch); + curl_close($ch); + + if ($error) { + throw new Exception('Cloudflare Zone 查询失败: ' . $error); + } + + $decoded = json_decode((string) $body, true); + if (empty($decoded['success'])) { + throw new Exception('Cloudflare Zone 查询失败: ' . $this->formatErrors(is_array($decoded) ? $decoded : [])); + } + + $zone = $decoded['result'][0] ?? null; + if (empty($zone['id'])) { + throw new Exception("Cloudflare 未找到域名 {$domain},请确认 Token 有该域名的 DNS 编辑权限,或手动填写 Zone ID"); + } + + return $zone['id']; + } + + private function formatErrors(array $response) + { + $errors = $response['errors'] ?? []; + if (empty($errors)) { + return '未知错误'; + } + + $messages = []; + foreach ($errors as $error) { + $messages[] = $error['message'] ?? json_encode($error, JSON_UNESCAPED_UNICODE); + } + return implode(';', $messages); + } + + private function slug($value) + { + $original = trim((string) $value); + $value = strtolower($original); + if ($value === '') { + return ''; + } + + if (function_exists('iconv')) { + $converted = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value); + if ($converted !== false) { + $value = $converted; + } + } + + $value = preg_replace('/[^a-z0-9]+/', '-', $value); + $value = trim($value, '-'); + if ($value === '') { + $value = substr(sha1($original), 0, 8); + } + return substr($value, 0, 48); + } + + private function normalizeDomain($domain) + { + $domain = strtolower(trim((string) $domain)); + $domain = preg_replace('#^https?://#', '', $domain); + $domain = trim(explode('/', $domain)[0] ?? '', '.'); + return $domain; + } +} diff --git a/Dockerfile b/Dockerfile index 407e1da..a6ccb6c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,9 @@ WORKDIR /app COPY composer.json composer.lock ./ # 安装依赖 (排除开发依赖,优化自动加载) -RUN composer install --no-dev --optimize-autoloader --ignore-platform-reqs --no-interaction --no-scripts +# 注意:SDK Sign::uuid() 的 microtime() 返回带空格的字符串导致签名失败,需修复 +RUN composer install --no-dev --optimize-autoloader --ignore-platform-reqs --no-interaction --no-scripts \ + && sed -i 's/return md5(\$salt . uniqid(md5(microtime(true)), true)) . microtime();/return md5(\$salt . uniqid(md5(microtime(true)), true)) . str_replace(" ", "", microtime());/' /app/vendor/alibabacloud/client/src/Support/Sign.php # 复制其余项目文件 COPY . . @@ -20,7 +22,7 @@ COPY . . FROM php:8.2-fpm-alpine # 设置镜像元数据 -LABEL maintainer="CDT-Monitor-Docker" +LABEL maintainer="ECS-Controller-Docker" # 设置环境变量 ENV TZ=Asia/Shanghai diff --git a/NotificationService.php b/NotificationService.php index c07003a..31bb8f8 100644 --- a/NotificationService.php +++ b/NotificationService.php @@ -22,22 +22,22 @@ class NotificationService $title = "定时任务: " . $actionType; $maskedKey = substr($account['access_key_id'], 0, 7) . '***'; - $traffic = isset($account['traffic_used']) ? round($account['traffic_used'], 2) : 'N/A'; + $traffic = isset($account['traffic_used']) ? $this->formatTraffic((float) $account['traffic_used']) : '暂无'; $threshold = $this->config['traffic_threshold'] ?? 95; $details = [ - ['label' => '账号 ID', 'value' => $maskedKey], + ['label' => '账号', 'value' => $maskedKey], ['label' => '执行动作', 'value' => $actionType, 'highlight' => true], ['label' => '执行时间', 'value' => date('Y-m-d H:i:s')], - ['label' => '当前流量', 'value' => $traffic . ' GB'], + ['label' => '当前流量', 'value' => $traffic], ['label' => '设定阈值', 'value' => $threshold . '%'], ['label' => '详情说明', 'value' => $description ?: '根据预设时间表自动执行。'] ]; - $textMsg = "【CDT Monitor】{$title}\n" . - "账号 ID: {$maskedKey}\n" . + $textMsg = "【ECS 服务器管家】{$title}\n" . + "账号: {$maskedKey}\n" . "执行动作: {$actionType}\n" . - "当前流量: {$traffic} GB\n" . + "当前流量: {$traffic}\n" . "设定阈值: {$threshold}%\n" . "执行时间: " . date('Y-m-d H:i:s') . "\n" . "详情说明: " . ($description ?: '根据预设时间表自动执行。'); @@ -52,17 +52,18 @@ class NotificationService public function sendTrafficWarning($accessKeyId, $traffic, $percentage, $statusText, $threshold) { $title = "流量告警 - " . $statusText; + $trafficText = $this->formatTraffic((float) $traffic); $details = [ - ['label' => '账号 ID', 'value' => substr($accessKeyId, 0, 7) . '***'], - ['label' => '当前流量', 'value' => $traffic . ' GB'], + ['label' => '账号', 'value' => substr($accessKeyId, 0, 7) . '***'], + ['label' => '当前流量', 'value' => $trafficText], ['label' => '使用率', 'value' => $percentage . '%', 'highlight' => true], ['label' => '设定阈值', 'value' => $threshold . '%'], ['label' => '当前状态', 'value' => $statusText] ]; - $textMsg = "【CDT Monitor】{$title}\n" . - "账号 ID: " . substr($accessKeyId, 0, 7) . '***' . "\n" . - "当前流量: {$traffic} GB\n" . + $textMsg = "【ECS 服务器管家】{$title}\n" . + "账号: " . substr($accessKeyId, 0, 7) . '***' . "\n" . + "当前流量: {$trafficText}\n" . "使用率: {$percentage}%\n" . "设定阈值: {$threshold}%\n" . "当前状态: {$statusText}"; @@ -70,30 +71,144 @@ class NotificationService return $this->dispatchNotifications($title, "检测到流量异常或达到阈值", $details, 'warning', $textMsg, $accessKeyId); } + public function notifyEcsCreated($accountLabel, array $result, array $preview = []) + { + $title = 'ECS 创建并启动成功'; + $instanceId = $result['instanceId'] ?? ''; + $publicIp = $result['publicIp'] ?? ''; + $loginUser = $result['loginUser'] ?? ''; + $loginPassword = $result['loginPassword'] ?? ''; + $regionId = $preview['regionId'] ?? ''; + $instanceType = $preview['instanceType'] ?? ($result['instanceType'] ?? ''); + $instanceName = $preview['instanceName'] ?? ($result['instanceName'] ?? ''); + + $details = [ + ['label' => '账号', 'value' => $accountLabel], + ['label' => '实例名称', 'value' => $instanceName ?: '-'], + ['label' => '实例编号', 'value' => $instanceId ?: '-', 'highlight' => true], + ['label' => '区域', 'value' => $regionId ?: '-'], + ['label' => '实例规格', 'value' => $instanceType ?: '-'], + ['label' => '公网地址', 'value' => $publicIp ?: '等待阿里云分配'], + ['label' => '登录用户', 'value' => $loginUser ?: '-'], + ['label' => '初始密码', 'value' => $loginPassword ?: '-', 'highlight' => true], + ['label' => '安全提醒', 'value' => '初始密码仅在本次创建完成通知和控制台弹窗展示,请立即保存。'] + ]; + + $textMsg = "【ECS 服务器管家】ECS 创建并启动成功\n" . + "账号: {$accountLabel}\n" . + "实例名称: " . ($instanceName ?: '-') . "\n" . + "实例编号: " . ($instanceId ?: '-') . "\n" . + "区域: " . ($regionId ?: '-') . "\n" . + "规格: " . ($instanceType ?: '-') . "\n" . + "公网地址: " . ($publicIp ?: '等待阿里云分配') . "\n" . + "登录用户: " . ($loginUser ?: '-') . "\n" . + "初始密码: " . ($loginPassword ?: '-') . "\n" . + "安全提醒: 初始密码仅在本次创建完成通知和控制台弹窗展示,请立即保存。"; + + return $this->dispatchNotifications($title, '实例已创建并启动,请立即保存一次性登录密码。', $details, 'success', $textMsg, $accountLabel); + } + + public function notifyInstanceStatusChanged($accountLabel, array $account, $fromStatus, $toStatus, $reason = '') + { + $fromLabel = $this->statusLabel($fromStatus); + $toLabel = $this->statusLabel($toStatus); + $instanceName = $account['instance_name'] ?? ($account['remark'] ?? ''); + $instanceId = $account['instance_id'] ?? ''; + $region = $account['region_id'] ?? ''; + $title = $toStatus === 'Running' ? '实例已启动' : ($toStatus === 'Stopped' ? '实例已停机' : "实例状态变化 - {$toLabel}"); + + $details = [ + ['label' => '账号', 'value' => $accountLabel], + ['label' => '实例名称', 'value' => $instanceName ?: '-'], + ['label' => '实例编号', 'value' => $instanceId ?: '-', 'highlight' => true], + ['label' => '区域', 'value' => $region ?: '-'], + ['label' => '原状态', 'value' => $fromLabel], + ['label' => '新状态', 'value' => $toLabel, 'highlight' => true], + ['label' => '变化时间', 'value' => date('Y-m-d H:i:s')], + ['label' => '说明', 'value' => $reason ?: '系统检测到实例运行状态发生变化。'] + ]; + + $textMsg = "【ECS 服务器管家】{$title}\n" . + "账号: {$accountLabel}\n" . + "实例名称: " . ($instanceName ?: '-') . "\n" . + "实例编号: " . ($instanceId ?: '-') . "\n" . + "区域: " . ($region ?: '-') . "\n" . + "原状态: {$fromLabel}\n" . + "新状态: {$toLabel}\n" . + "变化时间: " . date('Y-m-d H:i:s') . "\n" . + "说明: " . ($reason ?: '系统检测到实例运行状态发生变化。'); + + return $this->dispatchNotifications($title, '实例已进入最终状态', $details, 'success', $textMsg, $accountLabel); + } + + public function notifyInstanceReleased($accountLabel, array $account, $reason = '') + { + $instanceName = $account['instance_name'] ?? ($account['remark'] ?? ''); + $instanceId = $account['instance_id'] ?? ''; + $region = $account['region_id'] ?? ''; + $publicIp = $account['public_ip'] ?? ''; + $title = '实例已释放'; + + $details = [ + ['label' => '账号', 'value' => $accountLabel], + ['label' => '实例名称', 'value' => $instanceName ?: '-'], + ['label' => '实例编号', 'value' => $instanceId ?: '-', 'highlight' => true], + ['label' => '区域', 'value' => $region ?: '-'], + ['label' => '公网地址', 'value' => $publicIp ?: '-'], + ['label' => '释放时间', 'value' => date('Y-m-d H:i:s')], + ['label' => '说明', 'value' => $reason ?: '实例已从 ECS 控制台释放,本地记录和 DDNS 解析将同步清理。'] + ]; + + $textMsg = "【ECS 服务器管家】实例已释放\n" . + "账号: {$accountLabel}\n" . + "实例名称: " . ($instanceName ?: '-') . "\n" . + "实例编号: " . ($instanceId ?: '-') . "\n" . + "区域: " . ($region ?: '-') . "\n" . + "公网地址: " . ($publicIp ?: '-') . "\n" . + "释放时间: " . date('Y-m-d H:i:s') . "\n" . + "说明: " . ($reason ?: '实例已从 ECS 控制台释放,本地记录和 DDNS 解析将同步清理。'); + + return $this->dispatchNotifications($title, '实例已释放,本地记录和 DDNS 解析将同步清理。', $details, 'warning', $textMsg, $accountLabel); + } + + private function statusLabel($status) + { + $map = [ + 'Running' => '已启动', + 'Starting' => '启动中', + 'Stopping' => '停机中', + 'Stopped' => '已停机', + 'Pending' => '创建中', + 'Released' => '已释放', + 'Unknown' => '未知' + ]; + return $map[$status] ?? ($status ?: '未知'); + } + public function sendTestEmail($to) { $details = [ - ['label' => '测试结果', 'value' => '成功 (Success)'], + ['label' => '测试结果', 'value' => '成功'], ['label' => '发送时间', 'value' => date('Y-m-d H:i:s')], ['label' => '服务器', 'value' => $_SERVER['SERVER_NAME'] ?? 'localhost'] ]; - $html = $this->renderEmailTemplate("测试邮件", "SMTP 配置验证成功", $details, 'success'); - return $this->sendMail($to, 'Admin', 'CDT Monitor Test', $html); + $html = $this->renderEmailTemplate("测试邮件", "邮件服务配置验证成功", $details, 'success'); + return $this->sendMail($to, '管理员', 'ECS 服务器管家测试邮件', $html); } public function sendTestTelegram($data) { - $textMsg = "【CDT Monitor】测试推送\n这是一条来自 Telegram 的测试消息。\n发送时间: " . date('Y-m-d H:i:s'); + $textMsg = "【ECS 服务器管理】测试推送\n这是一条来自 Telegram 的测试消息。\n发送时间: " . date('Y-m-d H:i:s'); return $this->sendTelegram($textMsg, $data); } public function sendTestWebhook($data) { - $textMsg = "【CDT Monitor】测试推送\n这是一条来自 Webhook 的测试消息。\n发送时间: " . date('Y-m-d H:i:s'); - $summary = "这是一条来自 Webhook 的测试消息。"; + $textMsg = "【ECS 服务器管家】测试推送\n这是一条来自接口回调的测试消息。\n发送时间: " . date('Y-m-d H:i:s'); + $summary = "这是一条来自接口回调的测试消息。"; $threshold = $this->config['traffic_threshold'] ?? 95; $details = [ - ['label' => '当前流量', 'value' => '0 GB'], + ['label' => '当前流量', 'value' => '0 MB'], ['label' => '设定阈值', 'value' => $threshold . '%'] ]; return $this->sendWebhook($textMsg, "测试推送", $summary, $details, 'test_account_id', $data); @@ -105,15 +220,15 @@ class NotificationService $successCount = 0; $attemptCount = 0; - // Email + // 邮件通知 if (($this->config['notify_email_enabled'] ?? '1') === '1' && !empty($this->config['notify_email'])) { $attemptCount++; $html = $this->renderEmailTemplate($title, $summary, $details, $type); - $res = $this->sendMail($this->config['notify_email'], '', "CDT通知 - " . $title, $html); + $res = $this->sendMail($this->config['notify_email'], '', "ECS 服务器管家通知 - " . $title, $html); if ($res === true) $successCount++; else - $errors[] = "Email: " . $res; + $errors[] = "邮件通知: " . $res; } // Telegram @@ -123,21 +238,21 @@ class NotificationService if ($res === true) $successCount++; else - $errors[] = "TG: " . $res; + $errors[] = "Telegram: " . $res; } - // Webhook + // 接口回调 if (($this->config['notify_wh_enabled'] ?? '0') === '1' && !empty($this->config['notify_wh_url'])) { $attemptCount++; $res = $this->sendWebhook($textMsg, $title, $summary, $details, $accountId); if ($res === true) $successCount++; else - $errors[] = "WH: " . $res; + $errors[] = "接口回调: " . $res; } if ($attemptCount == 0) - return true; // No notifications enabled + return true; if ($successCount == 0 && count($errors) > 0) { return implode(" | ", $errors); @@ -175,12 +290,12 @@ class NotificationService - +
-
CDT MONITOR
+
ECS 服务器管家

{$title}

{$summary}

{$rows}
© " . date('Y') . " CDT Monitor
© " . date('Y') . " ECS 服务器管家
@@ -208,7 +323,7 @@ class NotificationService $mail->Username = $this->config['notify_username'] ?? ''; $mail->Password = $this->config['notify_password'] ?? ''; - $mail->SetFrom($mail->Username, '阿里云CDT监控'); + $mail->SetFrom($mail->Username, 'ECS 服务器管家'); $mail->Subject = $subject; $mail->MsgHTML($body); $mail->AddAddress($to, $name); @@ -228,7 +343,7 @@ class NotificationService $proxyType = $overrideConfig['proxy_type'] ?? $this->config['notify_tg_proxy_type'] ?? 'none'; if (empty($token) || empty($chatId)) - return "Telegram Token 或 Chat ID 为空"; + return "Telegram 的机器人令牌或接收会话编号为空"; $url = "https://api.telegram.org/bot{$token}/sendMessage"; if ($proxyType === 'custom' && !empty($overrideConfig['proxy_url'] ?? $this->config['notify_tg_proxy_url'] ?? '')) { @@ -268,9 +383,9 @@ class NotificationService curl_close($ch); if ($error) - return "Curl Error: " . $error; + return "网络请求错误: " . $error; if ($httpCode != 200) - return "HTTP Error {$httpCode}: " . $result; + return "接口返回错误 {$httpCode}: " . $result; return true; } @@ -283,14 +398,14 @@ class NotificationService $bodyTemplate = $overrideConfig['body'] ?? $this->config['notify_wh_body'] ?? ''; if (empty($url)) - return "Webhook URL为空"; + return "接口回调地址为空"; // Parse variables - $traffic = 'N/A'; - $maxTraffic = 'N/A'; + $traffic = '暂无'; + $maxTraffic = '暂无'; foreach ($details as $d) { if ($d['label'] === '当前流量') - $traffic = str_replace(' GB', '', $d['value']); + $traffic = str_replace([' GB', ' MB', ' GB', ' MB'], '', $d['value']); if ($d['label'] === '设定阈值') $maxTraffic = str_replace('%', '', $d['value']); } @@ -322,10 +437,8 @@ class NotificationService } $finalUrl = strtr($url, $urlReplacePairs); - // If body template exists, replace vars and append it to URL query string if no URL vars were found. - // Fallback for simple GET without body: + // 读取请求没有请求体时,将默认参数拼到地址上。 if (empty($bodyTemplate) && strpos($finalUrl, '?') === false && strpos($url, '#') === false) { - // Default fallback if no variables exist in URL or body $payload = [ 'title' => $title, 'text' => $text, @@ -335,7 +448,7 @@ class NotificationService } curl_setopt($ch, CURLOPT_URL, $finalUrl); } else { - // POST request + // 发送请求 $urlReplacePairs = []; foreach ($replacePairs as $k => $v) { $urlReplacePairs[$k] = urlencode((string) $v); @@ -348,7 +461,7 @@ class NotificationService $bodyReplacePairs = $replacePairs; if ($requestType === 'JSON') { foreach ($bodyReplacePairs as $k => $v) { - // Safe JSON encoding for values injected into string literals + // 数据格式安全转义,避免模板变量破坏 JSON 字符串。 $bodyReplacePairs[$k] = substr(json_encode((string) $v, JSON_UNESCAPED_UNICODE), 1, -1); } } else if ($requestType === 'FORM') { @@ -363,14 +476,14 @@ class NotificationService $customHeaders[] = 'Content-Type: application/json'; } else if ($requestType === 'FORM') { $customHeaders[] = 'Content-Type: application/x-www-form-urlencoded'; - // Test if user provided JSON instead of form data, attempt conversion + // 用户误填 JSON 时,尽量转换为表单格式。 $decoded = json_decode($finalBody, true); if (is_array($decoded)) { $finalBody = http_build_query($decoded); } } } else { - // Fallback default payload if no body is configured + // 未配置请求体时发送默认内容。 $payload = ['title' => $title, 'text' => $text, 'time' => date('Y-m-d H:i:s')]; if ($requestType === 'JSON') { $finalBody = json_encode($payload, JSON_UNESCAPED_UNICODE); @@ -396,9 +509,21 @@ class NotificationService curl_close($ch); if ($error) - return "Curl Error: " . $error; + return "网络请求错误: " . $error; if ($httpCode >= 400) - return "HTTP Error {$httpCode}: " . $result; + return "接口返回错误 {$httpCode}: " . $result; return true; } -} \ No newline at end of file + + private function formatTraffic($trafficGb) + { + $trafficGb = (float) $trafficGb; + if ($trafficGb <= 0) { + return '0 MB'; + } + if ($trafficGb < 1) { + return round($trafficGb * 1024, 2) . ' MB'; + } + return round($trafficGb, 2) . ' GB'; + } +} diff --git a/README.MD b/README.MD index 0dbc0d1..735f124 100644 --- a/README.MD +++ b/README.MD @@ -1,189 +1,143 @@ -# CDT-Monitor 🌩️ +# ecs-controller 🌩️ -> **阿里云 CDT 流量监控与自动化熔断解决方案** +

+ ecs-controller Logo +

+ +> **阿里云 CDT 流量监控与自动化管理终极解决方案** > -> 旨在通过集成流量追踪、阈值熔断机制及抢占式实例保活策略,优化云端资源成本管理。 +> 专为 **MJJ** 大佬设计。旨在最大化榨干阿里云 CDT 200GB 免费流量,集成流量实时监控、自动熔断保护、抢占式实例保活、**实例规格与价格透明展示**于一体。 -## 📖 项目概述 (Introduction) +## ⚠️ 免责声明 -**CDT-Monitor** 是一款专为阿里云云数据传输(CDT)用户研发的轻量级流量监控与管理系统。该系统基于 **PHP** 与 **SQLite** 架构设计,无需复杂的数据库配置即可快速部署。 +1. **配置参考**:本项目提供的默认一键创建配置仅供参考,请在下单前务必核对阿里云最新的 API 返回价格。 +2. **代码修改**:代码完全开源,您可以根据个人需求自行修改逻辑或 UI。 +3. **AI 开发声明**:本项目由 **AI (Antigravity)** 深度参与开发。作者已尽最大努力确保核心功能(如流量熔断、自动释放)的逻辑正确性。 +4. **Bug 修复**:由于云平台 API 变动或环境差异,如遇见 Bug 建议先行尝试自行修复,或提交 **Issue / PR**,由于精力有限,作者不保证实时维护。 +5. **风险自担**:因使用本脚本、或是阿里云 API 异常导致的相关资源损失或超支费用,作者概不负责。 -其核心价值在于自动化解决云资源管理中的“黑天鹅”事件:**通过实时流量熔断机制防止 CDT 免费额度超支扣费,利用智能保活算法解决抢占式实例非预期释放导致的业务中断,并提供精准的定时任务调度,是个人开发者与中小团队控制云成本的理想管家。** - -## 📸 界面预览 (Interface Preview) - -| 登陆界面 | 初始化 | -| --- | --- | -| ![https://assets.qninq.cn/qning/tzONAKQq.webp](https://assets.qninq.cn/qning/tzONAKQq.webp) | ![https://assets.qninq.cn/qning/xDFaQTTX.webp](https://assets.qninq.cn/qning/xDFaQTTX.webp) | - -| 后台页面 | 后台页面 | -| --- | --- | -| ![https://assets.qninq.cn/qning/WQG9I8qC.webp](https://assets.qninq.cn/qning/WQG9I8qC.webp) | ![https://assets.qninq.cn/qning/ipOStKeK.webp](https://assets.qninq.cn/qning/ipOStKeK.webp) | - -| 图表展示 | 邮件展示 | -| --- | --- | -| ![https://assets.qninq.cn/qning/a5zqmuJQ.webp](https://assets.qninq.cn/qning/a5zqmuJQ.webp) | ![https://assets.qninq.cn/qning/6KEjK7bw.webp](https://assets.qninq.cn/qning/6KEjK7bw.webp) | -## ✨ 核心功能 (Core Features) - -* **便捷部署架构** :基于 SQLite 构建,无需配置 MySQL 或 Redis 等外部数据库,支持数据私有化存储与快速部署。 -* **多账户聚合监控** :提供统一管理面板,实现对多个阿里云账户下 CDT 流量使用情况及实例运行状态的集中监控。 -* **流量熔断机制** : - * **阈值控制** :支持自定义流量使用阈值(例如 95%)。 - * **自动化停机** :当流量超出预设阈值时自动触发实例停机,支持配置 **普通停机** 或 **节省停机(停止计费)** 模式。 - * **告警通知** :集成 SMTP 协议,支持通过邮件发送即时告警通知。 -* **实例保活策略** :内置保活逻辑算法,旨在防止抢占式实例在预定运行时间段内被异常回收。 -* **定时任务管理** :支持针对特定实例设定每日自动开关机计划。 -* **现代化用户界面** :采用原生 Tailwind CSS 构建,提供统一的响应式布局,确保移动端与桌面端的兼容性与视觉一致性。 -* **安全保障体系** :内置初始化配置向导,确保敏感配置信息的本地加密存储与安全性。 - -## 🛠️ 环境要求 (System Requirements) - -* **PHP** 版本 >= 8.0 -* **Composer** 依赖管理工具 -* **PHP 扩展** :`pdo_sqlite`, `curl`, `json` -* **Web 服务器** :Nginx / Apache / OpenLiteSpeed - -## 🚀 安装指南 (Installation Guide) - -### 1. 获取源代码 - -``` -git clone https://github.com/wang4386/CDT-Monitor.git -cd CDT-Monitor -``` - -### 2. 安装依赖包 - -``` -composer install --no-dev -``` - -### 3. 配置目录权限 - -请确保 Web 服务器用户对项目根目录及 `data.sqlite` 文件拥有 **写入权限** 。 - -``` -# 示例:假设 Web 根目录为 /www/wwwroot/yoursite.com -chown -R www:www /www/wwwroot/yoursite.com -chmod -R 755 /www/wwwroot/yoursite.com -``` - -> **🛡️ Nginx 安全配置(重要)** -> -> 为防止数据库文件被下载,请务必在 Nginx 配置文件(server 段)中添加以下伪静态规则以禁止访问 `/data/` 目录: -> -> ``` -> location ^~ /data/ { -> deny all; -> return 403; -> } -> ``` - -### 4. 系统初始化 - -通过浏览器访问站点(例如 `https://monitor.yourdomain.com`)。首次访问将自动进入 **初始化向导** ,请按照提示完成管理员密码及相关参数的配置。 - -## 🐳 Docker 部署 (Docker Deployment) - -无需配置 PHP 环境,直接使用 Docker 即可快速部署。Docker 版本已内置定时任务,**无需** 额外配置 Crontab。 +--- +## 🚀 快速部署 ### 方式一:Docker Compose (推荐) +这是最省心的部署方案,内置了自动化的定时任务巡检,无需额外配置 Crontab。 -创建一个 `docker-compose.yml` 文件: - -``` +1. **新建配置文件** `docker-compose.yml`: +```yaml services: - cdt-monitor: - image: qninq/cdt-monitor:latest - # build: . - container_name: cdt-monitor + ecs-controller: + image: kori1c/ecs-controller:latest + container_name: ecs-controller restart: always ports: - - "43210:80" # 将容器的 80 端口映射到宿主机的 43210 端口 + - "43210:80" volumes: - # 持久化数据目录,确保重启后配置和数据库不丢失 - ./data:/var/www/html/data - # 可选:如果你想查看 Docker 内部的日志 - # - ./logs:/var/log/nginx environment: - - TZ=Asia/Shanghai # 设置时区,对 Cron 任务很重要 + - TZ=Asia/Shanghai ``` -启动服务: - -``` +2. **启动服务**: +```bash docker-compose up -d ``` +访问 `http://localhost:43210` 即可开始使用。 -### 方式二:Docker CLI +--- -``` -docker run -d \ - --name cdt-monitor \ - --restart always \ - -p 43210:80 \ - -v $(pwd)/data:/var/www/html/data \ - -e TZ=Asia/Shanghai \ - qninq/cdt-monitor:latest -``` +## 🔑 获取阿里云密钥 -部署完成后,访问 `http://服务器IP:43210` 即可开始使用。 +为了让系统能够正常获取流量数据并管理实例,您需要准备具有相关权限的阿里云 AccessKey: -## ⚙️ 自动化任务配置 (Automation Setup) +1. **登录阿里云控制台**,前往 [RAM 访问控制 - 用户](https://ram.console.aliyun.com/users)。 +2. **创建用户**:点击“创建用户”,勾选“OpenAPI 调用访问”。 +3. **获取密钥**:保存好生成的 `AccessKey ID` 和 `AccessKey Secret`。 +4. **添加权限**:为该用户添加以下三个权限策略(必须包含): + - `AliyunECSFullAccess`:用于查看实例状态、开关机及释放。 + - `AliyunBSSFullAccess`:用于查询账号余额及消费账单。 + - `AliyunCDTFullAccess`:用于获取 CDT 流量实时统计信息。 -> **注意:** 如果您使用 Docker 部署,请跳过此步骤(容器已内置自动任务)。 +--- -CDT-Monitor 依赖定时任务以实现精确的流量监控与实例保活功能。建议执行频率为 **每分钟一次** 。 +## ✨ 核心功能 -您可以根据服务器环境选择以下任意一种方式: -### 方式一:命令行 Crontab (推荐) +### 🛡️ 流量盾牌 (CDT 监控) +- **多账户聚合**:支持同时管理多个阿里云 AK/SK,多区域实例一屏尽览。 +- **自然月流量重置**:自动适配阿里云 CDT 计费周期,每月 1 号零点自动重置已用流量统计。 +- **熔断机制**:支持设定 **告警阈值 (如 95%)**,触发时自动执行关机动作。 +- **灵活关机模式**:可选 **普通停机 (KeepCharging)** 或 **节省停机 (StopCharging/释放计算资源停止计费)**。 -最稳定且高效的方式,适合有服务器 SSH 权限的用户。 +### ⚡ 自动化高阶管理 +- **异步安全释放**:彻底解决释放逻辑响应缓慢问题。点击后后台接管,自动执行“强制离线 -> 等待状态 -> 物理销毁”全流程,无需前台苦等。 +- **抢占式实例保活**:实时守护低成本 Spot 实例,检测到非预期停机时(如被回收)自动重试拉起。 +- **定时任务清单**:支持为指定实例设置每日定时开机、定时关机计划(自定义时间点)。 +- **ECS 快速创建**:支持从预设规格中一键拉起新实例(默认采用最低配置、最低价格方案),并自动配置安全组与防火墙规则。 +- **预检与成本预览**:创建前自动调用阿里云 API 进行库存预检与其费用估算,拒绝盲目下单。 +- **初次登录信息保护**:针对新建实例,系统仅在创建成功的瞬间展示初始密码,确保 AK/SK 与凭据安全。 +- **DDNS 联动**:深度集成 **Cloudflare**,实例重启 IP 变更后自动同步 A 记录。 -``` -# CDT-Monitor 核心任务 -* * * * * /usr/bin/php /path/to/your/project/monitor.php > /dev/null 2>&1 -``` +### 📊 成本与审计 +- **费用中心**:实时拉取账号 **可用余额**,并预估当月实例已产生账单金额。 +- **实时日志审计**:详细记录系统心跳、API 调用状态、告警触发记录,支持分级清理,保证系统轻量运行。 +- **一键同步**:支持主动从阿里云云端同步最新机器规格、状态、公网 IP 等所有属性。 -> **注意** :请将路径替换为实际的项目部署路径,并确保使用 PHP CLI 版本执行。 +### 📢 预警系统 +- **多通路覆盖**:集成 **Telegram (纸飞机)**、**SMTP 邮件** 及 **通用 Webhook** 接口。 +- **状态变更通知**:实例关机、启动、释放成功、流量超标时均会发送详尽的富文本通知。 -### 方式二:URL 监控 (Web Cron) +--- -如果您使用虚拟主机或希望通过第三方监控服务(如 UptimeRobot, 宝塔计划任务-访问URL)来触发,可以使用此方式。 +## 💡 省钱小秘籍 (Saving Tips) -**监控地址格式:** +- **流量熔断**:系统默认检测到流量即将用尽时自动关机(建议配合“节省停机”模式),确保不产生额外扣费,真正做到“用完即止”。 +- **折扣充值**:本项目可搭配 [portal.acm.ee](https://portal.acm.ee/) 使用,享受阿里云七折充值优惠,叠加 CDT 200GB 免费流量,实现在线极致性价比。 -``` -https://您的域名/monitor.php?key=您的管理员密码 -``` +--- -> **注意** : -> -> 1. 为了安全起见,Web 访问必须携带 `key` 参数,该参数值即为您初始化时设置的 **管理员密码** 。 -> 2. 如果密码错误,系统将返回 403 禁止访问。 +## 📸 界面预览 -无论使用哪种方式,`monitor.php` 均内置了智能缓存机制,高频执行不会导致 API 调用额度的过度消耗。 +### 汇总监控 +![监控状态](./image/截屏2026-04-16%2012.15.51.png) -## 💡 功能详解 (Advanced Documentation) +### 系统配置 +![系统设置](./image/截屏2026-04-16%2012.16.12.png) -### 停机模式说明 +### 实例生命周期管理 +![创建结果展示](./image/截屏2026-04-16%2011.24.14.png) -* **普通停机 (KeepCharging)** :停止实例后保留计算资源与 IP 地址,持续产生费用,具备快速启动特性。 -* **节省停机 (StopCharging)** :释放计算资源并停止计费。**注意:固定公网 IP 地址可能会在重启后发生变更,但弹性公网 IP (EIP) 地址保持不变。建议用于成本控制场景。** +--- -### 抢占式实例保活机制 -该机制专为**抢占式实例**设计。系统激活后,将实时监控处于“预定运行时间段”内的实例状态。若检测到非预期的关机状态(如被云平台释放),系统将尝试自动重启实例以维持服务可用性。 -* *注:为防止状态频繁震荡,触发保活操作后将执行 30 分钟的冷却期。* +## 🛠️ 技术架构 -## 🤝 参与贡献 (Contributing) +- **Backend**: 原生 PHP 8.1+,无框架依赖,追求极致性能。 +- **Database**: SQLite 3 (WAL 模式),兼顾轻量与读写并发。 +- **Frontend**: Vue 3.x (SFC 理念) + 原生 Vanilla CSS。 +- **SDK**: Alibaba Cloud SDK for PHP (V1)。 -欢迎提交 Issue 或 Pull Request 以协助改进本项目。 +--- -## 📄 开源协议 (License) +## ☕ 赏杯咖啡 (Donation) -本项目遵循 [MIT License](https://mit-license.org/ "null") 开源协议。 +如果您觉得这个工具对您有帮助,阔佬随手赏杯咖啡: -

Created by 青柠·倾城于你

\ No newline at end of file +- **USDT-TRC20**: `TMBmXngHcKtNo9nLxTCJcGTfNutsbb9rp3` +- **USDT-Polygon**: `0x846ad38ed7159aee70b0ebd8ec39ad2d7b32835b` +- **USDT-BSC**: `0x846ad38ed7159aee70b0ebd8ec39ad2d7b32835b` +- **USDT-ERC20**: `0x846ad38ed7159aee70b0ebd8ec39ad2d7b32835b` +- **USDT-ArbitrumOne**: `0x846ad38ed7159aee70b0ebd8ec39ad2d7b32835b` + +--- + + + + +## 📄 许可协议 + +本项目遵循 [MIT License](https://opensource.org/licenses/MIT) 开源协议。 + +--- +

Made with ❤️ for Aliyun Users

+

Based on CDT-Monitor

\ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 2a88e5b..5133f9a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,15 +1,14 @@ services: - cdt-monitor: - image: qninq/cdt-monitor:latest - # build: . - container_name: cdt-monitor + ecs-controller: + image: kori1c/ecs-controller:latest + container_name: ecs-controller restart: always ports: - - "43210:80" # 将容器的 80 端口映射到宿主机的 8080 端口 + - "43211:80" volumes: - # 持久化数据目录,确保重启后配置和数据库不丢失 - ./data:/var/www/html/data - # 可选:如果你想查看 Docker 内部的日志 - # - ./logs:/var/log/nginx environment: - - TZ=Asia/Shanghai # 设置时区,对 Cron 任务很重要 \ No newline at end of file + - TZ=Asia/Shanghai + dns: + - 223.5.5.5 + - 114.114.114.114 \ No newline at end of file diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index bb47272..131099c 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -1,7 +1,7 @@ #!/bin/sh set -e -echo "Starting CDT-Monitor..." +echo "Starting ECS-Controller..." # 1. 确保数据目录权限正确 # Docker 挂载卷时可能会导致权限归属为 root,这里强制修正为 www-data diff --git a/image/截屏2026-04-16 11.24.03.png b/image/截屏2026-04-16 11.24.03.png new file mode 100644 index 0000000..e5391c9 Binary files /dev/null and b/image/截屏2026-04-16 11.24.03.png differ diff --git a/image/截屏2026-04-16 11.24.14.png b/image/截屏2026-04-16 11.24.14.png new file mode 100644 index 0000000..f62e763 Binary files /dev/null and b/image/截屏2026-04-16 11.24.14.png differ diff --git a/image/截屏2026-04-16 11.24.31.png b/image/截屏2026-04-16 11.24.31.png new file mode 100644 index 0000000..91c9c2d Binary files /dev/null and b/image/截屏2026-04-16 11.24.31.png differ diff --git a/image/截屏2026-04-16 12.15.51.png b/image/截屏2026-04-16 12.15.51.png new file mode 100644 index 0000000..b790901 Binary files /dev/null and b/image/截屏2026-04-16 12.15.51.png differ diff --git a/image/截屏2026-04-16 12.15.56.png b/image/截屏2026-04-16 12.15.56.png new file mode 100644 index 0000000..eb26abe Binary files /dev/null and b/image/截屏2026-04-16 12.15.56.png differ diff --git a/image/截屏2026-04-16 12.16.05.png b/image/截屏2026-04-16 12.16.05.png new file mode 100644 index 0000000..8cc34a8 Binary files /dev/null and b/image/截屏2026-04-16 12.16.05.png differ diff --git a/image/截屏2026-04-16 12.16.12.png b/image/截屏2026-04-16 12.16.12.png new file mode 100644 index 0000000..183c5b3 Binary files /dev/null and b/image/截屏2026-04-16 12.16.12.png differ diff --git a/image/截屏2026-04-16 12.16.18.png b/image/截屏2026-04-16 12.16.18.png new file mode 100644 index 0000000..0e95747 Binary files /dev/null and b/image/截屏2026-04-16 12.16.18.png differ diff --git a/image/截屏2026-04-16 12.16.27.png b/image/截屏2026-04-16 12.16.27.png new file mode 100644 index 0000000..f6b77d0 Binary files /dev/null and b/image/截屏2026-04-16 12.16.27.png differ diff --git a/index.php b/index.php index 183854b..f883dd0 100644 --- a/index.php +++ b/index.php @@ -1,4 +1,9 @@ isInitialized()) { http_response_code(403); - echo json_encode(['success' => false, 'message' => 'System already initialized']); + echo json_encode(['success' => false, 'message' => '系统已完成初始化']); exit; } @@ -38,7 +43,7 @@ if ($action === 'setup') { $_SESSION['is_admin'] = true; echo json_encode(['success' => true]); } else { - echo json_encode(['success' => false, 'message' => 'Setup failed']); + echo json_encode(['success' => false, 'message' => '初始化失败']); } } catch (Exception $e) { echo json_encode(['success' => false, 'message' => $e->getMessage()]); @@ -72,7 +77,13 @@ if ($action === 'get_status') { if ($initError) { echo json_encode(['error' => $initError]); } else { - echo json_encode($app->getStatusForFrontend()); + $isAdmin = isset($_SESSION['is_admin']) && $_SESSION['is_admin'] === true; + if (!$isAdmin) { + http_response_code(403); + echo json_encode(['error' => '请先登录后再操作']); + } else { + echo json_encode($app->getStatusForFrontend(true)); + } } exit; } @@ -81,7 +92,7 @@ if ($action === 'get_status') { if ($action !== 'view' && !isset($_SESSION['is_admin'])) { http_response_code(403); - echo json_encode(['error' => 'Unauthorized']); + echo json_encode(['error' => '请先登录后再操作']); exit; } @@ -126,7 +137,7 @@ if ($action === 'refresh_account') { $id = $data['id'] ?? 0; $result = $app->refreshAccount($id); if ($result === false) { - echo json_encode(['success' => false, 'message' => 'Refresh failed']); + echo json_encode(['success' => false, 'message' => '刷新失败']); } elseif (is_array($result)) { // 流量/状态刷新成功,但账单获取失败 echo json_encode($result); @@ -136,6 +147,111 @@ if ($action === 'refresh_account') { exit; } +if ($action === 'fetch_instances') { + header('Content-Type: application/json; charset=utf-8'); + $data = json_decode(file_get_contents('php://input'), true); + + try { + $instances = $app->fetchInstances($data['accessKeyId'] ?? '', $data['accessKeySecret'] ?? '', $data['regionId'] ?? ''); + echo json_encode(['success' => true, 'data' => $instances]); + } catch (Exception $e) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => $e->getMessage()]); + } + exit; +} + +if ($action === 'test_account') { + header('Content-Type: application/json; charset=utf-8'); + $data = json_decode(file_get_contents('php://input'), true); + + try { + $result = $app->testAccountCredentials($data['account'] ?? []); + echo json_encode($result); + } catch (Exception $e) { + http_response_code(400); + echo json_encode([ + 'success' => false, + 'message' => $e->getMessage() + ]); + } + exit; +} + +if ($action === 'sync_account_group') { + header('Content-Type: application/json; charset=utf-8'); + $data = json_decode(file_get_contents('php://input'), true) ?: []; + + try { + echo json_encode($app->syncAccountGroup($data['groupKey'] ?? '')); + } catch (Exception $e) { + http_response_code(400); + echo json_encode([ + 'success' => false, + 'message' => $e->getMessage() + ]); + } + exit; +} + +if ($action === 'preview_ecs_create') { + header('Content-Type: application/json; charset=utf-8'); + $data = json_decode(file_get_contents('php://input'), true) ?: []; + + try { + $result = $app->previewEcsCreate($data); + $_SESSION['ecs_create_previews'] = $_SESSION['ecs_create_previews'] ?? []; + $_SESSION['ecs_create_previews'][$result['previewId']] = [ + 'summary' => $result['summary'], + 'created_at' => time() + ]; + echo json_encode($result); + } catch (Exception $e) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => $e->getMessage()]); + } + exit; +} + +if ($action === 'create_ecs') { + header('Content-Type: application/json; charset=utf-8'); + $data = json_decode(file_get_contents('php://input'), true) ?: []; + $previewId = $data['previewId'] ?? ''; + $confirmed = !empty($data['confirmed']); + + try { + if (!$confirmed) { + throw new Exception('请先确认配置清单和费用提示'); + } + $previewStore = $_SESSION['ecs_create_previews'][$previewId] ?? null; + if (!$previewStore || (time() - ($previewStore['created_at'] ?? 0)) > 900) { + throw new Exception('配置清单已过期,请重新预检'); + } + + $result = $app->createEcsFromPreview($previewId, $previewStore['summary']); + unset($_SESSION['ecs_create_previews'][$previewId]); + echo json_encode($result); + } catch (Exception $e) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => $e->getMessage()]); + } + exit; +} + +if ($action === 'get_ecs_create_task') { + header('Content-Type: application/json; charset=utf-8'); + $taskId = $_GET['taskId'] ?? ''; + $task = $app->getEcsCreateTask($taskId); + if (!$task) { + http_response_code(404); + echo json_encode(['success' => false, 'message' => '任务不存在']); + } else { + unset($task['login_password']); + echo json_encode(['success' => true, 'data' => $task]); + } + exit; +} + // 修改:获取系统日志,支持 Tab if ($action === 'get_logs') { header('Content-Type: application/json; charset=utf-8'); @@ -151,7 +267,7 @@ if ($action === 'clear_logs') { if ($app->clearSystemLogs($tab)) { echo json_encode(['success' => true]); } else { - echo json_encode(['success' => false, 'message' => 'Clear failed']); + echo json_encode(['success' => false, 'message' => '清空失败']); } exit; } @@ -169,4 +285,38 @@ if ($action === 'logout') { exit; } -echo $app->renderTemplate(); \ No newline at end of file +if ($action === 'get_all_instances') { + header('Content-Type: application/json; charset=utf-8'); + $sync = ($_GET['sync'] ?? '0') === '1'; + echo json_encode(['data' => $app->getAllManagedInstances($sync)]); + exit; +} + +if ($action === 'control_instance') { + $data = json_decode(file_get_contents('php://input'), true); + $accountId = $data['accountId'] ?? 0; + $actionType = $data['action'] ?? ''; + $shutdownMode = $data['shutdownMode'] ?? 'KeepCharging'; + + if (!in_array($actionType, ['start', 'stop'])) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => '无效的操作类型']); + exit; + } + + $result = $app->controlInstanceAction($accountId, $actionType, $shutdownMode); + echo json_encode(['success' => $result]); + exit; +} + +if ($action === 'delete_instance') { + $data = json_decode(file_get_contents('php://input'), true); + $accountId = $data['accountId'] ?? 0; + $forceStop = $data['forceStop'] ?? false; + + $result = $app->deleteInstanceAction($accountId, $forceStop); + echo json_encode(['success' => $result]); + exit; +} + +echo $app->renderTemplate(); diff --git a/monitor.php b/monitor.php index 5bd4522..d09fcd7 100644 --- a/monitor.php +++ b/monitor.php @@ -8,20 +8,24 @@ header('Content-Type: text/plain; charset=utf-8'); $app = new AliyunTrafficCheck(); -// CLI 模式直接运行,Web 模式需要密码 +// CLI 模式直接运行,Web 模式使用 Bearer Token 鉴权 $isCli = (PHP_SAPI === 'cli'); if (!$isCli) { - $inputKey = $_GET['key'] ?? ''; - $adminPassword = $app->getAdminPassword(); - if ($inputKey !== $adminPassword) { + $authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['HTTP_X_MONITOR_TOKEN'] ?? ''; + if (preg_match('/^Bearer\s+(.+)$/i', $authHeader, $matches)) { + $authHeader = $matches[1]; + } + + $monitorKey = $app->getMonitorKey(); + if (empty($monitorKey) || !hash_equals($monitorKey, $authHeader)) { http_response_code(403); - echo "Access Denied."; + echo "访问被拒绝,请使用有效的监控密钥。"; exit; } } // 输出简洁日志 -echo "--- CDT Monitor Start: " . date('Y-m-d H:i:s') . " ---\n"; +echo "--- ECS 服务器管理 开始检测: " . date('Y-m-d H:i:s') . " ---\n"; echo $app->monitor(); -echo "\n--- End ---\n"; \ No newline at end of file +echo "\n--- 检测结束 ---\n"; diff --git a/template.html b/template.html index baa1b0f..a58118c 100644 --- a/template.html +++ b/template.html @@ -1,969 +1,2568 @@ - + - 阿里云 CDT 监控控制台 - - + ECS 服务器管理 + - + + + - - + +
+
+ + +
+ 超过 3 分钟未检测到自动检测任务,请检查后台监控任务是否正常运行。 +
+ +
+ +
+ +
+ + +
+
+
+
+

实例总览

+

实例状态

+

以卡片方式展示所有已同步实例,可按账号筛选,并支持通过备注或实例标识搜索。

+
+ +
+ + + + +
+
+ +
+
+
+
+

备注

+

{{ item.remark || item.instanceName || item.instanceId }}

+

{{ item.accountLabel }}

+
+
+ + {{ statusText(item.instanceStatus) }} + + + 节省停机 + + + 操作系统启动中 + +
+
+ +
+
+
+

硬件系统

+

{{ item.osName || '-' }}

+
+
+

规格

+

{{ item.cpu }} 核 {{ item.memory < 1024 ? item.memory + ' MiB' : (item.memory/1024).toFixed(1) + ' GiB' }}

+
+
+
+
+

公网 IP

+

{{ isAdmin ? (item.publicIp || '-') : '登录后可见' }}

+
+
+
+ +
+
+ 流量消耗 ({{ item.percentageOfUse }}%) + + {{ formatTrafficValue(item.flow_used) }} / {{ item.flow_total }} GB + +
+
+
+
+
+ +
+
+ + {{ item.lastUpdated }} +
+ +
+
+ +
+

暂无实例状态数据。

+
+
+
+ +
+
+
+

实例管理

+

实例管理

+

管理所有账号组同步下来的实例,可按账号、区域、状态过滤并执行批量操作。

+
+
+ + +
+ +
+ + + + +
+
+ + +
+ 已选择 {{ selectedInstanceIds.length }} 台实例 + + + + +
+
+ +
+
+ 列表模式 +
+
+
+ + + + +
+ 暂无实例数据,点击"手动同步"获取最新实例。 +
+
+
+
+ +
+
+
+
+

{{ inst.remark || inst.instanceName || inst.instanceId }}

+

{{ inst.instanceId }}

+
+ +
+
+
账号

{{ inst.accountLabel }}

+
区域

{{ inst.regionName }}

+
状态

{{ statusText(inst.instanceStatus) }}

+
地址

{{ inst.publicIp || inst.privateIp || '-' }}

+
公网带宽峰值

{{ inst.internetMaxBandwidthOut ? inst.internetMaxBandwidthOut + ' Mbps' : '-' }}

+
+
+ + + +
+
+ +
+

暂无实例数据。

+
+
+
+ +
+
+
+

账号管理

+

账号管理

+

账号通过弹窗维护。每次新增或编辑保存后,会自动同步该账号在所选区域下的全部实例,并把备注一并带到实例卡片中。

+
+
+ +
+
+ +
+
+ 列表模式 +
+
+ + + + + +
+
+ +
+
+
+

{{ acc.remark || `账号 ${idx + 1}` }}

+ {{ acc.instanceCount || 0 }} 台实例 +
+ +
+ + + +
+
+ +
+

还没有账号,点击“新增账号”开始配置。

+
+
+
+ +
+
+

系统配置

+

系统设置

+
+ +
+
+
+

全局配置

+

全局设置

+
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + + +
+
+ +
+
+

DDNS

+

动态域名解析

+

+ 默认使用 Cloudflare。系统会在 ECS 创建、实例启动后和手动同步时,把公网 IP 写入 A 记录。 +

+
+
+ + +
+
Cloudflare
+
当前仅支持 Cloudflare,后续可扩展其他 DNS 服务商。
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ +

+ 必填:API Token 和根域名。Zone ID 可不填,系统会根据根域名自动查询。命名规则:单机器使用“账号备注.根域名”;同账号多机器使用“账号备注-实例备注或实例短 ID.根域名”。 +

+
+
+ +
+
+ + + +
+ +
+ + +
+ + +
+
+ + +
+ + +
+ +
+ + + + + +
+ + + + +
+ +
+ +
+ + +
+ + +
+ + + +
+
+
+ +
+ +
+
+ +
+
+
+

系统日志

+

系统日志

+
+
+ + + +
+
+ +
+
+
+
+ + {{ logTypeLabel(log.type) }} + + {{ log.time_str }} +
+
+

{{ log.message }}

+
+ +
+

当前没有日志记录。

+
+
+
+
+
+ + +
+ + +
+
{{ toast.type === 'error' ? '错误' : toast.type === 'success' ? '完成' : '提示' }}
+
{{ toast.message }}
+
+
+ +
+
+

{{ dialogState.mode === 'confirm' ? '操作确认' : '系统提示' }}

+

{{ dialogState.title }}

+

{{ dialogState.message }}

+
+ +
-
-
-

CDT Monitor

-

Status & Control

-
- - -
- -
-
-
- - - -
-
-
- -
-
- {{ - item.regionName }} - {{ item.lastUpdated ? item.lastUpdated.split(' ')[1] : - '' }} 更新 -
-

- {{ item.flow_used }} GB -

-
-

已用流量

-

{{ item.percentageOfUse }}% / {{ item.flow_total - }}G

-
-
-
-
-
-
-
- 运行状态 - {{ - item.instanceStatus }} -
-
- 账号 - {{ item.account }} -
-
- 本月费用 - - {{ item.cost.currency === 'USD' ? '$' : '¥' }}{{ - item.cost.monthly_cost }} - | - {{ item.cost.currency === 'USD' ? '$' : '¥' }}{{ - item.cost.balance }} - - {{ item.cost.error }} -
-
- 备注 - {{ item.remark }} -
-
- 当前阈值 - {{ item.threshold }}% -
+
+
+

初始化异常

+

系统初始化错误

+

{{ criticalError }}

+
+
-
-

暂无监控账号,请点击右上角登录管理员添加。

-
-
- -
-
-
-
-
-
+
+
+

管理员登录

+

管理员登录

+ +
+ + +
+
+
+ + -
-
-
+