FluxIP/app/fleet/service.py

413 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)