from __future__ import annotations import asyncio import uuid from typing import Any from app.accounts.service import AccountService from app.core.errors import ConflictError, NotFoundError, ValidationAppError from app.core.time import to_iso from app.fleet.proxy import parse_proxy_url, probe_proxy from app.fleet.repository import FleetRepository, InstanceGroupRecord, ManagedInstanceRecord from app.fleet.schemas import ( InstanceCreate, InstanceGroupCreate, InstanceGroupUpdate, InstanceUpdate, ) from app.integrations.aws_client import LightsailClient from app.integrations.cloudflare_client import CloudflareClient from app.integrations.service import IntegrationService from app.rotation.repository import RotationRepository class FleetService: def __init__( self, repository: FleetRepository, integration_service: IntegrationService, rotation_repository: RotationRepository, account_service: AccountService | None = None, ) -> None: self.repository = repository self.integration_service = integration_service self.rotation_repository = rotation_repository self.account_service = account_service def list_instances(self) -> list[dict[str, Any]]: groups = self.repository.list_groups() membership = { instance_id: (group.id, group.name) for group in groups for instance_id in group.member_ids } return [ self._instance_view(instance, membership.get(instance.id)) for instance in self.repository.list_instances() ] def get_instance(self, instance_id: str) -> dict[str, Any]: instance = self._require_instance(instance_id) membership = next( ( (group.id, group.name) for group in self.repository.list_groups() if instance_id in group.member_ids ), None, ) return self._instance_view(instance, membership) def create_instance(self, payload: InstanceCreate) -> dict[str, Any]: try: record = self.repository.create_instance(self._instance_payload(payload)) except RuntimeError as exc: raise self._repository_error(exc) from exc return self.get_instance(record.id) def update_instance(self, instance_id: str, payload: InstanceUpdate) -> dict[str, Any]: current = self._require_instance(instance_id) try: record = self.repository.update_instance( instance_id, self._instance_payload(payload, update=True, current=current), ) except RuntimeError as exc: raise self._repository_error(exc) from exc return self.get_instance(record.id) def archive_instance(self, instance_id: str) -> dict[str, Any]: try: record = self.repository.archive_instance(instance_id) except RuntimeError as exc: raise self._repository_error(exc) from exc return record.to_dict() def list_groups(self) -> list[dict[str, Any]]: instances = {item.id: item for item in self.repository.list_instances()} return [self._group_view(group, instances) for group in self.repository.list_groups()] def get_group(self, group_id: str) -> dict[str, Any]: group = self.repository.get_group(group_id) if group is None: raise NotFoundError("实例组不存在", code="INSTANCE_GROUP_NOT_FOUND") instances = {item.id: item for item in self.repository.list_instances()} return self._group_view(group, instances) def create_group(self, payload: InstanceGroupCreate) -> dict[str, Any]: try: group = self.repository.create_group(payload.model_dump(exclude_none=True)) except RuntimeError as exc: raise self._repository_error(exc) from exc return self.get_group(group.id) def update_group(self, group_id: str, payload: InstanceGroupUpdate) -> dict[str, Any]: try: group = self.repository.update_group( group_id, payload.model_dump(exclude_unset=True), ) except RuntimeError as exc: raise self._repository_error(exc) from exc return self.get_group(group.id) def archive_group(self, group_id: str) -> dict[str, Any]: try: group = self.repository.archive_group(group_id) except RuntimeError as exc: raise self._repository_error(exc) from exc return group.to_dict() async def test_instance(self, instance_id: str) -> dict[str, Any]: instance = self._require_instance(instance_id) aws, cloudflare = self._clients(instance) aws_result, cloudflare_result = await asyncio.gather( asyncio.to_thread(aws.test_connection), asyncio.to_thread(cloudflare.test_connection), ) public_ip = str(aws_result.get("public_ip") or "") or None record_ip = str(cloudflare_result.get("record_ip") or "") or None proxied = bool(cloudflare_result.get("proxied")) proxy_result: dict[str, Any] = { "ok": False, "protocol": instance.proxy_protocol, "port": instance.socks_port, "message": "实例暂无公网 IPv4,无法检查代理", } if public_ip: try: username, password = self.repository.resolve_proxy_credentials(instance_id) await probe_proxy( public_ip, instance.socks_port, instance.proxy_protocol, username=username, password=password, ) await probe_proxy( instance.cloudflare_record_name, instance.socks_port, instance.proxy_protocol, username=username, password=password, ) proxy_result.update(ok=True, message="代理认证与 CONNECT 检查通过") except (OSError, TimeoutError, asyncio.IncompleteReadError): proxy_result["message"] = "代理协议、认证或 CONNECT 检查失败" except RuntimeError as exc: raise self._repository_error(exc) from exc expected_ttl = 1 if instance.cloudflare_proxied else 60 cloudflare_mode_ok = ( proxied == instance.cloudflare_proxied and cloudflare_result.get("ttl") == expected_ttl ) route_ok = bool( public_ip and record_ip == public_ip and cloudflare_mode_ok and proxy_result["ok"] ) values: dict[str, Any] = { "last_known_ip": public_ip, "last_checked_at": to_iso(), } try: self.repository.update_instance_status(instance_id, values) zone_id = str(cloudflare_result.get("zone_id") or "") if zone_id and zone_id != instance.cloudflare_zone_id: self.repository.update_instance(instance_id, {"cloudflare_zone_id": zone_id}) except RuntimeError as exc: raise self._repository_error(exc) from exc return { "ok": route_ok, "route_ok": route_ok, "aws": aws_result, "cloudflare": cloudflare_result, "proxy": proxy_result, "message": ( "实例、Static IP 与 Cloudflare 路由一致" if route_ok else "实例、代理或 Cloudflare 路由仍有配置未对齐" ), } async def sync_dns(self, instance_id: str) -> dict[str, Any]: instance = self._require_instance(instance_id) owner_id = f"dns-sync-{uuid.uuid4()}" if not self.rotation_repository.acquire_dns_sync_lock(owner_id): raise ConflictError( "已有轮换或 DNS 同步任务进行中", code="ROTATION_IN_PROGRESS", ) try: current = self._require_instance(instance_id) if current.config_version != instance.config_version: raise ConflictError( "实例配置已变化,请重新执行 DNS 同步", code="INSTANCE_CONFIG_CHANGED", ) instance = current aws, cloudflare = self._clients(instance) snapshot = await asyncio.to_thread(aws.get_instance) if snapshot.state != "running" or not snapshot.public_ip: raise ValidationAppError("实例未运行或暂无公网 IPv4,无法同步 DNS") if not self.rotation_repository.renew_operation_lock(owner_id): raise ConflictError("DNS 同步锁已失效", code="DNS_SYNC_LOCK_LOST") record = await asyncio.to_thread( cloudflare.upsert_a_record, snapshot.public_ip, ) try: updates: dict[str, Any] = { "last_known_ip": snapshot.public_ip, "last_checked_at": to_iso(), } self.repository.update_instance_status( instance_id, updates, operation_owner_id=owner_id, ) if cloudflare.zone_id and cloudflare.zone_id != instance.cloudflare_zone_id: self.repository.update_instance( instance_id, {"cloudflare_zone_id": cloudflare.zone_id}, operation_owner_id=owner_id, ) except RuntimeError as exc: raise self._repository_error(exc) from exc return { "ok": True, "ip": snapshot.public_ip, "record_name": record.get("name"), "proxied": bool(record.get("proxied")), "ttl": record.get("ttl"), } finally: self.rotation_repository.release_operation_lock(owner_id) def _clients( self, instance: ManagedInstanceRecord, ) -> tuple[LightsailClient, CloudflareClient]: legacy_settings = None legacy_secrets: dict[str, str | None] = {} if not instance.aws_account_id or not instance.cloudflare_account_id: legacy_settings, legacy_secrets = self.integration_service.credentials() if instance.aws_account_id and self.account_service is not None: use_default, aws_secrets = self.account_service.resolve_aws(instance.aws_account_id) else: if legacy_settings is None: # pragma: no cover - guarded by condition above legacy_settings, legacy_secrets = self.integration_service.credentials() use_default = legacy_settings.use_default_aws_credentials aws_secrets = legacy_secrets if not use_default and not ( aws_secrets.get("aws_access_key_id") and aws_secrets.get("aws_secret_access_key") ): raise ValidationAppError("当前没有可用的 AWS 凭据") if instance.cloudflare_account_id and self.account_service is not None: token = self.account_service.resolve_cloudflare(instance.cloudflare_account_id) else: token = legacy_secrets.get("cloudflare_api_token") if not token: # pragma: no cover - ensure_configured guards this raise ValidationAppError("当前没有可用的 Cloudflare API Token") return ( LightsailClient.from_credentials( region=instance.aws_region, instance_name=instance.lightsail_instance_name, use_default=use_default, secrets=aws_secrets, ), CloudflareClient( token=token, zone_name=instance.cloudflare_zone_name, zone_id=instance.cloudflare_zone_id, record_name=instance.cloudflare_record_name, proxied=instance.cloudflare_proxied, ), ) @staticmethod def _instance_payload( payload: InstanceCreate | InstanceUpdate, *, update: bool = False, current: ManagedInstanceRecord | None = None, ) -> dict[str, Any]: values = payload.model_dump( exclude_none=not update, exclude_unset=update, exclude={"proxy_url", "clear_proxy_credentials"}, ) if payload.proxy_url is not None: try: endpoint = parse_proxy_url(payload.proxy_url.get_secret_value()) except ValueError as exc: raise ValidationAppError( "代理链接格式无效,请检查协议、认证信息、主机和端口", code="INVALID_PROXY_URL", ) from exc record_name = ( str( values.get("cloudflare_record_name") or (current.cloudflare_record_name if current is not None else "") ) .rstrip(".") .lower() ) if endpoint.host.rstrip(".").lower() != record_name: raise ValidationAppError( "代理链接中的主机必须与 Cloudflare A 记录完整域名一致", code="PROXY_HOST_MISMATCH", ) if current is None or endpoint.protocol != current.proxy_protocol: values["proxy_protocol"] = endpoint.protocol else: values.pop("proxy_protocol", None) if current is None or endpoint.port != current.socks_port: values["socks_port"] = endpoint.port else: values.pop("socks_port", None) values["_proxy_credentials"] = ( (endpoint.username, endpoint.password) if endpoint.username is not None and endpoint.password is not None else None ) elif payload.clear_proxy_credentials: values["_proxy_credentials"] = None return values def _require_instance(self, instance_id: str) -> ManagedInstanceRecord: instance = self.repository.get_instance(instance_id) if instance is None: raise NotFoundError("托管实例不存在", code="INSTANCE_NOT_FOUND") return instance @staticmethod def _instance_view( instance: ManagedInstanceRecord, membership: tuple[str, str] | None, ) -> dict[str, Any]: return { **instance.to_dict(), "group_id": membership[0] if membership else None, "group_name": membership[1] if membership else None, } @staticmethod def _group_view( group: InstanceGroupRecord, instances: dict[str, ManagedInstanceRecord], ) -> dict[str, Any]: members = [instances[item_id] for item_id in group.member_ids if item_id in instances] return { **group.to_dict(), "members": [ { "id": member.id, "display_name": member.display_name, "enabled": member.enabled, "last_known_ip": member.last_known_ip, "cloudflare_record_name": member.cloudflare_record_name, } for member in members ], } @staticmethod def _repository_error(exc: RuntimeError) -> Exception: code = str(exc) if code in {"ACTIVE_ROTATION", "FLEET_RUN_ACTIVE"}: return ConflictError( "轮换任务进行中,暂时不能修改实例或分组", code="ROTATION_IN_PROGRESS", ) if code == "INSTANCE_NOT_FOUND": return NotFoundError("托管实例不存在", code="INSTANCE_NOT_FOUND") if code == "GROUP_NOT_FOUND": return NotFoundError("实例组不存在", code="INSTANCE_GROUP_NOT_FOUND") conflicts = { "INSTANCE_DISPLAY_NAME_CONFLICT": "实例显示名称已存在", "INSTANCE_AWS_TARGET_CONFLICT": "该 Lightsail 实例已被其他配置管理", "INSTANCE_DNS_RECORD_CONFLICT": "该 Cloudflare 记录已被其他实例使用", "INSTANCE_ALREADY_GROUPED": "一个实例只能加入一个实例组", "GROUP_NAME_CONFLICT": "实例组名称已存在", "INSTANCE_CONFIG_VERSION_CONFLICT": "实例配置已被其他操作更新,请刷新后重试", "GROUP_CONFIG_VERSION_CONFLICT": "实例组配置已被其他操作更新,请刷新后重试", } if code in conflicts: return ConflictError(conflicts[code], code=code) validations = { "GROUP_REQUIRES_ENABLED_MEMBER": "启用的实例组至少需要一个已启用实例", "GROUP_MEMBER_NOT_FOUND": "实例组包含不存在或已归档的实例", "INSTANCE_DISABLE_BREAKS_GROUP": "该实例是启用组中最后一个可用成员", "DNS_RECORD_OUTSIDE_ZONE": "DNS 记录必须属于所选 Cloudflare Zone", "AWS_ACCOUNT_NOT_FOUND": "所选 AWS 账号不存在或已归档", "CLOUDFLARE_ACCOUNT_NOT_FOUND": "所选 Cloudflare 账号不存在或已归档", "AWS_ACCOUNT_PROVIDER_MISMATCH": "所选账号不是 AWS 账号", "CLOUDFLARE_ACCOUNT_PROVIDER_MISMATCH": "所选账号不是 Cloudflare 账号", "INVALID_PROXY_CREDENTIALS": "代理认证配置无效", "PROXY_CREDENTIALS_INCOMPLETE": "代理认证密钥不完整,请重新粘贴代理链接", "PROXY_SECRET_STORAGE_UNAVAILABLE": "代理认证密钥存储尚未初始化", } if code in validations: return ValidationAppError(validations[code], code=code) return ValidationAppError("配置保存失败", code=code)