feat: 支持多账号凭据与区域选择
This commit is contained in:
parent
a7762aac63
commit
933cfb6484
63
README.md
63
README.md
@ -8,8 +8,9 @@ DNS-only A 记录组成一个托管目标,再通过实例组统一设置轮换
|
||||
把新地址附加到仍在运行的实例,验证 SOCKS5 与 Cloudflare DNS,等待释放宽限期,
|
||||
最后从 AWS 账号释放旧 Static IP。
|
||||
|
||||
配置、任务状态和历史记录保存在 SQLite。AWS 密钥与 Cloudflare API Token 使用本机
|
||||
主密钥加密后存储。
|
||||
配置、账号资料、任务状态和历史记录保存在 SQLite。可以分别维护多套 AWS 与
|
||||
Cloudflare 账号,每个托管实例独立选择要使用的两套账号。AWS 密钥与 Cloudflare
|
||||
API Token 使用本机主密钥加密后存储,API 和 WebUI 不会返回密钥明文。
|
||||
|
||||
## 重要边界
|
||||
|
||||
@ -31,8 +32,8 @@ DNS-only A 记录组成一个托管目标,再通过实例组统一设置轮换
|
||||
一个托管实例包含:
|
||||
|
||||
- 显示名称;
|
||||
- AWS 区域和 Lightsail 实例名称;
|
||||
- Cloudflare Zone、唯一的完整 A 记录名;
|
||||
- AWS 账号、从内置中文名称与图标目录选择的 Lightsail 区域,以及 Lightsail 实例名称;
|
||||
- Cloudflare 账号、Zone ID 和唯一的完整 A 记录名;
|
||||
- SOCKS5 端口、健康检查开关和超时;
|
||||
- 旧地址释放宽限期;
|
||||
- 是否允许参与手动或分组轮换。
|
||||
@ -42,13 +43,18 @@ DNS-only A 记录组成一个托管目标,再通过实例组统一设置轮换
|
||||
代理。下次执行时间从整个批次结束后重新计算,两个批次之间至少间隔一个完整周期。
|
||||
当前系统全局只允许一个轮换批次或 DNS 同步任务执行。
|
||||
|
||||
所有托管实例共用一套 AWS 凭据和一枚 Cloudflare API Token。数据库会阻止以下
|
||||
冲突:
|
||||
AWS 与 Cloudflare 账号分别建档,同一账号可以由多个实例复用,不同实例也可以选择
|
||||
完全独立的账号组合。数据库会阻止以下冲突:
|
||||
|
||||
- 同一个 AWS 区域和 Lightsail 实例被重复管理;
|
||||
- 同一个 Cloudflare 记录被多个托管实例使用;
|
||||
- 同一个 AWS 账号下的同一区域和 Lightsail 实例被重复管理;
|
||||
- 同一个 Cloudflare 账号下的同一记录被多个托管实例使用;
|
||||
- 同一个实例同时加入多个实例组。
|
||||
|
||||
账号的新增、修改和删除与实例配置共用轮换写锁。轮换批次或 DNS 同步任务执行期间
|
||||
通常不能写入账号;任务进入待处理或待清理后,只允许替换当前运行项所绑定账号的
|
||||
恢复密钥,不能改名、切换认证模式或操作其他账号。仍被活动实例引用的账号不能删除,
|
||||
必须先把实例改绑到其他账号或删除相关实例。
|
||||
|
||||
## 轮换流程
|
||||
|
||||
一次实例轮换按以下顺序执行:
|
||||
@ -137,6 +143,10 @@ data/master.key
|
||||
首次启动会创建数据库和主密钥。数据库存在而主密钥丢失时,程序会拒绝启动,避免
|
||||
用新密钥覆盖后无法解密原凭据。
|
||||
|
||||
从只支持一套全局凭据的旧版本升级时,已配置的 AWS 和 Cloudflare 凭据会在启动迁移
|
||||
后分别导入为 legacy 账号,旧实例会自动绑定对应账号。导入过程使用现有主密钥解密并
|
||||
重新加密,不会把密钥明文写入数据库或日志。
|
||||
|
||||
## 管理员账号
|
||||
|
||||
FluxIP **没有默认管理员账号或默认密码**。
|
||||
@ -157,19 +167,24 @@ Bootstrap Token、云端密钥或数据库主密钥提交到 Git。
|
||||
## WebUI 首次配置
|
||||
|
||||
1. 创建管理员并登录。
|
||||
2. 在“共享凭据”中选择 AWS 默认凭据链,或填写专用 Access Key ID、Secret Access
|
||||
Key;临时凭据还需要 Session Token。填写 Cloudflare API Token。
|
||||
3. 新建托管实例,填写区域、Lightsail 实例名称、Zone 和完整 A 记录名。
|
||||
4. 执行实例连接测试,确认 AWS 实例公网地址和 Cloudflare 记录状态。
|
||||
5. 如 DNS 尚未指向实例当前地址,先执行 DNS 同步。
|
||||
6. 对单个实例执行一次手动轮换,核对新 Static IP、SOCKS5 和 DNS。
|
||||
7. 创建实例组、添加成员、设置轮换间隔并启用计划。
|
||||
2. 打开“账号”,新增 AWS 账号:选择默认凭据链,或填写专用 Access Key ID、Secret
|
||||
Access Key;临时凭据还需要 Session Token。
|
||||
3. 继续新增 Cloudflare 账号并填写 API Token。可以为不同 AWS 或 Cloudflare 租户创建
|
||||
多个独立账号资料。
|
||||
4. 新建托管实例,选择已保存的 AWS 账号和 Cloudflare 账号;从带图标和中文名称的
|
||||
区域目录选择 AWS Region,再填写 Lightsail 实例名称、Zone ID 和完整 A 记录名。
|
||||
5. 执行实例连接测试,确认所选 AWS 账号可以读取实例公网地址,所选 Cloudflare 账号
|
||||
可以读取目标记录。
|
||||
6. 如 DNS 尚未指向实例当前地址,先执行 DNS 同步。
|
||||
7. 对单个实例执行一次手动轮换,核对新 Static IP、SOCKS5 和 DNS。
|
||||
8. 创建实例组、添加成员、设置轮换间隔并启用计划。
|
||||
|
||||
配置更新使用版本号进行并发检查。如果页面数据已被另一会话修改,刷新后再提交。
|
||||
实例和实例组的删除是软删除,不会自动删除 Lightsail 实例或 Cloudflare Zone。
|
||||
账号、实例和实例组的删除是软删除,不会自动删除 Lightsail 实例或 Cloudflare Zone。
|
||||
|
||||
轮换执行中通常禁止修改共享凭据、实例或实例组。任务因凭据失效进入待处理或待清理
|
||||
后,可以只替换恢复凭据;审计日志只记录被更新的字段名,不记录密钥内容。
|
||||
轮换批次或 DNS 同步执行中禁止新增、删除账号,也禁止修改实例或实例组。任务因凭据
|
||||
失效进入待处理或待清理后,可以只替换当前运行项所绑定账号的恢复密钥;账号名称和
|
||||
认证模式仍被锁定。审计日志只记录被更新的字段名,不记录密钥内容。
|
||||
|
||||
## AWS 最小权限
|
||||
|
||||
@ -382,14 +397,18 @@ systemd 定时备份示例为 `deploy/fluxip-backup.service` 和
|
||||
|
||||
只有目标目录本身已经加密时,才使用 `--allow-plaintext-key-archive` 创建包含主密钥的
|
||||
完整归档。恢复时先停止 FluxIP,同时恢复匹配的数据库和主密钥,再启动并验证
|
||||
`/readyz`、管理员登录、共享凭据状态、实例组计划和云端连接。
|
||||
`/readyz`、管理员登录、账号资料状态、实例组计划和云端连接。
|
||||
|
||||
## 健康检查与排错
|
||||
|
||||
- `GET /healthz`:进程存活状态。
|
||||
- `GET /readyz`:SQLite 就绪状态,正常返回 HTTP 200。
|
||||
- `GET /healthz`:进程存活状态,返回 `status` 和应用版本;不探测 SQLite 或云端账号。
|
||||
- `GET /readyz`:检查 SQLite 是否可用;就绪时返回 HTTP 200,数据库异常时返回
|
||||
HTTP 503 和 `degraded`。该接口不会调用 AWS 或 Cloudflare。
|
||||
- **没有默认管理员**:首次打开页面自行创建;远程初始化需 Bootstrap Token。
|
||||
- **AWS AccessDenied**:核对上述 8 个 Lightsail Action 和目标区域,不要直接授予管理员。
|
||||
- **账号连接测试失败**:确认实例选中了正确类型的 AWS 与 Cloudflare 账号,账号密钥
|
||||
完整,并且 Region、Zone ID 和记录名属于对应账号。
|
||||
- **AWS AccessDenied**:核对所选 AWS 账号的上述 8 个 Lightsail Action 和目标区域,
|
||||
不要直接授予管理员。
|
||||
- **Static IP 配额不足**:提高该区域配额;不要先释放正在使用的旧地址。
|
||||
- **找不到附加的旧地址**:确认实例、Static IP 和区域一致,避免同时在控制台人工修改。
|
||||
- **SOCKS5 检查失败**:确认服务监听公网接口、端口开放、协议握手可用。
|
||||
|
||||
1
app/accounts/__init__.py
Normal file
1
app/accounts/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Credential account management."""
|
||||
642
app/accounts/repository.py
Normal file
642
app/accounts/repository.py
Normal 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
60
app/accounts/router.py
Normal 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
124
app/accounts/schemas.py
Normal 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
226
app/accounts/service.py
Normal 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)
|
||||
@ -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,
|
||||
|
||||
@ -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
33
app/fleet/regions.py
Normal 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]
|
||||
@ -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"
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"],
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
84
migrations/005_credential_accounts.sql
Normal file
84
migrations/005_credential_accounts.sql
Normal 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
595
tests/test_accounts_api.py
Normal 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
|
||||
507
tests/test_accounts_repository.py
Normal file
507
tests/test_accounts_repository.py
Normal 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"]
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user