feat: 支持多账号凭据与区域选择

This commit is contained in:
3127647737@qq.com 2026-07-17 16:29:12 +08:00
parent a7762aac63
commit 933cfb6484
24 changed files with 3342 additions and 142 deletions

View File

@ -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 检查失败**:确认服务监听公网接口、端口开放、协议握手可用。

1
app/accounts/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Credential account management."""

642
app/accounts/repository.py Normal file
View File

@ -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"

60
app/accounts/router.py Normal file
View File

@ -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)}

124
app/accounts/schemas.py Normal file
View File

@ -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

226
app/accounts/service.py Normal file
View File

@ -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)

View File

@ -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,

View File

@ -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

33
app/fleet/regions.py Normal file
View File

@ -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]

View File

@ -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"

View File

@ -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),

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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"],

View File

@ -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,

View File

@ -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 `<button class="custom-select-option" type="button" role="option" tabindex="-1" data-value="${escapeHtml(value)}" data-label="${escapeHtml(label)}" aria-selected="${selected}">
<span class="custom-select-option-icon ${toneClass}">${icon(option.icon || "check")}</span>
const symbol = option.symbol ? String(option.symbol) : "";
const optionIcon = symbol
? `<span class="custom-select-option-symbol" aria-hidden="true">${escapeHtml(symbol)}</span>`
: icon(option.icon || "check");
return `<button class="custom-select-option" type="button" role="option" tabindex="-1" data-value="${escapeHtml(value)}" data-label="${escapeHtml(label)}" data-symbol="${escapeHtml(symbol)}" aria-selected="${selected}">
<span class="custom-select-option-icon ${toneClass}">${optionIcon}</span>
<span class="custom-select-option-copy"><strong>${escapeHtml(label)}</strong>${detail ? `<small>${escapeHtml(detail)}</small>` : ""}</span>
<span class="custom-select-option-check">${icon("check")}</span>
</button>`;
@ -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 `<article class="credential-card" data-account-id="${escapeHtml(account.id)}">
<div class="credential-card-main">
<span class="service-glyph ${meta.glyph}">${icon(meta.icon)}</span>
<span class="credential-card-copy"><strong>${escapeHtml(account.name)}</strong><small>${escapeHtml(accountAuthLabel(account))} · ${escapeHtml(formatDate(account.updated_at))}</small></span>
</div>
<span class="credential-state ${ready ? "is-ready" : "is-missing"}"><span class="status-dot ${ready ? "status-dot-ok" : "status-dot-error"}"></span>${ready ? "" : ""}</span>
<div class="credential-card-actions">
<button class="icon-button" data-action="edit-account" data-account-id="${escapeHtml(account.id)}" type="button" aria-label="编辑 ${escapeHtml(account.name)}" title="编辑账号">${icon("pencil")}</button>
<button class="icon-button credential-delete-button" data-action="delete-account" data-account-id="${escapeHtml(account.id)}" type="button" aria-label="删除 ${escapeHtml(account.name)}" title="删除账号">${icon("trash-2")}</button>
</div>
</article>`;
}
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);

View File

@ -58,10 +58,10 @@
<button class="nav-item active" data-view="overview" type="button"><i data-lucide="layout-dashboard"></i><span>概览</span></button>
<button class="nav-item" data-view="instances" type="button"><i data-lucide="server"></i><span>实例</span></button>
<button class="nav-item" data-view="groups" type="button"><i data-lucide="boxes"></i><span>分组</span></button>
<button class="nav-item" data-view="accounts" type="button"><i data-lucide="key-round"></i><span>账号</span></button>
<button class="nav-item" data-view="history" type="button"><i data-lucide="history"></i><span>记录</span></button>
</nav>
<div class="sidebar-foot">
<button id="settings-button" class="sidebar-action" type="button"><i data-lucide="settings-2"></i><span>共享凭据</span></button>
<button id="account-button" class="account-button" type="button">
<span class="avatar">A</span>
<span><strong id="sidebar-username">admin</strong><small>管理员</small></span>
@ -73,7 +73,7 @@
<header class="mobile-header glass-surface">
<div class="sidebar-brand"><div class="brand-mark"><span></span><span></span></div><strong>FluxIP</strong></div>
<div class="mobile-header-actions">
<button id="mobile-settings" class="icon-button" type="button" aria-label="共享凭据" title="共享凭据"><i data-lucide="settings-2"></i></button>
<button id="mobile-account" class="icon-button" type="button" aria-label="管理员账户" title="管理员账户"><i data-lucide="user-round"></i></button>
<button id="mobile-refresh" class="icon-button" type="button" aria-label="刷新当前页面" title="刷新当前页面"><i data-lucide="refresh-cw"></i></button>
</div>
</header>
@ -191,6 +191,32 @@
</div>
</section>
<section id="view-accounts" class="view" data-view-panel="accounts">
<header class="page-header">
<div><p class="eyebrow">CREDENTIAL VAULT</p><h1>账号</h1></div>
<button id="refresh-accounts" class="icon-button" type="button" aria-label="刷新账号" title="刷新账号"><i data-lucide="refresh-cw"></i></button>
</header>
<div id="accounts-loading" class="account-vault-loading hidden"><div class="skeleton skeleton-account"></div><div class="skeleton skeleton-account"></div></div>
<div id="accounts-content" class="account-vault-grid">
<section class="account-collection" aria-labelledby="aws-accounts-title">
<div class="account-collection-head">
<div><span class="service-glyph service-glyph-aws"><i data-lucide="cloud"></i></span><span><small>AMAZON WEB SERVICES</small><strong id="aws-accounts-title">AWS 账号</strong></span></div>
<button class="icon-button" data-create-account="aws" type="button" aria-label="新增 AWS 账号" title="新增 AWS 账号"><i data-lucide="plus"></i></button>
</div>
<div id="aws-account-list" class="credential-list"></div>
<div id="aws-accounts-empty" class="credential-empty hidden"><span class="empty-glyph"><i data-lucide="key-round"></i></span><strong>还没有 AWS 账号</strong><span>添加后即可绑定 Lightsail 实例</span><button class="button button-secondary" data-create-account="aws" type="button"><i data-lucide="plus"></i><span>新增 AWS 账号</span></button></div>
</section>
<section class="account-collection" aria-labelledby="cf-accounts-title">
<div class="account-collection-head">
<div><span class="service-glyph service-glyph-cf"><i data-lucide="cloud"></i></span><span><small>CLOUDFLARE</small><strong id="cf-accounts-title">Cloudflare 账号</strong></span></div>
<button class="icon-button" data-create-account="cloudflare" type="button" aria-label="新增 Cloudflare 账号" title="新增 Cloudflare 账号"><i data-lucide="plus"></i></button>
</div>
<div id="cloudflare-account-list" class="credential-list"></div>
<div id="cloudflare-accounts-empty" class="credential-empty hidden"><span class="empty-glyph"><i data-lucide="shield-keyhole"></i></span><strong>还没有 Cloudflare 账号</strong><span>添加后即可自动同步 DNS</span><button class="button button-secondary" data-create-account="cloudflare" type="button"><i data-lucide="plus"></i><span>新增 Cloudflare 账号</span></button></div>
</section>
</div>
</section>
<section id="view-history" class="view" data-view-panel="history">
<header class="page-header"><div><p class="eyebrow">AUDIT TRAIL</p><h1>轮换记录</h1></div><button id="refresh-history" class="icon-button" type="button" aria-label="刷新记录" title="刷新记录"><i data-lucide="refresh-cw"></i></button></header>
<div class="resource-toolbar history-toolbar" aria-label="记录筛选">
@ -221,6 +247,7 @@
<button class="nav-item active" data-view="overview" type="button"><i data-lucide="layout-dashboard"></i><span>概览</span></button>
<button class="nav-item" data-view="instances" type="button"><i data-lucide="server"></i><span>实例</span></button>
<button class="nav-item" data-view="groups" type="button"><i data-lucide="boxes"></i><span>分组</span></button>
<button class="nav-item" data-view="accounts" type="button"><i data-lucide="key-round"></i><span>账号</span></button>
<button class="nav-item" data-view="history" type="button"><i data-lucide="history"></i><span>记录</span></button>
</nav>
</div>
@ -251,17 +278,44 @@
<section class="editor-section">
<div class="editor-section-title"><span class="service-glyph service-glyph-aws"><i data-lucide="server"></i></span><div><span class="eyebrow">AMAZON LIGHTSAIL</span><h3>目标实例</h3></div></div>
<div class="form-grid form-grid-2">
<label class="field"><span>AWS Region</span><span class="input-shell"><i data-lucide="map-pin"></i><input name="aws_region" maxlength="32" placeholder="ap-southeast-1" required></span><small class="field-error" data-error-for="aws_region"></small></label>
<label class="field"><span>Lightsail 实例名</span><span class="input-shell"><i data-lucide="box"></i><input name="lightsail_instance_name" maxlength="255" placeholder="socks-gateway" required></span><small class="field-error" data-error-for="lightsail_instance_name"></small></label>
<div class="field">
<span id="instance-aws-account-label">AWS 账号</span>
<div id="instance-aws-account-select" class="custom-select custom-select-rich" data-custom-select>
<input name="aws_account_id" type="hidden" value="">
<button class="custom-select-trigger" type="button" aria-haspopup="listbox" aria-expanded="false" aria-controls="instance-aws-account-options" aria-labelledby="instance-aws-account-label instance-aws-account-value" aria-describedby="instance-aws-account-error" aria-required="true"><i data-lucide="key-round"></i><span id="instance-aws-account-value" class="custom-select-value" data-custom-select-label>选择 AWS 账号</span><i class="custom-select-chevron" data-lucide="chevron-down"></i></button>
<div id="instance-aws-account-options" class="custom-select-menu" data-custom-select-menu role="listbox" aria-labelledby="instance-aws-account-label" hidden></div>
</div>
<small id="instance-aws-account-error" class="field-error" data-error-for="aws_account_id" role="alert"></small>
</div>
<div class="field">
<span id="instance-region-label">AWS Region</span>
<div id="instance-region-select" class="custom-select custom-select-rich custom-select-region" data-custom-select>
<input name="aws_region" type="hidden" value="">
<button class="custom-select-trigger" type="button" aria-haspopup="listbox" aria-expanded="false" aria-controls="instance-region-options" aria-labelledby="instance-region-label instance-region-value" aria-describedby="instance-region-error" aria-required="true"><span class="custom-select-trigger-symbol" data-custom-select-symbol aria-hidden="true"><i data-lucide="map-pin"></i></span><span id="instance-region-value" class="custom-select-value" data-custom-select-label>选择区域</span><i class="custom-select-chevron" data-lucide="chevron-down"></i></button>
<div id="instance-region-options" class="custom-select-menu" data-custom-select-menu role="listbox" aria-labelledby="instance-region-label" hidden></div>
</div>
<small id="instance-region-error" class="field-error" data-error-for="aws_region" role="alert"></small>
</div>
<label class="field field-span-2"><span>Lightsail 实例名</span><span class="input-shell"><i data-lucide="box"></i><input name="lightsail_instance_name" maxlength="255" placeholder="socks-gateway" required></span><small class="field-error" data-error-for="lightsail_instance_name"></small></label>
</div>
</section>
<section class="editor-section">
<div class="editor-section-title"><span class="service-glyph service-glyph-cf"><i data-lucide="cloud"></i></span><div><span class="eyebrow">CLOUDFLARE DNS</span><h3>域名路由</h3></div></div>
<div class="form-grid form-grid-2">
<div class="field field-span-2">
<span id="instance-cf-account-label">Cloudflare 账号</span>
<div id="instance-cf-account-select" class="custom-select custom-select-rich" data-custom-select>
<input name="cloudflare_account_id" type="hidden" value="">
<button class="custom-select-trigger" type="button" aria-haspopup="listbox" aria-expanded="false" aria-controls="instance-cf-account-options" aria-labelledby="instance-cf-account-label instance-cf-account-value" aria-describedby="instance-cf-account-error" aria-required="true"><i data-lucide="shield-keyhole"></i><span id="instance-cf-account-value" class="custom-select-value" data-custom-select-label>选择 Cloudflare 账号</span><i class="custom-select-chevron" data-lucide="chevron-down"></i></button>
<div id="instance-cf-account-options" class="custom-select-menu" data-custom-select-menu role="listbox" aria-labelledby="instance-cf-account-label" hidden></div>
</div>
<small id="instance-cf-account-error" class="field-error" data-error-for="cloudflare_account_id" role="alert"></small>
</div>
<label class="field"><span>Zone 根域名</span><span class="input-shell"><i data-lucide="globe-2"></i><input name="cloudflare_zone_name" maxlength="253" placeholder="example.com" required></span><small class="field-error" data-error-for="cloudflare_zone_name"></small></label>
<label class="field"><span>Zone ID <small>可选</small></span><span class="input-shell"><i data-lucide="scan-line"></i><input name="cloudflare_zone_id" maxlength="64" placeholder="留空自动查找"></span><small class="field-error" data-error-for="cloudflare_zone_id"></small></label>
<label class="field field-span-2"><span>A 记录完整域名</span><span class="input-shell"><i data-lucide="at-sign"></i><input name="cloudflare_record_name" maxlength="253" placeholder="proxy.example.com" required></span><small class="field-error" data-error-for="cloudflare_record_name"></small></label>
</div>
<div id="instance-account-callout" class="credential-callout hidden"><span><i data-lucide="key-round"></i></span><div><strong>缺少可用账号</strong><small id="instance-account-callout-copy">请先添加 AWS 与 Cloudflare 账号</small></div><button class="button button-secondary" data-manage-accounts type="button"><span>前往账号</span><i data-lucide="arrow-up-right"></i></button></div>
<div class="compatibility-note"><span class="note-icon"><i data-lucide="cloud-off"></i></span><div><strong>DNS-only · TTL 60s</strong><span>SOCKS5 路由保持灰云解析</span></div></div>
</section>
<section class="editor-section">
@ -303,24 +357,29 @@
</form>
</dialog>
<dialog id="settings-dialog" class="modal-dialog editor-dialog settings-dialog" aria-labelledby="settings-dialog-title">
<form id="settings-form" novalidate>
<div class="dialog-header sticky-dialog-head"><div><span class="eyebrow">SHARED CREDENTIALS</span><h2 id="settings-dialog-title">共享凭据</h2></div><button class="icon-button" data-close-dialog type="button" aria-label="关闭" title="关闭"><i data-lucide="x"></i></button></div>
<section class="editor-section">
<div class="editor-section-title"><span class="service-glyph service-glyph-aws"><i data-lucide="key-round"></i></span><div><span class="eyebrow">AMAZON WEB SERVICES</span><h3>AWS 凭据</h3></div><span id="aws-config-badge" class="badge badge-neutral">未配置</span></div>
<div class="segmented-control" aria-label="AWS 凭据方式"><button type="button" data-aws-mode="access">访问密钥</button><button type="button" data-aws-mode="default">默认凭据链</button></div>
<dialog id="credential-dialog" class="modal-dialog editor-dialog credential-dialog" aria-labelledby="credential-dialog-title">
<form id="credential-form" novalidate>
<div class="dialog-header sticky-dialog-head"><div><span id="credential-dialog-kicker" class="eyebrow">AMAZON WEB SERVICES</span><h2 id="credential-dialog-title">新增 AWS 账号</h2></div><button class="icon-button" data-close-dialog type="button" aria-label="关闭" title="关闭"><i data-lucide="x"></i></button></div>
<section class="editor-section credential-identity-section">
<div class="editor-section-title"><span id="credential-provider-glyph" class="service-glyph service-glyph-aws"><i data-lucide="key-round"></i></span><div><span class="eyebrow">ACCOUNT PROFILE</span><h3>账号标识</h3></div><span id="credential-config-badge" class="badge badge-neutral">新账号</span></div>
<input name="provider" type="hidden" value="aws">
<label class="field"><span>账号名称</span><span class="input-shell"><i data-lucide="tag"></i><input name="name" maxlength="80" placeholder="生产环境 AWS" required></span><small class="field-error" data-error-for="name"></small></label>
</section>
<section id="credential-aws-fields" class="editor-section">
<div class="editor-section-title compact-section-title"><span class="service-glyph service-glyph-aws"><i data-lucide="fingerprint"></i></span><div><span class="eyebrow">AUTHENTICATION</span><h3>AWS 认证方式</h3></div></div>
<div class="segmented-control credential-mode-control" aria-label="AWS 凭据方式"><button type="button" data-credential-aws-mode="access">访问密钥</button><button type="button" data-credential-aws-mode="default">默认凭据链</button></div>
<input type="hidden" name="use_default_aws_credentials" value="false">
<div id="aws-secret-fields" class="form-grid form-grid-2 secret-grid">
<div id="credential-aws-secret-fields" class="form-grid form-grid-2 secret-grid">
<label class="field"><span>Access Key ID</span><span class="input-shell"><i data-lucide="fingerprint"></i><input name="aws_access_key_id" autocomplete="off" maxlength="128"></span><small class="field-error" data-error-for="aws_access_key_id"></small></label>
<label class="field"><span>Secret Access Key</span><span class="input-shell"><i data-lucide="key-round"></i><input name="aws_secret_access_key" type="password" autocomplete="new-password" maxlength="256"></span><small class="field-error" data-error-for="aws_secret_access_key"></small></label>
<label class="field field-span-2"><span>Session Token <small>可选</small></span><span class="input-shell"><i data-lucide="ticket-check"></i><input name="aws_session_token" type="password" autocomplete="new-password" maxlength="4096"></span></label>
<label class="field field-span-2"><span>Session Token <small>可选</small></span><span class="input-shell"><i data-lucide="ticket-check"></i><input name="aws_session_token" type="password" autocomplete="new-password" maxlength="4096"></span><small class="field-error" data-error-for="aws_session_token"></small></label>
</div>
</section>
<section class="editor-section">
<div class="editor-section-title"><span class="service-glyph service-glyph-cf"><i data-lucide="shield-keyhole"></i></span><div><span class="eyebrow">CLOUDFLARE</span><h3>API Token</h3></div><span id="cf-config-badge" class="badge badge-neutral">未配置</span></div>
<label class="field"><span>Cloudflare API Token</span><span class="input-shell"><i data-lucide="shield-check"></i><input name="cloudflare_api_token" type="password" autocomplete="new-password" maxlength="2048"></span><small class="field-error" data-error-for="cloudflare_api_token"></small></label>
<section id="credential-cf-fields" class="editor-section hidden">
<div class="editor-section-title compact-section-title"><span class="service-glyph service-glyph-cf"><i data-lucide="shield-keyhole"></i></span><div><span class="eyebrow">AUTHENTICATION</span><h3>Cloudflare API Token</h3></div></div>
<label class="field"><span>API Token</span><span class="input-shell"><i data-lucide="shield-check"></i><input name="cloudflare_api_token" type="password" autocomplete="new-password" maxlength="2048"></span><small class="field-error" data-error-for="cloudflare_api_token"></small></label>
</section>
<div class="form-footer sticky-dialog-foot"><span id="settings-save-state"></span><button id="save-settings" class="button button-primary" type="submit"><i data-lucide="save"></i><span>保存凭据</span></button></div>
<div class="form-footer sticky-dialog-foot"><button id="delete-credential" class="button button-danger-soft hidden" type="button"><i data-lucide="trash-2"></i><span>删除</span></button><span id="credential-save-state"></span><button id="save-credential" class="button button-primary" type="submit"><i data-lucide="save"></i><span>保存账号</span></button></div>
</form>
</dialog>

View File

@ -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%;
}
}

View File

@ -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;

595
tests/test_accounts_api.py Normal file
View File

@ -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

View File

@ -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"]

View File

@ -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)

View File

@ -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: