From 933cfb6484aa8de32f4c587a66d83ab6746b4b41 Mon Sep 17 00:00:00 2001 From: "3127647737@qq.com" <3127647737@qq.com> Date: Fri, 17 Jul 2026 16:29:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=A4=9A=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E5=87=AD=E6=8D=AE=E4=B8=8E=E5=8C=BA=E5=9F=9F=E9=80=89?= =?UTF-8?q?=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 63 ++- app/accounts/__init__.py | 1 + app/accounts/repository.py | 642 +++++++++++++++++++++++++ app/accounts/router.py | 60 +++ app/accounts/schemas.py | 124 +++++ app/accounts/service.py | 226 +++++++++ app/container.py | 7 + app/dependencies.py | 5 + app/fleet/regions.py | 33 ++ app/fleet/repository.py | 50 +- app/fleet/router.py | 8 + app/fleet/schemas.py | 31 +- app/fleet/service.py | 40 +- app/main.py | 2 + app/rotation/repository.py | 49 +- app/rotation/service.py | 61 ++- app/static/app.js | 448 ++++++++++++++--- app/static/index.html | 91 +++- app/static/styles.css | 326 ++++++++++++- migrations/005_credential_accounts.sql | 84 ++++ tests/test_accounts_api.py | 595 +++++++++++++++++++++++ tests/test_accounts_repository.py | 507 +++++++++++++++++++ tests/test_fleet_repository.py | 2 + tests/test_static_frontend.py | 29 +- 24 files changed, 3342 insertions(+), 142 deletions(-) create mode 100644 app/accounts/__init__.py create mode 100644 app/accounts/repository.py create mode 100644 app/accounts/router.py create mode 100644 app/accounts/schemas.py create mode 100644 app/accounts/service.py create mode 100644 app/fleet/regions.py create mode 100644 migrations/005_credential_accounts.sql create mode 100644 tests/test_accounts_api.py create mode 100644 tests/test_accounts_repository.py diff --git a/README.md b/README.md index b801b23..e65b1a7 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,9 @@ DNS-only A 记录组成一个托管目标,再通过实例组统一设置轮换 把新地址附加到仍在运行的实例,验证 SOCKS5 与 Cloudflare DNS,等待释放宽限期, 最后从 AWS 账号释放旧 Static IP。 -配置、任务状态和历史记录保存在 SQLite。AWS 密钥与 Cloudflare API Token 使用本机 -主密钥加密后存储。 +配置、账号资料、任务状态和历史记录保存在 SQLite。可以分别维护多套 AWS 与 +Cloudflare 账号,每个托管实例独立选择要使用的两套账号。AWS 密钥与 Cloudflare +API Token 使用本机主密钥加密后存储,API 和 WebUI 不会返回密钥明文。 ## 重要边界 @@ -31,8 +32,8 @@ DNS-only A 记录组成一个托管目标,再通过实例组统一设置轮换 一个托管实例包含: - 显示名称; -- AWS 区域和 Lightsail 实例名称; -- Cloudflare Zone、唯一的完整 A 记录名; +- AWS 账号、从内置中文名称与图标目录选择的 Lightsail 区域,以及 Lightsail 实例名称; +- Cloudflare 账号、Zone ID 和唯一的完整 A 记录名; - SOCKS5 端口、健康检查开关和超时; - 旧地址释放宽限期; - 是否允许参与手动或分组轮换。 @@ -42,13 +43,18 @@ DNS-only A 记录组成一个托管目标,再通过实例组统一设置轮换 代理。下次执行时间从整个批次结束后重新计算,两个批次之间至少间隔一个完整周期。 当前系统全局只允许一个轮换批次或 DNS 同步任务执行。 -所有托管实例共用一套 AWS 凭据和一枚 Cloudflare API Token。数据库会阻止以下 -冲突: +AWS 与 Cloudflare 账号分别建档,同一账号可以由多个实例复用,不同实例也可以选择 +完全独立的账号组合。数据库会阻止以下冲突: -- 同一个 AWS 区域和 Lightsail 实例被重复管理; -- 同一个 Cloudflare 记录被多个托管实例使用; +- 同一个 AWS 账号下的同一区域和 Lightsail 实例被重复管理; +- 同一个 Cloudflare 账号下的同一记录被多个托管实例使用; - 同一个实例同时加入多个实例组。 +账号的新增、修改和删除与实例配置共用轮换写锁。轮换批次或 DNS 同步任务执行期间 +通常不能写入账号;任务进入待处理或待清理后,只允许替换当前运行项所绑定账号的 +恢复密钥,不能改名、切换认证模式或操作其他账号。仍被活动实例引用的账号不能删除, +必须先把实例改绑到其他账号或删除相关实例。 + ## 轮换流程 一次实例轮换按以下顺序执行: @@ -137,6 +143,10 @@ data/master.key 首次启动会创建数据库和主密钥。数据库存在而主密钥丢失时,程序会拒绝启动,避免 用新密钥覆盖后无法解密原凭据。 +从只支持一套全局凭据的旧版本升级时,已配置的 AWS 和 Cloudflare 凭据会在启动迁移 +后分别导入为 legacy 账号,旧实例会自动绑定对应账号。导入过程使用现有主密钥解密并 +重新加密,不会把密钥明文写入数据库或日志。 + ## 管理员账号 FluxIP **没有默认管理员账号或默认密码**。 @@ -157,19 +167,24 @@ Bootstrap Token、云端密钥或数据库主密钥提交到 Git。 ## WebUI 首次配置 1. 创建管理员并登录。 -2. 在“共享凭据”中选择 AWS 默认凭据链,或填写专用 Access Key ID、Secret Access - Key;临时凭据还需要 Session Token。填写 Cloudflare API Token。 -3. 新建托管实例,填写区域、Lightsail 实例名称、Zone 和完整 A 记录名。 -4. 执行实例连接测试,确认 AWS 实例公网地址和 Cloudflare 记录状态。 -5. 如 DNS 尚未指向实例当前地址,先执行 DNS 同步。 -6. 对单个实例执行一次手动轮换,核对新 Static IP、SOCKS5 和 DNS。 -7. 创建实例组、添加成员、设置轮换间隔并启用计划。 +2. 打开“账号”,新增 AWS 账号:选择默认凭据链,或填写专用 Access Key ID、Secret + Access Key;临时凭据还需要 Session Token。 +3. 继续新增 Cloudflare 账号并填写 API Token。可以为不同 AWS 或 Cloudflare 租户创建 + 多个独立账号资料。 +4. 新建托管实例,选择已保存的 AWS 账号和 Cloudflare 账号;从带图标和中文名称的 + 区域目录选择 AWS Region,再填写 Lightsail 实例名称、Zone ID 和完整 A 记录名。 +5. 执行实例连接测试,确认所选 AWS 账号可以读取实例公网地址,所选 Cloudflare 账号 + 可以读取目标记录。 +6. 如 DNS 尚未指向实例当前地址,先执行 DNS 同步。 +7. 对单个实例执行一次手动轮换,核对新 Static IP、SOCKS5 和 DNS。 +8. 创建实例组、添加成员、设置轮换间隔并启用计划。 配置更新使用版本号进行并发检查。如果页面数据已被另一会话修改,刷新后再提交。 -实例和实例组的删除是软删除,不会自动删除 Lightsail 实例或 Cloudflare Zone。 +账号、实例和实例组的删除是软删除,不会自动删除 Lightsail 实例或 Cloudflare Zone。 -轮换执行中通常禁止修改共享凭据、实例或实例组。任务因凭据失效进入待处理或待清理 -后,可以只替换恢复凭据;审计日志只记录被更新的字段名,不记录密钥内容。 +轮换批次或 DNS 同步执行中禁止新增、删除账号,也禁止修改实例或实例组。任务因凭据 +失效进入待处理或待清理后,可以只替换当前运行项所绑定账号的恢复密钥;账号名称和 +认证模式仍被锁定。审计日志只记录被更新的字段名,不记录密钥内容。 ## AWS 最小权限 @@ -382,14 +397,18 @@ systemd 定时备份示例为 `deploy/fluxip-backup.service` 和 只有目标目录本身已经加密时,才使用 `--allow-plaintext-key-archive` 创建包含主密钥的 完整归档。恢复时先停止 FluxIP,同时恢复匹配的数据库和主密钥,再启动并验证 -`/readyz`、管理员登录、共享凭据状态、实例组计划和云端连接。 +`/readyz`、管理员登录、账号资料状态、实例组计划和云端连接。 ## 健康检查与排错 -- `GET /healthz`:进程存活状态。 -- `GET /readyz`:SQLite 就绪状态,正常返回 HTTP 200。 +- `GET /healthz`:进程存活状态,返回 `status` 和应用版本;不探测 SQLite 或云端账号。 +- `GET /readyz`:检查 SQLite 是否可用;就绪时返回 HTTP 200,数据库异常时返回 + HTTP 503 和 `degraded`。该接口不会调用 AWS 或 Cloudflare。 - **没有默认管理员**:首次打开页面自行创建;远程初始化需 Bootstrap Token。 -- **AWS AccessDenied**:核对上述 8 个 Lightsail Action 和目标区域,不要直接授予管理员。 +- **账号连接测试失败**:确认实例选中了正确类型的 AWS 与 Cloudflare 账号,账号密钥 + 完整,并且 Region、Zone ID 和记录名属于对应账号。 +- **AWS AccessDenied**:核对所选 AWS 账号的上述 8 个 Lightsail Action 和目标区域, + 不要直接授予管理员。 - **Static IP 配额不足**:提高该区域配额;不要先释放正在使用的旧地址。 - **找不到附加的旧地址**:确认实例、Static IP 和区域一致,避免同时在控制台人工修改。 - **SOCKS5 检查失败**:确认服务监听公网接口、端口开放、协议握手可用。 diff --git a/app/accounts/__init__.py b/app/accounts/__init__.py new file mode 100644 index 0000000..8c24490 --- /dev/null +++ b/app/accounts/__init__.py @@ -0,0 +1 @@ +"""Credential account management.""" diff --git a/app/accounts/repository.py b/app/accounts/repository.py new file mode 100644 index 0000000..3cc8b67 --- /dev/null +++ b/app/accounts/repository.py @@ -0,0 +1,642 @@ +from __future__ import annotations + +import json +import sqlite3 +import uuid +from collections.abc import Iterable, Mapping +from dataclasses import asdict, dataclass +from typing import Any, Literal + +from app.core.security import EncryptedValue, SecretCipher +from app.core.time import to_iso +from app.database.database import Database + +AccountProvider = Literal["aws", "cloudflare"] + +AWS_SECRET_NAMES = ( + "aws_access_key_id", + "aws_secret_access_key", + "aws_session_token", +) +CLOUDFLARE_SECRET_NAMES = ("cloudflare_api_token",) +SECRET_NAMES = (*AWS_SECRET_NAMES, *CLOUDFLARE_SECRET_NAMES) + +ACCOUNT_COLUMNS = """ +id, provider, name, use_default_aws_credentials, config_version, +created_at, updated_at, archived_at +""" + + +@dataclass(slots=True) +class CredentialAccountRecord: + id: str + provider: AccountProvider + name: str + use_default_aws_credentials: bool | None + config_version: int + created_at: str + updated_at: str + archived_at: str | None + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +class AccountRepository: + def __init__(self, database: Database, cipher: SecretCipher) -> None: + self.database = database + self.cipher = cipher + + def list_accounts( + self, + provider: AccountProvider | None = None, + *, + include_archived: bool = False, + ) -> list[CredentialAccountRecord]: + clauses: list[str] = [] + parameters: list[object] = [] + if provider is not None: + clauses.append("provider = ?") + parameters.append(provider) + if not include_archived: + clauses.append("archived_at IS NULL") + where = f"WHERE {' AND '.join(clauses)}" if clauses else "" + with self.database.connect() as connection: + rows = connection.execute( + f"SELECT {ACCOUNT_COLUMNS} FROM credential_accounts {where} " + "ORDER BY provider, name COLLATE NOCASE, id", + parameters, + ).fetchall() + return [self._account_from_row(row) for row in rows] + + def get_account( + self, + account_id: str, + *, + include_archived: bool = False, + ) -> CredentialAccountRecord | None: + archived_clause = "" if include_archived else " AND archived_at IS NULL" + with self.database.connect() as connection: + row = connection.execute( + f"SELECT {ACCOUNT_COLUMNS} FROM credential_accounts WHERE id = ?{archived_clause}", + (account_id,), + ).fetchone() + return self._account_from_row(row) if row else None + + def create_account( + self, + *, + provider: AccountProvider, + name: str, + use_default_aws_credentials: bool | None, + secret_values: Mapping[str, str], + ) -> CredentialAccountRecord: + account_id = str(uuid.uuid4()) + now = to_iso() + self._validate_secret_names(provider, secret_values) + with self.database.connect() as connection: + try: + connection.execute("BEGIN IMMEDIATE") + self._ensure_writes_allowed(connection) + connection.execute( + """ + INSERT INTO credential_accounts( + id, provider, name, use_default_aws_credentials, + config_version, created_at, updated_at + ) VALUES (?, ?, ?, ?, 1, ?, ?) + """, + ( + account_id, + provider, + name.strip(), + self._database_bool(use_default_aws_credentials), + now, + now, + ), + ) + self._upsert_secrets(connection, account_id, secret_values, now) + connection.commit() + except sqlite3.IntegrityError as exc: + connection.rollback() + raise RuntimeError(self._integrity_code(exc)) from exc + except Exception: + connection.rollback() + raise + created = self.get_account(account_id) + if created is None: # pragma: no cover - insert and read share the same database + raise RuntimeError("ACCOUNT_CREATION_FAILED") + return created + + def import_legacy_accounts( + self, + *, + aws_use_default: bool | None, + aws_secret_values: Mapping[str, str], + cloudflare_api_token: str | None, + ) -> dict[AccountProvider, str]: + """Import the former global credentials exactly once during startup.""" + imported: dict[AccountProvider, str] = {} + now = to_iso() + with self.database.connect() as connection: + try: + connection.execute("BEGIN IMMEDIATE") + if aws_use_default is not None: + aws_id, aws_can_import = self._legacy_account_target(connection, "aws") + if aws_id is None and aws_can_import: + aws_id = "legacy-aws" + connection.execute( + """ + INSERT INTO credential_accounts( + id, provider, name, use_default_aws_credentials, + config_version, created_at, updated_at + ) VALUES (?, 'aws', ?, ?, 1, ?, ?) + """, + ( + aws_id, + "旧版全局 AWS 账号", + int(aws_use_default), + now, + now, + ), + ) + self._upsert_secrets( + connection, + aws_id, + aws_secret_values, + now, + ) + if aws_id == "legacy-aws" and aws_can_import: + imported["aws"] = aws_id + connection.execute( + """ + UPDATE managed_instances SET aws_account_id = ? + WHERE aws_account_id IS NULL + """, + (aws_id,), + ) + connection.execute( + """ + UPDATE fleet_run_items SET aws_account_id = ? + WHERE aws_account_id IS NULL + """, + (aws_id,), + ) + if cloudflare_api_token: + cloudflare_id, cloudflare_can_import = self._legacy_account_target( + connection, + "cloudflare", + ) + if cloudflare_id is None and cloudflare_can_import: + cloudflare_id = "legacy-cloudflare" + connection.execute( + """ + INSERT INTO credential_accounts( + id, provider, name, use_default_aws_credentials, + config_version, created_at, updated_at + ) VALUES (?, 'cloudflare', ?, NULL, 1, ?, ?) + """, + ( + cloudflare_id, + "旧版全局 Cloudflare 账号", + now, + now, + ), + ) + self._upsert_secrets( + connection, + cloudflare_id, + {"cloudflare_api_token": cloudflare_api_token}, + now, + ) + if cloudflare_id == "legacy-cloudflare" and cloudflare_can_import: + imported["cloudflare"] = cloudflare_id + connection.execute( + """ + UPDATE managed_instances SET cloudflare_account_id = ? + WHERE cloudflare_account_id IS NULL + """, + (cloudflare_id,), + ) + connection.execute( + """ + UPDATE fleet_run_items SET cloudflare_account_id = ? + WHERE cloudflare_account_id IS NULL + """, + (cloudflare_id,), + ) + connection.commit() + except sqlite3.IntegrityError as exc: + connection.rollback() + raise RuntimeError(self._integrity_code(exc)) from exc + except Exception: + connection.rollback() + raise + return imported + + def update_account( + self, + account_id: str, + *, + expected_version: int, + values: Mapping[str, object], + secret_values: Mapping[str, str], + delete_secret_names: Iterable[str] = (), + ) -> CredentialAccountRecord: + delete_names = tuple(dict.fromkeys(delete_secret_names)) + with self.database.connect() as connection: + try: + connection.execute("BEGIN IMMEDIATE") + current = self._get_account_row(connection, account_id) + if current is None: + raise RuntimeError("ACCOUNT_NOT_FOUND") + if int(current["config_version"]) != expected_version: + raise RuntimeError("ACCOUNT_CONFIG_VERSION_CONFLICT") + provider = str(current["provider"]) + self._validate_secret_names(provider, secret_values) + self._validate_secret_names(provider, delete_names) + recovery_target = self._ensure_account_update_allowed( + connection, + account_id, + current, + values, + secret_values, + ) + updates: dict[str, object] = {} + if "name" in values: + updates["name"] = str(values["name"]).strip() + if "use_default_aws_credentials" in values: + updates["use_default_aws_credentials"] = self._database_bool( + values["use_default_aws_credentials"] + ) + now = to_iso() + if updates: + assignments = ", ".join(f"{field} = ?" for field in updates) + connection.execute( + f""" + UPDATE credential_accounts + SET {assignments}, config_version = config_version + 1, + updated_at = ? + WHERE id = ? AND archived_at IS NULL + """, + (*updates.values(), now, account_id), + ) + else: + connection.execute( + """ + UPDATE credential_accounts + SET config_version = config_version + 1, updated_at = ? + WHERE id = ? AND archived_at IS NULL + """, + (now, account_id), + ) + if delete_names: + placeholders = ", ".join("?" for _ in delete_names) + connection.execute( + f"DELETE FROM account_secrets " + f"WHERE account_id = ? AND name IN ({placeholders})", + (account_id, *delete_names), + ) + self._upsert_secrets(connection, account_id, secret_values, now) + if recovery_target is not None: + run_id, item_id = recovery_target + connection.execute( + """ + INSERT INTO fleet_events( + run_id, item_id, occurred_at, stage, level, + message, details_json + ) VALUES (?, ?, ?, 'credentials', 'info', ?, ?) + """, + ( + run_id, + item_id, + now, + "管理员已更新当前账号的恢复凭据", + json.dumps( + { + "account_id": account_id, + "updated_credentials": sorted( + set(secret_values) | set(delete_names) + ), + }, + ensure_ascii=False, + ), + ), + ) + connection.commit() + except sqlite3.IntegrityError as exc: + connection.rollback() + raise RuntimeError(self._integrity_code(exc)) from exc + except Exception: + connection.rollback() + raise + updated = self.get_account(account_id) + if updated is None: # pragma: no cover + raise RuntimeError("ACCOUNT_NOT_FOUND") + return updated + + def archive_account(self, account_id: str) -> CredentialAccountRecord: + now = to_iso() + with self.database.connect() as connection: + try: + connection.execute("BEGIN IMMEDIATE") + self._ensure_writes_allowed(connection) + if self._get_account_row(connection, account_id) is None: + raise RuntimeError("ACCOUNT_NOT_FOUND") + if connection.execute( + """ + SELECT 1 FROM managed_instances + WHERE archived_at IS NULL + AND (aws_account_id = ? OR cloudflare_account_id = ?) + LIMIT 1 + """, + (account_id, account_id), + ).fetchone(): + raise RuntimeError("ACCOUNT_IN_USE") + connection.execute( + "DELETE FROM account_secrets WHERE account_id = ?", + (account_id,), + ) + connection.execute( + """ + UPDATE credential_accounts + SET config_version = config_version + 1, + updated_at = ?, archived_at = ? + WHERE id = ? AND archived_at IS NULL + """, + (now, now, account_id), + ) + connection.commit() + except Exception: + connection.rollback() + raise + archived = self.get_account(account_id, include_archived=True) + if archived is None: # pragma: no cover + raise RuntimeError("ACCOUNT_NOT_FOUND") + return archived + + def secret_flags(self, account_id: str) -> dict[str, bool]: + return self.secret_flags_many([account_id]).get( + account_id, + {name: False for name in SECRET_NAMES}, + ) + + def secret_flags_many(self, account_ids: Iterable[str]) -> dict[str, dict[str, bool]]: + ids = list(dict.fromkeys(account_ids)) + result = {account_id: {name: False for name in SECRET_NAMES} for account_id in ids} + if not ids: + return result + placeholders = ", ".join("?" for _ in ids) + with self.database.connect() as connection: + rows = connection.execute( + f"SELECT account_id, name FROM account_secrets " + f"WHERE account_id IN ({placeholders})", + ids, + ).fetchall() + for row in rows: + account_id = str(row["account_id"]) + name = str(row["name"]) + if account_id in result and name in result[account_id]: + result[account_id][name] = True + return result + + def resolve_aws( + self, + account_id: str, + ) -> tuple[bool, dict[str, str | None]]: + account = self._require_provider(account_id, "aws") + secrets = {name: self._get_secret(account.id, name) for name in AWS_SECRET_NAMES} + use_default = bool(account.use_default_aws_credentials) + if not use_default and not ( + secrets["aws_access_key_id"] and secrets["aws_secret_access_key"] + ): + raise RuntimeError("ACCOUNT_CREDENTIALS_INCOMPLETE") + return use_default, secrets + + def resolve_cloudflare(self, account_id: str) -> str: + account = self._require_provider(account_id, "cloudflare") + token = self._get_secret(account.id, "cloudflare_api_token") + if not token: + raise RuntimeError("ACCOUNT_CREDENTIALS_INCOMPLETE") + return token + + def _require_provider( + self, + account_id: str, + provider: AccountProvider, + ) -> CredentialAccountRecord: + account = self.get_account(account_id) + if account is None: + raise RuntimeError("ACCOUNT_NOT_FOUND") + if account.provider != provider: + raise RuntimeError("ACCOUNT_PROVIDER_MISMATCH") + return account + + def _get_secret(self, account_id: str, name: str) -> str | None: + with self.database.connect() as connection: + row = connection.execute( + """ + SELECT key_version, nonce, ciphertext + FROM account_secrets WHERE account_id = ? AND name = ? + """, + (account_id, name), + ).fetchone() + if row is None: + return None + return self.cipher.decrypt( + self._secret_aad(account_id, name), + EncryptedValue(**dict(row)), + ) + + @staticmethod + def _legacy_account_target( + connection: sqlite3.Connection, + provider: AccountProvider, + ) -> tuple[str | None, bool]: + legacy_id = f"legacy-{provider}" + legacy = connection.execute( + "SELECT archived_at FROM credential_accounts WHERE id = ?", + (legacy_id,), + ).fetchone() + if legacy is not None: + return ( + legacy_id if legacy["archived_at"] is None else None, + legacy["archived_at"] is None, + ) + active = connection.execute( + """ + SELECT id FROM credential_accounts + WHERE provider = ? AND archived_at IS NULL + ORDER BY created_at, id + LIMIT 1 + """, + (provider,), + ).fetchone() + if active is not None: + return str(active["id"]), False + return None, True + + def _upsert_secrets( + self, + connection: sqlite3.Connection, + account_id: str, + secret_values: Mapping[str, str], + now: str, + ) -> None: + for name, plaintext in secret_values.items(): + encrypted = self.cipher.encrypt( + self._secret_aad(account_id, name), + plaintext.strip(), + ) + connection.execute( + """ + INSERT INTO account_secrets( + account_id, name, key_version, nonce, ciphertext, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(account_id, name) DO UPDATE SET + key_version = excluded.key_version, + nonce = excluded.nonce, + ciphertext = excluded.ciphertext, + updated_at = excluded.updated_at + """, + ( + account_id, + name, + encrypted.key_version, + encrypted.nonce, + encrypted.ciphertext, + now, + now, + ), + ) + + @staticmethod + def _secret_aad(account_id: str, name: str) -> str: + return f"credential-account:{account_id}:{name}" + + @staticmethod + def _ensure_writes_allowed(connection: sqlite3.Connection) -> None: + now = to_iso() + connection.execute( + """ + DELETE FROM fleet_operation_locks + WHERE kind = 'dns_sync' AND lease_until IS NOT NULL AND lease_until < ? + """, + (now,), + ) + if connection.execute("SELECT 1 FROM fleet_runs WHERE active_slot = 1 LIMIT 1").fetchone(): + raise RuntimeError("FLEET_RUN_ACTIVE") + if connection.execute( + """ + SELECT 1 FROM fleet_operation_locks + WHERE lease_until IS NULL OR lease_until >= ? + LIMIT 1 + """, + (now,), + ).fetchone(): + raise RuntimeError("FLEET_RUN_ACTIVE") + + @staticmethod + def _ensure_account_update_allowed( + connection: sqlite3.Connection, + account_id: str, + current: sqlite3.Row, + values: Mapping[str, object], + secret_values: Mapping[str, str], + ) -> tuple[str, str | None] | None: + now = to_iso() + connection.execute( + """ + DELETE FROM fleet_operation_locks + WHERE kind = 'dns_sync' AND lease_until IS NOT NULL AND lease_until < ? + """, + (now,), + ) + active = connection.execute( + """ + SELECT id, status, current_item_id + FROM fleet_runs WHERE active_slot = 1 + """ + ).fetchone() + if active is None: + if connection.execute("SELECT 1 FROM fleet_operation_locks LIMIT 1").fetchone(): + raise RuntimeError("FLEET_RUN_ACTIVE") + return None + + if active["status"] not in {"needs_attention", "cleanup_pending"}: + raise RuntimeError("FLEET_RUN_ACTIVE") + if connection.execute( + """ + SELECT 1 FROM fleet_operation_locks + WHERE NOT (kind = 'rotation' AND owner_id = ?) + LIMIT 1 + """, + (active["id"],), + ).fetchone(): + raise RuntimeError("FLEET_RUN_ACTIVE") + item = connection.execute( + """ + SELECT 1 FROM fleet_run_items + WHERE id = ? AND (aws_account_id = ? OR cloudflare_account_id = ?) + """, + (active["current_item_id"], account_id, account_id), + ).fetchone() + if item is None: + raise RuntimeError("FLEET_RUN_ACTIVE") + + name_changed = "name" in values and str(values["name"]).strip() != str(current["name"]) + mode_changed = ( + "use_default_aws_credentials" in values + and AccountRepository._database_bool(values["use_default_aws_credentials"]) + != current["use_default_aws_credentials"] + ) + if name_changed or mode_changed or not secret_values: + raise RuntimeError("FLEET_RUN_ACTIVE") + return str(active["id"]), ( + str(active["current_item_id"]) if active["current_item_id"] else None + ) + + @staticmethod + def _validate_secret_names( + provider: str, + values: Mapping[str, object] | Iterable[str], + ) -> None: + names = set(values) + allowed = set(AWS_SECRET_NAMES if provider == "aws" else CLOUDFLARE_SECRET_NAMES) + if not names <= allowed: + raise RuntimeError("ACCOUNT_PROVIDER_MISMATCH") + + @staticmethod + def _database_bool(value: object) -> int | None: + if value is None: + return None + return int(bool(value)) + + @staticmethod + def _get_account_row( + connection: sqlite3.Connection, + account_id: str, + ) -> sqlite3.Row | None: + return connection.execute( + f"SELECT {ACCOUNT_COLUMNS} FROM credential_accounts " + "WHERE id = ? AND archived_at IS NULL", + (account_id,), + ).fetchone() + + @staticmethod + def _account_from_row(row: sqlite3.Row) -> CredentialAccountRecord: + values = dict(row) + if values["use_default_aws_credentials"] is not None: + values["use_default_aws_credentials"] = bool(values["use_default_aws_credentials"]) + return CredentialAccountRecord(**values) + + @staticmethod + def _integrity_code(exc: sqlite3.IntegrityError) -> str: + message = str(exc) + if "credential_accounts.provider" in message and "credential_accounts.name" in message: + return "ACCOUNT_NAME_CONFLICT" + if "credential_accounts.id" in message: + return "ACCOUNT_ID_CONFLICT" + if "CHECK constraint failed" in message or "NOT NULL constraint failed" in message: + return "INVALID_ACCOUNT_DATA" + return "ACCOUNT_CONSTRAINT_CONFLICT" diff --git a/app/accounts/router.py b/app/accounts/router.py new file mode 100644 index 0000000..92bd7f4 --- /dev/null +++ b/app/accounts/router.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query, Response, status + +from app.accounts.repository import AccountProvider +from app.accounts.schemas import AccountCreate, AccountUpdate +from app.accounts.service import AccountService +from app.auth.repository import SessionRecord +from app.dependencies import get_account_service, require_session + +router = APIRouter(prefix="/api/v1/accounts", tags=["accounts"]) + + +@router.get("") +def list_accounts( + provider: AccountProvider | None = Query(default=None), + _: SessionRecord = Depends(require_session), + service: AccountService = Depends(get_account_service), +) -> dict: + return {"data": service.list_accounts(provider)} + + +@router.post("", status_code=status.HTTP_201_CREATED) +def create_account( + payload: AccountCreate, + response: Response, + _: SessionRecord = Depends(require_session), + service: AccountService = Depends(get_account_service), +) -> dict: + account = service.create_account(payload) + response.headers["Location"] = f"/api/v1/accounts/{account['id']}" + return {"data": account} + + +@router.get("/{account_id}") +def get_account( + account_id: str, + _: SessionRecord = Depends(require_session), + service: AccountService = Depends(get_account_service), +) -> dict: + return {"data": service.get_account(account_id)} + + +@router.put("/{account_id}") +def update_account( + account_id: str, + payload: AccountUpdate, + _: SessionRecord = Depends(require_session), + service: AccountService = Depends(get_account_service), +) -> dict: + return {"data": service.update_account(account_id, payload)} + + +@router.delete("/{account_id}") +def archive_account( + account_id: str, + _: SessionRecord = Depends(require_session), + service: AccountService = Depends(get_account_service), +) -> dict: + return {"data": service.archive_account(account_id)} diff --git a/app/accounts/schemas.py b/app/accounts/schemas.py new file mode 100644 index 0000000..96d33cf --- /dev/null +++ b/app/accounts/schemas.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field, field_validator, model_validator + +AccountProvider = Literal["aws", "cloudflare"] + + +class AccountCreate(BaseModel): + provider: AccountProvider + name: str = Field(min_length=1, max_length=80) + use_default_aws_credentials: bool = False + aws_access_key_id: str | None = Field(default=None, max_length=128) + aws_secret_access_key: str | None = Field(default=None, max_length=256) + aws_session_token: str | None = Field(default=None, max_length=4096) + cloudflare_api_token: str | None = Field(default=None, max_length=2048) + + @field_validator("name") + @classmethod + def normalize_name(cls, value: str) -> str: + normalized = value.strip() + if not normalized: + raise ValueError("账号名称不能为空") + return normalized + + @field_validator( + "aws_access_key_id", + "aws_secret_access_key", + "aws_session_token", + "cloudflare_api_token", + mode="before", + ) + @classmethod + def normalize_secret(cls, value: object) -> object: + if value is None: + return None + normalized = str(value).strip() + return normalized or None + + @model_validator(mode="after") + def validate_provider_credentials(self) -> AccountCreate: + if self.provider == "aws": + if self.cloudflare_api_token: + raise ValueError("AWS 账号不能包含 Cloudflare API Token") + if self.use_default_aws_credentials: + if self.aws_access_key_id or self.aws_secret_access_key or self.aws_session_token: + raise ValueError("使用 AWS 默认凭据链时不能同时填写 API 密钥") + elif not (self.aws_access_key_id and self.aws_secret_access_key): + raise ValueError("AWS Access Key ID 和 Secret Access Key 必须同时填写") + else: + if self.use_default_aws_credentials or any( + (self.aws_access_key_id, self.aws_secret_access_key, self.aws_session_token) + ): + raise ValueError("Cloudflare 账号不能包含 AWS 凭据") + if not self.cloudflare_api_token: + raise ValueError("Cloudflare API Token 不能为空") + return self + + +class AccountUpdate(BaseModel): + config_version: int = Field(ge=1) + name: str | None = Field(default=None, min_length=1, max_length=80) + use_default_aws_credentials: bool | None = None + aws_access_key_id: str | None = Field(default=None, max_length=128) + aws_secret_access_key: str | None = Field(default=None, max_length=256) + aws_session_token: str | None = Field(default=None, max_length=4096) + cloudflare_api_token: str | None = Field(default=None, max_length=2048) + + @field_validator("name") + @classmethod + def normalize_name(cls, value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip() + if not normalized: + raise ValueError("账号名称不能为空") + return normalized + + @field_validator( + "aws_access_key_id", + "aws_secret_access_key", + "aws_session_token", + "cloudflare_api_token", + mode="before", + ) + @classmethod + def normalize_secret(cls, value: object) -> object: + if value is None: + return None + normalized = str(value).strip() + return normalized or None + + @model_validator(mode="after") + def validate_update(self) -> AccountUpdate: + fields = self.model_fields_set + access_fields = {"aws_access_key_id", "aws_secret_access_key"} + supplied_access_fields = fields & access_fields + if supplied_access_fields and supplied_access_fields != access_fields: + raise ValueError("AWS Access Key ID 和 Secret Access Key 必须同时填写") + if bool(self.aws_access_key_id) != bool(self.aws_secret_access_key): + raise ValueError("AWS Access Key ID 和 Secret Access Key 必须同时填写") + if not fields - {"config_version"}: + raise ValueError("请至少修改一项账号配置") + return self + + +class AccountSecretFlags(BaseModel): + aws_access_key_id: bool = False + aws_secret_access_key: bool = False + aws_session_token: bool = False + cloudflare_api_token: bool = False + + +class AccountView(BaseModel): + id: str + provider: AccountProvider + name: str + use_default_aws_credentials: bool | None + config_version: int + secrets_configured: AccountSecretFlags + created_at: str + updated_at: str + archived_at: str | None diff --git a/app/accounts/service.py b/app/accounts/service.py new file mode 100644 index 0000000..906fb9f --- /dev/null +++ b/app/accounts/service.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any + +from app.accounts.repository import ( + AWS_SECRET_NAMES, + AccountProvider, + AccountRepository, + CredentialAccountRecord, +) +from app.accounts.schemas import ( + AccountCreate, + AccountSecretFlags, + AccountUpdate, + AccountView, +) +from app.core.errors import ConflictError, NotFoundError, ValidationAppError +from app.integrations.repository import IntegrationRepository + + +class AccountService: + def __init__(self, repository: AccountRepository) -> None: + self.repository = repository + + def list_accounts(self, provider: AccountProvider | None = None) -> list[dict[str, Any]]: + accounts = self.repository.list_accounts(provider) + flags = self.repository.secret_flags_many(account.id for account in accounts) + return [self._view(account, flags[account.id]).model_dump() for account in accounts] + + def get_account(self, account_id: str) -> dict[str, Any]: + account = self._require_account(account_id) + return self._view(account, self.repository.secret_flags(account.id)).model_dump() + + def create_account(self, payload: AccountCreate) -> dict[str, Any]: + if payload.provider == "aws": + use_default: bool | None = payload.use_default_aws_credentials + secrets = self._provided_secrets(payload, AWS_SECRET_NAMES) + else: + use_default = None + secrets = self._provided_secrets(payload, ("cloudflare_api_token",)) + try: + account = self.repository.create_account( + provider=payload.provider, + name=payload.name, + use_default_aws_credentials=use_default, + secret_values=secrets, + ) + except RuntimeError as exc: + raise self._repository_error(exc) from exc + return self.get_account(account.id) + + def import_legacy_accounts( + self, + integration_repository: IntegrationRepository, + ) -> dict[AccountProvider, str]: + settings = integration_repository.get() + legacy_secrets = integration_repository.get_secrets() + access_key = legacy_secrets.get("aws_access_key_id") + secret_key = legacy_secrets.get("aws_secret_access_key") + aws_configured = settings.use_default_aws_credentials or bool(access_key and secret_key) + aws_secrets: dict[str, str] = {} + if access_key and secret_key: + aws_secrets = { + "aws_access_key_id": access_key, + "aws_secret_access_key": secret_key, + } + if session_token := legacy_secrets.get("aws_session_token"): + aws_secrets["aws_session_token"] = session_token + return self.repository.import_legacy_accounts( + aws_use_default=(settings.use_default_aws_credentials if aws_configured else None), + aws_secret_values=aws_secrets, + cloudflare_api_token=legacy_secrets.get("cloudflare_api_token"), + ) + + def update_account(self, account_id: str, payload: AccountUpdate) -> dict[str, Any]: + account = self._require_account(account_id) + values: dict[str, object] = {} + if "name" in payload.model_fields_set: + if payload.name is None: + raise ValidationAppError("账号名称不能为空") + values["name"] = payload.name + secret_values: dict[str, str] = {} + delete_names: set[str] = set() + if account.provider == "aws": + self._reject_cloudflare_fields(payload) + self._prepare_aws_update( + account, + payload, + values, + secret_values, + delete_names, + ) + else: + self._reject_aws_fields(payload) + if "cloudflare_api_token" in payload.model_fields_set and payload.cloudflare_api_token: + secret_values["cloudflare_api_token"] = payload.cloudflare_api_token + try: + updated = self.repository.update_account( + account_id, + expected_version=payload.config_version, + values=values, + secret_values=secret_values, + delete_secret_names=delete_names, + ) + except RuntimeError as exc: + raise self._repository_error(exc) from exc + return self.get_account(updated.id) + + def archive_account(self, account_id: str) -> dict[str, Any]: + try: + account = self.repository.archive_account(account_id) + except RuntimeError as exc: + raise self._repository_error(exc) from exc + return self._view(account, self.repository.secret_flags(account.id)).model_dump() + + def resolve_aws(self, account_id: str) -> tuple[bool, dict[str, str | None]]: + try: + return self.repository.resolve_aws(account_id) + except RuntimeError as exc: + raise self._repository_error(exc) from exc + + def resolve_cloudflare(self, account_id: str) -> str: + try: + return self.repository.resolve_cloudflare(account_id) + except RuntimeError as exc: + raise self._repository_error(exc) from exc + + def _prepare_aws_update( + self, + account: CredentialAccountRecord, + payload: AccountUpdate, + values: dict[str, object], + secret_values: dict[str, str], + delete_names: set[str], + ) -> None: + fields = payload.model_fields_set + if "use_default_aws_credentials" in fields: + if payload.use_default_aws_credentials is None: + raise ValidationAppError("请选择 AWS 凭据模式") + values["use_default_aws_credentials"] = payload.use_default_aws_credentials + final_use_default = bool( + values.get( + "use_default_aws_credentials", + account.use_default_aws_credentials, + ) + ) + pair_supplied = bool(payload.aws_access_key_id and payload.aws_secret_access_key) + if pair_supplied: + secret_values["aws_access_key_id"] = str(payload.aws_access_key_id) + secret_values["aws_secret_access_key"] = str(payload.aws_secret_access_key) + if payload.aws_session_token: + secret_values["aws_session_token"] = payload.aws_session_token + elif pair_supplied: + delete_names.add("aws_session_token") + if final_use_default: + if pair_supplied or payload.aws_session_token: + raise ValidationAppError("使用 AWS 默认凭据链时不能同时填写 API 密钥") + delete_names.update(AWS_SECRET_NAMES) + return + flags = self.repository.secret_flags(account.id) + has_pair = pair_supplied or (flags["aws_access_key_id"] and flags["aws_secret_access_key"]) + if not has_pair: + raise ValidationAppError("请填写 AWS API 密钥,或选择默认凭据链") + + @staticmethod + def _reject_cloudflare_fields(payload: AccountUpdate) -> None: + if "cloudflare_api_token" in payload.model_fields_set: + raise ValidationAppError("AWS 账号不能包含 Cloudflare API Token") + + @staticmethod + def _reject_aws_fields(payload: AccountUpdate) -> None: + aws_fields = { + "use_default_aws_credentials", + "aws_access_key_id", + "aws_secret_access_key", + "aws_session_token", + } + if payload.model_fields_set & aws_fields: + raise ValidationAppError("Cloudflare 账号不能包含 AWS 凭据") + + @staticmethod + def _provided_secrets(payload: object, names: Iterable[str]) -> dict[str, str]: + return { + name: str(value) + for name in names + if (value := getattr(payload, name, None)) is not None + } + + def _require_account(self, account_id: str) -> CredentialAccountRecord: + account = self.repository.get_account(account_id) + if account is None: + raise NotFoundError("账号不存在", code="ACCOUNT_NOT_FOUND") + return account + + @staticmethod + def _view( + account: CredentialAccountRecord, + flags: dict[str, bool], + ) -> AccountView: + return AccountView( + **account.to_dict(), + secrets_configured=AccountSecretFlags(**flags), + ) + + @staticmethod + def _repository_error(exc: RuntimeError) -> Exception: + code = str(exc) + if code == "ACCOUNT_NOT_FOUND": + return NotFoundError("账号不存在", code=code) + conflicts = { + "FLEET_RUN_ACTIVE": "轮换或 DNS 同步任务进行中,暂时不能修改账号", + "ACCOUNT_NAME_CONFLICT": "同类型的账号名称已存在", + "ACCOUNT_CONFIG_VERSION_CONFLICT": "账号配置已被其他操作更新,请刷新后重试", + "ACCOUNT_IN_USE": "账号仍被活动实例使用,不能删除", + } + if code in conflicts: + return ConflictError(conflicts[code], code=code) + validations = { + "ACCOUNT_PROVIDER_MISMATCH": "账号类型与所需凭据类型不匹配", + "ACCOUNT_CREDENTIALS_INCOMPLETE": "账号凭据不完整,请先更新账号", + "INVALID_ACCOUNT_DATA": "账号配置不合法", + } + if code in validations: + return ValidationAppError(validations[code], code=code) + return ValidationAppError("账号保存失败", code=code) diff --git a/app/container.py b/app/container.py index 11c9848..ef01723 100644 --- a/app/container.py +++ b/app/container.py @@ -2,6 +2,8 @@ from __future__ import annotations from pathlib import Path +from app.accounts.repository import AccountRepository +from app.accounts.service import AccountService from app.auth.repository import AuthRepository from app.auth.service import AuthService from app.core.config import AppSettings @@ -29,6 +31,7 @@ class AppContainer: self.database.migrate() self.auth_repository = AuthRepository(self.database) + self.account_repository = AccountRepository(self.database, self.cipher) self.integration_repository = IntegrationRepository(self.database, self.cipher) self.fleet_repository = FleetRepository(self.database) self.rotation_repository = RotationRepository(self.database) @@ -41,15 +44,19 @@ class AppContainer: settings.session_days, bootstrap_token, ) + self.account_service = AccountService(self.account_repository) self.integration_service = IntegrationService(self.integration_repository) + self.account_service.import_legacy_accounts(self.integration_repository) self.rotation_service = RotationService( self.rotation_repository, self.integration_service, + self.account_service, ) self.fleet_service = FleetService( self.fleet_repository, self.integration_service, self.rotation_repository, + self.account_service, ) self.scheduler_loop = SchedulerLoop( self.rotation_repository, diff --git a/app/dependencies.py b/app/dependencies.py index 82745d2..82d8934 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -2,6 +2,7 @@ from __future__ import annotations from fastapi import Depends, Request +from app.accounts.service import AccountService from app.auth.repository import SessionRecord from app.auth.service import AuthService from app.core.config import AppSettings @@ -19,6 +20,10 @@ def get_auth_service(request: Request) -> AuthService: return request.app.state.container.auth_service +def get_account_service(request: Request) -> AccountService: + return request.app.state.container.account_service + + def get_integration_service(request: Request) -> IntegrationService: return request.app.state.container.integration_service diff --git a/app/fleet/regions.py b/app/fleet/regions.py new file mode 100644 index 0000000..5d7040f --- /dev/null +++ b/app/fleet/regions.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from typing import Final + +AWS_LIGHTSAIL_REGIONS: Final[tuple[dict[str, str], ...]] = ( + {"code": "ap-east-1", "name_zh": "亚太地区(香港)", "icon": "🇭🇰"}, + {"code": "ap-northeast-1", "name_zh": "亚太地区(东京)", "icon": "🇯🇵"}, + {"code": "ap-northeast-2", "name_zh": "亚太地区(首尔)", "icon": "🇰🇷"}, + {"code": "ap-south-1", "name_zh": "亚太地区(孟买)", "icon": "🇮🇳"}, + {"code": "ap-southeast-1", "name_zh": "亚太地区(新加坡)", "icon": "🇸🇬"}, + {"code": "ap-southeast-2", "name_zh": "亚太地区(悉尼)", "icon": "🇦🇺"}, + {"code": "ap-southeast-3", "name_zh": "亚太地区(雅加达)", "icon": "🇮🇩"}, + {"code": "ap-southeast-5", "name_zh": "亚太地区(马来西亚)", "icon": "🇲🇾"}, + {"code": "ca-central-1", "name_zh": "加拿大(中部)", "icon": "🇨🇦"}, + {"code": "eu-central-1", "name_zh": "欧洲(法兰克福)", "icon": "🇩🇪"}, + {"code": "eu-north-1", "name_zh": "欧洲(斯德哥尔摩)", "icon": "🇸🇪"}, + {"code": "eu-south-2", "name_zh": "欧洲(西班牙)", "icon": "🇪🇸"}, + {"code": "eu-west-1", "name_zh": "欧洲(爱尔兰)", "icon": "🇮🇪"}, + {"code": "eu-west-2", "name_zh": "欧洲(伦敦)", "icon": "🇬🇧"}, + {"code": "eu-west-3", "name_zh": "欧洲(巴黎)", "icon": "🇫🇷"}, + {"code": "sa-east-1", "name_zh": "南美洲(圣保罗)", "icon": "🇧🇷"}, + {"code": "us-east-1", "name_zh": "美国东部(弗吉尼亚北部)", "icon": "🇺🇸"}, + {"code": "us-east-2", "name_zh": "美国东部(俄亥俄)", "icon": "🇺🇸"}, + {"code": "us-west-2", "name_zh": "美国西部(俄勒冈)", "icon": "🇺🇸"}, +) + +AWS_LIGHTSAIL_REGION_CODES: Final[frozenset[str]] = frozenset( + region["code"] for region in AWS_LIGHTSAIL_REGIONS +) + + +def list_lightsail_regions() -> list[dict[str, str]]: + return [dict(region) for region in AWS_LIGHTSAIL_REGIONS] diff --git a/app/fleet/repository.py b/app/fleet/repository.py index d674c4a..dec1f48 100644 --- a/app/fleet/repository.py +++ b/app/fleet/repository.py @@ -14,6 +14,8 @@ from app.database.database import Database @dataclass(slots=True) class ManagedInstanceRecord: id: str + aws_account_id: str | None + cloudflare_account_id: str | None display_name: str aws_region: str lightsail_instance_name: str @@ -87,6 +89,8 @@ class FleetRunItemRecord: attempt_count: int config_version: int instance_display_name: str + aws_account_id: str | None + cloudflare_account_id: str | None aws_region: str lightsail_instance_name: str cloudflare_zone_name: str @@ -115,7 +119,8 @@ class FleetRunItemRecord: INSTANCE_COLUMNS = """ -id, display_name, aws_region, lightsail_instance_name, cloudflare_zone_name, +id, aws_account_id, cloudflare_account_id, display_name, aws_region, +lightsail_instance_name, cloudflare_zone_name, cloudflare_zone_id, cloudflare_record_name, socks_port, proxy_health_check, health_timeout_seconds, release_grace_seconds, enabled, config_version, last_known_ip, last_checked_at, created_at, updated_at, archived_at @@ -135,7 +140,8 @@ total_items, succeeded_items, started_at, updated_at, finished_at FLEET_RUN_ITEM_COLUMNS = """ id, run_id, instance_id, position, status, stage, stage_started_at, attempt_count, config_version, instance_display_name, aws_region, lightsail_instance_name, -cloudflare_zone_name, cloudflare_zone_id, cloudflare_record_name, socks_port, +aws_account_id, cloudflare_account_id, cloudflare_zone_name, cloudflare_zone_id, +cloudflare_record_name, socks_port, proxy_health_check, health_timeout_seconds, release_grace_seconds, old_static_ip_name, old_ip, new_static_ip_name, new_ip, dns_ip_before, dns_ip_after, grace_until, aws_operation_id, error_code, error_message, @@ -144,6 +150,8 @@ old_static_ip_name, old_ip, new_static_ip_name, new_ip, dns_ip_before, """ INSTANCE_WRITABLE_FIELDS = ( + "aws_account_id", + "cloudflare_account_id", "display_name", "aws_region", "lightsail_instance_name", @@ -199,6 +207,11 @@ class FleetRepository: self._ensure_writes_allowed(connection) if group_id is not None and self._get_group_row(connection, group_id) is None: raise RuntimeError("GROUP_NOT_FOUND") + self._validate_account_bindings( + connection, + normalized["aws_account_id"], + normalized["cloudflare_account_id"], + ) connection.execute( f""" INSERT INTO managed_instances( @@ -263,6 +276,11 @@ class FleetRepository: connection.commit() return self._instance_from_row(current) merged = {**dict(current), **updates} + self._validate_account_bindings( + connection, + merged["aws_account_id"], + merged["cloudflare_account_id"], + ) self._validate_dns_scope( str(merged["cloudflare_zone_name"]), str(merged["cloudflare_record_name"]), @@ -745,6 +763,8 @@ class FleetRepository: @staticmethod def _instance_values(values: Mapping[str, Any]) -> dict[str, Any]: defaults: dict[str, Any] = { + "aws_account_id": None, + "cloudflare_account_id": None, "cloudflare_zone_id": "", "socks_port": 1080, "proxy_health_check": True, @@ -778,6 +798,30 @@ class FleetRepository: if record != zone and not record.endswith(f".{zone}"): raise RuntimeError("DNS_RECORD_OUTSIDE_ZONE") + @staticmethod + def _validate_account_bindings( + connection: sqlite3.Connection, + aws_account_id: object, + cloudflare_account_id: object, + ) -> None: + for account_id, provider in ( + (aws_account_id, "aws"), + (cloudflare_account_id, "cloudflare"), + ): + if account_id is None: + continue + row = connection.execute( + """ + SELECT provider FROM credential_accounts + WHERE id = ? AND archived_at IS NULL + """, + (str(account_id),), + ).fetchone() + if row is None: + raise RuntimeError(f"{provider.upper()}_ACCOUNT_NOT_FOUND") + if str(row["provider"]) != provider: + raise RuntimeError(f"{provider.upper()}_ACCOUNT_PROVIDER_MISMATCH") + @staticmethod def _normalize_member_ids(member_ids: object) -> list[str]: if isinstance(member_ids, (str, bytes)) or not isinstance(member_ids, Sequence): @@ -997,7 +1041,7 @@ class FleetRepository: if ( "managed_instances.aws_region" in message and "managed_instances.lightsail_instance_name" in message - ): + ) or "uq_managed_instances_aws_target_active" in message: return "INSTANCE_AWS_TARGET_CONFLICT" if "uq_managed_instances_dns_record_active" in message: return "INSTANCE_DNS_RECORD_CONFLICT" diff --git a/app/fleet/router.py b/app/fleet/router.py index d2f7f41..64a02e8 100644 --- a/app/fleet/router.py +++ b/app/fleet/router.py @@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, Response, status from app.auth.repository import SessionRecord from app.dependencies import get_fleet_service, require_session +from app.fleet.regions import list_lightsail_regions from app.fleet.schemas import ( InstanceCreate, InstanceGroupCreate, @@ -15,6 +16,13 @@ from app.fleet.service import FleetService router = APIRouter(prefix="/api/v1", tags=["fleet"]) +@router.get("/regions") +def list_regions( + _: SessionRecord = Depends(require_session), +) -> dict: + return {"data": list_lightsail_regions()} + + @router.get("/instances") def list_instances( _: SessionRecord = Depends(require_session), diff --git a/app/fleet/schemas.py b/app/fleet/schemas.py index 82de94c..d4e374a 100644 --- a/app/fleet/schemas.py +++ b/app/fleet/schemas.py @@ -5,11 +5,12 @@ import re from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from app.fleet.regions import AWS_LIGHTSAIL_REGION_CODES + HOSTNAME_RE = re.compile( r"^(?=.{1,253}\.?$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+" r"[a-zA-Z]{2,63}\.?$" ) -AWS_REGION_RE = re.compile(r"^[a-z]{2}(?:-gov)?-[a-z0-9-]+-\d+$") def _normalize_hostname(value: str) -> str: @@ -20,6 +21,8 @@ def _normalize_hostname(value: str) -> str: class ManagedInstanceBase(BaseModel): + aws_account_id: str | None = Field(default=None, min_length=1, max_length=128) + cloudflare_account_id: str | None = Field(default=None, min_length=1, max_length=128) display_name: str = Field(min_length=1, max_length=80) aws_region: str = Field(min_length=3, max_length=32) lightsail_instance_name: str = Field(min_length=1, max_length=255) @@ -43,12 +46,19 @@ class ManagedInstanceBase(BaseModel): def strip_text(cls, value: object) -> object: return value.strip() if isinstance(value, str) else value + @field_validator("aws_account_id", "cloudflare_account_id", mode="before") + @classmethod + def normalize_account_id(cls, value: object) -> object: + if isinstance(value, str): + return value.strip() or None + return value + @field_validator("aws_region") @classmethod def validate_region(cls, value: str) -> str: normalized = value.lower() - if not AWS_REGION_RE.fullmatch(normalized): - raise ValueError("请输入有效的 AWS 区域") + if normalized not in AWS_LIGHTSAIL_REGION_CODES: + raise ValueError("请选择受支持的 Lightsail 区域") return normalized @field_validator("cloudflare_zone_name", "cloudflare_record_name") @@ -81,6 +91,8 @@ class ManagedInstanceCreate(ManagedInstanceBase): class ManagedInstanceUpdate(BaseModel): config_version: int = Field(ge=1) group_id: str | None = Field(default=None, min_length=1, max_length=128) + aws_account_id: str | None = Field(default=None, min_length=1, max_length=128) + cloudflare_account_id: str | None = Field(default=None, min_length=1, max_length=128) display_name: str | None = Field(default=None, min_length=1, max_length=80) aws_region: str | None = Field(default=None, min_length=3, max_length=32) lightsail_instance_name: str | None = Field(default=None, min_length=1, max_length=255) @@ -111,14 +123,21 @@ class ManagedInstanceUpdate(BaseModel): def strip_text(cls, value: object) -> object: return value.strip() if isinstance(value, str) else value + @field_validator("aws_account_id", "cloudflare_account_id", mode="before") + @classmethod + def normalize_account_id(cls, value: object) -> object: + if isinstance(value, str): + return value.strip() or None + return value + @field_validator("aws_region") @classmethod def validate_region(cls, value: str | None) -> str | None: if value is None: return None normalized = value.lower() - if not AWS_REGION_RE.fullmatch(normalized): - raise ValueError("请输入有效的 AWS 区域") + if normalized not in AWS_LIGHTSAIL_REGION_CODES: + raise ValueError("请选择受支持的 Lightsail 区域") return normalized @field_validator("cloudflare_zone_name", "cloudflare_record_name") @@ -289,6 +308,8 @@ class FleetRunItemView(BaseModel): attempt_count: int config_version: int instance_display_name: str + aws_account_id: str | None + cloudflare_account_id: str | None aws_region: str lightsail_instance_name: str cloudflare_zone_name: str diff --git a/app/fleet/service.py b/app/fleet/service.py index e53306c..36444f6 100644 --- a/app/fleet/service.py +++ b/app/fleet/service.py @@ -4,6 +4,7 @@ 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.repository import FleetRepository, InstanceGroupRecord, ManagedInstanceRecord @@ -25,10 +26,12 @@ class FleetService: 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() @@ -205,17 +208,40 @@ class FleetService: self, instance: ManagedInstanceRecord, ) -> tuple[LightsailClient, CloudflareClient]: - self.integration_service.ensure_configured() - settings, secrets = self.integration_service.credentials() - token = secrets.get("cloudflare_api_token") + 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=settings.use_default_aws_credentials, - secrets=secrets, + use_default=use_default, + secrets=aws_secrets, ), CloudflareClient( token=token, @@ -290,6 +316,10 @@ class FleetService: "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 账号", } if code in validations: return ValidationAppError(validations[code], code=code) diff --git a/app/main.py b/app/main.py index 1f46710..dc26f1f 100644 --- a/app/main.py +++ b/app/main.py @@ -15,6 +15,7 @@ from starlette.middleware.trustedhost import TrustedHostMiddleware from starlette.responses import Response from app import __version__ +from app.accounts.router import router as accounts_router from app.auth.router import router as auth_router from app.container import AppContainer from app.core.config import AppSettings, get_settings @@ -120,6 +121,7 @@ def create_app(settings: AppSettings | None = None) -> FastAPI: ) application.include_router(auth_router) + application.include_router(accounts_router) application.include_router(integrations_router) application.include_router(fleet_router) application.include_router(rotation_router) diff --git a/app/rotation/repository.py b/app/rotation/repository.py index 46bdafc..1caff4d 100644 --- a/app/rotation/repository.py +++ b/app/rotation/repository.py @@ -21,7 +21,8 @@ total_items, succeeded_items, started_at, updated_at, finished_at ITEM_COLUMNS = """ id, run_id, instance_id, position, status, stage, stage_started_at, attempt_count, config_version, instance_display_name, aws_region, -lightsail_instance_name, cloudflare_zone_name, cloudflare_zone_id, +lightsail_instance_name, aws_account_id, cloudflare_account_id, +cloudflare_zone_name, cloudflare_zone_id, cloudflare_record_name, socks_port, proxy_health_check, health_timeout_seconds, release_grace_seconds, old_static_ip_name, old_ip, new_static_ip_name, new_ip, dns_ip_before, dns_ip_after, grace_until, @@ -70,6 +71,8 @@ class FleetRunItem: attempt_count: int config_version: int instance_display_name: str + aws_account_id: str | None + cloudflare_account_id: str | None aws_region: str lightsail_instance_name: str cloudflare_zone_name: str @@ -120,6 +123,43 @@ class RotationRepository: ) -> FleetRun: return self._create_run("group", group_id, trigger) + def target_account_bindings( + self, + target_type: str, + target_id: str, + ) -> list[tuple[str | None, str | None]]: + with self.database.connect() as connection: + if target_type == "instance": + rows = connection.execute( + """ + SELECT aws_account_id, cloudflare_account_id + FROM managed_instances + WHERE id = ? AND archived_at IS NULL AND enabled = 1 + """, + (target_id,), + ).fetchall() + else: + rows = connection.execute( + """ + SELECT instance.aws_account_id, instance.cloudflare_account_id + FROM instance_group_members AS member + JOIN managed_instances AS instance ON instance.id = member.instance_id + WHERE member.group_id = ? AND instance.archived_at IS NULL + AND instance.enabled = 1 + ORDER BY member.position, instance.created_at + """, + (target_id,), + ).fetchall() + return [ + ( + str(row["aws_account_id"]) if row["aws_account_id"] else None, + str(row["cloudflare_account_id"]) + if row["cloudflare_account_id"] + else None, + ) + for row in rows + ] + def _create_run(self, target_type: str, target_id: str, trigger: str) -> FleetRun: now = to_iso() run_id = str(uuid.uuid4()) @@ -210,13 +250,14 @@ class RotationRepository: INSERT INTO fleet_run_items( id, run_id, instance_id, position, status, stage, stage_started_at, config_version, instance_display_name, - aws_region, lightsail_instance_name, cloudflare_zone_name, + aws_region, lightsail_instance_name, aws_account_id, + cloudflare_account_id, cloudflare_zone_name, cloudflare_zone_id, cloudflare_record_name, socks_port, proxy_health_check, health_timeout_seconds, release_grace_seconds, started_at, updated_at ) VALUES ( ?, ?, ?, ?, 'queued', 'preflight', ?, ?, ?, ?, ?, ?, ?, ?, - ?, ?, ?, ?, ?, ? + ?, ?, ?, ?, ?, ?, ?, ? ) """, ( @@ -229,6 +270,8 @@ class RotationRepository: instance["display_name"], instance["aws_region"], instance["lightsail_instance_name"], + instance["aws_account_id"], + instance["cloudflare_account_id"], instance["cloudflare_zone_name"], instance["cloudflare_zone_id"], instance["cloudflare_record_name"], diff --git a/app/rotation/service.py b/app/rotation/service.py index c5d7ef5..38cefef 100644 --- a/app/rotation/service.py +++ b/app/rotation/service.py @@ -7,6 +7,7 @@ from contextlib import suppress from datetime import timedelta from typing import Literal +from app.accounts.service import AccountService from app.core.errors import AppError, ConflictError, NotFoundError, ValidationAppError from app.core.time import from_iso, to_iso, utc_now from app.integrations.aws_client import LightsailClient, StaticIpSnapshot @@ -28,11 +29,13 @@ class RotationService: self, repository: RotationRepository, integration_service: IntegrationService, + account_service: AccountService | None = None, *, poll_interval_seconds: float = 3, ) -> None: self.repository = repository self.integration_service = integration_service + self.account_service = account_service self.poll_interval_seconds = poll_interval_seconds self.worker_id = str(uuid.uuid4()) self._tasks: dict[str, asyncio.Task[bool]] = {} @@ -49,7 +52,13 @@ class RotationService: return self._start("group", group_id, trigger) def _start(self, target_type: str, target_id: str, trigger: str) -> FleetRun: - self.integration_service.ensure_configured() + if self.account_service is None: + self.integration_service.ensure_configured() + else: + for aws_account_id, cloudflare_account_id in ( + self.repository.target_account_bindings(target_type, target_id) + ): + self._resolve_credentials(aws_account_id, cloudflare_account_id) try: run = ( self.repository.create_for_instance(target_id, trigger) # type: ignore[arg-type] @@ -189,19 +198,16 @@ class RotationService: expected_owner=self.worker_id, ) self._require_lease(run_id) - settings, secrets = self.integration_service.credentials() + use_default, secrets, token = self._resolve_credentials( + item.aws_account_id, + item.cloudflare_account_id, + ) aws = LightsailClient.from_credentials( region=item.aws_region, instance_name=item.lightsail_instance_name, - use_default=settings.use_default_aws_credentials, + use_default=use_default, secrets=secrets, ) - token = secrets.get("cloudflare_api_token") - if not token: - raise ValidationAppError( - "当前没有可用的 Cloudflare API Token", - code="CLOUDFLARE_TOKEN_MISSING", - ) cloudflare = CloudflareClient( token=token, zone_name=item.cloudflare_zone_name, @@ -235,6 +241,43 @@ class RotationService: await asyncio.gather(heartbeat, return_exceptions=True) self._lease_lost_runs.discard(run_id) + def _resolve_credentials( + self, + aws_account_id: str | None, + cloudflare_account_id: str | None, + ) -> tuple[bool, dict[str, str | None], str]: + legacy_settings = None + legacy_secrets: dict[str, str | None] = {} + if not aws_account_id or not cloudflare_account_id: + legacy_settings, legacy_secrets = self.integration_service.credentials() + + if aws_account_id and self.account_service is not None: + use_default, aws_secrets = self.account_service.resolve_aws(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 凭据", + code="AWS_CREDENTIALS_MISSING", + ) + + if cloudflare_account_id and self.account_service is not None: + token = self.account_service.resolve_cloudflare(cloudflare_account_id) + else: + token = legacy_secrets.get("cloudflare_api_token") + if not token: + raise ValidationAppError( + "当前没有可用的 Cloudflare API Token", + code="CLOUDFLARE_TOKEN_MISSING", + ) + return use_default, aws_secrets, token + async def _dispatch_stage( self, run: FleetRun, diff --git a/app/static/app.js b/app/static/app.js index 3e1f950..c1dab30 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -8,12 +8,13 @@ const state = { username: "admin", currentView: "overview", dashboard: null, - integrations: null, + accounts: { items: [], loaded: false }, + regions: { items: [], loaded: false }, instances: { items: [], page: 1, pages: 1, total: 0, query: "", groupId: "", status: "" }, groups: { items: [], query: "", status: "" }, history: { items: [], page: 1, pages: 1, total: 0, instanceId: "", groupId: "", status: "" }, catalog: { instances: [], groups: [], instancesLoaded: false, groupsLoaded: false }, - selected: { instanceId: null, groupId: null, runId: null }, + selected: { instanceId: null, groupId: null, accountId: null, accountProvider: null, runId: null }, groupMemberDraft: new Set(), pendingAction: null, pendingDelete: null, @@ -329,13 +330,21 @@ function clearAuthSecrets() { function resetPrivateState() { state.dashboard = null; - state.integrations = null; + state.accounts = { items: [], loaded: false }; + state.regions = { items: [], loaded: false }; state.instances = { items: [], page: 1, pages: 1, total: 0, query: "", groupId: "", status: "" }; state.groups = { items: [], query: "", status: "" }; state.history = { items: [], page: 1, pages: 1, total: 0, instanceId: "", groupId: "", status: "" }; state.catalog = { instances: [], groups: [], instancesLoaded: false, groupsLoaded: false }; - state.selected = { instanceId: null, groupId: null, runId: null }; + state.selected = { instanceId: null, groupId: null, accountId: null, accountProvider: null, runId: null }; state.groupMemberDraft = new Set(); + const credentialForm = $("#credential-form"); + credentialForm?.reset(); + $("#password-form")?.reset(); + if (credentialForm) $$('input[type="password"], input[name="aws_access_key_id"]', credentialForm).forEach((input) => { input.value = ""; input.placeholder = ""; }); + if ($("#aws-account-list")) $("#aws-account-list").innerHTML = ""; + if ($("#cloudflare-account-list")) $("#cloudflare-account-list").innerHTML = ""; + populateInstanceCredentialSelects(); resetCustomSelectValues(); state.pendingAction = null; state.pendingDelete = null; @@ -414,8 +423,8 @@ async function handleAuthSubmit(event) { clearAuthSecrets(); await showApp(username); if (wasSetup) { - await openSettings(); - toast("管理员已创建", "请先保存 AWS 与 Cloudflare 共享凭据"); + setView("accounts", true); + toast("管理员已创建", "请添加 AWS 与 Cloudflare 账号"); } } catch (error) { if (error instanceof ApiError) formErrors(error).forEach((item) => setAuthFieldError(item.field, item.message)); @@ -434,6 +443,7 @@ function setView(view, force = false) { if (view === "overview") loadOverview(true); if (view === "instances") loadInstances({ firstPage: true }); if (view === "groups") loadGroups(); + if (view === "accounts") loadAccounts(); if (view === "history") loadHistory(1); } @@ -441,6 +451,7 @@ function refreshCurrentView(forceExternal = true) { if (state.currentView === "overview") return loadOverview(forceExternal); if (state.currentView === "instances") return loadInstances({ silent: false }); if (state.currentView === "groups") return loadGroups({ silent: false }); + if (state.currentView === "accounts") return loadAccounts({ force: forceExternal }); if (state.currentView === "history") return loadHistory(state.history.page); return Promise.resolve(); } @@ -639,6 +650,27 @@ async function fetchGroupCatalog(force = false) { return state.catalog.groups; } +async function fetchAccountCatalog(force = false) { + if (state.accounts.loaded && !force) return state.accounts.items; + const response = await api("/accounts"); + state.accounts.items = itemsOf(response); + state.accounts.loaded = true; + return state.accounts.items; +} + +async function fetchRegionCatalog(force = false) { + if (state.regions.loaded && !force) return state.regions.items; + const response = await api("/regions"); + state.regions.items = itemsOf(response); + state.regions.loaded = true; + return state.regions.items; +} + +async function ensureInstanceOptionCatalogs(force = false) { + await Promise.all([fetchAccountCatalog(force), fetchRegionCatalog(force)]); + populateInstanceCredentialSelects(); +} + async function ensureCatalogs(force = false) { await Promise.all([fetchInstanceCatalog(force), fetchGroupCatalog(force)]); return populateResourceSelects(); @@ -690,8 +722,12 @@ function customSelectOptionMarkup(option, selectedValue) { muted: "is-muted", }[option.tone] || ""; const selected = value === selectedValue; - return ``; @@ -705,9 +741,12 @@ function syncCustomSelectSelection(target, value, { emitChange = false } = {}) { const selected = options.find((option) => option.dataset.value === requested) || options[0]; const resolved = selected?.dataset.value || ""; const label = selected?.dataset.label || ""; + const symbol = selected?.dataset.symbol || ""; elements.input.value = resolved; elements.label.textContent = label; elements.label.title = label; + const symbolTarget = $("[data-custom-select-symbol]", elements.root); + if (symbolTarget) symbolTarget.textContent = symbol; const accessibleLabel = elements.trigger.dataset.customSelectName || elements.trigger.getAttribute("aria-label"); if (accessibleLabel) { @@ -895,6 +934,60 @@ function populateInstanceGroupSelect(items, selectedValue = null) { return populateCustomSelect($("#instance-form").elements.group_id, options, selectedValue); } +function accountSelectOptions(provider) { + const isAws = provider === "aws"; + const emptyLabel = isAws ? "选择 AWS 账号" : "选择 Cloudflare 账号"; + return [ + { value: "", label: emptyLabel, detail: "需先在账号页创建", icon: isAws ? "key-round" : "shield-keyhole", tone: "muted" }, + ...state.accounts.items + .filter((account) => account.provider === provider) + .map((account) => ({ + value: account.id, + label: account.name, + detail: isAws + ? (account.use_default_aws_credentials ? "默认凭据链" : "Access Key") + : "API Token", + icon: isAws ? "key-round" : "shield-keyhole", + tone: "success", + })), + ]; +} + +function regionSelectOptions() { + return [ + { value: "", label: "选择区域", detail: "Lightsail 支持区域", symbol: "-", tone: "muted" }, + ...state.regions.items.map((region) => ({ + value: region.code, + label: region.name_zh, + detail: region.code, + symbol: region.icon || "·", + tone: "info", + })), + ]; +} + +function populateInstanceCredentialSelects(instance = null) { + const form = $("#instance-form"); + if (!form) return; + const awsValue = instance ? instance.aws_account_id || "" : form.elements.aws_account_id.value; + const regionValue = instance ? instance.aws_region || "" : form.elements.aws_region.value; + const cloudflareValue = instance ? instance.cloudflare_account_id || "" : form.elements.cloudflare_account_id.value; + populateCustomSelect(form.elements.aws_account_id, accountSelectOptions("aws"), awsValue); + populateCustomSelect(form.elements.aws_region, regionSelectOptions(), regionValue); + populateCustomSelect(form.elements.cloudflare_account_id, accountSelectOptions("cloudflare"), cloudflareValue); + updateInstanceAccountCallout(); +} + +function updateInstanceAccountCallout() { + const awsCount = state.accounts.items.filter((account) => account.provider === "aws").length; + const cloudflareCount = state.accounts.items.filter((account) => account.provider === "cloudflare").length; + const missing = []; + if (!awsCount) missing.push("AWS"); + if (!cloudflareCount) missing.push("Cloudflare"); + $("#instance-account-callout").classList.toggle("hidden", missing.length === 0); + $("#instance-account-callout-copy").textContent = missing.length ? `请先添加 ${missing.join(" 与 ")} 账号` : ""; +} + function initializeCustomSelects() { populateCustomSelect($("#instance-group-filter"), [ { value: "", label: "全部分组", icon: "boxes", tone: "muted" }, @@ -918,6 +1011,7 @@ function initializeCustomSelects() { ...HISTORY_STATUS_OPTIONS, ], state.history.status); populateInstanceGroupSelect([], ""); + populateInstanceCredentialSelects(); } function resetCustomSelectValues() { @@ -1068,7 +1162,7 @@ function getInstance(id) { async function openInstanceDialog(instanceId = null) { try { - await ensureCatalogs(false); + await Promise.all([ensureCatalogs(false), ensureInstanceOptionCatalogs(false)]); const instance = instanceId ? getInstance(instanceId) : null; if (instanceId && !instance) throw new Error("实例不在当前列表中"); state.selected.instanceId = instance ? String(instance.id) : null; @@ -1080,11 +1174,12 @@ async function openInstanceDialog(instanceId = null) { form.elements.socks_port.value = instance?.socks_port ?? 1080; form.elements.health_timeout_seconds.value = instance?.health_timeout_seconds ?? 120; form.elements.release_grace_seconds.value = instance?.release_grace_seconds ?? 75; - ["display_name", "aws_region", "lightsail_instance_name", "cloudflare_zone_name", "cloudflare_zone_id", "cloudflare_record_name"].forEach((name) => { + ["display_name", "lightsail_instance_name", "cloudflare_zone_name", "cloudflare_zone_id", "cloudflare_record_name"].forEach((name) => { form.elements[name].value = instance?.[name] ?? ""; }); populateInstanceGroupSelect(state.catalog.groups, instance?.group_id || ""); - setCustomSelectOpen(form.elements.group_id, false); + populateInstanceCredentialSelects(instance); + ["group_id", "aws_account_id", "aws_region", "cloudflare_account_id"].forEach((name) => setCustomSelectOpen(form.elements[name], false)); $("#instance-dialog-title").textContent = instance ? `编辑 ${instance.display_name}` : "新增实例"; $("#delete-instance").classList.toggle("hidden", !instance); $("#test-instance-dialog").classList.toggle("hidden", !instance); @@ -1103,8 +1198,10 @@ function instancePayload() { const payload = { display_name: value("display_name"), group_id: value("group_id") || null, + aws_account_id: value("aws_account_id"), aws_region: value("aws_region"), lightsail_instance_name: value("lightsail_instance_name"), + cloudflare_account_id: value("cloudflare_account_id"), cloudflare_zone_name: value("cloudflare_zone_name"), cloudflare_zone_id: value("cloudflare_zone_id"), cloudflare_record_name: value("cloudflare_record_name"), @@ -1118,17 +1215,49 @@ function instancePayload() { return payload; } +function validateRequiredInstanceSelects(form) { + const requiredSelects = [ + ["aws_account_id", "请选择 AWS 账号"], + ["aws_region", "请选择 AWS Region"], + ["cloudflare_account_id", "请选择 Cloudflare 账号"], + ]; + let firstInvalidTrigger = null; + requiredSelects.forEach(([name, message]) => { + const input = form.elements[name]; + const trigger = $(".custom-select-trigger", input.closest(".custom-select")); + const output = $(`[data-error-for="${CSS.escape(name)}"]`, form); + const invalid = !input.value; + input.toggleAttribute("aria-invalid", invalid); + trigger.toggleAttribute("aria-invalid", invalid); + if (output) output.textContent = invalid ? message : ""; + if (invalid && !firstInvalidTrigger) firstInvalidTrigger = trigger; + }); + firstInvalidTrigger?.focus(); + return !firstInvalidTrigger; +} + +function clearCustomSelectFieldError(input) { + if (!input.value) return; + input.removeAttribute("aria-invalid"); + const root = input.closest(".custom-select"); + $(".custom-select-trigger", root)?.removeAttribute("aria-invalid"); + const output = $(`[data-error-for="${CSS.escape(input.name)}"]`, input.form); + if (output) output.textContent = ""; +} + async function saveInstance(event) { event.preventDefault(); const form = event.currentTarget; - if (!form.reportValidity()) return; + clearFormErrors(form); + const nativeValid = form.reportValidity(); + const selectsValid = validateRequiredInstanceSelects(form); + if (!nativeValid || !selectsValid) return; const id = state.selected.instanceId; const key = `instance:${id || "new"}:save`; if (state.pending.has(key)) return; const button = $("#save-instance"); state.pending.add(key); setButtonLoading(button, true, "正在保存"); - clearFormErrors(form); try { await api(id ? `/instances/${encodeURIComponent(id)}` : "/instances", { method: id ? "PUT" : "POST", body: JSON.stringify(instancePayload()) }); $("#instance-dialog").close(); @@ -1411,14 +1540,21 @@ async function confirmRotation() { } function requestDelete(type, id) { - const target = type === "group" ? getGroup(id) : getInstance(id); + const target = type === "group" ? getGroup(id) : type === "account" ? getCredentialAccount(id) : getInstance(id); if (!target) return; state.pendingDelete = { type, id: String(id) }; const isGroup = type === "group"; - $("#delete-confirm-title").textContent = isGroup ? `删除分组「${target.name}」?` : `删除实例「${target.display_name}」?`; + const isAccount = type === "account"; + $("#delete-confirm-title").textContent = isGroup + ? `删除分组「${target.name}」?` + : isAccount + ? `删除账号「${target.name}」?` + : `删除实例「${target.display_name}」?`; $("#delete-confirm-copy").textContent = isGroup ? "该分组会被软删除,成员实例将变为未分组;正在使用的 Static IP 和域名不会改变。" - : "该实例配置会被软删除并移出轮换计划;当前附加的 Static IP 不会被主动释放。"; + : isAccount + ? "账号和加密密钥将从控制台移除。仍被实例使用的账号无法删除。" + : "该实例配置会被软删除并移出轮换计划;当前附加的 Static IP 不会被主动释放。"; $("#delete-confirm-dialog").showModal(); } @@ -1431,14 +1567,27 @@ async function confirmDelete() { state.pending.add(key); setButtonLoading(button, true, "正在删除"); try { - const path = action.type === "group" ? `/instance-groups/${encodeURIComponent(action.id)}` : `/instances/${encodeURIComponent(action.id)}`; + const path = action.type === "group" + ? `/instance-groups/${encodeURIComponent(action.id)}` + : action.type === "account" + ? `/accounts/${encodeURIComponent(action.id)}` + : `/instances/${encodeURIComponent(action.id)}`; await api(path, { method: "DELETE", body: "{}" }); $("#delete-confirm-dialog").close(); - $(action.type === "group" ? "#group-dialog" : "#instance-dialog").close(); + if (action.type === "group") $("#group-dialog").close(); + if (action.type === "instance") $("#instance-dialog").close(); + if (action.type === "account" && $("#credential-dialog").open) $("#credential-dialog").close(); state.pendingDelete = null; - invalidateResources(); - await Promise.all([loadInstances(), loadGroups(), loadOverview(true)]); - toast(action.type === "group" ? "实例组已删除" : "实例配置已删除"); + if (action.type === "account") { + state.accounts.loaded = false; + state.loaded.delete("accounts"); + await loadAccounts({ force: true }); + toast("账号已删除"); + } else { + invalidateResources(); + await Promise.all([loadInstances(), loadGroups(), loadOverview(true)]); + toast(action.type === "group" ? "实例组已删除" : "实例配置已删除"); + } } catch (error) { displayError(error, "删除失败"); } finally { @@ -1458,80 +1607,215 @@ function invalidateResources(clearLoaded = true) { } } -async function openSettings() { - const dialog = $("#settings-dialog"); - dialog.showModal(); - if (state.integrations) { - populateSettings(state.integrations); - return; +async function loadAccounts({ force = false, silent = false } = {}) { + if (state.loading.has("accounts")) return; + state.loading.add("accounts"); + const firstLoad = !state.loaded.has("accounts"); + if (firstLoad && !silent) { + $("#accounts-loading").classList.remove("hidden"); + $("#accounts-content").classList.add("hidden"); } - const button = $("#save-settings"); - button.disabled = true; try { - const response = await api("/integrations"); - state.integrations = dataOf(response) || {}; - populateSettings(state.integrations); + await fetchAccountCatalog(force); + renderAccounts(); + state.loaded.add("accounts"); } catch (error) { - dialog.close(); - displayError(error, "共享凭据加载失败"); + if (!silent) displayError(error, "账号列表加载失败"); } finally { - button.disabled = false; + state.loading.delete("accounts"); + $("#accounts-loading").classList.add("hidden"); + $("#accounts-content").classList.remove("hidden"); } } -function populateSettings(data) { - const form = $("#settings-form"); - const flags = data.secrets_configured || {}; - setAwsMode(data.use_default_aws_credentials ? "default" : "access"); - ["aws_access_key_id", "aws_secret_access_key", "aws_session_token", "cloudflare_api_token"].forEach((name) => { - const input = form.elements[name]; - input.value = ""; - input.placeholder = flags[name] ? "已安全保存,留空保持不变" : ""; +function accountProviderMeta(provider) { + return provider === "aws" + ? { label: "AWS", icon: "key-round", glyph: "service-glyph-aws" } + : { label: "Cloudflare", icon: "shield-keyhole", glyph: "service-glyph-cf" }; +} + +function accountSecretReady(account) { + const flags = account.secrets_configured || {}; + if (account.provider === "aws") { + return Boolean(account.use_default_aws_credentials || (flags.aws_access_key_id && flags.aws_secret_access_key)); + } + return Boolean(flags.cloudflare_api_token); +} + +function accountAuthLabel(account) { + if (account.provider === "cloudflare") return "API Token"; + return account.use_default_aws_credentials ? "默认凭据链" : "Access Key"; +} + +function accountCardMarkup(account) { + const meta = accountProviderMeta(account.provider); + const ready = accountSecretReady(account); + return `
+
+ ${icon(meta.icon)} + ${escapeHtml(account.name)}${escapeHtml(accountAuthLabel(account))} · 更新于 ${escapeHtml(formatDate(account.updated_at))} +
+ ${ready ? "密钥已配置" : "密钥待补充"} +
+ + +
+
`; +} + +function renderAccounts() { + ["aws", "cloudflare"].forEach((provider) => { + const items = state.accounts.items.filter((account) => account.provider === provider); + const list = $(`#${provider === "aws" ? "aws" : "cloudflare"}-account-list`); + const empty = $(`#${provider === "aws" ? "aws" : "cloudflare"}-accounts-empty`); + list.innerHTML = items.map(accountCardMarkup).join(""); + list.classList.toggle("hidden", items.length === 0); + empty.classList.toggle("hidden", items.length > 0); }); - const awsReady = Boolean(data.use_default_aws_credentials || flags.aws_access_key_id); - setBadge($("#aws-config-badge"), awsReady ? "已配置" : "未配置", awsReady ? "success" : "neutral"); - setBadge($("#cf-config-badge"), flags.cloudflare_api_token ? "已配置" : "未配置", flags.cloudflare_api_token ? "success" : "neutral"); - $("#settings-save-state").textContent = data.config_version ? `配置版本 ${data.config_version}` : ""; + populateInstanceCredentialSelects(); + refreshIcons(); } -function setAwsMode(mode) { +function getCredentialAccount(id) { + return state.accounts.items.find((account) => String(account.id) === String(id)); +} + +function setCredentialAwsMode(mode) { const useDefault = mode === "default"; - $("#settings-form").elements.use_default_aws_credentials.value = String(useDefault); - $$('[data-aws-mode]').forEach((button) => button.classList.toggle("active", button.dataset.awsMode === mode)); - $("#aws-secret-fields").classList.toggle("hidden", useDefault); + $("#credential-form").elements.use_default_aws_credentials.value = String(useDefault); + $$('[data-credential-aws-mode]').forEach((button) => button.classList.toggle("active", button.dataset.credentialAwsMode === mode)); + $("#credential-aws-secret-fields").classList.toggle("hidden", useDefault); } -function settingsPayload() { - const form = $("#settings-form"); +async function openCredentialDialog(provider, accountId = null) { + try { + await fetchAccountCatalog(false); + let account = accountId ? getCredentialAccount(accountId) : null; + if (accountId) { + const response = await api(`/accounts/${encodeURIComponent(accountId)}`); + account = dataOf(response) || account; + if (account) { + const index = state.accounts.items.findIndex((item) => String(item.id) === String(account.id)); + if (index >= 0) state.accounts.items[index] = account; + else state.accounts.items.push(account); + } + } + if (accountId && !account) throw new Error("账号不在当前列表中"); + const resolvedProvider = account?.provider || provider; + const meta = accountProviderMeta(resolvedProvider); + const form = $("#credential-form"); + state.selected.accountId = account ? String(account.id) : null; + state.selected.accountProvider = resolvedProvider; + form.reset(); + clearFormErrors(form); + form.elements.provider.value = resolvedProvider; + form.elements.name.value = account?.name || ""; + $("#credential-dialog-kicker").textContent = resolvedProvider === "aws" ? "AMAZON WEB SERVICES" : "CLOUDFLARE"; + $("#credential-dialog-title").textContent = account ? `编辑 ${account.name}` : `新增 ${meta.label} 账号`; + $("#credential-provider-glyph").className = `service-glyph ${meta.glyph}`; + $("#credential-provider-glyph").innerHTML = icon(meta.icon); + $("#credential-aws-fields").classList.toggle("hidden", resolvedProvider !== "aws"); + $("#credential-cf-fields").classList.toggle("hidden", resolvedProvider !== "cloudflare"); + $("#delete-credential").classList.toggle("hidden", !account); + $("#credential-save-state").textContent = account ? `配置版本 ${account.config_version || 1}` : ""; + setBadge($("#credential-config-badge"), account ? (accountSecretReady(account) ? "密钥已配置" : "待补充") : "新账号", account && accountSecretReady(account) ? "success" : "neutral"); + setCredentialAwsMode(account?.use_default_aws_credentials ? "default" : "access"); + const flags = account?.secrets_configured || {}; + ["aws_access_key_id", "aws_secret_access_key", "aws_session_token", "cloudflare_api_token"].forEach((name) => { + form.elements[name].value = ""; + form.elements[name].placeholder = flags[name] ? "已安全保存,留空保持不变" : ""; + }); + $("#credential-dialog").showModal(); + refreshIcons(); + window.setTimeout(() => form.elements.name.focus(), 0); + } catch (error) { + displayError(error, "无法打开账号配置"); + } +} + +function setCredentialFieldError(form, name, message) { + const input = form.elements[name]; + const output = $(`[data-error-for="${CSS.escape(name)}"]`, form); + input?.setAttribute("aria-invalid", "true"); + if (output) output.textContent = message; +} + +function validateCredentialForm(form) { + if (!form.reportValidity()) return false; + const provider = form.elements.provider.value; + const account = state.selected.accountId ? getCredentialAccount(state.selected.accountId) : null; + const flags = account?.secrets_configured || {}; + if (provider === "cloudflare") { + if (!form.elements.cloudflare_api_token.value.trim() && !flags.cloudflare_api_token) { + setCredentialFieldError(form, "cloudflare_api_token", "请输入 Cloudflare API Token"); + form.elements.cloudflare_api_token.focus(); + return false; + } + return true; + } + if (form.elements.use_default_aws_credentials.value === "true") return true; + const accessKey = form.elements.aws_access_key_id.value.trim(); + const secretKey = form.elements.aws_secret_access_key.value.trim(); + const hasStoredPair = Boolean(flags.aws_access_key_id && flags.aws_secret_access_key); + if (!accessKey && !secretKey && hasStoredPair) return true; + if (!accessKey || !secretKey) { + if (!accessKey) setCredentialFieldError(form, "aws_access_key_id", "请输入 Access Key ID"); + if (!secretKey) setCredentialFieldError(form, "aws_secret_access_key", "请输入 Secret Access Key"); + (accessKey ? form.elements.aws_secret_access_key : form.elements.aws_access_key_id).focus(); + return false; + } + return true; +} + +function credentialPayload() { + const form = $("#credential-form"); const value = (name) => form.elements[name].value.trim(); - return { - use_default_aws_credentials: form.elements.use_default_aws_credentials.value === "true", - aws_access_key_id: value("aws_access_key_id") || null, - aws_secret_access_key: value("aws_secret_access_key") || null, - aws_session_token: value("aws_session_token") || null, - cloudflare_api_token: value("cloudflare_api_token") || null, - }; + const provider = form.elements.provider.value; + const account = state.selected.accountId ? getCredentialAccount(state.selected.accountId) : null; + const payload = { name: value("name") }; + if (!account) payload.provider = provider; + if (provider === "aws") { + payload.use_default_aws_credentials = form.elements.use_default_aws_credentials.value === "true"; + const accessKey = value("aws_access_key_id"); + const secretKey = value("aws_secret_access_key"); + const sessionToken = value("aws_session_token"); + if (accessKey && secretKey) { + payload.aws_access_key_id = accessKey; + payload.aws_secret_access_key = secretKey; + } + if (sessionToken) payload.aws_session_token = sessionToken; + } else { + const apiToken = value("cloudflare_api_token"); + if (apiToken) payload.cloudflare_api_token = apiToken; + } + if (account?.config_version != null) payload.config_version = account.config_version; + return payload; } -async function saveSettings(event) { +async function saveCredential(event) { event.preventDefault(); const form = event.currentTarget; - const button = $("#save-settings"); - if (state.pending.has("settings:save")) return; - state.pending.add("settings:save"); - setButtonLoading(button, true, "正在保存"); clearFormErrors(form); + if (!validateCredentialForm(form)) return; + const id = state.selected.accountId; + const key = `account:${id || "new"}:save`; + if (state.pending.has(key)) return; + const payload = credentialPayload(); + const button = $("#save-credential"); + state.pending.add(key); + setButtonLoading(button, true, "正在保存"); try { - const response = await api("/integrations", { method: "PUT", body: JSON.stringify(settingsPayload()) }); - state.integrations = dataOf(response) || {}; - populateSettings(state.integrations); - $("#settings-save-state").textContent = "已安全保存"; - toast("共享凭据已保存", "所有实例将使用这组凭据"); + await api(id ? `/accounts/${encodeURIComponent(id)}` : "/accounts", { method: id ? "PUT" : "POST", body: JSON.stringify(payload) }); + $("#credential-dialog").close(); + state.accounts.loaded = false; + state.loaded.delete("accounts"); + await loadAccounts({ force: true }); + toast(id ? "账号已更新" : "账号已创建", payload.name); } catch (error) { mapFormErrors(form, error); - displayError(error, "共享凭据保存失败"); + displayError(error, "账号保存失败"); } finally { - state.pending.delete("settings:save"); + state.pending.delete(key); setButtonLoading(button, false); } } @@ -1779,6 +2063,13 @@ function handleResourceAction(event) { if (action === "rotate-group") requestRotation("group", button.dataset.groupId); } +function handleAccountAction(event) { + const button = event.target.closest("[data-action]"); + if (!button || button.disabled) return; + if (button.dataset.action === "edit-account") openCredentialDialog(null, button.dataset.accountId); + if (button.dataset.action === "delete-account") requestDelete("account", button.dataset.accountId); +} + function debounce(callback, delay = 260) { if (state.filterTimer) window.clearTimeout(state.filterTimer); state.filterTimer = window.setTimeout(callback, delay); @@ -1789,6 +2080,7 @@ function bindEvents() { $$('[data-view]').forEach((button) => button.addEventListener("click", () => setView(button.dataset.view))); $$('[data-create-instance]').forEach((button) => button.addEventListener("click", () => openInstanceDialog())); $$('[data-create-group]').forEach((button) => button.addEventListener("click", () => openGroupDialog())); + $$('[data-create-account]').forEach((button) => button.addEventListener("click", () => openCredentialDialog(button.dataset.createAccount))); $("#refresh-overview").addEventListener("click", async (event) => { setIconLoading(event.currentTarget, true); await loadOverview(true); setIconLoading(event.currentTarget, false); }); $("#mobile-refresh").addEventListener("click", async (event) => { setIconLoading(event.currentTarget, true); await refreshCurrentView(true); setIconLoading(event.currentTarget, false); }); @@ -1804,8 +2096,10 @@ function bindEvents() { $("#instances-prev").addEventListener("click", () => { state.instances.page = Math.max(1, state.instances.page - 1); loadInstances(); }); $("#instances-next").addEventListener("click", () => { state.instances.page += 1; loadInstances(); }); $("#instance-form").addEventListener("submit", saveInstance); + ["aws_account_id", "aws_region", "cloudflare_account_id"].forEach((name) => $("#instance-form").elements[name].addEventListener("change", (event) => clearCustomSelectFieldError(event.currentTarget))); $("#test-instance-dialog").addEventListener("click", (event) => testInstance(state.selected.instanceId, event.currentTarget)); $("#delete-instance").addEventListener("click", () => requestDelete("instance", state.selected.instanceId)); + $("#instance-account-callout").querySelector("[data-manage-accounts]").addEventListener("click", () => { $("#instance-dialog").close(); setView("accounts", true); }); $("#group-search").addEventListener("input", (event) => { state.groups.query = event.target.value.trim(); debounce(() => loadGroups()); }); $("#group-status-filter").addEventListener("change", (event) => { state.groups.status = event.target.value; loadGroups(); }); @@ -1819,10 +2113,11 @@ function bindEvents() { $("#group-member-list").addEventListener("change", updateMemberDraft); $("#delete-group").addEventListener("click", () => requestDelete("group", state.selected.groupId)); - $("#settings-button").addEventListener("click", openSettings); - $("#mobile-settings").addEventListener("click", openSettings); - $("#settings-form").addEventListener("submit", saveSettings); - $$('[data-aws-mode]').forEach((button) => button.addEventListener("click", () => setAwsMode(button.dataset.awsMode))); + $("#refresh-accounts").addEventListener("click", async (event) => { setIconLoading(event.currentTarget, true); await loadAccounts({ force: true }); setIconLoading(event.currentTarget, false); }); + [$("#aws-account-list"), $("#cloudflare-account-list")].forEach((container) => container.addEventListener("click", handleAccountAction)); + $("#credential-form").addEventListener("submit", saveCredential); + $$('[data-credential-aws-mode]').forEach((button) => button.addEventListener("click", () => setCredentialAwsMode(button.dataset.credentialAwsMode))); + $("#delete-credential").addEventListener("click", () => requestDelete("account", state.selected.accountId)); $("#confirm-rotation").addEventListener("click", confirmRotation); $("#confirm-delete").addEventListener("click", confirmDelete); @@ -1841,6 +2136,7 @@ function bindEvents() { $("#cancel-run").addEventListener("click", confirmCancelRun); $("#confirm-cancel-run").addEventListener("click", cancelRun); $("#account-button").addEventListener("click", () => $("#account-dialog").showModal()); + $("#mobile-account").addEventListener("click", () => $("#account-dialog").showModal()); $("#password-form").addEventListener("submit", changePassword); $("#logout-button").addEventListener("click", logout); diff --git a/app/static/index.html b/app/static/index.html index d695639..2fe3aae 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -58,10 +58,10 @@ + @@ -191,6 +191,32 @@ +
+ + +
+ + +
+
+
@@ -221,6 +247,7 @@ +
@@ -251,17 +278,44 @@
AMAZON LIGHTSAIL

目标实例

- - +
+ AWS 账号 +
+ + + +
+ +
+
+ AWS Region +
+ + + +
+ +
+
CLOUDFLARE DNS

域名路由

+
+ Cloudflare 账号 +
+ + + +
+ +
+
DNS-only · TTL 60sSOCKS5 路由保持灰云解析
@@ -303,24 +357,29 @@ - -
-
SHARED CREDENTIALS

共享凭据

-
-
AMAZON WEB SERVICES

AWS 凭据

未配置
-
+ + +
AMAZON WEB SERVICES

新增 AWS 账号

+
+
ACCOUNT PROFILE

账号标识

新账号
+ + +
+
+
AUTHENTICATION

AWS 认证方式

+
-
+
- +
-
-
CLOUDFLARE

API Token

未配置
- + - +
diff --git a/app/static/styles.css b/app/static/styles.css index b0cf39c..3bebe9a 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -1103,7 +1103,7 @@ input:checked + .toggle::after { transform: translateX(16px); } .mobile-header .brand-mark { width: 29px; height: 29px; } .mobile-header .icon-button { width: 34px; height: 34px; } .content-frame { width: auto; max-width: none; margin: 0; padding: 86px 14px 96px; } - .mobile-nav { position: fixed; right: 8px; bottom: 8px; left: 8px; z-index: 30; display: grid; height: 62px; grid-template-columns: repeat(4, 1fr); align-items: center; padding: 5px; } + .mobile-nav { position: fixed; right: 8px; bottom: 8px; left: 8px; z-index: 30; display: grid; height: 62px; grid-template-columns: repeat(5, 1fr); align-items: center; padding: 5px; } .mobile-nav .nav-item { display: grid; min-height: 50px; place-content: center; justify-items: center; gap: 3px; padding: 0; font-size: 8px; text-align: center; } .mobile-nav .nav-item svg { width: 17px; height: 17px; } .status-band { grid-template-columns: 1.3fr 0.7fr; } @@ -2296,3 +2296,327 @@ input:checked + .toggle::after { transform: translateX(16px); } align-items: flex-start; } } + +/* Credential vault */ +.account-vault-grid, +.account-vault-loading { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 34px; +} + +.skeleton-account { + height: 310px; +} + +.account-collection { + min-width: 0; +} + +.account-collection-head { + display: flex; + min-height: 52px; + align-items: center; + justify-content: space-between; + gap: 18px; + margin-bottom: 13px; + padding-bottom: 13px; + border-bottom: 1px solid var(--line); +} + +.account-collection-head > div, +.credential-card-main { + display: flex; + min-width: 0; + align-items: center; + gap: 11px; +} + +.account-collection-head > div > span:last-child, +.credential-card-copy { + display: grid; + min-width: 0; + gap: 3px; +} + +.account-collection-head small { + color: var(--ink-faint); + font-size: 8px; + font-weight: 750; +} + +.account-collection-head strong { + font-size: 14px; + font-weight: 720; +} + +.account-collection-head .icon-button { + width: 36px; + height: 36px; +} + +.credential-list { + display: grid; + gap: 8px; +} + +.credential-card { + display: grid; + min-height: 84px; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 14px; + padding: 13px 12px 13px 14px; + border: 1px solid rgba(255, 255, 255, 0.9); + border-radius: 8px; + background: rgba(255, 255, 255, 0.69); + box-shadow: 0 9px 28px rgba(33, 42, 33, 0.055); + backdrop-filter: blur(18px) saturate(1.08); + -webkit-backdrop-filter: blur(18px) saturate(1.08); + transition: border-color 160ms ease, background 160ms ease, transform 160ms ease; +} + +.credential-card:hover { + border-color: rgba(29, 33, 29, 0.12); + background: rgba(255, 255, 255, 0.86); + transform: translateY(-1px); +} + +.credential-card-copy strong, +.credential-card-copy small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.credential-card-copy strong { + font-size: 12px; + font-weight: 700; +} + +.credential-card-copy small { + color: var(--ink-faint); + font-size: 8px; +} + +.credential-state { + display: inline-flex; + align-items: center; + gap: 7px; + color: var(--ink-faint); + font-size: 9px; + font-weight: 650; + white-space: nowrap; +} + +.credential-state.is-ready { + color: #4f6c13; +} + +.credential-state.is-missing { + color: #9a352c; +} + +.credential-card-actions { + display: flex; + gap: 5px; +} + +.credential-card-actions .icon-button { + width: 36px; + height: 36px; + background: rgba(255, 255, 255, 0.62); +} + +.credential-delete-button:hover:not(:disabled) { + border-color: rgba(217, 79, 67, 0.24); + color: var(--red); + background: rgba(217, 79, 67, 0.07); +} + +.credential-empty { + display: grid; + min-height: 232px; + place-content: center; + justify-items: center; + gap: 7px; + padding: 24px; + border: 1px dashed rgba(29, 33, 29, 0.15); + border-radius: 8px; + color: var(--ink-faint); + background: rgba(255, 255, 255, 0.34); + text-align: center; +} + +.credential-empty .empty-glyph { + display: grid; + width: 42px; + height: 42px; + margin-bottom: 5px; + place-items: center; + border-radius: 8px; + color: var(--lime-deep); + background: rgba(184, 235, 54, 0.2); +} + +.credential-empty strong { + color: var(--ink-soft); + font-size: 11px; +} + +.credential-empty > span:not(.empty-glyph) { + font-size: 9px; +} + +.credential-empty .button { + margin-top: 10px; +} + +.credential-dialog { + width: min(calc(100% - 32px), 700px); +} + +.credential-identity-section { + padding-bottom: 22px; +} + +.compact-section-title { + margin-bottom: 17px; +} + +.credential-mode-control { + margin-bottom: 20px; +} + +.credential-callout { + display: grid; + grid-template-columns: 34px minmax(0, 1fr) auto; + align-items: center; + gap: 11px; + margin-top: 16px; + padding: 11px; + border: 1px solid rgba(239, 168, 47, 0.24); + border-radius: 8px; + color: #6d531e; + background: rgba(255, 245, 219, 0.72); +} + +.credential-callout > span:first-child { + display: grid; + width: 34px; + height: 34px; + place-items: center; + border-radius: 7px; + background: rgba(239, 168, 47, 0.13); +} + +.credential-callout > span:first-child svg { + width: 16px; + height: 16px; +} + +.credential-callout > div { + display: grid; + min-width: 0; + gap: 3px; +} + +.credential-callout strong { + font-size: 10px; +} + +.credential-callout small { + overflow: hidden; + font-size: 8px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.credential-callout .button { + min-height: 36px; + color: #6d531e; +} + +.custom-select-trigger-symbol { + display: grid; + width: 18px; + height: 18px; + place-items: center; + color: var(--ink-faint); + font-size: 15px; + line-height: 1; +} + +.custom-select-trigger-symbol svg { + width: 16px; + height: 16px; +} + +.custom-select-option-symbol { + font-size: 18px; + line-height: 1; +} + +.custom-select-region .custom-select-option-icon { + background: rgba(71, 118, 231, 0.08); +} + +@media (max-width: 1120px) { + .account-vault-grid, + .account-vault-loading { + gap: 20px; + } + + .credential-card { + grid-template-columns: minmax(0, 1fr) auto; + } + + .credential-state { + grid-column: 1; + grid-row: 2; + padding-left: 47px; + } + + .credential-card-actions { + grid-column: 2; + grid-row: 1 / span 2; + } +} + +@media (max-width: 760px) { + .account-vault-grid, + .account-vault-loading { + grid-template-columns: 1fr; + gap: 30px; + } +} + +@media (max-width: 640px) { + .credential-dialog { + width: calc(100% - 12px); + max-height: calc(100dvh - 12px); + } + + .credential-card { + gap: 9px; + padding: 12px 10px 12px 12px; + } + + .credential-card-actions .icon-button { + width: 40px; + height: 40px; + } + + .credential-state { + padding-left: 45px; + } + + .credential-callout { + grid-template-columns: 34px minmax(0, 1fr); + } + + .credential-callout .button { + grid-column: 1 / -1; + width: 100%; + } +} diff --git a/migrations/005_credential_accounts.sql b/migrations/005_credential_accounts.sql new file mode 100644 index 0000000..eb644de --- /dev/null +++ b/migrations/005_credential_accounts.sql @@ -0,0 +1,84 @@ +CREATE TABLE credential_accounts ( + id TEXT PRIMARY KEY, + provider TEXT NOT NULL CHECK (provider IN ('aws', 'cloudflare')), + name TEXT NOT NULL CHECK (length(trim(name)) BETWEEN 1 AND 80), + use_default_aws_credentials INTEGER + CHECK (use_default_aws_credentials IS NULL OR use_default_aws_credentials IN (0, 1)), + config_version INTEGER NOT NULL DEFAULT 1 CHECK (config_version > 0), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + archived_at TEXT, + CHECK ( + (provider = 'aws' AND use_default_aws_credentials IS NOT NULL) + OR (provider = 'cloudflare' AND use_default_aws_credentials IS NULL) + ) +); + +CREATE UNIQUE INDEX uq_credential_accounts_provider_name_active +ON credential_accounts(provider, name COLLATE NOCASE) +WHERE archived_at IS NULL; + +CREATE INDEX idx_credential_accounts_active +ON credential_accounts(provider, archived_at, name); + +CREATE TABLE account_secrets ( + account_id TEXT NOT NULL + REFERENCES credential_accounts(id) ON DELETE CASCADE, + name TEXT NOT NULL CHECK ( + name IN ( + 'aws_access_key_id', + 'aws_secret_access_key', + 'aws_session_token', + 'cloudflare_api_token' + ) + ), + key_version INTEGER NOT NULL DEFAULT 1 CHECK (key_version > 0), + nonce TEXT NOT NULL, + ciphertext TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (account_id, name) +); + +CREATE INDEX idx_account_secrets_account +ON account_secrets(account_id); + +ALTER TABLE managed_instances +ADD COLUMN aws_account_id TEXT + REFERENCES credential_accounts(id) ON DELETE RESTRICT; + +ALTER TABLE managed_instances +ADD COLUMN cloudflare_account_id TEXT + REFERENCES credential_accounts(id) ON DELETE RESTRICT; + +DROP INDEX uq_managed_instances_aws_target_active; + +CREATE UNIQUE INDEX uq_managed_instances_aws_target_active +ON managed_instances( + coalesce(aws_account_id, ''), aws_region, lightsail_instance_name +) +WHERE archived_at IS NULL; + +DROP INDEX uq_managed_instances_dns_record_active; + +CREATE UNIQUE INDEX uq_managed_instances_dns_record_active +ON managed_instances( + coalesce(cloudflare_account_id, ''), lower(cloudflare_record_name) +) +WHERE archived_at IS NULL; + +CREATE INDEX idx_managed_instances_aws_account +ON managed_instances(aws_account_id) +WHERE archived_at IS NULL; + +CREATE INDEX idx_managed_instances_cloudflare_account +ON managed_instances(cloudflare_account_id) +WHERE archived_at IS NULL; + +ALTER TABLE fleet_run_items +ADD COLUMN aws_account_id TEXT + REFERENCES credential_accounts(id) ON DELETE RESTRICT; + +ALTER TABLE fleet_run_items +ADD COLUMN cloudflare_account_id TEXT + REFERENCES credential_accounts(id) ON DELETE RESTRICT; diff --git a/tests/test_accounts_api.py b/tests/test_accounts_api.py new file mode 100644 index 0000000..4afa142 --- /dev/null +++ b/tests/test_accounts_api.py @@ -0,0 +1,595 @@ +from __future__ import annotations + +from datetime import timedelta +from typing import Any + +from fastapi.testclient import TestClient +from pytest import MonkeyPatch + +import app.fleet.service as fleet_service_module +from app.core.time import to_iso, utc_now +from tests.conftest import csrf_headers + + +def create_account( + client: TestClient, + payload: dict[str, object], +) -> tuple[dict[str, Any], str]: + response = client.post( + "/api/v1/accounts", + json=payload, + headers=csrf_headers(client), + ) + assert response.status_code == 201, response.text + return response.json()["data"], response.headers["location"] + + +def aws_access_payload(name: str = "AWS Access") -> dict[str, object]: + return { + "provider": "aws", + "name": name, + "use_default_aws_credentials": False, + "aws_access_key_id": "AKIA_API_ACCESS_KEY", + "aws_secret_access_key": "api-secret-access-key", + "aws_session_token": "api-session-token", + } + + +def cloudflare_payload(name: str = "Cloudflare Main") -> dict[str, object]: + return { + "provider": "cloudflare", + "name": name, + "cloudflare_api_token": "cloudflare-api-token-value", + } + + +def test_account_create_list_get_flags_and_no_plaintext_leak( + authenticated_client: TestClient, +) -> None: + client = authenticated_client + access, access_location = create_account(client, aws_access_payload()) + default, _ = create_account( + client, + { + "provider": "aws", + "name": "AWS Default Chain", + "use_default_aws_credentials": True, + }, + ) + cloudflare, _ = create_account(client, cloudflare_payload()) + + assert access["config_version"] == 1 + assert access["use_default_aws_credentials"] is False + assert access["secrets_configured"] == { + "aws_access_key_id": True, + "aws_secret_access_key": True, + "aws_session_token": True, + "cloudflare_api_token": False, + } + assert default["use_default_aws_credentials"] is True + assert not any(default["secrets_configured"].values()) + assert cloudflare["use_default_aws_credentials"] is None + assert cloudflare["secrets_configured"] == { + "aws_access_key_id": False, + "aws_secret_access_key": False, + "aws_session_token": False, + "cloudflare_api_token": True, + } + assert client.get(access_location).json()["data"] == access + + listed = client.get("/api/v1/accounts") + assert listed.status_code == 200 + assert [item["id"] for item in listed.json()["data"]] == [ + access["id"], + default["id"], + cloudflare["id"], + ] + aws_only = client.get("/api/v1/accounts", params={"provider": "aws"}) + assert {item["id"] for item in aws_only.json()["data"]} == { + access["id"], + default["id"], + } + + plaintexts = ( + "AKIA_API_ACCESS_KEY", + "api-secret-access-key", + "api-session-token", + "cloudflare-api-token-value", + ) + serialized_responses = " ".join((listed.text, client.get(access_location).text)) + assert all(secret not in serialized_responses for secret in plaintexts) + container = client.app.state.container + with container.database.connect() as connection: + rows = connection.execute( + "SELECT account_id, name, nonce, ciphertext FROM account_secrets" + ).fetchall() + stored = " ".join(str(value) for row in rows for value in tuple(row)) + assert all(secret not in stored for secret in plaintexts) + + +def test_account_updates_preserve_blank_secrets_replace_pair_and_enforce_cas( + authenticated_client: TestClient, +) -> None: + client = authenticated_client + aws, location = create_account(client, aws_access_payload()) + + preserved = client.put( + location, + json={ + "config_version": aws["config_version"], + "name": "AWS Access Renamed", + "use_default_aws_credentials": False, + "aws_access_key_id": None, + "aws_secret_access_key": None, + "aws_session_token": None, + }, + headers=csrf_headers(client), + ) + assert preserved.status_code == 200, preserved.text + preserved_data = preserved.json()["data"] + assert preserved_data["config_version"] == 2 + assert all( + preserved_data["secrets_configured"][name] + for name in ( + "aws_access_key_id", + "aws_secret_access_key", + "aws_session_token", + ) + ) + assert client.app.state.container.account_repository.resolve_aws(aws["id"])[1] == { + "aws_access_key_id": "AKIA_API_ACCESS_KEY", + "aws_secret_access_key": "api-secret-access-key", + "aws_session_token": "api-session-token", + } + + replaced = client.put( + location, + json={ + "config_version": preserved_data["config_version"], + "aws_access_key_id": "AKIA_API_REPLACED_KEY", + "aws_secret_access_key": "api-replaced-secret", + "aws_session_token": None, + }, + headers=csrf_headers(client), + ) + assert replaced.status_code == 200, replaced.text + replaced_data = replaced.json()["data"] + assert replaced_data["config_version"] == 3 + assert replaced_data["secrets_configured"]["aws_session_token"] is False + assert client.app.state.container.account_repository.resolve_aws(aws["id"])[1] == { + "aws_access_key_id": "AKIA_API_REPLACED_KEY", + "aws_secret_access_key": "api-replaced-secret", + "aws_session_token": None, + } + + stale = client.put( + location, + json={"config_version": preserved_data["config_version"], "name": "Stale"}, + headers=csrf_headers(client), + ) + assert stale.status_code == 409 + assert stale.json()["type"] == "about:blank#account_config_version_conflict" + + +def test_cloudflare_update_preserves_blank_token( + authenticated_client: TestClient, +) -> None: + client = authenticated_client + cloudflare, cloudflare_location = create_account(client, cloudflare_payload()) + cloudflare_preserved = client.put( + cloudflare_location, + json={ + "config_version": cloudflare["config_version"], + "name": "Cloudflare Renamed", + "cloudflare_api_token": None, + }, + headers=csrf_headers(client), + ) + assert cloudflare_preserved.status_code == 200, cloudflare_preserved.text + assert cloudflare_preserved.json()["data"]["secrets_configured"]["cloudflare_api_token"] is True + assert ( + client.app.state.container.account_repository.resolve_cloudflare(cloudflare["id"]) + == "cloudflare-api-token-value" + ) + + +def test_account_soft_delete_and_provider_payload_validation( + authenticated_client: TestClient, +) -> None: + client = authenticated_client + account, location = create_account(client, cloudflare_payload("Cloudflare Archived")) + + assert client.delete(location).status_code == 403 + archived = client.delete(location, headers=csrf_headers(client)) + assert archived.status_code == 200 + assert archived.json()["data"]["archived_at"] is not None + assert client.get(location).status_code == 404 + assert account["id"] not in { + item["id"] for item in client.get("/api/v1/accounts").json()["data"] + } + stored = client.app.state.container.account_repository.get_account( + account["id"], include_archived=True + ) + assert stored is not None and stored.archived_at is not None + + invalid_payloads = ( + { + "provider": "aws", + "name": "AWS With Cloudflare Token", + "use_default_aws_credentials": True, + "cloudflare_api_token": "wrong-token", + }, + { + "provider": "cloudflare", + "name": "Cloudflare With AWS Key", + "cloudflare_api_token": "cf-token", + "aws_access_key_id": "AKIA_WRONG", + "aws_secret_access_key": "wrong-secret", + }, + { + "provider": "aws", + "name": "Incomplete AWS", + "aws_access_key_id": "AKIA_ONLY", + }, + ) + for payload in invalid_payloads: + response = client.post( + "/api/v1/accounts", + json=payload, + headers=csrf_headers(client), + ) + assert response.status_code == 422 + assert client.get("/api/v1/accounts", params={"provider": "azure"}).status_code == 422 + + +def test_instance_account_binding_validates_provider_missing_and_in_use( + authenticated_client: TestClient, + managed_instance_payload: dict[str, object], +) -> None: + client = authenticated_client + aws, aws_location = create_account(client, aws_access_payload("AWS Instance")) + cloudflare, cloudflare_location = create_account( + client, cloudflare_payload("Cloudflare Instance") + ) + + provider_mismatch = client.post( + "/api/v1/instances", + json={ + **managed_instance_payload, + "id": "provider-mismatch-instance", + "aws_account_id": cloudflare["id"], + "cloudflare_account_id": aws["id"], + }, + headers=csrf_headers(client), + ) + assert provider_mismatch.status_code == 422 + assert provider_mismatch.json()["type"] == ("about:blank#aws_account_provider_mismatch") + + missing = client.post( + "/api/v1/instances", + json={ + **managed_instance_payload, + "id": "missing-account-instance", + "aws_account_id": aws["id"], + "cloudflare_account_id": "missing-cloudflare-account", + }, + headers=csrf_headers(client), + ) + assert missing.status_code == 422 + assert missing.json()["type"] == "about:blank#cloudflare_account_not_found" + + created = client.post( + "/api/v1/instances", + json={ + **managed_instance_payload, + "id": "account-linked-instance", + "aws_account_id": aws["id"], + "cloudflare_account_id": cloudflare["id"], + }, + headers=csrf_headers(client), + ) + assert created.status_code == 201, created.text + created_data = created.json()["data"] + assert created_data["aws_account_id"] == aws["id"] + assert created_data["cloudflare_account_id"] == cloudflare["id"] + + for location in (aws_location, cloudflare_location): + in_use = client.delete(location, headers=csrf_headers(client)) + assert in_use.status_code == 409 + assert in_use.json()["type"] == "about:blank#account_in_use" + + instance_location = created.headers["location"] + assert client.delete(instance_location, headers=csrf_headers(client)).status_code == 200 + assert client.delete(aws_location, headers=csrf_headers(client)).status_code == 200 + assert client.delete(cloudflare_location, headers=csrf_headers(client)).status_code == 200 + + +def test_account_writes_are_blocked_by_active_run_and_dns_lock( + authenticated_client: TestClient, +) -> None: + client = authenticated_client + account, location = create_account(client, aws_access_payload("AWS Locked")) + container = client.app.state.container + now = to_iso() + with container.database.connect() as connection, connection: + connection.execute( + """ + INSERT INTO fleet_runs( + id, target_type, target_id, target_name, trigger, status, + active_slot, total_items, succeeded_items, started_at, updated_at + ) VALUES ( + 'api-account-run', 'instance', 'target', 'Target', 'manual', + 'running', 1, 0, 0, ?, ? + ) + """, + (now, now), + ) + + run_blocked_requests = ( + ("post", "/api/v1/accounts", cloudflare_payload("Blocked Cloudflare")), + ( + "put", + location, + {"config_version": account["config_version"], "name": "Blocked rename"}, + ), + ("delete", location, None), + ) + for method, path, payload in run_blocked_requests: + response = client.request( + method, + path, + json=payload, + headers=csrf_headers(client), + ) + assert response.status_code == 409 + assert response.json()["type"] == "about:blank#fleet_run_active" + + with container.database.connect() as connection, connection: + connection.execute("DELETE FROM fleet_runs WHERE id = 'api-account-run'") + connection.execute( + """ + INSERT INTO fleet_operation_locks( + slot, kind, owner_id, lease_until, created_at + ) VALUES (1, 'dns_sync', 'api-account-dns-lock', ?, ?) + """, + (to_iso(utc_now() + timedelta(minutes=5)), now), + ) + + dns_blocked = client.put( + location, + json={"config_version": account["config_version"], "name": "DNS blocked"}, + headers=csrf_headers(client), + ) + assert dns_blocked.status_code == 409 + assert dns_blocked.json()["type"] == "about:blank#fleet_run_active" + + +def test_region_catalog_requires_auth_and_contains_chinese_metadata( + client: TestClient, + authenticated_client: TestClient, +) -> None: + # Both fixtures refer to the same test client instance for this test. + client.cookies.clear() + assert client.get("/api/v1/regions").status_code == 401 + login = client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "correct-horse-battery-staple"}, + ) + assert login.status_code == 200 + + response = authenticated_client.get("/api/v1/regions") + assert response.status_code == 200 + regions = response.json()["data"] + assert len(regions) == 19 + assert len({region["code"] for region in regions}) == len(regions) + by_code = {region["code"]: region for region in regions} + assert by_code["ap-east-1"] == { + "code": "ap-east-1", + "name_zh": "亚太地区(香港)", + "icon": "🇭🇰", + } + assert by_code["ap-southeast-1"]["name_zh"] == "亚太地区(新加坡)" + assert by_code["us-east-1"]["name_zh"] == "美国东部(弗吉尼亚北部)" + + +def test_instance_rejects_region_outside_catalog( + authenticated_client: TestClient, + managed_instance_payload: dict[str, object], +) -> None: + response = authenticated_client.post( + "/api/v1/instances", + json={**managed_instance_payload, "aws_region": "moon-east-1"}, + headers=csrf_headers(authenticated_client), + ) + assert response.status_code == 422 + assert any(error["field"] == "aws_region" for error in response.json()["errors"]) + + +def test_fleet_clients_resolve_credentials_from_instance_accounts( + authenticated_client: TestClient, + managed_instance_payload: dict[str, object], + monkeypatch: MonkeyPatch, +) -> None: + client = authenticated_client + aws, _ = create_account( + client, + { + "provider": "aws", + "name": "AWS Client Resolution", + "use_default_aws_credentials": False, + "aws_access_key_id": "AKIA_BOUND_CLIENT", + "aws_secret_access_key": "bound-client-secret", + "aws_session_token": "bound-client-session", + }, + ) + cloudflare, _ = create_account( + client, + { + "provider": "cloudflare", + "name": "Cloudflare Client Resolution", + "cloudflare_api_token": "bound-cloudflare-token", + }, + ) + created = client.post( + "/api/v1/instances", + json={ + **managed_instance_payload, + "id": "client-resolution-instance", + "aws_account_id": aws["id"], + "cloudflare_account_id": cloudflare["id"], + "aws_region": "ap-northeast-1", + "lightsail_instance_name": "bound-lightsail-node", + "cloudflare_zone_id": "bound-zone-id", + "cloudflare_record_name": "bound.example.com", + }, + headers=csrf_headers(client), + ) + assert created.status_code == 201, created.text + + captured: dict[str, dict[str, object]] = {} + aws_client = object() + cloudflare_client = object() + + def lightsail_factory(**kwargs: object) -> object: + captured["aws"] = kwargs + return aws_client + + def cloudflare_factory(**kwargs: object) -> object: + captured["cloudflare"] = kwargs + return cloudflare_client + + def reject_legacy_credentials() -> None: + raise AssertionError("绑定账号完整时不应读取旧全局凭据") + + container = client.app.state.container + monkeypatch.setattr( + fleet_service_module.LightsailClient, + "from_credentials", + staticmethod(lightsail_factory), + ) + monkeypatch.setattr( + fleet_service_module, + "CloudflareClient", + cloudflare_factory, + ) + monkeypatch.setattr( + container.integration_service, + "credentials", + reject_legacy_credentials, + ) + instance = container.fleet_repository.get_instance("client-resolution-instance") + assert instance is not None + + resolved_aws, resolved_cloudflare = container.fleet_service._clients(instance) + + assert resolved_aws is aws_client + assert resolved_cloudflare is cloudflare_client + assert captured["aws"] == { + "region": "ap-northeast-1", + "instance_name": "bound-lightsail-node", + "use_default": False, + "secrets": { + "aws_access_key_id": "AKIA_BOUND_CLIENT", + "aws_secret_access_key": "bound-client-secret", + "aws_session_token": "bound-client-session", + }, + } + assert captured["cloudflare"] == { + "token": "bound-cloudflare-token", + "zone_name": "example.com", + "zone_id": "bound-zone-id", + "record_name": "bound.example.com", + } + + +def test_rotation_starts_with_bound_accounts_without_global_credentials( + authenticated_client: TestClient, + managed_instance_payload: dict[str, object], + monkeypatch: MonkeyPatch, +) -> None: + client = authenticated_client + legacy = client.get("/api/v1/integrations") + assert legacy.status_code == 200 + assert not any(legacy.json()["data"]["secrets_configured"].values()) + assert legacy.json()["data"]["use_default_aws_credentials"] is False + + aws, _ = create_account( + client, + { + "provider": "aws", + "name": "AWS Rotation Only", + "use_default_aws_credentials": False, + "aws_access_key_id": "AKIA_ROTATION_BOUND", + "aws_secret_access_key": "rotation-bound-secret", + }, + ) + cloudflare, _ = create_account( + client, + { + "provider": "cloudflare", + "name": "Cloudflare Rotation Only", + "cloudflare_api_token": "rotation-bound-cf-token", + }, + ) + instance = client.post( + "/api/v1/instances", + json={ + **managed_instance_payload, + "id": "account-only-rotation-instance", + "aws_account_id": aws["id"], + "cloudflare_account_id": cloudflare["id"], + "lightsail_instance_name": "account-only-rotation-node", + "cloudflare_record_name": "rotation-only.example.com", + }, + headers=csrf_headers(client), + ) + assert instance.status_code == 201, instance.text + + spawned: list[str] = [] + container = client.app.state.container + monkeypatch.setattr(container.rotation_service, "_spawn", spawned.append) + response = client.post( + "/api/v1/instances/account-only-rotation-instance/rotations", + headers=csrf_headers(client), + ) + + assert response.status_code == 202, response.text + run = response.json()["data"] + assert response.headers["location"] == f"/api/v1/rotations/{run['id']}" + assert run["status"] == "queued" + assert spawned == [run["id"]] + with container.database.connect() as connection: + item = connection.execute( + """ + SELECT aws_account_id, cloudflare_account_id + FROM fleet_run_items WHERE run_id = ? + """, + (run["id"],), + ).fetchone() + assert item is not None + assert item["aws_account_id"] == aws["id"] + assert item["cloudflare_account_id"] == cloudflare["id"] + + +def test_account_catalog_coexists_with_legacy_global_integrations( + authenticated_client: TestClient, +) -> None: + client = authenticated_client + legacy = client.put( + "/api/v1/integrations", + json={ + "aws_access_key_id": "AKIA_LEGACY_ACCESS", + "aws_secret_access_key": "legacy-secret-key", + "aws_session_token": None, + "use_default_aws_credentials": False, + "cloudflare_api_token": "legacy-cloudflare-token", + }, + headers=csrf_headers(client), + ) + assert legacy.status_code == 200, legacy.text + account, _ = create_account(client, cloudflare_payload("Independent Cloudflare")) + + legacy_after = client.get("/api/v1/integrations") + assert legacy_after.status_code == 200 + assert legacy_after.json()["data"] == legacy.json()["data"] + assert client.get(f"/api/v1/accounts/{account['id']}").status_code == 200 diff --git a/tests/test_accounts_repository.py b/tests/test_accounts_repository.py new file mode 100644 index 0000000..2729344 --- /dev/null +++ b/tests/test_accounts_repository.py @@ -0,0 +1,507 @@ +from __future__ import annotations + +import shutil +from datetime import timedelta +from pathlib import Path + +import pytest + +from app.accounts.repository import AccountRepository +from app.accounts.service import AccountService +from app.core.errors import AppError +from app.core.security import SecretCipher +from app.core.time import to_iso, utc_now +from app.database.database import Database +from app.fleet.repository import FleetRepository +from app.integrations.repository import IntegrationRepository +from app.rotation.repository import RotationRepository + +MIGRATIONS_PATH = Path(__file__).resolve().parents[1] / "migrations" + + +@pytest.fixture +def database(tmp_path: Path) -> Database: + result = Database(tmp_path / "accounts.db", MIGRATIONS_PATH) + result.migrate() + return result + + +@pytest.fixture +def repository(database: Database) -> AccountRepository: + return AccountRepository(database, SecretCipher(b"a" * 32)) + + +def create_aws_account( + repository: AccountRepository, + *, + name: str = "AWS Production", + access_key: str = "AKIA_REPOSITORY_ACCESS", + secret_key: str = "repository-secret-key", + session_token: str | None = "repository-session-token", +): + secrets = { + "aws_access_key_id": access_key, + "aws_secret_access_key": secret_key, + } + if session_token is not None: + secrets["aws_session_token"] = session_token + return repository.create_account( + provider="aws", + name=name, + use_default_aws_credentials=False, + secret_values=secrets, + ) + + +def test_repository_crud_resolution_flags_and_encrypted_storage( + database: Database, + repository: AccountRepository, +) -> None: + aws = create_aws_account(repository) + default_aws = repository.create_account( + provider="aws", + name="AWS Instance Role", + use_default_aws_credentials=True, + secret_values={}, + ) + cloudflare = repository.create_account( + provider="cloudflare", + name="Cloudflare Production", + use_default_aws_credentials=None, + secret_values={"cloudflare_api_token": "cf-repository-token"}, + ) + + assert repository.get_account(aws.id) == aws + assert {item.id for item in repository.list_accounts()} == { + aws.id, + default_aws.id, + cloudflare.id, + } + assert [item.id for item in repository.list_accounts("cloudflare")] == [cloudflare.id] + assert repository.secret_flags(aws.id) == { + "aws_access_key_id": True, + "aws_secret_access_key": True, + "aws_session_token": True, + "cloudflare_api_token": False, + } + assert repository.secret_flags(default_aws.id) == { + "aws_access_key_id": False, + "aws_secret_access_key": False, + "aws_session_token": False, + "cloudflare_api_token": False, + } + assert repository.secret_flags(cloudflare.id)["cloudflare_api_token"] is True + + use_default, aws_secrets = repository.resolve_aws(aws.id) + assert use_default is False + assert aws_secrets == { + "aws_access_key_id": "AKIA_REPOSITORY_ACCESS", + "aws_secret_access_key": "repository-secret-key", + "aws_session_token": "repository-session-token", + } + assert repository.resolve_aws(default_aws.id) == ( + True, + { + "aws_access_key_id": None, + "aws_secret_access_key": None, + "aws_session_token": None, + }, + ) + assert repository.resolve_cloudflare(cloudflare.id) == "cf-repository-token" + + plaintexts = ( + "AKIA_REPOSITORY_ACCESS", + "repository-secret-key", + "repository-session-token", + "cf-repository-token", + ) + with database.connect() as connection: + rows = connection.execute( + "SELECT account_id, name, nonce, ciphertext FROM account_secrets" + ).fetchall() + stored = " ".join(str(value) for row in rows for value in tuple(row)) + assert all(secret not in stored for secret in plaintexts) + + +def test_account_secret_ciphertext_is_bound_to_account_aad( + database: Database, + repository: AccountRepository, +) -> None: + first = create_aws_account(repository, name="AWS First", session_token=None) + second = create_aws_account(repository, name="AWS Second", session_token=None) + + with database.connect() as connection, connection: + encrypted = connection.execute( + """ + SELECT key_version, nonce, ciphertext FROM account_secrets + WHERE account_id = ? AND name = 'aws_access_key_id' + """, + (first.id,), + ).fetchone() + assert encrypted is not None + connection.execute( + """ + UPDATE account_secrets + SET key_version = ?, nonce = ?, ciphertext = ? + WHERE account_id = ? AND name = 'aws_access_key_id' + """, + ( + encrypted["key_version"], + encrypted["nonce"], + encrypted["ciphertext"], + second.id, + ), + ) + + with pytest.raises(AppError) as exc_info: + repository.resolve_aws(second.id) + assert exc_info.value.code == "SECRET_DECRYPTION_FAILED" + + +def test_repository_update_cas_replaces_pair_clears_session_and_soft_deletes( + repository: AccountRepository, +) -> None: + account = create_aws_account(repository) + + updated = repository.update_account( + account.id, + expected_version=account.config_version, + values={"name": "AWS Renamed"}, + secret_values={ + "aws_access_key_id": "AKIA_REPLACED_ACCESS", + "aws_secret_access_key": "replaced-secret-key", + }, + delete_secret_names=("aws_session_token",), + ) + assert updated.name == "AWS Renamed" + assert updated.config_version == account.config_version + 1 + assert repository.resolve_aws(account.id) == ( + False, + { + "aws_access_key_id": "AKIA_REPLACED_ACCESS", + "aws_secret_access_key": "replaced-secret-key", + "aws_session_token": None, + }, + ) + + with pytest.raises(RuntimeError, match="ACCOUNT_CONFIG_VERSION_CONFLICT"): + repository.update_account( + account.id, + expected_version=account.config_version, + values={"name": "Stale rename"}, + secret_values={}, + ) + + archived = repository.archive_account(account.id) + assert archived.archived_at is not None + assert archived.config_version == updated.config_version + 1 + assert repository.get_account(account.id) is None + assert repository.list_accounts() == [] + assert repository.get_account(account.id, include_archived=True) == archived + assert not any(repository.secret_flags(account.id).values()) + + +def test_provider_validation_and_active_instance_prevent_archive( + repository: AccountRepository, + database: Database, +) -> None: + aws = create_aws_account(repository) + cloudflare = repository.create_account( + provider="cloudflare", + name="Cloudflare Bound", + use_default_aws_credentials=None, + secret_values={"cloudflare_api_token": "cf-bound-token"}, + ) + + with pytest.raises(RuntimeError, match="ACCOUNT_PROVIDER_MISMATCH"): + repository.resolve_cloudflare(aws.id) + with pytest.raises(RuntimeError, match="ACCOUNT_PROVIDER_MISMATCH"): + repository.resolve_aws(cloudflare.id) + with pytest.raises(RuntimeError, match="ACCOUNT_PROVIDER_MISMATCH"): + repository.create_account( + provider="aws", + name="Wrong secret type", + use_default_aws_credentials=False, + secret_values={"cloudflare_api_token": "wrong-provider-token"}, + ) + + instance = FleetRepository(database).create_instance( + { + "id": "account-bound-instance", + "aws_account_id": aws.id, + "cloudflare_account_id": cloudflare.id, + "display_name": "Account bound proxy", + "aws_region": "ap-southeast-1", + "lightsail_instance_name": "account-bound-node", + "cloudflare_zone_name": "example.com", + "cloudflare_zone_id": "zone-account-bound", + "cloudflare_record_name": "account-bound.example.com", + "socks_port": 1080, + "proxy_health_check": True, + "health_timeout_seconds": 120, + "release_grace_seconds": 75, + "enabled": True, + } + ) + with pytest.raises(RuntimeError, match="ACCOUNT_IN_USE"): + repository.archive_account(aws.id) + with pytest.raises(RuntimeError, match="ACCOUNT_IN_USE"): + repository.archive_account(cloudflare.id) + + FleetRepository(database).archive_instance(instance.id) + assert repository.archive_account(aws.id).archived_at is not None + assert repository.archive_account(cloudflare.id).archived_at is not None + + +def test_active_fleet_run_and_dns_lock_block_account_writes( + database: Database, + repository: AccountRepository, +) -> None: + account = create_aws_account(repository) + now = to_iso() + with database.connect() as connection, connection: + connection.execute( + """ + INSERT INTO fleet_runs( + id, target_type, target_id, target_name, trigger, status, + active_slot, total_items, succeeded_items, started_at, updated_at + ) VALUES ( + 'account-lock-run', 'instance', 'target', 'Target', 'manual', + 'running', 1, 0, 0, ?, ? + ) + """, + (now, now), + ) + + blocked_by_run = ( + lambda: create_aws_account(repository, name="Blocked create"), + lambda: repository.update_account( + account.id, + expected_version=account.config_version, + values={"name": "Blocked update"}, + secret_values={}, + ), + lambda: repository.archive_account(account.id), + ) + for write in blocked_by_run: + with pytest.raises(RuntimeError, match="FLEET_RUN_ACTIVE"): + write() + + with database.connect() as connection, connection: + connection.execute("DELETE FROM fleet_runs WHERE id = 'account-lock-run'") + connection.execute( + """ + INSERT INTO fleet_operation_locks( + slot, kind, owner_id, lease_until, created_at + ) VALUES (1, 'dns_sync', 'account-dns-lock', ?, ?) + """, + (to_iso(utc_now() + timedelta(minutes=5)), now), + ) + + with pytest.raises(RuntimeError, match="FLEET_RUN_ACTIVE"): + repository.update_account( + account.id, + expected_version=account.config_version, + values={"name": "Still blocked"}, + secret_values={}, + ) + + with database.connect() as connection, connection: + connection.execute( + "UPDATE fleet_operation_locks SET lease_until = ? WHERE owner_id = ?", + (to_iso(utc_now() - timedelta(seconds=1)), "account-dns-lock"), + ) + created = create_aws_account(repository, name="Created after lock expiry") + assert created.name == "Created after lock expiry" + with database.connect() as connection: + assert ( + connection.execute( + "SELECT 1 FROM fleet_operation_locks WHERE owner_id = 'account-dns-lock'" + ).fetchone() + is None + ) + + +def test_legacy_global_credentials_are_imported_and_bound_idempotently( + tmp_path: Path, +) -> None: + staged_migrations = tmp_path / "migrations" + staged_migrations.mkdir() + for name in ( + "001_initial.sql", + "002_operation_locks.sql", + "003_multi_instance_static_ip.sql", + "004_rotation_rollback.sql", + ): + shutil.copy2(MIGRATIONS_PATH / name, staged_migrations) + + database = Database(tmp_path / "legacy-accounts.db", staged_migrations) + database.migrate() + cipher = SecretCipher(b"l" * 32) + integrations = IntegrationRepository(database, cipher) + integrations.save( + False, + { + "aws_access_key_id": "AKIA_LEGACY_IMPORT", + "aws_secret_access_key": "legacy-import-secret", + "aws_session_token": "legacy-import-session", + "cloudflare_api_token": "legacy-import-cloudflare", + }, + ) + now = to_iso() + with database.connect() as connection, connection: + connection.execute( + """ + INSERT INTO managed_instances( + id, display_name, aws_region, lightsail_instance_name, + cloudflare_zone_name, cloudflare_zone_id, cloudflare_record_name, + socks_port, proxy_health_check, health_timeout_seconds, + release_grace_seconds, enabled, config_version, created_at, updated_at + ) VALUES ( + 'legacy-account-instance', 'Legacy account instance', 'us-east-1', + 'legacy-account-node', 'example.com', 'legacy-zone', + 'legacy-account.example.com', 1080, 1, 120, 75, 1, 1, ?, ? + ) + """, + (now, now), + ) + + shutil.copy2(MIGRATIONS_PATH / "005_credential_accounts.sql", staged_migrations) + database.migrate() + accounts = AccountRepository(database, cipher) + service = AccountService(accounts) + + first = service.import_legacy_accounts(integrations) + second = service.import_legacy_accounts(integrations) + + assert ( + first + == second + == { + "aws": "legacy-aws", + "cloudflare": "legacy-cloudflare", + } + ) + assert accounts.resolve_aws("legacy-aws") == ( + False, + { + "aws_access_key_id": "AKIA_LEGACY_IMPORT", + "aws_secret_access_key": "legacy-import-secret", + "aws_session_token": "legacy-import-session", + }, + ) + assert accounts.resolve_cloudflare("legacy-cloudflare") == "legacy-import-cloudflare" + with database.connect() as connection: + instance = connection.execute( + """ + SELECT aws_account_id, cloudflare_account_id + FROM managed_instances WHERE id = 'legacy-account-instance' + """ + ).fetchone() + counts = connection.execute( + """ + SELECT + (SELECT COUNT(*) FROM credential_accounts) AS accounts, + (SELECT COUNT(*) FROM account_secrets) AS secrets + """ + ).fetchone() + assert dict(instance) == { + "aws_account_id": "legacy-aws", + "cloudflare_account_id": "legacy-cloudflare", + } + assert dict(counts) == {"accounts": 2, "secrets": 4} + + +def test_attention_run_allows_only_current_account_secret_recovery( + database: Database, + repository: AccountRepository, +) -> None: + aws = create_aws_account(repository, name="AWS Recovery") + other_aws = create_aws_account(repository, name="AWS Other") + cloudflare = repository.create_account( + provider="cloudflare", + name="Cloudflare Recovery", + use_default_aws_credentials=None, + secret_values={"cloudflare_api_token": "cf-recovery-token"}, + ) + instance = FleetRepository(database).create_instance( + { + "id": "recovery-account-instance", + "aws_account_id": aws.id, + "cloudflare_account_id": cloudflare.id, + "display_name": "Recovery account instance", + "aws_region": "us-east-1", + "lightsail_instance_name": "recovery-account-node", + "cloudflare_zone_name": "example.com", + "cloudflare_zone_id": "recovery-zone", + "cloudflare_record_name": "recovery-account.example.com", + "socks_port": 1080, + "proxy_health_check": True, + "health_timeout_seconds": 120, + "release_grace_seconds": 75, + "enabled": True, + } + ) + run = RotationRepository(database).create_for_instance(instance.id) + with database.connect() as connection, connection: + connection.execute( + "UPDATE fleet_runs SET status = 'needs_attention' WHERE id = ?", + (run.id,), + ) + connection.execute( + """ + UPDATE fleet_run_items SET status = 'needs_attention' + WHERE run_id = ? + """, + (run.id,), + ) + + recovered = repository.update_account( + aws.id, + expected_version=aws.config_version, + values={ + "name": aws.name, + "use_default_aws_credentials": False, + }, + secret_values={ + "aws_access_key_id": "AKIA_RECOVERED_ACCESS", + "aws_secret_access_key": "recovered-secret-key", + }, + delete_secret_names=("aws_session_token",), + ) + assert recovered.config_version == aws.config_version + 1 + assert repository.resolve_aws(aws.id)[1] == { + "aws_access_key_id": "AKIA_RECOVERED_ACCESS", + "aws_secret_access_key": "recovered-secret-key", + "aws_session_token": None, + } + + with pytest.raises(RuntimeError, match="FLEET_RUN_ACTIVE"): + repository.update_account( + other_aws.id, + expected_version=other_aws.config_version, + values={}, + secret_values={ + "aws_access_key_id": "AKIA_OTHER_REPLACED", + "aws_secret_access_key": "other-replaced-secret", + }, + ) + with pytest.raises(RuntimeError, match="FLEET_RUN_ACTIVE"): + repository.update_account( + aws.id, + expected_version=recovered.config_version, + values={"name": "Renamed during recovery"}, + secret_values={"aws_session_token": "new-session-token"}, + ) + + with database.connect() as connection: + event = connection.execute( + """ + SELECT message, details_json FROM fleet_events + WHERE run_id = ? AND stage = 'credentials' + """, + (run.id,), + ).fetchone() + assert event["message"] == "管理员已更新当前账号的恢复凭据" + assert "recovered-secret-key" not in event["details_json"] + assert "aws_secret_access_key" in event["details_json"] diff --git a/tests/test_fleet_repository.py b/tests/test_fleet_repository.py index da94853..2edc0d2 100644 --- a/tests/test_fleet_repository.py +++ b/tests/test_fleet_repository.py @@ -804,6 +804,8 @@ def test_legacy_configuration_is_migrated_and_old_active_run_is_failed( ) shutil.copy2(MIGRATIONS_PATH / "003_multi_instance_static_ip.sql", staged_migrations) + shutil.copy2(MIGRATIONS_PATH / "004_rotation_rollback.sql", staged_migrations) + shutil.copy2(MIGRATIONS_PATH / "005_credential_accounts.sql", staged_migrations) database.migrate() repository = FleetRepository(database) diff --git a/tests/test_static_frontend.py b/tests/test_static_frontend.py index 3e53934..a4e3f91 100644 --- a/tests/test_static_frontend.py +++ b/tests/test_static_frontend.py @@ -104,7 +104,7 @@ def test_all_native_selects_are_replaced_by_custom_selects() -> None: nodes = descendants(parse_index()) assert not [node for node in nodes if node.tag == "select"] - assert len([node for node in nodes if "data-custom-select" in node.attrs]) == 7 + assert len([node for node in nodes if "data-custom-select" in node.attrs]) == 10 def test_every_custom_select_has_scoped_hidden_input_and_valid_aria() -> None: @@ -148,13 +148,38 @@ def test_every_custom_select_has_scoped_hidden_input_and_valid_aria() -> None: assert "data-custom-select-menu" in listbox.attrs, description controlled_ids.append(controls) - assert len(controlled_ids) == len(set(controlled_ids)) == 7 + assert len(controlled_ids) == len(set(controlled_ids)) == 10 assert { node.attrs["id"] for node in hidden_inputs if node.attrs.get("id") } == FILTER_INPUT_IDS assert len( [node for node in hidden_inputs if node.attrs.get("name") == "group_id"] ) == 1 + for field_name in ("aws_account_id", "aws_region", "cloudflare_account_id"): + assert len( + [node for node in hidden_inputs if node.attrs.get("name") == field_name] + ) == 1 + + +def test_account_view_and_region_catalog_are_wired_into_the_app() -> None: + nodes = descendants(parse_index()) + account_nav_items = [ + node + for node in nodes + if node.attrs.get("data-view") == "accounts" and has_class(node, "nav-item") + ] + account_panels = [ + node for node in nodes if node.attrs.get("data-view-panel") == "accounts" + ] + source = APP_JS.read_text(encoding="utf-8") + + assert len(account_nav_items) == 2 + assert len(account_panels) == 1 + assert 'api("/accounts")' in source + assert 'api("/regions")' in source + assert "aws_account_id: value(\"aws_account_id\")" in source + assert "cloudflare_account_id: value(\"cloudflare_account_id\")" in source + assert "symbol: region.icon" in source def test_shared_option_template_keeps_listbox_option_aria_contract() -> None: