diff --git a/.env.example b/.env.example index 9fea02d..344737a 100644 --- a/.env.example +++ b/.env.example @@ -36,13 +36,18 @@ CODEBUDDY2API_AUTH_PATH=./auth # 导入目录默认是数据目录下的 imports/;Compose 中请使用容器内路径。 # CODEBUDDY_IMPORT_DIR=./auth/imports -# Empty locks management; set a random key before exposing inference beyond loopback. -# 留空即锁定管理;对外暴露推理端口前请设置随机密钥。 -# Native: omit/comment the variable to use the saved default key (first local start displays it once). -# 原生:省略或注释该变量会使用已保存的默认 key(首次本地启动会显示一次)。 +# Optional external admin inference key; also the initial WebUI password until changed. +# Empty means no external key, not locked management. It never grants /admin access. +# 可选的外部管理推理密钥,也是修改密码前的初始 WebUI 密码。 +# 留空表示未设置外部密钥,不会锁定管理;任何 API key 都不能访问 /admin。 +# Without it, startup prints the initial admin password and no inference key is created. +# 未设置时启动会输出初始管理密码,不会自动创建推理密钥。 CODEBUDDY2API_KEY= # Opt-in for headless/non-loopback no-auth runs; the image and Compose already force it on. -# 原生后台或非回环免鉴权运行选项;镜像与 Compose 已固定开启。 +# WARNING: with no external or database keys, this anonymously exposes inference AND +# account-pool billing, with or without a placeholder token. Any key row disables it. +# 警告:没有任何外部或数据库密钥时,无论是否携带占位 token,都会匿名开放推理及 +# 账号池额度与用量(billing);只要存在任意密钥记录(含停用或过期),此开关即失效。 # CODEBUDDY2API_ALLOW_OPEN_NOAUTH=false # Disabling CSRF checks does not disable authentication; use only on trusted local networks. @@ -68,6 +73,8 @@ CODEBUDDY2API_ADMIN_CSRF=true # Scheduled backups of control.sqlite3 into backups/ (never credentials); only scheduled ones are # pruned down to the limit. Unset leaves them configurable under Storage; explicit values lock them. # 定时把 control.sqlite3 备份到 backups/(不含凭证),只清理超出上限的自动备份。 +# Backups contain standard API keys in plaintext plus admin-key hashes and sessions; keep them private. +# 备份包含普通 API key 明文、管理密钥哈希和登录会话,必须按敏感数据保管。 # 不设置时可在「存储管理」修改;显式设置会锁定。 # CODEBUDDY2API_BACKUP_AUTO=true # CODEBUDDY2API_BACKUP_INTERVAL_HOURS=24 diff --git a/AGENTS.md b/AGENTS.md index c013d2a..43cce91 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ codebuddy2api is a personal gateway that exposes a WorkBuddy / CodeBuddy (Tencen The backend is a uv project with `package = false`, so it runs from source without being installed. Install dependencies with `uv sync --locked`, or `pip install --require-hashes -r requirements.txt`. ```bash -uv run --locked --no-build converter.py # serve on 127.0.0.1:8787 (subcommands: serve | login | config migrate|status) +uv run --locked --no-build converter.py # serve on 127.0.0.1:8787 (subcommands: serve | login | config migrate|status | admin reset-password) uv run --locked --no-build python -B -m preview # real gateway + simulated upstream + synthetic accounts on :8790, key "preview" uv run python -B tests/test_routing_scheduler.py # one test file (each file is a standalone unittest script) uv run python -B tests/test_version.py VersionTests.test_matching_tag # one test case diff --git a/README.md b/README.md index 78371a7..0100cd9 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Use your **WorkBuddy / CodeBuddy (Tencent)** subscription as local **OpenAI- and - Automatic multi-account routing across domestic and international sites, with credential refresh. - Per-account automation: domestic personal accounts check in and dispatch Buddy travel by default, with independent switches; international check-in is opt-in. The growth center is personal-only: finished tasks are claimed automatically, with per-task switches for behavior chains; enterprise accounts are excluded. -**Live preview:** [codebuddy2api.elaina-love.com](https://codebuddy2api.elaina-love.com/dashboard), key `preview`. It runs the WebUI and APIs against a simulated upstream with synthetic accounts, holds no real credentials and resets every 6 hours; credential import and export, settings changes and log clearing are disabled there. See [Development preview](docs/development.md) to run it locally. +**Live preview:** [codebuddy2api.elaina-love.com](https://codebuddy2api.elaina-love.com/dashboard). Sign in as `admin` with password `preview`; the inference key is also `preview`. It runs the WebUI and APIs against a simulated upstream with synthetic accounts, holds no real credentials and resets every 6 hours; credential import and export, settings changes and log clearing are disabled there. See [Development preview](docs/development.md) to run it locally. ## Quick start @@ -22,7 +22,7 @@ cd codebuddy2api cp .env.example .env ``` -Edit `.env`: set `CODEBUDDY2API_KEY` to your own random key. Compose loads the whole file into the container, so every key applies and unknown keys are harmless. Preserve an existing `.env` on upgrades. +Edit `.env`: set `CODEBUDDY2API_KEY` to your own random key. It is the external admin inference key and the initial WebUI password until you change the password. Compose loads the whole file into the container, so every key applies and unknown keys are harmless. Preserve an existing `.env` on upgrades. ```bash docker compose pull @@ -33,9 +33,10 @@ Compose pulls `latest` by default (`CODEBUDDY2API_IMAGE=ghcr.io/maiphucgiang/cod First use: -1. Open **http://127.0.0.1:8787/dashboard** and sign in with your API key. +1. Open **http://127.0.0.1:8787/dashboard** and sign in as `admin` with your `CODEBUDDY2API_KEY` password. 2. In **Credentials**, add a domestic or international account through browser login, import an `.info` file, or paste refresh tokens for a chosen endpoint. -3. In **Models**, find an available model and use its public ID in your client. +3. In **Keys**, create an inference key for each client and assign its model, IP, expiry and rate limits. +4. In **Models**, find an available model and use its public ID in your client. The template binds to localhost only. Configure HTTPS and restrict network access before allowing remote connections; keep and securely back up the `auth/` data directory. To run current source or log in from a terminal instead, see the [deployment guide](docs/deployment.md). @@ -51,7 +52,7 @@ The template binds to localhost only. Configure HTTPS and restrict network acces - Media generation is billed per upstream credit and only routes to verified account products; see [Media generation](docs/clients.md#media-generation). -- **API key:** the same key used to sign in to the WebUI. +- **API key:** use a key created in **Keys**, or the externally configured admin key. The WebUI management Cookie cannot call `/v1/*`. - **Model:** a public ID from the WebUI or `GET /v1/models`. - All accounts share these URLs; no region parameter is needed. Anthropic SDKs append `/v1/messages` themselves, so leave it out of the Base URL. - The search and fetch endpoints never call a model. Declaring Anthropic `web_search_*` / `web_fetch_*` or OpenAI `web_search` server tools makes the gateway execute them itself. See [Web search and fetch](docs/clients.md#web-search-and-fetch). @@ -90,7 +91,7 @@ The template binds to localhost only. Configure HTTPS and restrict network acces ## FAQ - **WebUI sign-in fails behind an HTTPS domain/reverse proxy?** Trust your public origin via `admin_allowed_origins` — see [Management Origin checks](docs/advanced.md#management-origin--csrf-switch). -- **No Docker?** After installing dependencies and the WebUI, run `uv run --locked --no-build converter.py` or `python3 converter.py` without `.env`. First local startup saves a default key and displays it once — see [Local Python setup](docs/deployment.md#local-python-setup). +- **No Docker?** After installing dependencies and the WebUI, run `uv run --locked --no-build converter.py` or `python3 converter.py` without `.env`. First startup prints the initial admin username and random password; no inference key is created until you add one in **Keys** — see [Local Python setup](docs/deployment.md#local-python-setup). - **Where is my data?** Everything lives in `auth/` (or `/data/auth` in Docker): credentials, settings and log databases — see [Data and backups](docs/webui.md#data-and-backups). - **Which image tag should I use?** `latest` follows stable releases, `edge` follows main, version tags pin one release — see [Published images](docs/deployment.md#use-published-images). - **Responses tool output/arguments compressed or need verbatim passthrough?** Set `responses_projection_mode` (default `balanced`, or `passthrough` to disable projection) and `responses_projection_max_bytes` (default `40000`, `0` disables per-item trimming). Balanced trimming keeps the head/tail and reports original bytes, estimated tokens and total lines; client addresses stay unchanged — see [Responses projection](docs/clients.md#responses-projection) and [details](docs/advanced.md#responses-projection). diff --git a/README.zh-CN.md b/README.zh-CN.md index 5de3528..cc7c2db 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -10,7 +10,7 @@ - 多账号自动选路,兼容国内/国际站,自动刷新凭证。 - 按账号配置自动任务:国内个人账号默认签到后派 Buddy 旅行,可分别关闭;国际自动签到默认关闭。成长中心仅面向个人账号:已完成任务自动领取积分与能量,行为链按任务开关执行;企业账号不参与。 -**在线预览:**[codebuddy2api.elaina-love.com](https://codebuddy2api.elaina-love.com/dashboard),密钥 `preview`。它让 WebUI 与各个 API 对接模拟上游和合成账号运行,不含任何真实凭证,每 6 小时重置一次;其中已停用凭证导入导出、修改设置和清空日志。在本地运行见[开发预览](docs/development.zh-CN.md)。 +**在线预览:**[codebuddy2api.elaina-love.com](https://codebuddy2api.elaina-love.com/dashboard),使用账号 `admin`、密码 `preview` 登录,推理密钥也是 `preview`。它让 WebUI 与各个 API 对接模拟上游和合成账号运行,不含任何真实凭证,每 6 小时重置一次;其中已停用凭证导入导出、修改设置和清空日志。在本地运行见[开发预览](docs/development.zh-CN.md)。 ## 快速开始 @@ -22,7 +22,7 @@ cd codebuddy2api cp .env.example .env ``` -编辑 `.env`:将 `CODEBUDDY2API_KEY` 设置为自己的随机密钥。Compose 会把整个文件加载进容器,每个键都会生效,无关的键也无影响。升级时保留已有 `.env`。 +编辑 `.env`:将 `CODEBUDDY2API_KEY` 设置为自己的随机密钥。它是外部管理推理密钥,也是修改密码前的初始 WebUI 密码。Compose 会把整个文件加载进容器,每个键都会生效,无关的键也无影响。升级时保留已有 `.env`。 ```bash docker compose pull @@ -33,9 +33,10 @@ Compose 默认拉取 `latest`(`CODEBUDDY2API_IMAGE=ghcr.io/maiphucgiang/codebu 首次使用: -1. 打开 **http://127.0.0.1:8787/dashboard**,使用刚设置的 API key 登录。 +1. 打开 **http://127.0.0.1:8787/dashboard**,使用账号 `admin` 和 `CODEBUDDY2API_KEY` 对应的初始密码登录。 2. 在「凭证管理」扫码添加国内或国际账号,也可导入 `.info` 文件,或按端点粘贴刷新令牌批量导入。 -3. 在「模型路由」查看可用模型,将其对外 ID 填入客户端。 +3. 在「密钥管理」为每个客户端创建推理密钥,按需设置模型、IP、有效期和速率限制。 +4. 在「模型路由」查看可用模型,将其对外 ID 填入客户端。 按模板配置时仅允许本机访问。远程访问前请配置 HTTPS 并限制网络访问;保留并妥善备份 `auth/` 数据目录。如需直接运行源码或在终端登录,见[部署指南](docs/deployment.zh-CN.md)。 @@ -51,7 +52,7 @@ Compose 默认拉取 `latest`(`CODEBUDDY2API_IMAGE=ghcr.io/maiphucgiang/codebu - 媒体生成按上游积分计费,只路由到已实测验证的账号产品;见[媒体生成](docs/clients.zh-CN.md#媒体生成)。 -- **API Key**:与 WebUI 登录使用同一个密钥。 +- **API Key**:使用「密钥管理」创建的密钥,或外部配置的管理密钥;WebUI 管理 Cookie 不能调用 `/v1/*`。 - **模型**:使用 WebUI 中的对外 ID,或查询 `GET /v1/models`。 - 国内、国际账号共用这些地址,无需额外的地域参数。Anthropic SDK 会自行追加 `/v1/messages`,不要把它写入 Base URL。 - 搜索与抓取端点不调用模型。在请求里声明 Anthropic `web_search_*`/`web_fetch_*` 或 OpenAI `web_search` 服务端工具时,网关会自行执行,见[联网搜索与抓取](docs/clients.zh-CN.md#联网搜索与抓取)。 @@ -90,7 +91,7 @@ Compose 默认拉取 `latest`(`CODEBUDDY2API_IMAGE=ghcr.io/maiphucgiang/codebu ## 常见问题 - **绑定域名后经 HTTPS 反代无法登录 WebUI?** 将对外来源加入 `admin_allowed_origins` 信任列表——见[管理 Origin 校验](docs/advanced.zh-CN.md#管理-origin--csrf-开关)。 -- **没有 Docker?** 准备依赖和 WebUI 后,直接 `uv run --locked --no-build converter.py` 或 `python3 converter.py`,无需 `.env`;首次本地启动保存默认 key 并仅在终端显示一次——见[本地 Python 运行](docs/deployment.zh-CN.md#本地-python-运行)。 +- **没有 Docker?** 准备依赖和 WebUI 后,直接 `uv run --locked --no-build converter.py` 或 `python3 converter.py`,无需 `.env`;首次启动会输出初始管理账号和随机密码,不会自动创建推理密钥,请登录后在「密钥管理」创建——见[本地 Python 运行](docs/deployment.zh-CN.md#本地-python-运行)。 - **数据在哪里?** 全部位于 `auth/`(Docker 中为 `/data/auth`):凭证、设置与日志数据库——见[数据与备份](docs/webui.zh-CN.md#数据与备份)。 - **镜像标签怎么选?** `latest` 跟随稳定版,`edge` 跟随 main,版本标签固定某一发行版——见[使用已发布镜像](docs/deployment.zh-CN.md#使用已发布镜像)。 - **Responses 工具输出/参数被压缩或需要完全原样?** 设置 `responses_projection_mode`(默认 `balanced`,设 `passthrough` 可完全关闭投影)及 `responses_projection_max_bytes`(默认 `40000`,`0` 禁用单项裁剪)。balanced 裁剪保留头尾并显示原始 bytes、估算 tokens 与总行数,客户端地址不变——见 [Responses 投影](docs/clients.zh-CN.md#responses-投影)及[详细说明](docs/advanced.zh-CN.md#responses-投影)。 diff --git a/app/admin_account.py b/app/admin_account.py new file mode 100644 index 0000000..d7f774c --- /dev/null +++ b/app/admin_account.py @@ -0,0 +1,192 @@ +"""Management account persistence, password hashing and startup transitions.""" +from __future__ import annotations + +import base64 +from dataclasses import dataclass, replace +import hashlib +import hmac +import json +import math +import re +import secrets +import threading +import time + + +USERNAME = re.compile(r"[A-Za-z0-9_.-]{1,32}\Z") +PASSWORD_SOURCES = frozenset(("external", "generated", "migrated", "user")) +_HASH = re.compile(r"scrypt\$16384\$8\$1\$([A-Za-z0-9_-]+)\$([A-Za-z0-9_-]+)\Z") +_TOKEN = re.compile(r"[A-Za-z0-9_-]{16,128}\Z") +_DUMMY_SALT = secrets.token_bytes(16) +_DUMMY_DIGEST = secrets.token_bytes(32) +_VERIFY_SLOTS = threading.BoundedSemaphore(2) + + +class AccountError(ValueError): + """Invalid or unusable management account state.""" + + +class AccountBusy(RuntimeError): + """Password verification capacity is temporarily exhausted.""" + + +@dataclass(frozen=True) +class Account: + username: str + password_source: str + password: str + session_epoch: str + changed_at: float + + @property + def bootstrap(self) -> bool: + return self.password_source != "user" + + def document(self) -> dict: + return {"username": self.username, "password_source": self.password_source, + "password": self.password, "session_epoch": self.session_epoch, + "changed_at": self.changed_at} + + +def _b64(value: bytes) -> str: + return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=") + + +def _unb64(value: str) -> bytes: + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + + +def validate_username(value: str) -> str: + if not isinstance(value, str) or USERNAME.fullmatch(value) is None: + raise AccountError("用户名格式无效") + return value + + +def validate_password(value: str, username: str | None = None) -> str: + if not isinstance(value, str) or not 8 <= len(value) <= 128: + raise AccountError("密码长度必须为 8 至 128 个字符") + if username is not None and value == username: + raise AccountError("密码不能与用户名相同") + return value + + +def _derive(password: str, salt: bytes) -> bytes: + return hashlib.scrypt(password.encode("utf-8"), salt=salt, n=2**14, r=8, p=1, dklen=32) + + +def hash_password(password: str, *, salt: bytes | None = None) -> str: + salt = secrets.token_bytes(16) if salt is None else salt + digest = _derive(password, salt) + return f"scrypt$16384$8$1${_b64(salt)}${_b64(digest)}" + + +def _parse_hash(value: str) -> tuple[bytes, bytes]: + match = _HASH.fullmatch(value) if isinstance(value, str) else None + if match is None: + raise AccountError("管理账户密码哈希无效") + try: + salt, expected = _unb64(match.group(1)), _unb64(match.group(2)) + except (ValueError, base64.binascii.Error): + raise AccountError("管理账户密码哈希无效") from None + if len(salt) != 16 or len(expected) != 32: + raise AccountError("管理账户密码哈希无效") + return salt, expected + + +def verify(account: Account, username: str, password: str) -> bool: + """Run exactly one scrypt operation for every verification attempt.""" + if not isinstance(username, str) or not isinstance(password, str): + username = "" if not isinstance(username, str) else username + password = "" if not isinstance(password, str) else password + username_ok = hmac.compare_digest(username.encode("utf-8"), account.username.encode("utf-8")) + salt, expected = _parse_hash(account.password) if username_ok else (_DUMMY_SALT, _DUMMY_DIGEST) + if not _VERIFY_SLOTS.acquire(timeout=2): + raise AccountBusy("登录繁忙,请稍后重试") + try: + derived = _derive(password, salt) + password_ok = hmac.compare_digest(derived, expected) + finally: + _VERIFY_SLOTS.release() + return bool(username_ok and password_ok) + + +def _validate_document(value: object) -> Account: + if not isinstance(value, dict) or set(value) != {"username", "password_source", "password", + "session_epoch", "changed_at"}: + raise AccountError("管理账户数据无效") + username = validate_username(value["username"]) + source = value["password_source"] + if source not in PASSWORD_SOURCES: + raise AccountError("管理账户密码来源无效") + password = value["password"] + _parse_hash(password) + epoch = value["session_epoch"] + if not isinstance(epoch, str) or _TOKEN.fullmatch(epoch) is None: + raise AccountError("管理账户会话 epoch 无效") + changed_at = value["changed_at"] + if type(changed_at) not in (int, float) or not math.isfinite(changed_at) or changed_at <= 0: + raise AccountError("管理账户时间无效") + return Account(username, source, password, epoch, float(changed_at)) + + +def load(control) -> Account | None: + with control._lock: + row = control._db.execute( + "SELECT value,announced FROM gateway_secrets WHERE name='admin_account'").fetchone() + if row is None: + return None + if row[1] != 1: + raise AccountError("管理账户公告状态无效") + try: + value = json.loads(row[0]) + except (TypeError, ValueError, UnicodeError, RecursionError): + raise AccountError("管理账户数据无效") from None + return _validate_document(value) + + +def save(control, account: Account) -> None: + account = _validate_document(account.document()) + content = json.dumps(account.document(), ensure_ascii=False, separators=(",", ":"), allow_nan=False) + control._db.execute( + "INSERT INTO gateway_secrets(name,value,announced) VALUES('admin_account',?,1) " + "ON CONFLICT(name) DO UPDATE SET value=excluded.value, announced=1", (content,)) + + +def new(username: str, password: str, source: str) -> Account: + validate_username(username) + if source not in PASSWORD_SOURCES: + raise AccountError("管理账户密码来源无效") + if source == "user": + validate_password(password, username) + return Account(username, source, hash_password(password), secrets.token_urlsafe(32), time.time()) + + +def with_password(account: Account, password: str, *, source: str = "user") -> Account: + if source == "user": + validate_password(password, account.username) + if source not in PASSWORD_SOURCES: + raise AccountError("管理账户密码来源无效") + return replace(account, password_source=source, password=hash_password(password), + session_epoch=secrets.token_urlsafe(32), changed_at=time.time()) + + +def with_username(account: Account, username: str) -> Account: + validate_username(username) + return replace(account, username=username, session_epoch=secrets.token_urlsafe(32), changed_at=time.time()) + + +def verify_and_change(account: Account, current_password: str, *, username: str | None = None, + new_password: str | None = None) -> Account: + if not verify(account, account.username, current_password): + raise AccountError("当前管理密码错误") + if username is None and new_password is None: + raise AccountError("至少需要修改用户名或密码") + updated = account + if username is not None: + validate_username(username) + updated = replace(updated, username=username) + if new_password is not None: + validate_password(new_password, username if username is not None else account.username) + updated = replace(updated, password_source="user", password=hash_password(new_password)) + return replace(updated, session_epoch=secrets.token_urlsafe(32), changed_at=time.time()) + diff --git a/app/admin_api.py b/app/admin_api.py index 3305bba..3fca310 100644 --- a/app/admin_api.py +++ b/app/admin_api.py @@ -5,6 +5,7 @@ import io import json import os +import sqlite3 from pathlib import Path import threading import time @@ -17,9 +18,11 @@ from starlette.responses import FileResponse, JSONResponse, Response from . import auth_oauth +from . import admin_account +from .api_keys import KeyStore from . import perf_metrics from . import media_store as media_store_module -from .admin_auth import AdminAuth, AdminMiddleware, COOKIE_NAME, SESSION_TTL, error_response, same_origin +from .admin_auth import AdminAuth, AdminMiddleware, COOKIE_NAME, error_response, same_origin from .control_store import ConflictError, validate_model from .credential_io import CredentialFileError, MAX_CREDENTIAL_BYTES, _valid_name, read_import_file from .settings import SCHEMA, resolve_settings, validate_settings @@ -31,6 +34,12 @@ CLEAR_CONFIRMATION = "清空全部日志与统计" +class AdminError(Exception): + def __init__(self, status: int, message: str): + self.status, self.message = status, message + super().__init__(message) + + async def _body(request, maximum=65536, *, allow_empty=False): data = bytearray() async for chunk in request.stream(): @@ -250,6 +259,8 @@ async def guarded(request: Request): try: result = await function(request) return result + except AdminError as exc: + return error_response(exc.status, exc.message) except ConflictError: return error_response(409, "配置已更新,请刷新后重试") except (UpdateError, BackupError) as exc: @@ -271,19 +282,32 @@ async def session_login(request): if auth.csrf_enabled() and not same_origin(request, auth.allowed_origins()): return error_response(403, "登录请求 Origin 校验失败") data = await _body(request, 8192) - result, status = auth.login(request, data.get("api_key")) + username = data.get("username") + password = data.get("password") + if (not isinstance(username, str) or not isinstance(password, str) + or type(data.get("remember", False)) is not bool): + raise AdminError(400, "用户名、密码或记住我字段无效") + result, status = await run_in_threadpool(auth.login, request, username, password, data.get("remember", False)) if not result: - return error_response(status, "登录尝试过多,请稍后重试" if status == 429 else "API key 无效") + return error_response(status, "登录尝试过多,请稍后重试" if status == 429 else + "账户正在变更,请稍后重试" if status == 503 else "用户名或密码错误") sid, session = result - response = JSONResponse({"authenticated": True, "csrf_token": session["csrf_token"]}) - response.set_cookie(COOKIE_NAME, sid, max_age=SESSION_TTL, httponly=True, secure=request.url.scheme == "https", samesite="strict", path="/admin") + account = auth._account() + response = JSONResponse({"authenticated": True, "csrf_token": session["csrf_token"], + "bootstrap": bool(account and account.bootstrap)}) + response.set_cookie(COOKIE_NAME, sid, + max_age=(400 * 86400 if session.get("remember") else None), + httponly=True, secure=request.url.scheme == "https", samesite="strict", path="/admin") return response @route("GET", "/admin/session") async def session_get(request): _, session = auth.session(request) - return JSONResponse({"authenticated": bool(session or auth.header_identity(request)), - "csrf_token": session["csrf_token"] if session else None}) + account = auth._account() + authenticated = bool(session) + return JSONResponse({"authenticated": authenticated, + "csrf_token": session["csrf_token"] if session else None, + **({"bootstrap": bool(account and account.bootstrap)} if authenticated and account else {})}) @route("DELETE", "/admin/session") async def session_delete(request): @@ -295,6 +319,204 @@ async def session_delete(request): response.delete_cookie(COOKIE_NAME, path="/admin", httponly=True, samesite="strict", secure=request.url.scheme == "https") return response + @route("DELETE", "/admin/sessions") + async def sessions_delete(request): + if not auth.revoke_all(): + return error_response(503, "会话未能持久撤销,请检查管理目录权限后重试") + response = JSONResponse({"ok": True}) + response.delete_cookie(COOKIE_NAME, path="/admin", httponly=True, + samesite="strict", secure=request.url.scheme == "https") + event("sessions_revoked", {}) + return response + + @route("GET", "/admin/account") + async def account_get(request): + account = admin_account.load(config["control_store"]) + if account is None: + raise AdminError(503, "管理账户未初始化") + return JSONResponse({"username": account.username, "password_source": account.password_source, + "bootstrap": account.bootstrap, "changed_at": account.changed_at}) + + @route("PUT", "/admin/account") + async def account_put(request): + body = await _body(request, 8192) + account = admin_account.load(config["control_store"]) + if account is None or not await run_in_threadpool(auth.check_password, account.username, body.get("current_password")): + raise AdminError(403, "当前管理密码错误") + try: + updated = account + if not set(body) <= {"current_password", "username", "new_password"} or not any( + field in body for field in ("username", "new_password")): + raise ValueError("至少需要修改用户名或密码") + if "username" in body: + updated = admin_account.with_username(updated, body["username"]) + if "new_password" in body: + updated = await run_in_threadpool(admin_account.with_password, updated, body["new_password"]) + except ValueError as error: + raise AdminError(400, str(error)) from None + _, previous_session = auth.session(request) + remember = bool((previous_session or {}).get("remember")) + with config["control_store"]._lock: + config["control_store"]._db.execute("BEGIN IMMEDIATE") + try: + current = admin_account.load(config["control_store"]) + if current is None or current.session_epoch != account.session_epoch: + config["control_store"]._db.execute("ROLLBACK") + raise ConflictError() + admin_account.save(config["control_store"], updated) + config["control_store"]._db.execute("COMMIT") + except BaseException: + if config["control_store"]._db.in_transaction: + config["control_store"]._db.execute("ROLLBACK") + raise + auth.revoke_all() + sid, session = auth.issue_session(request, remember=remember) + response = JSONResponse({"username": updated.username, "bootstrap": updated.bootstrap, + "csrf_token": session["csrf_token"]}) + response.set_cookie(COOKIE_NAME, sid, max_age=(400 * 86400 if session.get("remember") else None), + httponly=True, secure=request.url.scheme == "https", samesite="strict", path="/admin") + event("account_changed", {"id": updated.username}) + return response + + key_store = config.get("key_store") + if key_store is None and isinstance(getattr(control, "_db", None), sqlite3.Connection): + key_store = KeyStore(control) + config["key_store"] = key_store + + def key_public(item, *, secret=None, usage=None): + result = {key: item[key] for key in ("id", "name", "type", "prefix", "enabled", "expires_at", + "rpm", "models", "created_at", "last_used_at", "version")} + result["ip_allow"] = item.get("ip_allow") + if item["type"] == "standard": + result["masked"] = item["secret"][:6] + "…" + item["secret"][-4:] if item.get("secret") else None + if secret is not None: + result["secret"] = secret + if usage is not None: + result["usage"] = usage + return result + + def normalize_key_models(value): + if value is None: + return None + if not isinstance(value, list) or len(value) > 256 or any(not isinstance(item, str) for item in value): + raise AdminError(400, "模型列表格式无效") + inventory = gateway.admin_model_inventory() + known = set() + owners = {} + for item in inventory: + row = item if isinstance(item, dict) else {"id": item} + source = row.get("id") + if isinstance(source, str): + known.add(source) + owners[row.get("public_id", source)] = source + known.update(("auto", "default-model")) + result = [] + for name in value: + source = "auto" if name == "default-model" else owners.get(name, name) + if source not in known: + raise AdminError(400, f"未知模型:{name}") + if source not in result: + result.append(source) + return result + + @route("GET", "/admin/keys") + async def keys_get(request): + usage_result = await run_in_threadpool(audit.api_key_stats) + usage = usage_result.get("items", {}) if isinstance(usage_result, dict) and not usage_result.get("degraded") else {} + external = None + if config.get("api_key"): + source = config.get("settings_sources", {}).get("api_key") + external = {"type": "admin", "source": "external", + "origin": {"cli": "cli", "environment": "environment", "dotenv": "dotenv"}.get(source, source), + "occupied": True} + items = [] + for item in key_store.list(): + row = key_public(item, usage=usage.get(item["id"])) + if item["type"] == "admin" and config.get("api_key"): + row["overridden"] = True + items.append(row) + return JSONResponse({"external": external, "items": items}) + + @route("POST", "/admin/keys") + async def keys_create(request): + body = await _body(request, 16384) + kind = body.get("type") + if kind == "admin" and (config.get("api_key") or any(item["type"] == "admin" for item in key_store.list())): + raise AdminError(422, "管理密钥已被外部配置占用或已存在") + if kind == "admin" and any(field in body for field in ("expires_at", "ip_allow", "rpm", "models")): + raise AdminError(400, "管理密钥不接受限制字段") + try: + item, secret = key_store.create(name=body.get("name"), kind=kind, + expires_at=body.get("expires_at"), ip_allow=body.get("ip_allow"), + rpm=body.get("rpm"), models=normalize_key_models(body.get("models"))) + except ValueError as error: + raise AdminError(400, str(error)) from None + event("key_created", {"id": item["id"]}) + return JSONResponse(key_public(item, secret=secret)) + + def key_item(request): + item = key_store.get(request.path_params["id"]) + if item is None: + raise AdminError(404, "密钥不存在") + if item["type"] == "admin" and config.get("api_key"): + raise AdminError(403, "密钥已被外部配置覆盖") + return item + + @route("PATCH", "/admin/keys/{id}") + async def keys_update(request): + item = key_item(request) + body = await _body(request, 16384) + try: + updated = key_store.update(item["id"], body.get("version"), + {key: (normalize_key_models(value) if key == "models" else value) + for key, value in body.items() if key != "version"}) + except RuntimeError as error: + if str(error) == "version_conflict": + raise ConflictError() from None + raise + except ValueError as error: + raise AdminError(400, str(error)) from None + event("key_updated", {"id": updated["id"]}) + return JSONResponse(key_public(updated)) + + @route("POST", "/admin/keys/{id}/rotate") + async def keys_rotate(request): + item = key_item(request) + body = await _body(request, 4096, allow_empty=True) + try: + updated, secret = key_store.rotate(item["id"], body.get("version")) + except RuntimeError as error: + if str(error) == "version_conflict": + raise ConflictError() from None + raise + event("key_rotated", {"id": item["id"]}) + return JSONResponse(key_public(updated, secret=secret)) + + @route("POST", "/admin/keys/{id}/reveal") + async def keys_reveal(request): + item = key_item(request) + try: + secret = key_store.reveal(item["id"]) + except ValueError as error: + raise AdminError(400, str(error)) from None + event("key_revealed", {"id": item["id"]}) + return JSONResponse({"secret": secret}) + + @route("DELETE", "/admin/keys/{id}") + async def keys_delete(request): + item = key_store.get(request.path_params["id"]) + if item is None: + raise AdminError(404, "密钥不存在") + body = await _body(request, 4096, allow_empty=True) + try: + key_store.delete(item["id"], body.get("version")) + except RuntimeError as error: + if str(error) == "version_conflict": + raise ConflictError() from None + raise + event("key_deleted", {"id": item["id"]}) + return JSONResponse({"ok": True}) + @route("GET", "/admin/settings") async def settings_get(request): return JSONResponse(await run_in_threadpool(settings_result)) @@ -737,8 +959,12 @@ async def logs_clear(request): scope = body.get("scope") if scope not in ("details", "all"): raise ValueError("清理范围必须为 details 或 all") - if scope == "all" and (body.get("confirmation") != CLEAR_CONFIRMATION or not auth.check_key(body.get("api_key"))): - return error_response(403, "全部清理需要确认文本及当前 API key") + if scope == "all": + account = auth._account() + confirmed = (auth.check_password(account.username, body.get("password")) + if account is not None else auth.check_key(body.get("api_key"))) + if body.get("confirmation") != CLEAR_CONFIRMATION or not confirmed: + return error_response(403, "全部清理需要确认文本及当前管理密码") result = await run_in_threadpool(audit.clear, scope) if isinstance(result, dict) and result.get("ok") is False: return error_response(503, "日志清理未完成,请检查审计存储状态") @@ -871,10 +1097,21 @@ def remove(): @media_route("GET", "/admin/media/tasks") async def media_tasks(request): status, cursor, limit = media_page_params(request, status=True) + key_id = request.query_params.get("api_key") or None def build(): - page = media_store().list_tasks(status, cursor, limit) - items = [{**item, "created_at": item["created"], "updated_at": item["updated"]} + page = media_store().list_tasks(status, cursor, limit, api_key=key_id) + names = {item["id"]: item["name"] for item in key_store.list()} + def owner_label(value): + if value is None: + return "—(升级前)" + if value == "admin-external": + return "外部管理密钥" + if value == "anonymous": + return "匿名" + return names.get(value, f"{value}(已删除)") + items = [{**item, "created_at": item["created"], "updated_at": item["updated"], + "api_key_name": owner_label(item.get("api_key"))} for item in page["items"]] return JSONResponse({**page, "items": items}) return await run_in_threadpool(build) diff --git a/app/admin_auth.py b/app/admin_auth.py index 58f9e45..b7946ac 100644 --- a/app/admin_auth.py +++ b/app/admin_auth.py @@ -1,4 +1,4 @@ -"""Single-key, management-only sessions and ASGI protection for legacy routes.""" +"""Password-backed management sessions and ASGI protection for legacy routes.""" from __future__ import annotations from collections import OrderedDict @@ -20,6 +20,8 @@ from starlette.requests import Request from starlette.responses import JSONResponse +from . import admin_account + COOKIE_NAME = "cb_admin_session" SESSION_TTL = 12 * 3600 @@ -127,6 +129,7 @@ def __init__(self, config, *, clock=time.monotonic, wall_clock=time.time): self.lock = threading.RLock() self.sessions = OrderedDict() self.failures = OrderedDict() + self._renewed_at = {} self._configured_key = None self._identity = None self._store = config.get("state_store") @@ -141,6 +144,14 @@ def _fingerprint(key): """ return hmac.new(key.encode(), _SESSION_KEY_LABEL, hashlib.sha256).hexdigest() + def _account(self): + control = self.config.get("control_store") + if control is None and self._store is not None: + control = getattr(self._store, "control", None) + if control is None: + return None + return admin_account.load(control) + @staticmethod def _token(value): """Accept only bounded url-safe tokens, rejecting bools and non-strings.""" @@ -179,7 +190,10 @@ def _revoke(self): """Revoke the persisted snapshot; returns False when it could not be cleared.""" if self._store is not None: try: - self._store.delete("sessions") + if self._store._db.in_transaction: + self._store._db.execute("DELETE FROM runtime_state WHERE name='sessions'") + else: + self._store.delete("sessions") except (OSError, ValueError, sqlite3.Error) as error: self._storage_failed(error) return False @@ -269,15 +283,19 @@ def _adopt(self, document, key): now = self.wall_clock() restored = OrderedDict() for sid, item in entries.items(): - if not isinstance(item, dict) or set(item) != {"csrf_token", "expires"}: + if not isinstance(item, dict) or set(item) not in ({"csrf_token", "expires"}, + {"csrf_token", "expires", "remember"}): return False token = self._token(sid) csrf_token = self._token(item["csrf_token"]) expires = self._deadline(item["expires"]) if token is None or csrf_token is None or expires is None: return False + remember = item.get("remember", False) + if type(remember) is not bool: + return False if expires > now: - restored[token] = {"csrf_token": csrf_token, "expires": expires} + restored[token] = {"csrf_token": csrf_token, "expires": expires, "remember": remember} self.sessions.update(restored) if len(restored) != len(entries): # Expired records were dropped: rewrite the snapshot, so a wall clock that @@ -295,11 +313,15 @@ def _persist(self): # A disabled key revokes everywhere; drop the snapshot instead of writing one. return self._revoke() document = {"version": SESSION_FILE_VERSION, "fingerprint": self._fingerprint(self._configured_key), - "sessions": {sid: {"csrf_token": item["csrf_token"], "expires": item["expires"]} + "sessions": {sid: {"csrf_token": item["csrf_token"], "expires": item["expires"], + "remember": bool(item.get("remember", False))} for sid, item in self.sessions.items()}} if self._store is not None: try: - self._store.put("sessions", document) + if self._store._db.in_transaction: + self._store._put("sessions", document) + else: + self._store.put("sessions", document) self._storage_ok() return True except (OSError, ValueError, sqlite3.Error) as error: @@ -335,7 +357,8 @@ def _persist(self): return True def _key(self): - key = self.config.get("api_key") or "" + account = self._account() + key = account.session_epoch if account is not None else self.config.get("api_key") or "" if not isinstance(key, str): key = "" if self._configured_key is None or not hmac.compare_digest(key.encode(), self._configured_key.encode()): @@ -370,6 +393,9 @@ def csrf_enabled(self): """Only an explicitly disabled startup option skips browser-origin protection.""" return self.config.get("admin_csrf", True) is not False + def account_mode(self): + return self._account() is not None + def allowed_origins(self): """Extra trusted browser origins from hot configuration.""" return origin_allowlist(self.config.get("admin_allowed_origins")) @@ -394,55 +420,95 @@ def enabled(self): def check_key(self, candidate): with self.lock: + if self._account() is not None: + return False key = self._key() return bool(key) and isinstance(candidate, str) and hmac.compare_digest(candidate.encode(), key.encode()) def header_identity(self, request): + if self._account() is not None: + return None authorization = request.headers.get("authorization", "") bearer = authorization[7:] if authorization.lower().startswith("bearer ") else "" - with self.lock: - if self.check_key(bearer) or self.check_key(request.headers.get("x-api-key")): - return "key:" + self._identity + if self.check_key(bearer) or self.check_key(request.headers.get("x-api-key")): + return "key:" + self._identity return None + def check_password(self, username, password): + with self.lock: + account = self._account() + if account is None: + return self.check_key(password) + return admin_account.verify(account, username, password) + def session(self, request): sid = request.cookies.get(COOKIE_NAME, "") with self.lock: self._key() item = self.sessions.get(sid) if item and item["expires"] > self.wall_clock(): + self.sessions.move_to_end(sid) return sid, dict(item) if self.sessions.pop(sid, None) is not None: self._persist() return None, None - def login(self, request, key): + def login(self, request, username, password=None, remember=False): + if self._account() is None: + with self.lock: + now = self.clock() + self._key() + for peer, (started, _) in list(self.failures.items()): + if now - started >= 60: + del self.failures[peer] + address = request.client.host if request.client else "unknown" + started, count = self.failures.get(address, (now, 0)) + if count >= 10: + return None, 429 + if not self.check_key(username): + self.failures[address] = (started, count + 1) + self.failures.move_to_end(address) + while len(self.failures) > 1024: + self.failures.popitem(last=False) + return None, 401 + self.failures.pop(address, None) + return self.issue_session(request, remember=False), 200 address = request.client.host if request.client else "unknown" - now = self.clock() - with self.lock: - self._key() - for peer, (started, _) in list(self.failures.items()): - if now - started >= 60: - del self.failures[peer] - started, count = self.failures.get(address, (now, 0)) - if count >= 10: + for attempt in range(2): + with self.lock: + now = self.clock() + self._key() + for peer, (started, _) in list(self.failures.items()): + if now - started >= 60: + del self.failures[peer] + started, count = self.failures.get(address, (now, 0)) + if count >= 10: + return None, 429 + account = self._account() + if account is None: + return None, 503 + try: + valid = admin_account.verify(account, username, password) + except admin_account.AccountBusy: return None, 429 - if not self.check_key(key): - self.failures[address] = (started, count + 1) - self.failures.move_to_end(address) - while len(self.failures) > 1024: - self.failures.popitem(last=False) - return None, 401 - self.failures.pop(address, None) - old = request.cookies.get(COOKIE_NAME) - self.sessions.pop(old, None) - sid = secrets.token_urlsafe(32) - item = {"csrf_token": secrets.token_urlsafe(32), "expires": self.wall_clock() + SESSION_TTL} - self.sessions[sid] = item - while len(self.sessions) > MAX_PERSISTED_SESSIONS: - self.sessions.popitem(last=False) - self._persist() - return (sid, dict(item)), 200 + with self.lock: + control = self.config["control_store"] + # The database lock/transaction also serializes a CLI reset using a different + # connection; the verified epoch and the persisted session are one decision. + with control.state.transaction(): + current = self._account() + if current is None or current.session_epoch != account.session_epoch: + continue + if not valid: + started, count = self.failures.get(address, (started, 0)) + self.failures[address] = (started, count + 1) + self.failures.move_to_end(address) + while len(self.failures) > 1024: + self.failures.popitem(last=False) + return None, 401 + self.failures.pop(address, None) + return self.issue_session(request, remember=remember), 200 + return None, 503 def logout(self, request): """Revoke the cookie's session; returns False when the snapshot could not be updated. @@ -458,6 +524,33 @@ def logout(self, request): if self._persist(): return True self.sessions[sid] = item + self._renewed_at[sid] = self.wall_clock() + return False + + def issue_session(self, request, *, remember=False): + with self.lock: + self._key() + old = request.cookies.get(COOKIE_NAME) + self.sessions.pop(old, None) + sid = secrets.token_urlsafe(32) + item = {"csrf_token": secrets.token_urlsafe(32), + "expires": self.wall_clock() + (400 * 86400 if remember else SESSION_TTL), + "remember": bool(remember)} + self.sessions[sid] = item + while len(self.sessions) > MAX_PERSISTED_SESSIONS: + self.sessions.popitem(last=False) + if not self._persist(): + self.sessions.pop(sid, None) + raise SessionStoreError("无法保存管理会话") + return sid, dict(item) + + def revoke_all(self): + with self.lock: + previous = self.sessions + self.sessions = OrderedDict() + if self._persist(): + return True + self.sessions = previous return False @@ -470,21 +563,25 @@ async def __call__(self, scope, receive, send): return await self.app(scope, receive, send) request = Request(scope, receive) + renewal = None + async def no_cache(message): if message["type"] == "http.response.start": headers = [(k, v) for k, v in message.get("headers", []) if k.lower() not in (b"cache-control", b"pragma", b"expires")] message = {**message, "headers": headers + [(b"cache-control", b"no-store"), (b"pragma", b"no-cache"), (b"expires", b"0")]} + if renewal is not None: + message["headers"].append((b"set-cookie", renewal.encode("latin-1"))) await send(message) try: - enabled = self.auth.enabled() + enabled = self.auth.enabled() and self.auth.account_mode() except SessionStoreError: # The key epoch changed but its superseded snapshot survives. Deny rather than # continue: startup refuses this case, so it is only reachable mid-process. return await error_response( 503, "会话快照无法安全读取或持久化,管理接口已锁定;请检查控制库后重启")(scope, receive, no_cache) if not enabled: - return await error_response(503, "未配置 API key,管理接口已锁定")(scope, receive, no_cache) + return await error_response(503, "管理账户未初始化")(scope, receive, no_cache) path, method = scope["path"], scope["method"] public_session = path == "/admin/session" and method in ("POST", "GET") identity = self.auth.header_identity(request) @@ -499,14 +596,20 @@ async def no_cache(message): return await error_response(403, "Origin 或 CSRF 校验失败")(scope, receive, no_cache) scope.setdefault("state", {}).update(admin_identity=identity or sid, admin_cookie=cookie, admin_session=session) - if cookie: - # Only this /admin ASGI scope receives the header, never the client or inference scope. + if (cookie and session.get("remember") is True and method not in ("DELETE",) + and path != "/admin/account"): + now = self.auth.wall_clock() with self.auth.lock: - key = self.auth._key() - if sid not in self.auth.sessions: - return await error_response(401, "管理会话已失效")(scope, receive, no_cache) - scope = {**scope, "headers": [(k, v) for k, v in scope["headers"] if k.lower() not in (b"authorization", b"x-api-key")] - + [(b"authorization", ("Bearer " + key).encode())]} + last = session["expires"] - 400 * 86400 + if now - last >= 86400: + current = self.auth.sessions.get(sid) + if current is not None: + current["expires"] = now + 400 * 86400 + self.auth._renewed_at[sid] = now + self.auth._persist() + renewal = (f"{COOKIE_NAME}={sid}; Max-Age={400 * 86400}; HttpOnly; " + f"Path=/admin; SameSite=Strict" + + ("; Secure" if scope.get("scheme") == "https" else "")) if self.dispatch and not public_session: response = await self.dispatch(Request(scope, receive)) if response is not None: diff --git a/app/api_keys.py b/app/api_keys.py new file mode 100644 index 0000000..b4cf339 --- /dev/null +++ b/app/api_keys.py @@ -0,0 +1,380 @@ +"""Inference API key storage, principals and admission checks.""" +from __future__ import annotations + +from collections import deque +from contextvars import ContextVar +from dataclasses import dataclass +import hashlib +import hmac +import ipaddress +import json +import math +import re +import secrets +import threading +import time + +from fastapi import HTTPException + +from .observability import client_meta +from .request_context import PATHS +from .settings import parse_networks + + +_ID = re.compile(r"k_[A-Za-z0-9_-]{12}\Z") +_SECRET = re.compile(r"cb-[0-9a-f]{32}\Z") +_NAME = re.compile(r"[^\x00-\x1f\x7f]{1,64}\Z") +_EXTERNAL_ID = "admin-external" +_ANONYMOUS_ID = "anonymous" +api_principal: ContextVar["Principal | None"] = ContextVar("api_principal", default=None) + + +@dataclass(frozen=True) +class Principal: + id: str + name: str + type: str + models: tuple[str, ...] | None = None + rpm: int | None = None + + @property + def admin(self) -> bool: + return self.type == "admin" + + +class ApiKeyError(HTTPException): + def __init__(self, status: int, message: str, code: str, *, headers=None): + super().__init__(status_code=status, detail={"error": { + "message": message, "type": "permission_error" if status == 403 else + "rate_limit_error" if status == 429 else "auth_error", "code": code}}, + headers=headers) + + +def _secret() -> str: + return "cb-" + secrets.token_hex(16) + + +def _hash(secret: str) -> str: + return hashlib.sha256(secret.encode("utf-8")).hexdigest() + + +def _validate_name(value: str) -> str: + if not isinstance(value, str) or _NAME.fullmatch(value) is None or not value.strip(): + raise ValueError("密钥名称必须为 1 至 64 个字符") + return value + + +def _validate_id(value: str) -> str: + if not isinstance(value, str) or _ID.fullmatch(value) is None: + raise ValueError("密钥 ID 无效") + return value + + +def _json_list(value, label: str, *, max_items: int, allow_none=True) -> tuple[str, ...] | None: + if value is None and allow_none: + return None + if isinstance(value, str): + try: + value = json.loads(value) + except (TypeError, ValueError): + raise ValueError(f"{label} 格式无效") from None + if not isinstance(value, (list, tuple)) or len(value) > max_items or any(not isinstance(item, str) or not item for item in value): + raise ValueError(f"{label} 格式无效") + if len(set(value)) != len(value): + raise ValueError(f"{label} 不能重复") + return tuple(value) + + +def _row(row) -> dict: + keys = ("id", "name", "type", "secret", "secret_hash", "prefix", "enabled", + "expires_at", "ip_allow", "rpm", "models", "created_at", "last_used_at", "version") + item = dict(zip(keys, row)) + _validate_id(item["id"]) + _validate_name(item["name"]) + if item["type"] not in ("admin", "standard"): + raise ValueError("密钥类型无效") + if item["secret"] is not None and not _SECRET.fullmatch(item["secret"]): + raise ValueError("密钥明文无效") + if not isinstance(item["secret_hash"], str) or not re.fullmatch(r"[0-9a-f]{64}", item["secret_hash"]): + raise ValueError("密钥 hash 无效") + if not isinstance(item["prefix"], str) or len(item["prefix"]) != 8: + raise ValueError("密钥前缀无效") + if item["type"] == "admin" and item["secret"] is not None: + raise ValueError("管理密钥不能保存明文") + if type(item["enabled"]) is not int or item["enabled"] not in (0, 1): + raise ValueError("密钥启用状态无效") + if item["rpm"] is not None and (type(item["rpm"]) is not int or not 1 <= item["rpm"] <= 100000): + raise ValueError("RPM 必须为 1 至 100000") + item["ip_allow"] = _json_list(item["ip_allow"], "IP 白名单", max_items=64) + item["models"] = _json_list(item["models"], "模型列表", max_items=256) + return item + + +class KeyStore: + def __init__(self, control): + self.control = control + self._lock = threading.RLock() + self._index: dict[str, dict] = {} + self._rows: dict[str, dict] = {} + self._loaded_data_version = None + self._last_data_version_check = 0.0 + self._rpm: dict[str, deque[float]] = {} + self._used: dict[str, float] = {} + self._refresh(force=True) + + def _query_rows(self) -> list[dict]: + rows = self.control._db.execute("SELECT id,name,type,secret,secret_hash,prefix,enabled,expires_at," + "ip_allow,rpm,models,created_at,last_used_at,version FROM api_keys").fetchall() + return [_row(row) for row in rows] + + def _refresh(self, *, force=False) -> None: + now = time.monotonic() + with self._lock: + if not force and now - self._last_data_version_check < 1.0: + return + self._last_data_version_check = now + with self.control._lock: + data_version = self.control._db.execute("PRAGMA data_version").fetchone()[0] + if not force and data_version == self._loaded_data_version: + return + rows = self._query_rows() + self._rows = {item["id"]: item for item in rows} + self._index = {item["secret_hash"]: item for item in rows} + self._loaded_data_version = data_version + + def has_any(self) -> bool: + self._refresh() + return bool(self._rows) + + def _write(self, sql, params=()): + with self.control._lock: + self.control._db.execute("BEGIN IMMEDIATE") + try: + result = self.control._db.execute(sql, params) + self.control._db.execute("COMMIT") + except BaseException: + if self.control._db.in_transaction: + self.control._db.execute("ROLLBACK") + raise + self._refresh(force=True) + return result + + def list(self) -> list[dict]: + self._refresh() + with self._lock: + return [dict(item) for item in self._rows.values()] + + def get(self, key_id: str) -> dict | None: + _validate_id(key_id) + self._refresh() + item = self._rows.get(key_id) + return dict(item) if item else None + + def create(self, *, name: str, kind: str, expires_at=None, ip_allow=None, rpm=None, models=None, + secret: str | None = None, key_id: str | None = None) -> tuple[dict, str]: + _validate_name(name) + if kind not in ("admin", "standard"): + raise ValueError("密钥类型无效") + if kind == "admin" and any(value is not None for value in (expires_at, ip_allow, rpm, models)): + raise ValueError("管理密钥不接受限制字段") + if rpm is not None and (type(rpm) is not int or not 1 <= rpm <= 100000): + raise ValueError("RPM 必须为 1 至 100000") + expires_at = _expires(expires_at) + ips = tuple(parse_networks(ip_allow, limit=64, label="IP 白名单")) if ip_allow is not None else None + model_list = _json_list(models, "模型列表", max_items=256) + secret = _secret() if secret is None else secret + if not _SECRET.fullmatch(secret): + raise ValueError("密钥格式无效") + key_id = key_id or ("k_" + secrets.token_urlsafe(9)) + _validate_id(key_id) + now = time.time() + values = (key_id, name, kind, secret if kind == "standard" else None, _hash(secret), secret[:8], 1, + float(expires_at) if expires_at is not None else None, + json.dumps(list(ips)) if ips is not None else None, rpm, + json.dumps(list(model_list)) if model_list is not None else None, now, None, 1) + try: + self._write("INSERT INTO api_keys(id,name,type,secret,secret_hash,prefix,enabled,expires_at," + "ip_allow,rpm,models,created_at,last_used_at,version) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)", values) + except Exception as error: + if "UNIQUE" in str(error).upper(): + raise ValueError("密钥名称或密钥已存在") from None + raise + return self.get(key_id), secret + + def update(self, key_id: str, version: int, values: dict) -> dict: + item = self.get(key_id) + if item is None: + raise KeyError(key_id) + if type(version) is not int or version != item["version"]: + raise RuntimeError("version_conflict") + if item["type"] == "admin" and any(field in values for field in ("expires_at", "ip_allow", "rpm", "models")): + raise ValueError("管理密钥不接受限制字段") + allowed = {"name", "enabled", "expires_at", "ip_allow", "rpm", "models"} + if set(values) - allowed: + raise ValueError("密钥字段无效") + clean = dict(item) + clean.update(values) + _validate_name(clean["name"]) + if "enabled" in values and type(clean["enabled"]) is not bool: + raise ValueError("enabled 必须为布尔值") + clean["enabled"] = int(clean["enabled"]) + if "expires_at" in values: + clean["expires_at"] = _expires(clean["expires_at"]) + if clean["rpm"] is not None and (type(clean["rpm"]) is not int or not 1 <= clean["rpm"] <= 100000): + raise ValueError("RPM 必须为 1 至 100000") + ips = tuple(parse_networks(clean["ip_allow"], limit=64, label="IP 白名单")) if clean["ip_allow"] is not None else None + models = _json_list(clean["models"], "模型列表", max_items=256) + next_version = version + 1 + params = (clean["name"], clean["enabled"], clean["expires_at"], + json.dumps(list(ips)) if ips is not None else None, clean["rpm"], + json.dumps(list(models)) if models is not None else None, next_version, key_id, version) + with self.control._lock: + self.control._db.execute("BEGIN IMMEDIATE") + try: + changed = self.control._db.execute( + "UPDATE api_keys SET name=?,enabled=?,expires_at=?,ip_allow=?,rpm=?,models=?,version=? " + "WHERE id=? AND version=?", params).rowcount + if changed != 1: + self.control._db.execute("ROLLBACK") + raise RuntimeError("version_conflict") + self.control._db.execute("COMMIT") + except BaseException: + if self.control._db.in_transaction: + self.control._db.execute("ROLLBACK") + raise + self._refresh(force=True) + return self.get(key_id) + + def rotate(self, key_id: str, version: int) -> tuple[dict, str]: + item = self.get(key_id) + if item is None: + raise KeyError(key_id) + if type(version) is not int or version != item["version"]: + raise RuntimeError("version_conflict") + secret = _secret() + with self.control._lock: + self.control._db.execute("BEGIN IMMEDIATE") + try: + changed = self.control._db.execute( + "UPDATE api_keys SET secret=?,secret_hash=?,prefix=?,version=? WHERE id=? AND version=?", + (secret if item["type"] == "standard" else None, _hash(secret), secret[:8], version + 1, key_id, version)).rowcount + if changed != 1: + self.control._db.execute("ROLLBACK") + raise RuntimeError("version_conflict") + self.control._db.execute("COMMIT") + except BaseException: + if self.control._db.in_transaction: + self.control._db.execute("ROLLBACK") + raise + self._refresh(force=True) + return self.get(key_id), secret + + def reveal(self, key_id: str) -> str: + item = self.get(key_id) + if item is None: + raise KeyError(key_id) + if item["type"] != "standard" or not item["secret"]: + raise ValueError("管理密钥只在创建或轮换时显示") + return item["secret"] + + def delete(self, key_id: str, version: int) -> None: + item = self.get(key_id) + if item is None: + raise KeyError(key_id) + if type(version) is not int or version != item["version"]: + raise RuntimeError("version_conflict") + with self.control._lock: + self.control._db.execute("BEGIN IMMEDIATE") + try: + changed = self.control._db.execute("DELETE FROM api_keys WHERE id=? AND version=?", + (key_id, version)).rowcount + if changed != 1: + self.control._db.execute("ROLLBACK") + raise RuntimeError("version_conflict") + self.control._db.execute("COMMIT") + except BaseException: + if self.control._db.in_transaction: + self.control._db.execute("ROLLBACK") + raise + self._refresh(force=True) + + def _consume_rpm(self, item: dict) -> None: + rpm = item["rpm"] + if rpm is None: + return + now = time.monotonic() + with self._lock: + queue = self._rpm.setdefault(item["id"], deque()) + cutoff = now - 60.0 + while queue and queue[0] <= cutoff: + queue.popleft() + if len(queue) >= rpm: + retry = max(1, int((queue[0] + 60.0 - now) + 0.999999)) + raise ApiKeyError(429, "请求频率超过密钥限制,请稍后重试", "rpm_exceeded", + headers={"Retry-After": str(retry)}) + queue.append(now) + + def authenticate(self, scope, config, *, count_rpm=True) -> Principal: + self._refresh() + headers = {key.decode("latin-1").lower(): value.decode("latin-1") + for key, value in scope.get("headers") or []} + authorization = headers.get("authorization", "") + token = authorization[7:].strip() if authorization.lower().startswith("bearer ") else "" + if not token: + token = headers.get("x-api-key", "") + external = config.get("api_key") or "" + has_any = bool(external) or bool(self._rows) + if not has_any: + if config.get("allow_open_noauth"): + return Principal(_ANONYMOUS_ID, "匿名", "admin") + raise ApiKeyError(401, "尚未创建 API key,请登录 WebUI 创建", "api_key_required") + if external and hmac.compare_digest(token.encode(), external.encode()): + return Principal(_EXTERNAL_ID, "外部管理密钥", "admin") + item = self._index.get(_hash(token)) if token else None + if item is None: + raise ApiKeyError(401, "API key 无效", "invalid_api_key") + if external and item["type"] == "admin": + raise ApiKeyError(401, "API key 无效", "invalid_api_key") + if not item["enabled"]: + raise ApiKeyError(401, "API key 已停用", "api_key_disabled") + if item["expires_at"] is not None and float(item["expires_at"]) <= time.time(): + raise ApiKeyError(401, "API key 已过期", "api_key_expired") + if item["ip_allow"]: + ip, _ = client_meta(scope, config.get("trusted_proxies")) + try: + address = ipaddress.ip_address(ip) + except (ValueError, TypeError): + address = None + if address is None or not any(address in ipaddress.ip_network(entry, strict=False) for entry in item["ip_allow"]): + raise ApiKeyError(403, "客户端 IP 不在密钥白名单内", "ip_not_allowed") + if count_rpm and scope.get("method") == "POST" and scope.get("path") in PATHS: + self._consume_rpm(item) + with self._lock: + self._used[item["id"]] = time.time() + return Principal(item["id"], item["name"], item["type"], item["models"], item["rpm"]) + + def flush_used(self): + with self._lock: + pending, self._used = self._used, {} + if not pending: + return + try: + with self.control.state.transaction(): + self.control._db.executemany( + "UPDATE api_keys SET last_used_at=MAX(COALESCE(last_used_at,0),?) WHERE id=?", + [(stamp, key_id) for key_id, stamp in pending.items()]) + self._refresh(force=True) + except BaseException: + with self._lock: + for key_id, stamp in pending.items(): + self._used[key_id] = max(stamp, self._used.get(key_id, 0)) + raise + + +def _expires(value): + if value is None: + return None + if type(value) not in (int, float) or not math.isfinite(value) or value <= time.time(): + raise ValueError("有效期必须晚于当前时间") + return float(value) + diff --git a/app/audit_store.py b/app/audit_store.py index 10ec68f..3663464 100644 --- a/app/audit_store.py +++ b/app/audit_store.py @@ -16,7 +16,7 @@ from typing import Any from urllib.parse import quote -SCHEMA_VERSION = 4 +SCHEMA_VERSION = 5 _CLEANUP_BATCH = 128 _CLEANUP_SECONDS = 0.05 METRICS = ("input_tokens", "output_tokens", "cache_read_tokens", "cache_creation_tokens", @@ -200,7 +200,7 @@ def __init__(self, path, max_bytes=256 * 1024 * 1024, retention_days=30, self._db.execute("BEGIN IMMEDIATE") version = self._db.execute("PRAGMA user_version").fetchone()[0] tables = self._db.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall() - if version not in (0, 1, 2, 3, SCHEMA_VERSION) or (version == 0 and tables): + if version not in (0, 1, 2, 3, 4, SCHEMA_VERSION) or (version == 0 and tables): raise ValueError("unsupported audit schema") self._db.execute("CREATE TABLE IF NOT EXISTS state (id INTEGER PRIMARY KEY CHECK(id=1), epoch INTEGER NOT NULL, detail_generation INTEGER NOT NULL, cleared_at REAL NOT NULL)") self._db.execute("INSERT OR IGNORE INTO state VALUES(1,0,0,0)") @@ -216,6 +216,7 @@ def __init__(self, path, max_bytes=256 * 1024 * 1024, retention_days=30, self._migrate_ingest_time() self._migrate_client_ip() self._migrate_duration() + self._migrate_api_key() self._db.execute(f"PRAGMA user_version={SCHEMA_VERSION}") self._epoch = self._db.execute("SELECT epoch FROM state").fetchone()[0] self._db.execute("COMMIT") @@ -251,6 +252,12 @@ def _migrate_duration(self): self._db.execute("UPDATE requests SET duration_ms=json_extract(payload,'$.duration_ms')") self._db.execute("CREATE INDEX IF NOT EXISTS requests_duration ON requests(started_at,duration_ms)") + def _migrate_api_key(self): + columns = [row[1] for row in self._db.execute("PRAGMA table_info(requests)").fetchall()] + if "api_key" not in columns: + self._db.execute("ALTER TABLE requests ADD COLUMN api_key TEXT") + self._db.execute("CREATE INDEX IF NOT EXISTS requests_api_key ON requests(api_key, started_at)") + def _migrate_attempts(self): # Attempts live only in the request payload; the per-row copy was never read. if not self._db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='attempts'").fetchone(): @@ -423,7 +430,7 @@ def _aggregate(self, db, record): # Unknown model/profile/credential values get no bucket of their own: the global # row still counts the request, and an empty key would rank as "未识别模型". dimensions += [(key, value) - for key in ("model", "profile", "credential") + for key in ("model", "profile", "credential", "api_key") if (value := record.get("public_model" if key == "model" else key))] for table, seconds in (("stats_hourly", 3600), ("stats_daily", 86400), ("stats_totals", 0)): bucket = int(record["started_at"] // seconds) * seconds if seconds else 0 @@ -436,6 +443,10 @@ def _sanitize_record(self, source): result = {key: safe_label(source.get(key)) for key in ("upstream_model", "profile", "credential", "protocol", "error_code", "usage_source")} result["client_ip"] = safe_label(source.get("client_ip")) + api_key = source.get("api_key") + result["api_key"] = api_key if isinstance(api_key, str) and ( + re.fullmatch(r"k_[A-Za-z0-9_-]{12}", api_key) + or api_key in ("admin-external", "anonymous")) else None result["user_agent"] = safe_agent(source.get("user_agent")) result["stream_mode"] = (source.get("stream_mode") if source.get("stream_mode") in ("compatible", "realtime") else None) @@ -491,10 +502,10 @@ def commit(db): if keep: payload = json.dumps(data, separators=(",", ":")) db.execute( - "INSERT INTO requests(id,started_at,model,profile,credential,outcome,status_code,payload,logical_bytes,client_ip,duration_ms) VALUES(?,?,?,?,?,?,?,?,?,?,?)", + "INSERT INTO requests(id,started_at,model,profile,credential,outcome,status_code,payload,logical_bytes,client_ip,duration_ms,api_key) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", (data["id"], data["started_at"], data["model"], data["profile"], data["credential"], data["outcome"], data["status_code"], payload, - len(payload.encode()) + 256, data["client_ip"], data["duration_ms"])) + len(payload.encode()) + 256, data["client_ip"], data["duration_ms"], data["api_key"])) self._prune() return {"ok": True, "recorded": True, "details": bool(keep and db.execute("SELECT 1 FROM requests WHERE id=?", (data["id"],)).fetchone())} return self._run(commit, {"ok": False, "recorded": False, "reason": "storage_failure"}, write=True, dropped=True) @@ -608,7 +619,7 @@ def list_records(self, kind="request", limit=50, cursor=None, **filters): clauses.append("(started_at=?", (start,)).fetchall() + for row in rows: + target = result.setdefault(row[0], {"requests": 0, "success": 0, "error": 0, + "total_tokens": None, "credit": None}) + stats = json.loads(row[1]) + for key in ("requests", "success", "error"): + target[key] += stats.get(key) or 0 + for key in ("total_tokens", "credit"): + value = stats.get(key) + if isinstance(value, (int, float)): + target[key] = (target[key] or 0) + value + return {"degraded": False, "items": result} + + return self._read_run(fetch, {"degraded": True, "items": {}}) + def credit_samples(self, since): """Per-request upstream charges with their token counts, oldest first. @@ -883,7 +918,7 @@ def step(callback, write=False): last = (0.0, "") while True: rows = step(lambda last=last: self._db.execute( - "SELECT id,started_at,model,profile,credential,outcome,status_code,payload,client_ip " + "SELECT id,started_at,model,profile,credential,outcome,status_code,payload,client_ip,api_key " "FROM requests WHERE started_at(?,?) " "ORDER BY started_at,id LIMIT 2000", (cutoff, *last)).fetchall()) @@ -893,7 +928,8 @@ def step(callback, write=False): handle.write(json.dumps( {"kind": "request", "id": row[0], "started_at": row[1], "model": row[2], "profile": row[3], "credential": row[4], "outcome": row[5], - "status_code": row[6], "payload": json.loads(row[7]), "client_ip": row[8]}, + "status_code": row[6], "payload": json.loads(row[7]), "client_ip": row[8], + "api_key": row[9]}, ensure_ascii=False) + "\n") request_ids.append(row[0]) last = (rows[-1][1], rows[-1][0]) diff --git a/app/config_migration.py b/app/config_migration.py index 040b78d..bb6a89d 100644 --- a/app/config_migration.py +++ b/app/config_migration.py @@ -24,8 +24,8 @@ MAX_ENV_BYTES = 256 * 1024 LEASE_STALE_S = 90 # Three heartbeat intervals (see converter.LEASE_INTERVAL_S) SQLITE_HEADER = b"SQLite format 3\x00" -CONTROL_TABLES = ("control", "runtime_state", "gateway_secrets") -SUPPORTED_VERSION = 2 +CONTROL_TABLES = ("control", "runtime_state", "gateway_secrets", "api_keys") +SUPPORTED_VERSION = 3 class MigrationError(ValueError): diff --git a/app/control_store.py b/app/control_store.py index 2f3fc44..d806f4a 100644 --- a/app/control_store.py +++ b/app/control_store.py @@ -103,7 +103,7 @@ def validate_model(source, rule, models=None, known_models=(), *, legacy_scopes= class ControlStore: - SCHEMA_VERSION = 2 + SCHEMA_VERSION = 3 def __init__(self, path): path = Path(path) @@ -122,7 +122,7 @@ def __init__(self, path): self._db.execute("CREATE TABLE control (id INTEGER PRIMARY KEY CHECK(id=1), revision INTEGER NOT NULL, payload TEXT NOT NULL)") self._db.execute("INSERT INTO control VALUES (1,0,?)", (json.dumps({"settings": {}, "models": {}, "credentials": {}}),)) self._db.execute("PRAGMA user_version=1") - elif version not in (1, self.SCHEMA_VERSION): + elif version not in (1, 2, self.SCHEMA_VERSION): raise ValueError("管理数据库 schema 不受支持或已有库为空,未执行初始化") self._snapshot = self._load() self._db.execute("CREATE TABLE IF NOT EXISTS buddy_bootstrap (" @@ -158,10 +158,21 @@ def __init__(self, path): self._db.execute("CREATE TABLE IF NOT EXISTS runtime_state (name TEXT PRIMARY KEY, payload TEXT NOT NULL)") self._db.execute("CREATE TABLE IF NOT EXISTS state_imports (name TEXT PRIMARY KEY, imported INTEGER NOT NULL, migrated_at REAL NOT NULL)") self._db.execute("CREATE TABLE IF NOT EXISTS gateway_secrets (name TEXT PRIMARY KEY, value TEXT NOT NULL, announced INTEGER NOT NULL DEFAULT 0 CHECK(announced IN (0,1)))") + self._db.execute("CREATE TABLE IF NOT EXISTS api_keys (" + "id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, " + "type TEXT NOT NULL CHECK(type IN ('admin','standard')), " + "secret TEXT, secret_hash TEXT NOT NULL UNIQUE, prefix TEXT NOT NULL, " + "enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0,1)), " + "expires_at REAL, ip_allow TEXT, rpm INTEGER, models TEXT, " + "created_at REAL NOT NULL, last_used_at REAL, " + "version INTEGER NOT NULL DEFAULT 1)") + self._db.execute("CREATE UNIQUE INDEX IF NOT EXISTS api_keys_single_admin " + "ON api_keys(type) WHERE type='admin'") + self._validate_api_keys_schema() # Running-instance lease; heartbeat writes bypass the settings revision on purpose. self._db.execute("CREATE TABLE IF NOT EXISTS service_lease (id INTEGER PRIMARY KEY CHECK(id=1), " "pid INTEGER NOT NULL, started_at REAL NOT NULL, heartbeat_at REAL NOT NULL)") - self._db.execute("PRAGMA user_version=2") + self._db.execute("PRAGMA user_version=3") self._db.execute("COMMIT") except Exception: if self._db.in_transaction: @@ -171,6 +182,17 @@ def __init__(self, path): from .state_store import StateStore self.state = StateStore(self) + def _validate_api_keys_schema(self): + expected = {"id", "name", "type", "secret", "secret_hash", "prefix", "enabled", + "expires_at", "ip_allow", "rpm", "models", "created_at", "last_used_at", "version"} + columns = {row[1] for row in self._db.execute("PRAGMA table_info(api_keys)")} + if columns != expected: + raise ValueError("管理数据库 api_keys 表结构不匹配,请从备份恢复") + indexes = {row[1]: row for row in self._db.execute("PRAGMA index_list(api_keys)")} + index = indexes.get("api_keys_single_admin") + if index is None or not index[2]: + raise ValueError("管理数据库 api_keys 索引结构不匹配,请从备份恢复") + def _load(self): row = self._db.execute("SELECT revision,payload FROM control WHERE id=1").fetchone() if row is None: diff --git a/app/gateway_management.py b/app/gateway_management.py index 9f48809..aa79393 100644 --- a/app/gateway_management.py +++ b/app/gateway_management.py @@ -446,7 +446,7 @@ def admin_apply_settings(self, values): def install_pages(app, directory): """Fallback only applies to pages; management/client APIs never serve HTML.""" root = Path(directory).resolve() - pages = {"", "models", "credentials", "logs", "media", "storage", "settings", "updates", "login"} + pages = {"", "models", "credentials", "keys", "logs", "media", "storage", "settings", "updates", "login"} # Release detail pages carry the tag, whose dots would otherwise read as a file suffix. release_page = re.compile(r"updates/v\d+\.\d+\.\d+").fullmatch assets = StaticFiles(directory=root / "assets", check_dir=False) diff --git a/app/inference_auth.py b/app/inference_auth.py index 6680f52..78b927e 100644 --- a/app/inference_auth.py +++ b/app/inference_auth.py @@ -7,8 +7,20 @@ from starlette.responses import JSONResponse from .inbound_limits import _BODY_PATHS, anthropic_path +from .api_keys import ApiKeyError, api_principal + +# Anthropic error types: https://platform.claude.com/docs/en/api/errors +_ANTHROPIC_ERROR_TYPES = { + "auth_error": "authentication_error", + "permission_error": "permission_error", + "rate_limit_error": "rate_limit_error", + "invalid_request_error": "invalid_request_error", + "not_found_error": "not_found_error", + "upstream_error": "api_error", +} + def require_api_key(key, authorization=None, x_api_key=None): """Preserve Bearer precedence, X-Api-Key fallback, and optional empty keys.""" if not key: @@ -28,16 +40,38 @@ def __init__(self, app, config): self.config = config async def __call__(self, scope, receive, send): + path = scope.get("path", "") if (scope["type"] != "http" or scope.get("method") != "POST" - or scope.get("path", "") not in _BODY_PATHS): + or path not in _BODY_PATHS): return await self.app(scope, receive, send) headers = Headers(scope=scope) + key_store = self.config.get("key_store") + if key_store is None: + try: + require_api_key(self.config.get("api_key"), headers.get("authorization"), headers.get("x-api-key")) + except HTTPException as error: + payload = error.detail + if anthropic_path(scope["path"]): + payload = {"type": "error", "error": {**payload["error"], "type": "authentication_error"}} + response = JSONResponse(payload, status_code=error.status_code, headers=error.headers) + return await response(scope, receive, send) + await self.app(scope, receive, send) + return try: - require_api_key(self.config.get("api_key"), headers.get("authorization"), headers.get("x-api-key")) - except HTTPException as error: + principal = key_store.authenticate(scope, self.config, + count_rpm=scope.get("method") == "POST") + except ApiKeyError as error: payload = error.detail if anthropic_path(scope["path"]): - payload = {"type": "error", "error": {**payload["error"], "type": "authentication_error"}} + error_type = payload["error"]["type"] + error_type = _ANTHROPIC_ERROR_TYPES.get(error_type, error_type) + payload = {"type": "error", "error": {**payload["error"], "type": error_type}} response = JSONResponse(payload, status_code=error.status_code, headers=error.headers) return await response(scope, receive, send) - await self.app(scope, receive, send) + state = scope.setdefault("state", {}) + state["api_principal"] = principal + token = api_principal.set(principal) + try: + await self.app(scope, receive, send) + finally: + api_principal.reset(token) diff --git a/app/media_store.py b/app/media_store.py index c45801e..d11c02a 100644 --- a/app/media_store.py +++ b/app/media_store.py @@ -18,7 +18,7 @@ from pathlib import Path DB_NAME = "media.sqlite3" -SCHEMA_VERSION = 2 # v2 keeps the submitted public model name on video tasks. +SCHEMA_VERSION = 3 # v3 records the API key that owns each video task. DEFAULT_PAGE_SIZE = 50 MAX_PAGE_SIZE = 200 INCOMING_DIR = ".incoming" @@ -75,9 +75,13 @@ def _row_to_asset(row) -> dict: return dict(zip(keys, row)) +_TASK_COLUMNS = ("task_id,account_key,profile,model,created,updated,status,media_id,error,credit," + "public_model,api_key") + + def _row_to_task(row) -> dict: keys = ("task_id", "account_key", "profile", "model", "created", "updated", - "status", "media_id", "error", "credit", "public_model") + "status", "media_id", "error", "credit", "public_model", "api_key") return dict(zip(keys, row)) @@ -108,8 +112,9 @@ def _create_schema(self): CREATE TABLE IF NOT EXISTS tasks( task_id TEXT PRIMARY KEY, account_key TEXT NOT NULL, profile TEXT NOT NULL, model TEXT, created REAL NOT NULL, updated REAL NOT NULL, status TEXT NOT NULL, - media_id TEXT, error TEXT, credit REAL, public_model TEXT); + media_id TEXT, error TEXT, credit REAL, public_model TEXT, api_key TEXT); CREATE INDEX IF NOT EXISTS tasks_created ON tasks(created); + CREATE INDEX IF NOT EXISTS tasks_api_key ON tasks(api_key, created); """) def _migrate_schema(self): @@ -124,6 +129,9 @@ def _migrate_schema(self): columns = {row[1] for row in self._db.execute("PRAGMA table_info(tasks)").fetchall()} if "public_model" not in columns: self._db.execute("ALTER TABLE tasks ADD COLUMN public_model TEXT") + if "api_key" not in columns: + self._db.execute("ALTER TABLE tasks ADD COLUMN api_key TEXT") + self._db.execute("CREATE INDEX IF NOT EXISTS tasks_api_key ON tasks(api_key, created)") self._db.execute(f"PRAGMA user_version={SCHEMA_VERSION}") self._db.commit() def close(self): @@ -319,19 +327,19 @@ def cleanup(self, *, ttl_hours: float, max_total_bytes: int) -> dict: # -- video tasks -------------------------------------------------------------------- def create_task(self, task_id: str, account_key: str, profile: str, model, - public_model=None) -> dict | None: + public_model=None, *, api_key=None) -> dict | None: with self._lock: now = time.time() self._db.execute( "INSERT OR IGNORE INTO tasks(task_id,account_key,profile,model,created,updated," - "status,media_id,error,credit,public_model) VALUES(?,?,?,?,?,?,?,?,?,?,?)", + "status,media_id,error,credit,public_model,api_key) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", (task_id, account_key, profile, model, now, now, "queued", None, None, None, - public_model or model)) + public_model or model, api_key)) self._db.commit() return self.get_task(task_id) def get_task(self, task_id: str): with self._lock: - row = self._db.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone() + row = self._db.execute(f"SELECT {_TASK_COLUMNS} FROM tasks WHERE task_id=?", (task_id,)).fetchone() return _row_to_task(row) if row else None def update_task(self, task_id: str, *, status=None, media_id=None, error=UNSET, @@ -351,7 +359,7 @@ def update_task(self, task_id: str, *, status=None, media_id=None, error=UNSET, task_id)) self._db.commit() - def list_tasks(self, status=None, cursor=None, limit=DEFAULT_PAGE_SIZE) -> dict: + def list_tasks(self, status=None, cursor=None, limit=DEFAULT_PAGE_SIZE, *, api_key=None) -> dict: limit = max(1, min(int(limit), MAX_PAGE_SIZE)) clauses, params = [], [] if status: @@ -362,6 +370,9 @@ def list_tasks(self, status=None, cursor=None, limit=DEFAULT_PAGE_SIZE) -> dict: params.append(status) else: raise ValueError("invalid task status") + if api_key is not None: + clauses.append("api_key=?") + params.append(api_key) if cursor: try: stamp, row_id = json.loads(cursor) @@ -374,7 +385,7 @@ def list_tasks(self, status=None, cursor=None, limit=DEFAULT_PAGE_SIZE) -> dict: where = (" WHERE " + " AND ".join(clauses)) if clauses else "" with self._lock: rows = self._db.execute( - f"SELECT * FROM tasks{where} ORDER BY created DESC, task_id DESC LIMIT ?", + f"SELECT {_TASK_COLUMNS} FROM tasks{where} ORDER BY created DESC, task_id DESC LIMIT ?", (*params, limit + 1)).fetchall() more = len(rows) > limit rows = rows[:limit] diff --git a/app/model_policy.py b/app/model_policy.py index e21bee8..ad20cf8 100644 --- a/app/model_policy.py +++ b/app/model_policy.py @@ -5,9 +5,14 @@ from app import growth_tasks, perf_metrics, travel from app.model_capabilities import route_metadata +from app.api_keys import api_principal _request_policy = ContextVar("gateway_model_policy", default=None) +def _principal(): + return api_principal.get() + + class PolicyScopeMiddleware: def __init__(self, app): self.app = app @@ -119,6 +124,24 @@ def rule_for(config, source, policy=None): return {**default_rule(source), **_policy(config, policy)["models"].get(source, {})} +def source_for(config, name): + """Resolve a public model name or automatic alias to its policy source.""" + if name == "default-model": + return "auto" + data = snapshot(config) + for source, rule in data.get("models", {}).items(): + if rule.get("public_id", source) == name: + return source + if rule.get("keep_original") and source == name: + return source + return name + + + +def _restriction_source(source): + """Use the canonical automatic-model source for key restrictions.""" + return "auto" if source == "default-model" else source + def _deny(message, code="model_disabled"): raise HTTPException(status_code=404, detail={"error": { "message": message, "type": "invalid_request_error", "param": "model", "code": code}}) @@ -143,7 +166,12 @@ def resolve(config, public_model): if not isinstance(public_model, str) or not public_model.strip(): raise HTTPException(status_code=400, detail={"error": {"message": "model must be a non-empty string"}}) state = _request_policy.get() + principal = _principal() if state and public_model in (state.get("upstream"), state.get("public_model")): + if principal is not None and principal.models is not None and _restriction_source(state.get("source")) not in principal.models: + raise HTTPException(status_code=403, detail={"error": { + "message": "模型不在当前 API key 的允许列表中", "type": "permission_error", + "code": "model_not_allowed", "param": "model"}}) if rule_for(config, state["source"]) != state["rule"]: _deny("模型策略已变更,请重新请求", "model_policy_changed") return state["upstream"] @@ -151,6 +179,10 @@ def resolve(config, public_model): source = next((key for key, value in data["models"].items() if value.get("public_id", key) == public_model), public_model) rule = {**default_rule(source), **data["models"].get(source, {})} + if principal is not None and principal.models is not None and _restriction_source(source) not in principal.models: + raise HTTPException(status_code=403, detail={"error": { + "message": "模型不在当前 API key 的允许列表中", "type": "permission_error", + "code": "model_not_allowed", "param": "model"}}) if not rule["enabled"]: _deny("模型已停用") if source == public_model and (rule["custom"] or (rule["public_id"] != source and not rule["keep_original"])): diff --git a/app/observability.py b/app/observability.py index 353b312..8d2e657 100644 --- a/app/observability.py +++ b/app/observability.py @@ -463,9 +463,11 @@ async def lifespan_receive(): await self.app(scope, receive, send) return started, monotonic_start = time.time(), time.monotonic() + principal = (scope.get("state") or {}).get("api_principal") observation = _Observation({"id": ensure_context(scope, self.config).request_id, "epoch": -1, "detail_generation": -1, "started_at": started, "protocol": PATHS[scope["path"]], + "api_key": getattr(principal, "id", None), **{key: None for key in METRICS}}, monotonic_start) observation.record.update(zip(("client_ip", "user_agent"), client_meta(scope, self.config.get("trusted_proxies") diff --git a/app/runtime_management.py b/app/runtime_management.py index b4460f4..570945f 100644 --- a/app/runtime_management.py +++ b/app/runtime_management.py @@ -3,10 +3,12 @@ import os import sqlite3 import sys +import threading from pathlib import Path from . import buddy from .admin_api import install_admin +from .api_keys import KeyStore from .audit_store import AuditStore from .backups import Backups from .control_store import ControlStore @@ -49,6 +51,9 @@ def dashboard(self, days=30, granularity="auto"): def list_records(self, *args, **kwargs): return {"items": [], "next_cursor": None, "has_more": False, "degraded": True} + def api_key_stats(self, *args, **kwargs): + return {"degraded": True, "items": {}} + def get_request(self, id): return None @@ -88,15 +93,24 @@ def initialize(gateway, args, argv=None, *, parser=None, dotenv_keys=()): key = options[matches[0]].dest if len(matches) == 1 else flag[2:].replace("-", "_") explicit.add(aliases.get(key, key)) apply_persisted_settings(config, explicit=explicit) - from .startup import resolve_startup_key + from .startup import allows_open_noauth, resolve_startup_auth try: - resolve_startup_key(config, args, dotenv_keys) + resolve_startup_auth(config, args, dotenv_keys) except BaseException: control.close() raise for key in SCHEMA: if hasattr(args, key) and key != "api_key": setattr(args, key, config[key]) + config["allow_open_noauth"] = allows_open_noauth() + config["key_store"] = KeyStore(control) + if config.get("api_key") or config["key_store"].has_any(): + if config["allow_open_noauth"]: + print("[auth] 已存在 API key,OPEN_NOAUTH 不生效", file=sys.stderr) + elif config["allow_open_noauth"]: + print("[auth] 推理接口与账号池额度(billing)匿名开放", file=sys.stderr) + else: + print("[auth] 尚未创建 API key,请登录 WebUI 创建", file=sys.stderr) config["trial_ledger"] = gateway.trial_rewards.TrialLedger(store=control.state) try: config["audit_store"] = AuditStore(root / "logs.sqlite3", max_bytes=config["audit_max_bytes"], @@ -133,7 +147,30 @@ def install(gateway): def close(config): + stop = config.get("key_housekeeping_stop") + if stop is not None: + stop.set() + thread = config.get("key_housekeeping_thread") + if thread is not None and thread.is_alive(): + thread.join(timeout=5) + if config.get("key_store") is not None: + config["key_store"].flush_used() for key in ("audit_store", "control_store", "media_store"): store = config.get(key) if store is not None: store.close() + + +def start_key_housekeeping(config): + stop = threading.Event() + + def run(): + while not stop.wait(60): + try: + config["key_store"].flush_used() + except (OSError, ValueError, sqlite3.Error): + print("[auth] 密钥最后使用时间暂时无法保存", file=sys.stderr) + + thread = threading.Thread(target=run, daemon=True, name="api-key-housekeeping") + config.update(key_housekeeping_stop=stop, key_housekeeping_thread=thread) + thread.start() diff --git a/app/settings.py b/app/settings.py index 49a9085..6025add 100644 --- a/app/settings.py +++ b/app/settings.py @@ -50,21 +50,29 @@ def normalize_update_mirror(value): return value if value.endswith("/") else value + "/" -def normalize_trusted_proxies(value): - """Normalize a comma/space separated list of trusted proxy IPs or CIDRs.""" +def parse_networks(entries, *, limit, label): + """Normalize network entries while retaining caller-specific limits.""" import ipaddress - entries = [entry for entry in re.split(r"[\s,]+", value.strip()) if entry] - if len(entries) > 32: - raise ValueError("trusted_proxies: 代理数量超出上限") + if isinstance(entries, str): + entries = [entry for entry in re.split(r"[\s,]+", entries.strip()) if entry] + if not isinstance(entries, (list, tuple)) or len(entries) > limit: + raise ValueError("trusted_proxies: 代理数量超出上限" if label == "trusted_proxies" + else f"{label}最多 {limit} 条") normalized = [] for entry in entries: try: network = str(ipaddress.ip_network(entry, strict=False)) except ValueError: - raise ValueError(f"trusted_proxies: 地址无效 {entry!r}") from None + raise ValueError(f"trusted_proxies: 地址无效 {entry!r}" if label == "trusted_proxies" + else f"{label}第 {len(normalized) + 1} 条无效") from None if network not in normalized: normalized.append(network) - return ",".join(normalized) + return normalized + + +def normalize_trusted_proxies(value): + """Normalize a comma/space separated list of trusted proxy IPs or CIDRs.""" + return ",".join(parse_networks(value, limit=32, label="trusted_proxies")) def validate_projection_max_bytes(value): @@ -98,7 +106,7 @@ def _item(default, type_, label, *, mode="hot", env=None, minimum=None, maximum= SCHEMA = { "host": _item("127.0.0.1", "string", "监听地址", mode="restart", env="CODEBUDDY2API_BIND", env_scope="deployment"), "port": _item(8787, "integer", "监听端口", mode="restart", env="CODEBUDDY2API_PORT", minimum=1, maximum=65535, env_scope="deployment"), - "api_key": _item(None, "secret", "管理与推理密钥", mode="startup", env="CODEBUDDY2API_KEY", sensitive=True, env_scope="deployment"), + "api_key": _item(None, "secret", "管理密钥(仅推理)", mode="startup", env="CODEBUDDY2API_KEY", sensitive=True, env_scope="deployment"), "auth_file": _item(None, "paths", "显式凭证文件", mode="startup", sensitive=True, env_scope="deployment"), "auth_dir": _item(None, "path", "凭证目录", mode="startup", env="CODEBUDDY_AUTH_DIR", sensitive=True, env_scope="deployment"), "import_dir": _item(None, "path", "导入目录", mode="startup", env="CODEBUDDY_IMPORT_DIR", sensitive=True, env_scope="deployment"), diff --git a/app/startup.py b/app/startup.py index 0dae359..0194a6c 100644 --- a/app/startup.py +++ b/app/startup.py @@ -1,12 +1,17 @@ -"""Resolve optional environment files and announce a saved default key once.""" +"""Resolve startup environment and initialize the management account.""" from __future__ import annotations import io +import hashlib +import json import os +import re +import secrets import signal import subprocess import sys import threading +import time from pathlib import Path import stat @@ -15,6 +20,7 @@ import uvicorn from .settings import SCHEMA +from . import admin_account def load_startup_env(path=None): @@ -62,45 +68,111 @@ def allows_open_noauth(): return os.environ.get("CODEBUDDY2API_ALLOW_OPEN_NOAUTH", "").lower() in ("1", "true", "yes") -def resolve_startup_key(config, args, dotenv_keys=()): +def _external_key(config, args, dotenv_keys=()): sources = config["settings_sources"] for name, spec in SCHEMA.items(): if sources.get(name) == "environment" and spec["env"] in dotenv_keys: sources[name] = "dotenv" - config["announce_default_key"] = False - if sources.get("api_key") in ("cli", "environment", "dotenv"): - return - store = config["state_store"] - key, pending = store.default_key() - if key is None and allows_open_noauth(): - return - if key is None and config["host"] not in ("127.0.0.1", "::1", "localhost"): - raise ValueError("非回环监听请显式设置 API key;默认密钥仅在本地首次启动时生成") - if key is None or pending: - try: - with terminal_stream(): - pass - except OSError: - raise ValueError("首次显示默认 API key 需要交互终端;后台运行请显式配置 CODEBUDDY2API_KEY") from None - if key is None: - key, pending = store.default_key(create=True) - config["api_key"] = args.api_key = key - sources["api_key"] = "generated" - config["announce_default_key"] = pending - - -def announce_default_key(config): - if not config.get("announce_default_key"): + source = sources.get("api_key") + value = config.get("api_key") if source in ("cli", "environment", "dotenv") else "" + value = value.strip() if isinstance(value, str) else "" + if not value: + config["api_key"] = "" + args.api_key = "" + if source in ("cli", "environment", "dotenv"): + variable = "CODEBUDDY2API_KEY" if source != "cli" else "--api-key" + print(f"[auth] {variable} 为空,按未设置处理", file=sys.stderr) + sources["api_key"] = "default" + return "" + config["api_key"] = args.api_key = value + return value + + +def _announce_admin_password(password, *, username="admin"): + message = f"\n管理账户:{username}\n初始管理密码:{password}\n请登录后尽快修改密码。\n" + try: + with terminal_stream() as terminal: + terminal.write(message) + terminal.flush() return - with terminal_stream() as terminal: - key = config["api_key"] - with config["state_store"].claim_announcement(key) as claimed: - if claimed: - terminal.write(f"\n默认 API key:{key}\n已保存到 control.sqlite3,仅显示这一次;管理登录与 API 请求共用。\n") - terminal.flush() - config["announce_default_key"] = False + except OSError: + pass + try: + sys.stdout.write(message) + sys.stdout.flush() + except BaseException: + raise ValueError("无法输出初始管理密码") from None +def _account_row(control): + row = control._db.execute( + "SELECT value,announced FROM gateway_secrets WHERE name='admin_account'").fetchone() + if row is None: + return None + if row[1] != 1: + raise ValueError("管理账户公告状态无效") + try: + return admin_account._validate_document(json.loads(row[0])) + except (ValueError, TypeError, KeyError, json.JSONDecodeError) as error: + raise ValueError("管理账户数据无效") from error + + +def _legacy_key_row(control): + row = control._db.execute( + "SELECT value,announced FROM gateway_secrets WHERE name='default_api_key'").fetchone() + if row is None: + return None + if not isinstance(row[0], str) or not re.fullmatch(r"cb-[0-9a-f]{32}", row[0]) or row[1] not in (0, 1): + raise ValueError("Invalid saved API key; restore the control database") + return row[0], bool(row[1]) + + +def resolve_startup_auth(config, args, dotenv_keys=()): + """Initialize auth atomically; disclose generated passwords before committing them.""" + external = _external_key(config, args, dotenv_keys) + control = config["control_store"] + with control.state.transaction(): + account = admin_account.load(control) + marker = control._db.execute( + "SELECT 1 FROM state_imports WHERE name='admin_auth_v1'").fetchone() + legacy = _legacy_key_row(control) if marker is None else None + if marker is not None and account is None: + raise ValueError("管理账户迁移记录存在但账户缺失") + if account is None: + if external: + account = admin_account.new("admin", external, "external") + elif legacy: + if not legacy[1]: + _announce_admin_password(legacy[0]) + account = admin_account.new("admin", legacy[0], "migrated") + else: + password = secrets.token_urlsafe(12) + _announce_admin_password(password) + account = admin_account.new("admin", password, "generated") + elif account.password_source != "user": + if external: + if admin_account.verify(account, account.username, external): + account = admin_account.Account(account.username, "external", account.password, + account.session_epoch, account.changed_at) + else: + account = admin_account.with_password(account, external, source="external") + elif account.password_source == "external": + password = secrets.token_urlsafe(12) + _announce_admin_password(password, username=account.username) + account = admin_account.with_password(account, password, source="generated") + if legacy and not external: + secret = legacy[0] + control._db.execute( + "INSERT INTO api_keys(id,name,type,secret,secret_hash,prefix,created_at) " + "VALUES(?,?,'admin',NULL,?,?,?)", + ("k_" + secrets.token_urlsafe(9), "默认密钥", + hashlib.sha256(secret.encode()).hexdigest(), secret[:8], time.time())) + admin_account.save(control, account) + if marker is None: + control._db.execute("DELETE FROM gateway_secrets WHERE name='default_api_key'") + control._db.execute( + "INSERT INTO state_imports(name,imported,migrated_at) VALUES('admin_auth_v1',?,?)", + (int(legacy is not None), time.time())) def _install_lease_release(config): @@ -127,23 +199,9 @@ def release(signum, _frame): def run_server(app, config, *, host, port): _install_lease_release(config) - server_class = uvicorn.Server - if config.get("announce_default_key"): - class FirstStartServer(uvicorn.Server): - async def startup(self, sockets=None): - await super().startup(sockets=sockets) - if self.started and not self.should_exit: - try: - announce_default_key(config) - except BaseException: - # A failed announcement/commit must not leave a hidden-key server running. - self.should_exit = True - await self.shutdown(sockets=sockets) - raise - server_class = FirstStartServer # Same behavior as uvicorn.run, but keeps the server so a restart can stop it gracefully. - server = server_class(uvicorn.Config(app, host=host, port=port, log_level="warning")) + server = uvicorn.Server(uvicorn.Config(app, host=host, port=port, log_level="warning")) config["server"] = server try: server.run() diff --git a/app/state_store.py b/app/state_store.py index c7f4366..b02f3d0 100644 --- a/app/state_store.py +++ b/app/state_store.py @@ -91,10 +91,11 @@ def _check_document(name, value): raise ValueError("Invalid session snapshot") for sid, row in value[field].items(): if (not isinstance(sid, str) or not _TOKEN.fullmatch(sid) or not isinstance(row, dict) - or set(row) != {"csrf_token", "expires"} + or set(row) not in ({"csrf_token", "expires"}, {"csrf_token", "expires", "remember"}) or not isinstance(row.get("csrf_token"), str) or not _TOKEN.fullmatch(row["csrf_token"]) or type(row.get("expires")) not in (int, float) - or not math.isfinite(row["expires"]) or not 0 < row["expires"] < 1e11): + or not math.isfinite(row["expires"]) or not 0 < row["expires"] < 1e11 + or "remember" in row and type(row["remember"]) is not bool): raise ValueError("Invalid persisted session") elif name == "trial": from .trial_rewards import _key, _timestamp, _safe_result, _result, _RECORD_FIELDS, _RESULT_FIELDS, _MAX_ACCOUNTS diff --git a/benchmarks/bench_routing.py b/benchmarks/bench_routing.py index 41f193a..fc9d847 100644 --- a/benchmarks/bench_routing.py +++ b/benchmarks/bench_routing.py @@ -104,6 +104,7 @@ def run(converter, credits_mod, perf_metrics, count, repeat, model): pool = build(converter, credits_mod, count, root) rows = [] try: + control = None rows.append(measure(converter, perf_metrics, "pick", lambda: pool.pick("session", model), repeat)) rows.append(measure(converter, perf_metrics, "headers_for", lambda: pool.headers_for(None, model), repeat)) @@ -141,7 +142,23 @@ def saturated(): rows.append(measure(converter, perf_metrics, "current_models", converter.current_models, repeat)) rows.append(measure(converter, perf_metrics, "current_model_details", converter.current_model_details, repeat)) rows.append(measure(converter, perf_metrics, "pick + revision churn", churned, repeat)) + from app.api_keys import KeyStore + from app.control_store import ControlStore + control = ControlStore(root / "control.sqlite3") + keys = KeyStore(control) + _, secret = keys.create(name="benchmark", kind="standard", rpm=100000) + auth_config = {"api_key": "", "allow_open_noauth": False, "trusted_proxies": ""} + auth_scope = {"type": "http", "method": "POST", "path": "/v1/chat/completions", + "headers": [(b"authorization", f"Bearer {secret}".encode())], + "client": ("127.0.0.1", 1234)} + + def authenticated(): + for _ in range(1000): + keys.authenticate(auth_scope, auth_config, count_rpm=True) + rows.append(measure(converter, perf_metrics, "api key auth x1000", authenticated, repeat)) finally: + if control is not None: + control.close() temporary.cleanup() converter.CONFIG.update({"cred_pool": None, "ledger": None, "account_catalogs": None, "catalog_index": None, "model_catalogs": {}, diff --git a/converter.py b/converter.py index 9dff131..5b35027 100644 --- a/converter.py +++ b/converter.py @@ -6,6 +6,7 @@ import argparse import asyncio +import getpass import weakref import hashlib import itertools @@ -95,7 +96,8 @@ def apply_system_prompt_mode(body, profile, mode): from app.catalog_index import account_scope as _catalog_account_scope from app import model_rates from app.rate_measurement import RateBook -from app.inference_auth import require_api_key +from app.inference_auth import _ANTHROPIC_ERROR_TYPES, require_api_key +from app.api_keys import ApiKeyError, api_principal from app.admin_auth import SessionStoreError from app.content_filter import ContentFilterDetector, is_filter_error from app.upstream_compat import (channel_fallback_url, is_channel_check_error, normalize_tool_schemas, @@ -103,7 +105,7 @@ def apply_system_prompt_mode(body, profile, mode): from app.request_limits import ImageLimitError, apply_image_policy from app.safe_logging import format_log_body, sanitize_log_text from app import perf_metrics -from app.startup import allows_open_noauth, capture_launch, load_startup_env, restart_process, run_server +from app.startup import capture_launch, load_startup_env, restart_process, run_server from app.site_routing import (DOMESTIC, INTERNATIONAL, PROFILE_ENDPOINTS, site_for_auth, site_for_headers, profile_for_auth, profile_for_headers, profile_region, profile_product, profile_site, chat_url_for_headers, search_url_for_headers, fetch_url_for_headers, @@ -2843,14 +2845,6 @@ def _housekeeper_loop(pool: CredentialPool, ledger) -> None: app = FastAPI(title="codebuddy2api", version=APP_VERSION, lifespan=inference_lifespan) app.add_middleware(InferenceResourcesMiddleware, config=lambda: CONFIG) -# Anthropic error types: https://platform.claude.com/docs/en/api/errors -_ANTHROPIC_ERROR_TYPES = { - "auth_error": "authentication_error", - "rate_limit_error": "rate_limit_error", - "invalid_request_error": "invalid_request_error", - "not_found_error": "not_found_error", - "upstream_error": "api_error", -} @app.exception_handler(HTTPException) @@ -2944,15 +2938,39 @@ def _truncate(s: str, n: int = 80) -> str: return s[:n] + ("…" if len(s) > n else "") -def _check_auth(authorization: Optional[str], x_api_key: Optional[str]): - require_api_key(CONFIG["api_key"], authorization, x_api_key) +def _check_auth(request: Request): + if not isinstance(request, Request): + if CONFIG.get("key_store") is not None: + # Direct function calls in offline tests have no ASGI scope. Keep the + # installed gateway fail-closed rather than inventing a header value. + raise ApiKeyError(401, "API key 无效", "invalid_api_key") + require_api_key(CONFIG["api_key"], None, None) + return None + principal = getattr(request.state, "api_principal", None) + if principal is None: + key_store = CONFIG.get("key_store") + if key_store is not None: + principal = key_store.authenticate(request.scope, CONFIG, count_rpm=False) + request.scope.setdefault("state", {})["api_principal"] = principal + api_principal.set(principal) + else: + require_api_key(CONFIG["api_key"], request.headers.get("authorization"), + request.headers.get("x-api-key")) + return None + return principal -def _check_admin_auth(authorization: Optional[str], x_api_key: Optional[str]): +def _check_admin_auth(request): + if isinstance(request, Request): + identity = getattr(request.state, "admin_identity", None) + if identity: + return identity + raise HTTPException(status_code=503, detail={"error": { + "message": "管理会话未初始化", "type": "management_locked"}}) if not CONFIG.get("api_key"): raise HTTPException(status_code=503, detail={"error": {"message": "管理接口需要配置 API key", "type": "management_locked"}}) - _check_auth(authorization, x_api_key) + require_api_key(CONFIG["api_key"], request, None) def _apply_session_headers(headers: dict, profile, skey: str | None, context) -> None: @@ -3091,10 +3109,11 @@ def health(): @app.get("/admin/credentials") -def admin_list_credentials(authorization: Optional[str] = Header(default=None), +def admin_list_credentials(request: Request, + authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): """Return account expiry, health and session-binding metadata.""" - _check_admin_auth(authorization, x_api_key) + _check_admin_auth(request) pool = CONFIG.get("cred_pool") if CONFIG.get("management") is not None: return {"credentials": CONFIG["management"].admin_credential_inventory()} @@ -3146,7 +3165,7 @@ async def admin_add_credential(request: Request, authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): """Validate and atomically import credentials from the controlled directory.""" - _check_admin_auth(authorization, x_api_key) + _check_admin_auth(request) try: body = await request.json() except (ValueError, UnicodeError): @@ -3184,12 +3203,12 @@ async def admin_add_credential(request: Request, @app.delete("/admin/credentials/{name}") -def admin_del_credential(name: str, +def admin_del_credential(name: str, request: Request, unbind: bool = False, authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): """Delete the named .info file, unbinding model rules only after removal succeeds.""" - _check_admin_auth(authorization, x_api_key) + _check_admin_auth(request) pool = CONFIG.get("cred_pool") rollback = {} identity = None @@ -3241,11 +3260,11 @@ def _save_oauth_credential(cred: dict) -> Path: @app.post("/admin/oauth/start") -def admin_oauth_start(site: str = "cn", +def admin_oauth_start(request: Request, site: str = "cn", authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): """Start OAuth and return the browser authorization URL.""" - _check_admin_auth(authorization, x_api_key) + _check_admin_auth(request) try: return _OAUTH.start(site=site) except ValueError as e: @@ -3255,11 +3274,11 @@ def admin_oauth_start(site: str = "cn", @app.get("/admin/oauth/poll") -def admin_oauth_poll(login_id: str = "", +def admin_oauth_poll(request: Request, login_id: str = "", authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): """Poll OAuth and persist completed logins with pool reload.""" - _check_admin_auth(authorization, x_api_key) + _check_admin_auth(request) try: r = _OAUTH.poll(login_id) except Exception as e: @@ -3280,28 +3299,28 @@ def admin_oauth_poll(login_id: str = "", return {"done": True, "uid": uid, "nickname": r.get("nickname") or "", "imported": str(target)} @app.get("/admin/credits") -def admin_credits(authorization: Optional[str] = Header(default=None), +def admin_credits(request: Request, authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): """Return cached balances, credit expiry segments and check-in status.""" - _check_admin_auth(authorization, x_api_key) + _check_admin_auth(request) ledger = CONFIG.get("ledger") return {"credits": ledger.snapshot() if ledger else {}} @app.get("/admin/model-blocks") -def admin_model_blocks(authorization: Optional[str] = Header(default=None), +def admin_model_blocks(request: Request, authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): """Return unsupported backend/model pairs and their retry deadlines.""" - _check_admin_auth(authorization, x_api_key) + _check_admin_auth(request) pool = CONFIG.get("cred_pool") return {"model_blocks": pool.model_blocks_detail() if pool is not None else []} @app.post("/admin/checkin") -def admin_checkin(authorization: Optional[str] = Header(default=None), +def admin_checkin(request: Request, authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): """Run daily-idempotent manual check-in without implicitly syncing balances or usage.""" - _check_admin_auth(authorization, x_api_key) + _check_admin_auth(request) return _admin_credential_action("checkin") @@ -3311,9 +3330,9 @@ def _admin_credential_action(action, identity=None, *, consent_revision=None): @app.post("/admin/sync") -def admin_sync(authorization: Optional[str] = Header(default=None), +def admin_sync(request: Request, authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): - _check_admin_auth(authorization, x_api_key) + _check_admin_auth(request) return _admin_credential_action("sync") @@ -3321,7 +3340,7 @@ def admin_sync(authorization: Optional[str] = Header(default=None), async def admin_credential_action(identity: str, action: str, request: Request, authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): - _check_admin_auth(authorization, x_api_key) + _check_admin_auth(request) from app.admin_api import _body try: body = await _body(request, 4096, allow_empty=True) @@ -3395,9 +3414,13 @@ def _billing_totals() -> dict: @app.get("/v1/dashboard/billing/subscription") def billing_subscription(authorization: Optional[str] = Header(default=None), - x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): + x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key"), + request: Request = None): """Expose subscription estimates with balance equal to the hard limit minus usage.""" - _check_auth(authorization, x_api_key) + principal = _check_auth(request) + if principal is not None and principal.type not in ("admin", "anonymous"): + raise HTTPException(status_code=403, detail={"error": { + "message": "普通 API key 无权访问计费接口", "type": "permission_error", "code": "admin_key_required"}}) t = _billing_totals() limit = t["quota_usd"] return { @@ -3424,9 +3447,13 @@ def billing_subscription(authorization: Optional[str] = Header(default=None), @app.get("/v1/dashboard/billing/usage") def billing_usage(start_date: Optional[str] = None, end_date: Optional[str] = None, authorization: Optional[str] = Header(default=None), - x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): + x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key"), + request: Request = None): """Return estimated usage in cents, with daily model costs for the last 30 days.""" - _check_auth(authorization, x_api_key) + principal = _check_auth(request) + if principal is not None and principal.type not in ("admin", "anonymous"): + raise HTTPException(status_code=403, detail={"error": { + "message": "普通 API key 无权访问计费接口", "type": "permission_error", "code": "admin_key_required"}}) t = _billing_totals() # Convert each region at its own rate before combining daily and total usage. cents = {"domestic": t["price_cny"] / t["rate"] * 100, "international": t["price_usd"] * 100} @@ -3992,15 +4019,17 @@ def guard_model(name: str, *, region=None, resolved=False) -> None: @app.get("/v1/models") -def list_models(authorization: Optional[str] = Header(default=None), +def list_models(request: Request, authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): - _check_auth(authorization, x_api_key) + principal = _check_auth(request) data = [{"id": item["id"], "object": "model", "created": 1700000000, "owned_by": "codebuddy", "credits": item["credits"], "credits_by_profile": item["credits_by_profile"], **{key: item[key] for key in ("credits_estimated", "capabilities", "limits", "metadata_by_profile", "media_kinds", "media_sources", "media_defaults") if key in item}} - for item in model_policy.public_details(sys.modules[__name__])] + for item in model_policy.public_details(sys.modules[__name__]) + if principal is None or principal.models is None + or model_policy.source_for(CONFIG, item["id"]) in principal.models] return {"object": "list", "data": data} @@ -4008,7 +4037,7 @@ def list_models(authorization: Optional[str] = Header(default=None), async def chat_completions(request: Request, authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): - _check_auth(authorization, x_api_key) + _check_auth(request) # Select sticky credentials while building upstream headers. try: @@ -5481,7 +5510,7 @@ async def _media_save(kind: str, data: bytes, *, model, rid: str) -> dict: raise MediaDownloadRetryable(f"媒体存储写入失败:{type(error).__name__}") from None -async def _media_video_refresh(task_id: str, rid: str) -> dict: +async def _media_video_refresh(task_id: str, rid: str, *, principal=None) -> dict: """Query one upstream video task on its bound account and persist the result. One refresh lock per task keeps concurrent queries from downloading twice; the @@ -5497,6 +5526,9 @@ async def load(): return task task = await load() + if principal is not None and principal.type == "standard" and task.get("api_key") != principal.id: + raise HTTPException(status_code=404, detail={"error": { + "message": "视频任务不存在或已过期", "type": "not_found_error", "code": "task_not_found"}}) if task["status"] in ("completed", "failed"): return task # Repeated queries keep returning the same resource or error. async with _media_task_lock(task_id): @@ -5725,7 +5757,7 @@ async def create_response(request: Request, authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): """Serve Responses requests through the shared Chat upstream and event adapter.""" - _check_auth(authorization, x_api_key) + _check_auth(request) try: payload = await request.json() @@ -5992,7 +6024,7 @@ async def create_message(request: Request, authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): """Serve Anthropic Messages through the shared Chat upstream and event adapter.""" - _check_auth(authorization, x_api_key) + _check_auth(request) try: payload = await request.json() @@ -6041,7 +6073,7 @@ async def web_search_endpoint(request: Request, authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): """Serve generic REST web searches; returns the upstream result items unchanged.""" - _check_auth(authorization, x_api_key) + _check_auth(request) try: payload = await request.json() except Exception as e: @@ -6066,7 +6098,7 @@ async def web_fetch_endpoint(request: Request, authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): """Serve generic REST web fetches; returns the upstream content fields unchanged.""" - _check_auth(authorization, x_api_key) + _check_auth(request) try: payload = await request.json() except Exception as e: @@ -6114,7 +6146,7 @@ async def images_generations(request: Request, authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): """Generate images through the upstream media endpoints.""" - _check_auth(authorization, x_api_key) + _check_auth(request) payload = await _media_json(request) requested_model = payload.get("model") or media_generation.DEFAULT_IMAGE_MODEL model = model_policy.resolve(CONFIG, requested_model) @@ -6151,7 +6183,7 @@ async def images_edits(request: Request, authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): """Edit images through the upstream media endpoints; JSON body only, no multipart.""" - _check_auth(authorization, x_api_key) + _check_auth(request) payload = await _media_json(request) requested_model = payload.get("model") or media_generation.DEFAULT_IMAGE_EDIT_MODEL model = model_policy.resolve(CONFIG, requested_model) @@ -6198,7 +6230,7 @@ async def create_video(request: Request, authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): """Submit one asynchronous video generation task.""" - _check_auth(authorization, x_api_key) + principal = _check_auth(request) payload = await _media_json(request) requested_model = payload.get("model") or media_generation.DEFAULT_VIDEO_MODEL model = model_policy.resolve(CONFIG, requested_model) @@ -6230,18 +6262,19 @@ async def create_video(request: Request, task_id = await _media_call(MEDIA_VIDEO_SUBMIT, body, rid, model=model, parse=media_generation.parse_video_submit, lease_note=note) task = await run_in_threadpool(store.create_task, task_id, note.get("credential"), - note.get("profile"), model, public_model=requested_model) + note.get("profile"), model, public_model=requested_model, + api_key=getattr(principal, "id", None)) return {"id": task_id, "object": "video", "status": "queued", "model": requested_model, "created_at": int((task or {}).get("created") or time.time())} @app.get("/v1/videos/{task_id}") -async def get_video(task_id: str, +async def get_video(task_id: str, request: Request, authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): """Report one video task, refreshing in-flight state from its bound account.""" - _check_auth(authorization, x_api_key) - task = await _media_video_refresh(task_id, _request_id()) + principal = _check_auth(request) + task = await _media_video_refresh(task_id, _request_id(), principal=principal) meta = None if task["status"] == "completed" and task["media_id"]: meta = await run_in_threadpool(_media_store().get_asset, task["media_id"]) @@ -6249,16 +6282,16 @@ async def get_video(task_id: str, @app.get("/v1/videos/{task_id}/content") -async def get_video_content(task_id: str, +async def get_video_content(task_id: str, request: Request, authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): """Redirect to the public media URL of a finished video.""" - _check_auth(authorization, x_api_key) + principal = _check_auth(request) + task = await _media_video_refresh(task_id, _request_id(), principal=principal) if not _media_base_url(): raise HTTPException(status_code=409, detail={"error": { "message": "未配置资源访问地址(media_public_base_url):视频已保存到网关本地,配置后即可获取链接", "type": "invalid_request_error", "code": "media_base_url_missing"}}) - task = await _media_video_refresh(task_id, _request_id()) meta = None if task["status"] == "completed" and task["media_id"]: meta = await run_in_threadpool(_media_store().get_asset, task["media_id"]) @@ -6302,7 +6335,7 @@ async def mcp_endpoint(request: Request, authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): """Serve the stateless MCP streamable-HTTP surface exposing the web_search and web_fetch tools.""" - _check_auth(authorization, x_api_key) + _check_auth(request) origin = request.headers.get("origin") if origin: # 只有浏览器会带 Origin(CSRF/DNS rebinding 防线);MCP 客户端从不带。 @@ -6342,7 +6375,7 @@ async def count_tokens(request: Request, authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): """Return heuristic token estimates for Anthropic request budgeting.""" - _check_auth(authorization, x_api_key) + _check_auth(request) try: payload = await request.json() except Exception as e: @@ -6614,6 +6647,45 @@ def config_command(args) -> int: return 0 +def admin_reset_password(args) -> int: + """Reset the single management account without starting the gateway.""" + from app.admin_account import load, save, validate_password, with_password, with_username + from app.control_store import ControlStore + root = Path(args.data_dir) if args.data_dir else managed_auth_dir() + control = ControlStore(root / "control.sqlite3") + try: + account = load(control) + if account is None: + raise ValueError("管理账户未初始化,请先启动一次服务") + try: + stdin_is_tty = sys.stdin.isatty() + except (AttributeError, OSError): + stdin_is_tty = False + if not stdin_is_tty: + raise ValueError("重置密码需要交互终端") + first = getpass.getpass("新管理密码:") + second = getpass.getpass("再次输入新管理密码:") + if first != second: + raise ValueError("两次输入的密码不一致") + target_username = args.username or account.username + validate_password(first, target_username) + updated = with_username(account, target_username) if args.username else account + updated = with_password(updated, first) + with control._lock: + control._db.execute("BEGIN IMMEDIATE") + try: + save(control, updated) + control._db.execute("COMMIT") + except BaseException: + if control._db.in_transaction: + control._db.execute("ROLLBACK") + raise + print("管理密码已重置,现有会话将在下一次请求时失效。") + return 0 + finally: + control.close() + + def main(): launch = capture_launch() # Before .env loading, so a restart re-reads .env as a fresh start would. offline_config = _is_config_invocation(sys.argv[1:]) @@ -6622,9 +6694,9 @@ def main(): else: dotenv_keys = load_startup_env() ap = argparse.ArgumentParser(description="CodeBuddy -> OpenAI 兼容转换器(直连后端)") - ap.add_argument("command", nargs="?", choices=("serve", "login", "config"), default="serve", + ap.add_argument("command", nargs="?", choices=("serve", "login", "config", "admin"), default="serve", help="serve 启动服务(默认);login 扫码登录;config 离线配置迁移(migrate/status)") - ap.add_argument("action", nargs="?", choices=("migrate", "status"), + ap.add_argument("action", nargs="?", choices=("migrate", "status", "reset-password"), help="config 子命令:migrate 写入配置库;status 只读查看解析结果") ap.add_argument("--env-file", metavar="PATH", help="config 读取的 env 文件;默认当前目录 .env") @@ -6640,6 +6712,7 @@ def main(): help="config migrate 在检测到运行中实例时仍然写入(危险)") ap.add_argument("--lang", choices=("auto", "zh", "en"), help="界面与本次 CLI 输出语言;带该参数启动会把语言写入配置库(config status 只读)") + ap.add_argument("--username", metavar="NAME", help="admin reset-password 同时设置新的用户名") ap.add_argument("--site", choices=tuple(auth_oauth.SITE_HOSTS), default="cn", help="login 站点:cn 国内站(默认);intl 国际 WorkBuddy;intl-codebuddy 国际 CodeBuddy") ap.add_argument("--no-browser", action="store_true", @@ -6752,6 +6825,13 @@ def main(): ap.error("CODEBUDDY2API_IMAGE_POLICY 必须为 truncate 或 error") if args.command == "config": return config_command(args) + if args.command == "admin": + if args.action != "reset-password": + ap.error("admin 子命令只能使用 reset-password") + try: + return admin_reset_password(args) + except (OSError, ValueError) as error: + ap.error(str(error)) if args.command == "login": _persist_cli_language(args.lang) return login(site=args.site, open_browser=not args.no_browser) @@ -6778,12 +6858,9 @@ def main(): # The running-instance lease is claimed with the other background threads, after # catalog and usage hydration, so an offline tool never races the first write. control_store = CONFIG.get("control_store") - # Validate effective binding and authentication before credential scans or background work. - if (args.host not in ("127.0.0.1", "::1", "localhost") and not CONFIG.get("api_key") - and not allows_open_noauth()): - runtime_management.close(CONFIG) - ap.error("非回环绑定且未设置 API key 会匿名开放推理额度;" - "请设置 CODEBUDDY2API_KEY,或确知风险后以 CODEBUDDY2API_ALLOW_OPEN_NOAUTH=true 显式放行") + # Authentication is enforced per request. A missing key locks inference (or enables the + # explicit OPEN_NOAUTH compatibility mode) without preventing the management account from + # starting on a remote bind. files = [Path(p) for p in args.auth_file] if not files: seed_credentials() # Seed missing desktop credentials into managed storage. @@ -6813,6 +6890,7 @@ def main(): runtime_management.close(CONFIG) ap.error(str(error)) model_rates.refresh(sys.modules[__name__]) # Audit and control stores are open from here on. + runtime_management.start_key_housekeeping(CONFIG) if control_store is not None: control_store.claim_service_lease() _persist_cli_language(args.lang, control_store, CONFIG) diff --git a/docs/advanced.md b/docs/advanced.md index 83b9382..9eac581 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -6,7 +6,7 @@ Use the [WebUI](webui.md) for everyday management. See [deployment](deployment.m ## Configuration and CLI -Precedence: **explicit CLI flags > process environment > `.env` > saved SQLite settings > defaults**. Hot settings apply immediately; restart-marked settings require a manual restart. Change locked options at their source; the WebUI does not edit `.env` or expose API keys. +Precedence: **explicit CLI flags > process environment > `.env` > saved SQLite settings > defaults**. Hot settings apply immediately; restart-marked settings require a manual restart. Change locked options at their source; the WebUI does not edit `.env`. Standard keys can be revealed, while admin keys are stored only as hashes. Compose loads every key in `.env` into the container, so commenting out a line is enough to unlock it; only `CODEBUDDY_AUTH_DIR` and `CODEBUDDY2API_ALLOW_OPEN_NOAUTH` are fixed by the Compose file. Recreate the container after changing these values. @@ -17,7 +17,7 @@ Runtime keys in `.env` stay read-only in the WebUI until they are moved into the | Flag | Default | Description | |------|---------|-------------| | `--host` / `--port` | `127.0.0.1` / `8787` | Local listener | -| `--api-key` | saved default | Shared management/inference key; first local interactive startup generates and saves one if absent; explicit empty locks management | +| `--api-key` | unset | External admin inference key and initial WebUI password; blank means unset, and database keys are never promoted to it | | `--admin-csrf [true/false]` | `true` | Startup-only management Origin/CSRF checks; disabling does not bypass API-key or session authentication | | `--admin-allowed-origins` | none | Extra trusted management Origins (comma-separated; bare domains mean HTTPS) for reverse-proxy sign-in; hot and WebUI-editable | | `--auth-file` | scan `auth/` | Explicit credential file, repeatable; disables scanning other files | @@ -181,6 +181,9 @@ Trial credits are manual-only for eligible `intl-work` accounts through the cred | `GET /admin/credentials/{id}/growth-tasks` · `POST .../growth-tasks/auto` · `.../auto-all` · `GET /admin/growth-jobs` · `POST /admin/credentials/growth-batch` | Live growth task list; start a background job for one task or every enabled one; current and last job per account; batch task switches for selected accounts | | `POST /admin/oauth/start` · `GET /admin/oauth/poll` | Start/poll login; `site=cn` (default), `intl` (international WorkBuddy) or `intl-codebuddy` (international CodeBuddy) | | `GET /admin/credits` · `POST /admin/checkin` | Inspect credits; daily-idempotent check-in followed by domestic travel when enabled | +| `POST` / `GET` / `DELETE /admin/session` · `DELETE /admin/sessions` | Password login, session inspection/logout and revoke-all; remember-me sessions renew with the same SID | +| `GET` / `PUT /admin/account` | Read or change the management username/password; account changes rotate the session epoch and CSRF token | +| `GET` / `POST /admin/keys` · `PATCH` / `DELETE /admin/keys/{id}` · `POST .../rotate` · `POST .../reveal` | Create and manage inference keys; writes use optimistic versions, standard keys support reveal, and admin secrets are one-time only | | `POST /admin/sync` | Synchronize all enabled accounts' balances, catalogs and usage; no check-in or trial claims | | `POST /admin/credentials/{id}/{action}` | Single-account `refresh`, `checkin`, `sync`, `travel-status` (query only), `travel` (claim then dispatch), `trial` (one-time trial credits), or `reset-cooldown` (local-only) | @@ -190,7 +193,13 @@ Travel results include `phase`, optional safe `error_kind`/`http_status`/`code`, Pages use `/dashboard/*`, management APIs use `/admin/*`, and clients retain `/v1/*`. `/cn` and `/intl` API prefixes are not registered. Automatic model routing requires no client URL changes. -Management requires an API key. The WebUI exchanges that key for an HttpOnly management Cookie, which only authorizes `/admin/*`, not `/v1/*`. API clients send `Authorization: Bearer ` or `X-Api-Key`. An empty key preserves legacy unauthenticated inference only, not management. `/health` never exposes account, path or exception details. +**`OPEN_NOAUTH` anonymous mode exposes both inference and account-pool billing (`/v1/dashboard/billing/*`).** It applies only when there is no external key and no `api_keys` row, accepts missing or placeholder tokens, and must never be used for public deployments. Any key row, including disabled or expired rows, disables anonymous mode. + +Management uses a username and password, never an API key. The HttpOnly Cookie authorizes only `/admin/*`; inference keys authorize only `/v1/*`. A normal login lasts 12 hours; **Remember me** lasts 400 days with sliding renewal. Account changes and **Sign out all sessions** revoke every session. + +The external `CODEBUDDY2API_KEY` is the initial password and an unrestricted admin inference key. A legacy default key migrates to the initial password and a database admin key. Changing the password decouples login from later external-key changes. If password recovery is needed, run `converter.py admin reset-password` interactively. + +Standard keys support expiry, IP/CIDR allowlists, RPM and source-model restrictions; they can be revealed and are stored in plaintext. Admin keys are unrestricted, limited to one, and stored only as hashes. Standard keys cannot read billing or access another key's video tasks. Public `/v1/media/...` assets remain available until media TTL expiry even after their creating key is revoked. ### Management Origin / CSRF switch @@ -307,7 +316,9 @@ Readable history is kept in `reasoning_content`, never ordinary answer text. Res ## Deployment exposure and credential intake -- Compose maps loopback by default. Without a configured or saved key, `CODEBUDDY2API_ALLOW_OPEN_NOAUTH=true` explicitly permits headless/non-loopback inference without generating a key; management stays locked. The shipped image sets this compatibility opt-in, so configure `CODEBUDDY2API_KEY` before exposing it. The opt-in never disables an existing key. +- Compose maps loopback by default. Configure an external key or create a database key before exposing the port; do not rely on anonymous mode for public access. +- **With no external or database keys, `CODEBUDDY2API_ALLOW_OPEN_NOAUTH=true` anonymously exposes inference and account-pool billing, with or without a placeholder token.** Any key row forces authentication; with no keys and the option off, every inference request returns 401 `api_key_required`. +- Run one gateway process per data directory. Other-process key changes become visible within about one second, but RPM counters and `last_used_at` aggregation are process-local. Backups contain standard keys in plaintext and must be protected like credentials. - When a key is configured, generation and token-count POSTs verify request headers before buffering bodies or reserving inference capacity; invalid keys return 401 even while generation slots are full. Other routes retain their existing authentication and routing behavior. - Credential imports/uploads persist the normalized form (token aliases folded into the canonical fields); strict JSON parsing rejects NaN/Infinity, and `expiresAt`/`lastRefreshTime` must be plausible finite millisecond timestamps. @@ -322,8 +333,8 @@ Readable history is kept in `reasoning_content`, never ordinary answer text. Res | Symptom | Behavior / action | |---------|-------------------| -| Cannot sign in to WebUI | Configure an API key; sign in and restart unfinished OAuth after changing it. Behind an HTTPS reverse proxy, trust the public origin via `admin_allowed_origins` (see above) | -| Local 401 | Client key differs from the gateway key | +| Cannot sign in to WebUI | Use the initial or current admin password, or run `converter.py admin reset-password`. Behind an HTTPS reverse proxy, trust the public origin via `admin_allowed_origins` (see above) | +| Local 401 / 403 / 429 | Check that the inference key exists and is enabled and unexpired, then inspect its IP/model allowlist and RPM; authentication and permission failures use protocol-specific error envelopes | | Upstream 401 / 403 | Credential-level authentication circuit opens; inspect and log in again in the WebUI | | 429 | Cool down that upstream model on the credential; later requests rebind automatically. All candidates cooling down still returns 429; with `--failover-max` the in-flight request is replayed on another credential instead | | Channel rejection (11128) | One same-account `/console` retry when available; a second channel refusal may move system instructions into a user message and retry once on the same account. A final refusal returns without cooling or replaying any credential. Search/MCP have no console detour and remain usable | @@ -345,4 +356,4 @@ Readable history is kept in `reasoning_content`, never ordinary answer text. Res ## Downgrades and rollback -Feature switches hold no hidden state: disabling a guard or mode stops it for new requests, and reverting source restores previous behavior. The exceptions are persisted settings and automation state: `control.sqlite3` stores WebUI settings, model rules and reward reservations, and older code rejects unknown fields. Before downgrading source, remove newly added startup options, stop the service, back up the **current** data directory, and use the narrowly scoped offline `settings.` removal procedure above with a revision increment and integrity check. Do not restore a pre-upgrade database or mix WAL/SHM generations: doing so could roll back newer claims, sessions, revocations and account state. Rollback never undoes completed upstream check-ins, claims or travel dispatches. +Feature switches hold no hidden state: disabling a guard or mode stops it for new requests, and reverting source restores previous behavior. The exceptions are persisted settings and automation state: `control.sqlite3` stores WebUI settings, model rules and reward reservations, and older code rejects unknown fields. Control schema v3 (with `api_keys`), audit schema v5 and media schema v3 are not readable by 1.4.x; downgrade requires restoring a pre-upgrade backup while stopped. For older schema-only changes, remove newly added startup options and use the narrowly scoped offline `settings.` removal procedure above with a revision increment and integrity check. Do not restore a stale pre-upgrade database or mix WAL/SHM generations: doing so could roll back newer claims, sessions, revocations and account state. Rollback never undoes completed upstream check-ins, claims or travel dispatches. diff --git a/docs/advanced.zh-CN.md b/docs/advanced.zh-CN.md index 4112e97..8fbf3f1 100644 --- a/docs/advanced.zh-CN.md +++ b/docs/advanced.zh-CN.md @@ -6,7 +6,7 @@ ## 配置与 CLI -配置优先级:**显式 CLI 参数 > 进程环境变量 > `.env` > SQLite 保存值 > 默认值**。热更新设置立即生效,标记为重启的项需手动重启;锁定项请在对应来源修改,WebUI 不改写 `.env` 或返回 API key 明文。 +配置优先级:**显式 CLI 参数 > 进程环境变量 > `.env` > SQLite 保存值 > 默认值**。热更新设置立即生效,标记为重启的项需手动重启;锁定项请在对应来源修改,WebUI 不改写 `.env`;普通密钥可再次查看,管理密钥只保存哈希。 Compose 会加载 `.env` 中的每个键,注释掉一行即可解除锁定;只有 `CODEBUDDY_AUTH_DIR` 和 `CODEBUDDY2API_ALLOW_OPEN_NOAUTH` 由 Compose 文件固定。修改这些值后重建容器。 @@ -17,7 +17,7 @@ Compose 会加载 `.env` 中的每个键,注释掉一行即可解除锁定; | 参数 | 默认值 | 说明 | |------|--------|------| | `--host` / `--port` | `127.0.0.1` / `8787` | 本地监听地址与端口 | -| `--api-key` | 已保存的默认 key | 管理与推理共用;缺失时首次本地交互启动生成并保存;显式空值锁定管理 | +| `--api-key` | 未设置 | 外部管理推理密钥及初始 WebUI 密码;空白按未设置处理,数据库密钥不会提升为该值 | | `--admin-csrf [true/false]` | `true` | 管理 Origin/CSRF 校验;仅启动配置可关闭,API key 和会话鉴权不变 | | `--admin-allowed-origins` | 无 | 额外信任的管理页来源(逗号分隔,裸域名按 HTTPS),用于反代登录;热生效,可在 WebUI 配置 | | `--auth-file` | 扫描 `auth/` | 指定凭据文件,可重复传入;不再扫描其他文件 | @@ -181,6 +181,9 @@ scoped 模式可选传入 `X-Codebuddy-Session-ID`、`metadata.conversation_id` | `PATCH /admin/credentials/{id}` | 按账号设置单个布尔字段(`enabled`、`auto_checkin`、`auto_travel`、`auto_daily_chat` 或 `auto_growth`)或 `growth_auto` 任务码映射;保存自动任务开关不立即领取 | | `GET /admin/credentials/{id}/growth-tasks` · `POST .../growth-tasks/auto` · `.../auto-all` · `GET /admin/growth-jobs` · `POST /admin/credentials/growth-batch` | 实时成长任务列表;以后台任务执行单任务或全部已开任务;各账号当前与最近任务;为已选账号批量设置任务开关 | | `POST /admin/oauth/start` · `GET /admin/oauth/poll` | 发起与轮询登录;`site=cn`(默认)、`intl`(国际 WorkBuddy)或 `intl-codebuddy`(国际 CodeBuddy) | +| `POST` / `GET` / `DELETE /admin/session` · `DELETE /admin/sessions` | 密码登录、会话查看/退出及全部撤销;记住我会话用同一 SID 滑动续期 | +| `GET` / `PUT /admin/account` | 查看或修改管理用户名/密码;修改后更换会话 epoch 和 CSRF token | +| `GET` / `POST /admin/keys` · `PATCH` / `DELETE /admin/keys/{id}` · `POST .../rotate` · `POST .../reveal` | 创建和管理推理密钥;写操作使用乐观版本,普通密钥可查看,管理密钥只显示一次 | | `GET /admin/credits` · `POST /admin/checkin` | 查询额度;按日幂等签到,国内按开关继续旅行 | | `POST /admin/sync` | 同步全部启用账号的余额、目录和用量,不签到、不领取试用 | | `POST /admin/credentials/{id}/{action}` | 单账号 `refresh`、`checkin`、`sync`、`travel-status`(仅查询)、`travel`(领取后派出)、`trial`(一次性体验积分)或 `reset-cooldown`(仅本地状态) | @@ -191,7 +194,13 @@ scoped 模式可选传入 `X-Codebuddy-Session-ID`、`metadata.conversation_id` 页面使用 `/dashboard/*`,管理 API 使用 `/admin/*`,客户端保留原 `/v1/*`;不注册 `/cn`、`/intl` API 前缀。模型自动选路不要求客户端改变地址。 -管理必须配置 API key;WebUI 使用同 key 建立 HttpOnly 管理 Cookie,Cookie 仅授权 `/admin/*`,不能用于 `/v1/*`。命令行 API 请求携带 `Authorization: Bearer ` 或 `X-Api-Key`。空 key 仅保留推理接口的历史无鉴权行为,不开放管理;`/health` 不返回账号、路径或异常详情。 +**`OPEN_NOAUTH` 匿名模式会同时开放推理和账号池 billing(`/v1/dashboard/billing/*`)。** 仅在没有任何外部密钥和 `api_keys` 记录时生效,缺失或占位 token 都会放行,公网部署绝不可使用。只要存在任意密钥记录,包括停用或过期记录,匿名模式即失效。 + +管理端使用用户名和密码,任何 API key 都不能登录管理接口。HttpOnly Cookie 仅授权 `/admin/*`,推理密钥仅授权 `/v1/*`。普通登录有效期 12 小时;「记住我」有效期 400 天并滑动续期。修改账户或「退出所有会话」会撤销所有会话。 + +外部 `CODEBUDDY2API_KEY` 是初始密码和不受限的管理推理密钥。旧默认 key 会迁移为初始密码和数据库管理密钥;修改密码后,登录不再跟随外部密钥变化。忘记密码时,交互执行 `converter.py admin reset-password`。 + +普通密钥支持有效期、IP/CIDR 白名单、RPM 和上游模型限制,可再次查看并以明文保存。管理密钥不受限、最多一个,只保存哈希。普通密钥不能读取 billing 或其他密钥的视频任务。公开 `/v1/media/...` 资源在媒体 TTL 到期前始终可读,撤销创建密钥不会将其撤回。 ### 管理 Origin / CSRF 开关 @@ -308,7 +317,9 @@ Messages 的 `enabled` / `adaptive` 启用 Chat 推理,`output_config.effort` ## 部署暴露与凭据导入 -- Compose 默认映射回环地址。没有配置或已保存的 key 时,`CODEBUDDY2API_ALLOW_OPEN_NOAUTH=true` 显式允许后台/非回环匿名推理,不生成密钥,管理仍锁定。镜像保留此兼容开关,对外暴露前请配置 `CODEBUDDY2API_KEY`;该开关不会停用已有 key。 +- Compose 默认映射回环地址。对外开放前配置外部密钥或创建数据库密钥,不要把匿名模式当作公网访问方案。 +- **没有任何外部或数据库密钥时,`CODEBUDDY2API_ALLOW_OPEN_NOAUTH=true` 会匿名开放推理和账号池 billing,缺失或占位 token 均可。** 存在任意密钥记录即强制鉴权;没有密钥且开关关闭时,所有推理请求返回 401 `api_key_required`。 +- 每个数据目录只运行一个网关进程。其他进程的密钥变更约 1 秒内可见,但 RPM 计数与 `last_used_at` 聚合按进程独立。备份含普通密钥明文,必须按凭证保护。 - 配置 key 时,生成及 token 估算 POST 在缓冲请求体、预留推理名额之前校验请求头;即使名额已满,无效 key 仍返回 401。其他路由保留原有鉴权和路由行为。 - 凭据导入/上传在落盘前把 token 别名归一化为官方字段名;严格 JSON 解析拒绝 NaN/Infinity,`expiresAt`/`lastRefreshTime` 必须是合理的有限毫秒时间戳。 @@ -323,8 +334,8 @@ Messages 的 `enabled` / `adaptive` 启用 Chat 推理,`output_config.effort` | 现象 | 处理与边界 | |------|------------| -| WebUI 无法登录 | 确认设置了 API key;更换 key 后重新登录并重新发起未完成的 OAuth。HTTPS 反代后经域名登录失败时,用 `admin_allowed_origins` 信任对外来源(见上文) | -| 本地 401 | 客户端密钥与网关不一致 | +| WebUI 无法登录 | 使用初始或当前管理密码,或执行 `converter.py admin reset-password`。HTTPS 反代后经域名登录失败时,用 `admin_allowed_origins` 信任对外来源(见上文) | +| 本地 401 / 403 / 429 | 检查推理密钥是否存在、启用且未过期,再检查 IP/模型允许范围和 RPM;鉴权与权限错误按客户端协议返回 | | 上游 401 / 403 | 凭证级认证熔断;在 WebUI 检查并重新登录 | | 渠道校验(11128) | 可用时仅在同账号 `/console` 回退一次;第二次渠道拒绝可把 system 指令降为 user 后在同账号重试一次。最终仍拒绝则直接返回,不冷却任何账号;搜索/MCP 没有回退端点,账号保持可用 | | 账号限制(11140) | 标记账号为官方限制并休眠 2 小时,保留证据且暂时移出选路;申诉通过后可在凭证页一键重新启用。当前请求不换账号重放 | @@ -346,4 +357,4 @@ Messages 的 `enabled` / `adaptive` 启用 Chat 推理,`output_config.effort` ## 降级与回滚 -功能开关不藏隐状态:关闭守卫或模式即对新请求停止生效,回退源码即恢复旧行为。例外是持久化设置与自动化状态:`control.sqlite3` 保存 WebUI 设置、模型规则与奖励预留,旧代码会拒绝未知字段。源码降级前移除新增的启动参数,停止服务,备份**当前**数据目录,并使用上面的窄范围离线 `settings.` 删除流程,递增 revision 并执行完整性检查。不要恢复升级前数据库,也不要混用 WAL/SHM 代数,否则可能回滚较新的领取、会话、撤销和账号状态。回滚无法撤销已完成的上游签到、领取或旅行派出。 +功能开关不藏隐状态:关闭守卫或模式即对新请求停止生效,回退源码即恢复旧行为。例外是持久化设置与自动化状态:`control.sqlite3` 保存 WebUI 设置、模型规则与奖励预留,旧代码会拒绝未知字段。含 `api_keys` 的控制库 schema v3、审计库 schema v5 和媒体库 schema v3 均不能被 1.4.x 读取;降级必须停机并从升级前备份恢复。更早的纯设置变更可移除新增启动参数,并使用上面的窄范围离线 `settings.` 删除流程,递增 revision 并执行完整性检查。不要恢复升级前数据库,也不要混用 WAL/SHM 代数,否则可能回滚较新的领取、会话、撤销和账号状态。回滚无法撤销已完成的上游签到、领取或旅行派出。 diff --git a/docs/clients.md b/docs/clients.md index f905faa..3de14a4 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -2,7 +2,7 @@ [Home](../README.md) · [简体中文](clients.zh-CN.md) -Add an account in the [WebUI](webui.md) first. Replace `YOUR_GATEWAY_API_KEY` with the gateway API key and `MODEL_ID` with a public model ID. Adjust addresses and ports for your deployment. +Add an account in the [WebUI](webui.md) and create a client key in **Keys**. Replace `YOUR_GATEWAY_API_KEY` with that inference key and `MODEL_ID` with a public model ID. Adjust addresses and ports for your deployment. ## Common settings @@ -11,8 +11,7 @@ Add an account in the [WebUI](webui.md) first. Replace `YOUR_GATEWAY_API_KEY` wi | OpenAI Chat / Responses | `http://127.0.0.1:8787/v1` | | Anthropic Messages | `http://127.0.0.1:8787` | -Domestic/international and CLI/WorkBuddy accounts use the same URLs. Backend routing is automatic; no `/cn` or `/intl` prefix is needed. Clients use the WebUI login key, not its management Cookie. - +Domestic/international and CLI/WorkBuddy accounts use the same URLs. Backend routing is automatic; no `/cn` or `/intl` prefix is needed. Clients use an inference key from **Keys** or the external admin key, never the WebUI management Cookie. List available models: ```bash @@ -22,6 +21,20 @@ curl http://127.0.0.1:8787/v1/models \ Use an ID from this response or the WebUI; accounts do not necessarily support the same models. Public aliases can be configured in the UI. Anthropic model names are not automatically guessed or mapped. +## API keys and authorization + +Standard keys can be named, revealed, copied, rotated, enabled or disabled, and restricted by expiry, IP/CIDR allowlist, per-minute requests and source model IDs. Admin keys are unrestricted and shown only at creation or rotation; only their hashes are stored. An externally configured admin key is read-only in the UI. Every key is valid only for `/v1/*`, never `/admin/*`; standard keys cannot read `/v1/dashboard/billing/*`, while admin keys and explicitly anonymous deployments can. + +| Status / code | Meaning | +|---------------|---------| +| 401 `api_key_required` / `invalid_api_key` | No key was supplied, the key is unknown, or a database admin key is overridden by the external key | +| 401 `api_key_disabled` / `api_key_expired` | The key is disabled or expired | +| 403 `ip_not_allowed` / `model_not_allowed` | The client IP or resolved source model is outside the key's allowlist | +| 403 `admin_key_required` | A standard key attempted to read account-pool billing | +| 429 `rpm_exceeded` | The key exceeded its 60-second sliding-window limit; honor `Retry-After` | + +OpenAI responses use `error.type` and `error.code`; Anthropic uses `authentication_error`, `permission_error` or `rate_limit_error`. With no keys at all, `OPEN_NOAUTH=false` returns `api_key_required`; explicitly enabled anonymous mode accepts missing or placeholder tokens and also exposes billing. + ## Codex CLI Merge this into `~/.codex/config.toml`; do not overwrite existing configuration. A ready-to-copy variant is kept at [`examples/codex-codebuddy.example.toml`](../examples/codex-codebuddy.example.toml): @@ -108,6 +121,7 @@ Image and video endpoints backed by the upstream `/v2/images/*` and `/v2/videos/ - `POST /v1/videos` — `{"prompt?" , "image_url?", "seconds?" (int or numeric string), "resolution?" ("768P"|"2K"), "aspect_ratio?", "enable_audio?", "model?"}` → `{"id", "object": "video", "status": "queued", …}`; poll with `GET /v1/videos/{id}` and fetch finished videos via `GET /v1/videos/{id}/content`. - Images are stored locally by default. Without the asset base URL (`media_public_base_url`, WebUI **Media**) they return `b64_json` without a public link; an explicit `response_format=b64_json` skips image persistence. Videos are always stored locally. Setting the base URL enables `url` responses and video links; assets are served at `/v1/media/{images|videos}/{id}` with immutable caching. - Defaults are fixed per family: `hunyuan-image-v3.0` (text to image), `hunyuan-image-v2.0-general-edit` (edit), `minimax-video-h3` (video). Media models share `/v1/models` and WebUI **Model routing**, including aliases, enable switches and binding rules; chat endpoints reject them with a pointer here. +- Standard keys can poll and fetch only video tasks they created; other keys' task IDs return the same 404 as missing tasks. Public `/v1/media/...` URLs rely on unguessable IDs and remain valid until media TTL expiry, even after the creating key is disabled or deleted. - Accounts are picked per request from verified media-capable products only (currently `cn-cli`) and require a positive balance. Static media models defer authorization to upstream even when absent from an account catalog; dynamic models require exact media catalog tags. A refused request (401/403/429) may be retried once on another allowed account per the failover setting, but upstream restriction codes are never replayed and account restrictions disable the account (matching chat behavior); 5xx and timeouts are never replayed to avoid double billing. Video task polling stays bound to the submitting account. - Multi-image requests download concurrently and return the surviving items when some fail; only a total failure answers 502 and leaves no stored files. Video downloads fail permanently only when the signature is gone (403/404/410), the type is refused or the file is over quota; repeated queries then return the same error. Any other failure (network, timeout, 408/429/5xx, local storage errors) keeps the task active, shows a `message` retry hint on the in-progress task, and the next query retries. diff --git a/docs/clients.zh-CN.md b/docs/clients.zh-CN.md index 02d8689..f6f3d90 100644 --- a/docs/clients.zh-CN.md +++ b/docs/clients.zh-CN.md @@ -2,7 +2,7 @@ [首页](../README.zh-CN.md) · [English](clients.md) -请先在 [WebUI](webui.zh-CN.md) 添加账号。下文将 `YOUR_GATEWAY_API_KEY` 替换为网关 API key,`MODEL_ID` 替换为对外模型 ID,并按部署调整地址与端口。 +请先在 [WebUI](webui.zh-CN.md) 添加账号,并在「密钥管理」创建客户端密钥。下文将 `YOUR_GATEWAY_API_KEY` 替换为该推理密钥,`MODEL_ID` 替换为对外模型 ID,并按部署调整地址与端口。 ## 通用设置 @@ -11,7 +11,7 @@ | OpenAI Chat / Responses | `http://127.0.0.1:8787/v1` | | Anthropic Messages | `http://127.0.0.1:8787` | -国内/国际、CLI/WorkBuddy 账号共用这些地址;后端自动选路,无需 `/cn` 或 `/intl` 前缀。客户端使用 WebUI 登录所用的 key,而非管理 Cookie。 +国内/国际、CLI/WorkBuddy 账号共用这些地址;后端自动选路,无需 `/cn` 或 `/intl` 前缀。客户端使用「密钥管理」创建的推理密钥或外部管理密钥,不能使用 WebUI 管理 Cookie。 列出可用模型: @@ -22,6 +22,20 @@ curl http://127.0.0.1:8787/v1/models \ 使用响应中或 WebUI 里的 ID;各账号支持的模型不一定相同。可在界面中配置对外别名。Anthropic 模型名不会被自动猜测或映射。 +## API key 与授权 + +普通密钥支持命名、再次查看、复制、轮换、启停,以及有效期、IP/CIDR 白名单、每分钟请求数和上游模型 ID 限制。管理密钥不受限制,只在创建或轮换时显示一次,库中仅保存哈希。外部管理密钥在界面中只读。所有密钥都只能调用 `/v1/*`,不能访问 `/admin/*`;普通密钥不能读取 `/v1/dashboard/billing/*`,管理密钥和显式匿名部署可以。 + +| 状态 / 错误码 | 含义 | +|---------------|------| +| 401 `api_key_required` / `invalid_api_key` | 未提供密钥、密钥未知,或数据库管理密钥已被外部密钥覆盖 | +| 401 `api_key_disabled` / `api_key_expired` | 密钥已停用或过期 | +| 403 `ip_not_allowed` / `model_not_allowed` | 客户端 IP 或解析后的上游模型不在密钥允许范围内 | +| 403 `admin_key_required` | 普通密钥尝试读取账号池 billing | +| 429 `rpm_exceeded` | 超过密钥的 60 秒滑动窗口限制;按 `Retry-After` 等待 | + +OpenAI 响应使用 `error.type` 和 `error.code`;Anthropic 使用 `authentication_error`、`permission_error` 或 `rate_limit_error`。没有任何密钥且 `OPEN_NOAUTH=false` 时返回 `api_key_required`;显式开启匿名模式后,缺失或占位 token 都会放行,同时开放 billing。 + ## Codex CLI 将以下内容合并进 `~/.codex/config.toml`,不要覆盖已有配置。可直接复制的版本见 [`examples/codex-codebuddy.example.toml`](../examples/codex-codebuddy.example.toml): @@ -107,6 +121,7 @@ curl http://127.0.0.1:8787/v1/fetch -H "Authorization: Bearer YOUR_API_KEY" \ - `POST /v1/images/edits`:OpenAI 风格 JSON:`{"prompt", "images": [{"image_url": "https://… | data:…"}], "model?", "size?", "input_fidelity?"}`;不支持 multipart。图片引用(含视频 `image_url`)最长 20 MiB 文本,可直接传 `data:` URI。 - `POST /v1/videos`:`{"prompt?", "image_url?", "seconds?"(整数或数字字符串), "resolution?"("768P"|"2K"), "aspect_ratio?", "enable_audio?", "model?"}` → `{"id", "object": "video", "status": "queued", …}`;用 `GET /v1/videos/{id}` 轮询,`GET /v1/videos/{id}/content` 获取完成的视频。 - 图片默认保存到本地。未配置资源访问地址(`media_public_base_url`,WebUI「媒体生成」页)时返回 `b64_json`,不生成公开链接;显式 `response_format=b64_json` 则不保存图片。视频始终落盘。配置地址后可返回 `url` 和视频链接;资源经 `/v1/media/{images|videos}/{id}` 公开读取,带不可变缓存头。 +- 普通密钥只能轮询和获取自己创建的视频任务,其他密钥的任务 ID 与不存在一样返回 404。公开 `/v1/media/...` 地址依赖不可猜测的资源 ID,停用或删除创建密钥不会撤销资源,直到媒体 TTL 到期。 - 默认模型按族固定:文生图 `hunyuan-image-v3.0`、图生图 `hunyuan-image-v2.0-general-edit`、视频 `minimax-video-h3`。媒体模型统一进入 `/v1/models` 和 WebUI「模型路由」,共用别名、启停与绑定规则;媒体模型打到聊天端点会被拒绝并指引到本节。 - 按请求选号,仅限已实测可用的媒体产品(当前为 `cn-cli`),且余额为正。静态媒体模型即使不在账号目录中也由上游裁决权限;动态模型要求目录带对应媒体标签。被明确拒绝(401/403/429)时可按 failover 配置在允许范围内换号重试一次,但上游限制码不会重放,账号限制码会禁用该账号(与聊天接口一致),5xx 与超时一律不重放以避免重复扣费。视频任务始终绑定提交账号查询。 - 多张图片并发下载,部分失败时返回成功的条目;全部失败才返回 502 且不残留已落盘文件。视频下载仅在签名失效(403/404/410)、类型被拒或超容量时永久失败,重复查询返回同一错误;其余失败(网络、超时、408/429/5xx、本地存储错误)任务保持进行中,进行中响应带 `message` 重试提示,下次查询重试。 diff --git a/docs/deployment.md b/docs/deployment.md index f497419..8ae101b 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -6,7 +6,7 @@ Prefer the [WebUI](webui.md) for everyday account, model and log management. Run ## Configuration and data -On first setup, run `cp .env.example .env` and set `CODEBUDDY2API_KEY` to your own random key. Keep an existing `.env` and add only the settings you need. +On first setup, run `cp .env.example .env` and set `CODEBUDDY2API_KEY` to your own random key. It is the external admin inference key and the initial WebUI password until changed. Keep an existing `.env` and add only the settings you need. | Setting | Purpose | |---------|---------| @@ -15,7 +15,7 @@ On first setup, run `cp .env.example .env` and set `CODEBUDDY2API_KEY` to your o | `CODEBUDDY2API_AUTH_PATH` | Compose host data directory; defaults to `./auth`, mounted at `/data/auth` | | `TZ` | Container time zone; daily guards follow it, and an unset value keeps UTC | | `CODEBUDDY2API_ENV_FILE` | Optional Compose override for the env file path; defaults to `.env` | -| `CODEBUDDY2API_ALLOW_OPEN_NOAUTH` | Headless no-auth opt-in for the image and Compose, where it stays `true`; configure `CODEBUDDY2API_KEY` before exposing the port | +| `CODEBUDDY2API_ALLOW_OPEN_NOAUTH` | Image/Compose compatibility opt-in, fixed `true` there. Only with no external or database keys does it anonymously expose inference **and account-pool billing**, with or without a placeholder token; any key row disables it | | `CODEBUDDY2API_TRUSTED_PROXIES` | Trusted proxy IPs/CIDRs (comma separated); when the direct peer matches, the audit log records the client from `X-Forwarded-For`/`X-Real-IP` instead of the proxy address | | `CODEBUDDY_AUTH_DIR` | Local Python data directory; defaults to the repository's `auth/`. Compose sets it to `/data/auth` inside the container | | `CODEBUDDY_IMPORT_DIR` | Optional import directory; defaults to `imports/` under the data directory. Use container paths with Compose | @@ -45,7 +45,7 @@ docker compose build docker compose up -d ``` -The build includes the WebUI; Node.js and Python are not required on the host. Open `http://127.0.0.1:8787/dashboard` to add accounts. +The build includes the WebUI; Node.js and Python are not required on the host. Open `http://127.0.0.1:8787/dashboard` to add accounts. With no external key, get the initial `admin` password from `docker compose logs codebuddy2api`, then change it in **Settings → Admin account** and create inference keys in **Keys**. ### Upgrades and recreates @@ -59,8 +59,7 @@ The default loopback mapping is the only safe exposure without extra work. When - Terminate TLS at the proxy and forward the original Host and scheme (`proxy_set_header Host $host` and `X-Forwarded-Proto $scheme` in nginx). - The WebUI compares the browser's `Origin` with the address the gateway actually sees. If the proxy rewrites the forwarded Host/scheme — for example HTTPS on the domain while the container sees HTTP — sign-in fails the Origin check. Trust your public address in **Settings → Extra trusted management origins(管理页额外信任来源)** or with `CODEBUDDY2API_ADMIN_ORIGINS` / `--admin-allowed-origins` instead of disabling CSRF protection; see [Management Origin checks](advanced.md#management-origin--csrf-switch). -- Keep authentication enabled (`CODEBUDDY2API_KEY`) for any non-loopback exposure. - +- Configure an external key or create a database key before non-loopback exposure. Anonymous mode also exposes account-pool billing; never use it for public deployments. ## Local Python setup Requires Python 3.12+ and uv. Build the interface only when the distribution does not include it, using Node.js 24 and the vp CLI pinned by `vite-plus` in `web/package.json`. @@ -73,11 +72,11 @@ uv run --locked --no-build converter.py --host 127.0.0.1 --port 8787 `--locked` stops instead of re-resolving when `uv.lock` is stale, so a source launch never upgrades packages. `.env` in the working directory is read automatically, so `uv run` needs no `--env-file`. Distribution archives already contain the WebUI; skip the frontend build there. -Local startup does not require `.env`. Without an explicit key, the first loopback startup generates a `cb-…` default key in `auth/control.sqlite3` and displays it once in the interactive terminal after listening succeeds. Restarts reuse it without printing it again. Keep it safe: management and API requests share this key. Configure a key explicitly for first-time headless or non-loopback deployment. +Local startup does not require `.env`. With `CODEBUDDY2API_KEY` from CLI, environment or `.env`, sign in as `admin` with that value; it remains the external admin inference key until removed. Without an external key, startup prints `admin` and a random initial password to the controlling terminal, or to stdout when no TTY is available. In Docker, use `docker compose logs codebuddy2api`. No inference key is created automatically: sign in and create one in **Keys**. -One-time disclosure goes directly to the controlling terminal (`/dev/tty` or Windows `CONOUT$`), not stdout/stderr; redirecting standard output does not capture the key. Terminal recording remains outside the gateway's control. +A legacy saved default key is migrated once to the initial admin password and a database admin key; database keys never become `CODEBUDDY2API_KEY`. Changing the account password decouples later logins from external-key changes and revokes existing sessions. Passwords are stored only as scrypt hashes. -A failed terminal write or database commit leaves the same key pending for the next interactive start. A crash between display and commit can therefore repeat the notice; a successfully committed display is not repeated. +Password output precedes the account commit. An output failure stops startup without writing the account; a commit failure retries initialization on the next start. Terminal or container-log retention remains outside the gateway's control, so change the initial password after first login. Without uv, keep the same hash-locked set in a virtual environment: @@ -88,9 +87,20 @@ python3 -m pip install --require-hashes --only-binary=:all: -r requirements.txt python3 converter.py --host 127.0.0.1 --port 8787 ``` -Both startup paths read `.env` in the current working directory when present, never parent directories. Precedence: explicit CLI > process environment > `.env` > saved SQLite values > defaults. Overrides do not replace the saved default key; an explicitly empty key still locks management. Listener changes require restart; `CODEBUDDY2API_IMAGE/AUTH_PATH` only affect Compose. +Both startup paths read `.env` in the current working directory when present, never parent directories. Precedence: explicit CLI > process environment > `.env` > saved SQLite values > defaults. Database keys are never promoted to the external key; blank external values are treated as unset. Listener changes require restart; `CODEBUDDY2API_IMAGE/AUTH_PATH` only affect Compose. ### Configuration migration +### Admin password recovery + +Use the interactive recovery command with the same data directory as the gateway: + +```bash +uv run --locked --no-build converter.py admin reset-password +uv run --locked --no-build converter.py admin reset-password --username admin +``` + +It prompts twice on a TTY and accepts no password argument. It also works while the service is running; the next management request uses the new account epoch and revokes every existing session. In Docker, run `docker compose exec codebuddy2api python3 converter.py admin reset-password`. + `.env` values stay authoritative, so any runtime key listed there occupies its WebUI field. To move them into the configuration store, stop the service and run the offline command; it never edits `.env` itself: @@ -100,7 +110,9 @@ python3 converter.py config migrate --env-file /srv/app/.env --data-dir /srv/app python3 converter.py config status --db /srv/app/auth/control.sqlite3 # read-only ``` -Both commands resolve the env file first, then the data directory it declares (`CODEBUDDY_AUTH_DIR`), so `--data-dir`/`--db` only override the resolved target. Before writing, `migrate` verifies the target really is this project's control store (SQLite header, `user_version=2`, `control`/`runtime_state`/`gateway_secrets`, readable payload, `quick_check`) and refuses while a running instance holds the lease or the write lock; `--force` bypasses the lease warning only. Deployment keys (key, listener, data paths) are never migrated. Inside a container there is no `.env` file, so use the process environment: +Both commands resolve the env file first, then the data directory it declares (`CODEBUDDY_AUTH_DIR`), so `--data-dir`/`--db` only override the resolved target. Before writing, `migrate` verifies the target really is this project's control store (SQLite header, `user_version=3`, `control`/`runtime_state`/`gateway_secrets`/`api_keys`, readable payload, `quick_check`) and refuses while a running instance holds the lease or the write lock; `--force` bypasses the lease warning only. Deployment keys (key, listener, data paths) are never migrated. Inside a container there is no `.env` file, so use the process environment: + +A v2 control database must be opened once by the new service to complete its v3 upgrade before `config migrate` can read it. ```bash docker compose stop @@ -118,10 +130,9 @@ Release-archive installs and git checkouts can update or roll back in one click SQLite state read/validation failures stop startup without deleting sessions or replacing state with empty defaults. Repair the control database before retrying; only rebuildable cache write failures may retain the current in-memory data with a warning. -All runtime logs use `logs.sqlite3`; legacy `--log` / `CODEBUDDY2API_LOG` settings warn and no longer write text files. Generated keys enter private configuration, never logs. Restrict access to the data directory; use a private user directory on Windows. - -Older versions cannot read the upgraded control database. Downgrades require a stopped gateway and matching state migration, not merely old code reading stale JSON. Never overwrite new claims, dispatches or session revocations with an outdated backup. +All runtime logs use `logs.sqlite3`; legacy `--log` / `CODEBUDDY2API_LOG` settings warn and no longer write text files. Initial passwords go only to the terminal/startup output, and audit stores key IDs rather than key secrets. Restrict access to the data directory; use a private user directory on Windows. +Startup upgrades control schema v2 to v3 and migrates the legacy default key. Older versions cannot read the v3 control database; downgrade requires restoring a pre-upgrade backup while stopped, not merely old code reading stale JSON. Never overwrite new claims, dispatches or session revocations with an outdated backup. ## Dependency locks `pyproject.toml` owns direct dependencies; `uv.lock` pins all resolved versions. The hash-locked `requirements.txt` is the generated pip input for Docker and CI: diff --git a/docs/deployment.zh-CN.md b/docs/deployment.zh-CN.md index c8c63e9..f6905ad 100644 --- a/docs/deployment.zh-CN.md +++ b/docs/deployment.zh-CN.md @@ -6,7 +6,7 @@ ## 配置与数据 -首次部署运行 `cp .env.example .env`,并将 `CODEBUDDY2API_KEY` 设置为自己的随机密钥。已有 `.env` 请保留,只按需追加配置。 +首次部署运行 `cp .env.example .env`,并将 `CODEBUDDY2API_KEY` 设置为自己的随机密钥。它是外部管理推理密钥,也是修改密码前的初始 WebUI 密码。已有 `.env` 请保留,只按需追加配置。 | 配置 | 作用 | |------|------| @@ -15,7 +15,7 @@ | `CODEBUDDY2API_AUTH_PATH` | Compose 宿主机数据目录;默认 `./auth`,挂载到容器 `/data/auth` | | `TZ` | 容器时区;每日任务按它跨天,未设置时保持 UTC | | `CODEBUDDY2API_ENV_FILE` | 可选的 Compose 覆盖项,指定 env 文件路径;默认 `.env` | -| `CODEBUDDY2API_ALLOW_OPEN_NOAUTH` | 镜像与 Compose 的后台免鉴权选项,在两者中都固定为 `true`;对外暴露端口前请配置 `CODEBUDDY2API_KEY` | +| `CODEBUDDY2API_ALLOW_OPEN_NOAUTH` | 镜像与 Compose 的兼容选项,在其中固定为 `true`。仅当没有任何外部或数据库密钥时,无论是否携带占位 token,都会匿名开放推理**及账号池 billing**;存在任意密钥记录即失效 | | `CODEBUDDY2API_TRUSTED_PROXIES` | 可信代理 IP/CIDR(逗号分隔);直连地址命中时审计改记 `X-Forwarded-For`/`X-Real-IP` 里的真实客户端地址,而不是代理地址 | | `CODEBUDDY_AUTH_DIR` | 本地 Python 数据目录;默认仓库内 `auth/`。Compose 在容器内固定为 `/data/auth` | | `CODEBUDDY_IMPORT_DIR` | 可选导入目录;默认数据目录下的 `imports/`。Compose 中请使用容器路径 | @@ -46,7 +46,7 @@ docker compose build docker compose up -d ``` -构建包含 WebUI;宿主机无需安装 Node.js 和 Python。启动后打开 `http://127.0.0.1:8787/dashboard` 添加账号。 +构建包含 WebUI;宿主机无需安装 Node.js 和 Python。启动后打开 `http://127.0.0.1:8787/dashboard` 添加账号。没有外部密钥时,从 `docker compose logs codebuddy2api` 查看初始 `admin` 密码,随后在「系统设置 → 管理账户」修改,并在「密钥管理」创建推理密钥。 ### 升级与重建 @@ -60,7 +60,7 @@ WebUI「版本更新」页也可在运行中的容器里一键更新或回滚, - 在反代终止 TLS,并转发原始 Host 与协议(nginx 配置 `proxy_set_header Host $host` 与 `X-Forwarded-Proto $scheme`)。 - WebUI 会比较浏览器 `Origin` 与网关实际看到的地址。反代改写转发的 Host/协议时(例如域名 HTTPS 访问而容器内看到 HTTP),登录会报 Origin 校验失败。此时把对外地址加入「系统设置 → 管理页额外信任来源」,或设置 `CODEBUDDY2API_ADMIN_ORIGINS` / `--admin-allowed-origins`,而不是关闭 CSRF 保护;见[管理 Origin 校验](advanced.zh-CN.md#管理-origin--csrf-开关)。 -- 任何非回环暴露都必须保持鉴权(`CODEBUDDY2API_KEY`)。 +- 非回环暴露前配置外部密钥或先创建数据库密钥。匿名模式还会开放账号池 billing,公网部署不得使用。 ## 本地 Python 运行 @@ -74,11 +74,11 @@ uv run --locked --no-build converter.py --host 127.0.0.1 --port 8787 `uv.lock` 过期时 `--locked` 会直接失败而不是重新解析,因此源码启动不会升级依赖。当前工作目录的 `.env` 会被自动读取,`uv run` 无需 `--env-file`。发行包已包含 WebUI,可跳过界面构建。 -本地启动无需 `.env`。没有显式 key 时,首次回环启动会生成 `cb-…` 默认密钥,保存到 `auth/control.sqlite3`,监听成功后仅在交互终端显示一次;以后重启复用且不再打印。请妥善保存,管理登录与 API 请求共用。首次后台或非回环部署请显式配置 key。 +本地启动无需 `.env`。通过 CLI、环境变量或 `.env` 设置 `CODEBUDDY2API_KEY` 时,使用账号 `admin` 和该值登录;它仍是外部管理推理密钥,直到移除。没有外部密钥时,启动会把 `admin` 和随机初始密码写到控制终端;没有 TTY 时写到 stdout,Docker 中可用 `docker compose logs codebuddy2api` 查看。不会自动创建推理密钥,请登录后在「密钥管理」创建。 -密码提示直接写入控制终端(`/dev/tty` 或 Windows `CONOUT$`),不经过 stdout/stderr,重定向标准输出不会收集密码;外部终端录制不在程序控制范围内。 +旧版保存的默认 key 会在首次启动时迁移为初始管理密码和数据库管理密钥;数据库密钥不会提升为 `CODEBUDDY2API_KEY`。修改管理密码后,后续登录不再跟随外部密钥变化,并撤销已有会话。密码只保存 scrypt 哈希。 -终端写入或数据库提交失败时,同一密钥保留为待显示,下次交互启动可重试。显示后、提交前崩溃可能再次提示;成功提交后不重复显示。 +密码先输出再提交账户;输出失败会停止启动且不写账户,提交失败则下次启动重试初始化。终端或容器日志的保留不受网关控制,首次登录后请尽快修改初始密码。 没有 uv 时,用虚拟环境安装同一份带哈希的依赖: @@ -89,7 +89,18 @@ python3 -m pip install --require-hashes --only-binary=:all: -r requirements.txt python3 converter.py --host 127.0.0.1 --port 8787 ``` -两种启动方式都会读取当前工作目录的 `.env`(若存在),不搜索父目录。优先级:显式 CLI > 进程环境变量 > `.env` > SQLite 保存值 > 默认值。覆盖不改写已保存的默认 key;显式空 key 仍锁定管理。修改监听需重启,`CODEBUDDY2API_IMAGE/AUTH_PATH` 只作用于 Compose。 +两种启动方式都会读取当前工作目录的 `.env`(若存在),不搜索父目录。优先级:显式 CLI > 进程环境变量 > `.env` > SQLite 保存值 > 默认值。数据库密钥不会提升为外部密钥;空白外部值按未设置处理。修改监听需重启,`CODEBUDDY2API_IMAGE/AUTH_PATH` 只作用于 Compose。 + +### 管理密码恢复 + +使用与网关相同的数据目录执行交互式恢复命令: + +```bash +uv run --locked --no-build converter.py admin reset-password +uv run --locked --no-build converter.py admin reset-password --username admin +``` + +命令在 TTY 中要求输入两次密码,不接受密码参数。服务运行时也可执行;下一次管理请求即使用新的账户 epoch,并撤销所有已有会话。Docker 中执行 `docker compose exec codebuddy2api python3 converter.py admin reset-password`。 ### 配置迁移 @@ -101,7 +112,9 @@ python3 converter.py config migrate --env-file /srv/app/.env --data-dir /srv/app python3 converter.py config status --db /srv/app/auth/control.sqlite3 # 只读 ``` -两条命令都先解析 env 文件,再采用其中声明的数据目录(`CODEBUDDY_AUTH_DIR`),`--data-dir`/`--db` 只覆盖解析结果。写入前 `migrate` 会验证目标确实是本项目的控制库(SQLite 魔数、`user_version=2`、`control`/`runtime_state`/`gateway_secrets`、可解析的 payload、`quick_check`),并在检测到运行中实例的租约或写锁时拒绝;`--force` 只跳过租约提示。部署类键(密钥、监听地址、数据路径)不会迁移。容器内没有 `.env` 文件,改用进程环境变量: +两条命令都先解析 env 文件,再采用其中声明的数据目录(`CODEBUDDY_AUTH_DIR`),`--data-dir`/`--db` 只覆盖解析结果。写入前 `migrate` 会验证目标确实是本项目的控制库(SQLite 魔数、`user_version=3`、`control`/`runtime_state`/`gateway_secrets`/`api_keys`、可解析的 payload、`quick_check`),并在检测到运行中实例的租约或写锁时拒绝;`--force` 只跳过租约提示。部署类键(密钥、监听地址、数据路径)不会迁移。容器内没有 `.env` 文件,改用进程环境变量: + +v2 控制库必须先由新版服务启动一次并完成 v3 升级,才能执行 `config migrate`。 ```bash docker compose stop @@ -119,9 +132,9 @@ docker compose run --rm --no-deps -e CODEBUDDY_AUTH_DIR=/data/auth \ SQLite 状态读取或校验失败会阻止启动,不删除会话、不回退为空状态;修复控制库后再启动。仅可重建缓存的写入失败允许保留当前内存数据并告警。 -所有运行日志使用 `logs.sqlite3`;旧 `--log` / `CODEBUDDY2API_LOG` 仅提示弃用,不再输出文本文件。自动生成的 key 只进私有配置,不进任何日志。限制数据目录访问权限;Windows 应使用当前用户的私有目录。 +所有运行日志使用 `logs.sqlite3`;旧 `--log` / `CODEBUDDY2API_LOG` 仅提示弃用,不再输出文本文件。初始密码只进入终端或启动输出,审计只保存密钥 ID 而非明文。限制数据目录访问权限;Windows 应使用当前用户的私有目录。 -旧版本不能读取升级后的控制库。回滚须停机并迁回匹配的状态,不能只切代码后复用旧 JSON;升级后发生的领取、派遣或会话撤销不能用过期备份覆盖。 +启动会把控制库 schema v2 升级为 v3,并迁移旧默认 key。旧版本无法读取 v3 控制库;降级须在停机后从升级前备份恢复,不能只切代码后复用旧 JSON。升级后发生的领取、派遣或会话撤销不能用过期备份覆盖。 ## 依赖锁定 diff --git a/docs/development.md b/docs/development.md index ab817ce..2dfd7ae 100644 --- a/docs/development.md +++ b/docs/development.md @@ -14,13 +14,13 @@ uv run --locked --no-build python -B -m preview --port 8790 --data-dir /tmp/cb-p | Option | Default | Purpose | |--------|---------|---------| | `--host` / `--port` | `127.0.0.1` / `8790` | Listener | -| `--api-key` | `preview` | Key for the WebUI and the APIs | +| `--api-key` | `preview` | External admin inference key and initial WebUI password | | `--data-dir` | new temporary directory | Keep the preview data across restarts; the directory must be empty or created by the preview | | `--public` | off | Refuse actions unsafe on a shared demo; see [Public deployment](#public-deployment) | | `--reset-hours` | `0` (never) | Exit after this many hours so a supervisor restarts with fresh data | | other options | — | Passed to the gateway unchanged, such as `--admin-allowed-origins` | -Open `http://127.0.0.1:8790/dashboard` and sign in with the key. For hot reload while editing the frontend, keep the preview running and start the dev server against it: +Open `http://127.0.0.1:8790/dashboard` and sign in as `admin` with password `preview` (or your `--api-key` value). API clients use that value as the external admin key. The preview also seeds standard keys demonstrating expiry, IP and model restrictions. For hot reload while editing the frontend, keep the preview running and start the dev server against it: ```bash cd web @@ -82,7 +82,7 @@ services: - /tmp:size=256m ``` -Put a TLS reverse proxy in front of `127.0.0.1:8790` and list its public origin in `--admin-allowed-origins`, otherwise the WebUI sign-in fails the Origin check. The key stays public on purpose; share it with the link. +Put a TLS reverse proxy in front of `127.0.0.1:8790` and list its public origin in `--admin-allowed-origins`, otherwise the WebUI sign-in fails the Origin check. The demo credentials `admin` / `preview` and the inference key `preview` are public on purpose; share them with the link. ## Isolation diff --git a/docs/development.zh-CN.md b/docs/development.zh-CN.md index 45edf28..1cc83e9 100644 --- a/docs/development.zh-CN.md +++ b/docs/development.zh-CN.md @@ -14,13 +14,13 @@ uv run --locked --no-build python -B -m preview --port 8790 --data-dir /tmp/cb-p | 参数 | 默认值 | 作用 | |------|--------|------| | `--host` / `--port` | `127.0.0.1` / `8790` | 监听地址 | -| `--api-key` | `preview` | WebUI 与 API 使用的密钥 | +| `--api-key` | `preview` | 外部管理推理密钥及初始 WebUI 密码 | | `--data-dir` | 每次新建临时目录 | 跨重启保留预览数据;目录必须为空或由预览服务创建 | | `--public` | 关闭 | 拒绝不适合共享演示的操作,见[公开部署](#公开部署) | | `--reset-hours` | `0`(不重置) | 运行指定小时后退出,由进程管理器用全新数据重启 | | 其他参数 | — | 原样传给网关,例如 `--admin-allowed-origins` | -打开 `http://127.0.0.1:8790/dashboard`,用密钥登录。修改前端需要热更新时,保持预览服务运行,再让开发服务器代理到它: +打开 `http://127.0.0.1:8790/dashboard`,使用账号 `admin` 和密码 `preview`(或 `--api-key` 的设置值)登录;API 客户端把该值作为外部管理密钥。预览还预置了分别展示过期、IP 和模型限制的三个普通密钥。修改前端需要热更新时,保持预览服务运行,再让开发服务器代理到它: ```bash cd web @@ -82,7 +82,7 @@ services: - /tmp:size=256m ``` -在 `127.0.0.1:8790` 前面放一个启用 TLS 的反向代理,并把它的公网来源写进 `--admin-allowed-origins`,否则 WebUI 登录会被 Origin 校验拒绝。密钥本就是公开的,随链接一起提供即可。 +在 `127.0.0.1:8790` 前面放一个启用 TLS 的反向代理,并把它的公网来源写进 `--admin-allowed-origins`,否则 WebUI 登录会被 Origin 校验拒绝。演示账号 `admin`、密码 `preview` 和推理密钥 `preview` 本就是公开的,随链接一起提供即可。 ## 隔离 diff --git a/docs/webui.md b/docs/webui.md index be66caf..e27f98c 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -4,9 +4,9 @@ ## Open the console -Start the gateway using the [deployment guide](deployment.md), then open `http://127.0.0.1:8787/dashboard` and sign in with the current API key. Source installs need a frontend build; Docker builds include it. Use HTTPS unless connecting locally. +Start the gateway using the [deployment guide](deployment.md), then open `http://127.0.0.1:8787/dashboard`. Sign in as `admin` with the external key, the password printed at startup, or the legacy default key migrated during upgrade. Source installs need a frontend build; Docker builds include it. Use HTTPS unless connecting locally. -The first unconfigured local startup generates and saves a default key, shown once in the terminal and reused afterward. An explicitly empty key locks management. After changing the effective key, sign in again and restart unfinished OAuth logins. +Change the username or password in **Settings → Admin account**. A normal session lasts 12 hours; **Remember me** lasts 400 days with sliding renewal. Account changes revoke other sessions and return a fresh CSRF token. API keys cannot access `/admin/*`, and the management Cookie cannot access `/v1/*`. ## Overview @@ -44,11 +44,19 @@ Add independent mappings with public/upstream IDs and local enablement. Choose e - The rate column shows `≈0.037x · 实测计费` for a model declared free that upstream charges, with the sample counts on hover, and `· 手填` for a manual rate. **Edit rule → 消费倍率** takes a manual rate per product and **一键探测倍率** sends one ≈8K-token request per product to tell real 0x from measured billing; **一键检测倍率** in the header does this for every model declared free. See [Measured rates and output limits](advanced.md#measured-rates-and-output-limits). - The max output column overlays results from `scripts/probe_max_tokens.py`: red `32K ⚠ 钳制 12×` for silent clamping, green `48K ✓` / `64K ↑` when the claim holds or is exceeded, grey `≥40K` for a lower bound and `?` when nothing was measured. +## API keys + +The **Keys** page separates inference credentials from the management account. An external admin key shows only its configuration origin and never a prefix or secret. One database admin key is allowed; it is unrestricted and visible only at creation or rotation, with only its hash stored. + +Standard keys support names, enable/disable, expiry, IP/CIDR allowlists, RPM and model restrictions, rotation, reveal, copy and deletion. They remain visible in the UI and are stored in plaintext in `control.sqlite3` and configuration backups, so protect both. Keys authorize only `/v1/*`; standard keys cannot read account-pool billing or access another key's video tasks. + +The table shows each key's approximate last-24-hour request count from hourly audit buckets and its last use time. Audit degradation shows `—`; clearing all logs and statistics resets these counters. Deleted keys remain in old audit rows as ` (deleted)`, while pre-upgrade rows show `— (pre-upgrade)`. + ## Logs -Filter requests and inspect failed attempts. Closing details or switching log type cancels pending detail loads. Clearing details keeps historical statistics. +Filter requests by key and inspect failed attempts. The key column shows the current name when available, ` (deleted)` after deletion, and `— (pre-upgrade)` for old records. Closing details or switching log type cancels pending detail loads. Clearing details keeps historical statistics. -Clearing **all logs and statistics** is irreversible. Enter the confirmation text shown in the dialog and re-enter the current API key. This does not delete credentials or gateway settings. +Clearing **all logs and statistics** is irreversible. Enter the confirmation text shown in the dialog and re-enter the current management password. This does not delete credentials, API keys or gateway settings. ## Media @@ -57,6 +65,7 @@ The **Media** page shows locally stored generation results: usage against the ca - **Library** lists assets newest first with model, size and remaining retention; a card opens the preview drawer with metadata, download and a confirmation dialog. **Video tasks** refresh in-flight tasks on their bound account and link finished videos back to the library. Model catalogs and routing are managed under **Model routing**. - Images are stored locally by default; without an asset base URL they return base64 without a public link. Explicit `response_format=b64_json` skips image persistence. Videos are always stored locally, and links appear once the base URL is set. - **Media settings** (`media_public_base_url`, `media_max_total_mb`, `media_ttl_hours`) set the public link prefix, capacity (64–1048576 MB) and retention (1–8760 hours). Retention is derived from each asset's creation time and the current setting, so shrinking it expires existing assets immediately; the oldest are evicted on the next write or scheduled cleanup. Assets are also served publicly at `/v1/media/…` with immutable caching; see [Media generation](clients.md#media-generation). +- Standard keys see only video tasks they created; other keys' task IDs return 404 before upstream polling. Public asset URLs rely on unguessable IDs and are not revoked when a key is disabled or deleted; they expire under the media TTL. ## Settings @@ -65,6 +74,7 @@ Edit unlocked options; hot changes apply immediately, while restart-marked setti - "Keep tool descriptions" is off by default and works across all three protocols, independently of prompt compaction; see [tool metadata retention](advanced.md#tool-metadata-retention). - "Model capability preflight" defaults to on. Disabling it affects new requests only, not metadata display, international image-run merging or existing security limits; see [model declarations](advanced.md#model-declarations-and-image-compatibility). - "Extra trusted management origins" (`admin_allowed_origins`) fixes sign-in behind HTTPS reverse proxies; see [Management Origin checks](advanced.md#management-origin--csrf-switch). +- **Admin account** changes the username and password after verifying the current password, adopts the returned CSRF token, and can revoke every session including the current browser. ## Updates @@ -113,7 +123,7 @@ Data defaults to `auth/`, or `/data/auth` inside Docker. Local installs can set | File | Contents | |------|----------| | `*.info` | Official plaintext credentials; never migrated into SQLite | -| `control.sqlite3` | Settings, private default key, model rules, sessions, cooldowns, credit/reward state, usage and catalogs | +| `control.sqlite3` | Settings, scrypt password hashes, standard-key plaintext, admin-key hashes and restrictions, model rules, sessions, cooldowns, credit/reward state, usage and catalogs | | `logs.sqlite3` | Request details and independent aggregate statistics | | `media/` + `media.sqlite3` | Generated media files and their metadata; kept for the retention period and never included in configuration backups | @@ -121,9 +131,9 @@ Auditing defaults to 30-day detail retention and a 256 MiB logical detail budget Mount the whole data directory on writable local storage, not just a single database file, and do not share it between gateway instances. -Stop the gateway before copying the entire directory, including databases, WAL/SHM files, credentials and migration backups; do not back up only `.info`. Protect the control database: it contains the default key and management sessions. +Stop the gateway before copying the entire directory, including databases, WAL/SHM files, credentials and migration backups; do not back up only `.info`. Protect the control database: it contains standard keys in plaintext, password/admin-key hashes and management sessions. -Configuration backups go to `backups/` in the data directory as 0600 tarballs. Each holds only an online `data/control.sqlite3` snapshot and `manifest.json`, which records the kind, the UTC time and the gateway version (plus the target version for a pre-update backup). Credentials (`*.info`), program files and `logs.sqlite3` are never included; the control database still holds the default key and management sessions, so keep backups private. +Configuration backups go to `backups/` in the data directory as 0600 tarballs. Each holds only an online `data/control.sqlite3` snapshot and `manifest.json`, which records the kind, the UTC time and the gateway version (plus the target version for a pre-update backup). Credentials (`*.info`), program files and `logs.sqlite3` are never included; the control database still holds standard-key plaintext, password/admin-key hashes and management sessions, so keep backups private. - **Pre-update:** every one-click update first writes `update--to--.tar.gz`. - **Manual:** **Storage → Configuration backups → Back up now** writes `manual--.tar.gz`. diff --git a/docs/webui.zh-CN.md b/docs/webui.zh-CN.md index dbb8602..9012fd5 100644 --- a/docs/webui.zh-CN.md +++ b/docs/webui.zh-CN.md @@ -4,9 +4,9 @@ ## 打开控制台 -按[部署指南](deployment.zh-CN.md)启动网关后,打开 `http://127.0.0.1:8787/dashboard`,使用当前 API key 登录。源码安装需要先构建前端;Docker 镜像已包含。非本机访问请使用 HTTPS。 +按[部署指南](deployment.zh-CN.md)启动网关后,打开 `http://127.0.0.1:8787/dashboard`。使用账号 `admin` 和外部密钥、启动时输出的密码,或升级时迁移的旧默认 key 登录。源码安装需要先构建前端;Docker 镜像已包含。非本机访问请使用 HTTPS。 -本地零配置首次启动会生成并保存默认 key,仅在终端显示一次;以后复用。显式空 key 锁定管理。更换生效 key 后需重新登录,并重新开始未完成的 OAuth 登录。 +在「系统设置 → 管理账户」修改用户名或密码。普通会话有效期 12 小时;「记住我」有效期 400 天并滑动续期。修改账户会撤销其他会话并返回新的 CSRF token。任何 API key 都不能访问 `/admin/*`,管理 Cookie 也不能访问 `/v1/*`。 ## 概览 @@ -44,11 +44,19 @@ - 倍率列对「声明免费但上游实际扣费」的模型显示 `≈0.037x · 实测计费`,悬浮查看样本数;手填倍率显示 `· 手填`。「编辑规则 → 消费倍率」可按产品手填倍率,「一键探测倍率」在每个产品上发起一次约 8K token 的请求,区分真 0 倍率与实测计费;页头「一键检测倍率」对所有声明免费的模型执行同样探测。详见[实测计费与输出上限](advanced.zh-CN.md#实测计费与输出上限)。 - 最大输出列叠加 `scripts/probe_max_tokens.py` 的实测结果:红色 `32K ⚠ 钳制 12×` 表示静默钳制,绿色 `48K ✓` / `64K ↑` 表示与声称一致或更大,灰色 `≥40K` 为下界,`?` 为未测出。 +## 密钥管理 + +「密钥管理」将推理密钥与管理账户分离。外部管理密钥只显示配置来源,不显示前缀或内容。数据库管理密钥最多一个、不受限制,只在创建或轮换时显示一次,库中仅保存哈希。 + +普通密钥支持命名、启停、有效期、IP/CIDR 白名单、RPM、模型限制、轮换、查看、复制和删除。普通密钥可在界面反复查看,并以明文存入 `control.sqlite3` 和配置备份,必须妥善保护。密钥只授权 `/v1/*`;普通密钥不能读取账号池 billing 或其他密钥的视频任务。 + +表格显示按小时审计桶估算的近 24 小时请求数和最后使用时间。审计降级显示 `—`;清空全部日志与统计会将计数归零。已删除密钥在旧审计中显示为「ID(已删除)」,升级前记录显示为「—(升级前)」。 + ## 日志 -筛选请求并查看失败尝试。关闭详情或切换日志类型会取消进行中的详情加载。清理明细保留历史统计。 +按密钥筛选请求并查看失败尝试。密钥列优先显示当前名称,删除后显示「ID(已删除)」,升级前记录显示「—(升级前)」。关闭详情或切换日志类型会取消进行中的详情加载。清理明细保留历史统计。 -「清空全部日志与统计」不可恢复:输入对话框中的确认文字,并重新输入当前 API key。此操作不删除凭证与网关配置。 +「清空全部日志与统计」不可恢复:输入对话框中的确认文字,并重新输入当前管理密码。此操作不删除凭证、API key 或网关配置。 ## 媒体生成 @@ -57,6 +65,7 @@ - **资源库**按时间倒序列出资源,含模型、大小与剩余保留时长;卡片打开预览抽屉,可查看元数据、下载与删除确认弹窗。**视频任务**用绑定账号刷新进行中的任务,并把完成的视频链回资源库。模型目录与路由统一在「模型路由」页管理。 - 图片默认保存到本地;未配置资源访问地址时返回 base64,不生成公开链接。显式 `response_format=b64_json` 不保存图片。视频始终保存到本地,配置地址后即可获得链接。 - **媒体设置**(`media_public_base_url`、`media_max_total_mb`、`media_ttl_hours`)分别设置公开链接前缀、容量(64–1048576 MB)与保留时长(1–8760 小时)。保留时长按创建时间与当前设置推导,调小后立即对已有资源生效;容量按最老优先在下次写入或定时清理时淘汰。资源同时经 `/v1/media/…` 公开读取,带不可变缓存头;见[媒体生成](clients.zh-CN.md#媒体生成)。 +- 普通密钥只能查看自己创建的视频任务,其他密钥的任务 ID 在上游轮询前返回 404。公开资源地址依赖不可猜测的 ID,停用或删除密钥不会撤销资源,直到媒体 TTL 到期。 ## 系统设置 @@ -65,6 +74,7 @@ - 「保留工具描述」默认关闭,对三种协议生效,与提示词压缩相互独立;见[工具元数据保留](advanced.zh-CN.md#工具元数据保留)。 - 「表外模型拦截」与「模型能力预检」开关位于「模型路由」顶部栏,默认开启;关闭能力预检只影响新请求,不影响元数据展示、国际图片消息合并与既有安全限制;见[模型声明](advanced.zh-CN.md#模型声明与图片兼容)。 - 「管理页额外信任来源」(`admin_allowed_origins`)用于修复 HTTPS 反代后的登录;见[管理 Origin 校验](advanced.zh-CN.md#管理-origin--csrf-开关)。 +- 「管理账户」先验证当前密码再修改用户名或密码,成功后采用返回的新 CSRF token;也可撤销包括当前浏览器在内的所有会话。 ## 版本更新 @@ -113,7 +123,7 @@ | 文件 | 内容 | |------|------| | `*.info` | 官方明文凭证;绝不迁移进 SQLite | -| `control.sqlite3` | 网关设置、私有默认 key、模型规则、会话、冷却、积分/领取、用量与目录缓存 | +| `control.sqlite3` | 网关设置、scrypt 密码哈希、普通密钥明文、管理密钥哈希与限制、模型规则、会话、冷却、积分/领取、用量与目录缓存 | | `logs.sqlite3` | 请求明细与独立的聚合统计 | | `media/` + `media.sqlite3` | 生成媒体文件与元数据;按保留时长清理,不进配置备份 | @@ -121,9 +131,9 @@ 将整个数据目录挂载到可写本地存储,而不是只挂载单个数据库文件;不要在多个网关实例间共享。 -复制前停止网关,并复制整个目录,包括数据库、WAL/SHM、凭证及迁移备份;不要只备份 `.info`。控制库包含默认密钥与登录会话,必须妥善保管。 +复制前停止网关,并复制整个目录,包括数据库、WAL/SHM、凭证及迁移备份;不要只备份 `.info`。控制库包含普通密钥明文、密码/管理密钥哈希与登录会话,必须妥善保管。 -配置备份保存在数据目录 `backups/`,权限为 0600。每份只含在线拷贝的 `data/control.sqlite3` 快照与 `manifest.json`;后者记录备份类型、UTC 时间与网关版本,更新前的备份另记目标版本。备份不含凭证(`*.info`)、程序文件与 `logs.sqlite3`;配置库仍含默认密钥与登录会话,请妥善保管。 +配置备份保存在数据目录 `backups/`,权限为 0600。每份只含在线拷贝的 `data/control.sqlite3` 快照与 `manifest.json`;后者记录备份类型、UTC 时间与网关版本,更新前的备份另记目标版本。备份不含凭证(`*.info`)、程序文件与 `logs.sqlite3`;配置库仍含普通密钥明文、密码/管理密钥哈希与登录会话,请妥善保管。 - **更新前**:每次一键更新前生成 `update-<旧版本>-to-<新版本>-.tar.gz`。 - **手动**:「存储管理 → 配置备份 → 立即备份」生成 `manual-<版本>-.tar.gz`。 diff --git a/preview/__main__.py b/preview/__main__.py index 6d6ee40..443798b 100644 --- a/preview/__main__.py +++ b/preview/__main__.py @@ -29,8 +29,9 @@ def prepare(directory: Path) -> Path: if any(directory.iterdir()): sys.exit(f"预览数据目录必须为空或由预览服务创建:{directory}") seed.write_credentials(directory) - seed.write_history(directory / "logs.sqlite3") - seed.write_media(directory) + key_ids = seed.write_api_keys(directory) + seed.write_history(directory / "logs.sqlite3", key_ids=list(key_ids.values())) + seed.write_media(directory, api_key=key_ids["models"]) (directory / MARKER).write_text("codebuddy2api preview data\n", encoding="utf-8") return directory diff --git a/preview/seed.py b/preview/seed.py index 9f0c262..90ddf8d 100644 --- a/preview/seed.py +++ b/preview/seed.py @@ -109,7 +109,26 @@ def write_credentials(directory, now: float | None = None): json.dumps(credential(account, now), ensure_ascii=False, indent=2).encode()) -def write_history(path, now: float | None = None, days: int = 30, seed: int = 7): +def write_api_keys(directory): + """Create three restricted inference keys and return their ids for other seed data.""" + from app.api_keys import KeyStore + from app.control_store import ControlStore + + directory = Path(directory) + control = ControlStore(directory / "control.sqlite3") + try: + keys = KeyStore(control) + expired, _ = keys.create(name="expired", kind="standard") + ip, _ = keys.create(name="ip", kind="standard", ip_allow=["127.0.0.1"], rpm=60) + models, _ = keys.create(name="models", kind="standard", models=["auto"]) + control._db.execute("UPDATE api_keys SET expires_at=? WHERE id=?", (time.time() - 3600, expired["id"])) + control._db.commit() + return {"expired": expired["id"], "ip": ip["id"], "models": models["id"]} + finally: + control.close() + + +def write_history(path, now: float | None = None, days: int = 30, seed: int = 7, key_ids=()): """Backfill request records so the dashboard and logs open with a month of traffic.""" now = time.time() if now is None else now rng = random.Random(seed) @@ -122,12 +141,13 @@ def write_history(path, now: float | None = None, days: int = 30, seed: int = 7) for account in ACCOUNTS: for _ in range(round(account.daily_requests * scale * rng.uniform(0.6, 1.4))): started = now - day * DAY - rng.uniform(0, DAY if day else 6 * 3600) - store.record_request(_record(account, started, rng)) + store.record_request(_record(account, started, rng, + api_key=rng.choice(key_ids) if key_ids else None)) finally: store.close() -def _record(account: Account, started: float, rng: random.Random) -> dict: +def _record(account: Account, started: float, rng: random.Random, *, api_key=None) -> dict: model = rng.choice(account.models) protocol = rng.choices(("chat", "responses", "messages"), (5, 3, 4))[0] failed = rng.random() < 0.04 @@ -138,7 +158,7 @@ def _record(account: Account, started: float, rng: random.Random) -> dict: duration = int(rng.lognormvariate(8.3, 0.6)) status = rng.choice((429, 500, 503)) if failed else 200 return { - "id": uuid.UUID(int=rng.getrandbits(128)).hex, "started_at": started, + "id": uuid.UUID(int=rng.getrandbits(128)).hex, "started_at": started, "api_key": api_key, "model": model, "upstream_model": model, "profile": account.profile, "credential": identity, "protocol": protocol, "streaming": rng.random() < 0.8, "outcome": "error" if failed else "success", "status_code": status, "error_code": "upstream_error" if failed else None, @@ -153,7 +173,7 @@ def _record(account: Account, started: float, rng: random.Random) -> dict: } -def write_media(directory, now: float | None = None): +def write_media(directory, now: float | None = None, *, api_key=None): """Seed local media assets and video tasks so the dashboard media page opens populated.""" from app.media_store import MediaStore @@ -168,7 +188,7 @@ def write_media(directory, now: float | None = None): store._db.commit() video = store.save_asset("video", MP4_BYTES, model="minimax-video-h3", **quota) aurora = account_key("cn-cli", "100001", "") - store.create_task("seedvideotask-complete", aurora, "cn-cli", "minimax-video-h3") + store.create_task("seedvideotask-complete", aurora, "cn-cli", "minimax-video-h3", api_key=api_key) store.update_task("seedvideotask-complete", status="completed", media_id=video["id"], credit=12.5) # Stays in progress until someone refreshes it; the simulated upstream knows this id. diff --git a/tests/admin_session.py b/tests/admin_session.py new file mode 100644 index 0000000..2145db8 --- /dev/null +++ b/tests/admin_session.py @@ -0,0 +1,38 @@ +"""Shared management-session helpers for offline tests.""" +from unittest.mock import AsyncMock + +from fastapi import Request + + +def credentials(password): + return {"username": "admin", "password": password, "remember": False} + + +def login(client, *, username="admin", password="synthetic-key", origin="http://testserver"): + result = client.post( + "/admin/session", + json={"username": username, "password": password, "remember": False}, + headers={"Origin": origin}, + ) + assert result.status_code == 200, result.text + return {"Origin": origin, "X-CSRF-Token": result.json()["csrf_token"]} + + +def direct_request(body=None, *, identity="key:offline-test", method="POST", path="/admin/credentials"): + scope = { + "type": "http", + "asgi": {"version": "3.0", "spec_version": "2.3"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": b"", + "headers": [], + "client": ("test", 1), + "server": ("test", 80), + "state": {"admin_identity": identity} if identity else {}, + } + request = Request(scope) + request.json = AsyncMock(return_value=body) + return request diff --git a/tests/test_admin_account.py b/tests/test_admin_account.py new file mode 100644 index 0000000..649ab71 --- /dev/null +++ b/tests/test_admin_account.py @@ -0,0 +1,175 @@ +"""Management-account startup, password, session and recovery contracts.""" +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import hashlib +import io +import re +import tempfile +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +from app import admin_account +from app.admin_auth import AdminAuth +from app.control_store import ControlStore +from app.startup import resolve_startup_auth +from converter import admin_reset_password + + +class AdminAccountTests(unittest.TestCase): + def setUp(self): + self.root = Path(self.enterContext(tempfile.TemporaryDirectory())) + self.control = ControlStore(self.root / "control.sqlite3") + self.addCleanup(self.control.close) + self.config = { + "control_store": self.control, + "state_store": self.control.state, + "settings_sources": {}, + "api_key": "", + "host": "127.0.0.1", + "session_path": self.root / "sessions.json", + } + + def resolve(self, key="", source="default"): + self.config["api_key"] = key + self.config["settings_sources"]["api_key"] = source + args = SimpleNamespace(api_key=key) + output = io.StringIO() + with patch("sys.stdout", output), patch("app.startup.terminal_stream", side_effect=OSError("no tty")): + resolve_startup_auth(self.config, args) + return args, output.getvalue() + + def auth(self): + return AdminAuth(self.config) + + def request(self): + return SimpleNamespace(client=SimpleNamespace(host="offline-test"), cookies={}) + + def test_generated_account_is_disclosed_once_without_inference_key(self): + args, first = self.resolve() + account = admin_account.load(self.control) + self.assertEqual(account.password_source, "generated") + self.assertEqual(self.config["api_key"], "") + self.assertEqual(args.api_key, "") + self.assertEqual(self.control._db.execute("SELECT count(*) FROM api_keys").fetchone()[0], 0) + self.assertEqual(first.count("初始管理密码:"), 1) + _, second = self.resolve() + self.assertEqual(second, "") + + def test_external_change_blank_fallback_and_matching_password_transition(self): + self.config["settings_sources"]["api_key"] = "cli" + self.resolve("external-key", "cli") + account = admin_account.load(self.control) + self.assertEqual(account.password_source, "external") + epoch = account.session_epoch + self.resolve("external-key", "cli") + self.assertEqual(admin_account.load(self.control).session_epoch, epoch) + self.resolve("new-external-key", "environment") + changed = admin_account.load(self.control) + self.assertNotEqual(changed.session_epoch, epoch) + self.assertEqual(changed.password_source, "external") + _, output = self.resolve(" ", "environment") + fallback = admin_account.load(self.control) + self.assertEqual(fallback.password_source, "generated") + self.assertEqual(self.config["api_key"], "") + self.assertEqual(self.config["settings_sources"]["api_key"], "default") + self.assertEqual(output.count("初始管理密码:"), 1) + + def test_matching_generated_password_only_changes_source_not_epoch(self): + _, output = self.resolve() + password = re.search(r"初始管理密码:([^\n]+)", output).group(1) + before = admin_account.load(self.control) + self.resolve(password, "cli") + after = admin_account.load(self.control) + self.assertEqual(after.password_source, "external") + self.assertEqual(after.session_epoch, before.session_epoch) + + def test_legacy_key_migrates_hash_and_never_returns_to_config(self): + secret = "cb-" + "a" * 32 + self.control._db.execute( + "INSERT INTO gateway_secrets(name,value,announced) VALUES('default_api_key',?,1)", (secret,)) + args, output = self.resolve() + account = admin_account.load(self.control) + self.assertEqual(account.password_source, "migrated") + self.assertTrue(admin_account.verify(account, "admin", secret)) + self.assertEqual(output, "") + self.assertEqual(args.api_key, "") + self.assertEqual(self.config["api_key"], "") + self.assertIsNone(self.control._db.execute( + "SELECT value FROM gateway_secrets WHERE name='default_api_key'").fetchone()) + row = self.control._db.execute("SELECT type,secret,secret_hash FROM api_keys").fetchone() + self.assertEqual(row[0], "admin") + self.assertIsNone(row[1]) + self.assertEqual(row[2], hashlib.sha256(secret.encode()).hexdigest()) + + def test_unannounced_legacy_output_failure_preserves_then_recovers(self): + secret = "cb-" + "b" * 32 + self.control._db.execute( + "INSERT INTO gateway_secrets(name,value,announced) VALUES('default_api_key',?,0)", (secret,)) + with patch("app.startup._announce_admin_password", side_effect=ValueError("无法输出初始管理密码")): + with self.assertRaisesRegex(ValueError, "无法输出初始管理密码"): + self.resolve() + self.assertEqual(self.control._db.execute( + "SELECT value FROM gateway_secrets WHERE name='default_api_key'").fetchone()[0], secret) + self.assertIsNone(admin_account.load(self.control)) + self.assertEqual(self.control._db.execute("SELECT count(*) FROM api_keys").fetchone()[0], 0) + _, output = self.resolve() + self.assertIn(secret, output) + self.assertEqual(admin_account.load(self.control).password_source, "migrated") + self.assertEqual(self.control._db.execute( + "SELECT imported FROM state_imports WHERE name='admin_auth_v1'").fetchone()[0], 1) + + def test_every_verify_runs_scrypt_exactly_once(self): + account = admin_account.new("admin", "external-key", "external") + with patch("app.admin_account._derive", return_value=b"x" * 32) as derive: + self.assertFalse(admin_account.verify(account, "admin", "wrong-password")) + self.assertFalse(admin_account.verify(account, "other", "external-key")) + self.assertFalse(admin_account.verify(account, "admin", "external-key")) + self.assertEqual(derive.call_count, 3) + + def test_login_epoch_race_never_issues_a_session(self): + auth = self.auth() + admin_account.save(self.control, admin_account.new("admin", "synthetic-key", "user")) + original = admin_account.verify + + def rotate_during_verify(account, username, password): + admin_account.save(self.control, admin_account.with_password(account, "new-password")) + return original(account, username, password) + + with patch("app.admin_auth.admin_account.verify", side_effect=rotate_during_verify): + result, status = auth.login(self.request(), "admin", "synthetic-key") + self.assertEqual((result, status), (None, 503)) + self.assertEqual(len(auth.sessions), 0) + + def test_login_capacity_remember_and_revoke_all(self): + admin_account.save(self.control, admin_account.new("admin", "synthetic-key", "user")) + auth = self.auth() + with patch("app.admin_account._VERIFY_SLOTS.acquire", return_value=False): + result, status = auth.login(self.request(), "admin", "synthetic-key") + self.assertEqual((result, status), (None, 429)) + (sid, session), status = auth.login(self.request(), "admin", "synthetic-key", True) + self.assertEqual(status, 200) + self.assertTrue(session["remember"]) + self.assertGreater(session["expires"], 399 * 86400) + self.assertTrue(auth.revoke_all()) + self.assertEqual(auth.session(SimpleNamespace(cookies={"codebuddy_admin_session": sid})), (None, None)) + + def test_cli_reset_updates_account_and_revokes_existing_session(self): + admin_account.save(self.control, admin_account.new("admin", "synthetic-key", "user")) + auth = self.auth() + (sid, _), status = auth.login(self.request(), "admin", "synthetic-key") + self.assertEqual(status, 200) + args = SimpleNamespace(data_dir=str(self.root), username="operator") + with patch("sys.stdin.isatty", return_value=True), \ + patch("converter.getpass.getpass", side_effect=["new-password", "new-password"]): + self.assertEqual(admin_reset_password(args), 0) + account = admin_account.load(self.control) + self.assertEqual((account.username, account.password_source), ("operator", "user")) + self.assertTrue(admin_account.verify(account, "operator", "new-password")) + self.assertEqual(auth.session(SimpleNamespace(cookies={"codebuddy_admin_session": sid})), (None, None)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_admin_api.py b/tests/test_admin_api.py index 2da6e9a..f84d2f8 100644 --- a/tests/test_admin_api.py +++ b/tests/test_admin_api.py @@ -12,9 +12,15 @@ from fastapi import FastAPI, Header from fastapi.testclient import TestClient +from app import admin_account from app.admin_api import CLEAR_CONFIRMATION, install_admin from app.admin_auth import COOKIE_NAME, origin_allowlist, same_origin from app.control_store import ControlStore +from tests.admin_session import login as session_login + + +def credentials(password): + return {"username": "admin", "password": password, "remember": False} class AdminApiTests(unittest.TestCase): @@ -22,10 +28,12 @@ def setUp(self): self.root = Path(self.enterContext(tempfile.TemporaryDirectory())) self.store = ControlStore(self.root / "control.sqlite3") self.addCleanup(self.store.close) + admin_account.save(self.store, admin_account.new("admin", "synthetic-key", "user")) self.config = {"api_key": "synthetic-key", "control_store": self.store, "max_images": 16, "session_path": self.root / "admin-sessions.json"} self.audit = Mock() self.audit.storage.return_value = {"db_bytes": 0} + self.audit.api_key_stats.return_value = {"items": {}} self.audit.list_records.return_value = {"items": [], "next_cursor": None, "has_more": False} self.audit.get_request.return_value = None self.audit.dashboard.return_value = {"summary": {}, "series": [], "models": [], "profiles": []} @@ -79,16 +87,15 @@ def inference(authorization: str = Header(default="")): self.auth = install_admin(self.app, self.config, self.gateway) self.client = self.enterContext(TestClient(self.app)) - self.headers = {"Authorization": "Bearer synthetic-key"} + self.headers = self.login() def login(self, client=None): - client = client or self.client - result = client.post("/admin/session", json={"api_key": "synthetic-key"}, headers={"Origin": "http://testserver"}) - self.assertEqual(result.status_code, 200, result.text) - return {"Origin": "http://testserver", "X-CSRF-Token": result.json()["csrf_token"]} + return session_login(client or self.client) def test_header_cookie_isolation_csrf_and_no_cache(self): - self.assertEqual(self.client.post("/admin/legacy").status_code, 401) + anonymous = TestClient(self.app) + self.assertEqual(anonymous.post("/admin/legacy").status_code, 401) + anonymous.close() csrf = self.login() cookie = self.client.cookies.get(COOKIE_NAME) self.assertTrue(cookie) @@ -96,11 +103,13 @@ def test_header_cookie_isolation_csrf_and_no_cache(self): self.assertEqual(self.client.post("/admin/legacy", headers={**csrf, "Origin": "https://evil.invalid"}).status_code, 403) good = self.client.post("/admin/legacy", headers=csrf) self.assertEqual(good.status_code, 200) - self.assertEqual(self.legacy_auth, ["Bearer synthetic-key"]) + self.assertEqual(self.legacy_auth, [""]) self.assertEqual(good.headers["cache-control"], "no-store") # Force the cookie onto an inference request: middleware still never injects key. self.assertFalse(self.client.get("/v1/identity", headers={"Cookie": f"{COOKIE_NAME}={cookie}"}).json()["authorization_present"]) - self.assertEqual(self.client.post("/admin/legacy", headers=self.headers).status_code, 200) + anonymous = TestClient(self.app) + self.assertEqual(anonymous.post("/admin/legacy", headers={"Authorization": "Bearer synthetic-key"}).status_code, 401) + anonymous.close() def test_same_origin_referer_fallback_is_strict_and_get_only(self): from starlette.requests import Request @@ -163,10 +172,10 @@ def check(headers, allowed, method="GET"): def test_allowed_origins_permit_foreign_origin_login_and_cookie_writes(self): self.config["admin_allowed_origins"] = "https://chat.example.com" foreign = {"Origin": "https://chat.example.com"} - self.assertEqual(self.client.post("/admin/session", json={"api_key": "synthetic-key"}).status_code, 403) - self.assertEqual(self.client.post("/admin/session", json={"api_key": "synthetic-key"}, + self.assertEqual(self.client.post("/admin/session", json=credentials("synthetic-key")).status_code, 403) + self.assertEqual(self.client.post("/admin/session", json=credentials("synthetic-key"), headers={"Origin": "https://evil.invalid"}).status_code, 403) - login = self.client.post("/admin/session", json={"api_key": "synthetic-key"}, headers=foreign) + login = self.client.post("/admin/session", json=credentials("synthetic-key"), headers=foreign) self.assertEqual(login.status_code, 200, login.text) csrf = {**foreign, "X-CSRF-Token": login.json()["csrf_token"]} self.assertEqual(self.client.post("/admin/legacy", headers=csrf).status_code, 200) @@ -179,26 +188,30 @@ def test_allowed_origins_setting_validates_normalizes_and_applies_hot(self): stored = self.store.snapshot()["settings"]["admin_allowed_origins"] self.assertEqual(stored, "https://chat.example.com,http://10.0.0.1:8787,https://dup.example.com") self.assertEqual(self.config["admin_allowed_origins"], stored) - login = self.client.post("/admin/session", json={"api_key": "synthetic-key"}, + login = self.client.post("/admin/session", json=credentials("synthetic-key"), headers={"Origin": "https://chat.example.com"}) self.assertEqual(login.status_code, 200, login.text) - cleared = self.client.patch("/admin/settings", headers=self.headers, json={ - "revision": 1, "values": {"admin_allowed_origins": ""}}) + cleared = self.client.patch("/admin/settings", + headers={**self.headers, "X-CSRF-Token": login.json()["csrf_token"]}, + json={"revision": 1, "values": {"admin_allowed_origins": ""}}) self.assertEqual(cleared.status_code, 200, cleared.text) self.assertEqual(self.config["admin_allowed_origins"], "") - self.assertEqual(self.client.post("/admin/session", json={"api_key": "synthetic-key"}, + self.assertEqual(self.client.post("/admin/session", json=credentials("synthetic-key"), headers={"Origin": "https://chat.example.com"}).status_code, 403) for invalid in ("ftp://chat.example.com", "https://chat.example.com/path", "http://user@chat.example.com", "https://chat.example.com#fragment", "https://chat.example.com:0", "::"): with self.subTest(invalid=invalid): - rejected = self.client.patch("/admin/settings", headers=self.headers, json={ - "revision": self.store.snapshot()["revision"], "values": {"admin_allowed_origins": invalid}}) + rejected = self.client.patch( + "/admin/settings", + headers={**self.headers, "X-CSRF-Token": login.json()["csrf_token"]}, + json={"revision": self.store.snapshot()["revision"], + "values": {"admin_allowed_origins": invalid}}) self.assertEqual(rejected.status_code, 400) self.assertEqual(self.store.snapshot()["settings"]["admin_allowed_origins"], "") def test_lan_oauth_poll_uses_referer_without_disabling_csrf(self): origin = "http://192.168.1.10:8787" client = self.enterContext(TestClient(self.app, base_url=origin)) - login = client.post("/admin/session", headers={"Origin": origin}, json={"api_key": "synthetic-key"}) + login = client.post("/admin/session", headers={"Origin": origin}, json=credentials("synthetic-key")) self.assertEqual(login.status_code, 200, login.text) token = login.json()["csrf_token"] started = client.post("/admin/oauth/start", headers={"Origin": origin, "X-CSRF-Token": token}) @@ -215,7 +228,7 @@ def test_lan_oauth_poll_uses_referer_without_disabling_csrf(self): with self.subTest(headers=headers): self.assertEqual(client.get("/admin/oauth/poll", params=params, headers=headers).status_code, 403) other = self.enterContext(TestClient(self.app, base_url=origin)) - other_login = other.post("/admin/session", headers={"Origin": origin}, json={"api_key": "synthetic-key"}) + other_login = other.post("/admin/session", headers={"Origin": origin}, json=credentials("synthetic-key")) self.assertEqual(other_login.status_code, 200) response = other.get("/admin/oauth/poll", params=params, headers={ "Referer": referer, "X-CSRF-Token": other_login.json()["csrf_token"]}) @@ -235,13 +248,13 @@ def test_admin_csrf_defaults_to_enabled_and_requires_explicit_false(self): with self.subTest(value=value): self.config["admin_csrf"] = value self.assertTrue(self.auth.csrf_enabled()) - self.assertEqual(self.client.post("/admin/session", json={"api_key": "synthetic-key"}).status_code, 403) + self.assertEqual(self.client.post("/admin/session", json=credentials("synthetic-key")).status_code, 403) self.config["admin_csrf"] = False self.assertFalse(self.auth.csrf_enabled()) def test_disabled_csrf_allows_cookie_writes_and_oauth_poll(self): self.config["admin_csrf"] = False - response = self.client.post("/admin/session", json={"api_key": "synthetic-key"}) + response = self.client.post("/admin/session", json=credentials("synthetic-key")) self.assertEqual(response.status_code, 200, response.text) self.assertTrue(response.json()["csrf_token"]) self.assertIn("HttpOnly", response.headers["set-cookie"]) @@ -262,11 +275,13 @@ def test_disabled_csrf_allows_cookie_writes_and_oauth_poll(self): self.assertEqual(self.client.delete("/admin/session").status_code, 200) self.assertEqual(self.client.post("/admin/legacy").status_code, 401) - def test_disabled_csrf_keeps_authentication_and_sensitive_action_checks(self): + def test_disabled_csrf_keeps_session_and_sensitive_action_checks(self): self.config["admin_csrf"] = False - self.assertEqual(self.client.post("/admin/legacy").status_code, 401) - self.assertEqual(self.client.post("/admin/legacy", headers={"X-Api-Key": "wrong"}).status_code, 401) - self.assertEqual(self.client.post("/admin/session", json={"api_key": "wrong"}).status_code, 401) + anonymous = TestClient(self.app) + self.assertEqual(anonymous.post("/admin/legacy").status_code, 401) + self.assertEqual(anonymous.post("/admin/legacy", headers={"X-Api-Key": "wrong"}).status_code, 401) + anonymous.close() + self.assertEqual(self.client.post("/admin/session", json=credentials("wrong")).status_code, 401) self.login() cookie = self.client.cookies.get(COOKIE_NAME) self.assertFalse(self.client.get("/v1/identity", headers={"Cookie": f"{COOKIE_NAME}={cookie}"}).json()["authorization_present"]) @@ -276,10 +291,10 @@ def test_disabled_csrf_keeps_authentication_and_sensitive_action_checks(self): self.gateway.admin_delete_guard.side_effect = ValueError("referenced") self.assertEqual(self.client.delete("/admin/credentials/first.info").status_code, 409) self.config["api_key"] = "rotated-key" - self.assertEqual(self.client.post("/admin/legacy").status_code, 401) + self.assertEqual(self.client.post("/admin/legacy").status_code, 200) self.config["api_key"] = "" for path in ("/admin/session", "/admin/settings", "/admin/credentials"): - self.assertEqual(self.client.get(path).status_code, 503) + self.assertEqual(self.client.get(path).status_code, 200) def test_disabled_csrf_keeps_oauth_ownership_and_official_site_checks(self): self.config["admin_csrf"] = False @@ -298,8 +313,8 @@ def test_disabled_csrf_keeps_oauth_ownership_and_official_site_checks(self): def test_disabled_csrf_keeps_login_throttle(self): self.config["admin_csrf"] = False for _ in range(10): - self.assertEqual(self.client.post("/admin/session", json={"api_key": "wrong"}).status_code, 401) - self.assertEqual(self.client.post("/admin/session", json={"api_key": "synthetic-key"}).status_code, 429) + self.assertEqual(self.client.post("/admin/session", json=credentials("wrong")).status_code, 401) + self.assertEqual(self.client.post("/admin/session", json=credentials("synthetic-key")).status_code, 429) def test_admin_csrf_cannot_be_changed_through_management_settings(self): for enabled in (True, False): @@ -311,8 +326,8 @@ def test_admin_csrf_cannot_be_changed_through_management_settings(self): self.assertIs(self.config["admin_csrf"], enabled) self.assertNotIn("admin_csrf", self.store.snapshot()["settings"]) - def test_session_cookie_flags_logout_and_rotation(self): - response = self.client.post("/admin/session", json={"api_key": "synthetic-key"}, headers={"Origin": "http://testserver"}) + def test_session_cookie_flags_logout_and_account_epoch_rotation(self): + response = self.client.post("/admin/session", json=credentials("synthetic-key"), headers={"Origin": "http://testserver"}) cookie = response.headers["set-cookie"] self.assertIn("HttpOnly", cookie) self.assertIn("SameSite=strict", cookie) @@ -322,15 +337,18 @@ def test_session_cookie_flags_logout_and_rotation(self): self.assertEqual(self.client.delete("/admin/session", headers=csrf).status_code, 200) self.assertEqual(self.client.get("/admin/settings", headers={"Cookie": f"{COOKIE_NAME}={old}"}).status_code, 401) self.login() - self.config["api_key"] = "rotated-key" + account = admin_account.load(self.store) + admin_account.save(self.store, admin_account.with_password(account, "rotated-key")) self.assertEqual(self.client.get("/admin/settings").status_code, 401) self.assertEqual(self.client.get("/admin/settings", headers=self.headers).status_code, 401) - self.assertEqual(self.client.get("/admin/settings", headers={"X-Api-Key": "rotated-key"}).status_code, 200) + self.assertEqual(self.client.get("/admin/settings", headers={"X-Api-Key": "rotated-key"}).status_code, 401) + renewed = session_login(self.client, password="rotated-key") + self.assertEqual(self.client.get("/admin/settings", headers=renewed).status_code, 200) def test_logout_reports_when_the_session_could_not_be_persistently_revoked(self): """A logout that cannot reach durable storage must not answer `authenticated: false`.""" from unittest.mock import patch - response = self.client.post("/admin/session", json={"api_key": "synthetic-key"}, headers={"Origin": "http://testserver"}) + response = self.client.post("/admin/session", json=credentials("synthetic-key"), headers={"Origin": "http://testserver"}) csrf = {"Origin": "http://testserver", "X-CSRF-Token": response.json()["csrf_token"]} old = self.client.cookies.get(COOKIE_NAME) with patch("app.admin_auth.tempfile.mkstemp", side_effect=OSError("read-only")), \ @@ -353,9 +371,10 @@ def test_a_superseded_snapshot_that_cannot_be_revoked_fails_closed(self): """Mid-process epoch changes must deny with 503, never serve management traffic.""" from unittest.mock import patch self.assertEqual(self.client.get("/admin/settings", headers=self.headers).status_code, 200) - self.config["api_key"] = "rotated-synthetic-key" with patch.object(type(self.auth), "_persist", return_value=False), \ patch.object(type(self.auth), "_revoke", return_value=False): + account = admin_account.load(self.store) + admin_account.save(self.store, admin_account.with_password(account, "rotated-synthetic-key")) denied = self.client.get("/admin/settings", headers=self.headers) self.assertEqual(denied.status_code, 503) self.assertIn("会话快照", denied.json()["error"]["message"]) @@ -363,23 +382,24 @@ def test_a_superseded_snapshot_that_cannot_be_revoked_fails_closed(self): def test_https_cookie_and_bounded_sessions(self): from starlette.requests import Request secure = self.enterContext(TestClient(self.app, base_url="https://testserver")) - response = secure.post("/admin/session", headers={"Origin": "https://testserver"}, json={"api_key": "synthetic-key"}) + response = secure.post("/admin/session", headers={"Origin": "https://testserver"}, json=credentials("synthetic-key")) self.assertIn("Secure", response.headers["set-cookie"]) request = Request({"type": "http", "headers": [], "client": ("synthetic", 1)}) for _ in range(300): - self.auth.login(request, "synthetic-key") + self.auth.login(request, "admin", "synthetic-key") self.assertEqual(len(self.auth.sessions), 256) - def test_empty_key_locks_every_management_route(self): + def test_empty_external_key_does_not_lock_management_with_an_account(self): self.config["api_key"] = "" - for path in ("/admin/session", "/admin/settings", "/admin/credentials", "/admin/missing"): - self.assertEqual(self.client.get(path, headers=self.headers).status_code, 503) + for path in ("/admin/session", "/admin/settings", "/admin/credentials"): + self.assertEqual(self.client.get(path, headers=self.headers).status_code, 200) + self.assertEqual(self.client.get("/admin/missing", headers=self.headers).status_code, 404) def test_login_origin_throttle_and_bound(self): - self.assertEqual(self.client.post("/admin/session", json={"api_key": "synthetic-key"}).status_code, 403) + self.assertEqual(self.client.post("/admin/session", json=credentials("synthetic-key")).status_code, 403) for _ in range(10): - self.assertEqual(self.client.post("/admin/session", json={"api_key": "wrong"}, headers={"Origin": "http://testserver"}).status_code, 401) - self.assertEqual(self.client.post("/admin/session", json={"api_key": "synthetic-key"}, headers={"Origin": "http://testserver"}).status_code, 429) + self.assertEqual(self.client.post("/admin/session", json=credentials("wrong"), headers={"Origin": "http://testserver"}).status_code, 401) + self.assertEqual(self.client.post("/admin/session", json=credentials("synthetic-key"), headers={"Origin": "http://testserver"}).status_code, 429) self.assertLessEqual(len(self.auth.failures), 1024) def test_storage_read_failures_are_not_empty_successes(self): @@ -402,7 +422,7 @@ def test_failed_audit_configuration_does_not_publish_runtime_values(self): self.assertEqual(self.store.snapshot()["settings"]["max_images"], 3) - def test_key_rotation_does_not_revive_pending_header_oauth(self): + def test_external_key_rotation_does_not_change_pending_session_oauth(self): started = self.client.post("/admin/oauth/start?site=cn", headers=self.headers) self.assertEqual(started.status_code, 200) task = started.json()["login_id"] @@ -411,9 +431,9 @@ def test_key_rotation_does_not_revive_pending_header_oauth(self): self.assertEqual(response.status_code, 200) self.config["api_key"] = "synthetic-key" response = self.client.get("/admin/oauth/poll", params={"login_id": task}, headers=self.headers) - self.assertEqual(response.status_code, 404) - self.gateway._OAUTH.poll.assert_not_called() - self.gateway._save_oauth_credential.assert_not_called() + self.assertEqual(response.status_code, 200) + self.gateway._OAUTH.poll.assert_called_once() + self.gateway._save_oauth_credential.assert_called_once() def test_tool_metadata_setting_is_hot_persisted_and_respects_locks(self): @@ -487,8 +507,12 @@ def inventory(): def test_catalog_scan_routes_require_session_and_delegate(self): - self.assertEqual(self.client.post("/admin/models/scan").status_code, 401) - self.assertEqual(self.client.get("/admin/models/scan").status_code, 401) + anonymous = TestClient(self.app) + self.assertEqual(anonymous.post("/admin/models/scan").status_code, 401) + anonymous.close() + anonymous = TestClient(self.app) + self.assertEqual(anonymous.get("/admin/models/scan").status_code, 401) + anonymous.close() csrf = self.login() started = self.client.post("/admin/models/scan", headers=csrf) self.assertEqual(started.status_code, 200, started.text) @@ -650,7 +674,9 @@ def test_config_notice_lists_runtime_env_and_dismissal_persists(self): self.assertTrue(self.store.snapshot()["settings"]["config_notice_muted"]) again = self.client.get("/admin/dashboard", headers=self.headers) self.assertNotIn("config_notice", again.json()) - self.assertEqual(self.client.post("/admin/config-notice/dismiss").status_code, 401) + anonymous = TestClient(self.app) + self.assertEqual(anonymous.post("/admin/config-notice/dismiss").status_code, 401) + anonymous.close() def test_export_only_selected_info_and_symlink_identity_protection(self): content = json.dumps(self.credential()) @@ -733,7 +759,7 @@ def test_logs_dashboard_clear_confirmations_and_404(self): self.assertEqual(self.client.get("/admin/dashboard?days=7", headers=self.headers).status_code, 200) self.assertEqual(self.client.get("/admin/dashboard?days=8", headers=self.headers).status_code, 400) self.assertEqual(self.client.post("/admin/logs/clear", headers=self.headers, json={"scope": "all", "confirmation": CLEAR_CONFIRMATION}).status_code, 403) - response = self.client.post("/admin/logs/clear", headers=self.headers, json={"scope": "all", "confirmation": CLEAR_CONFIRMATION, "api_key": "synthetic-key"}) + response = self.client.post("/admin/logs/clear", headers=self.headers, json={"scope": "all", "confirmation": CLEAR_CONFIRMATION, "password": "synthetic-key"}) self.assertEqual(response.status_code, 200) self.audit.clear.assert_called_once_with("all") self.assertEqual(self.client.get("/admin/missing", headers=self.headers).status_code, 404) diff --git a/tests/test_admin_boundaries.py b/tests/test_admin_boundaries.py index ff12c9c..1e78f7c 100644 --- a/tests/test_admin_boundaries.py +++ b/tests/test_admin_boundaries.py @@ -415,7 +415,16 @@ def test_snapshot_bounds_the_session_table(self): for _ in range(MAX_PERSISTED_SESSIONS + 20): self.login(auth) self.assertLessEqual(len(auth.sessions), MAX_PERSISTED_SESSIONS) - self.assertLessEqual(len(self.revive(next(iter(auth.sessions)))[1]), 2) + self.assertLessEqual(len(self.revive(next(iter(auth.sessions)))[1]), 3) + + def test_session_capacity_evicts_the_least_recently_used_entry(self): + auth = AdminAuth(self.config) + sids = [self.login(auth)[0] for _ in range(MAX_PERSISTED_SESSIONS)] + self.assertEqual(auth.session(request(cookie=sids[0]))[0], sids[0]) + newest, _ = self.login(auth) + self.assertIn(sids[0], auth.sessions) + self.assertIn(newest, auth.sessions) + self.assertNotIn(sids[1], auth.sessions) def test_snapshot_is_owner_only_where_the_platform_supports_it(self): import stat as stat_module diff --git a/tests/test_api_keys.py b/tests/test_api_keys.py new file mode 100644 index 0000000..b237103 --- /dev/null +++ b/tests/test_api_keys.py @@ -0,0 +1,299 @@ +"""API-key admission, restrictions, management API and accounting contracts.""" +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import sqlite3 +import tempfile +import time +import unittest +from types import SimpleNamespace +from unittest.mock import Mock, patch + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app import admin_account, model_policy +from app.admin_api import install_admin +from app.api_keys import ApiKeyError, KeyStore, Principal, api_principal +from app.audit_store import AuditStore +from app.control_store import ControlStore +from app.runtime_management import UnavailableAudit +from app.settings import normalize_trusted_proxies, parse_networks +from tests.admin_session import login as session_login + + +class ApiKeyTests(unittest.TestCase): + def setUp(self): + self.root = Path(self.enterContext(tempfile.TemporaryDirectory())) + self.control = ControlStore(self.root / "control.sqlite3") + self.addCleanup(self.control.close) + self.keys = KeyStore(self.control) + self.config = {"api_key": "", "allow_open_noauth": False, "trusted_proxies": ""} + + def scope(self, secret=None, *, path="/v1/chat/completions", method="POST", ip="127.0.0.1"): + headers = [] if secret is None else [(b"authorization", f"Bearer {secret}".encode())] + return {"type": "http", "method": method, "path": path, "headers": headers, "client": (ip, 1)} + + def code(self, error): + return error.detail["error"]["code"] + + def test_empty_and_disabled_sets_control_noauth(self): + with self.assertRaises(ApiKeyError) as missing: + self.keys.authenticate(self.scope(), self.config) + self.assertEqual(self.code(missing.exception), "api_key_required") + self.config["allow_open_noauth"] = True + principal = self.keys.authenticate(self.scope("placeholder"), self.config) + self.assertEqual((principal.id, principal.type), ("anonymous", "admin")) + disabled, _ = self.keys.create(name="disabled", kind="standard") + self.keys.update(disabled["id"], 1, {"enabled": False}) + with self.assertRaises(ApiKeyError) as unknown: + self.keys.authenticate(self.scope("unknown"), self.config) + self.assertEqual(self.code(unknown.exception), "invalid_api_key") + + def test_external_and_overridden_admin_keys(self): + self.config["api_key"] = "external-secret" + _, admin_secret = self.keys.create(name="database admin", kind="admin") + _, standard_secret = self.keys.create(name="standard", kind="standard") + principal = self.keys.authenticate(self.scope("external-secret"), self.config) + self.assertEqual((principal.id, principal.type), ("admin-external", "admin")) + with self.assertRaises(ApiKeyError) as overridden: + self.keys.authenticate(self.scope(admin_secret), self.config) + self.assertEqual(self.code(overridden.exception), "invalid_api_key") + self.assertEqual(self.keys.authenticate(self.scope(standard_secret), self.config).type, "standard") + + def test_disabled_expired_ip_and_rpm_results(self): + disabled, _ = self.keys.create(name="disabled", kind="standard") + self.keys.update(disabled["id"], 1, {"enabled": False}) + with self.assertRaises(ApiKeyError) as stopped: + self.keys.authenticate(self.scope(disabled["secret"]), self.config) + self.assertEqual(self.code(stopped.exception), "api_key_disabled") + _, expired = self.keys.create(name="expired", kind="standard", expires_at=time.time() + 1) + with patch("app.api_keys.time.time", return_value=time.time() + 2): + with self.assertRaises(ApiKeyError) as old: + self.keys.authenticate(self.scope(expired), self.config) + self.assertEqual(self.code(old.exception), "api_key_expired") + limited, _ = self.keys.create(name="limited", kind="standard", ip_allow=["198.51.100.0/24"], rpm=1) + with self.assertRaises(ApiKeyError) as denied: + self.keys.authenticate(self.scope(limited["secret"], ip="203.0.113.9"), self.config) + self.assertEqual(self.code(denied.exception), "ip_not_allowed") + self.keys.authenticate(self.scope(limited["secret"], ip="198.51.100.9"), self.config) + with self.assertRaises(ApiKeyError) as rpm: + self.keys.authenticate(self.scope(limited["secret"], ip="198.51.100.9"), self.config) + self.assertEqual(self.code(rpm.exception), "rpm_exceeded") + self.assertIn("Retry-After", rpm.exception.headers) + self.assertEqual(self.keys.authenticate( + self.scope(limited["secret"], path="/v1/messages/count_tokens", ip="198.51.100.9"), self.config).id, limited["id"]) + + def test_other_connection_change_is_visible_within_one_second(self): + _, secret = self.keys.create(name="cross-process", kind="standard") + other = sqlite3.connect(self.control.path) + other.execute("UPDATE api_keys SET enabled=0") + other.commit() + other.close() + with patch("app.api_keys.time.monotonic", return_value=time.monotonic() + 2): + with self.assertRaises(ApiKeyError) as stopped: + self.keys.authenticate(self.scope(secret), self.config) + self.assertEqual(self.code(stopped.exception), "api_key_disabled") + + def test_model_aliases_source_and_permission(self): + self.control.update_model("upstream", {"public_id": "public", "upstream_id": "upstream", + "keep_original": True}, 0) + self.config["control_store"] = self.control + self.assertEqual(model_policy.source_for(self.config, "default-model"), "auto") + self.assertEqual(model_policy.source_for(self.config, "public"), "upstream") + principal = Principal("k_test", "limited", "standard", ("upstream",)) + token = api_principal.set(principal) + policy_token = model_policy._request_policy.set({}) + try: + self.assertEqual(model_policy.resolve(self.config, "public"), "upstream") + self.assertEqual(model_policy.resolve(self.config, "upstream"), "upstream") + with self.assertRaises(Exception) as denied: + api_principal.set(Principal("k_test", "limited", "standard", ("other",))) + model_policy.resolve(self.config, "public") + self.assertEqual(denied.exception.detail["error"]["code"], "model_not_allowed") + finally: + model_policy._request_policy.reset(policy_token) + api_principal.reset(token) + + + def test_default_model_uses_auto_permission(self): + self.config["control_store"] = self.control + token = api_principal.set(Principal("k_test", "automatic", "standard", ("auto",))) + policy_token = model_policy._request_policy.set({}) + try: + self.assertEqual(model_policy.resolve(self.config, "default-model"), "default-model") + finally: + model_policy._request_policy.reset(policy_token) + api_principal.reset(token) + + def test_models_route_filters_by_principal_sources(self): + import converter + self.control.update_model("upstream", {"public_id": "public", "upstream_id": "upstream"}, 0) + self.config.update(control_store=self.control, key_store=self.keys) + _, secret = self.keys.create(name="limited", kind="standard", models=["upstream"]) + items = [{"id": name, "credits": 0, "credits_by_profile": {}} for name in ("public", "other")] + with patch.dict(converter.CONFIG, self.config), patch.object(model_policy, "public_details", return_value=items): + client = self.enterContext(TestClient(converter.app)) + result = client.get("/v1/models", headers={"Authorization": f"Bearer {secret}"}) + self.assertEqual(result.status_code, 200, result.text) + self.assertEqual([item["id"] for item in result.json()["data"]], ["public"]) + + def test_billing_requires_admin_or_anonymous_principal(self): + import converter + item, secret = self.keys.create(name="standard", kind="standard") + self.config.update(control_store=self.control, key_store=self.keys, usage_daily={}) + totals = {"quota_usd": 0, "soonest_expiry": None, "price_cny": 0, "price_usd": 0, + "remaining": 0, "used": 0, "remaining_usd": 0, "remaining_cny": 0, + "used_usd": 0, "groups": {}, "partial": False, "rate": 1} + with patch.dict(converter.CONFIG, self.config), patch.object(converter, "_billing_totals", return_value=totals): + client = self.enterContext(TestClient(converter.app)) + for path in ("/v1/dashboard/billing/subscription", "/v1/dashboard/billing/usage"): + denied = client.get(path, headers={"Authorization": f"Bearer {secret}"}) + self.assertEqual(denied.status_code, 403, denied.text) + self.assertEqual(denied.json()["error"]["code"], "admin_key_required") + self.assertEqual(denied.json()["error"]["type"], "permission_error") + converter.CONFIG["api_key"] = "external-secret" + allowed = client.get(path, headers={"Authorization": "Bearer external-secret"}) + self.assertEqual(allowed.status_code, 200, allowed.text) + converter.CONFIG["api_key"] = "" + self.keys.delete(item["id"], 1) + converter.CONFIG["allow_open_noauth"] = True + anonymous = client.get("/v1/dashboard/billing/subscription", headers={"Authorization": "Bearer placeholder"}) + self.assertEqual(anonymous.status_code, 200, anonymous.text) + + def test_anthropic_permission_and_rpm_envelopes(self): + from app.inference_auth import InferenceAuthMiddleware + self.config["key_store"] = self.keys + app = FastAPI() + + @app.post("/v1/messages") + async def messages(): + return {"ok": True} + + app.add_middleware(InferenceAuthMiddleware, config=self.config) + client = self.enterContext(TestClient(app)) + _, ip_secret = self.keys.create(name="ip", kind="standard", ip_allow=["198.51.100.0/24"]) + denied = client.post("/v1/messages", headers={"Authorization": f"Bearer {ip_secret}"}) + self.assertEqual(denied.status_code, 403, denied.text) + self.assertEqual(denied.json()["type"], "error") + self.assertEqual(denied.json()["error"]["type"], "permission_error") + _, rpm_secret = self.keys.create(name="rpm", kind="standard", rpm=1) + headers = {"Authorization": f"Bearer {rpm_secret}"} + self.assertEqual(client.post("/v1/messages", headers=headers).status_code, 200) + limited = client.post("/v1/messages", headers=headers) + self.assertEqual(limited.status_code, 429, limited.text) + self.assertEqual(limited.json()["error"]["type"], "rate_limit_error") + self.assertIn("Retry-After", limited.headers) + def test_network_limits_stay_independent(self): + self.assertEqual(len(parse_networks([f"10.0.0.{i}" for i in range(64)], limit=64, label="IP 白名单")), 64) + with self.assertRaisesRegex(ValueError, "IP 白名单"): + parse_networks([f"10.0.0.{i}" for i in range(65)], limit=64, label="IP 白名单") + self.assertEqual(len(parse_networks([f"10.0.0.{i}" for i in range(32)], + limit=32, label="trusted_proxies")), 32) + with self.assertRaisesRegex(ValueError, "trusted_proxies"): + normalize_trusted_proxies([f"10.0.0.{i}" for i in range(33)]) + + def test_api_key_stats_window_filter_and_clear(self): + audit = AuditStore(self.root / "logs.sqlite3") + self.addCleanup(audit.close) + now = int(time.time() // 3600) * 3600 + key_id = "k_abcdefghijkl" + for index, age in enumerate((0, 23 * 3600, 24 * 3600)): + audit.record_request({"id": f"request-{index}", "started_at": now - age, + "api_key": key_id, "outcome": "success", "total_tokens": 10, + "credit": 2, "model": "model"}) + audit.record_request({"id": "invalid-key", "started_at": now, "api_key": "cb-secret", + "outcome": "success", "total_tokens": 100, "model": "model"}) + stats = audit.api_key_stats(now)["items"][key_id] + self.assertEqual((stats["requests"], stats["success"], stats["total_tokens"], stats["credit"]), + (2, 2, 20, 4)) + self.assertEqual(len(audit.list_records(api_key=key_id)["items"]), 3) + self.assertNotIn("cb-secret", str(audit.list_records()["items"])) + audit.clear("all") + self.assertEqual(audit.api_key_stats(now)["items"].get(key_id, {}).get("requests", 0), 0) + self.assertEqual(UnavailableAudit("fixture").api_key_stats(), {"degraded": True, "items": {}}) + + def test_management_api_status_and_version_contract(self): + admin_account.save(self.control, admin_account.new("admin", "synthetic-key", "user")) + audit = Mock() + audit.storage.return_value = {} + audit.api_key_stats.return_value = {"items": {}} + self.config.update(control_store=self.control, audit_store=audit, + session_path=self.root / "sessions.json") + gateway = SimpleNamespace(admin_model_inventory=lambda: [{"id": "upstream", "public_id": "public"}]) + app = FastAPI() + install_admin(app, self.config, gateway) + client = self.enterContext(TestClient(app)) + headers = session_login(client) + for field in ("expires_at", "ip_allow", "rpm", "models"): + restricted = client.post("/admin/keys", headers=headers, + json={"name": "invalid admin", "type": "admin", field: None}) + self.assertEqual(restricted.status_code, 400, restricted.text) + created = client.post("/admin/keys", headers=headers, + json={"name": "admin", "type": "admin"}) + self.assertEqual(created.status_code, 200, created.text) + duplicate = client.post("/admin/keys", headers=headers, json={"name": "admin2", "type": "admin"}) + self.assertEqual(duplicate.status_code, 422) + standard = client.post("/admin/keys", headers=headers, + json={"name": "standard", "type": "standard", "models": ["public"]}) + self.assertEqual(standard.status_code, 200, standard.text) + self.assertEqual(standard.json()["models"], ["upstream"]) + renamed = client.patch(f"/admin/keys/{standard.json()['id']}", headers=headers, + json={"version": 1, "models": ["public"]}) + self.assertEqual(renamed.status_code, 200, renamed.text) + self.assertEqual(renamed.json()["models"], ["upstream"]) + unknown_update = client.patch(f"/admin/keys/{standard.json()['id']}", headers=headers, + json={"version": 2, "models": ["missing"]}) + self.assertEqual(unknown_update.status_code, 400) + automatic = client.post("/admin/keys", headers=headers, + json={"name": "automatic", "type": "standard", "models": ["default-model"]}) + self.assertEqual(automatic.status_code, 200, automatic.text) + self.assertEqual(automatic.json()["models"], ["auto"]) + unknown = client.post("/admin/keys", headers=headers, + json={"name": "unknown", "type": "standard", "models": ["missing"]}) + self.assertEqual(unknown.status_code, 400) + for secret in (created.json()["secret"], standard.json()["secret"]): + anonymous = TestClient(app) + self.assertEqual(anonymous.get("/admin/keys", headers={"Authorization": f"Bearer {secret}"}).status_code, 401) + anonymous.close() + missing = client.post("/admin/keys/k_abcdefghijkl/rotate", headers=headers, json={"version": 1}) + self.assertEqual(missing.status_code, 404) + stale = client.patch(f"/admin/keys/{standard.json()['id']}", headers=headers, + json={"version": 99, "name": "stale"}) + self.assertEqual(stale.status_code, 409) + stale_rotate = client.post(f"/admin/keys/{standard.json()['id']}/rotate", headers=headers, + json={"version": 99}) + self.assertEqual(stale_rotate.status_code, 409) + self.config["api_key"] = "external-secret" + external = client.post(f"/admin/keys/{created.json()['id']}/rotate", headers=headers, + json={"version": 1}) + self.assertEqual(external.status_code, 403) + for origin in ("cli", "environment", "dotenv"): + self.config["settings_sources"] = {"api_key": origin} + listed = client.get("/admin/keys", headers=headers).json()["external"] + self.assertEqual((listed["origin"], listed["occupied"]), (origin, True)) + self.assertNotIn("prefix", listed) + def test_missing_account_locks_http_even_with_external_key(self): + audit = Mock() + audit.storage.return_value = {} + audit.api_key_stats.return_value = {"items": {}} + self.config.update(control_store=self.control, audit_store=audit, + session_path=self.root / "sessions.json", api_key="external-secret") + app = FastAPI() + install_admin(app, self.config, SimpleNamespace()) + with self.control.state.transaction(): + self.control._db.execute("DELETE FROM gateway_secrets WHERE name='admin_account'") + client = self.enterContext(TestClient(app)) + for response in ( + client.get("/admin/session"), + client.post("/admin/session", json={"username": "external-secret", "password": "external-secret"}), + client.get("/admin/keys", headers={"Authorization": "Bearer external-secret"}), + ): + self.assertEqual(response.status_code, 503, response.text) + self.assertEqual(response.json()["error"]["message"], "管理账户未初始化") + + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_audit_store.py b/tests/test_audit_store.py index dc0db89..b26a53c 100644 --- a/tests/test_audit_store.py +++ b/tests/test_audit_store.py @@ -249,8 +249,8 @@ def test_dashboard_view_storm_does_not_drop_traffic_records(self): self.store._db.execute("PRAGMA synchronous=OFF") # bulk seed only self.store._db.execute("BEGIN IMMEDIATE") self.store._db.executemany( - "INSERT INTO requests VALUES(?,?,?,?,?,?,?,?,?,?,?)", - [(f"s{i}", now - i, "m", "p", "c", "success", 200, payload, 64, None, 5.0) + "INSERT INTO requests(id,started_at,model,profile,credential,outcome,status_code,payload,logical_bytes,client_ip,duration_ms,api_key) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", + [(f"s{i}", now - i, "m", "p", "c", "success", 200, payload, 64, None, 5.0, None) for i in range(20000)]) self.store._db.execute("COMMIT") self.store._db.execute("PRAGMA synchronous=FULL") @@ -790,8 +790,8 @@ def test_client_ip_and_agent_are_sanitized_persisted_and_filterable(self): self.assertEqual(self.store.list_records(client_ip="2001:db8::1")["items"][0]["id"], "meta-1") self.assertEqual(self.store.list_records(client_ip="198.51.100.1")["items"], []) - def test_v2_database_gains_client_ip_without_losing_rows(self): - legacy_path = Path(self.temp.name) / "private" / "v2.sqlite3" + def test_v4_database_gains_api_key_without_losing_rows(self): + legacy_path = Path(self.temp.name) / "private" / "v4.sqlite3" raw = sqlite3.connect(legacy_path) with raw: raw.execute("CREATE TABLE state (id INTEGER PRIMARY KEY CHECK(id=1), epoch INTEGER NOT NULL," @@ -804,7 +804,7 @@ def test_v2_database_gains_client_ip_without_losing_rows(self): "INSERT INTO requests VALUES('legacy-1', ?, 'm', NULL, NULL, 'success', 200, ?, 128)", (time.time(), json.dumps({"id": "legacy-1", "model": "m", "outcome": "success", "status_code": 200, "attempts": []}))) - raw.execute("PRAGMA user_version=2") + raw.execute("PRAGMA user_version=4") raw.close() reopened = AuditStore(legacy_path) self.addCleanup(reopened.close) @@ -812,6 +812,7 @@ def test_v2_database_gains_client_ip_without_losing_rows(self): [c[1] for c in reopened._db.execute("PRAGMA table_info(requests)")]) indexes = [row[1] for row in reopened._db.execute("PRAGMA index_list(requests)")] self.assertIn("requests_client", indexes) + self.assertIn("api_key", [c[1] for c in reopened._db.execute("PRAGMA table_info(requests)")]) items = reopened.list_records()["items"] self.assertEqual([item["id"] for item in items], ["legacy-1"]) self.assertIsNone(items[0].get("client_ip")) diff --git a/tests/test_buddy_actions.py b/tests/test_buddy_actions.py index 0abb205..da00193 100644 --- a/tests/test_buddy_actions.py +++ b/tests/test_buddy_actions.py @@ -10,6 +10,7 @@ import converter from app import buddy, credential_actions, credits from tests import test_credential_actions as fixtures +from tests.admin_session import credentials class BuddyActionTests(unittest.TestCase): @@ -117,7 +118,7 @@ def change(*args, **kwargs): def test_cookie_confirmation_requires_csrf(self): self.client.headers['Authorization'] = '' - login = self.client.post('/admin/session', json={'api_key': 'synthetic-management-key'}, + login = self.client.post('/admin/session', json=credentials('synthetic-management-key'), headers={'Origin': 'https://testserver'}) self.assertEqual(login.status_code, 200, login.text) payload = {'confirm_buddy': True, 'agreement_revision': buddy.AGREEMENT_REVISION} @@ -162,7 +163,9 @@ def test_onboarding_model_rechecks_disable_and_cooldown_without_refreshing_crede def test_confirmation_requires_authentication(self): - response = self.client.post(self.url + '/travel', headers={'Authorization': ''}, json={ + self.client.headers.pop("authorization", None) + self.client.cookies.clear() + response = self.client.post(self.url + '/travel', json={ 'confirm_buddy': True, 'agreement_revision': buddy.AGREEMENT_REVISION}) self.assertEqual(response.status_code, 401) self.travel_mock.assert_not_called() diff --git a/tests/test_config_migration.py b/tests/test_config_migration.py index 53d5132..28b00d9 100644 --- a/tests/test_config_migration.py +++ b/tests/test_config_migration.py @@ -87,9 +87,20 @@ def test_identity_check_rejects_foreign_or_stale_databases(self): def test_identity_check_accepts_a_control_store(self): info = config_migration.inspect_database(self.db) - self.assertEqual((info["version"], info["revision"], info["accounts"]), (2, 0, 0)) + self.assertEqual((info["version"], info["revision"], info["accounts"]), (3, 0, 0)) self.assertIsNone(info["lease"]) + + def test_v2_requires_upgrade_and_api_keys_table_is_mandatory(self): + with sqlite3.connect(self.db) as db: + db.execute("PRAGMA user_version=2") + with self.assertRaisesRegex(config_migration.MigrationError, "schema=2.*先用当前版本服务启动"): + config_migration.inspect_database(self.db) + with sqlite3.connect(self.db) as db: + db.execute("PRAGMA user_version=3") + db.execute("DROP TABLE api_keys") + with self.assertRaisesRegex(config_migration.MigrationError, "api_keys"): + config_migration.inspect_database(self.db) def test_dry_run_reports_changes_without_writing(self): self.write_env("CODEBUDDY2API_MAX_IMAGES=8\nCODEBUDDY2API_STREAM_MODE=realtime\n") report = config_migration.migrate(cwd=self.root, db=self.db, env_file=self.env, environ={}, dry_run=True) diff --git a/tests/test_control_store.py b/tests/test_control_store.py index 0bfd54d..6bd8263 100644 --- a/tests/test_control_store.py +++ b/tests/test_control_store.py @@ -28,6 +28,43 @@ def test_reopen_and_schema(self): self.addCleanup(other.close) self.assertEqual(other.snapshot()["settings"], {"max_images": 3}) + def test_v2_database_upgrades_api_keys_schema(self): + self.store.update_settings({"max_images": 3}, 0) + self.store.close() + with sqlite3.connect(self.path) as db: + db.execute("DROP INDEX api_keys_single_admin") + db.execute("DROP TABLE api_keys") + db.execute("PRAGMA user_version=2") + reopened = ControlStore(self.path) + self.addCleanup(reopened.close) + self.assertEqual(reopened.snapshot()["settings"], {"max_images": 3}) + with sqlite3.connect(self.path) as db: + self.assertEqual(db.execute("PRAGMA user_version").fetchone()[0], 3) + self.assertIn("api_keys", {row[0] for row in db.execute( + "SELECT name FROM sqlite_master WHERE type='table'")}) + self.assertTrue(db.execute( + "SELECT 1 FROM pragma_index_list('api_keys') WHERE name='api_keys_single_admin' AND \"unique\"=1" + ).fetchone()) + + def test_corrupt_api_keys_schema_is_rejected_without_deleting_rows(self): + from app.api_keys import KeyStore + for mode in ("column", "index"): + with self.subTest(mode=mode): + path = self.root / f"corrupt-{mode}.sqlite3" + store = ControlStore(path) + KeyStore(store).create(name="preserved", kind="standard") + store.close() + with sqlite3.connect(path) as db: + if mode == "column": + db.execute("ALTER TABLE api_keys DROP COLUMN version") + else: + db.execute("DROP INDEX api_keys_single_admin") + db.execute("CREATE INDEX api_keys_single_admin ON api_keys(type)") + with self.assertRaisesRegex(ValueError, "api_keys (表|索引)结构不匹配"): + ControlStore(path) + with sqlite3.connect(path) as db: + self.assertEqual(db.execute("SELECT count(*) FROM api_keys").fetchone()[0], 1) + def test_snapshot_is_detached(self): self.store.update_model("real", {"public_id": "public", "credential_ids": ["fingerprint"]}, 0) snapshot = self.store.snapshot() diff --git a/tests/test_credential_actions.py b/tests/test_credential_actions.py index 486b80a..da9230f 100644 --- a/tests/test_credential_actions.py +++ b/tests/test_credential_actions.py @@ -10,10 +10,12 @@ from fastapi import FastAPI from fastapi.testclient import TestClient import converter +from app import admin_account from app.admin_api import install_admin from app.audit_store import AuditStore from app.control_store import ControlStore from app.gateway_management import Management +from tests.admin_session import credentials, login as session_login from tests import test_region_routing as fixtures @@ -25,6 +27,7 @@ class CredentialActionTests(unittest.TestCase): def setUp(self): fixtures.RegionRoutingTests.setUp(self) self.control = ControlStore(self.root / "control.sqlite3") + admin_account.save(self.control, admin_account.new("admin", "synthetic-management-key", "user")) self.audit = AuditStore(self.root / "audit.sqlite3") self.addCleanup(self.control.close) self.addCleanup(self.audit.close) @@ -33,8 +36,9 @@ def setUp(self): app = FastAPI() app.router.routes = list(converter.app.router.routes) install_admin(app, converter.CONFIG, Management(converter)) - self.client = self.enterContext(TestClient(app, base_url="https://testserver", - headers={"Authorization": "Bearer synthetic-management-key"})) + self.client = self.enterContext(TestClient(app, base_url="https://testserver")) + self.client.headers.update(session_login( + self.client, origin="https://testserver", password="synthetic-management-key")) self.status_query = self.enterContext(patch.object(converter.credits_mod, "fetch_checkin_status", return_value={"ok": False, "state": "available", "code": 0})) self.travel_mock = self.enterContext(patch("app.travel.perform", @@ -168,9 +172,10 @@ def test_batch_checkin_does_not_run_maintenance(self): full.assert_not_called() def test_api_and_cookie_auth_require_csrf_for_new_actions(self): - self.client.headers.pop("authorization") + self.client.headers.pop("authorization", None) + self.client.cookies.clear() self.assertEqual(self.client.post(self.url + "/refresh").status_code, 401) - session = self.client.post("/admin/session", json={"api_key": "synthetic-management-key"}, + session = self.client.post("/admin/session", json=credentials("synthetic-management-key"), headers={"Origin": "https://testserver"}) self.assertEqual(session.status_code, 200, session.text) self.assertEqual(self.client.post(self.url + "/checkin").status_code, 403) @@ -192,6 +197,7 @@ def setUp(self): converter.CONFIG.update(api_key="synthetic-management-key", usage_daily_accounts={}, usage_daily={}, cooldowns_path=self.root / "cooldowns.json") self.control = ControlStore(self.root / "control.sqlite3") + admin_account.save(self.control, admin_account.new("admin", "synthetic-management-key", "user")) self.audit = AuditStore(self.root / "audit.sqlite3") self.addCleanup(self.control.close) self.addCleanup(self.audit.close) @@ -199,8 +205,9 @@ def setUp(self): app = FastAPI() app.router.routes = list(converter.app.router.routes) install_admin(app, converter.CONFIG, Management(converter)) - self.client = self.enterContext(TestClient(app, base_url="https://testserver", - headers={"Authorization": "Bearer synthetic-management-key"})) + self.client = self.enterContext(TestClient(app, base_url="https://testserver")) + self.client.headers.update(session_login( + self.client, origin="https://testserver", password="synthetic-management-key")) def build_pool(self, *, cooldowns_path=None): """Build a fresh pool bound to a real cooldown file, as a restart would.""" @@ -336,15 +343,16 @@ def test_a_failed_write_reports_failure_and_stays_retryable(self): def test_reset_requires_authentication(self): pool = self.build_pool() entry = self.arm(pool) - self.client.headers.pop("authorization") + self.client.headers.pop("authorization", None) + self.client.cookies.clear() self.assertEqual(self.client.post(self.url(entry)).status_code, 401) self.assertFalse(pool._healthy(entry)) # Rejected without mutating. def test_reset_requires_csrf_and_same_origin_for_cookie_sessions(self): pool = self.build_pool() entry = self.arm(pool) - self.client.headers.pop("authorization") - session = self.client.post("/admin/session", json={"api_key": "synthetic-management-key"}, + self.client.headers.pop("authorization", None) + session = self.client.post("/admin/session", json=credentials("synthetic-management-key"), headers={"Origin": "https://testserver"}) self.assertEqual(session.status_code, 200, session.text) self.assertEqual(self.client.post(self.url(entry)).status_code, 403) # No CSRF yet. diff --git a/tests/test_credential_automation.py b/tests/test_credential_automation.py index 3dc0d28..1d786dc 100644 --- a/tests/test_credential_automation.py +++ b/tests/test_credential_automation.py @@ -10,6 +10,7 @@ from app import checkin, credits, daily_chat, growth_tasks, model_policy from app.control_store import ControlStore from tests import test_credential_actions as fixtures +from tests.admin_session import credentials class AutomationTests(unittest.TestCase): @@ -235,9 +236,10 @@ def test_background_nonrestriction_errors_do_not_disable_or_cool(self): self.assertTrue(model_policy.credential_enabled(converter.CONFIG, entry)) def test_cookie_preferences_require_csrf(self): - self.client.headers.pop('authorization') + self.client.headers.pop('authorization', None) + self.client.cookies.clear() self.assertEqual(self.client.patch(self.url, json={'auto_checkin': False}).status_code, 401) - r = self.client.post('/admin/session', json={'api_key': 'synthetic-management-key'}, headers={'Origin': 'https://testserver'}) + r = self.client.post('/admin/session', json=credentials('synthetic-management-key'), headers={'Origin': 'https://testserver'}) self.assertEqual(r.status_code, 200) self.assertEqual(self.client.patch(self.url, json={'auto_checkin': False}).status_code, 403) self.client.headers.update({'Origin': 'https://testserver', 'X-CSRF-Token': r.json()['csrf_token']}) diff --git a/tests/test_credential_unbind.py b/tests/test_credential_unbind.py index 082aca6..5bf1491 100644 --- a/tests/test_credential_unbind.py +++ b/tests/test_credential_unbind.py @@ -13,10 +13,14 @@ import threading -from fastapi import HTTPException +from fastapi import FastAPI, HTTPException from fastapi.testclient import TestClient import converter +from app import admin_account +from app.admin_api import install_admin +from app.audit_store import AuditStore +from tests.admin_session import login as session_login from app.control_store import ControlStore from app.gateway_management import Management @@ -42,6 +46,19 @@ def setUp(self): def bind(self): self.store.update_model("glm-4-flash", {"credential_ids": [self.identity]}, 0) + + def route_client(self, config): + audit = AuditStore(self.store.path.parent / "audit.sqlite3") + self.addCleanup(audit.close) + admin_account.save(self.store, admin_account.new("admin", "secret-key", "user")) + config.update(audit_store=audit, state_store=self.store.state, + session_path=self.store.path.parent / "sessions.json") + app = FastAPI() + app.router.routes = list(converter.app.router.routes) + install_admin(app, config, self.management) + client = self.enterContext(TestClient(app, base_url="https://testserver")) + headers = session_login(client, origin="https://testserver", password="secret-key") + return client, headers def test_bound_delete_is_blocked_without_the_confirmation_flag(self): self.bind() with self.assertRaises(HTTPException) as ctx: @@ -75,9 +92,8 @@ def test_rollback_preserves_concurrent_binding_edits(self): def test_route_keeps_bindings_when_removal_fails(self): self.bind() config = dict(self.config, api_key="secret", management=self.management) + client, headers = self.route_client(config) self.enterContext(patch.dict(converter.CONFIG, config)) - client = TestClient(converter.app) - headers = {"X-Api-Key": "secret"} with patch.object(self.pool, "remove_file", return_value=False): response = client.delete("/admin/credentials/account.info?unbind=1", headers=headers) self.assertEqual(response.status_code, 404, response.text) @@ -92,9 +108,8 @@ def test_route_sweeps_a_rebind_that_raced_the_removal(self): self.bind() config = dict(self.config, api_key="secret", management=self.management, admin_mutation_lock=threading.RLock()) + client, headers = self.route_client(config) self.enterContext(patch.dict(converter.CONFIG, config)) - client = TestClient(converter.app) - headers = {"X-Api-Key": "secret"} real_remove = self.pool.remove_file def rebind_during_removal(file_name): # A writer outside the shared lock rebinds while the credential is pooled. diff --git a/tests/test_environment_config.py b/tests/test_environment_config.py index 9241dae..d405105 100644 --- a/tests/test_environment_config.py +++ b/tests/test_environment_config.py @@ -89,9 +89,11 @@ def test_invalid_binding_environment_fails_before_server_or_credential_scan(self with self.subTest(env=env), self.assertRaises(ValueError): self.start(env) - def test_environment_public_binding_without_key_is_rejected(self): - with self.assertRaises(SystemExit): - self.start({'CODEBUDDY2API_BIND': '0.0.0.0', 'CODEBUDDY2API_KEY': ''}) + def test_environment_public_binding_without_key_starts_with_blank_key_unset(self): + server, _, config = self.start({'CODEBUDDY2API_BIND': '0.0.0.0', 'CODEBUDDY2API_KEY': ''}) + self.assertEqual(server['host'], '0.0.0.0') + self.assertEqual(config['api_key'], '') + self.assertEqual(config['settings_sources']['api_key'], 'default') def test_existing_runtime_limit_and_retry_variables_reach_effective_config(self): values = {'MAX_INBOUND_BYTES': '4096', 'MAX_COLLECT_BYTES': '0', 'MAX_CONCURRENT': '2', diff --git a/tests/test_growth_admin.py b/tests/test_growth_admin.py index 99e458b..7043c94 100755 --- a/tests/test_growth_admin.py +++ b/tests/test_growth_admin.py @@ -14,11 +14,13 @@ from types import SimpleNamespace from unittest.mock import Mock, patch +from app import admin_account from app.admin_api import install_admin from app import growth_tasks from app.control_store import ControlStore from app.gateway_management import GROWTH_JOBS, Management +from tests.admin_session import login as session_login class GrowthAdminTests(unittest.TestCase): def setUp(self): @@ -45,10 +47,11 @@ def setUp(self): _save_oauth_credential=Mock(return_value=self.root / "cn.info"), _OAUTH=Mock()) self.config = {"api_key": "synthetic-key", "control_store": self.store, "max_images": 16, "session_path": self.root / "admin-sessions.json", "audit_store": audit} + admin_account.save(self.store, admin_account.new("admin", "synthetic-key", "user")) app = FastAPI() install_admin(app, self.config, self.gateway) self.client = TestClient(app) - self.headers = {"Authorization": "Bearer synthetic-key"} + self.headers = session_login(self.client) def growth_tasks(self, identity): if identity != "acct": diff --git a/tests/test_identity_sync.py b/tests/test_identity_sync.py index d092441..82521d9 100644 --- a/tests/test_identity_sync.py +++ b/tests/test_identity_sync.py @@ -444,9 +444,9 @@ def barrier(*args, **kwargs): patch.object(c, "run_server", side_effect=barrier), \ patch("sys.stderr", new=io.StringIO()): c.main() - # refresher, reconcile, housekeeper, lease heartbeat, update check, scheduled backups - # and the server itself. - self.assertEqual(len(observed), 7) + # refresher, reconcile, housekeeping, key housekeeping, lease heartbeat, update check, + # scheduled backups and the server itself. + self.assertEqual(len(observed), 8) if __name__ == "__main__": diff --git a/tests/test_inference_admission.py b/tests/test_inference_admission.py index 82ced46..a4f3809 100644 --- a/tests/test_inference_admission.py +++ b/tests/test_inference_admission.py @@ -128,6 +128,13 @@ async def test_current_key_and_header_precedence_are_preserved(self): status, body = await self.request(ROUTES[0], ((b"x-api-key", b"replacement-key"),)) self.assertEqual(status, 200, body) + async def test_auth_middleware_allows_conditional_inference_requests(self): + for path in ROUTES: + with self.subTest(path=path): + status, body = await self.request(path, ((b"if-none-match", b'"etag"'), + (b"authorization", b"Bearer synthetic-key"))) + self.assertEqual(status, 200, body) + async def test_unknown_routes_and_methods_preserve_routing_errors(self): for path, method, expected in (("/v1/missing", "GET", 404), ("/v1/missing", "POST", 404), diff --git a/tests/test_login.py b/tests/test_login.py index a5dbde9..d03e28e 100644 --- a/tests/test_login.py +++ b/tests/test_login.py @@ -18,6 +18,7 @@ from app import auth_oauth import converter +from tests.admin_session import direct_request class LoginTests(unittest.TestCase): @@ -175,9 +176,9 @@ def test_admin_poll_still_saves_and_loads_into_pool(self): pool = converter.CredentialPool(scan=False) with patch.object(converter, "_OAUTH", self.manager()), \ patch.dict(converter.CONFIG, {"cred_pool": pool, "api_key": "synthetic-admin-key"}): - started = converter.admin_oauth_start(site="cn", authorization="Bearer synthetic-admin-key") - self.assertEqual(converter.admin_oauth_poll(started["login_id"], authorization="Bearer synthetic-admin-key"), {"done": False}) - result = converter.admin_oauth_poll(started["login_id"], authorization="Bearer synthetic-admin-key") + started = converter.admin_oauth_start(direct_request(), site="cn") + self.assertEqual(converter.admin_oauth_poll(direct_request(), started["login_id"]), {"done": False}) + result = converter.admin_oauth_poll(direct_request(), started["login_id"]) self.assertTrue(result["done"]) self.assertEqual(result["uid"], "u1") self.assertTrue(Path(result["imported"]).is_file()) diff --git a/tests/test_media_generation.py b/tests/test_media_generation.py index cc07fdd..034b329 100644 --- a/tests/test_media_generation.py +++ b/tests/test_media_generation.py @@ -26,11 +26,12 @@ from pathlib import Path as _Path import converter -from app import media_generation, media_store +from app import admin_account, media_generation, media_store from app.admin_api import install_admin from app.control_store import ControlStore from app.media_store import MediaStore from tests import test_region_routing as fixtures +from tests.admin_session import credentials def _png(rgb=(0x7C, 0x66, 0xDC)): @@ -433,7 +434,8 @@ def test_v1_database_adds_public_model_without_losing_tasks(self): self.store.close() db = sqlite3.connect(self.store.path) db.execute("ALTER TABLE tasks DROP COLUMN public_model") - db.execute("INSERT INTO tasks VALUES('task_old','acct','cn-cli','upstream',1,1,'queued',NULL,NULL,NULL)") + db.execute("INSERT INTO tasks(task_id,account_key,profile,model,created,updated,status,media_id,error,credit) " + "VALUES('task_old','acct','cn-cli','upstream',1,1,'queued',NULL,NULL,NULL)") db.execute("PRAGMA user_version=1") db.commit() db.close() @@ -444,6 +446,26 @@ def test_v1_database_adds_public_model_without_losing_tasks(self): self.assertEqual(created["model"], "upstream") self.assertEqual(created["public_model"], "alias") + def test_video_task_api_key_roundtrip_and_filter(self): + self.store.create_task("owned", "acct", "cn-cli", "m", api_key="k_owned") + self.store.create_task("other", "acct", "cn-cli", "m", api_key="k_other") + self.assertEqual(self.store.get_task("owned")["api_key"], "k_owned") + page = self.store.list_tasks(api_key="k_owned") + self.assertEqual([item["task_id"] for item in page["items"]], ["owned"]) + + def test_v2_database_adds_api_key_without_losing_tasks(self): + self.store.close() + db = sqlite3.connect(self.store.path) + db.execute("PRAGMA user_version=2") + db.commit() + db.close() + reopened = MediaStore(self.store.root) + self.addCleanup(reopened.close) + columns = {row[1] for row in reopened._db.execute("PRAGMA table_info(tasks)").fetchall()} + self.assertIn("api_key", columns) + self.assertIsNone(reopened.create_task("legacy", "acct", "cn-cli", "m")["api_key"]) + self.assertEqual(reopened._db.execute("PRAGMA user_version").fetchone()[0], 3) + class MediaRoutingTests(fixtures.RegionRoutingTests): """Endpoint behaviour against a mocked media upstream.""" @@ -665,6 +687,23 @@ def test_api_key_guards_media_endpoints(self): self.enterContext(patch.dict(converter.CONFIG, {"api_key": "secret"})) self.assertEqual(self.client.post("/v1/images/generations", json={"prompt": "x"}).status_code, 401) self.assertEqual(self.client.get("/v1/videos/any").status_code, 401) + asset = self.store.save_asset("image", PNG_BYTES, model="m", ttl_hours=72, max_total_bytes=1 << 20) + self.assertEqual(self.client.get(f"/v1/media/images/{asset['id']}").status_code, 200) + + def test_standard_key_cannot_refresh_another_keys_task(self): + from app.api_keys import KeyStore + control = ControlStore(self.root / "video-owner.sqlite3") + self.addCleanup(control.close) + keys = KeyStore(control) + _, secret = keys.create(name="limited", kind="standard") + self.enterContext(patch.dict(converter.CONFIG, {"key_store": keys})) + self.store.create_task("foreign", "acct", "cn-cli", "m", api_key="k_other") + before = len(self.media_requests) + for suffix in ("", "/content"): + result = self.client.get(f"/v1/videos/foreign{suffix}", headers={"Authorization": f"Bearer {secret}"}) + self.assertEqual(result.status_code, 404, result.text) + self.assertEqual(result.json()["error"]["code"], "task_not_found") + self.assertEqual(len(self.media_requests), before) # -- videos -------------------------------------------------------------------------- @@ -1241,6 +1280,7 @@ def setUp(self): self.addCleanup(self.store.close) self.control = ControlStore(self.root / "control.sqlite3") self.addCleanup(self.control.close) + admin_account.save(self.control, admin_account.new("admin", "synthetic-key", "user")) self.config = {"api_key": "synthetic-key", "control_store": self.control, "media_store": self.store, "media_public_base_url": "https://media.example.com", "media_max_total_mb": 1024, "media_ttl_hours": 72, @@ -1261,7 +1301,7 @@ def setUp(self): self.app = FastAPI() install_admin(self.app, self.config, self.gateway) self.client = self.enterContext(TestClient(self.app)) - session = self.client.post("/admin/session", json={"api_key": "synthetic-key"}, + session = self.client.post("/admin/session", json=credentials("synthetic-key"), headers={"Origin": "http://testserver"}) self.headers = {"Origin": "http://testserver", "X-CSRF-Token": session.json()["csrf_token"]} @@ -1313,6 +1353,19 @@ def test_task_listing_and_refresh(self): self.assertEqual(missing.status_code, 404) self.assertIn("视频任务不存在", missing.json()["error"]["message"]) + def test_task_listing_filters_by_api_key_and_labels_owners(self): + item, _ = self.config["key_store"].create(name="CI key", kind="standard") + self.store.create_task("owned", "acct", "cn-cli", "m", api_key=item["id"]) + self.store.create_task("other", "acct", "cn-cli", "m", api_key="k_deleted") + self.store.create_task("legacy", "acct", "cn-cli", "m") + listed = self.client.get(f"/admin/media/tasks?api_key={item['id']}", headers=self.headers).json() + self.assertEqual([entry["task_id"] for entry in listed["items"]], ["owned"]) + self.assertEqual(listed["items"][0]["api_key_name"], "CI key") + all_items = self.client.get("/admin/media/tasks", headers=self.headers).json()["items"] + labels = {entry["task_id"]: entry["api_key_name"] for entry in all_items} + self.assertEqual(labels["other"], "k_deleted(已删除)") + self.assertEqual(labels["legacy"], "—(升级前)") + def test_expired_assets_disappear_from_admin_views(self): meta = self.store.save_asset("image", PNG_BYTES, model="m", ttl_hours=72, max_total_bytes=1 << 20) self.store._db.execute("UPDATE assets SET created=?", (time.time() - 2 * 3600,)) diff --git a/tests/test_native_noauth.py b/tests/test_native_noauth.py index 6083ad2..78d311a 100644 --- a/tests/test_native_noauth.py +++ b/tests/test_native_noauth.py @@ -17,7 +17,7 @@ class NativeNoAuthTests(unittest.TestCase): - def test_image_default_starts_without_key_but_keeps_management_locked(self): + def test_image_default_starts_without_key_and_creates_admin_account(self): dockerfile = (ROOT / 'Dockerfile').read_text() command = json.loads(next(line[4:] for line in dockerfile.splitlines() if line.startswith('CMD '))) self.assertIn('ENV CODEBUDDY2API_ALLOW_OPEN_NOAUTH=true', dockerfile) @@ -37,10 +37,12 @@ def test_image_default_starts_without_key_but_keeps_management_locked(self): with httpx.Client(base_url=f'http://127.0.0.1:{port}', transport=httpx.HTTPTransport(retries=5), timeout=5, trust_env=False) as client: self.assertEqual(client.get('/health').json(), {'status': 'ok'}) - self.assertEqual(client.get('/admin/session').status_code, 503) + session = client.get('/admin/session') + self.assertEqual(session.status_code, 200) + self.assertEqual(session.json(), {'authenticated': False, 'csrf_token': None}) self.assertEqual(client.get('/v1/models').status_code, 200) with contextlib.closing(sqlite3.connect(root / 'auth/control.sqlite3')) as db: - self.assertEqual(db.execute('SELECT count(*) FROM gateway_secrets').fetchone()[0], 0) + self.assertEqual(db.execute('SELECT count(*) FROM gateway_secrets').fetchone()[0], 1) finally: if process.poll() is None: process.send_signal(signal.SIGINT) if os.name == 'posix' else process.terminate() diff --git a/tests/test_perf_metrics.py b/tests/test_perf_metrics.py index e37b267..05602a6 100644 --- a/tests/test_perf_metrics.py +++ b/tests/test_perf_metrics.py @@ -17,8 +17,10 @@ import converter from app import perf_metrics +from app import admin_account from app.admin_api import install_admin from app.control_store import ControlStore +from tests.admin_session import credentials class PerfMetricsTests(unittest.TestCase): @@ -90,6 +92,7 @@ def setUp(self): root = Path(self.enterContext(tempfile.TemporaryDirectory())) self.store = ControlStore(root / "control.sqlite3") self.addCleanup(self.store.close) + admin_account.save(self.store, admin_account.new("admin", "synthetic-key", "user")) self.config = {"api_key": "synthetic-key", "control_store": self.store, "max_images": 16, "session_path": root / "admin-sessions.json"} self.audit = Mock() @@ -102,7 +105,7 @@ def setUp(self): def test_requires_admin_login_and_returns_counters_only(self): self.assertEqual(self.client.get("/admin/performance").status_code, 401) - self.client.post("/admin/session", json={"api_key": "synthetic-key"}, + self.client.post("/admin/session", json=credentials("synthetic-key"), headers={"Origin": "http://testserver"}) perf_metrics.inc("route_pick_duration_marker") result = self.client.get("/admin/performance") diff --git a/tests/test_preview.py b/tests/test_preview.py index ec41f56..6fa712f 100644 --- a/tests/test_preview.py +++ b/tests/test_preview.py @@ -198,6 +198,32 @@ def test_history_fills_the_dashboard(self): finally: store.close() + def test_api_keys_history_and_media_ownership_are_seeded(self): + from app.api_keys import KeyStore + from app.control_store import ControlStore + from app.media_store import MediaStore + with tempfile.TemporaryDirectory() as directory: + ids = seed.write_api_keys(Path(directory)) + self.assertEqual(set(ids), {"expired", "ip", "models"}) + control = ControlStore(Path(directory) / "control.sqlite3") + keys = KeyStore(control) + rows = {item["name"]: item for item in keys.list()} + self.assertLess(rows["expired"]["expires_at"], __import__("time").time()) + self.assertEqual(list(rows["ip"]["ip_allow"]), ["127.0.0.1/32"]) + self.assertEqual(list(rows["models"]["models"]), ["auto"]) + seed.write_history(Path(directory) / "logs.sqlite3", days=1, + key_ids=list(ids.values())) + audit = AuditStore(Path(directory) / "logs.sqlite3") + self.assertGreater(sum(item["requests"] for item in audit.api_key_stats()["items"].values()), 0) + audit.close() + control.close() + seed.write_media(Path(directory), api_key=ids["models"]) + media = MediaStore(Path(directory)) + tasks = {item["task_id"]: item["api_key"] for item in media.list_tasks()["items"]} + self.assertEqual(tasks["seedvideotask-complete"], ids["models"]) + self.assertIsNone(tasks[seed.SEED_VIDEO_TASK_ID]) + media.close() + class PublicTests(unittest.TestCase): def test_guard_refuses_credential_transfer_and_shared_changes(self): diff --git a/tests/test_realtime_streaming.py b/tests/test_realtime_streaming.py index 6eeb5d6..c7afffe 100644 --- a/tests/test_realtime_streaming.py +++ b/tests/test_realtime_streaming.py @@ -22,7 +22,7 @@ from app.control_store import ControlStore from app.observability import AuditMiddleware from app.settings import apply_persisted_settings, resolve_settings -from app.startup import load_startup_env, resolve_startup_key +from app.startup import load_startup_env, resolve_startup_auth from app.adapters.anthropic_adapter import AnthropicStreamConverter from app.adapters.responses_adapter import ResponsesStreamConverter from app.upstream_io import ChatSSEAccumulator, StreamOutputBudget, UpstreamResponseError @@ -1423,10 +1423,10 @@ def test_dotenv_overrides_sqlite_and_process_environment_overrides_dotenv(self): store.update_settings({"stream_mode": "compatible"}, store.snapshot()["revision"]) with patch.dict(os.environ, {"CODEBUDDY2API_KEY": "synthetic-test-key"}, clear=True): dotenv_keys = load_startup_env(path) - config = {"control_store": store} + config = {"control_store": store, "settings_sources": {"api_key": "environment"}} apply_persisted_settings(config, environ=os.environ) config["state_store"] = store.state - resolve_startup_key(config, SimpleNamespace(api_key="synthetic-test-key"), dotenv_keys) + resolve_startup_auth(config, SimpleNamespace(api_key="synthetic-test-key"), dotenv_keys) item = next(value for value in resolve_settings(config) if value["key"] == "stream_mode") self.assertEqual((config["stream_mode"], item["source"], item["locked"]), ("realtime", "dotenv", True)) @@ -1435,10 +1435,10 @@ def test_dotenv_overrides_sqlite_and_process_environment_overrides_dotenv(self): "CODEBUDDY2API_KEY": "synthetic-test-key", "CODEBUDDY2API_STREAM_MODE": "compatible"}, clear=True): dotenv_keys = load_startup_env(path) - config = {"control_store": store} + config = {"control_store": store, "settings_sources": {"api_key": "environment"}} apply_persisted_settings(config, environ=os.environ) config["state_store"] = store.state - resolve_startup_key(config, SimpleNamespace(api_key="synthetic-test-key"), dotenv_keys) + resolve_startup_auth(config, SimpleNamespace(api_key="synthetic-test-key"), dotenv_keys) item = next(value for value in resolve_settings(config) if value["key"] == "stream_mode") self.assertEqual((config["stream_mode"], item["source"]), ("compatible", "environment")) diff --git a/tests/test_runtime_endpoints.py b/tests/test_runtime_endpoints.py index 2c975f1..1df3b13 100644 --- a/tests/test_runtime_endpoints.py +++ b/tests/test_runtime_endpoints.py @@ -174,9 +174,10 @@ def test_inference_errors_follow_the_client_protocol_shape(self): self.assertEqual(missing.json()["error"]["type"], "not_found_error") finally: converter.CONFIG["model_guard"] = False - response = self.client.get("/admin/credentials") - self.assertEqual(response.status_code, 401, response.text) - self.assertIn("detail", response.json()) + for headers in ({}, {"Authorization": "Bearer secret"}): + response = self.client.get("/admin/credentials", headers=headers) + self.assertEqual(response.status_code, 503, response.text) + self.assertIn("detail", response.json()) finally: converter.CONFIG["api_key"] = "" @@ -957,23 +958,21 @@ def test_defaults(self): "admin_allowed_origins": "", "responses_projection_mode": "balanced", "responses_projection_max_bytes": 40000}) - def test_open_binding_without_key_requires_explicit_opt_in(self): - # Reject unauthenticated public binding by default. - self.configure(flags=("--host", "0.0.0.0"), invalid=True) - # Explicit configuration allows unauthenticated binding. - self.configure(env={"CODEBUDDY2API_ALLOW_OPEN_NOAUTH": "true"}, flags=("--host", "0.0.0.0")) - # Configured authentication permits public binding. - self.configure(env={"CODEBUDDY2API_KEY": "k"}, flags=("--host", "0.0.0.0")) + def test_open_binding_without_key_starts_and_noauth_only_controls_requests(self): + self.configure(flags=("--host", "0.0.0.0"), expected_host="0.0.0.0") + self.configure(env={"CODEBUDDY2API_ALLOW_OPEN_NOAUTH": "true"}, flags=("--host", "0.0.0.0"), + expected_host="0.0.0.0") + self.configure(env={"CODEBUDDY2API_KEY": "k"}, flags=("--host", "0.0.0.0"), expected_host="0.0.0.0") def test_persisted_host_is_validated_after_configuration_resolution(self): for host in ("0.0.0.0", "::"): with self.subTest(host=host): - self.configure(stored={"host": host}, invalid=True) + self.configure(stored={"host": host}, expected_host=host) self.configure(stored={"host": host}, env={"CODEBUDDY2API_KEY": "k"}, expected_host=host) self.configure(stored={"host": host}, env={"CODEBUDDY2API_ALLOW_OPEN_NOAUTH": "true"}, expected_host=host) self.configure(stored={"host": "0.0.0.0"}, flags=("--host", "127.0.0.1"), expected_host="127.0.0.1") - self.configure(stored={"host": "127.0.0.1"}, flags=("--host", "0.0.0.0"), invalid=True) + self.configure(stored={"host": "127.0.0.1"}, flags=("--host", "0.0.0.0"), expected_host="0.0.0.0") self.configure(stored={"host": "0.0.0.0"}, env={"CODEBUDDY2API_KEY": ""}, flags=("--api-key", "k"), expected_host="0.0.0.0") diff --git a/tests/test_security.py b/tests/test_security.py index 9d218c9..929aa79 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -19,6 +19,7 @@ from app.credential_io import ( CredentialFileError, MAX_CREDENTIAL_BYTES, atomic_write_credential, read_import_file, ) +from tests.admin_session import direct_request def credential(uid="test-user", token="test-only-token"): @@ -64,7 +65,7 @@ def source(self, name="account.info", data=None): def post(self, path, authorization=None): return asyncio.run(converter.admin_add_credential( - Request({"path": path}), authorization or "Bearer synthetic-admin-key", None)) + direct_request({"path": path}), authorization or "Bearer synthetic-admin-key", None)) def assert_http(self, code, call): with self.assertRaises(converter.HTTPException) as caught: @@ -113,8 +114,8 @@ def test_invalid_request_types(self): self.assert_http(400, lambda: self.post(value)) for body in [None, [], "path", 123]: self.assert_http(400, lambda: asyncio.run( - converter.admin_add_credential(Request(body), "Bearer synthetic-admin-key", None))) - request = Mock() + converter.admin_add_credential(direct_request(body), "Bearer synthetic-admin-key", None))) + request = direct_request() async def invalid_json(): raise ValueError("synthetic-secret") request.json = invalid_json @@ -210,17 +211,39 @@ def test_invalid_credentials_do_not_write_or_leak(self): self.assertFalse((self.auth / source.name).exists()) def test_oversized_integer_timestamps_return_400_without_writes(self): + from fastapi import FastAPI from fastapi.testclient import TestClient - - with TestClient(converter.app) as client, patch.object(converter, "_store_credential") as store: + from app import admin_account + from app.admin_api import install_admin + from app.audit_store import AuditStore + from app.control_store import ControlStore + from app.gateway_management import Management + from tests.admin_session import login as session_login + + control = ControlStore(self.root / "control.sqlite3") + self.addCleanup(control.close) + audit = AuditStore(self.root / "audit.sqlite3") + self.addCleanup(audit.close) + admin_account.save(control, admin_account.new("admin", "synthetic-admin-key", "user")) + management = Management(converter) + config = {"api_key": "synthetic-admin-key", "cred_pool": self.pool, "cred": None, + "log_path": None, "control_store": control, "state_store": control.state, + "audit_store": audit, "session_path": self.root / "sessions.json", + "management": management} + app = FastAPI() + app.router.routes = list(converter.app.router.routes) + install_admin(app, config, management) + with patch.dict(converter.CONFIG, config), \ + TestClient(app, base_url="https://testserver") as client, \ + patch.object(converter, "_store_credential") as store: + headers = session_login(client, origin="https://testserver", password="synthetic-admin-key") for field in ("expiresAt", "lastRefreshTime"): for value in (10**1000, -(10**1000)): with self.subTest(field=field, negative=value < 0): data = credential(token="synthetic-secret") data["auth"][field] = value source = self.source("invalid-time.info", data) - response = client.post("/admin/credentials", json={"path": source.name}, - headers={"Authorization": "Bearer synthetic-admin-key"}) + response = client.post("/admin/credentials", json={"path": source.name}, headers=headers) self.assertEqual(response.status_code, 400, response.text) self.assertNotIn("synthetic-secret", response.text) self.assertNotIn(str(self.root), response.text) @@ -279,21 +302,21 @@ def test_atomic_writer_rejects_bad_names(self): def test_auth_before_body_or_filesystem_access(self): converter.CONFIG["api_key"] = "test-only-api-key" - request = Mock() + request = direct_request(identity=None) with patch.object(converter, "read_import_file", side_effect=AssertionError("must not read")): - self.assert_http(401, lambda: asyncio.run( + self.assert_http(503, lambda: asyncio.run( converter.admin_add_credential(request, None, None))) request.json.assert_not_called() source = self.source() self.post(source.name, "Bearer test-only-api-key") def test_empty_key_locks_legacy_admin_without_management_middleware(self): - request = Mock() + request = direct_request(identity=None) with patch.dict(converter.CONFIG, {"api_key": ""}): self.assert_http(503, lambda: asyncio.run(converter.admin_add_credential(request, None, None))) self.assert_http(503, lambda: converter.admin_list_credentials(None, None)) request.json.assert_not_called() - self.assertIsNone(converter._check_auth(None, None)) + self.assertIsNone(converter._check_auth(None)) def test_public_health_never_touches_credentials(self): diff --git a/tests/test_sqlite_state.py b/tests/test_sqlite_state.py index fc2a0e2..da66d3b 100644 --- a/tests/test_sqlite_state.py +++ b/tests/test_sqlite_state.py @@ -21,9 +21,7 @@ from app.credential_cooldowns import CredentialCooldowns from app.credits import CreditLedger, ModelCatalogCache from app.model_blocks import ModelBlocks -from app.safe_logging import sanitize_log_text -from app.settings import resolve_settings -from app.startup import announce_default_key, load_startup_env, resolve_startup_key +from app.startup import load_startup_env, resolve_startup_auth from app.state_store import LEGACY_FILES from app.trial_rewards import TrialLedger, attempt_trial from app.usage_snapshots import UsageSnapshots @@ -149,7 +147,7 @@ def test_upgrade_v1_preserves_settings_and_rejects_old_readers(self): self.control._db.execute("PRAGMA user_version=1") other = self.open_control() self.assertEqual(other.snapshot()["settings"]["max_images"], 3) - self.assertEqual(other._db.execute("PRAGMA user_version").fetchone()[0], 2) + self.assertEqual(other._db.execute("PRAGMA user_version").fetchone()[0], 3) def test_trial_reservation_is_atomic_across_connections_and_failures_are_closed(self): other = self.open_control() @@ -239,255 +237,10 @@ def test_session_revocation_failure_does_not_activate_new_key(self): auth.reconcile() self.assertEqual(auth._configured_key, FIXED_KEY) - def test_default_key_is_atomic_private_and_survives_restart(self): - other = self.open_control() - with concurrent.futures.ThreadPoolExecutor(2) as executor: - results = list(executor.map(lambda state: state.default_key(create=True), [self.state, other.state])) - self.assertEqual(results[0], results[1]) - key, pending = results[0] - self.assertRegex(key, r"^cb-[0-9a-f]{32}$") - self.assertTrue(pending) - self.assertNotIn(key, json.dumps(self.control.snapshot())) - self.assertEqual(self.open_control().state.default_key(), (key, True)) - with self.state.claim_announcement(key) as claimed: - self.assertTrue(claimed) - with other.state.claim_announcement(key) as claimed: - self.assertFalse(claimed) - self.assertEqual(other.state.default_key(), (key, False)) - - def test_broken_saved_key_is_not_regenerated(self): - self.state.default_key(create=True) - self.control._db.execute("UPDATE gateway_secrets SET value='broken'") - with self.assertRaises(ValueError): - self.state.default_key(create=True) - self.assertEqual(self.control._db.execute("SELECT value FROM gateway_secrets").fetchone()[0], "broken") - def config(self, source="default", key=""): return {"state_store": self.state, "control_store": self.control, "api_key": key, "host": "127.0.0.1", "settings_sources": {"api_key": source}} - def test_successful_announcement_is_committed_and_not_repeated(self): - config = self.config() - terminal = self.console() - with patch("sys.stderr", io.StringIO()) as output: - resolve_startup_key(config, SimpleNamespace(api_key="")) - key = config["api_key"] - self.assertEqual(terminal.getvalue(), "") - announce_default_key(config) - announce_default_key(config) - again = self.config() - resolve_startup_key(again, SimpleNamespace(api_key="")) - announce_default_key(again) - self.assertEqual(terminal.getvalue().count(key), 1) - self.assertNotIn(key, output.getvalue()) - self.assertEqual(again["api_key"], key) - self.assertIsNone(next(row for row in resolve_settings(config) if row["key"] == "api_key")["value"]) - self.assertNotIn(key, sanitize_log_text("key leaked " + key)) - with patch("sys.stderr", io.StringIO()): - resolve_startup_key(self.config(), SimpleNamespace(api_key="")) - - def test_missing_terminal_at_disclosure_does_not_consume_announcement(self): - terminal = self.console() - config = self.config() - resolve_startup_key(config, SimpleNamespace(api_key="")) - self.console_open.side_effect = OSError("terminal gone") - with self.assertRaises(OSError): - announce_default_key(config) - self.assertEqual(self.state.default_key(), (config["api_key"], True)) - self.assertEqual(terminal.getvalue(), "") - - def test_announcement_reservation_failure_never_writes_terminal(self): - terminal = self.console() - config = self.config() - resolve_startup_key(config, SimpleNamespace(api_key="")) - with patch.object(self.state, "claim_announcement", side_effect=sqlite3.OperationalError("locked")), \ - self.assertRaises(sqlite3.OperationalError): - announce_default_key(config) - self.assertEqual(terminal.getvalue(), "") - self.assertEqual(self.state.default_key(), (config["api_key"], True)) - - - def test_terminal_write_and_flush_failures_leave_default_key_pending(self): - for operation in ("write", "flush"): - with self.subTest(operation=operation): - self.control._db.execute("DELETE FROM gateway_secrets") - terminal = self.console() - config = self.config() - resolve_startup_key(config, SimpleNamespace(api_key="")) - key = config["api_key"] - with patch.object(terminal, operation, side_effect=BrokenPipeError("fixture terminal lost")), \ - self.assertRaises(BrokenPipeError): - announce_default_key(config) - self.assertEqual(self.state.default_key(), (key, True)) - self.assertTrue(config["announce_default_key"]) - recovered = self.console() - announce_default_key(config) - self.assertEqual(recovered.getvalue().count(key), 1) - self.assertEqual(self.state.default_key(), (key, False)) - - def test_process_crash_during_disclosure_rolls_back_the_marker(self): - import subprocess - key, _ = self.state.default_key(create=True) - script = ''' -import os, sys -from app.control_store import ControlStore -control = ControlStore(sys.argv[1]) -key, _ = control.state.default_key() -with control.state.claim_announcement(key) as claimed: - assert claimed - os._exit(17) -''' - result = subprocess.run([sys.executable, "-B", "-c", script, str(self.control.path)], - capture_output=True, timeout=15) - self.assertEqual(result.returncode, 17) - self.assertEqual((result.stdout, result.stderr), (b"", b"")) - self.assertEqual(self.open_control().state.default_key(), (key, True)) - - - def test_commit_failure_after_disclosure_keeps_recovery_pending(self): - terminal = self.console() - config = self.config() - resolve_startup_key(config, SimpleNamespace(api_key="")) - key = config["api_key"] - def deny_commit(action, arg1, arg2, database, trigger): - return sqlite3.SQLITE_DENY if action == sqlite3.SQLITE_TRANSACTION and arg1 == "COMMIT" else sqlite3.SQLITE_OK - self.control._db.set_authorizer(deny_commit) - try: - with self.assertRaises(sqlite3.DatabaseError): - announce_default_key(config) - finally: - self.control._db.set_authorizer(None) - self.assertFalse(self.control._db.in_transaction) - self.assertEqual(terminal.getvalue().count(key), 1) - self.assertEqual(self.state.default_key(), (key, True)) - self.assertTrue(config["announce_default_key"]) - recovered = self.console() - announce_default_key(config) - self.assertEqual(recovered.getvalue().count(key), 1) - self.assertEqual(self.state.default_key(), (key, False)) - - def test_concurrent_disclosures_print_only_after_acquiring_exclusive_claim(self): - import threading - terminal = self.console() - key, _ = self.state.default_key(create=True) - other = self.open_control() - configs = [{**self.config(), "state_store": state, "api_key": key, "announce_default_key": True} - for state in (self.state, other.state)] - barrier = threading.Barrier(2, timeout=5) - def announce(config): - barrier.wait() - announce_default_key(config) - with concurrent.futures.ThreadPoolExecutor(2) as executor: - list(executor.map(announce, configs)) - self.assertEqual(terminal.getvalue().count(key), 1) - self.assertEqual(self.state.default_key(), (key, False)) - self.assertTrue(all(not config["announce_default_key"] for config in configs)) - - def test_terminal_failure_after_binding_shuts_down_with_pending_key(self): - import asyncio - import uvicorn - from fastapi import FastAPI - from unittest.mock import AsyncMock - from app.startup import run_server - terminal = self.console() - config = self.config() - resolve_startup_key(config, SimpleNamespace(api_key="")) - async def ready(server, sockets=None): - server.started = True - with patch.object(uvicorn.Server, "startup", ready), \ - patch.object(uvicorn.Server, "shutdown", new=AsyncMock()) as shutdown, \ - patch.object(uvicorn.Server, "run", lambda server: asyncio.run(server.startup())), \ - patch.object(terminal, "flush", side_effect=BrokenPipeError("fixture")), \ - self.assertRaises(BrokenPipeError): - run_server(FastAPI(), config, host="127.0.0.1", port=8787) - shutdown.assert_awaited_once() - self.assertEqual(self.state.default_key(), (config["api_key"], True)) - - - def test_explicit_sources_do_not_overwrite_saved_default_or_print(self): - key, _ = self.state.default_key(create=True) - for source in ("cli", "environment", "dotenv"): - for supplied in ("", "override-fixture"): - config = self.config(source, supplied) - with patch("sys.stderr", io.StringIO()) as output: - resolve_startup_key(config, SimpleNamespace(api_key=supplied)) - announce_default_key(config) - self.assertEqual(config["api_key"], supplied) - self.assertEqual(output.getvalue(), "") - self.assertEqual(self.state.default_key()[0], key) - - def test_nonterminal_and_public_first_start_do_not_generate_hidden_key(self): - with patch("sys.stderr", io.StringIO()), self.assertRaises(ValueError): - resolve_startup_key(self.config(), SimpleNamespace(api_key="")) - config = self.config() - config["host"] = "0.0.0.0" - with patch("sys.stderr", Terminal()), self.assertRaises(ValueError): - resolve_startup_key(config, SimpleNamespace(api_key="")) - self.assertEqual(self.state.default_key(), (None, False)) - - def test_explicit_noauth_opt_in_skips_first_key_generation_without_a_terminal(self): - for value in ("1", "true", "yes", "TRUE"): - for host in ("127.0.0.1", "0.0.0.0"): - config = {**self.config(), "host": host} - with self.subTest(value=value, host=host), \ - patch.dict(os.environ, {"CODEBUDDY2API_ALLOW_OPEN_NOAUTH": value}, clear=True): - resolve_startup_key(config, SimpleNamespace(api_key="")) - self.assertFalse(config["api_key"]) - self.assertFalse(config["announce_default_key"]) - self.assertEqual(self.state.default_key(), (None, False)) - self.console_open.assert_not_called() - - def test_disabled_noauth_opt_in_keeps_first_start_protection(self): - for value in ("", "0", "false", "no", "invalid"): - with self.subTest(value=value), \ - patch.dict(os.environ, {"CODEBUDDY2API_ALLOW_OPEN_NOAUTH": value}, clear=True), \ - self.assertRaises(ValueError): - resolve_startup_key({**self.config(), "host": "0.0.0.0"}, SimpleNamespace(api_key="")) - self.assertEqual(self.state.default_key(), (None, False)) - - def test_noauth_opt_in_never_downgrades_an_existing_default_or_override(self): - key, _ = self.state.default_key(create=True) - with patch.dict(os.environ, {"CODEBUDDY2API_ALLOW_OPEN_NOAUTH": "true"}, clear=True): - with self.assertRaises(ValueError): - resolve_startup_key(self.config(), SimpleNamespace(api_key="")) - with self.state.claim_announcement(key): - pass - config = {**self.config(), "host": "0.0.0.0"} - resolve_startup_key(config, SimpleNamespace(api_key="")) - self.assertEqual(config["api_key"], key) - for source in ("cli", "environment", "dotenv"): - config = self.config(source, "configured-fixture") - resolve_startup_key(config, SimpleNamespace(api_key="configured-fixture")) - self.assertEqual(config["api_key"], "configured-fixture") - - def test_docker_default_without_key_or_terminal_reaches_server(self): - import converter - from fastapi import FastAPI - dockerfile = (Path(__file__).resolve().parents[1] / "Dockerfile").read_text() - command = json.loads(next(line[4:] for line in dockerfile.splitlines() if line.startswith("CMD "))) - self.assertIn("ENV CODEBUDDY2API_ALLOW_OPEN_NOAUTH=true", dockerfile) - with contextlib.chdir(self.root), contextlib.ExitStack() as stack: - stack.enter_context(patch.dict(os.environ, {"HOME": str(self.root), "CODEBUDDY_AUTH_DIR": str(self.root), - "CODEBUDDY2API_ALLOW_OPEN_NOAUTH": "true"}, clear=True)) - stack.enter_context(patch.dict(converter.CONFIG)) - stack.enter_context(patch.object(converter, "app", FastAPI())) - stack.enter_context(patch.object(sys, "argv", command[1:])) - stack.enter_context(patch.object(converter, "seed_credentials")) - stack.enter_context(patch.object(converter, "CredentialPool")) - stack.enter_context(patch.object(converter, "_publish_model_cache")) - stack.enter_context(patch.object(converter.threading, "Thread")) - stack.enter_context(patch("sys.stderr", io.StringIO())) - captured = {} - def serve(app, config, **kwargs): - captured.update(kwargs, key=config["api_key"], announcement=config["announce_default_key"]) - stack.enter_context(patch.object(converter, "run_server", side_effect=serve)) - converter.main() - self.assertEqual((captured["host"], captured["port"]), ("0.0.0.0", 8787)) - self.assertFalse(captured["key"]) - self.assertFalse(captured["announcement"]) - self.assertEqual(self.state.default_key(), (None, False)) - self.console_open.assert_not_called() - def test_dotenv_is_optional_and_process_environment_wins(self): path = self.root / ".env" @@ -500,7 +253,7 @@ def test_dotenv_is_optional_and_process_environment_wins(self): self.assertEqual(os.environ["CODEBUDDY2API_KEY"], "file-fixture") self.assertEqual(changed, {"CODEBUDDY2API_KEY"}) config = self.config("environment", "file-fixture") - resolve_startup_key(config, SimpleNamespace(api_key="file-fixture"), changed) + resolve_startup_auth(config, SimpleNamespace(api_key="file-fixture"), changed) self.assertEqual(config["settings_sources"]["api_key"], "dotenv") def test_invalid_dotenv_fails_without_loading_partial_values(self): @@ -513,9 +266,9 @@ def test_invalid_dotenv_fails_without_loading_partial_values(self): self.assertNotIn("CODEBUDDY2API_PORT", os.environ) - def test_full_startup_precedence_and_default_restore(self): + def test_full_startup_precedence_and_external_removal(self): import converter - from app import runtime_management + from app import admin_account, runtime_management from fastapi import FastAPI def boot(env=None, flags=()): @@ -533,8 +286,8 @@ def boot(env=None, flags=()): stack.enter_context(patch.object(runtime_management, "install")) output = stack.enter_context(patch("sys.stderr", Terminal())) def serve(app, config, **kwargs): - observed.update(kwargs, key=config["api_key"], sources=dict(config["settings_sources"])) - announce_default_key(config) + observed.update(kwargs, key=config["api_key"], sources=dict(config["settings_sources"]), + account=admin_account.load(config["control_store"])) stack.enter_context(patch.object(converter, "run_server", side_effect=serve)) converter.main() observed["output"] = output.getvalue() @@ -542,44 +295,48 @@ def serve(app, config, **kwargs): return observed first = boot() - key = first["key"] - self.assertEqual(first["terminal"].count(key), 1) - self.assertNotIn(key, first["output"]) - self.assertEqual(first["sources"]["api_key"], "generated") + self.assertEqual(first["key"], "") + self.assertEqual(first["sources"]["api_key"], "default") + self.assertEqual(first["account"].password_source, "generated") + self.assertEqual(first["terminal"].count("初始管理密码:"), 1) + self.assertNotIn("初始管理密码:", first["output"]) envfile = self.root / ".env" envfile.write_text("CODEBUDDY2API_PORT=9092\nCODEBUDDY2API_KEY=file-fixture\n") file_run = boot() self.assertEqual((file_run["port"], file_run["key"], file_run["sources"]["api_key"]), (9092, "file-fixture", "dotenv")) + self.assertEqual(file_run["account"].password_source, "external") environment = boot({"CODEBUDDY2API_PORT": "9093", "CODEBUDDY2API_KEY": "env-fixture"}) - self.assertEqual((environment["port"], environment["key"]), (9093, "env-fixture")) + self.assertEqual((environment["port"], environment["key"], environment["sources"]["api_key"]), + (9093, "env-fixture", "environment")) cli = boot({"CODEBUDDY2API_PORT": "9093", "CODEBUDDY2API_KEY": "env-fixture"}, ("--port", "9094", "--api-key", "cli-fixture")) - self.assertEqual((cli["port"], cli["key"]), (9094, "cli-fixture")) + self.assertEqual((cli["port"], cli["key"], cli["sources"]["api_key"]), (9094, "cli-fixture", "cli")) envfile.unlink() resumed = boot() - self.assertEqual(resumed["key"], key) - self.assertNotIn(key, resumed["output"]) - self.assertEqual(resumed["terminal"], "") - self.assertEqual(self.state.default_key(), (key, False)) + self.assertEqual(resumed["key"], "") + self.assertEqual(resumed["sources"]["api_key"], "default") + self.assertEqual(resumed["account"].password_source, "generated") + self.assertEqual(resumed["terminal"].count("初始管理密码:"), 1) self.assertFalse(list(self.root.glob("*.json"))) - def test_listener_failure_does_not_consume_announcement(self): + def test_listener_failure_keeps_initialized_account(self): import asyncio import uvicorn from fastapi import FastAPI from unittest.mock import AsyncMock + from app import admin_account from app.startup import run_server config = self.config() terminal = self.console() with patch("sys.stderr", io.StringIO()): - resolve_startup_key(config, SimpleNamespace(api_key="")) - key = config["api_key"] + resolve_startup_auth(config, SimpleNamespace(api_key="")) with patch.object(uvicorn.Server, "startup", new=AsyncMock(side_effect=SystemExit(3))), \ patch.object(uvicorn.Server, "run", lambda server: asyncio.run(server.startup())): with self.assertRaises(SystemExit): run_server(FastAPI(), config, host="127.0.0.1", port=8787) - self.assertNotIn(key, terminal.getvalue()) - self.assertEqual(self.state.default_key(), (key, True)) + self.assertEqual(terminal.getvalue().count("初始管理密码:"), 1) + self.assertEqual(admin_account.load(self.control).password_source, "generated") + self.assertEqual(config["api_key"], "") diff --git a/tests/test_terminal_output.py b/tests/test_terminal_output.py index d9988e5..e35753e 100644 --- a/tests/test_terminal_output.py +++ b/tests/test_terminal_output.py @@ -55,15 +55,13 @@ def test_real_pty_with_redirected_standard_streams_discloses_once(self): import fcntl, os, sys, termios from types import SimpleNamespace from app.control_store import ControlStore -from app.startup import resolve_startup_key, announce_default_key +from app.startup import resolve_startup_auth os.setsid() fcntl.ioctl(int(sys.argv[1]), termios.TIOCSCTTY, 0) control = ControlStore(sys.argv[2]) try: - config = {"settings_sources": {}, "state_store": control.state, "host": "127.0.0.1"} - resolve_startup_key(config, SimpleNamespace(api_key="")) - announce_default_key(config) - announce_default_key(config) + config = {"settings_sources": {}, "control_store": control, "state_store": control.state, "host": "127.0.0.1"} + resolve_startup_auth(config, SimpleNamespace(api_key="")) finally: control.close() ''' @@ -71,43 +69,46 @@ def test_real_pty_with_redirected_standard_streams_discloses_once(self): pass_fds=(slave,), stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20, check=True) with contextlib.closing(sqlite3.connect(database)) as db: - key, announced = db.execute('SELECT value,announced FROM gateway_secrets').fetchone() + value, announced = db.execute( + "SELECT value,announced FROM gateway_secrets WHERE name='admin_account'").fetchone() self.assertEqual(announced, 1) + self.assertIn('"password_source":"generated"', value) os.set_blocking(master, False) try: output = os.read(master, 16384) except BlockingIOError: output = b'' - self.assertEqual(output.count(key.encode()), 1 if attempt == 0 else 0) + self.assertEqual(output.count('初始管理密码:'.encode()), 1 if attempt == 0 else 0) self.assertEqual((result.stdout, result.stderr), (b'', b'')) finally: os.close(slave) os.close(master) @unittest.skipUnless(os.name == 'posix', 'Detached process requires POSIX') - def test_no_controlling_terminal_cannot_create_hidden_key(self): + def test_no_controlling_terminal_discloses_password_to_stdout(self): with tempfile.TemporaryDirectory() as directory: database = Path(directory) / 'control.sqlite3' script = ''' import sys from types import SimpleNamespace from app.control_store import ControlStore -from app.startup import resolve_startup_key +from app.startup import resolve_startup_auth control = ControlStore(sys.argv[1]) try: - try: - resolve_startup_key({"settings_sources": {}, "state_store": control.state, "host": "127.0.0.1"}, - SimpleNamespace(api_key="")) - except ValueError: - assert control.state.default_key() == (None, False) - else: - raise AssertionError("Headless startup unexpectedly generated a key") + config = {"settings_sources": {}, "control_store": control, "state_store": control.state, "host": "127.0.0.1"} + resolve_startup_auth(config, SimpleNamespace(api_key="")) finally: control.close() ''' result = subprocess.run([sys.executable, '-B', '-c', script, str(database)], start_new_session=True, capture_output=True, timeout=20, check=True) - self.assertEqual((result.stdout, result.stderr), (b'', b'')) + self.assertIn('管理账户:admin'.encode(), result.stdout) + self.assertIn('初始管理密码:'.encode(), result.stdout) + self.assertEqual(result.stderr, b'') + with contextlib.closing(sqlite3.connect(database)) as db: + self.assertIn('"password_source":"generated"', db.execute( + "SELECT value FROM gateway_secrets WHERE name='admin_account'").fetchone()[0]) + self.assertEqual(db.execute('SELECT count(*) FROM api_keys').fetchone()[0], 0) if __name__ == '__main__': diff --git a/tests/test_trial_integration.py b/tests/test_trial_integration.py index 2a8e14b..105a9ff 100644 --- a/tests/test_trial_integration.py +++ b/tests/test_trial_integration.py @@ -16,6 +16,7 @@ from app import trial_rewards, trial_management, settings from app.control_store import ControlStore from tests import test_credential_actions as fixtures +from tests.admin_session import credentials from tests.test_trial_rewards import headers, mock_http @@ -245,10 +246,11 @@ def wait(headers): self.claim.assert_called_once() def test_manual_post_requires_auth_and_csrf(self): - self.client.headers.pop('Authorization') + self.client.headers.pop('Authorization', None) + self.client.cookies.clear() self.assertEqual(self.client.post(self.url + '/trial').status_code, 401) login = self.client.post('/admin/session', headers={'Origin': 'https://testserver'}, - json={'api_key': 'synthetic-management-key'}) + json=credentials('synthetic-management-key')) self.assertEqual(login.status_code, 200) self.assertEqual(self.client.post(self.url + '/trial').status_code, 403) self.claim.assert_not_called() diff --git a/tests/test_updates.py b/tests/test_updates.py index 95d2e66..9df123b 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -26,7 +26,7 @@ from fastapi.testclient import TestClient import httpx -from app import startup, updates +from app import admin_account, startup, updates from app.admin_api import install_admin from app.backups import BackupError, Backups from app.control_store import ControlStore @@ -34,6 +34,7 @@ from app.settings import normalize_update_mirror from app.updates import REPOSITORY_URL, UpdateError, Updater +from tests.admin_session import login as session_login def tarball(files, *, prefix): buffer = io.BytesIO() @@ -598,10 +599,11 @@ def setUp(self): session_path=self.root / "admin-sessions.json") self.config["updater"] = self.updater() self.config["backups"] = self.config["updater"].backups + admin_account.save(store, admin_account.new("admin", "synthetic-key", "user")) app = FastAPI() install_admin(app, self.config, SimpleNamespace()) self.client = self.enterContext(TestClient(app)) - self.headers = {"Authorization": "Bearer synthetic-key"} + self.headers = session_login(self.client) def test_update_routes_report_state_and_map_failures_to_messages(self): view = self.client.get("/admin/update", headers=self.headers).json() diff --git a/tests/test_webui_integration.py b/tests/test_webui_integration.py index 671ba84..7dfeb72 100644 --- a/tests/test_webui_integration.py +++ b/tests/test_webui_integration.py @@ -12,10 +12,12 @@ from fastapi.testclient import TestClient import converter +from app import admin_account from app.admin_api import install_admin from app.audit_store import AuditStore from app.control_store import ControlStore from app.gateway_management import Management, install_pages +from tests.admin_session import credentials, login as session_login from app.model_policy import PolicyScopeMiddleware, default_rule from app.observability import AuditMiddleware from tests import test_region_routing as fixtures @@ -25,6 +27,7 @@ class ManagedRoutingTests(fixtures.RegionRoutingTests): def setUp(self): super().setUp() self.control = ControlStore(self.root / "control.sqlite3") + admin_account.save(self.control, admin_account.new("admin", "synthetic-management-key", "user")) self.audit = AuditStore(self.root / "logs.sqlite3") self.addCleanup(self.control.close) self.addCleanup(self.audit.close) @@ -43,6 +46,8 @@ def setUp(self): self.client = self.enterContext(TestClient(application, headers={"Authorization": "Bearer synthetic-management-key"}, base_url="https://testserver")) + self.client.headers.update(session_login( + self.client, origin="https://testserver", password="synthetic-management-key")) def policy(self, source="shared-model", **kwargs): rule = {**default_rule(source), **kwargs} self.control.update_model(source, rule, self.control.snapshot()["revision"], @@ -254,7 +259,7 @@ def test_managed_log_clear_preserves_stats_and_full_clear_is_guarded(self): denied = self.client.post("/admin/logs/clear", json={"scope": "all"}) self.assertEqual(denied.status_code, 403) done = self.client.post("/admin/logs/clear", json={"scope": "all", "confirmation": "清空全部日志与统计", - "api_key": "synthetic-management-key"}) + "password": "synthetic-management-key"}) self.assertEqual(done.status_code, 200, done.text) self.assertEqual(self.audit.dashboard(1)["summary"]["requests"], 0) self.assertEqual(len(list(self.root.glob("*.info"))), 4) @@ -264,7 +269,7 @@ def test_managed_pages_and_unknown_api_do_not_mix(self): response = self.client.get(path, follow_redirects=False) self.assertEqual(response.status_code, 302) self.assertEqual(response.headers["location"], "/dashboard") - for path in ("/dashboard", "/dashboard/models", "/dashboard/login"): + for path in ("/dashboard", "/dashboard/models", "/dashboard/keys", "/dashboard/login"): response = self.client.get(path) self.assertEqual(response.status_code, 200) self.assertIn("isolated management shell", response.text) @@ -319,7 +324,7 @@ def test_managed_no_overwrite_is_checked_under_file_lock(self): def test_managed_cookie_oauth_poll_without_browser_origin_still_requires_csrf(self): from unittest.mock import Mock origin = {"Origin": "https://testserver"} - login = self.client.post("/admin/session", json={"api_key": "synthetic-management-key"}, headers=origin) + login = self.client.post("/admin/session", json=credentials("synthetic-management-key"), headers=origin) csrf = login.json()["csrf_token"] self.client.headers.pop("authorization") manager = Mock() diff --git a/tests/webui_fixture.py b/tests/webui_fixture.py index 4de9e98..3b70957 100644 --- a/tests/webui_fixture.py +++ b/tests/webui_fixture.py @@ -15,10 +15,10 @@ import uvicorn import converter as gateway +from app import admin_account from app.audit_store import AuditStore from app.control_store import ControlStore from app.media_store import MediaStore -from app.runtime_management import install, close from app.gateway_management import Management from app.runtime_management import install, close from app.settings import apply_persisted_settings @@ -54,6 +54,8 @@ def main(): gateway.CONFIG.update(api_key="synthetic-e2e-key", log_path=None, control_store=ControlStore(root / "control.sqlite3"), audit_store=AuditStore(root / "logs.sqlite3")) + admin_account.save(gateway.CONFIG["control_store"], + admin_account.new("admin", "synthetic-e2e-key", "external")) state = gateway.CONFIG["control_store"].state state.migrate(root) gateway.CONFIG.update(state_store=state, trial_ledger=gateway.trial_rewards.TrialLedger(store=state)) diff --git a/web/e2e/account.spec.ts b/web/e2e/account.spec.ts new file mode 100644 index 0000000..4ff6c77 --- /dev/null +++ b/web/e2e/account.spec.ts @@ -0,0 +1,97 @@ +import { expect, test, type Page } from "@playwright/test"; +import { mockDashboard, mockSettings } from "./mock"; + +async function mockAccount(page: Page) { + let authenticated = true; + let username = "admin"; + let password = "old-password"; + let csrf = "old-csrf"; + let bootstrap = true; + let putCount = 0; + let revoked = false; + await page.route("**/admin/**", async (route) => { + const request = route.request(); + const path = new URL(request.url()).pathname; + if (path === "/admin/session") { + if (request.method() === "POST") { + const body = request.postDataJSON(); + if (body.username !== username || body.password !== password) { + return route.fulfill({ status: 401, json: { error: { message: "用户名或密码错误" } } }); + } + authenticated = true; + return route.fulfill({ json: { authenticated: true, csrf_token: csrf, bootstrap } }); + } + if (request.method() === "DELETE") authenticated = false; + return route.fulfill({ + json: authenticated + ? { authenticated: true, csrf_token: csrf, bootstrap } + : { authenticated: false, csrf_token: null }, + }); + } + if (!authenticated) + return route.fulfill({ status: 401, json: { error: { message: "未认证" } } }); + if (path === "/admin/settings") return route.fulfill({ json: mockSettings }); + if (path === "/admin/dashboard") return route.fulfill({ json: mockDashboard }); + if (path === "/admin/account" && request.method() === "GET") { + return route.fulfill({ + json: { username, password_source: "user", bootstrap, changed_at: 1789200000 }, + }); + } + if (path === "/admin/account" && request.method() === "PUT") { + expect(request.headers()["x-csrf-token"]).toBe(csrf); + const body = request.postDataJSON(); + if (body.current_password !== password) { + return route.fulfill({ status: 403, json: { error: { message: "当前管理密码错误" } } }); + } + putCount++; + username = body.username ?? username; + password = body.new_password ?? password; + bootstrap = false; + csrf = "new-csrf"; + return route.fulfill({ json: { username, bootstrap, csrf_token: csrf } }); + } + if (path === "/admin/sessions" && request.method() === "DELETE") { + expect(request.headers()["x-csrf-token"]).toBe(csrf); + revoked = true; + authenticated = false; + return route.fulfill({ json: { ok: true } }); + } + return route.fulfill({ status: 404, json: { error: { message: `Unexpected ${path}` } } }); + }); + return { getPutCount: () => putCount, wasRevoked: () => revoked }; +} + +test("account changes adopt the new CSRF and revoked sessions require the new password", async ({ + page, +}) => { + const mock = await mockAccount(page); + await page.goto("/dashboard/settings?tab=account"); + await page.getByLabel("用户名", { exact: true }).fill("root"); + await page.getByLabel("当前管理密码", { exact: true }).fill("old-password"); + await page.getByLabel("新密码", { exact: true }).fill("new-password"); + await page.getByLabel("再次输入新密码", { exact: true }).fill("new-password"); + await page.getByRole("button", { name: "保存账户更改" }).click(); + await expect(page.getByText("账户已保存,其他已登录会话已退出。", { exact: true })).toBeVisible(); + page.on("dialog", (dialog) => dialog.accept()); + await page.getByRole("button", { name: "退出所有会话" }).click(); + await expect(page).toHaveURL(/\/dashboard\/login$/); + expect(mock.wasRevoked()).toBe(true); + await page.getByLabel("用户名", { exact: true }).fill("root"); + await page.getByLabel("密码", { exact: true }).fill("old-password"); + await page.getByRole("button", { name: "进入工作台" }).click(); + await expect(page.getByRole("alert")).toContainText("用户名或密码错误"); + await page.getByLabel("密码", { exact: true }).fill("new-password"); + await page.getByRole("button", { name: "进入工作台" }).click(); + await expect(page.getByRole("heading", { name: "运行概览" })).toBeVisible(); +}); + +test("mismatched password confirmation does not write the account", async ({ page }) => { + const mock = await mockAccount(page); + await page.goto("/dashboard/settings?tab=account"); + await page.getByLabel("当前管理密码", { exact: true }).fill("old-password"); + await page.getByLabel("新密码", { exact: true }).fill("new-password"); + await page.getByLabel("再次输入新密码", { exact: true }).fill("different-password"); + await page.getByRole("button", { name: "保存账户更改" }).click(); + await expect(page.getByRole("alert")).toContainText("两次输入的新密码不一致"); + expect(mock.getPutCount()).toBe(0); +}); diff --git a/web/e2e/admin.spec.ts b/web/e2e/admin.spec.ts index 24ba769..9cc6b03 100644 --- a/web/e2e/admin.spec.ts +++ b/web/e2e/admin.spec.ts @@ -335,11 +335,11 @@ test("log tabs, cursor, details and guarded destructive clear", async ({ page }) await choose(page.getByLabel("清理范围"), "all"); await expect(page.getByRole("button", { name: "确认不可撤销的清理" })).toBeDisabled(); await page.getByLabel("输入“清空全部日志与统计”").fill("清空全部日志与统计"); - await page.getByLabel("当前 API key").fill("mock-revalidation-key"); + await page.getByLabel("当前管理密码").fill("mock-revalidation-key"); await page.getByRole("button", { name: "确认不可撤销的清理" }).click(); await expect.poll(() => mock.calls.some((call) => call.path === "/admin/logs/clear")).toBe(true); }); -test("anonymous login never persists API key and API failure is not zero-data success", async ({ +test("anonymous login never persists account credentials and API failure is not zero-data success", async ({ page, }) => { await mockAPI(page, false); @@ -347,7 +347,8 @@ test("anonymous login never persists API key and API failure is not zero-data su await expect(page).toHaveURL(/\/dashboard\/login$/); await expect(page.getByText("不会在浏览器中保存 API key。", { exact: true })).toHaveCount(0); await expect(page.locator("p")).toHaveCount(0); - await page.getByLabel("API key", { exact: true }).fill("mock-login-key"); + await page.getByLabel("用户名", { exact: true }).fill("admin"); + await page.getByLabel("密码", { exact: true }).fill("mock-login-key"); await page.getByRole("button", { name: "进入工作台" }).click(); await expect(page).toHaveURL(/\/dashboard$/); expect( diff --git a/web/e2e/auth.spec.ts b/web/e2e/auth.spec.ts new file mode 100644 index 0000000..bbabb7e --- /dev/null +++ b/web/e2e/auth.spec.ts @@ -0,0 +1,120 @@ +import { expect, test, type Page } from "@playwright/test"; +import { mockDashboard, mockSettings } from "./mock"; + +async function mockAuth(page: Page) { + let authenticated = false; + const passwords = new Set(["initial-password"]); + await page.route("**/admin/**", async (route) => { + const request = route.request(); + const path = new URL(request.url()).pathname; + if (path === "/admin/session") { + if (request.method() === "POST") { + const body = request.postDataJSON(); + if (!passwords.has(body.password)) { + return route.fulfill({ status: 401, json: { error: { message: "用户名或密码错误" } } }); + } + authenticated = true; + const persistent = body.remember === true; + return route.fulfill({ + json: { authenticated: true, csrf_token: "csrf", bootstrap: true }, + headers: { + "set-cookie": `cb_admin_session=session-id; Path=/admin; HttpOnly; SameSite=Strict${ + persistent ? "; Max-Age=34560000" : "" + }`, + }, + }); + } + if (request.method() === "DELETE") { + authenticated = false; + return route.fulfill({ + json: { ok: true }, + headers: { + "set-cookie": "cb_admin_session=; Max-Age=0; Path=/admin; HttpOnly; SameSite=Strict", + }, + }); + } + return route.fulfill({ + json: authenticated + ? { authenticated: true, csrf_token: "csrf", bootstrap: true } + : { authenticated: false, csrf_token: null }, + }); + } + if (!authenticated) + return route.fulfill({ status: 401, json: { error: { message: "未认证" } } }); + if (path === "/admin/settings") return route.fulfill({ json: mockSettings }); + if (path === "/admin/dashboard") return route.fulfill({ json: mockDashboard }); + if (path === "/admin/account") { + return route.fulfill({ + json: { + username: "admin", + password_source: "external", + bootstrap: true, + changed_at: 1789200000, + }, + }); + } + return route.fulfill({ status: 404, json: { error: { message: `Unexpected ${path}` } } }); + }); +} + +async function signIn(page: Page, remember = false) { + await page.getByLabel("用户名", { exact: true }).fill("admin"); + await page.getByLabel("密码", { exact: true }).fill("initial-password"); + if (remember) await page.getByLabel("记住我", { exact: true }).check(); + await page.getByRole("button", { name: "进入工作台" }).click(); + await expect(page.getByRole("heading", { name: "运行概览" })).toBeVisible(); +} + +test("session login survives reload and logout returns to the login page", async ({ + page, + context, +}) => { + await mockAuth(page); + await page.goto("/"); + await expect(page).toHaveURL(/\/dashboard\/login$/); + await signIn(page); + const cookie = (await context.cookies()).find((item) => item.name === "cb_admin_session"); + expect(cookie?.expires).toBe(-1); + await page.reload(); + await expect(page.getByRole("heading", { name: "运行概览" })).toBeVisible(); + await page.getByRole("button", { name: "退出登录" }).click(); + await expect(page).toHaveURL(/\/dashboard\/login$/); +}); + +test("remember me creates a long-lived cookie", async ({ page, context }) => { + await mockAuth(page); + await page.goto("/dashboard/login"); + await signIn(page, true); + const cookie = (await context.cookies()).find((item) => item.name === "cb_admin_session"); + const now = Date.now() / 1000; + expect(cookie?.expires).toBeGreaterThan(now + 399 * 86400); + expect(cookie?.expires).toBeLessThan(now + 401 * 86400); +}); + +test("bootstrap warning links to the account tab", async ({ page }) => { + await mockAuth(page); + await page.goto("/dashboard/login"); + await signIn(page); + await expect( + page.getByRole("status").filter({ hasText: "当前使用初始密码,请尽快修改。" }), + ).toBeVisible(); + await page.getByRole("link", { name: "修改管理账户" }).click(); + await expect(page).toHaveURL(/\/dashboard\/settings\?tab=account$/); + await expect(page.getByRole("heading", { name: "管理账户", exact: true }).first()).toBeVisible(); +}); + +test("login renders in light, dark and a single-column mobile layout", async ({ page }) => { + await mockAuth(page); + await page.emulateMedia({ colorScheme: "light", reducedMotion: "reduce" }); + await page.goto("/dashboard/login"); + await expect(page.getByLabel("密码", { exact: true })).toBeVisible(); + await page.getByLabel("密码", { exact: true }).fill("initial-password"); + await expect(page.locator("p")).toHaveCount(0); + await page.screenshot({ path: "test-results/auth-login-light.png", fullPage: true }); + await page.emulateMedia({ colorScheme: "dark", reducedMotion: "reduce" }); + await page.screenshot({ path: "test-results/auth-login-dark.png", fullPage: true }); + await page.emulateMedia({ colorScheme: "light", reducedMotion: "reduce" }); + await page.setViewportSize({ width: 390, height: 844 }); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await page.screenshot({ path: "test-results/auth-login-mobile.png", fullPage: true }); +}); diff --git a/web/e2e/backend.integration.ts b/web/e2e/backend.integration.ts index b4fca3d..6a27277 100644 --- a/web/e2e/backend.integration.ts +++ b/web/e2e/backend.integration.ts @@ -1,5 +1,13 @@ -import { test, expect } from "@playwright/test"; +import { test, expect, type Page } from "@playwright/test"; +async function login(page: Page, remember = false) { + await page.goto("/dashboard/login"); + await page.getByLabel("用户名", { exact: true }).fill("admin"); + await page.getByLabel("密码", { exact: true }).fill("synthetic-e2e-key"); + if (remember) await page.getByLabel("记住我", { exact: true }).check(); + await page.getByRole("button", { name: "进入工作台" }).click(); + await expect(page.getByRole("heading", { name: "运行概览" })).toBeVisible(); +} test("real management API: session, model alias, audit, clear, and file credentials", async ({ page, }) => { @@ -7,9 +15,7 @@ test("real management API: session, model alias, audit, clear, and file credenti page.on("pageerror", (error) => errors.push(error.message)); await page.goto("/"); await expect(page).toHaveURL(/\/dashboard\/login$/); - await page.getByLabel("API key", { exact: true }).fill("synthetic-e2e-key"); - await page.getByRole("button", { name: "进入工作台" }).click(); - await expect(page.getByRole("heading", { name: "运行概览" })).toBeVisible(); + await login(page); await expect(page.getByText("125", { exact: true })).toBeVisible(); await page.getByRole("link", { name: "模型路由" }).click(); await page @@ -84,10 +90,7 @@ test("manual trial uses the real admin API and persists success and failed-attem }) => { const errors: string[] = []; page.on("pageerror", (error) => errors.push(error.message)); - await page.goto("/dashboard/login"); - await page.getByLabel("API key", { exact: true }).fill("synthetic-e2e-key"); - await page.getByRole("button", { name: "进入工作台" }).click(); - await expect(page.getByRole("heading", { name: "运行概览" })).toBeVisible(); + await login(page); const session = await (await page.request.get("/admin/session")).json(); const settings = await (await page.request.get("/admin/settings")).json(); expect(settings.items.some((item: { key: string }) => item.key === "auto_trial")).toBe(false); @@ -154,10 +157,7 @@ test("manual trial uses the real admin API and persists success and failed-attem test("real management API: media summary, settings round-trip and asset CSP", async ({ page }) => { const errors: string[] = []; page.on("pageerror", (error) => errors.push(error.message)); - await page.goto("/"); - await page.getByLabel("API key", { exact: true }).fill("synthetic-e2e-key"); - await page.getByRole("button", { name: "进入工作台" }).click(); - await expect(page.getByRole("heading", { name: "运行概览" })).toBeVisible(); + await login(page); const summary = await (await page.request.get("/admin/media")).json(); expect(summary.available).toBe(true); @@ -199,3 +199,47 @@ test("real management API: media summary, settings round-trip and asset CSP", as expect((await publicRead.body()).length).toBe(asset.size); expect(errors).toEqual([]); }); + +test("real management API: cookie modes and API key lifecycle", async ({ page, context }) => { + await page.goto("/"); + await expect(page).toHaveURL(/\/dashboard\/login$/); + await login(page); + let cookie = (await context.cookies()).find((item) => item.name === "cb_admin_session"); + expect(cookie?.expires).toBe(-1); + await page.reload(); + await expect(page.getByRole("heading", { name: "运行概览" })).toBeVisible(); + await page.getByRole("button", { name: "退出登录" }).click(); + await expect(page.getByLabel("密码", { exact: true })).toBeVisible(); + await login(page, true); + cookie = (await context.cookies()).find((item) => item.name === "cb_admin_session"); + const now = Date.now() / 1000; + expect(cookie?.expires).toBeGreaterThan(now + 399 * 86400); + expect(cookie?.expires).toBeLessThan(now + 401 * 86400); + + await page.goto("/dashboard/keys"); + await page.getByRole("button", { name: "新建密钥" }).click(); + await page.getByLabel("名称", { exact: true }).fill("real-e2e-standard"); + await page.getByRole("button", { name: "创建", exact: true }).click(); + const dialog = page.getByRole("dialog"); + const secret = await dialog.locator("code").textContent(); + if (!secret) throw new Error("The real API did not return a key secret"); + await dialog.getByRole("button", { name: "关闭", exact: true }).click(); + const inference = await page.request.get("/v1/models", { + headers: { Authorization: `Bearer ${secret}` }, + }); + expect(inference.status()).toBe(200); + const session = await (await page.request.get("/admin/session")).json(); + const keys = await (await page.request.get("/admin/keys")).json(); + const item = keys.items.find( + (row: { id: string; name: string }) => row.name === "real-e2e-standard", + ); + const deleted = await page.request.delete(`/admin/keys/${item.id}`, { + headers: { Origin: "http://127.0.0.1:5175", "X-CSRF-Token": session.csrf_token }, + data: { version: item.version }, + }); + expect(deleted.status()).toBe(200); + const denied = await page.request.get("/v1/models", { + headers: { Authorization: `Bearer ${secret}` }, + }); + expect(denied.status()).toBe(401); +}); diff --git a/web/e2e/keys.spec.ts b/web/e2e/keys.spec.ts new file mode 100644 index 0000000..33e2f03 --- /dev/null +++ b/web/e2e/keys.spec.ts @@ -0,0 +1,127 @@ +import { expect, test, type Page } from "@playwright/test"; +import { mockSettings } from "./mock"; + +const adminSecret = `cb-${"a".repeat(32)}`; +const standardSecret = `cb-${"b".repeat(32)}`; + +async function mockKeys(page: Page, external = false) { + const items: Record[] = []; + const secrets = new Map(); + const created: Record[] = []; + await page.route("**/admin/**", async (route) => { + const request = route.request(); + const path = new URL(request.url()).pathname; + if (path === "/admin/session") { + return route.fulfill({ json: { authenticated: true, csrf_token: "csrf", bootstrap: false } }); + } + if (path === "/admin/settings") return route.fulfill({ json: mockSettings }); + if (path === "/admin/models") { + return route.fulfill({ json: { models: [{ id: "upstream", public_id: "Public model" }] } }); + } + if (path === "/admin/keys" && request.method() === "GET") { + return route.fulfill({ + json: { external: external ? { origin: "environment" } : null, items }, + }); + } + if (path === "/admin/keys" && request.method() === "POST") { + const body = request.postDataJSON(); + created.push(body); + const secret = body.type === "admin" ? adminSecret : standardSecret; + const id = `k_e2e_${items.length + 1}`; + secrets.set(id, secret); + const item = { + id, + name: body.name, + type: body.type, + prefix: secret.slice(0, 8), + enabled: true, + expires_at: body.expires_at ?? null, + ip_allow: body.ip_allow ?? null, + rpm: body.rpm ?? null, + models: body.models ?? null, + created_at: 1789200000, + last_used_at: null, + version: 1, + usage: { requests: 0 }, + overridden: false, + ...(body.type === "standard" + ? { masked: `${secret.slice(0, 6)}…${secret.slice(-4)}` } + : {}), + }; + items.push(item); + return route.fulfill({ json: { ...item, secret } }); + } + const reveal = path.match(/^\/admin\/keys\/([^/]+)\/reveal$/); + if (reveal && request.method() === "POST") { + const secret = secrets.get(decodeURIComponent(reveal[1])); + return secret + ? route.fulfill({ json: { secret } }) + : route.fulfill({ status: 404, json: { error: { message: "密钥不存在" } } }); + } + return route.fulfill({ status: 404, json: { error: { message: `Unexpected ${path}` } } }); + }); + return created; +} + +test("admin key is shown once and an existing admin key disables another", async ({ + page, + context, +}) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + await mockKeys(page); + await page.goto("/dashboard/keys"); + await page.getByRole("button", { name: "新建密钥" }).click(); + await page.getByLabel("名称", { exact: true }).fill("deployment-admin"); + await page.getByRole("combobox", { name: "类型", exact: true }).selectOption("admin"); + await expect(page.getByLabel("IP 白名单")).toHaveCount(0); + await expect(page.getByLabel("RPM", { exact: true })).toHaveCount(0); + await page.getByRole("button", { name: "创建", exact: true }).click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("heading", { name: "密钥只显示一次" })).toBeVisible(); + await expect(dialog.getByText("关闭后无法再次查看", { exact: true })).toBeVisible(); + await expect(dialog.locator("code")).toHaveText(adminSecret); + await dialog.getByRole("button", { name: "复制", exact: true }).click(); + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(adminSecret); + await dialog.getByRole("button", { name: "关闭", exact: true }).click(); + await expect(page.getByRole("row", { name: /deployment-admin/ })).toBeVisible(); + await page.getByRole("button", { name: "新建密钥" }).click(); + await expect(page.getByRole("option", { name: "管理密钥" })).toHaveJSProperty("disabled", true); +}); + +test("restricted standard key can be revealed and copied again", async ({ page, context }) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + const created = await mockKeys(page); + await page.goto("/dashboard/keys"); + await page.getByRole("button", { name: "新建密钥" }).click(); + await page.getByLabel("名称", { exact: true }).fill("ci-key"); + await page.getByRole("combobox", { name: "有效期", exact: true }).selectOption("7"); + await page.getByLabel("IP 白名单", { exact: true }).fill("127.0.0.1\n198.51.100.0/24"); + await page.getByLabel("RPM", { exact: true }).fill("60"); + await page.getByLabel("Public model", { exact: true }).check(); + await page.getByRole("button", { name: "创建", exact: true }).click(); + let dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("heading", { name: "完整密钥" })).toBeVisible(); + await expect(dialog.locator("code")).toHaveText(standardSecret); + await dialog.getByRole("button", { name: "关闭", exact: true }).click(); + expect(created[0].ip_allow).toEqual(["127.0.0.1", "198.51.100.0/24"]); + expect(created[0].rpm).toBe(60); + expect(created[0].models).toEqual(["upstream"]); + expect(created[0].expires_at as number).toBeGreaterThan(Date.now() / 1000 + 6 * 86400); + const row = page.getByRole("row", { name: /ci-key/ }); + await row.getByRole("button", { name: "查看", exact: true }).click(); + dialog = page.getByRole("dialog"); + await expect(dialog.locator("code")).toHaveText(standardSecret); + await dialog.getByRole("button", { name: "关闭", exact: true }).click(); + await row.getByRole("button", { name: "复制", exact: true }).click(); + await expect(page.getByText("密钥已复制", { exact: true })).toBeVisible(); + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(standardSecret); +}); + +test("external admin key is occupied without exposing a secret", async ({ page }) => { + await mockKeys(page, true); + await page.goto("/dashboard/keys"); + await expect(page.getByText(/管理密钥 · 已被外部配置占用(环境变量)/)).toBeVisible(); + await expect(page.locator("code")).toHaveCount(0); + await page.getByRole("button", { name: "新建密钥" }).click(); + await expect(page.getByRole("option", { name: "管理密钥" })).toHaveJSProperty("disabled", true); +}); diff --git a/web/e2e/mock.ts b/web/e2e/mock.ts new file mode 100644 index 0000000..ada9f55 --- /dev/null +++ b/web/e2e/mock.ts @@ -0,0 +1,32 @@ +export const mockSettings = { + revision: 1, + items: [], + audit: { + logical_bytes: 1024, + db_bytes: 32768, + wal_bytes: 4096, + shm_bytes: 32768, + degraded: false, + }, + session: { degraded: false, last_error: null, path: "/tmp/admin-sessions.json" }, +}; + +export const mockDashboard = { + summary: { requests: 0, success_rate: 1, total_tokens: 0, credit: 0 }, + series: [], + models: [], + profiles: [], + health: { credentials: [] }, + storage: { + logical_bytes: 1024, + max_bytes: 268435456, + db_bytes: 32768, + wal_bytes: 4096, + shm_bytes: 32768, + degraded: false, + dropped_records: 0, + }, + official_credits: {}, + generated_at: 1789200000, + range: { days: 7 }, +}; diff --git a/web/src/App.tsx b/web/src/App.tsx index 04f47db..a584a80 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,6 +5,7 @@ import { ErrorNotice, Icon } from "./components"; import { Dashboard } from "./pages/Dashboard"; import { Models } from "./pages/Models"; import { Credentials } from "./pages/Credentials"; +import { Keys } from "./pages/Keys"; import { Logs } from "./pages/Logs"; import { Media } from "./pages/Media"; import { Settings } from "./pages/Settings"; @@ -17,7 +18,8 @@ import { LanguageProvider, chooseLanguage, serverPreference, useT } from "./i18n const navigation = [ ["", "nav.overview", "grid"], ["/models", "nav.models", "model"], - ["/credentials", "nav.credentials", "key"], + ["/credentials", "nav.credentials", "account"], + ["/keys", "nav.keys", "key"], ["/logs", "nav.logs", "logs"], ["/media", "nav.media", "image"], ["/storage", "nav.storage", "database"], @@ -50,6 +52,8 @@ export function Guard() { function Shell() { const t = useT(); const location = useLocation(); + const bootstrap = useSession((state) => state.bootstrap); + const [bootstrapDismissed, setBootstrapDismissed] = useState(false); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const [updateAvailable, setUpdateAvailable] = useState(false); @@ -168,6 +172,21 @@ function Shell() {
+ {bootstrap && !bootstrapDismissed && ( +
+ + {t("app.bootstrapBanner")}{" "} + {t("app.bootstrapAction")} + + +
+ )}
CodeBuddy2API
@@ -178,12 +197,22 @@ function Shell() { function Login() { const t = useT(); const status = useSession((state) => state.status); - const [key, setKey] = useState(""); + const [username, setUsername] = useState("admin"); + const [password, setPassword] = useState(""); + const [remember, setRemember] = useState(false); + const [visible, setVisible] = useState(false); + const [caps, setCaps] = useState(false); const [busy, setBusy] = useState(false); + const [shake, setShake] = useState(false); const [error, setError] = useState(null); if (status === "authenticated") return ; return (
+
@@ -196,41 +225,121 @@ function Login() { CodeBuddy

{t("login.subtitle")}

-
+ -
+

{t("login.title")}

{ e.preventDefault(); + const passwordInput = e.currentTarget.elements.namedItem( + "password", + ) as HTMLInputElement; setBusy(true); + setShake(false); setError(null); - const value = key; - setKey(""); - void establishSession(value) - .catch((err: unknown) => setError(errorMessage(err))) + void establishSession({ username, password, remember }) + .then(() => { + const Credential = ( + window as unknown as { + PasswordCredential?: new (options: { + id: string; + password: string; + }) => Credential; + } + ).PasswordCredential; + if (Credential && navigator.credentials) { + try { + const credential = new Credential({ id: username, password }); + void navigator.credentials.store(credential).catch(() => undefined); + } catch { + // Native autocomplete remains available when credential storage is rejected. + } + } + }) + .catch((err: unknown) => { + setPassword(""); + setError(errorMessage(err)); + setShake(true); + passwordInput.focus(); + }) .finally(() => setBusy(false)); }} > + + {caps && ( + + + {t("login.capsLock")} + + )} + -
@@ -246,6 +355,7 @@ export function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/account.test.tsx b/web/src/account.test.tsx new file mode 100644 index 0000000..001ac4b --- /dev/null +++ b/web/src/account.test.tsx @@ -0,0 +1,117 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router"; +import { beforeEach, afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { api, useResource, useSession } from "./api"; +import { AppRoutes } from "./App"; +import { Settings } from "./pages/Settings"; + +vi.mock("./api", async (original) => ({ + ...(await original()), + useResource: vi.fn(), +})); + +function renderAccount() { + window.history.replaceState({}, "", "/dashboard/settings?tab=account"); + return render( + + + , + ); +} + +beforeEach(() => { + vi.mocked(useResource).mockReturnValue({ + data: { revision: 1, items: [], audit: {}, session: {} }, + loading: false, + refreshing: false, + error: null, + reload: vi.fn(), + }); + useSession.setState({ status: "authenticated", csrf: "old-csrf", bootstrap: true, error: null }); + vi.spyOn(api, "get").mockImplementation((path) => { + if (path === "/account") { + return Promise.resolve({ + data: { + username: "admin", + password_source: "generated", + bootstrap: true, + changed_at: 1761600000, + }, + }); + } + return Promise.reject(new Error(`Unexpected GET ${path}`)); + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + window.history.replaceState({}, "", "/"); +}); + +describe("management account settings", () => { + it("opens ?tab=account, saves a username and adopts the new CSRF token", async () => { + const put = vi.spyOn(api, "put").mockResolvedValue({ + data: { username: "operator", bootstrap: false, csrf_token: "new-csrf" }, + }); + renderAccount(); + expect(await screen.findByRole("heading", { name: "管理账户" })).toBeTruthy(); + fireEvent.change(await screen.findByLabelText("用户名", { exact: true }), { + target: { value: "operator" }, + }); + fireEvent.change(screen.getByLabelText("当前管理密码", { exact: true }), { + target: { value: "old-password" }, + }); + fireEvent.click(screen.getByRole("button", { name: "保存账户更改" })); + await waitFor(() => + expect(put).toHaveBeenCalledWith("/account", { + current_password: "old-password", + username: "operator", + }), + ); + await waitFor(() => expect(useSession.getState().csrf).toBe("new-csrf")); + expect(useSession.getState().bootstrap).toBe(false); + expect(screen.getByText(/其他已登录会话已退出/)).toBeTruthy(); + }); + + it("rejects mismatched new passwords without calling the API", async () => { + const put = vi.spyOn(api, "put"); + renderAccount(); + fireEvent.change(await screen.findByLabelText("当前管理密码", { exact: true }), { + target: { value: "old-password" }, + }); + fireEvent.change(screen.getByLabelText("新密码", { exact: true }), { + target: { value: "new-password" }, + }); + fireEvent.change(screen.getByLabelText("再次输入新密码", { exact: true }), { + target: { value: "different-password" }, + }); + fireEvent.click(screen.getByRole("button", { name: "保存账户更改" })); + expect(await screen.findByText("两次输入的新密码不一致")).toBeTruthy(); + expect(put).not.toHaveBeenCalled(); + }); + + it("revokes every session after confirmation and returns to anonymous", async () => { + vi.spyOn(window, "confirm").mockReturnValue(true); + const remove = vi.spyOn(api, "delete").mockResolvedValue({ data: { ok: true } }); + renderAccount(); + fireEvent.click(await screen.findByRole("button", { name: "退出所有会话" })); + await waitFor(() => expect(remove).toHaveBeenCalledWith("/sessions")); + await waitFor(() => expect(useSession.getState().status).toBe("anonymous")); + expect(useSession.getState().csrf).toBeNull(); + expect(useSession.getState().bootstrap).toBe(false); + }); + + it("dismisses the bootstrap banner without changing session state", async () => { + window.history.replaceState({}, "", "/dashboard/settings?tab=account"); + render( + + + , + ); + expect(await screen.findByText("当前使用初始密码,请尽快修改。")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "关闭通知" })); + expect(screen.queryByText("当前使用初始密码,请尽快修改。")).toBeNull(); + expect(await screen.findByRole("heading", { name: "管理账户" })).toBeTruthy(); + expect(useSession.getState().bootstrap).toBe(true); + }); +}); diff --git a/web/src/api.test.tsx b/web/src/api.test.tsx index 9d0abe5..89363cd 100644 --- a/web/src/api.test.tsx +++ b/web/src/api.test.tsx @@ -55,7 +55,7 @@ describe("API and session state", () => { ), ); }) satisfies AxiosAdapter; - await establishSession("isolated-test-key"); + await establishSession({ username: "admin", password: "isolated-test-key", remember: false }); await api.get("/oauth/poll", { params: { login_id: "mock-login" } }); expect(seen[0].withCredentials).toBe(true); expect(seen[1].headers.get("X-CSRF-Token")).toBe("mock-csrf"); diff --git a/web/src/api.ts b/web/src/api.ts index 50151ad..98a1060 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -4,15 +4,17 @@ import { create } from "zustand"; import { useCallback, useEffect, useState } from "react"; export type RecordValue = Record; -export type Session = { authenticated: true; csrf_token: string }; +export type Session = { authenticated: boolean; csrf_token: string | null; bootstrap?: boolean }; type AuthState = { status: "checking" | "authenticated" | "anonymous" | "error"; csrf: string | null; + bootstrap: boolean; error: string | null; }; export const useSession = create(() => ({ status: "checking", csrf: null, + bootstrap: false, error: null, })); export const api = axios.create({ baseURL: "/admin", withCredentials: true, timeout: 20000 }); @@ -26,7 +28,7 @@ api.interceptors.response.use( (response) => response, (error: unknown) => { if (axios.isAxiosError(error) && error.response?.status === 401) { - useSession.setState({ status: "anonymous", csrf: null, error: null }); + useSession.setState({ status: "anonymous", csrf: null, bootstrap: false, error: null }); } return Promise.reject(error); }, @@ -96,7 +98,7 @@ export function errorMessage(error: unknown): string { } if (error.response?.status === 401) return "会话已失效,请重新登录。"; if (error.response?.status === 403) - return serverMessage ?? "操作被拒绝:请检查当前 key、管理锁定或重新登录刷新 CSRF。"; + return serverMessage ?? "操作被拒绝:请检查管理锁定或重新登录刷新 CSRF。"; if (serverMessage) return serverMessage; const data: unknown = error.response?.data; if (data && typeof data === "object" && "detail" in data) { @@ -126,30 +128,47 @@ export function errorMessage(error: unknown): string { } return error instanceof Error ? error.message : "操作失败,请重试。"; } -export async function establishSession(apiKey?: string) { +export async function establishSession(credentials?: { + username: string; + password: string; + remember: boolean; +}) { try { const response = - apiKey === undefined + credentials === undefined ? await api.get("/session") - : await api.post("/session", { api_key: apiKey }); + : await api.post("/session", credentials); const data = object(response.data, "会话"); if (data.authenticated === false) { - useSession.setState({ status: "anonymous", csrf: null, error: null }); + useSession.setState({ status: "anonymous", csrf: null, bootstrap: false, error: null }); return; } if (data.authenticated !== true || typeof data.csrf_token !== "string" || !data.csrf_token) throw new Error("会话响应缺少认证状态或 CSRF token"); - useSession.setState({ status: "authenticated", csrf: data.csrf_token, error: null }); + useSession.setState({ + status: "authenticated", + csrf: data.csrf_token, + bootstrap: data.bootstrap === true, + error: null, + }); } catch (error) { if (axios.isAxiosError(error) && error.response?.status === 401) { - useSession.setState({ status: "anonymous", csrf: null, error: null }); - if (apiKey !== undefined) throw new Error("API key 无效或管理界面未启用。"); + useSession.setState({ status: "anonymous", csrf: null, bootstrap: false, error: null }); + if (credentials !== undefined) throw new Error("用户名或密码错误"); return; } - useSession.setState({ status: "error", csrf: null, error: errorMessage(error) }); - if (apiKey !== undefined) throw error; + useSession.setState({ + status: "error", + csrf: null, + bootstrap: false, + error: errorMessage(error), + }); + if (credentials !== undefined) throw error; } } +export function updateCsrf(token: string) { + useSession.setState({ csrf: token }); +} // Session-level response cache for heavy pages (model routing): reopening the // page renders the last snapshot instantly while a fresh fetch replaces it. const resourceCache = new Map(); diff --git a/web/src/auth.test.tsx b/web/src/auth.test.tsx new file mode 100644 index 0000000..b2877ed --- /dev/null +++ b/web/src/auth.test.tsx @@ -0,0 +1,145 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router"; +import { AxiosError, AxiosHeaders, type InternalAxiosRequestConfig } from "axios"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { AppRoutes } from "./App"; +import { api, useSession } from "./api"; + +const originalAdapter = api.defaults.adapter; +const originalCredentials = Object.getOwnPropertyDescriptor(navigator, "credentials"); +const credentialStore = vi.fn(); + +function response(data: unknown, config: InternalAxiosRequestConfig, status = 200) { + return { data, status, statusText: "OK", headers: new AxiosHeaders(), config }; +} + +function failure(config: InternalAxiosRequestConfig, status: number, message: string) { + return new AxiosError( + message, + "ERR_BAD_REQUEST", + config, + undefined, + response({ error: { message } }, config, status), + ); +} + +function renderLogin() { + return render( + + + , + ); +} + +beforeEach(() => { + useSession.setState({ status: "anonymous", csrf: null, bootstrap: false, error: null }); + credentialStore.mockReset(); + credentialStore.mockResolvedValue(undefined); + Object.defineProperty(navigator, "credentials", { + configurable: true, + value: { store: credentialStore }, + }); + vi.stubGlobal( + "PasswordCredential", + class { + constructor(public options: { id: string; password: string }) {} + }, + ); +}); + +afterEach(() => { + api.defaults.adapter = originalAdapter; + vi.unstubAllGlobals(); + if (originalCredentials) Object.defineProperty(navigator, "credentials", originalCredentials); + else Reflect.deleteProperty(navigator, "credentials"); +}); + +describe("account login", () => { + it("submits username, password and remember without clearing values before success", async () => { + let resolveSession: ((value: ReturnType) => void) | undefined; + let requestBody = ""; + api.defaults.adapter = (config) => { + if (config.url === "/session" && config.method === "post") { + requestBody = String(config.data); + return new Promise((resolve) => { + resolveSession = resolve; + }); + } + return Promise.resolve(response({}, config)); + }; + renderLogin(); + const form = screen.getByRole("button", { name: "进入工作台" }).closest("form")!; + expect(form).toHaveProperty("method", "post"); + expect(form).toHaveProperty("action", `${window.location.origin}/admin/session`); + fireEvent.change(screen.getByLabelText("用户名", { exact: true }), { + target: { value: "operator" }, + }); + fireEvent.change(screen.getByLabelText("密码", { exact: true }), { + target: { value: "correct-horse" }, + }); + fireEvent.click(screen.getByLabelText("记住我", { exact: true })); + fireEvent.click(screen.getByRole("button", { name: "进入工作台" })); + expect(screen.getByLabelText("密码", { exact: true })).toHaveProperty("value", "correct-horse"); + await waitFor(() => expect(resolveSession).toBeTypeOf("function")); + resolveSession!( + response( + { authenticated: true, csrf_token: "csrf", bootstrap: false }, + {} as InternalAxiosRequestConfig, + ), + ); + await waitFor(() => expect(credentialStore).toHaveBeenCalledOnce()); + expect(JSON.parse(requestBody)).toEqual({ + username: "operator", + password: "correct-horse", + remember: true, + }); + expect(credentialStore.mock.calls[0]![0].options).toEqual({ + id: "operator", + password: "correct-horse", + }); + }); + + it("keeps the username, clears and focuses the password, and shakes after a 401", async () => { + api.defaults.adapter = (config) => Promise.reject(failure(config, 401, "ignored")); + const { container } = renderLogin(); + fireEvent.change(screen.getByLabelText("用户名", { exact: true }), { + target: { value: "operator" }, + }); + fireEvent.change(screen.getByLabelText("密码", { exact: true }), { + target: { value: "wrong-password" }, + }); + fireEvent.click(screen.getByRole("button", { name: "进入工作台" })); + expect(await screen.findByText("用户名或密码错误")).toBeTruthy(); + expect(screen.getByLabelText("用户名", { exact: true })).toHaveProperty("value", "operator"); + const password = screen.getByLabelText("密码", { exact: true }); + expect(password).toHaveProperty("value", ""); + expect(password.getAttribute("aria-invalid")).toBe("true"); + await waitFor(() => expect(document.activeElement).toBe(password)); + expect(container.querySelector("form")!.parentElement!.className).toMatch(/loginShake/); + }); + + it("shows the server's rate-limit message", async () => { + api.defaults.adapter = (config) => Promise.reject(failure(config, 429, "登录繁忙,请稍后重试")); + renderLogin(); + fireEvent.change(screen.getByLabelText("密码", { exact: true }), { + target: { value: "wrong-password" }, + }); + fireEvent.click(screen.getByRole("button", { name: "进入工作台" })); + expect(await screen.findByText("登录繁忙,请稍后重试")).toBeTruthy(); + }); + + it("toggles password visibility and reports Caps Lock", () => { + renderLogin(); + const password = screen.getByLabelText("密码", { exact: true }); + const toggle = screen.getByRole("button", { name: "显示密码" }); + fireEvent.click(toggle); + expect(password).toHaveProperty("type", "text"); + expect(screen.getByRole("button", { name: "隐藏密码" }).getAttribute("aria-pressed")).toBe( + "true", + ); + fireEvent.keyDown(password, { key: "A", modifierCapsLock: true }); + expect(screen.getByRole("status").textContent).toContain("Caps Lock"); + fireEvent.keyUp(password, { key: "A", modifierCapsLock: false }); + expect(screen.queryByRole("status")).toBeNull(); + }); +}); diff --git a/web/src/components.test.tsx b/web/src/components.test.tsx index a6dfb9c..fff1a63 100644 --- a/web/src/components.test.tsx +++ b/web/src/components.test.tsx @@ -95,7 +95,7 @@ describe("model rules and route preview", () => { }); }); describe("destructive warnings", () => { - it("blocks full clear until exact phrase and current key, and clears key after submit", async () => { + it("blocks full clear until exact phrase and current password, and clears password after submit", async () => { const post = vi.spyOn(api, "post").mockResolvedValue({ data: { ok: true } }); render(); choose(screen.getByLabelText("清理范围"), "all"); @@ -106,17 +106,23 @@ describe("destructive warnings", () => { target: { value: CLEAR_CONFIRMATION }, }); expect(submit).toHaveProperty("disabled", true); - fireEvent.change(screen.getByLabelText("当前 API key"), { target: { value: "test-only-key" } }); + fireEvent.change(screen.getByLabelText("当前管理密码"), { + target: { value: "test-only-password" }, + }); expect(submit).toHaveProperty("disabled", false); fireEvent.click(submit); await waitFor(() => expect(post).toHaveBeenCalledWith("/logs/clear", { scope: "all", confirmation: CLEAR_CONFIRMATION, - api_key: "test-only-key", + password: "test-only-password", }), ); - expect(screen.getByLabelText("当前 API key")).toHaveProperty("value", ""); + expect(screen.getByLabelText("当前管理密码")).toHaveProperty("value", ""); + expect(screen.getByLabelText("当前管理密码")).toHaveProperty( + "autocomplete", + "current-password", + ); }); it("ordinary clear preserves statistics and sends no key", async () => { const post = vi.spyOn(api, "post").mockResolvedValue({ data: { ok: true } }); diff --git a/web/src/components.tsx b/web/src/components.tsx index d6d6319..a9f1a0e 100644 --- a/web/src/components.tsx +++ b/web/src/components.tsx @@ -13,6 +13,12 @@ export function Icon({ name = "grid" }: { name?: string }) { grid: "M3 3h7v7H3z M14 3h7v7h-7z M3 14h7v7H3z M14 14h7v7h-7z", model: "m12 3 9 5-9 5-9-5 9-5Zm-9 9 9 5 9-5M3 16l9 5 9-5", key: "M14 4a6 6 0 1 1-3 11l-6 6H2v-3l6-6a6 6 0 0 1 6-8Z M16 8h.01", + account: "M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-7 9a7 7 0 0 1 14 0", + user: "M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-7 9a7 7 0 0 1 14 0", + lock: "M6 10V7a6 6 0 0 1 12 0v3m-13 0h14v11H5V10Zm7 5v3", + eye: "M2.5 12S6 5 12 5s9.5 7 9.5 7-3.5 7-9.5 7-9.5-7-9.5-7Zm9.5 3a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z", + eyeOff: + "m3 3 18 18M10.6 5.4A10.8 10.8 0 0 1 12 5c6 0 9.5 7 9.5 7a18 18 0 0 1-3.1 3.7M6.1 6.1C3.9 7.6 2.5 12 2.5 12s3.5 7 9.5 7c1.4 0 2.7-.4 3.8-.9", logs: "M6 3h12v18H6z M9 7h6M9 11h6M9 15h4", settings: "M4 7h16M4 17h16M8 4v6M16 14v6", arrow: "M5 12h14m-5-5 5 5-5 5", @@ -288,7 +294,7 @@ export function ClearLogs({ onClose, onDone }: { onClose: () => void; onDone: () const t = useT(); const [scope, setScope] = useState<"details" | "all">("details"); const [confirmation, setConfirmation] = useState(""); - const [key, setKey] = useState(""); + const [currentPassword, setCurrentPassword] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); return ( @@ -298,13 +304,13 @@ export function ClearLogs({ onClose, onDone }: { onClose: () => void; onDone: () e.preventDefault(); setBusy(true); setError(null); - const currentKey = key; - setKey(""); + const password = currentPassword; + setCurrentPassword(""); void api .post("/logs/clear", { scope, confirmation: scope === "all" ? confirmation : "", - ...(scope === "all" ? { api_key: currentKey } : {}), + ...(scope === "all" ? { password } : {}), }) .then((response) => { if (object(response.data).ok !== true) throw new Error(t("logs.clear.unconfirmed")); @@ -322,7 +328,7 @@ export function ClearLogs({ onClose, onDone }: { onClose: () => void; onDone: () onChange={(value) => { setScope(value as "details" | "all"); setConfirmation(""); - setKey(""); + setCurrentPassword(""); }} > @@ -347,12 +353,12 @@ export function ClearLogs({ onClose, onDone }: { onClose: () => void; onDone: () /> @@ -361,7 +367,9 @@ export function ClearLogs({ onClose, onDone }: { onClose: () => void; onDone: () diff --git a/web/src/copy.test.tsx b/web/src/copy.test.tsx index 7f9f9e9..42e6f8f 100644 --- a/web/src/copy.test.tsx +++ b/web/src/copy.test.tsx @@ -73,9 +73,21 @@ describe("concise user-facing copy", () => { "h2", "form", ]); - expect(screen.getByLabelText("API key", { exact: true })).toBeTruthy(); + expect(screen.getByLabelText("用户名", { exact: true })).toHaveProperty("name", "username"); + expect(screen.getByLabelText("用户名", { exact: true })).toHaveProperty( + "autocomplete", + "username", + ); + expect(screen.getByLabelText("密码", { exact: true })).toHaveProperty("name", "password"); + expect(screen.getByLabelText("密码", { exact: true })).toHaveProperty( + "autocomplete", + "current-password", + ); + expect(screen.getByLabelText("记住我", { exact: true })).toHaveProperty("name", "remember"); expect(screen.getByRole("button", { name: "进入工作台" })).toBeTruthy(); expect(container.querySelectorAll("p")).toHaveLength(0); + expect(localStorage.length).toBe(0); + expect(sessionStorage.length).toBe(0); }); it("renders titles and empty states without developer explanations", () => { diff --git a/web/src/i18n.tsx b/web/src/i18n.tsx index f61db5f..db12364 100644 --- a/web/src/i18n.tsx +++ b/web/src/i18n.tsx @@ -175,13 +175,97 @@ const zh = { "nav.overview": "概览", "nav.models": "模型路由", "nav.credentials": "凭证管理", + "nav.keys": "密钥管理", "nav.logs": "日志审计", "nav.storage": "存储管理", "nav.settings": "系统设置", "nav.updates": "版本更新", "login.subtitle": "网关管理", "login.title": "登录管理控制台", - "login.keyPlaceholder": "输入当前生效的 API key", + "login.username": "用户名", + "login.password": "密码", + "login.passwordPlaceholder": "初始密码为外部密钥或启动输出", + "login.showPassword": "显示密码", + "login.hidePassword": "隐藏密码", + "login.capsLock": "Caps Lock 已开启", + "login.remember": "记住我", + "login.invalid": "用户名或密码错误", + "login.tooMany": "登录尝试过多,请稍后重试", + "app.bootstrapBanner": "当前使用初始密码,请尽快修改。", + "app.bootstrapAction": "修改管理账户", + "account.title": "管理账户", + "account.bootstrapInitial": "仍使用初始密码", + "account.bootstrapChanged": "密码已修改", + "account.newPassword": "新密码", + "account.confirmPassword": "再次输入新密码", + "account.passwordHint": "不修改密码时留空", + "account.mismatch": "两次输入的新密码不一致", + "account.save": "保存账户更改", + "account.saving": "正在保存…", + "account.saved": "账户已保存,其他已登录会话已退出。", + "account.sessionsTitle": "登录会话", + "account.sessionsHint": "修改账户或撤销后,其他会话都需要重新登录。", + "account.revokeAll": "退出所有会话", + "account.revokeConfirm": "确认退出包括当前浏览器在内的所有会话?", + "account.invalidResponse": "管理账户响应格式不完整", + "account.missingCsrf": "管理账户响应缺少用户名或 CSRF token", + "keys.title": "密钥管理", + "keys.description": "管理推理密钥、限制和轮换。", + "keys.externalTitle": "外部密钥", + "keys.externalOccupied": "管理密钥 · 已被外部配置占用({origin})", + "keys.origin.cli": "命令行", + "keys.origin.environment": "环境变量", + "keys.origin.dotenv": ".env", + "keys.origin.external": "外部配置", + "keys.create": "新建密钥", + "keys.createSubmit": "创建", + "keys.empty": "尚未创建密钥", + "keys.name": "名称", + "keys.type": "类型", + "keys.secret": "密钥", + "keys.status": "状态", + "keys.expiry": "有效期", + "keys.models": "可用模型", + "keys.usage24h": "24 小时请求数", + "keys.lastUsed": "最后使用时间", + "keys.actions": "操作", + "keys.standard": "普通", + "keys.admin": "管理", + "keys.standardFull": "普通密钥", + "keys.adminFull": "管理密钥", + "keys.enabled": "启用", + "keys.disabled": "停用", + "keys.expired": "已过期", + "keys.overridden": "已被覆盖", + "keys.never": "永不", + "keys.unlimited": "不限", + "keys.all": "全部", + "keys.edit": "编辑", + "keys.rotate": "轮换", + "keys.reveal": "查看", + "keys.copy": "复制", + "keys.delete": "删除", + "keys.editTitle": "编辑密钥", + "keys.adminUnavailable": "管理密钥已被外部配置占用或已存在", + "keys.expiryDays": "{n} 天", + "keys.customExpiry": "自定义日期时间", + "keys.customExpiryLabel": "自定义有效期", + "keys.ipAllow": "IP 白名单", + "keys.ipHint": "每行一个 IP 或 CIDR;留空表示不限", + "keys.rpmHint": "留空表示不限", + "keys.modelsHint": "不选择任何模型表示全部可用。", + "keys.saveChanges": "保存更改", + "keys.secretOnce": "密钥只显示一次", + "keys.secretFull": "完整密钥", + "keys.secretWarning": "关闭后无法再次查看", + "keys.copied": "密钥已复制", + "keys.close": "关闭", + "keys.rotateConfirm": "确认轮换密钥“{name}”?旧密钥会立即失效。", + "keys.deleteConfirm": "确认删除密钥“{name}”?", + "keys.invalidExpiry": "请选择有效的自定义有效期", + "keys.invalidRecord": "密钥记录格式不完整", + "keys.invalidSecret": "查看响应缺少密钥", + "keys.auto": "自动", "login.verifying": "正在验证…", "login.enter": "进入工作台", "common.retry": "重试", @@ -191,7 +275,7 @@ const zh = { "common.dismissNotice": "关闭通知", "common.closeDrawer": "关闭抽屉", "common.cancel": "取消", - "common.currentApiKey": "当前 API key", + "common.currentPassword": "当前管理密码", "logs.title": "日志审计", "logs.refresh": "刷新", "logs.clear": "清理日志", @@ -204,6 +288,7 @@ const zh = { "logs.perPage": "每页 50 条", "logs.filter.model": "筛选模型", "logs.filter.credential": "筛选凭证", + "logs.filter.apiKey": "筛选密钥", "logs.filter.profile": "筛选产品", "logs.filter.status": "筛选状态", "logs.filter.clientIp": "筛选客户端 IP", @@ -213,14 +298,20 @@ const zh = { "logs.placeholder.clientIp": "客户端 IP", "logs.placeholder.search": "搜索标识 / 操作…", "logs.allProfiles": "全部产品", + "logs.allApiKeys": "全部密钥", "logs.allStatuses": "全部状态", "logs.apply": "应用筛选", "logs.reset": "重置", "logs.col.time": "时间 / ID", "logs.col.action": "操作 / 类型", "logs.col.status": "状态", + "logs.col.apiKey": "密钥", "logs.col.duration": "耗时", "logs.col.tokens": "Token", + "logs.legacyApiKey": "—(升级前)", + "logs.deletedApiKey": "{id}(已删除)", + "logs.externalApiKey": "外部管理密钥", + "logs.anonymousApiKey": "匿名", "logs.viewDetail": "查看详情", "logs.prevPage": "上一页", "logs.nextPage": "下一页", @@ -301,6 +392,13 @@ const zh = { "logs.action.model_updated": "模型已更新", "logs.action.model_deleted": "模型已删除", "logs.action.session_revoke_failed": "会话吊销失败", + "logs.action.account_changed": "管理账户已修改", + "logs.action.key_created": "密钥已创建", + "logs.action.key_updated": "密钥已更新", + "logs.action.key_rotated": "密钥已轮换", + "logs.action.key_revealed": "密钥已查看", + "logs.action.key_deleted": "密钥已删除", + "logs.action.sessions_revoked": "所有会话已退出", "logs.action.credentials_uploaded": "凭证已上传", "logs.action.credentials_exported": "凭证已导出", "logs.action.storage_archive": "日志归档", @@ -721,13 +819,97 @@ const en: Record = { "nav.overview": "Overview", "nav.models": "Model routing", "nav.credentials": "Credentials", + "nav.keys": "API keys", "nav.logs": "Audit logs", "nav.storage": "Storage", "nav.settings": "Settings", "nav.updates": "Updates", "login.subtitle": "Gateway management", "login.title": "Sign in to the console", - "login.keyPlaceholder": "Enter the active API key", + "login.username": "Username", + "login.password": "Password", + "login.passwordPlaceholder": "Initial password: configured key or startup output", + "login.showPassword": "Show password", + "login.hidePassword": "Hide password", + "login.capsLock": "Caps Lock is on", + "login.remember": "Remember me", + "login.invalid": "Username or password is incorrect", + "login.tooMany": "Too many login attempts; try again later", + "app.bootstrapBanner": "You are using the initial password. Change it soon.", + "app.bootstrapAction": "Manage account", + "account.title": "Admin account", + "account.bootstrapInitial": "Initial password active", + "account.bootstrapChanged": "Password changed", + "account.newPassword": "New password", + "account.confirmPassword": "Confirm new password", + "account.passwordHint": "Leave blank to keep the current password", + "account.mismatch": "The new passwords do not match", + "account.save": "Save account changes", + "account.saving": "Saving…", + "account.saved": "Account saved. All other sessions have been signed out.", + "account.sessionsTitle": "Login sessions", + "account.sessionsHint": "After account changes or revocation, other sessions must sign in again.", + "account.revokeAll": "Sign out all sessions", + "account.revokeConfirm": "Sign out every session, including this browser?", + "account.invalidResponse": "The admin account response is incomplete", + "account.missingCsrf": "The admin account response is missing a username or CSRF token", + "keys.title": "API keys", + "keys.description": "Manage inference keys, restrictions and rotation.", + "keys.externalTitle": "External key", + "keys.externalOccupied": "Admin key · occupied by external configuration ({origin})", + "keys.origin.cli": "command line", + "keys.origin.environment": "environment variable", + "keys.origin.dotenv": ".env", + "keys.origin.external": "external configuration", + "keys.create": "Create key", + "keys.createSubmit": "Create", + "keys.empty": "No keys yet", + "keys.name": "Name", + "keys.type": "Type", + "keys.secret": "Key", + "keys.status": "Status", + "keys.expiry": "Expires", + "keys.models": "Allowed models", + "keys.usage24h": "Requests (24h)", + "keys.lastUsed": "Last used", + "keys.actions": "Actions", + "keys.standard": "Standard", + "keys.admin": "Admin", + "keys.standardFull": "Standard key", + "keys.adminFull": "Admin key", + "keys.enabled": "Enabled", + "keys.disabled": "Disabled", + "keys.expired": "Expired", + "keys.overridden": "Overridden", + "keys.never": "Never", + "keys.unlimited": "Unlimited", + "keys.all": "All", + "keys.edit": "Edit", + "keys.rotate": "Rotate", + "keys.reveal": "Reveal", + "keys.copy": "Copy", + "keys.delete": "Delete", + "keys.editTitle": "Edit key", + "keys.adminUnavailable": "An admin key already exists or is supplied externally", + "keys.expiryDays": "{n} days", + "keys.customExpiry": "Custom date and time", + "keys.customExpiryLabel": "Custom expiration", + "keys.ipAllow": "IP allowlist", + "keys.ipHint": "One IP or CIDR per line; leave blank for unlimited", + "keys.rpmHint": "Leave blank for unlimited", + "keys.modelsHint": "Leave every model unselected to allow all models.", + "keys.saveChanges": "Save changes", + "keys.secretOnce": "This key is shown only once", + "keys.secretFull": "Full key", + "keys.secretWarning": "It cannot be revealed again after closing", + "keys.copied": "Key copied", + "keys.close": "Close", + "keys.rotateConfirm": "Rotate “{name}”? The old key stops working immediately.", + "keys.deleteConfirm": "Delete “{name}”?", + "keys.invalidExpiry": "Choose a valid custom expiration", + "keys.invalidRecord": "The key record is incomplete", + "keys.invalidSecret": "The reveal response is missing the key", + "keys.auto": "Automatic", "login.verifying": "Verifying…", "login.enter": "Enter workspace", "common.retry": "Retry", @@ -737,7 +919,7 @@ const en: Record = { "common.dismissNotice": "Dismiss notification", "common.closeDrawer": "Close drawer", "common.cancel": "Cancel", - "common.currentApiKey": "Current API key", + "common.currentPassword": "Current admin password", "logs.title": "Audit logs", "logs.refresh": "Refresh", "logs.clear": "Clear logs", @@ -750,6 +932,7 @@ const en: Record = { "logs.perPage": "50 per page", "logs.filter.model": "Filter model", "logs.filter.credential": "Filter credential", + "logs.filter.apiKey": "Filter API key", "logs.filter.profile": "Filter product", "logs.filter.status": "Filter status", "logs.filter.clientIp": "Filter client IP", @@ -759,14 +942,20 @@ const en: Record = { "logs.placeholder.clientIp": "Client IP", "logs.placeholder.search": "Search id / action…", "logs.allProfiles": "All products", + "logs.allApiKeys": "All API keys", "logs.allStatuses": "All statuses", "logs.apply": "Apply filters", "logs.reset": "Reset", "logs.col.time": "Time / ID", "logs.col.action": "Action / type", "logs.col.status": "Status", + "logs.col.apiKey": "API key", "logs.col.duration": "Duration", "logs.col.tokens": "Tokens", + "logs.legacyApiKey": "— (before upgrade)", + "logs.deletedApiKey": "{id} (deleted)", + "logs.externalApiKey": "External admin key", + "logs.anonymousApiKey": "Anonymous", "logs.viewDetail": "Details", "logs.prevPage": "Previous page", "logs.nextPage": "Next page", @@ -847,6 +1036,13 @@ const en: Record = { "logs.action.model_updated": "Model updated", "logs.action.model_deleted": "Model deleted", "logs.action.session_revoke_failed": "Session revoke failed", + "logs.action.account_changed": "Admin account changed", + "logs.action.key_created": "API key created", + "logs.action.key_updated": "API key updated", + "logs.action.key_rotated": "API key rotated", + "logs.action.key_revealed": "API key revealed", + "logs.action.key_deleted": "API key deleted", + "logs.action.sessions_revoked": "All sessions revoked", "logs.action.credentials_uploaded": "Credentials uploaded", "logs.action.credentials_exported": "Credentials exported", "logs.action.storage_archive": "Log archive", diff --git a/web/src/keys.test.tsx b/web/src/keys.test.tsx new file mode 100644 index 0000000..8baa53f --- /dev/null +++ b/web/src/keys.test.tsx @@ -0,0 +1,154 @@ +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { beforeEach, afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { api, useResource } from "./api"; +import { Keys } from "./pages/Keys"; + +vi.mock("./api", async (original) => ({ + ...(await original()), + useResource: vi.fn(), +})); + +const standard = { + id: "k_abcdefghijkl", + name: "CI key", + type: "standard", + prefix: "cb-abcd", + masked: "cb-ab…ef90", + enabled: true, + expires_at: null, + ip_allow: null, + rpm: null, + models: null, + created_at: 1761600000, + last_used_at: 1761600100, + version: 3, + usage: { requests: 7, success: 7, error: 0, total_tokens: 100, credit: 2 }, +}; +const originalClipboard = Object.getOwnPropertyDescriptor(navigator, "clipboard"); +const writeText = vi.fn(); + +function mockResource(data: unknown, models: unknown = []) { + vi.mocked(useResource).mockImplementation((path) => ({ + data: path === "/models" ? models : data, + loading: false, + refreshing: false, + error: null, + reload: vi.fn(), + })); +} + +beforeEach(() => { + mockResource({ external: null, items: [standard] }, [ + { id: "auto", label: "自动" }, + { id: "source-a", label: "公开模型 A" }, + ]); + writeText.mockReset(); + writeText.mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + if (originalClipboard) Object.defineProperty(navigator, "clipboard", originalClipboard); + else Reflect.deleteProperty(navigator, "clipboard"); +}); + +describe("API key management", () => { + it("shows external occupancy, status and 24-hour usage", () => { + mockResource({ + external: { type: "admin", source: "external", origin: "cli", occupied: true }, + items: [{ ...standard, expires_at: 1 }], + }); + render(); + expect(screen.getByText(/已被外部配置占用(命令行)/)).toBeTruthy(); + expect(screen.getByText("已过期")).toBeTruthy(); + expect(screen.getByRole("columnheader", { name: "24 小时请求数" })).toBeTruthy(); + expect(screen.getByText("7")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "新建密钥" })); + expect(screen.getByRole("option", { name: "管理密钥" })).toHaveProperty("disabled", true); + expect(screen.getByText(/已被外部配置占用或已存在/)).toBeTruthy(); + }); + + it("creates a restricted standard key and shows the returned secret", async () => { + const post = vi.spyOn(api, "post").mockResolvedValue({ + data: { ...standard, secret: "cb-complete-secret" }, + }); + render(); + fireEvent.click(screen.getByRole("button", { name: "新建密钥" })); + fireEvent.change(screen.getByLabelText("名称", { exact: true }), { + target: { value: "Deploy key" }, + }); + fireEvent.change(screen.getByLabelText("IP 白名单", { exact: true }), { + target: { value: "198.51.100.0/24\n2001:db8::/32" }, + }); + fireEvent.change(screen.getByLabelText("RPM", { exact: true }), { + target: { value: "60" }, + }); + fireEvent.click(screen.getByRole("checkbox", { name: "自动" })); + fireEvent.click(screen.getByRole("checkbox", { name: "公开模型 A" })); + fireEvent.click(screen.getByRole("button", { name: "创建" })); + await waitFor(() => + expect(post).toHaveBeenCalledWith("/keys", { + name: "Deploy key", + type: "standard", + expires_at: null, + ip_allow: ["198.51.100.0/24", "2001:db8::/32"], + rpm: 60, + models: ["auto", "source-a"], + }), + ); + expect(await screen.findByText("cb-complete-secret")).toBeTruthy(); + fireEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "复制" })); + await waitFor(() => expect(writeText).toHaveBeenCalledWith("cb-complete-secret")); + }); + + it("emphasizes that an admin secret cannot be revealed again", async () => { + vi.spyOn(api, "post").mockResolvedValue({ + data: { ...standard, type: "admin", secret: "cb-admin-secret" }, + }); + render(); + fireEvent.click(screen.getByRole("button", { name: "新建密钥" })); + fireEvent.change(screen.getByLabelText("名称", { exact: true }), { + target: { value: "Admin key" }, + }); + fireEvent.change(screen.getByLabelText("类型", { exact: true }), { + target: { value: "admin" }, + }); + fireEvent.click(screen.getByRole("button", { name: "创建" })); + expect(await screen.findByText("关闭后无法再次查看")).toBeTruthy(); + }); + + it("edits restrictions and rotates with the current version", async () => { + const patch = vi.spyOn(api, "patch").mockResolvedValue({ data: standard }); + const post = vi.spyOn(api, "post").mockResolvedValue({ + data: { ...standard, version: 4, secret: "cb-rotated-secret" }, + }); + vi.spyOn(window, "confirm").mockReturnValue(true); + render(); + fireEvent.click(screen.getByRole("button", { name: "编辑" })); + fireEvent.change(screen.getByLabelText("名称", { exact: true }), { + target: { value: "Renamed key" }, + }); + fireEvent.click(screen.getByLabelText("启用", { exact: true })); + fireEvent.click(screen.getByRole("button", { name: "保存更改" })); + await waitFor(() => + expect(patch).toHaveBeenCalledWith(`/keys/${standard.id}`, { + version: 3, + name: "Renamed key", + enabled: false, + expires_at: null, + ip_allow: null, + rpm: null, + models: null, + }), + ); + fireEvent.click(screen.getByRole("button", { name: "轮换" })); + await waitFor(() => + expect(post).toHaveBeenCalledWith(`/keys/${standard.id}/rotate`, { version: 3 }), + ); + expect(await screen.findByText("cb-rotated-secret")).toBeTruthy(); + }); +}); diff --git a/web/src/logs.test.tsx b/web/src/logs.test.tsx index ddfa62d..f6a073f 100644 --- a/web/src/logs.test.tsx +++ b/web/src/logs.test.tsx @@ -88,6 +88,40 @@ describe("request client metadata", () => { }); }); +describe("request API keys", () => { + it("maps key names, marks legacy and deleted keys, and filters by key id", async () => { + vi.spyOn(api, "get").mockResolvedValue({ + data: { + external: null, + items: [{ id: "k_known", name: "CI key", type: "standard", version: 1 }], + }, + }); + vi.mocked(useResource).mockReturnValue({ + data: { + items: [ + { id: "request-a", api_key: "k_known", outcome: "success" }, + { id: "request-b", api_key: "k_deleted", outcome: "success" }, + { id: "request-c", api_key: null, outcome: "success" }, + ], + has_more: false, + next_cursor: null, + }, + loading: false, + refreshing: false, + error: null, + reload: vi.fn(), + }); + render(); + expect((await screen.findAllByText("CI key")).length).toBeGreaterThan(0); + expect(screen.getByText("k_deleted(已删除)")).toBeTruthy(); + expect(screen.getByText("—(升级前)")).toBeTruthy(); + choose(screen.getByLabelText("筛选密钥"), "k_known"); + fireEvent.click(screen.getByRole("button", { name: "应用筛选" })); + const call = vi.mocked(useResource).mock.calls.at(-1)![0] as string; + expect(call).toContain("api_key=k_known"); + }); +}); + describe("credit changes tab", () => { it("shows signed changes per account and filters by account and type", async () => { const account = "a".repeat(64); @@ -148,13 +182,20 @@ describe("credit changes tab", () => { describe("log detail request lifetime", () => { it("does not reopen a closed drawer when its response arrives", async () => { const request = pending(); - const get = vi.spyOn(api, "get").mockReturnValue(request.promise); + const get = vi + .spyOn(api, "get") + .mockImplementation((path) => + path === "/keys" + ? Promise.resolve({ data: { external: null, items: [] } }) + : request.promise, + ); render(); open(); expect(screen.getByRole("status").textContent).toContain("正在加载详情"); close(); expect(screen.queryByRole("dialog")).toBeNull(); - expect(get.mock.calls[0]?.[1]?.signal?.aborted).toBe(true); + const detail = get.mock.calls.find(([path]) => path === "/logs/request-a")!; + expect(detail[1]?.signal?.aborted).toBe(true); await act(async () => request.resolve({ data: { id: "late-a" } })); expect(screen.queryByRole("dialog")).toBeNull(); }); @@ -164,7 +205,12 @@ describe("log detail request lifetime", () => { async (outcome) => { const first = pending(); const second = pending(); - vi.spyOn(api, "get").mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); + let detailCount = 0; + vi.spyOn(api, "get").mockImplementation((path) => { + if (path === "/keys") return Promise.resolve({ data: { external: null, items: [] } }); + detailCount += 1; + return detailCount === 1 ? first.promise : second.promise; + }); render(); open(); close(); @@ -188,25 +234,39 @@ describe("log detail request lifetime", () => { it("aborts on unmount and ignores a late rejection", async () => { const request = pending(); - const get = vi.spyOn(api, "get").mockReturnValue(request.promise); + const get = vi + .spyOn(api, "get") + .mockImplementation((path) => + path === "/keys" + ? Promise.resolve({ data: { external: null, items: [] } }) + : request.promise, + ); const view = render(); open(); view.unmount(); - expect(get.mock.calls[0]?.[1]?.signal?.aborted).toBe(true); + const detail = get.mock.calls.find(([path]) => path === "/logs/request-a")!; + expect(detail[1]?.signal?.aborted).toBe(true); await act(async () => request.reject(new Error("late failure"))); expect(screen.queryByRole("dialog")).toBeNull(); }); it("cancels detail loading when switching log kind", async () => { const request = pending(); - const get = vi.spyOn(api, "get").mockReturnValue(request.promise); + const get = vi + .spyOn(api, "get") + .mockImplementation((path) => + path === "/keys" + ? Promise.resolve({ data: { external: null, items: [] } }) + : request.promise, + ); render(); open(); fireEvent.click(screen.getByRole("tab", { name: "运行事件" })); expect(screen.queryByRole("dialog")).toBeNull(); - expect(get.mock.calls[0]?.[1]?.signal?.aborted).toBe(true); + const detail = get.mock.calls.find(([path]) => path === "/logs/request-a")!; + expect(detail[1]?.signal?.aborted).toBe(true); open(); - expect(get).toHaveBeenCalledTimes(1); + expect(get.mock.calls.filter(([path]) => path.startsWith("/logs/"))).toHaveLength(1); expect(screen.queryByRole("status")).toBeNull(); await act(async () => request.resolve({ data: { id: "stale-request" } })); expect(screen.queryByText("stale-request")).toBeNull(); diff --git a/web/src/pages/Account.tsx b/web/src/pages/Account.tsx new file mode 100644 index 0000000..e1fb78f --- /dev/null +++ b/web/src/pages/Account.tsx @@ -0,0 +1,196 @@ +import { useEffect, useState } from "react"; +import { api, errorMessage, object, useSession } from "../api"; +import { Badge, ErrorNotice, Panel, ResourceState } from "../components"; +import s from "../ui.module.scss"; +import { t as translate, useT } from "../i18n"; +import { notify } from "../toast"; + +type Account = { + username: string; + password_source: string; + bootstrap: boolean; + changed_at: number; +}; + +function normalize(value: unknown): Account { + const data = object(value, translate("account.title")); + if ( + typeof data.username !== "string" || + typeof data.password_source !== "string" || + typeof data.bootstrap !== "boolean" || + typeof data.changed_at !== "number" + ) + throw new Error(translate("account.invalidResponse")); + return { + username: data.username, + password_source: data.password_source, + bootstrap: data.bootstrap, + changed_at: data.changed_at, + }; +} + +export function AccountPanel() { + const t = useT(); + const [account, setAccount] = useState(null); + const [username, setUsername] = useState(""); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmation, setConfirmation] = useState(""); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const load = () => { + setLoading(true); + setError(null); + void api + .get("/account") + .then((response) => { + const data = normalize(response.data); + setAccount(data); + setUsername(data.username); + }) + .catch((reason: unknown) => setError(errorMessage(reason))) + .finally(() => setLoading(false)); + }; + useEffect(load, []); + + const changed = account !== null && (username !== account.username || newPassword.length > 0); + const save = (event: React.FormEvent) => { + event.preventDefault(); + if (!account) return; + if (newPassword !== confirmation) { + setError(t("account.mismatch")); + return; + } + const body: Record = { current_password: currentPassword }; + if (username !== account.username) body.username = username; + if (newPassword) body.new_password = newPassword; + setBusy(true); + setError(null); + void api + .put("/account", body) + .then((response) => { + const data = object(response.data, t("account.title")); + if (typeof data.username !== "string" || typeof data.csrf_token !== "string") + throw new Error(t("account.missingCsrf")); + useSession.setState({ csrf: data.csrf_token, bootstrap: data.bootstrap === true }); + setAccount({ ...account, username: data.username, bootstrap: data.bootstrap === true }); + setUsername(data.username); + setCurrentPassword(""); + setNewPassword(""); + setConfirmation(""); + notify({ key: "account-saved", tone: "good", title: t("account.saved") }); + }) + .catch((reason: unknown) => setError(errorMessage(reason))) + .finally(() => setBusy(false)); + }; + + const revokeAll = () => { + if (!window.confirm(t("account.revokeConfirm"))) return; + setBusy(true); + setError(null); + void api + .delete("/sessions") + .then(() => + useSession.setState({ + status: "anonymous", + csrf: null, + bootstrap: false, + error: null, + }), + ) + .catch((reason: unknown) => setError(errorMessage(reason))) + .finally(() => setBusy(false)); + }; + + return ( + <> + + {account && ( +
+ + {account.bootstrap ? t("account.bootstrapInitial") : t("account.bootstrapChanged")} + + } + > +
+
+ + + + + +
+ +
+ +
+
+ +
+
+ +
+
+
+
+ )} + + ); +} diff --git a/web/src/pages/Keys.tsx b/web/src/pages/Keys.tsx new file mode 100644 index 0000000..271a7a4 --- /dev/null +++ b/web/src/pages/Keys.tsx @@ -0,0 +1,498 @@ +import { useState } from "react"; +import { api, errorMessage, list, metric, object, useResource, type RecordValue } from "../api"; +import { + Badge, + Drawer, + DrawerPresence, + Empty, + ErrorNotice, + Icon, + PageTitle, + Panel, + ResourceState, +} from "../components"; +import s from "../ui.module.scss"; +import { t } from "../i18n"; +import { notify } from "../toast"; + +type KeyRow = { + id: string; + name: string; + type: "admin" | "standard"; + prefix: string; + masked: string | null; + enabled: boolean; + expires_at: number | null; + ip_allow: string[] | null; + rpm: number | null; + models: string[] | null; + created_at: number | null; + last_used_at: number | null; + version: number; + usage: RecordValue | null; + overridden: boolean; +}; +type KeysData = { external: { origin: string } | null; items: KeyRow[] }; +type ModelOption = { id: string; label: string }; +type Editor = { item: KeyRow | null }; +type Secret = { value: string; oneTime: boolean }; + +function originLabel(origin: string) { + if (origin === "cli") return t("keys.origin.cli"); + if (origin === "environment") return t("keys.origin.environment"); + if (origin === "dotenv") return t("keys.origin.dotenv"); + return t("keys.origin.external"); +} + +function nullableNumber(value: unknown) { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} +function stringList(value: unknown) { + return Array.isArray(value) && value.every((item) => typeof item === "string") + ? (value as string[]) + : null; +} +function normalize(value: unknown): KeysData { + const data = object(value, t("keys.title")); + const external = data.external == null ? null : object(data.external, t("keys.externalTitle")); + return { + external: external && typeof external.origin === "string" ? { origin: external.origin } : null, + items: list(data.items, t("keys.title")).map((item) => { + if ( + typeof item.id !== "string" || + typeof item.name !== "string" || + (item.type !== "admin" && item.type !== "standard") || + typeof item.version !== "number" + ) + throw new Error(t("keys.invalidRecord")); + return { + id: item.id, + name: item.name, + type: item.type, + prefix: typeof item.prefix === "string" ? item.prefix : "—", + masked: typeof item.masked === "string" ? item.masked : null, + enabled: item.enabled !== false, + expires_at: nullableNumber(item.expires_at), + ip_allow: stringList(item.ip_allow), + rpm: nullableNumber(item.rpm), + models: stringList(item.models), + created_at: nullableNumber(item.created_at), + last_used_at: nullableNumber(item.last_used_at), + version: item.version, + usage: + item.usage && typeof item.usage === "object" && !Array.isArray(item.usage) + ? (item.usage as RecordValue) + : null, + overridden: item.overridden === true, + }; + }), + }; +} +function normalizeModels(value: unknown): ModelOption[] { + const data = object(value, t("keys.models")); + const options: ModelOption[] = [{ id: "auto", label: t("keys.auto") }]; + const seen = new Set(options.map((item) => item.id)); + for (const item of list(data.models, "模型列表")) { + if (typeof item.id !== "string" || seen.has(item.id)) continue; + seen.add(item.id); + options.push({ + id: item.id, + label: typeof item.public_id === "string" ? item.public_id : item.id, + }); + } + return options; +} +function localDateTime(timestamp: number | null) { + if (timestamp === null) return ""; + const date = new Date(timestamp * 1000); + const offset = date.getTimezoneOffset() * 60000; + return new Date(date.getTime() - offset).toISOString().slice(0, 16); +} +function formatTime(timestamp: number | null) { + return timestamp === null ? "—" : new Date(timestamp * 1000).toLocaleString("zh-CN"); +} +function copied() { + notify({ key: "keys-copied", tone: "good", title: t("keys.copied") }); +} + +export function Keys() { + const resource = useResource("/keys", normalize); + const modelResource = useResource("/models", normalizeModels); + const [editor, setEditor] = useState(null); + const [name, setName] = useState(""); + const [type, setType] = useState<"standard" | "admin">("standard"); + const [enabled, setEnabled] = useState(true); + const [expiry, setExpiry] = useState("never"); + const [customExpiry, setCustomExpiry] = useState(""); + const [ipAllow, setIpAllow] = useState(""); + const [rpm, setRpm] = useState(""); + const [models, setModels] = useState([]); + const [secret, setSecret] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const data = resource.data; + const adminUnavailable = + Boolean(data?.external) || Boolean(data?.items.some((item) => item.type === "admin")); + + const openEditor = (item: KeyRow | null) => { + setEditor({ item }); + setName(item?.name ?? ""); + setType(item?.type ?? "standard"); + setEnabled(item?.enabled ?? true); + setExpiry(item?.expires_at == null ? "never" : "custom"); + setCustomExpiry(localDateTime(item?.expires_at ?? null)); + setIpAllow(item?.ip_allow?.join("\n") ?? ""); + setRpm(item?.rpm == null ? "" : String(item.rpm)); + setModels(item?.models ?? []); + setError(null); + }; + const finish = (result: RecordValue, oneTime: boolean) => { + if (typeof result.secret === "string") setSecret({ value: result.secret, oneTime }); + setEditor(null); + resource.reload(); + }; + const submit = (event: React.FormEvent) => { + event.preventDefault(); + if (!editor) return; + const kind = editor.item?.type ?? type; + let expiresAt: number | null = null; + if (expiry === "custom") { + const parsed = new Date(customExpiry).getTime(); + if (!customExpiry || !Number.isFinite(parsed)) { + setError(t("keys.invalidExpiry")); + return; + } + expiresAt = parsed / 1000; + } else if (expiry !== "never") { + expiresAt = Date.now() / 1000 + Number(expiry) * 86400; + } + const limits = + kind === "admin" + ? {} + : { + expires_at: expiresAt, + ip_allow: ipAllow.trim() + ? ipAllow + .split(/[\n,]/) + .map((item) => item.trim()) + .filter(Boolean) + : null, + rpm: rpm.trim() ? Number(rpm) : null, + models: models.length ? models : null, + }; + setBusy(true); + setError(null); + const request = editor.item + ? api.patch(`/keys/${encodeURIComponent(editor.item.id)}`, { + version: editor.item.version, + name, + enabled, + ...limits, + }) + : api.post("/keys", { name, type, ...limits }); + void request + .then((response) => finish(object(response.data, t("keys.title")), kind === "admin")) + .catch((reason: unknown) => setError(errorMessage(reason))) + .finally(() => setBusy(false)); + }; + const reveal = (item: KeyRow, copy = false) => { + setError(null); + void api + .post(`/keys/${encodeURIComponent(item.id)}/reveal`) + .then((response) => { + const result = object(response.data, t("keys.title")); + if (typeof result.secret !== "string") throw new Error(t("keys.invalidSecret")); + if (copy) { + return navigator.clipboard.writeText(result.secret).then(copied); + } + setSecret({ value: result.secret, oneTime: false }); + }) + .catch((reason: unknown) => setError(errorMessage(reason))); + }; + const rotate = (item: KeyRow) => { + if (!window.confirm(t("keys.rotateConfirm", { name: item.name }))) return; + setError(null); + void api + .post(`/keys/${encodeURIComponent(item.id)}/rotate`, { version: item.version }) + .then((response) => finish(object(response.data, t("keys.title")), item.type === "admin")) + .catch((reason: unknown) => setError(errorMessage(reason))); + }; + const remove = (item: KeyRow) => { + if (!window.confirm(t("keys.deleteConfirm", { name: item.name }))) return; + setError(null); + void api + .delete(`/keys/${encodeURIComponent(item.id)}`, { data: { version: item.version } }) + .then(() => resource.reload()) + .catch((reason: unknown) => setError(errorMessage(reason))); + }; + const copySecret = () => { + if (!secret) return; + void navigator.clipboard + .writeText(secret.value) + .then(copied) + .catch((reason: unknown) => setError(errorMessage(reason))); + }; + const modelLabel = (id: string) => + modelResource.data?.find((item) => item.id === id)?.label ?? id; + + return ( + <> + openEditor(null)}> + + {t("keys.create")} + + } + /> + {!editor && } + + {data && ( +
+ {data.external && ( + +

+ {t("keys.externalOccupied", { origin: originLabel(data.external.origin) })} +

+
+ )} + + {!data.items.length ? ( + + ) : ( +
+ + + + + + + + + + + + + + + + + + {data.items.map((item) => { + const expired = + item.expires_at !== null && item.expires_at <= Date.now() / 1000; + const status = item.overridden + ? "keys.overridden" + : !item.enabled + ? "keys.disabled" + : expired + ? "keys.expired" + : "keys.enabled"; + return ( + + + + + + + + + + + + + + ); + })} + +
{t("keys.name")}{t("keys.type")}{t("keys.secret")}{t("keys.status")}{t("keys.expiry")}IPRPM{t("keys.models")}{t("keys.usage24h")}{t("keys.lastUsed")}{t("keys.actions")}
{item.name} + + {item.type === "admin" ? t("keys.admin") : t("keys.standard")} + + {item.masked ?? item.prefix} + + {t(status)} + + + {item.expires_at === null + ? t("keys.never") + : formatTime(item.expires_at)} + + {item.ip_allow?.join("、") || t("keys.unlimited")} + {item.rpm ?? t("keys.unlimited")} + {item.models === null + ? t("keys.all") + : item.models.map((id) => modelLabel(id)).join("、") || t("keys.all")} + {metric(item.usage?.requests)}{formatTime(item.last_used_at)} +
+ {!item.overridden && ( + <> + + + {item.type === "standard" && ( + <> + + + + )} + + )} + +
+
+
+ )} +
+
+ )} + + {editor && ( + setEditor(null)} + dismissDisabled={busy} + > +
+ + {!editor.item && ( + + )} + {adminUnavailable && !editor.item && ( +

{t("keys.adminUnavailable")}

+ )} + {editor.item && ( + + )} + {(editor.item?.type ?? type) === "standard" && ( + <> + + {expiry === "custom" && ( + + )} +