diff --git a/AliyunTrafficCheck.php b/AliyunTrafficCheck.php index 1d1d762..29e0965 100644 --- a/AliyunTrafficCheck.php +++ b/AliyunTrafficCheck.php @@ -478,6 +478,9 @@ class AliyunTrafficCheck $logPrefix = "[{$accountLabel}]"; $actions = []; $forceRefresh = false; + $protectionSuspended = !empty($account['protection_suspended']); + $protectionSuspendReason = trim((string) ($account['protection_suspend_reason'] ?? '')); + $protectionSuspendNotifiedAt = (int) ($account['protection_suspend_notified_at'] ?? 0); // 1. 自适应心跳 $lastUpdate = $account['updated_at'] ?? 0; @@ -506,6 +509,23 @@ class AliyunTrafficCheck 'traffic_api_status' => $trafficResult['status'] ?? 'ok', 'traffic_api_message' => $trafficResult['message'] ?? '' ]; + $authInvalid = $this->isCredentialInvalidTrafficStatus($trafficResult['status'] ?? ''); + + if ($authInvalid) { + $metadata['protection_suspended'] = 1; + $metadata['protection_suspend_reason'] = 'credential_invalid'; + $metadata['protection_suspend_notified_at'] = $protectionSuspendNotifiedAt; + $protectionSuspended = true; + $protectionSuspendReason = 'credential_invalid'; + } elseif ($protectionSuspended && $protectionSuspendReason === 'credential_invalid') { + $metadata['protection_suspended'] = 0; + $metadata['protection_suspend_reason'] = ''; + $metadata['protection_suspend_notified_at'] = 0; + $protectionSuspended = false; + $protectionSuspendReason = ''; + $protectionSuspendNotifiedAt = 0; + $this->db->addLog('info', "账号鉴权已恢复,自动停机保护已重新启用 [{$accountLabel}]"); + } if (empty($trafficResult['success'])) { $traffic = $account['traffic_used']; @@ -548,20 +568,37 @@ class AliyunTrafficCheck $trafficDesc .= $isHardLimitExceeded ? "[已超出上限]" : "[接近上限]"; if ($thresholdAction === 'stop_and_notify') { - $canAttemptStop = !in_array($status, ['Stopped', 'Stopping', 'Released'], true); - - // 达到账号流量上限后必须立即保护,不再等待下一次接口刷新窗口。 - 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'; + if ($protectionSuspended && $protectionSuspendReason === 'credential_invalid') { + if ($protectionSuspendNotifiedAt <= 0) { + $actions[] = "账号密钥失效,已暂停自动停机"; + $notifyResult = $this->notificationService->notifyCredentialInvalid($accountLabel, $accountTraffic, $usagePercent, $threshold); + $this->logNotificationResult($notifyResult, $accountLabel); + $this->db->addLog('warning', "检测到账号鉴权失效,已暂停自动停机保护 [{$accountLabel}] 当前使用率:{$usagePercent}%"); + $protectionSuspendNotifiedAt = $currentTime; + $this->configManager->updateAccountStatus($account['id'], $traffic, $status, $lastUpdate, [ + 'protection_suspended' => 1, + 'protection_suspend_reason' => 'credential_invalid', + 'protection_suspend_notified_at' => $protectionSuspendNotifiedAt + ]); } else { - $actions[] = "自动停机失败"; - $this->db->addLog('error', "账号出口流量达到保护线,但自动停机失败 [{$accountLabel}] 当前使用率:{$usagePercent}%"); + $apiStatusLog .= " [鉴权失效,已暂停自动停机]"; + } + } else { + $canAttemptStop = !in_array($status, ['Stopped', 'Stopping', 'Released'], true); + + // 达到账号流量上限后必须立即保护,不再等待下一次接口刷新窗口。 + 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 { + $actions[] = "自动停机失败"; + $this->db->addLog('error', "账号出口流量达到保护线,但自动停机失败 [{$accountLabel}] 当前使用率:{$usagePercent}%"); + } } } } elseif ($shouldCheckApi) { @@ -569,7 +606,7 @@ class AliyunTrafficCheck $this->db->addLog('warning', "账号出口流量超限触发提醒 [{$accountLabel}] 当前使用率:{$usagePercent}%"); } - if (!empty($actions)) { + if (!empty($actions) && !($protectionSuspended && $protectionSuspendReason === 'credential_invalid')) { $mailRes = $this->notificationService->sendTrafficWarning($accountLabel, $accountTraffic, $usagePercent, implode(',', $actions), $threshold); $this->logNotificationResult($mailRes, $accountLabel); } @@ -683,6 +720,14 @@ class AliyunTrafficCheck 'traffic_api_status' => $trafficResult['status'] ?? 'ok', 'traffic_api_message' => $trafficResult['message'] ?? '' ]; + if ($this->isCredentialInvalidTrafficStatus($trafficResult['status'] ?? '')) { + $metadata['protection_suspended'] = 1; + $metadata['protection_suspend_reason'] = 'credential_invalid'; + } else { + $metadata['protection_suspended'] = 0; + $metadata['protection_suspend_reason'] = ''; + $metadata['protection_suspend_notified_at'] = 0; + } if (empty($trafficResult['success'])) { $traffic = $targetAccount['traffic_used']; @@ -1256,6 +1301,45 @@ class AliyunTrafficCheck return date('Y-m', (int) $timestamp) === date('Y-m', (int) $currentTime); } + private function isCredentialInvalidTrafficStatus($status) + { + return trim((string) $status) === 'auth_error'; + } + + private function isCredentialInvalidError($code, $message = '') + { + $normalizedCode = strtolower(trim((string) $code)); + $normalizedMessage = strtolower(trim((string) $message)); + if ($normalizedCode === '') { + return false; + } + + $credentialErrorCodes = [ + 'invalidaccesskeyid.notfound', + 'invalidaccesskeyid', + 'signaturedoesnotmatch', + 'incompletesignature', + 'forbidden.accesskeydisabled', + 'invalidsecuritytoken.expired', + 'invalidsecuritytoken.malformed', + 'missingsecuritytoken' + ]; + + if (in_array($normalizedCode, $credentialErrorCodes, true)) { + return true; + } + + if ($normalizedMessage === '') { + return false; + } + + return strpos($normalizedMessage, 'access key is not found') !== false + || strpos($normalizedMessage, 'access key id does not exist') !== false + || strpos($normalizedMessage, 'signature does not match') !== false + || strpos($normalizedMessage, 'incomplete signature') !== false + || strpos($normalizedMessage, 'accesskeydisabled') !== false; + } + private function safeGetTraffic($account) { try { @@ -1269,13 +1353,21 @@ class AliyunTrafficCheck $code = trim((string) $e->getErrorCode()); $message = '缺少云监控权限'; $status = 'permission_denied'; - if ($code !== '' && !in_array($code, ['403', 'NoPermission'], true)) { + if ($this->isCredentialInvalidError($code, $e->getMessage())) { + $message = '账号 AK 已失效'; + $status = 'auth_error'; + } elseif ($code !== '' && !in_array($code, ['403', 'NoPermission'], true)) { $message = '云监控鉴权失败'; $status = 'auth_error'; } $this->db->addLog('error', "公网出口流量查询配置错误 [{$this->getAccountLogLabel($account)}]: " . ($code ?: "鉴权失败") . ",请确认AK拥有云监控流量查询权限"); return ['success' => false, 'value' => null, 'status' => $status, 'message' => $message]; } catch (ServerException $e) { + $code = trim((string) $e->getErrorCode()); + if ($this->isCredentialInvalidError($code, $e->getErrorMessage())) { + $this->db->addLog('error', "公网出口流量查询失败 [{$this->getAccountLogLabel($account)}]: {$code} - " . $e->getErrorMessage()); + return ['success' => false, 'value' => null, 'status' => 'auth_error', 'message' => '账号 AK 已失效']; + } $this->db->addLog('error', "公网出口流量查询失败 [{$this->getAccountLogLabel($account)}]: " . $e->getErrorCode() . " - " . $e->getErrorMessage()); return ['success' => false, 'value' => null, 'status' => 'sync_error', 'message' => '云监控接口异常']; } catch (\Exception $e) { @@ -1951,6 +2043,14 @@ class AliyunTrafficCheck 'traffic_api_status' => $trafficResult['status'] ?? 'ok', 'traffic_api_message' => $trafficResult['message'] ?? '' ]; + if ($this->isCredentialInvalidTrafficStatus($trafficResult['status'] ?? '')) { + $metadata['protection_suspended'] = 1; + $metadata['protection_suspend_reason'] = 'credential_invalid'; + } else { + $metadata['protection_suspended'] = 0; + $metadata['protection_suspend_reason'] = ''; + $metadata['protection_suspend_notified_at'] = 0; + } $trafficApiStatus = $metadata['traffic_api_status']; $trafficApiMessage = $metadata['traffic_api_message']; diff --git a/ConfigManager.php b/ConfigManager.php index 2e90791..702bf90 100644 --- a/ConfigManager.php +++ b/ConfigManager.php @@ -877,6 +877,18 @@ class ConfigManager $sql .= ", traffic_api_message = ?"; $params[] = $metadata['traffic_api_message']; } + if (isset($metadata['protection_suspended'])) { + $sql .= ", protection_suspended = ?"; + $params[] = $metadata['protection_suspended'] ? 1 : 0; + } + if (isset($metadata['protection_suspend_reason'])) { + $sql .= ", protection_suspend_reason = ?"; + $params[] = (string) $metadata['protection_suspend_reason']; + } + if (isset($metadata['protection_suspend_notified_at'])) { + $sql .= ", protection_suspend_notified_at = ?"; + $params[] = (int) $metadata['protection_suspend_notified_at']; + } $sql .= " WHERE id = ?"; $params[] = $id; diff --git a/Database.php b/Database.php index 9d01516..2784678 100644 --- a/Database.php +++ b/Database.php @@ -210,6 +210,9 @@ class Database $this->ensureColumn('accounts', 'health_status', "TEXT DEFAULT 'Unknown'"); $this->ensureColumn('accounts', 'traffic_api_status', "TEXT DEFAULT 'ok'"); $this->ensureColumn('accounts', 'traffic_api_message', "TEXT DEFAULT ''"); + $this->ensureColumn('accounts', 'protection_suspended', 'INTEGER DEFAULT 0'); + $this->ensureColumn('accounts', 'protection_suspend_reason', "TEXT DEFAULT ''"); + $this->ensureColumn('accounts', 'protection_suspend_notified_at', 'INTEGER DEFAULT 0'); $this->ensureColumn('ecs_create_tasks', 'public_ip_mode', "TEXT DEFAULT 'ecs_public_ip'"); $this->ensureColumn('ecs_create_tasks', 'eip_allocation_id', "TEXT DEFAULT ''"); $this->ensureColumn('ecs_create_tasks', 'eip_address', "TEXT DEFAULT ''"); diff --git a/Dockerfile b/Dockerfile index a6ccb6c..92a4032 100644 --- a/Dockerfile +++ b/Dockerfile @@ -70,6 +70,7 @@ WORKDIR /var/www/html # 复制 Nginx 配置 (利用缓存,变更频率低) COPY docker/nginx.conf /etc/nginx/http.d/default.conf +COPY docker/php-fpm-www.conf /usr/local/etc/php-fpm.d/zz-www-local.conf # 复制并配置启动脚本 COPY docker/entrypoint.sh /entrypoint.sh @@ -82,4 +83,4 @@ COPY --from=builder --chown=www-data:www-data /app /var/www/html EXPOSE 80 # 设置容器启动入口 -ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file +ENTRYPOINT ["/entrypoint.sh"] diff --git a/NotificationService.php b/NotificationService.php index b67121a..01a9bc2 100644 --- a/NotificationService.php +++ b/NotificationService.php @@ -71,6 +71,32 @@ class NotificationService return $this->dispatchNotifications($title, "检测到流量异常或达到阈值", $details, 'warning', $textMsg, $accessKeyId); } + public function notifyCredentialInvalid($accessKeyId, $traffic, $percentage, $threshold) + { + $title = "流量告警 - 账号密钥已失效"; + $trafficText = $this->formatTraffic((float) $traffic); + $maskedAccount = substr($accessKeyId, 0, 7) . '***'; + $statusText = '检测到 AK 已失效,已暂停自动停机'; + $details = [ + ['label' => '账号', 'value' => $maskedAccount], + ['label' => '当前流量', 'value' => $trafficText], + ['label' => '使用率', 'value' => $percentage . '%', 'highlight' => true], + ['label' => '设定阈值', 'value' => $threshold . '%'], + ['label' => '当前状态', 'value' => $statusText], + ['label' => '处理建议', 'value' => '请更新 AK 后再恢复自动停机保护。'] + ]; + + $textMsg = "【ECS 服务器管家】{$title}\n" . + "账号: {$maskedAccount}\n" . + "当前流量: {$trafficText}\n" . + "使用率: {$percentage}%\n" . + "设定阈值: {$threshold}%\n" . + "当前状态: {$statusText}\n" . + "处理建议: 请更新 AK 后再恢复自动停机保护。"; + + return $this->dispatchNotifications($title, "检测到账号密钥失效,已暂停自动停机保护,避免重复失败通知。", $details, 'warning', $textMsg, $accessKeyId); + } + public function notifyEcsCreated($accountLabel, array $result, array $preview = []) { $title = 'ECS 创建并启动成功'; diff --git a/docker/php-fpm-www.conf b/docker/php-fpm-www.conf new file mode 100644 index 0000000..3bc6ed0 --- /dev/null +++ b/docker/php-fpm-www.conf @@ -0,0 +1,7 @@ +[www] +pm = dynamic +pm.max_children = 12 +pm.start_servers = 4 +pm.min_spare_servers = 2 +pm.max_spare_servers = 6 +pm.max_requests = 500