diff --git a/.env.example b/.env.example index 13de9905..63fd8524 100644 --- a/.env.example +++ b/.env.example @@ -43,8 +43,14 @@ ADMIN_AUTH_ENABLED=false ADMIN_USERNAME=admin ADMIN_PASSWORD= -# 用 Windows DPAPI 加密保存账户认证状态与管理会话文件,仅 Windows 支持;开启后这些文件无法复制到其他机器或用户 +# 加密保存账户认证状态与管理会话文件。默认关闭,已有明文文件保持不变,直到该文件下一次被保存。 +# Windows 使用 DPAPI,开启后这些文件不能复制到其他机器或用户。 +# Linux 与 Docker 使用 AES-256-GCM,必须另外提供密钥,且密钥不能放在程序目录或 auth 目录里: +# 进程环境变量 CREDENTIALS_KEY 为 32 字节的十六进制或标准 Base64,不要写进 .env +# 或 CREDENTIALS_KEY_FILE 指向权限 0600 的 32 字节密钥文件,例如 /run/secrets/aistudio-credentials +# 密钥文件不存在时不会自动生成。密钥丢失或错误时拒绝解密,不改写已有密文,也不回退成明文。 ENCRYPT_CREDENTIALS=false +# CREDENTIALS_KEY_FILE= # 可信反向代理的逗号分隔 CIDR,仅这些来源的 X-Forwarded-Proto 会影响会话 Cookie 与返回 URL TRUSTED_PROXY_CIDRS= @@ -109,8 +115,10 @@ MODEL_CHANNELS= # 启用 Build 通道时非流式请求先使用 Build 原生单次调用,不可用时回退流式并记录原因 BUILD_NATIVE_NONSTREAM=true -# 非流式请求等待超过 10 秒时先返回 200,之后每 10 秒写出一个换行,经反向代理时连接不会因长时间无数据断开 -NONSTREAM_KEEPALIVE=true +# 默认关闭,慢响应仍返回真实 HTTP 状态,429 和认证失败不会被提前写出的 200 盖掉。 +# 设为 true 时,非流式请求等待超过 10 秒先返回 200 并每 10 秒写一个换行,避免反代掐断空闲连接; +# 响应头一旦提交,之后的 429 或认证失败只能写在 JSON 正文里,访问日志仍记录真实状态。 +NONSTREAM_KEEPALIVE=false # 流式请求先按非流式取得完整结果再按流式格式输出,中途失败时换号重发 FAKE_STREAM=false diff --git a/.github/actions/static-libraries/action.yml b/.github/actions/static-libraries/action.yml index 047d249f..9d0063de 100644 --- a/.github/actions/static-libraries/action.yml +++ b/.github/actions/static-libraries/action.yml @@ -15,8 +15,9 @@ runs: run: | spidermonkey="$RUNNER_TEMP/spidermonkey/$TARGET" libwebp="$RUNNER_TEMP/libwebp/$TARGET" - mkdir -p "$spidermonkey" "$libwebp" - curl -fsSL "https://github.com/Mag1cFall/firefox/releases/download/$SPIDERMONKEY/spidermonkey-$SPIDERMONKEY-$TARGET.tar.gz" | tar -xz -C "$spidermonkey" - curl -fsSL "https://github.com/Mag1cFall/libwebp/releases/download/$LIBWEBP/libwebp-$LIBWEBP-$TARGET.tar.gz" | tar -xz -C "$libwebp" + sums="$GITHUB_WORKSPACE/build/dependency-checksums.txt" + fetch="$GITHUB_WORKSPACE/build/fetch-verified.sh" + sh "$fetch" "https://github.com/Mag1cFall/firefox/releases/download/$SPIDERMONKEY/spidermonkey-$SPIDERMONKEY-$TARGET.tar.gz" "spidermonkey-$SPIDERMONKEY-$TARGET.tar.gz" "$spidermonkey" "$sums" tar.gz + sh "$fetch" "https://github.com/Mag1cFall/libwebp/releases/download/$LIBWEBP/libwebp-$LIBWEBP-$TARGET.tar.gz" "libwebp-$LIBWEBP-$TARGET.tar.gz" "$libwebp" "$sums" tar.gz echo "CGO_CPPFLAGS=-I$spidermonkey/include -I$libwebp/include" >> "$GITHUB_ENV" echo "CGO_LDFLAGS=-L$spidermonkey/lib -L$libwebp/lib" >> "$GITHUB_ENV" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 09817f7b..42475f6e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -111,7 +111,7 @@ jobs: restore-keys: go-windows-amd64-${{ steps.go.outputs.go-version }}-${{ hashFiles('go.sum') }}- - name: Install Windows C Compiler run: | - curl -fsSL https://github.com/mstorsjo/llvm-mingw/releases/download/20261020/llvm-mingw-20261020-ucrt-ubuntu-22.04-x86_64.tar.xz | tar -xJ -C "$RUNNER_TEMP" + sh build/fetch-verified.sh https://github.com/mstorsjo/llvm-mingw/releases/download/20261020/llvm-mingw-20261020-ucrt-ubuntu-22.04-x86_64.tar.xz llvm-mingw-20261020-ucrt-ubuntu-22.04-x86_64.tar.xz "$RUNNER_TEMP" build/dependency-checksums.txt tar.xz echo "$RUNNER_TEMP/llvm-mingw-20261020-ucrt-ubuntu-22.04-x86_64/bin" >> "$GITHUB_PATH" - uses: ./.github/actions/static-libraries with: diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 6f1baac6..17070104 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -95,7 +95,7 @@ jobs: - name: Install Windows C Compiler if: matrix.check == 'vet' run: | - curl -fsSL https://github.com/mstorsjo/llvm-mingw/releases/download/20261020/llvm-mingw-20261020-ucrt-ubuntu-22.04-x86_64.tar.xz | tar -xJ -C "$RUNNER_TEMP" + sh build/fetch-verified.sh https://github.com/mstorsjo/llvm-mingw/releases/download/20261020/llvm-mingw-20261020-ucrt-ubuntu-22.04-x86_64.tar.xz llvm-mingw-20261020-ucrt-ubuntu-22.04-x86_64.tar.xz "$RUNNER_TEMP" build/dependency-checksums.txt tar.xz echo "$RUNNER_TEMP/llvm-mingw-20261020-ucrt-ubuntu-22.04-x86_64/bin" >> "$GITHUB_PATH" - uses: ./.github/actions/static-libraries if: matrix.check == 'vet' diff --git a/Dockerfile b/Dockerfile index f7261848..0e2828b1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,12 +11,15 @@ ARG TARGETARCH ARG ZIG=0.16.0 ARG SPIDERMONKEY=v152.0.4-aistudio2api.1 ARG LIBWEBP=v1.6.0-aistudio2api.1 -RUN apt-get update && apt-get install -y --no-install-recommends xz-utils && rm -rf /var/lib/apt/lists/* \ - && curl -fsSL https://ziglang.org/download/$ZIG/zig-$(uname -m)-linux-$ZIG.tar.xz | tar -xJ -C /opt \ +RUN apt-get update && apt-get install -y --no-install-recommends xz-utils && rm -rf /var/lib/apt/lists/* +COPY build/dependency-checksums.txt build/fetch-verified.sh /opt/verify/ +# 摘要来源写在 dependency-checksums.txt。校验失败不会解压。 +RUN arch=$(uname -m) \ + && sh /opt/verify/fetch-verified.sh "https://ziglang.org/download/${ZIG}/zig-${arch}-linux-${ZIG}.tar.xz" "zig-${arch}-linux-${ZIG}.tar.xz" /opt /opt/verify/dependency-checksums.txt tar.xz \ && mv /opt/zig-* /opt/zig \ - && mkdir /opt/spidermonkey /opt/libwebp \ - && curl -fsSL https://github.com/Mag1cFall/firefox/releases/download/$SPIDERMONKEY/spidermonkey-$SPIDERMONKEY-linux-$TARGETARCH.tar.gz | tar -xz -C /opt/spidermonkey \ - && curl -fsSL https://github.com/Mag1cFall/libwebp/releases/download/$LIBWEBP/libwebp-$LIBWEBP-linux-$TARGETARCH.tar.gz | tar -xz -C /opt/libwebp + && mkdir -p /opt/spidermonkey /opt/libwebp \ + && sh /opt/verify/fetch-verified.sh "https://github.com/Mag1cFall/firefox/releases/download/${SPIDERMONKEY}/spidermonkey-${SPIDERMONKEY}-linux-${TARGETARCH}.tar.gz" "spidermonkey-${SPIDERMONKEY}-linux-${TARGETARCH}.tar.gz" /opt/spidermonkey /opt/verify/dependency-checksums.txt tar.gz \ + && sh /opt/verify/fetch-verified.sh "https://github.com/Mag1cFall/libwebp/releases/download/${LIBWEBP}/libwebp-${LIBWEBP}-linux-${TARGETARCH}.tar.gz" "libwebp-${LIBWEBP}-linux-${TARGETARCH}.tar.gz" /opt/libwebp /opt/verify/dependency-checksums.txt tar.gz WORKDIR /src COPY go.mod go.sum ./ RUN --mount=type=cache,target=/go/pkg/mod go mod download diff --git a/README.md b/README.md index 3b420d61..1188d040 100644 --- a/README.md +++ b/README.md @@ -131,9 +131,11 @@ npm run build cd .. mkdir spidermonkey curl.exe -L -o spidermonkey\windows.tar.gz https://github.com/Mag1cFall/firefox/releases/download/v152.0.4-aistudio2api.1/spidermonkey-v152.0.4-aistudio2api.1-windows-amd64.tar.gz +if ((Get-FileHash spidermonkey\windows.tar.gz -Algorithm SHA256).Hash -ne "942A3B57B202F899D799CAC678B7471D01D0B34714070F0810C18FCA37FFD721") { throw "spidermonkey checksum mismatch" } tar -xzf spidermonkey\windows.tar.gz -C spidermonkey mkdir libwebp curl.exe -L -o libwebp\libwebp.tar.gz https://github.com/Mag1cFall/libwebp/releases/download/v1.6.0-aistudio2api.1/libwebp-v1.6.0-aistudio2api.1-windows-amd64.tar.gz +if ((Get-FileHash libwebp\libwebp.tar.gz -Algorithm SHA256).Hash -ne "CA38CA4CFAE9BFE5808DBCFA11707D81EFD534D723D2B7622CD32EF1114DEA1D") { throw "libwebp checksum mismatch" } tar -xzf libwebp\libwebp.tar.gz -C libwebp $env:CC = "x86_64-w64-mingw32-clang" $env:CXX = "x86_64-w64-mingw32-clang++" @@ -143,7 +145,7 @@ go build -o aistudio2api.exe ./cmd/aistudio2api .\aistudio2api.exe ``` -`x86_64-w64-mingw32-clang` 来自 llvm-mingw 的 `ucrt-x86_64` 包,解压后把其中的 `bin` 目录加入 `PATH`。 +`x86_64-w64-mingw32-clang` 来自 llvm-mingw 的 `ucrt-x86_64` 包,解压后把其中的 `bin` 目录加入 `PATH`。上面的 SHA256 来自 GitHub Release 资产的 `digest`,与 `build/dependency-checksums.txt` 相同;校验失败时不要解压。llvm-mingw `20261020` 的 `ucrt-ubuntu-22.04-x86_64` 包摘要是 `55064b60b991a1e1e8090b8c2f84e82b22cfe5903d12c159d65064cf5e8ae335`,依据同样是 GitHub 记录的资产摘要,发布说明里没有维护者签名。 `WAA_BACKEND=camoufox` 时启动生成服务,或第一次使用浏览器登录时,程序自动下载当前平台的 Camoufox 到 `runtime/camoufox/`: @@ -201,10 +203,9 @@ npm run build cd .. arch=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') platform=$([ "$(uname -s)" = Darwin ] && echo darwin || echo linux) -mkdir -p spidermonkey -curl -L https://github.com/Mag1cFall/firefox/releases/download/v152.0.4-aistudio2api.1/spidermonkey-v152.0.4-aistudio2api.1-$platform-$arch.tar.gz | tar -xz -C spidermonkey -mkdir -p libwebp -curl -L https://github.com/Mag1cFall/libwebp/releases/download/v1.6.0-aistudio2api.1/libwebp-v1.6.0-aistudio2api.1-$platform-$arch.tar.gz | tar -xz -C libwebp +mkdir -p spidermonkey libwebp +sh build/fetch-verified.sh "https://github.com/Mag1cFall/firefox/releases/download/v152.0.4-aistudio2api.1/spidermonkey-v152.0.4-aistudio2api.1-$platform-$arch.tar.gz" "spidermonkey-v152.0.4-aistudio2api.1-$platform-$arch.tar.gz" spidermonkey build/dependency-checksums.txt tar.gz +sh build/fetch-verified.sh "https://github.com/Mag1cFall/libwebp/releases/download/v1.6.0-aistudio2api.1/libwebp-v1.6.0-aistudio2api.1-$platform-$arch.tar.gz" "libwebp-v1.6.0-aistudio2api.1-$platform-$arch.tar.gz" libwebp build/dependency-checksums.txt tar.gz export CGO_CPPFLAGS="-I$PWD/spidermonkey/include -I$PWD/libwebp/include" CGO_LDFLAGS="-L$PWD/spidermonkey/lib -L$PWD/libwebp/lib" if [ "$platform" = darwin ]; then go build -o aistudio2api ./cmd/aistudio2api @@ -235,7 +236,8 @@ docker compose up -d - 容器内固定监听 `0.0.0.0:2048`,因此即使 Compose 只把端口绑定到宿主机回环地址,`data/.env` 也必须设置 `PROXY_API_KEY`,否则服务拒绝启动。确需供其他机器访问时,再将 `docker-compose.yml` 的端口改为 `0.0.0.0:2048:2048`(预览端口按需开放)并使用高强度 key;从容器外打开管理页面还需要 `ADMIN_AUTH_ENABLED=true` 与管理密码,用户名默认为 `admin` - 添加账户:在管理页面“添加账户”中选择“浏览器登录”,在管理页面打开的登录窗口中登录 Google;也可以粘贴 Cookie,或把能登录 Google 的电脑上的 `auth//` 复制到 `data/auth/` -- `PROXY` 与账户代理从容器内访问,代理运行在宿主机时填写宿主机的局域网地址,并允许代理接受局域网连接 +- `PROXY` 与账户代理从容器内访问,代理运行在宿主机时填写宿主机的局域网地址,并允许代理接受局域网连接。`PROXY` 不转发外部媒体下载和 webhook +- 需要加密 `data/auth` 时,把 `ENCRYPT_CREDENTIALS=true` 写进 `data/.env`,密钥用环境变量 `CREDENTIALS_KEY` 或挂到 `/data` 之外的 `CREDENTIALS_KEY_FILE`。不要把密钥放进 `data/` - 容器时区为 UTC,新账户的指纹时区、日志时间与用量页的按日统计取容器时区;在 `docker-compose.yml` 中加入 `environment: { TZ: }` 改用其他时区,账户时区也可在账户页单独设置 - 修改 `data/.env` 后执行 `docker compose restart`;更新镜像执行 `docker compose pull && docker compose up -d`;在源码目录中执行 `docker compose up -d --build` 从源码构建 @@ -603,7 +605,7 @@ cp .env.example .env | `PROXY_API_KEY` | 空 | 公开 API key;`LISTEN_ADDR` 不是本机回环地址时必填 | | `PUBLIC_RATE_LIMIT` | `30` | 每个 API key(未配置 key 时每个对端 IP)每分钟最多发起的公开 API 请求数;超限返回 429 | | `PUBLIC_RATE_BURST` | `4` | 单个 API key/IP 可立即发起的请求数,必须不大于 `PUBLIC_RATE_LIMIT` | -| `API_KEY_ACCOUNTS` | 空 | 各 key 可用的账户,每项写成 `key:账户\|账户`,未列出的 key 可用全部账户;也可在设置页按 key 勾选账户,保存后立即生效 | +| `API_KEY_ACCOUNTS` | 空 | 各 key 可用的账户,每项写成 `key:账户\|账户`,未列出的 key 可用全部账户,漏配时启动与保存设置会在日志提示数量;生成、上传、文件列表、文件详情、下载和删除都受这个范围约束,范围外的文件与不存在的文件一样返回 404;也可在设置页按 key 勾选账户,保存后立即生效 | | `API_KEY_RATE_LIMITS` | 空 | 为个别 API key 指定每分钟请求上限,每项写成 `key:每分钟`,未列出的 key 使用 `PUBLIC_RATE_LIMIT`;也可在设置页填写 | | `AUTO_START` | `false` | 管理程序启动后自动启动生成服务;网络等原因启动失败时按 5 秒起、翻倍至 5 分钟的间隔重试,手动启动或停止后不再重试 | | `HIDE_DOCK_ICON` | `false` | macOS 桌面版只在菜单栏显示图标、不显示 Dock 图标,关闭窗口后服务继续运行;在设置页修改立即生效 | @@ -614,7 +616,7 @@ cp .env.example .env | `ADMIN_AUTH_ENABLED` | `false` | 管理控制台账号密码登录开关 | | `ADMIN_USERNAME` | `admin` | 管理员账号 | | `ADMIN_PASSWORD` | 空 | 管理员密码,开启登录时必填 | -| `ENCRYPT_CREDENTIALS` | `false` | 用 Windows DPAPI 加密保存账户认证状态与管理会话文件;仅 Windows 支持,开启后这些文件不能复制到其他机器或用户 | +| `ENCRYPT_CREDENTIALS` | `false` | 加密账户认证状态与管理会话文件。Windows 用 DPAPI。Linux 与 Docker 用 AES-256-GCM,必须另给密钥,见下面的凭据加密 | | `TRUSTED_PROXY_CIDRS` | 空 | 可信反向代理的逗号分隔 CIDR,例如 `127.0.0.1/32`;仅这些来源的 `X-Forwarded-Proto` 可使管理会话 Cookie 标记为 Secure | | `PPROF_ADDR` | 空 | 性能分析监听地址,例如 `127.0.0.1:6060`;留空不启用,启用后可在 `/debug/pprof/` 获取 CPU、内存与 goroutine 剖析 | | `GEOIP_DB_PATH` | `runtime/geoip/dbip-country-lite.mmdb` | 本地 GeoIP 数据库路径,供账户环境检查比对出口 IP 所在国家;缺省文件时可在账户页一键下载 DB-IP 免费库(CC BY 4.0) | @@ -636,7 +638,7 @@ cp .env.example .env | `MODEL_MAPPING` | 空 | 客户端模型名到目录模型的映射,每项写成 `别名:模型 ID`,逗号分隔多项 | | `MODEL_CHANNELS` | 空 | 限定模型只走一个通道,每项写成 `模型 ID:playground` 或 `模型 ID:build`;也可在“模型目录”的模型详情中选择,保存后立即生效 | | `BUILD_NATIVE_NONSTREAM` | `true` | 启用 Build 通道时非流式请求优先使用 Build 原生单次调用;不可用时回退流式并记录原因 | -| `NONSTREAM_KEEPALIVE` | `true` | 非流式请求等待结果超过 10 秒时先返回 200,之后每 10 秒写出一个换行直到写出 JSON 正文,经 Cloudflare 等反向代理时连接不会因长时间无数据断开;此后才出现的错误以协议错误正文返回 | +| `NONSTREAM_KEEPALIVE` | `false` | 默认关闭,慢响应保持真实 HTTP 状态。设为 `true` 时,非流式请求超过 10 秒先返回 200,之后每 10 秒写一个换行,避免 Cloudflare 等反代掐断空闲连接;响应头提交后,429 和认证失败只能出现在 JSON 正文里,访问日志仍记录真实状态 | | `FAKE_STREAM` | `false` | 流式请求先按非流式取得完整结果再按流式格式输出,中途失败时换号重发,等待期间照常发送心跳 | | `YOUTUBE_LINKS` | `false` | 用户消息文本中的 YouTube 链接作为视频发送给模型,并从文本中移除 | | `STRIP_THOUGHT_SIGNATURES` | `false` | 发往上游前去掉对话历史中的思考签名,函数调用带 `skip_thought_signature_validator`;之前各轮的思考内容不随签名发给模型,长对话的输入 token 较少 | @@ -681,6 +683,7 @@ API 试用页的 HTML 预览运行在 `PREVIEW_LISTEN_ADDR` 提供的独立来 3. 在“账户”页面编辑单个账户时可以设置账户专用代理 4. 账户代理同时用于登录、WAA 与业务请求 5. 带账号密码的代理由程序在本机回环地址提供无认证 SOCKS5 中转供 Camoufox 使用,账号密码不写入浏览器 profile;中转运行期间,本机其他进程也能经它使用该代理 +6. `PROXY` 不是全流量代理。客户端传来的外部媒体 URL,以及 Interactions 的 webhook,固定直连公网:拨号前拒绝本机、私网、链路本地、运营商级 NAT 和文档地址,并在真正连接时重新解析,避免 DNS 重绑定。这两类请求不读取 `PROXY`。若改走可配置的正向代理,代理就能访问进程已经拒绝的地址,因此这里没有可选开关 ### 认证文件管理 @@ -697,6 +700,21 @@ API 试用页的 HTML 预览运行在 `PREVIEW_LISTEN_ADDR` 提供的独立来 账户邮箱同时作为目录名、管理页面标识和日志来源,统一使用小写形式。`.leases` 协调账户目录读写,用户缓存中的 runtime lease 保证同一邮箱在当前电脑上只有一个 WAA Worker。 +### 凭据加密 + +`ENCRYPT_CREDENTIALS` 默认关闭。关闭时,已有明文或密文都按原样读取;程序不会在启动时批量加密或解密 `auth/`。开启后,只有下次保存某个认证文件或管理会话时,那个文件才会写成密文。 + +Windows 使用 DPAPI。密文绑在这台机器的当前用户上,不能复制到另一台机器或另一个用户。 + +Linux 和 Docker 使用 AES-256-GCM。密钥必须单独提供,程序不会在 `runtime/` 里自动生成一把密钥再把它当成保护: + +- `CREDENTIALS_KEY`:32 字节密钥的十六进制或标准 Base64,只能放在进程环境变量里。写在 `.env` 中会拒绝启动,保存设置时也不会把这个值写回 `.env`。 +- `CREDENTIALS_KEY_FILE`:权限为 `0600` 的普通文件,内容正好 32 字节。路径不能位于程序工作目录或 `AISTUDIO_AUTH_STATES` 指向的目录中。文件不存在、是符号链接、权限过宽或长度不对时,进程拒绝启动,并且不会创建该文件。 + +两种来源只能设置一个。密钥丢失或与密文不匹配时,读取失败并留下原来的文件,不会改写成空内容,也不会假装解密成功。Docker 里把密钥放在 `/run/secrets/` 这类独立挂载,或用编排系统注入 `CREDENTIALS_KEY`。同一块磁盘上、数据目录之外的密钥文件可以避开随 `data/` 一起拷走,但不能防止整块磁盘被拿走。 + +以前在非 Windows 上把 `ENCRYPT_CREDENTIALS=true` 写进配置时,程序会忽略它并继续以明文运行。现在这种情况会拒绝启动。要保持原来的明文行为,把它改回 `false`。 + 账户页的“添加账户”支持从 Chrome 导入、粘贴 Cookie、从指纹浏览器导入、隔离 Camoufox 登录和远程添加: - 隔离 Camoufox 登录在本机打开可见窗口;服务器没有图形界面或从其他电脑访问时,登录画面显示在管理页面的登录窗口中,经反向代理访问时需要转发 `/api/login-view/` 的 WebSocket diff --git a/README_en.md b/README_en.md index 7b119798..c67c355b 100644 --- a/README_en.md +++ b/README_en.md @@ -131,9 +131,11 @@ npm run build cd .. mkdir spidermonkey curl.exe -L -o spidermonkey\windows.tar.gz https://github.com/Mag1cFall/firefox/releases/download/v152.0.4-aistudio2api.1/spidermonkey-v152.0.4-aistudio2api.1-windows-amd64.tar.gz +if ((Get-FileHash spidermonkey\windows.tar.gz -Algorithm SHA256).Hash -ne "942A3B57B202F899D799CAC678B7471D01D0B34714070F0810C18FCA37FFD721") { throw "spidermonkey checksum mismatch" } tar -xzf spidermonkey\windows.tar.gz -C spidermonkey mkdir libwebp curl.exe -L -o libwebp\libwebp.tar.gz https://github.com/Mag1cFall/libwebp/releases/download/v1.6.0-aistudio2api.1/libwebp-v1.6.0-aistudio2api.1-windows-amd64.tar.gz +if ((Get-FileHash libwebp\libwebp.tar.gz -Algorithm SHA256).Hash -ne "CA38CA4CFAE9BFE5808DBCFA11707D81EFD534D723D2B7622CD32EF1114DEA1D") { throw "libwebp checksum mismatch" } tar -xzf libwebp\libwebp.tar.gz -C libwebp $env:CC = "x86_64-w64-mingw32-clang" $env:CXX = "x86_64-w64-mingw32-clang++" @@ -143,7 +145,7 @@ go build -o aistudio2api.exe ./cmd/aistudio2api .\aistudio2api.exe ``` -`x86_64-w64-mingw32-clang` comes from the llvm-mingw `ucrt-x86_64` package; add its `bin` directory to `PATH` after extracting it. +`x86_64-w64-mingw32-clang` comes from the llvm-mingw `ucrt-x86_64` package; add its `bin` directory to `PATH` after extracting it. The SHA256 values above are the GitHub Release asset digests also listed in `build/dependency-checksums.txt`. Do not extract an archive whose hash does not match. The llvm-mingw `20261020` `ucrt-ubuntu-22.04-x86_64` archive digest is `55064b60b991a1e1e8090b8c2f84e82b22cfe5903d12c159d65064cf5e8ae335`, also from GitHub's asset digest; the release notes do not include a maintainer signature. With `WAA_BACKEND=camoufox`, starting the generation service, or the first browser sign-in, downloads Camoufox for the current platform to `runtime/camoufox/`: @@ -201,10 +203,9 @@ npm run build cd .. arch=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') platform=$([ "$(uname -s)" = Darwin ] && echo darwin || echo linux) -mkdir -p spidermonkey -curl -L https://github.com/Mag1cFall/firefox/releases/download/v152.0.4-aistudio2api.1/spidermonkey-v152.0.4-aistudio2api.1-$platform-$arch.tar.gz | tar -xz -C spidermonkey -mkdir -p libwebp -curl -L https://github.com/Mag1cFall/libwebp/releases/download/v1.6.0-aistudio2api.1/libwebp-v1.6.0-aistudio2api.1-$platform-$arch.tar.gz | tar -xz -C libwebp +mkdir -p spidermonkey libwebp +sh build/fetch-verified.sh "https://github.com/Mag1cFall/firefox/releases/download/v152.0.4-aistudio2api.1/spidermonkey-v152.0.4-aistudio2api.1-$platform-$arch.tar.gz" "spidermonkey-v152.0.4-aistudio2api.1-$platform-$arch.tar.gz" spidermonkey build/dependency-checksums.txt tar.gz +sh build/fetch-verified.sh "https://github.com/Mag1cFall/libwebp/releases/download/v1.6.0-aistudio2api.1/libwebp-v1.6.0-aistudio2api.1-$platform-$arch.tar.gz" "libwebp-v1.6.0-aistudio2api.1-$platform-$arch.tar.gz" libwebp build/dependency-checksums.txt tar.gz export CGO_CPPFLAGS="-I$PWD/spidermonkey/include -I$PWD/libwebp/include" CGO_LDFLAGS="-L$PWD/spidermonkey/lib -L$PWD/libwebp/lib" if [ "$platform" = darwin ]; then go build -o aistudio2api ./cmd/aistudio2api @@ -235,7 +236,8 @@ The image supports linux/amd64 and linux/arm64, and the service listens on `0.0. - The container always listens on `0.0.0.0:2048` inside, so `data/.env` must set `PROXY_API_KEY` even when Compose publishes the ports on the host loopback interface; without it the service refuses to start. Only when remote access is required, change the mapping in `docker-compose.yml` to `0.0.0.0:2048:2048` (and expose the preview port only if needed) and use a strong key. Opening the console from outside the container also requires `ADMIN_AUTH_ENABLED=true` and a console password; the default username is `admin`. - Add accounts: choose "Sign in with browser" in "Add account" on the console and sign in to Google in the sign-in window that opens in the console; you can also paste cookies, or copy `auth//` from a computer that can sign in to Google into `data/auth/`. -- `PROXY` and account proxies are reached from inside the container; when the proxy runs on the host, use the host's LAN address and allow LAN connections on the proxy. +- `PROXY` and account proxies are reached from inside the container; when the proxy runs on the host, use the host's LAN address and allow LAN connections on the proxy. `PROXY` does not carry remote media downloads or webhooks +- To encrypt `data/auth`, set `ENCRYPT_CREDENTIALS=true` in `data/.env` and supply `CREDENTIALS_KEY` as an environment variable, or mount `CREDENTIALS_KEY_FILE` outside `/data`. Do not put the key inside `data/` - The container time zone is UTC. New accounts' fingerprint time zone, log timestamps, and daily usage totals follow the container time zone; add `environment: { TZ: }` to `docker-compose.yml` to use another one. Each account's time zone can also be set on the Accounts page. - Run `docker compose restart` after editing `data/.env`, `docker compose pull && docker compose up -d` to update the image, and `docker compose up -d --build` in the source directory to build from source. @@ -603,7 +605,7 @@ Process environment variables override the same keys in `.env`, and those settin | `PROXY_API_KEY` | empty | Public API key; required when `LISTEN_ADDR` is not a loopback address | | `PUBLIC_RATE_LIMIT` | `30` | Public API requests allowed per minute for each API key, or each peer IP when no key is configured; excess requests receive 429 | | `PUBLIC_RATE_BURST` | `4` | Requests an API key/IP can make immediately; must not exceed `PUBLIC_RATE_LIMIT` | -| `API_KEY_ACCOUNTS` | empty | Accounts each key may use, written as `key:account\|account`; keys not listed can use every account. You can also tick accounts per key on the Settings page; takes effect when saved | +| `API_KEY_ACCOUNTS` | empty | Accounts each key may use, written as `key:account\|account`. Keys not listed can use every account; a miss in that list is reported in the logs at startup and when saving settings. Generation, upload, file list, file metadata, download, and delete all stay inside that set; a file outside it is a 404, same as a missing file. You can also tick accounts per key on the Settings page; takes effect when saved | | `API_KEY_RATE_LIMITS` | empty | Per-minute request limit for individual API keys, each item `key:requests-per-minute`; keys not listed use `PUBLIC_RATE_LIMIT`; also editable on the Settings page | | `AUTO_START` | `false` | Start the generation service when the manager starts; if startup fails for reasons such as the network, retry at intervals starting at 5 seconds and doubling up to 5 minutes, until the service is started or stopped manually | | `HIDE_DOCK_ICON` | `false` | On the macOS desktop app, show the icon only in the menu bar instead of the Dock; the service keeps running after the window closes. Changes in Settings take effect immediately | @@ -614,7 +616,7 @@ Process environment variables override the same keys in `.env`, and those settin | `ADMIN_AUTH_ENABLED` | `false` | Enable username/password login for the console | | `ADMIN_USERNAME` | `admin` | Administrator username | | `ADMIN_PASSWORD` | empty | Administrator password, required when login is enabled | -| `ENCRYPT_CREDENTIALS` | `false` | Encrypt account auth states and the console session file with Windows DPAPI; Windows only, and encrypted files cannot be copied to another machine or user | +| `ENCRYPT_CREDENTIALS` | `false` | Encrypt account auth states and the console session file. Windows uses DPAPI. Linux and Docker use AES-256-GCM and require a separate key; see Credential encryption below | | `TRUSTED_PROXY_CIDRS` | empty | Comma-separated CIDRs of trusted reverse proxies, such as `127.0.0.1/32`; only their `X-Forwarded-Proto` may mark a console-session cookie Secure | | `PPROF_ADDR` | empty | Listen address for profiling, such as `127.0.0.1:6060`; empty disables it, and when enabled `/debug/pprof/` serves CPU, memory and goroutine profiles | | `GEOIP_DB_PATH` | `runtime/geoip/dbip-country-lite.mmdb` | Local GeoIP database used by the account environment check to compare the exit IP country; when missing, the Accounts page can download the free DB-IP database (CC BY 4.0) | @@ -636,7 +638,7 @@ Process environment variables override the same keys in `.env`, and those settin | `MODEL_MAPPING` | empty | Client model names mapped to catalog models, each item `alias:model ID`, comma separated | | `MODEL_CHANNELS` | empty | Limit a model to one channel, each item `model ID:playground` or `model ID:build`; also selectable in model details on the Model catalog page, applied on save | | `BUILD_NATIVE_NONSTREAM` | `true` | With the Build channel enabled, prefer native Build unary calls for non-streaming requests; log any fallback to stream collection | -| `NONSTREAM_KEEPALIVE` | `true` | When a non-streaming request waits longer than 10 seconds, return 200 first and write a newline every 10 seconds until the JSON body, so reverse proxies such as Cloudflare keep the connection open; errors after that point come back as the protocol error body | +| `NONSTREAM_KEEPALIVE` | `false` | Off by default, so a slow response keeps its real HTTP status. Set `true` to return 200 after 10 seconds and write a newline every 10 seconds, which keeps proxies such as Cloudflare from closing an idle connection. Once those headers are sent, a later 429 or authentication failure can only be reported in the JSON body; the access log still records the real status | | `FAKE_STREAM` | `false` | Streaming requests get the full result as a non-streaming generation, retry on another account if it fails midway, then stream it in the protocol format; heartbeats continue while waiting | | `YOUTUBE_LINKS` | `false` | Send YouTube links in user message text to the model as video and remove them from the text | | `STRIP_THOUGHT_SIGNATURES` | `false` | Remove thought signatures from conversation history before sending upstream and send function calls with `skip_thought_signature_validator`; earlier turns' thinking is not passed to the model with the signatures, so long conversations use fewer input tokens | @@ -680,6 +682,7 @@ HTTP, HTTPS, and SOCKS5 proxies are supported. For proxies that require authenti 3. Edit an account to set an account-specific proxy 4. The account proxy is used for login, WAA, and business requests 5. For authenticated proxies, the program gives Camoufox a credential-free SOCKS5 relay on the loopback address; the credentials are not written to the browser profile. While the relay runs, other local processes can also use the proxy through it +6. `PROXY` is not a process-wide proxy. Remote media URLs supplied by clients, and Interactions webhooks, dial the public internet directly. Before connecting they reject loopback, private, link-local, carrier-grade NAT, and documentation addresses, and they resolve the name again at dial time to stop DNS rebinding. Those requests do not read `PROXY`. Sending them through a caller-configured forward proxy would let that proxy reach addresses this process already refused, so there is no optional switch ### Authentication File Management @@ -696,6 +699,21 @@ Authentication files are stored in `auth/` by default: The lowercase Google email is the account directory, management UI identity, and log source. `.leases` coordinates account-directory access, while the runtime lease in the user cache allows one WAA Worker per email on the current computer. +### Credential encryption + +`ENCRYPT_CREDENTIALS` defaults to off. Existing plaintext and ciphertext are read as they are; startup does not rewrite `auth/`. After it is turned on, a file is encrypted the next time that auth file or the console session is saved. + +Windows uses DPAPI. The ciphertext is bound to the current user on this machine and cannot be copied to another machine or user. + +Linux and Docker use AES-256-GCM. The key has to be supplied separately. The program does not generate one under `runtime/` and treat that as protection: + +- `CREDENTIALS_KEY`: hex or standard Base64 for 32 bytes, and only as a process environment variable. A value stored in `.env` prevents startup, and saving settings does not write this value back to `.env`. +- `CREDENTIALS_KEY_FILE`: a regular file with mode `0600` whose contents are exactly 32 bytes. The path must be outside the working directory and outside every directory named by `AISTUDIO_AUTH_STATES`. If the file is missing, a symlink, too widely readable, or the wrong length, the process refuses to start and does not create the file. + +Only one of the two sources may be set. If the key is missing or does not match, reads fail and the original file is left in place. In Docker, mount the key at `/run/secrets/` or inject `CREDENTIALS_KEY` from the orchestrator. A key file on the same disk but outside the data directory is not copied along with `data/`; it does not protect against theft of the whole disk. + +On non-Windows systems, `ENCRYPT_CREDENTIALS=true` used to be ignored and the process kept storing plaintext. It now refuses to start. Set it back to `false` to keep that plaintext behavior. + "Add account" on the Accounts page supports Chrome import, pasted cookies, antidetect browser import, isolated Camoufox login, and remote adding: - Isolated Camoufox login opens a visible local window; when the server has no graphical display or the console is opened from another computer, the sign-in screen appears in a sign-in window in the console, and a reverse proxy must forward the WebSocket at `/api/login-view/` diff --git a/build/dependency-checksums.txt b/build/dependency-checksums.txt new file mode 100644 index 00000000..c4574ef9 --- /dev/null +++ b/build/dependency-checksums.txt @@ -0,0 +1,33 @@ +# 构建依赖的 SHA256。下载脚本对照本文件校验,通过后才解压。 +# +# Zig 0.16.0 的摘要来自官方索引 https://ziglang.org/download/index.json +# 的 0.16.0..shasum,不是本地下载后自行计算再宣称认证。 +# 本环境额外下载并核对过 zig-x86_64-linux-0.16.0.tar.xz,与索引一致。 +# 其余 Zig 目标只记录索引中的 shasum,构建时仍会在解压前校验。 +70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00 zig-x86_64-linux-0.16.0.tar.xz +ea4b09bfb22ec6f6c6ceac57ab63efb6b46e17ab08d21f69f3a48b38e1534f17 zig-aarch64-linux-0.16.0.tar.xz + +# SpiderMonkey v152.0.4-aistudio2api.1 与 libwebp v1.6.0-aistudio2api.1 +# 的摘要来自 GitHub Releases API 的 asset.digest(GitHub 在资产上传时计算)。 +# 对应发布说明没有维护者单独签名的校验文件。 +# 这能发现下载内容被替换,不能代替维护者自己的签名。 +# 本环境额外下载并核对过 linux-amd64 的两个包,与 API digest 一致。 +# darwin、windows、linux-arm64 与 linux-amd64-gnu 只记录同一 API 的 digest, +# 构建下载时仍会在解压前校验;这里没有逐个重新下载。 +d3ecebf1890355426ad261ab8723038849a524b348af53e1bb440ca53cc2cacb spidermonkey-v152.0.4-aistudio2api.1-linux-amd64.tar.gz +b3c9539b8d8378d99f86b267e0e3d007dd01ff80acebf4f52f3e1d6ac04d9d00 spidermonkey-v152.0.4-aistudio2api.1-linux-arm64.tar.gz +50df89da549e87f0baedfaa15f05ba5faf4719eaafee5da48c3b9f46a29b06d3 spidermonkey-v152.0.4-aistudio2api.1-linux-amd64-gnu.tar.gz +942a3b57b202f899d799cac678b7471d01d0b34714070f0810c18fca37ffd721 spidermonkey-v152.0.4-aistudio2api.1-windows-amd64.tar.gz +8063d69f56711c72b1244c8415b6c5096aa64ae8b022f555722667531acfe659 spidermonkey-v152.0.4-aistudio2api.1-darwin-amd64.tar.gz +19f690d52ce2cfe7dbaedcc9771976145fff0dbd81f24e89a2dd094a1424b673 spidermonkey-v152.0.4-aistudio2api.1-darwin-arm64.tar.gz +d5047d17eace8b9639a08786b750e46ee83a1f251b7b961cd0f049371974ab78 libwebp-v1.6.0-aistudio2api.1-linux-amd64.tar.gz +299028ab369a7f8b34feefd41f68cebdc6118c1306d37a57731da9503cd64084 libwebp-v1.6.0-aistudio2api.1-linux-arm64.tar.gz +1c89878d1f7c7b7232ea8fad1292a8f6c70f3d2d92fffc24c1fa6ac9e8070c85 libwebp-v1.6.0-aistudio2api.1-linux-amd64-gnu.tar.gz +ca38ca4cfae9bfe5808dbcfa11707d81efd534d723d2b7622cd32ef1114dea1d libwebp-v1.6.0-aistudio2api.1-windows-amd64.tar.gz +d526ba604c0d2937122587eee4d13a01edd7c6c3e7e6bfdc72ab453ab18ae89f libwebp-v1.6.0-aistudio2api.1-darwin-amd64.tar.gz +66394cee457fe48408718c2dafd34808077f446a25678d54b4c504f711e579a6 libwebp-v1.6.0-aistudio2api.1-darwin-arm64.tar.gz + +# llvm-mingw 20261020 的 ucrt Ubuntu 22.04 x86_64 包。 +# 摘要同样来自 GitHub Releases API 的 asset.digest,发布说明为空,没有独立签名。 +# 本环境没有重新下载这个 80MB 包;工作流下载时会在解压前对照该 digest 校验。 +55064b60b991a1e1e8090b8c2f84e82b22cfe5903d12c159d65064cf5e8ae335 llvm-mingw-20261020-ucrt-ubuntu-22.04-x86_64.tar.xz diff --git a/build/fetch-verified.sh b/build/fetch-verified.sh new file mode 100644 index 00000000..a7e47687 --- /dev/null +++ b/build/fetch-verified.sh @@ -0,0 +1,35 @@ +#!/bin/sh +# 下载到临时文件,对照 dependency-checksums.txt 校验 SHA256,通过后才解压。 +# 用法: fetch-verified.sh URL FILENAME DEST SUMS tar.gz|tar.xz +set -eu +if [ "$#" -ne 5 ]; then + echo "usage: fetch-verified.sh URL FILENAME DEST SUMS tar.gz|tar.xz" >&2 + exit 2 +fi +url=$1 +filename=$2 +dest=$3 +sums=$4 +format=$5 +mkdir -p "$dest" +workdir=$(mktemp -d) +trap 'rm -rf "$workdir"' EXIT +curl -fsSL "$url" -o "$workdir/$filename" +sumline=$(awk -v name="$filename" '$2 == name { print; found = 1; exit } END { if (!found) exit 1 }' "$sums") || { + echo "没有已核实的 SHA256,拒绝解压: $filename" >&2 + exit 1 +} +printf '%s\n' "$sumline" > "$workdir/SHA256SUMS" +if command -v sha256sum >/dev/null 2>&1; then + (cd "$workdir" && sha256sum -c SHA256SUMS) +else + (cd "$workdir" && shasum -a 256 -c SHA256SUMS) +fi +case "$format" in +tar.gz) tar -xzf "$workdir/$filename" -C "$dest" ;; +tar.xz) tar -xJf "$workdir/$filename" -C "$dest" ;; +*) + echo "不支持的压缩格式: $format" >&2 + exit 2 + ;; +esac diff --git a/docker-compose.yml b/docker-compose.yml index 79879066..27265bcc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,3 +10,13 @@ services: - "127.0.0.1:2050:2050" volumes: - ./data:/data + # PROXY 只作用于访问 AI Studio 的账户流量。外部媒体下载和 Interactions webhook 固定直连,并拒绝私网目标。 + # 开启 ENCRYPT_CREDENTIALS 时不要把密钥放进 ./data。用进程环境变量,或挂载数据卷之外的 0600 密钥文件: + # secrets: + # aistudio_credentials: + # file: ./credentials.key + # environment: + # ENCRYPT_CREDENTIALS: "true" + # CREDENTIALS_KEY_FILE: /run/secrets/aistudio_credentials + # secrets: + # - aistudio_credentials diff --git a/docs/development.md b/docs/development.md index 097c8c42..d2f43f7a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -198,7 +198,7 @@ Camoufox 由 Go 通过 WebDriver BiDi 直接管理。启动数据面时,服务 | `PUBLIC_RATE_LIMIT` | 每个 API key(未配置 key 时每个对端 IP)每分钟最多发起的公开 API 请求数 | `30` | | `PUBLIC_RATE_BURST` | 单个 API key/IP 可立即发起的请求数,必须不大于 `PUBLIC_RATE_LIMIT` | `4` | | `API_KEY_RATE_LIMITS` | 为个别 API key 指定每分钟请求上限,每项写成 `key:每分钟`,多个用逗号分隔;未列出的 key 使用 `PUBLIC_RATE_LIMIT` | 空 | -| `API_KEY_ACCOUNTS` | 逗号分隔,每项以最后一个 `:` 分成 `PROXY_API_KEY` 中的 key 与以 `\|` 分隔的账户 ID,该 key 的请求与文件上传只在这些账户中选号,未列出的 key 可用全部账户,不在 `PROXY_API_KEY` 中的项不起作用;保存后立即生效,同时修改 `PROXY_API_KEY` 时,管理服务重启前仍被接受的 key 沿用原来的绑定 | 空 | +| `API_KEY_ACCOUNTS` | 逗号分隔,每项以最后一个 `:` 分成 `PROXY_API_KEY` 中的 key 与以 `\|` 分隔的账户 ID。该 key 的生成、上传、文件列表、文件详情、下载和删除只使用这些账户;范围外的文件与不存在的文件都是资源不存在,响应不说明它是否存在。未列出的 key 可用全部账户,不在 `PROXY_API_KEY` 中的项不起作用;保存后立即生效,同时修改 `PROXY_API_KEY` 时,管理服务重启前仍被接受的 key 沿用原来的绑定 | 空 | | `AUTO_START` | 管理服务就绪后自动启动生成服务;失败记录 `ERROR` 日志,管理服务继续运行,账户与配置以外的原因失败时以 5 秒起、翻倍至 5 分钟的间隔重试,手动启动或停止后不再重试 | `false` | | `HIDE_DOCK_ICON` | macOS 桌面版以 Accessory 激活策略启动,只显示菜单栏图标;设置页保存后管理页面向桌面壳发送 `dock:hide` 或 `dock:show`,桌面壳在主线程切换激活策略 | | `REQUEST_BODY_LOG` | 保存公开 API 的 POST 请求与响应正文,各截断到 64 KiB,只保留最近 1000 条;保存后立即生效 | `false` | @@ -208,9 +208,9 @@ Camoufox 由 Go 通过 WebDriver BiDi 直接管理。启动数据面时,服务 | `ADMIN_AUTH_ENABLED` | 管理员账号密码登录开关 | `false` | | `ADMIN_USERNAME` | 管理员账号 | `admin` | | `ADMIN_PASSWORD` | 管理员密码,开启登录时必填 | 空 | -| `ENCRYPT_CREDENTIALS` | 用 Windows DPAPI 加密保存账户认证状态与管理会话文件;仅 Windows 支持,开启后文件不能跨机器或用户复制 | `false` | +| `ENCRYPT_CREDENTIALS` | 加密账户认证状态与管理会话。Windows 用 DPAPI。其他系统用 AES-256-GCM,并要求进程环境变量 `CREDENTIALS_KEY`(32 字节十六进制或标准 Base64,不写回 `.env`)或数据目录之外、权限 0600 的 `CREDENTIALS_KEY_FILE`。密钥缺失时拒绝启动且不自动生成。开启后不在启动时改写已有 auth 文件,只在该文件下次保存时加密;密钥丢失时解密失败且不改写密文 | `false` | | `TRUSTED_PROXY_CIDRS` | 可信反向代理的逗号分隔 CIDR;仅这些来源的 `X-Forwarded-Proto` 可影响管理会话 Cookie 的 Secure 属性 | 空 | -| `PROXY` | setup 与未设置账户代理时使用的出口,多个代理用逗号分隔组成代理池;留空时生成服务使用系统代理 | 空 | +| `PROXY` | setup 与未设置账户代理时使用的出口,多个代理用逗号分隔组成代理池;留空时生成服务使用系统代理。外部媒体下载和 Interactions webhook 不走这里,见下方出站说明 | 空 | | `INIT_TIMEOUT` | 单账户初始化超时 | `2m` | | `REQUEST_TIMEOUT` | 单次请求最大执行时间,自动续写的每一轮开始时重新计时 | `30m` | | `WARM_WORKER_LIMIT` | 常驻预热账户数 | `10`,Camoufox 后端 `5` | @@ -222,7 +222,7 @@ Camoufox 由 Go 通过 WebDriver BiDi 直接管理。启动数据面时,服务 | `MODEL_MAPPING` | 逗号分隔,每项以最后一个 `:` 分成别名与模型 ID | 空 | | `MODEL_CHANNELS` | 逗号分隔,每项以最后一个 `:` 分成模型 ID 与通道 `playground` 或 `build`;保存后立即生效 | 空 | | `BUILD_NATIVE_NONSTREAM` | 启用 Build 通道时非流式请求优先选择 Build 原生单次调用 | `true` | -| `NONSTREAM_KEEPALIVE` | 非流式请求等待结果期间每 10 秒写出一个换行 | `true` | +| `NONSTREAM_KEEPALIVE` | 默认关闭,非流式响应保持真实 HTTP 状态。显式设为 true 时,等待超过 10 秒先提交 200 并每 10 秒写一个换行;此后的 429 或认证失败只出现在正文和访问日志里 | `false` | | `FAKE_STREAM` | 流式请求按非流式取得完整结果后再以流式输出 | `false` | | `YOUTUBE_LINKS` | 用户消息文本中的 YouTube 链接作为视频外部媒体发送 | `false` | | `STRIP_THOUGHT_SIGNATURES` | 发往上游前去掉对话历史中的思考签名,函数调用带 `skip_thought_signature_validator`;保存后停止再启动生成服务生效 | `false` | diff --git a/docs/protocol.md b/docs/protocol.md index 6e0bf620..0d48024b 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -766,7 +766,8 @@ generation config、安全设置、工具与转写配置在选择账户前按各 - 流式请求在写出响应头前等待首个事件:参数错误与首个事件之前的上游错误按非流式返回 HTTP 状态与错误对象;10 秒内没有事件时先写出响应头,此后的错误以各协议的流内错误事件返回 - 非流式请求收齐上游事件后再输出,上游流中途中断或在完成帧之前结束时换号重发 -- `NONSTREAM_KEEPALIVE=true` 时,Chat Completions、Responses、Messages、Gemini `generateContent`、Interactions 与图片生成的非流式请求在 10 秒内没有结果时先写出 `200`、`Content-Type: application/json` 与 `X-Accel-Buffering: no`,之后每 10 秒写出一个换行,结果就绪后写出 JSON 正文;此后的错误以各协议的错误对象作为正文,访问日志与请求账本记录错误的 HTTP 状态 +- `NONSTREAM_KEEPALIVE` 默认 `false`。非流式请求在结果就绪前不提交响应头,429、认证失败和其他错误仍以对应的 HTTP 状态返回 +- `NONSTREAM_KEEPALIVE=true` 时,Chat Completions、Responses、Messages、Gemini `generateContent`、Interactions 与图片生成的非流式请求在 10 秒内没有结果时先写出 `200`、`Content-Type: application/json` 与 `X-Accel-Buffering: no`,之后每 10 秒写出一个换行,结果就绪后写出 JSON 正文;此后的错误以各协议的错误对象作为正文,访问日志与请求账本记录错误的 HTTP 状态。这是为反代保活做出的让步,只在显式开启时发生 - `FAKE_STREAM=true` 时,上述协议的流式请求按非流式方式生成:收齐上游事件、中途中断时换号重发,再以各协议的流式事件输出;等待期间按下文的流式心跳保持连接 错误响应根形状为 `[null,[code,message,...]]`。输入被上游拒绝时帧为 `[null,[block_reason,null,message]]`: @@ -2140,7 +2141,7 @@ delta 联合类型为 `text_delta{text}`、`citations_delta{citation}`、`thinki 音频 `mime_type` 为 `audio/wav`、`audio/l16`、`audio/mp3`、`audio/ogg_opus`、`audio/alaw` 或 `audio/mulaw`,非流式默认 `audio/wav`,流式默认 `audio/l16`。TTS 上游为 24 kHz、16-bit 小端、单声道 PCM,Lyria 等返回的 MP3 先解码为 PCM;请求 `audio/mp3` 且未改采样率、未指定 `bit_rate` 时原样返回上游 MP3。`sample_rate` 省略时使用上游采样率,指定时重采样;MP3 取 32、44.1、48 kHz,Ogg Opus 取 8、12、16、24、48 kHz,均为不低于目标采样率的最小值,超出时取最高值。`bit_rate` 以 bit/s 计,用于 MP3(CBR 32000–320000 取最接近值,默认 192000)与 Opus。输出内容的 `mime_type` 依次为 `audio/wav`、`audio/l16`、`audio/mp3`、`audio/ogg`、`audio/alaw`、`audio/mulaw`,`sample_rate` 与 `channels` 为实际值。上游为 PCM 且不重采样时,`audio/l16`、`audio/alaw`、`audio/mulaw` 逐块输出,其余情况在音频汇总完成后发送一个完整块。请求同时含文本与音频而模型只能生成文本时按文本生成。`delivery` 取任意值时都内联返回。创建请求在当前连接内执行;非流式 `background:true` 时先返回 `in_progress` 的 interaction 并关闭连接,生成在服务内继续,终态可经检索读取。 -`webhook_config:{uris,user_metadata}` 随 `background:true` 使用,否则返回 `400 INVALID_ARGUMENT`,最多 8 个 `uris`,每个必须是解析到公网 IP 的 http 或 https 地址。后台交互结束时服务向每个地址 POST `{type,version:"v1",timestamp,data:{id},user_metadata}`,请求头带 `webhook-id` 与 `webhook-timestamp`:`type` 按终态为 `interaction.completed`(含 `incomplete`)、`interaction.requires_action`、`interaction.failed`(`data` 另有 `error_code` 与 `error_message`)或 `interaction.cancelled`;应答不是 2xx 或 10 秒内没有应答时,从 1 秒起按倍数退避(间隔最长 1 小时)重试 24 小时。 +`webhook_config:{uris,user_metadata}` 随 `background:true` 使用,否则返回 `400 INVALID_ARGUMENT`,最多 8 个 `uris`,每个必须是解析到公网 IP 的 http 或 https 地址。投递不经过 `PROXY`:目标在每次连接前重新解析,并拒绝私网、链路本地和保留地址,避免调用方配置的正向代理绕过这道限制。后台交互结束时服务向每个地址 POST `{type,version:"v1",timestamp,data:{id},user_metadata}`,请求头带 `webhook-id` 与 `webhook-timestamp`:`type` 按终态为 `interaction.completed`(含 `incomplete`)、`interaction.requires_action`、`interaction.failed`(`data` 另有 `error_code` 与 `error_message`)或 `interaction.cancelled`;应答不是 2xx 或 10 秒内没有应答时,从 1 秒起按倍数退避(间隔最长 1 小时)重试 24 小时。 非流式响应包含 `id`、`object:"interaction"`、`model`、`created`、`updated`、`status`、`steps` 与 `usage`,以 `previous_interaction_id` 续接时同时带该字段。`steps` 的 `model_output.content` 保存文本或媒体,带引用的文本内容含 `annotations`,每项为 `{type:"url_citation",url,title,start_index,end_index}`,下标为该段文本中的字节位置;音频位于 `{type:"audio",data,mime_type,sample_rate,channels}`;SDK 的 `output_audio` 与 `output_text` 从这些步骤读取。正文、图片与函数调用携带的思考签名作为其前 `thought` 步骤的 `signature` 输出,按步骤回传时签名回到其后第一个模型 Part。函数调用作为 `function_call` 步骤返回,状态为 `requires_action`;正常生成与命中停止序列的状态为 `completed`,输出限额与内容拦截的状态为 `incomplete`。`usage` 包含 `total_input_tokens`、`total_cached_tokens`、`total_output_tokens`、`total_thought_tokens`、`total_tool_use_tokens` 与 `total_tokens`;上游给出模态细分时另有 `input_tokens_by_modality`、`cached_tokens_by_modality`、`output_tokens_by_modality`、`tool_use_tokens_by_modality`,元素为 `{modality,tokens}`,`modality` 为小写模态名;使用 Google Search 或 Google Maps 时 `grounding_tool_count` 按 `{type:"google_search"|"google_maps",count}` 给出调用次数。 diff --git a/internal/aistudio/accounts.go b/internal/aistudio/accounts.go index 55212240..121266c6 100644 --- a/internal/aistudio/accounts.go +++ b/internal/aistudio/accounts.go @@ -2868,6 +2868,11 @@ func (p *AccountPool) selectionIndicesLocked(selection AccountSelection) ([]int, if !exists { return nil, ErrResourceNotFound } + if allowed != nil && !accountAllowed(allowed, owner) { + // 资源属于当前 key 不可用的账户时,与资源不存在使用同一错误。 + // 先于账户不匹配的错误返回,避免把资源属主的账户 ID 带进错误消息。 + return nil, fmt.Errorf("%w: %s", ErrResourceNotFound, strings.TrimSpace(selection.ResourceID)) + } if accountID != "" && accountID != owner { return nil, fmt.Errorf("资源 %s 绑定账户 %s", selection.ResourceID, owner) } @@ -2878,6 +2883,10 @@ func (p *AccountPool) selectionIndicesLocked(selection AccountSelection) ([]int, if account.ID == accountID { if allowed != nil { if _, exists := allowed[accountID]; !exists { + // 资源属于当前 key 不可用的账户时,与资源不存在使用同一错误,避免泄漏资源是否存在。 + if strings.TrimSpace(selection.ResourceID) != "" { + return nil, fmt.Errorf("%w: %s", ErrResourceNotFound, strings.TrimSpace(selection.ResourceID)) + } return nil, ErrNoEligibleAccount } } diff --git a/internal/aistudio/auth.go b/internal/aistudio/auth.go index 6e8967d3..4b0bf8e3 100644 --- a/internal/aistudio/auth.go +++ b/internal/aistudio/auth.go @@ -159,7 +159,7 @@ func LoadStorageState(filePath string) (StorageState, error) { return state, nil } -// WriteStorageState 原子写回 Playwright storage state,启用凭证加密时以 DPAPI 密文保存 +// WriteStorageState 原子写回 Playwright storage state。启用凭证加密时写成平台密文;未启用时保持明文。读取方对两种内容都能解密,启动时不批量改写已有文件。 func WriteStorageState(filePath string, state StorageState) error { if err := state.Validate(); err != nil { return err diff --git a/internal/aistudio/channel.go b/internal/aistudio/channel.go index a96188ce..fa830899 100644 --- a/internal/aistudio/channel.go +++ b/internal/aistudio/channel.go @@ -68,6 +68,23 @@ func (p *AccountPool) SetAPIKeyAccounts(accounts map[string][]string) { p.notifyLocked() } +// apiKeyScope 返回请求 API key 是否绑定了账户,以及允许的账户集合。 +// 上下文没有 key,或该 key 没有出现在 API_KEY_ACCOUNTS 中时,bound 为 false,调用方保持“可用全部账户”的行为。 +// 绑定到空列表时 bound 为 true 且 allowed 为空,任何账户都不可见。 +func (p *AccountPool) apiKeyScope(ctx context.Context) (bound bool, allowed map[string]struct{}) { + selection := p.keyScopedSelection(ctx, AccountSelection{}) + if selection.AllowedAccountIDs == nil { + return false, nil + } + allowed = make(map[string]struct{}, len(selection.AllowedAccountIDs)) + for _, accountID := range selection.AllowedAccountIDs { + if accountID = strings.TrimSpace(accountID); accountID != "" { + allowed[accountID] = struct{}{} + } + } + return true, allowed +} + // keyScopedSelection 把请求所用 API key 绑定的账户并入选择的允许账户 func (p *AccountPool) keyScopedSelection(ctx context.Context, selection AccountSelection) AccountSelection { key, _ := ctx.Value(apiKeyContextKey{}).(string) diff --git a/internal/aistudio/files_scope_test.go b/internal/aistudio/files_scope_test.go new file mode 100644 index 00000000..eb00ab25 --- /dev/null +++ b/internal/aistudio/files_scope_test.go @@ -0,0 +1,218 @@ +package aistudio + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func scopedFileAccount(id, fileID string, created time.Time) *Account { + return &Account{ + ID: id, + State: AccountReady, + Config: AccountConfig{Enabled: true, Label: id}, + runtime: accountRuntimeState{Resources: map[string]ResourceBinding{ + fileID: {Kind: "drive-file", Name: fileID + ".txt", MIME: "text/plain", Size: 8, Purpose: "user", CreatedAt: created}, + }}, + } +} + +func fileIDs(files []FileMetadata) []string { + ids := make([]string, 0, len(files)) + for _, file := range files { + ids = append(ids, file.ID) + } + return ids +} + +// TestFileAPIKeyScope 验证列表、详情和按资源选号都限制在当前 key 的账户内。 +// 跨 key 的文件与不存在的文件同样是 ErrResourceNotFound,错误里不出现对方账户。未绑定的 key 仍可看到全部账户。 +func TestFileAPIKeyScope(t *testing.T) { + accountA := scopedFileAccount("a@example.com", "file-a", time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC)) + accountB := scopedFileAccount("b@example.com", "file-b", time.Date(2024, 1, 3, 0, 0, 0, 0, time.UTC)) + pool := NewAccountPool([]*Account{accountA, accountB}, 2) + pool.SetAPIKeyAccounts(map[string][]string{ + "key-a": {"a@example.com"}, + "key-b": {"b@example.com"}, + "key-none": {}, + }) + + ctxA := ContextWithAPIKey(context.Background(), "key-a") + files, err := pool.ListFiles(ctxA) + if err != nil || strings.Join(fileIDs(files), ",") != "file-a" { + t.Fatalf("key-a 列表 = %v, %v", fileIDs(files), err) + } + metadata, err := pool.FileMetadata(ctxA, "file-a") + if err != nil || metadata.ID != "file-a" || metadata.Name != "file-a.txt" { + t.Fatalf("key-a 读取自己的文件 = %+v, %v", metadata, err) + } + _, err = pool.FileMetadata(ctxA, "file-b") + if !errors.Is(err, ErrResourceNotFound) || strings.Contains(err.Error(), "b@example.com") { + t.Fatalf("跨 key 详情应表现为不存在,得到 %v", err) + } + if _, err := pool.AcquireFor(ctxA, AccountSelection{ResourceID: "file-b"}); !errors.Is(err, ErrResourceNotFound) || errors.Is(err, ErrNoEligibleAccount) { + t.Fatalf("跨 key 下载选号应表现为不存在,得到 %v", err) + } + lease, err := pool.AcquireFor(ctxA, AccountSelection{ResourceID: "file-a"}) + if err != nil || lease.Account().ID != accountA.ID { + t.Fatalf("key-a 应能选中自己的文件账户: %v", err) + } + if err := lease.Release(); err != nil { + t.Fatal(err) + } + + ctxB := ContextWithAPIKey(context.Background(), "key-b") + files, err = pool.ListFiles(ctxB) + if err != nil || strings.Join(fileIDs(files), ",") != "file-b" { + t.Fatalf("key-b 列表 = %v, %v", fileIDs(files), err) + } + + files, err = pool.ListFiles(context.Background()) + if err != nil || len(files) != 2 { + t.Fatalf("未带 key 的调用应看到全部文件: %v %v", fileIDs(files), err) + } + files, err = pool.ListFiles(ContextWithAPIKey(context.Background(), "key-other")) + if err != nil || len(files) != 2 { + t.Fatalf("未绑定的 key 应看到全部文件: %v %v", fileIDs(files), err) + } + files, err = pool.ListFiles(ContextWithAPIKey(context.Background(), "key-none")) + if err != nil || len(files) != 0 { + t.Fatalf("没有账户的 key 不应看到文件: %v %v", fileIDs(files), err) + } + if _, err := pool.FileMetadata(ContextWithAPIKey(context.Background(), "key-none"), "file-a"); !errors.Is(err, ErrResourceNotFound) { + t.Fatalf("空绑定 key 的详情 = %v", err) + } +} + +// TestDeletedAccountFilesDisappear 验证账户目录删除后,列表和详情都不再返回该账户的文件。 +func TestDeletedAccountFilesDisappear(t *testing.T) { + root := t.TempDir() + directory := filepath.Join(root, "gone@example.com") + if err := os.MkdirAll(directory, 0o700); err != nil { + t.Fatal(err) + } + for _, name := range []string{"account.json", "storage-state.json"} { + if err := os.WriteFile(filepath.Join(directory, name), []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + } + state := accountRuntimeState{Resources: map[string]ResourceBinding{ + "file-gone": {Kind: "drive-file", Name: "gone.txt", MIME: "text/plain", Size: 4, Purpose: "user", CreatedAt: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC)}, + }} + encoded, err := json.Marshal(state) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, runtimeStateName), append(encoded, '\n'), 0o600); err != nil { + t.Fatal(err) + } + deleted := &Account{ + ID: directoryBase(directory), Directory: directory, + ConfigPath: filepath.Join(directory, accountConfigName), StoragePath: filepath.Join(directory, storageStateName), + RuntimePath: filepath.Join(directory, runtimeStateName), State: AccountReady, Config: AccountConfig{Enabled: true, Label: "gone@example.com"}, + } + kept := scopedFileAccount("keep@example.com", "file-keep", time.Date(2024, 2, 2, 0, 0, 0, 0, time.UTC)) + pool := NewAccountPool([]*Account{deleted, kept}, 1) + + files, err := pool.ListFiles(context.Background()) + if err != nil || !containsFile(files, "file-gone") || !containsFile(files, "file-keep") { + t.Fatalf("删除前列表 = %v, %v", fileIDs(files), err) + } + if err := os.RemoveAll(directory); err != nil { + t.Fatal(err) + } + files, err = pool.ListFiles(context.Background()) + if err != nil || containsFile(files, "file-gone") || !containsFile(files, "file-keep") { + t.Fatalf("删除账户后列表 = %v, %v", fileIDs(files), err) + } + if _, err := pool.FileMetadata(context.Background(), "file-gone"); !errors.Is(err, ErrResourceNotFound) || strings.Contains(err.Error(), "keep@example.com") { + t.Fatalf("已删除账户的文件详情 = %v", err) + } +} + +// corruptRuntimeAccount 返回一个 runtime-state 文件内容损坏、名下绑定有 fileID 资源的账户, +// 任何对它的刷新都会失败,用于验证越权探测不会触发或泄漏刷新错误 +func corruptRuntimeAccount(t *testing.T, id, fileID string) *Account { + t.Helper() + directory := t.TempDir() + for _, name := range []string{"account.json", "storage-state.json"} { + if err := os.WriteFile(filepath.Join(directory, name), []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(directory, runtimeStateName), []byte("not-json"), 0o600); err != nil { + t.Fatal(err) + } + return &Account{ + ID: id, Directory: directory, State: AccountReady, + ConfigPath: filepath.Join(directory, accountConfigName), StoragePath: filepath.Join(directory, storageStateName), + RuntimePath: filepath.Join(directory, runtimeStateName), Config: AccountConfig{Enabled: true, Label: id}, + runtime: accountRuntimeState{Resources: map[string]ResourceBinding{ + fileID: {Kind: "drive-file", Name: fileID + ".txt", MIME: "text/plain", Size: 8, Purpose: "user", + CreatedAt: time.Date(2024, 1, 4, 0, 0, 0, 0, time.UTC)}, + }}, + } +} + +// TestFileMetadataScopeSkipsRefresh 验证受限 key 对范围外与不存在的文件都在刷新之前返回 ErrResourceNotFound: +// 即使某个账户的 runtime 刷新必然失败,错误也不会作为区分存在性的信号返回。 +func TestFileMetadataScopeSkipsRefresh(t *testing.T) { + accountA := scopedFileAccount("a@example.com", "file-a", time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC)) + corrupt := corruptRuntimeAccount(t, "broken@example.com", "file-broken") + pool := NewAccountPool([]*Account{accountA, corrupt}, 2) + pool.SetAPIKeyAccounts(map[string][]string{"key-a": {"a@example.com"}}) + ctx := ContextWithAPIKey(context.Background(), "key-a") + + // 不存在的文件:刷新全部账户必然失败,但受限 key 拿到的仍是 ErrResourceNotFound,而不是刷新错误。 + if _, err := pool.FileMetadata(ctx, "file-missing"); !errors.Is(err, ErrResourceNotFound) { + t.Fatalf("不存在的文件应返回 ErrResourceNotFound,得到 %v", err) + } + // 范围外且属主刷新必然失败:同样在刷新之前返回不存在。 + if _, err := pool.FileMetadata(ctx, "file-broken"); !errors.Is(err, ErrResourceNotFound) { + t.Fatalf("范围外的文件应返回 ErrResourceNotFound,得到 %v", err) + } + // 未绑定的 key 保持旧行为:刷新错误按原样返回。 + if _, err := pool.FileMetadata(context.Background(), "file-missing"); err == nil || errors.Is(err, ErrResourceNotFound) { + t.Fatalf("未绑定 key 的刷新错误应按原样返回,得到 %v", err) + } +} + +// TestSelectionResourceScopeHidesOwner 验证受限 key 带上账户与资源 ID 选号时, +// 资源属于范围外账户的话返回 ErrResourceNotFound,错误消息不出现属主账户。 +func TestSelectionResourceScopeHidesOwner(t *testing.T) { + accountA := scopedFileAccount("a@example.com", "file-a", time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC)) + accountB := scopedFileAccount("b@example.com", "file-b", time.Date(2024, 1, 3, 0, 0, 0, 0, time.UTC)) + pool := NewAccountPool([]*Account{accountA, accountB}, 2) + pool.SetAPIKeyAccounts(map[string][]string{"key-a": {"a@example.com"}}) + + ctx := ContextWithAPIKey(context.Background(), "key-a") + selection := AccountSelection{AccountID: "a@example.com", ResourceID: "file-b"} + lease, err := pool.AcquireFor(ctx, selection) + if lease != nil { + if releaseErr := lease.Release(); releaseErr != nil { + t.Fatal(releaseErr) + } + } + if !errors.Is(err, ErrResourceNotFound) { + t.Fatalf("范围外资源应返回 ErrResourceNotFound,得到 %v", err) + } + if err != nil && (strings.Contains(err.Error(), "b@example.com") || strings.Contains(err.Error(), "绑定账户")) { + t.Fatalf("错误消息不应泄漏属主账户: %v", err) + } +} + +func directoryBase(directory string) string { return filepath.Base(directory) } + +func containsFile(files []FileMetadata, id string) bool { + for _, file := range files { + if file.ID == id { + return true + } + } + return false +} diff --git a/internal/aistudio/upload.go b/internal/aistudio/upload.go index bb5a6393..1f8f3f7f 100644 --- a/internal/aistudio/upload.go +++ b/internal/aistudio/upload.go @@ -557,28 +557,57 @@ func (p *AccountPool) bindFileResource(ctx context.Context, file FileRef, accoun return err } -// FileMetadata 返回 runtime-state 中的公开文件元数据 +// FileMetadata 返回 runtime-state 中的公开文件元数据。 +// 当前 API key 绑定了账户时,只返回这些账户名下的文件;其他文件与不存在的文件都是 ErrResourceNotFound。 +// 判断在刷新之前完成:越权探测不触发属主账户刷新,也不把刷新错误当成区分存在性的信号返回; +// 刷新后再次核对归属,资源中途改绑到范围外账户时同样返回不存在。 +// 未绑定的 key 保持可见全部账户,维持先刷新再查询的旧行为。 func (p *AccountPool) FileMetadata(ctx context.Context, fileID string) (FileMetadata, error) { fileID = strings.TrimSpace(fileID) if fileID == "" { return FileMetadata{}, fmt.Errorf("%w: 文件 ID 为空", ErrResourceNotFound) } - if err := p.refreshResource(ctx, fileID); err != nil { - return FileMetadata{}, err + bound, allowed := p.apiKeyScope(ctx) + if bound && !p.resourceInScope(fileID, allowed) { + return FileMetadata{}, fmt.Errorf("%w: %s", ErrResourceNotFound, fileID) } + refreshErr := p.refreshResource(ctx, fileID) p.mu.Lock() - defer p.mu.Unlock() accountID, exists := p.resources[fileID] - if !exists { + if bound && (!exists || !accountAllowed(allowed, accountID)) { + p.mu.Unlock() return FileMetadata{}, fmt.Errorf("%w: %s", ErrResourceNotFound, fileID) } - binding, exists := p.byID[accountID].runtime.Resources[fileID] + var binding ResourceBinding + if exists { + if account := p.byID[accountID]; account != nil { + binding = account.runtime.Resources[fileID] + } + } + p.mu.Unlock() + if refreshErr != nil { + return FileMetadata{}, refreshErr + } if !exists || binding.Kind != "drive-file" || binding.Name == "" || binding.MIME == "" || binding.Size <= 0 || binding.Purpose == "" { return FileMetadata{}, fmt.Errorf("%w: %s", ErrResourceNotFound, fileID) } return binding.fileMetadata(fileID), nil } +// resourceInScope 返回资源当前是否绑定在 allowed 名下的账户上 +func (p *AccountPool) resourceInScope(fileID string, allowed map[string]struct{}) bool { + p.mu.Lock() + defer p.mu.Unlock() + accountID, exists := p.resources[fileID] + return exists && accountAllowed(allowed, accountID) +} + +// accountAllowed 返回账户是否在允许集合中 +func accountAllowed(allowed map[string]struct{}, accountID string) bool { + _, ok := allowed[accountID] + return ok +} + // fileMetadata 返回绑定记录的公开文件元数据 func (binding ResourceBinding) fileMetadata(fileID string) FileMetadata { return FileMetadata{ @@ -587,7 +616,7 @@ func (binding ResourceBinding) fileMetadata(fileID string) FileMetadata { } } -// ListFiles 返回全部公开上传文件的持久元数据 +// ListFiles 返回当前 API key 可见的公开上传文件。未绑定账户的 key 仍返回全部文件。 func (s *PooledService) ListFiles(ctx context.Context) ([]FileMetadata, error) { return s.pool.ListFiles(ctx) } @@ -608,15 +637,22 @@ func (p *AccountPool) refreshAllRuntimes(ctx context.Context) error { return errors.Join(failures...) } -// ListFiles 刷新全部账户 runtime-state 后返回公开上传文件,按创建时间从新到旧排列 +// ListFiles 刷新全部账户 runtime-state 后返回公开上传文件,按创建时间从新到旧排列。 +// 已绑定账户的 API key 只能看到这些账户的文件;未绑定的 key 仍看到全部账户。已删除账户的文件会在刷新时移除。 func (p *AccountPool) ListFiles(ctx context.Context) ([]FileMetadata, error) { if err := p.refreshAllRuntimes(ctx); err != nil { return nil, err } + bound, allowed := p.apiKeyScope(ctx) p.mu.Lock() defer p.mu.Unlock() files := []FileMetadata{} for fileID, accountID := range p.resources { + if bound { + if _, visible := allowed[accountID]; !visible { + continue + } + } binding := p.byID[accountID].runtime.Resources[fileID] if binding.Kind != "drive-file" || binding.Name == "" || binding.MIME == "" || binding.Size <= 0 || binding.Purpose == "" { continue diff --git a/internal/api/keepalive.go b/internal/api/keepalive.go index 7fdd02dc..c5997081 100644 --- a/internal/api/keepalive.go +++ b/internal/api/keepalive.go @@ -8,10 +8,14 @@ import ( "time" ) -// keepaliveWriter 在非流式响应写出前每隔心跳间隔写出一个换行,首个换行以 200 与 JSON 类型提交响应头,状态码只记入访问日志 +// nonstreamKeepaliveInterval 是非流式保活第一次写出和后续间隔。测试可以改短,避免真实等待 10 秒。 +var nonstreamKeepaliveInterval = streamHeartbeatInterval + +// keepaliveWriter 在非流式响应写出前每隔心跳间隔写出一个换行。首个换行会以 200 提交响应头,之后的真实状态码只能记入访问日志。 type keepaliveWriter struct { http.ResponseWriter ctx context.Context + interval time.Duration mu sync.Mutex timer *time.Timer committed bool @@ -26,13 +30,19 @@ func (s *server) responseModes() ResponseModes { return s.config.ResponseModes() } -// nonstreamKeepalive 在开启非流式保活时返回带保活的 ResponseWriter,以及在处理函数返回前调用的停止函数 +// nonstreamKeepalive 在显式开启非流式保活时返回带保活的 ResponseWriter,以及在处理函数返回前调用的停止函数。 +// 默认关闭:等待期间不提交响应头,429、认证失败等仍按真实 HTTP 状态返回。 +// 开启后,间隔到达时先写出 200,此后的失败状态不再出现在 HTTP 状态码里。 func (s *server) nonstreamKeepalive(w http.ResponseWriter, r *http.Request) (http.ResponseWriter, func()) { if !s.responseModes().NonstreamKeepalive { return w, func() {} } - writer := &keepaliveWriter{ResponseWriter: w, ctx: r.Context()} - writer.timer = time.AfterFunc(streamHeartbeatInterval, writer.beat) + interval := nonstreamKeepaliveInterval + if interval <= 0 { + interval = streamHeartbeatInterval + } + writer := &keepaliveWriter{ResponseWriter: w, ctx: r.Context(), interval: interval} + writer.timer = time.AfterFunc(interval, writer.beat) return writer, writer.stop } @@ -55,7 +65,7 @@ func (writer *keepaliveWriter) beat() { if http.NewResponseController(writer.ResponseWriter).Flush() != nil { return } - writer.timer.Reset(streamHeartbeatInterval) + writer.timer.Reset(writer.interval) } // stop 停止保活,之后的写入直接到达底层 ResponseWriter diff --git a/internal/api/keepalive_test.go b/internal/api/keepalive_test.go new file mode 100644 index 00000000..c1a30705 --- /dev/null +++ b/internal/api/keepalive_test.go @@ -0,0 +1,151 @@ +package api + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func withKeepaliveInterval(t *testing.T, interval time.Duration) { + t.Helper() + previous := nonstreamKeepaliveInterval + nonstreamKeepaliveInterval = interval + t.Cleanup(func() { nonstreamKeepaliveInterval = previous }) +} + +func newKeepaliveServer(enabled bool) *server { + return &server{config: Config{ResponseModes: func() ResponseModes { + return ResponseModes{NonstreamKeepalive: enabled} + }}} +} + +type captureResponse struct { + header http.Header + status int + wroteHeader bool + body bytes.Buffer +} + +func (writer *captureResponse) Header() http.Header { + if writer.header == nil { + writer.header = make(http.Header) + } + return writer.header +} + +func (writer *captureResponse) WriteHeader(status int) { + if writer.wroteHeader { + return + } + writer.wroteHeader = true + writer.status = status +} + +func (writer *captureResponse) Write(data []byte) (int, error) { + if !writer.wroteHeader { + writer.WriteHeader(http.StatusOK) + } + return writer.body.Write(data) +} + +func (writer *captureResponse) Flush() {} + +func runKeepalive(enabled bool, request *http.Request, respond func(http.ResponseWriter, *http.Request)) *captureResponse { + recorder := &captureResponse{} + server := newKeepaliveServer(enabled) + writer, stop := server.nonstreamKeepalive(recorder, request) + defer stop() + respond(writer, request) + return recorder +} + +func TestNonstreamKeepaliveSlowResponses(t *testing.T) { + withKeepaliveInterval(t, 20*time.Millisecond) + slow := 300 * time.Millisecond + + t.Run("default failure keeps status", func(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + metadata := &accessLogMetadata{} + request = request.WithContext(context.WithValue(request.Context(), accessLogContextKey{}, metadata)) + response := runKeepalive(false, request, func(writer http.ResponseWriter, _ *http.Request) { + time.Sleep(slow) + writer.WriteHeader(http.StatusTooManyRequests) + _, _ = writer.Write([]byte(`{"error":"rate limit"}`)) + }) + if !response.wroteHeader || response.status != http.StatusTooManyRequests { + t.Fatalf("默认保活关闭时慢失败状态 = wrote:%v status:%d body:%q", response.wroteHeader, response.status, response.body.Bytes()) + } + if metadata.failureStatus != 0 { + t.Fatalf("未提前提交 200 时不应改写访问日志状态: %d", metadata.failureStatus) + } + }) + + t.Run("default success", func(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + response := runKeepalive(false, request, func(writer http.ResponseWriter, _ *http.Request) { + time.Sleep(slow) + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write([]byte(`{"ok":true}`)) + }) + if response.status != http.StatusOK || response.body.String() != `{"ok":true}` { + t.Fatalf("慢成功 = %d %q", response.status, response.body.String()) + } + }) + + t.Run("enabled fast failure keeps status", func(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + response := runKeepalive(true, request, func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(http.StatusUnauthorized) + _, _ = writer.Write([]byte(`{"error":"auth"}`)) + }) + if response.status != http.StatusUnauthorized || !bytes.Contains(response.body.Bytes(), []byte("auth")) { + t.Fatalf("间隔到达前的认证失败 = %d %q", response.status, response.body.String()) + } + }) + + t.Run("enabled slow failure commits 200", func(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + metadata := &accessLogMetadata{} + request = request.WithContext(context.WithValue(request.Context(), accessLogContextKey{}, metadata)) + response := runKeepalive(true, request, func(writer http.ResponseWriter, _ *http.Request) { + time.Sleep(slow) + writer.WriteHeader(http.StatusTooManyRequests) + _, _ = writer.Write([]byte(`{"error":"rate limit"}`)) + }) + if response.status != http.StatusOK || !bytes.Contains(response.body.Bytes(), []byte(`{"error":"rate limit"}`)) { + t.Fatalf("显式保活的慢失败 = %d %q", response.status, response.body.String()) + } + if metadata.failureStatus != http.StatusTooManyRequests { + t.Fatalf("访问日志状态 = %d", metadata.failureStatus) + } + }) + + t.Run("enabled slow success", func(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + response := runKeepalive(true, request, func(writer http.ResponseWriter, _ *http.Request) { + time.Sleep(slow) + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write([]byte(`{"ok":true}`)) + }) + if response.status != http.StatusOK || !bytes.Contains(response.body.Bytes(), []byte(`{"ok":true}`)) { + t.Fatalf("显式保活的慢成功 = %d %q", response.status, response.body.String()) + } + }) +} + +func TestNonstreamKeepaliveCancelBeforeCommit(t *testing.T) { + withKeepaliveInterval(t, 5*time.Second) + ctx, cancel := context.WithCancel(context.Background()) + request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil).WithContext(ctx) + response := runKeepalive(true, request, func(_ http.ResponseWriter, current *http.Request) { + cancel() + <-current.Context().Done() + }) + if response.wroteHeader || response.body.Len() != 0 { + t.Fatalf("取消后不应提交保活响应: wrote=%v body=%q", response.wroteHeader, response.body.String()) + } +} diff --git a/internal/api/remote_media.go b/internal/api/remote_media.go index 6c231628..40e17569 100644 --- a/internal/api/remote_media.go +++ b/internal/api/remote_media.go @@ -175,7 +175,11 @@ func validateImagePayload(data []byte) error { return nil } -// publicOutboundTransport 禁用环境代理并在实际拨号前重新解析及验证地址,避免校验后 DNS 重绑定到内网。 +// publicOutboundTransport 供外部媒体下载和 interaction webhook 使用。 +// 它故意不走 PROXY、账户代理或环境变量中的 HTTP(S)_PROXY。这两类请求的目标来自公开 API, +// 必须在每次拨号前重新解析,并且只连接当时判定为公网的地址;每次重定向也会重新检查主机。 +// 如果改成信任调用方配置的正向代理,代理可以访问这里拒绝的私网地址,也可以在校验和连接之间换掉解析结果,DNS 重绑定防护不再成立。 +// 因此没有“可选走 PROXY”的开关。PROXY 只覆盖访问 AI Studio 的账户流量,不是进程的全流量代理。 var publicOutboundTransport = newPublicOutboundTransport() // remoteMediaClient 是下载外部媒体的 HTTP 客户端,每次重定向前检查目标主机。 diff --git a/internal/app/admin.go b/internal/app/admin.go index 838473c1..73216a78 100644 --- a/internal/app/admin.go +++ b/internal/app/admin.go @@ -1044,7 +1044,7 @@ func (admin *runtimeAdmin) UpdateRuntimeConfig(_ context.Context, value api.Runt RequestBodyLog: value.RequestBodyLog, AuthFailurePause: saved.AuthFailurePause, LogFile: value.LogFile, LogMaxTotalMB: value.LogMaxTotalMB, - AdminAuthEnabled: value.AdminAuthEnabled, AdminUsername: strings.TrimSpace(value.AdminUsername), AdminPassword: password, EncryptCredentials: saved.EncryptCredentials, TrustedProxyCIDRs: saved.TrustedProxyCIDRs, + AdminAuthEnabled: value.AdminAuthEnabled, AdminUsername: strings.TrimSpace(value.AdminUsername), AdminPassword: password, EncryptCredentials: saved.EncryptCredentials, CredentialsKeyFile: saved.CredentialsKeyFile, CredentialsKey: append([]byte(nil), saved.CredentialsKey...), TrustedProxyCIDRs: saved.TrustedProxyCIDRs, BuildNativeNonstream: value.BuildNativeNonstream, NonstreamKeepalive: value.NonstreamKeepalive, FakeStream: value.FakeStream, YouTubeLinks: value.YouTubeLinks, SearchModelAliases: value.SearchModelAliases, GoogleSearch: value.GoogleSearch, StripThoughtSignatures: value.StripThoughtSignatures, diff --git a/internal/app/admin_config_test.go b/internal/app/admin_config_test.go index 335c78b4..cb9eaefa 100644 --- a/internal/app/admin_config_test.go +++ b/internal/app/admin_config_test.go @@ -7,7 +7,6 @@ import ( "net/http" "os" "path/filepath" - "runtime" "strings" "testing" "time" @@ -24,8 +23,9 @@ func TestAdminConfig(t *testing.T) { cfg.AdminAuthEnabled = true cfg.AdminUsername = "operator" cfg.AdminPassword = "test-password" - // 凭据加密依赖 Windows DPAPI,非 Windows 平台配置校验会拒绝该选项 - cfg.EncryptCredentials = runtime.GOOS == "windows" + // 非 Windows 的凭据加密需要数据目录之外的密钥。本测试保持关闭,只确认保存设置不会改掉这项。 + cfg.EncryptCredentials = false + cfg.CredentialsKeyFile = "/run/secrets/aistudio-credentials" cfg.PProfAddr = "127.0.0.1:6060" cfg.GeoIPDBPath = "custom-geoip.mmdb" if err := cfg.Save(path); err != nil { @@ -70,8 +70,8 @@ func TestAdminConfig(t *testing.T) { if !saved.AutoStart { t.Fatal("auto_start was not saved") } - if !saved.AuthFailurePause || saved.EncryptCredentials != cfg.EncryptCredentials || saved.PProfAddr != cfg.PProfAddr || saved.GeoIPDBPath != cfg.GeoIPDBPath { - t.Fatalf("environment-only config changed: auth=%v encrypt=%v pprof=%q geoip=%q", saved.AuthFailurePause, saved.EncryptCredentials, saved.PProfAddr, saved.GeoIPDBPath) + if !saved.AuthFailurePause || saved.EncryptCredentials != cfg.EncryptCredentials || saved.CredentialsKeyFile != cfg.CredentialsKeyFile || saved.PProfAddr != cfg.PProfAddr || saved.GeoIPDBPath != cfg.GeoIPDBPath { + t.Fatalf("environment-only config changed: auth=%v encrypt=%v keyfile=%q pprof=%q geoip=%q", saved.AuthFailurePause, saved.EncryptCredentials, saved.CredentialsKeyFile, saved.PProfAddr, saved.GeoIPDBPath) } manager := &runtimeManager{activeManagement: cfg} password := "new-test-password" diff --git a/internal/app/app.go b/internal/app/app.go index f78fc8f3..95727e1e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -70,7 +70,8 @@ func runCommand(args []string, logs *logOutput, executable string) (restart bool return false, err } securefile.Configure(cfg.EncryptCredentials) - securefile.ConfigureKeyPath(filepath.Join("runtime", "credentials.key")) + securefile.ConfigureKey(cfg.CredentialsKey) + securefile.ConfigureKeyPath(cfg.CredentialsKeyFile) if err := logs.set(cfg.LogFile, cfg.LogMaxTotalMB); err != nil { return false, err } diff --git a/internal/app/channels.go b/internal/app/channels.go index e765f320..bb1de611 100644 --- a/internal/app/channels.go +++ b/internal/app/channels.go @@ -1,6 +1,7 @@ package app import ( + "log/slog" "slices" "github.com/Mag1cFall/AIStudio2API/internal/aistudio" @@ -39,6 +40,11 @@ func apiKeyRateLimits(keys, bindings string) map[string]api.KeyRateLimit { // apiKeyAccounts 解析 API_KEY_ACCOUNTS 为各 API key 可用的账户 ID;active 是管理服务启动时的配置,已从 keys 中移除而管理服务仍接受的 key 沿用 active 中的绑定 func apiKeyAccounts(keys, bindings string, active config.Config) map[string][]string { + if unbound := config.UnboundAPIKeys(keys, bindings); len(unbound) > 0 { + // 未列入 API_KEY_ACCOUNTS 的 key 不受账户范围约束,可以访问全部账户与文件。 + // 不回显 key 内容,只提示数量,操作员可在 .env 里比对两个配置项。 + slog.Warn("API_KEY_ACCOUNTS 未覆盖全部 API key", "unbound_keys", len(unbound), "hint", "未列出的 key 可访问全部账户与文件") + } accounts, _ := config.ParseAPIKeyAccounts(bindings, keys) previous, _ := config.ParseAPIKeyAccounts(active.APIKeyAccounts, active.ProxyAPIKey) for key, ids := range previous { diff --git a/internal/config/config.go b/internal/config/config.go index a2c4bf35..5087fce2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,6 +3,8 @@ package config import ( "bufio" "bytes" + "encoding/base64" + "encoding/hex" "fmt" "net" "net/netip" @@ -80,6 +82,8 @@ var configKeys = [...]string{ "ADMIN_USERNAME", "ADMIN_PASSWORD", "ENCRYPT_CREDENTIALS", + "CREDENTIALS_KEY_FILE", + "CREDENTIALS_KEY", "TRUSTED_PROXY_CIDRS", "PPROF_ADDR", "GEOIP_DB_PATH", @@ -132,16 +136,21 @@ const DefaultContinuationPrompt = "Continue the assistant response after the fol // Config 保存服务的全局配置 type Config struct { - AutoStart bool - HideDockIcon bool - RequestBodyLog bool - AuthFailurePause bool - LogFile bool - LogMaxTotalMB int - AdminAuthEnabled bool - AdminUsername string - AdminPassword string - EncryptCredentials bool + AutoStart bool + HideDockIcon bool + RequestBodyLog bool + AuthFailurePause bool + LogFile bool + LogMaxTotalMB int + AdminAuthEnabled bool + AdminUsername string + AdminPassword string + EncryptCredentials bool + // CredentialsKeyFile 是非 Windows 凭证加密的密钥文件路径,必须位于程序目录和账户目录之外 + CredentialsKeyFile string + // CredentialsKey 是来自进程环境变量 CREDENTIALS_KEY 的 32 字节密钥,不会写回 .env + CredentialsKey []byte + credentialsKeyError error TrustedProxyCIDRs string BuildNativeNonstream bool NonstreamKeepalive bool @@ -217,7 +226,7 @@ func Default() Config { LogFile: true, LogMaxTotalMB: 500, BuildNativeNonstream: true, - NonstreamKeepalive: true, + NonstreamKeepalive: false, SearchModelAliases: true, FirstEventHedge: FirstEventHedgeAuto, FirstEventHedgeModels: DefaultFirstEventHedgeModels, @@ -257,7 +266,7 @@ type InvalidValue struct { } // secretKeys 是取值可能含凭据的配置键,无效时不记录原值 -var secretKeys = []string{"PROXY", "PROXY_API_KEY", "API_KEY_ACCOUNTS", "API_KEY_RATE_LIMITS", "ADMIN_PASSWORD"} +var secretKeys = []string{"PROXY", "PROXY_API_KEY", "API_KEY_ACCOUNTS", "API_KEY_RATE_LIMITS", "ADMIN_PASSWORD", "CREDENTIALS_KEY"} // invalidValue 返回无效配置项的记录,凭据键不带原值 func invalidValue(key, value string, err error) InvalidValue { @@ -300,14 +309,21 @@ var configSetters = map[string]func(*Config, string) error{ "ADMIN_USERNAME": textSetter(func(c *Config) *string { return &c.AdminUsername }, strings.TrimSpace), "ADMIN_PASSWORD": textSetter(func(c *Config) *string { return &c.AdminPassword }, func(value string) string { return value }), "ENCRYPT_CREDENTIALS": boolSetter("ENCRYPT_CREDENTIALS", func(c *Config) *bool { return &c.EncryptCredentials }), - "TRUSTED_PROXY_CIDRS": textSetter(func(c *Config) *string { return &c.TrustedProxyCIDRs }, strings.TrimSpace), - "FIRST_EVENT_HEDGE": textSetter(func(c *Config) *string { return &c.FirstEventHedge }, lowerTrim), - "FIRST_EVENT_HEDGE_MODELS": textSetter(func(c *Config) *string { return &c.FirstEventHedgeModels }, strings.TrimSpace), - "CONTINUATION_PROMPT": textSetter(func(c *Config) *string { return &c.ContinuationPrompt }, strings.TrimSpace), - "AISTUDIO_AUTH_STATES": textSetter(func(c *Config) *string { return &c.AuthStates }, strings.TrimSpace), - "LISTEN_ADDR": textSetter(func(c *Config) *string { return &c.ListenAddr }, strings.TrimSpace), - "PREVIEW_LISTEN_ADDR": textSetter(func(c *Config) *string { return &c.PreviewListenAddr }, strings.TrimSpace), - "PPROF_ADDR": textSetter(func(c *Config) *string { return &c.PProfAddr }, strings.TrimSpace), + "CREDENTIALS_KEY_FILE": textSetter(func(c *Config) *string { return &c.CredentialsKeyFile }, strings.TrimSpace), + "CREDENTIALS_KEY": func(c *Config, value string) error { + key, err := decodeCredentialsKey(value) + c.CredentialsKey = key + c.credentialsKeyError = err + return err + }, + "TRUSTED_PROXY_CIDRS": textSetter(func(c *Config) *string { return &c.TrustedProxyCIDRs }, strings.TrimSpace), + "FIRST_EVENT_HEDGE": textSetter(func(c *Config) *string { return &c.FirstEventHedge }, lowerTrim), + "FIRST_EVENT_HEDGE_MODELS": textSetter(func(c *Config) *string { return &c.FirstEventHedgeModels }, strings.TrimSpace), + "CONTINUATION_PROMPT": textSetter(func(c *Config) *string { return &c.ContinuationPrompt }, strings.TrimSpace), + "AISTUDIO_AUTH_STATES": textSetter(func(c *Config) *string { return &c.AuthStates }, strings.TrimSpace), + "LISTEN_ADDR": textSetter(func(c *Config) *string { return &c.ListenAddr }, strings.TrimSpace), + "PREVIEW_LISTEN_ADDR": textSetter(func(c *Config) *string { return &c.PreviewListenAddr }, strings.TrimSpace), + "PPROF_ADDR": textSetter(func(c *Config) *string { return &c.PProfAddr }, strings.TrimSpace), "GEOIP_DB_PATH": func(c *Config, value string) error { if path := strings.TrimSpace(value); path != "" { c.GeoIPDBPath = path @@ -430,12 +446,6 @@ var configChecks = []configCheck{ _, err := ParseTrustedProxyCIDRs(c.TrustedProxyCIDRs) return err }}, - {[]string{"ENCRYPT_CREDENTIALS"}, func(c Config) error { - if c.EncryptCredentials && runtime.GOOS != "windows" { - return fmt.Errorf("ENCRYPT_CREDENTIALS 仅支持 Windows") - } - return nil - }}, {[]string{"PROXY"}, func(c Config) error { for _, proxy := range ProxyList(c.Proxy) { if err := ValidateProxy(proxy); err != nil { @@ -696,6 +706,8 @@ func (c Config) envValues() map[string]string { "ADMIN_USERNAME": c.AdminUsername, "ADMIN_PASSWORD": c.AdminPassword, "ENCRYPT_CREDENTIALS": strconv.FormatBool(c.EncryptCredentials), + "CREDENTIALS_KEY_FILE": c.CredentialsKeyFile, + "CREDENTIALS_KEY": encodeCredentialsKey(c.CredentialsKey), "TRUSTED_PROXY_CIDRS": c.TrustedProxyCIDRs, "PPROF_ADDR": c.PProfAddr, "GEOIP_DB_PATH": c.GeoIPDBPath, @@ -762,6 +774,8 @@ func (c Config) Save(path string) error { for _, key := range configKeys { _, inFile := saved[key] switch { + case key == "CREDENTIALS_KEY": + // 不把密钥写进 .env。进程环境变量仍是运行时来源,已有的文件行在保存设置时去掉。 case slices.Contains(c.Environment, key): if inFile { file[key] = saved[key] @@ -802,8 +816,139 @@ func appendEnv(path, key, value string) error { return atomicWrite(path, append(content, key+"="+formatEnvValue(value)+"\n"...), 0o600) } +// credentialsKeySize 是 AES-256 凭证密钥的字节数 +const credentialsKeySize = 32 + +// encodeCredentialsKey 把密钥编成标准 Base64,供环境覆盖回填。空密钥返回空字符串。 +func encodeCredentialsKey(key []byte) string { + if len(key) == 0 { + return "" + } + return base64.StdEncoding.EncodeToString(key) +} + +// decodeCredentialsKey 解析 32 字节密钥。接受 64 位十六进制或标准 Base64,错误信息不回显密钥内容。 +func decodeCredentialsKey(value string) ([]byte, error) { + value = strings.TrimSpace(value) + if value == "" { + return nil, nil + } + if decoded, err := hex.DecodeString(value); err == nil && len(decoded) == credentialsKeySize { + return decoded, nil + } + if decoded, err := base64.StdEncoding.DecodeString(value); err == nil && len(decoded) == credentialsKeySize { + return decoded, nil + } + if decoded, err := base64.RawStdEncoding.DecodeString(value); err == nil && len(decoded) == credentialsKeySize { + return decoded, nil + } + return nil, fmt.Errorf("CREDENTIALS_KEY 必须是 32 字节的十六进制或标准 Base64") +} + +// validateCredentialEncryption 在非 Windows 上要求显式密钥,并且密钥文件不能和账户数据放在一起。 +// Windows 继续使用 DPAPI,不读取这些键。检查失败时拒绝启动,不把 ENCRYPT_CREDENTIALS 改回 false,也不改写已有 auth 文件。 +func (c Config) validateCredentialEncryption() error { + if !c.EncryptCredentials || runtime.GOOS == "windows" { + return nil + } + if c.credentialsKeyError != nil { + return c.credentialsKeyError + } + fileSet := strings.TrimSpace(c.CredentialsKeyFile) != "" + keySet := len(c.CredentialsKey) > 0 + switch { + case fileSet && keySet: + return fmt.Errorf("CREDENTIALS_KEY 与 CREDENTIALS_KEY_FILE 只能设置一个") + case keySet && len(c.CredentialsKey) != credentialsKeySize: + return fmt.Errorf("CREDENTIALS_KEY 必须是 32 字节") + case keySet && !slices.Contains(c.Environment, "CREDENTIALS_KEY"): + return fmt.Errorf("CREDENTIALS_KEY 只能来自进程环境变量,不能写在 .env 里;那样密钥会和 auth 放在同一数据目录") + case keySet: + return nil + case fileSet: + return validateCredentialsKeyFile(c.CredentialsKeyFile, c.AuthStates) + default: + return fmt.Errorf("非 Windows 开启 ENCRYPT_CREDENTIALS 时,必须设置进程环境变量 CREDENTIALS_KEY,或设置位于数据目录之外的 CREDENTIALS_KEY_FILE。不会自动在 runtime/ 生成密钥。若要继续使用明文,请把 ENCRYPT_CREDENTIALS 设为 false;已有 auth 文件不会被改写") + } +} + +// validateCredentialsKeyFile 检查密钥文件在数据目录之外、权限为 0600 且长度正好是 32 字节。文件不存在时不创建。 +func validateCredentialsKeyFile(keyPath, authStates string) error { + absolute, err := filepath.Abs(keyPath) + if err != nil { + return fmt.Errorf("解析 CREDENTIALS_KEY_FILE: %w", err) + } + if err := credentialKeyOutsideData(absolute, authStates); err != nil { + return err + } + info, err := os.Lstat(absolute) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("凭证密钥文件不存在,不会自动生成: %s", absolute) + } + return fmt.Errorf("读取凭证密钥文件: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return fmt.Errorf("凭证密钥文件必须是普通文件,不能是符号链接或目录: %s", absolute) + } + if info.Mode().Perm()&0o077 != 0 { + return fmt.Errorf("凭证密钥文件权限过宽 (%04o),应为 0600: %s", info.Mode().Perm(), absolute) + } + if info.Size() != credentialsKeySize { + return fmt.Errorf("凭证密钥文件长度应为 %d 字节: %s", credentialsKeySize, absolute) + } + return nil +} + +// credentialKeyOutsideData 拒绝把密钥放进工作目录或任一账户目录。工作目录里同时有 .env、auth 与 runtime。 +// 工作目录是文件系统根(如 systemd 默认的 /)时,任何绝对路径都位于它之下,该检查会拒绝全部 +// CREDENTIALS_KEY_FILE;此时跳过工作目录检查,只保留账户目录检查。 +func credentialKeyOutsideData(keyPath, authStates string) error { + keyPath = filepath.Clean(keyPath) + workdir, err := os.Getwd() + if err != nil { + return fmt.Errorf("读取工作目录: %w", err) + } + if !isFilesystemRoot(workdir) && pathWithin(keyPath, workdir) { + return fmt.Errorf("CREDENTIALS_KEY_FILE 不能位于程序工作目录 %s 内,否则密钥和 auth/runtime 在同一数据目录,不能当作加密保护", workdir) + } + for _, directory := range strings.Split(authStates, ",") { + directory = strings.TrimSpace(directory) + if directory == "" { + continue + } + absolute, err := filepath.Abs(directory) + if err != nil { + return fmt.Errorf("解析账户目录: %w", err) + } + if pathWithin(keyPath, absolute) { + return fmt.Errorf("CREDENTIALS_KEY_FILE 不能位于账户目录 %s 内", absolute) + } + } + return nil +} + +// isFilesystemRoot 判断目录是否为文件系统根,例如 POSIX 的 / 或 Windows 的 C:\ +func isFilesystemRoot(dir string) bool { + return filepath.Dir(dir) == dir +} + +// pathWithin 判断 path 是否等于 dir 或位于 dir 之中 +func pathWithin(path, dir string) bool { + path = filepath.Clean(path) + dir = filepath.Clean(dir) + relative, err := filepath.Rel(dir, path) + if err != nil { + return false + } + return relative == "." || (relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative)) +} + // Validate 校验配置值是否能用于服务启动 func (c Config) Validate() error { + if err := c.validateCredentialEncryption(); err != nil { + return err + } if c.AdminAuthEnabled && (strings.TrimSpace(c.AdminUsername) == "" || strings.TrimSpace(c.AdminPassword) == "") { return fmt.Errorf("开启管理登录需要 ADMIN_USERNAME 与 ADMIN_PASSWORD") } @@ -916,6 +1061,23 @@ func ParseAPIKeyRateLimits(value string, apiKeys string) (map[string]APIKeyRateL return limits, nil } +// UnboundAPIKeys 返回已配置、但未出现在 API_KEY_ACCOUNTS 中的 API key。 +// bindings 为空表示没有启用按 key 的账户范围,此时返回空。 +// 返回的 key 不受任何账户范围约束,可以访问全部账户与文件;调用方应提醒操作员确认是否漏配。 +func UnboundAPIKeys(keys, bindings string) []string { + if strings.TrimSpace(bindings) == "" { + return nil + } + bound, _ := ParseAPIKeyAccounts(bindings, keys) + var unbound []string + for _, key := range APIKeys(keys) { + if _, exists := bound[key]; !exists { + unbound = append(unbound, key) + } + } + return unbound +} + // APIKeys 返回 PROXY_API_KEY 中逗号分隔、去掉空白的各个 key func APIKeys(value string) []string { var keys []string diff --git a/internal/config/credentials_test.go b/internal/config/credentials_test.go new file mode 100644 index 00000000..94472fb4 --- /dev/null +++ b/internal/config/credentials_test.go @@ -0,0 +1,162 @@ +package config + +import ( + "bytes" + "encoding/base64" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestNonstreamKeepaliveDefaultIsOff(t *testing.T) { + if Default().NonstreamKeepalive { + t.Fatal("默认开启非流式保活会在 10 秒后提交 200") + } + enabled, err := Load(writeTestEnv(t, "WAA_BACKEND=spidermonkey\nNONSTREAM_KEEPALIVE=true\n")) + if err != nil || !enabled.NonstreamKeepalive { + t.Fatalf("显式开启没有保留: %+v %v", enabled.NonstreamKeepalive, err) + } + disabled, err := Load(writeTestEnv(t, "WAA_BACKEND=spidermonkey\n")) + if err != nil || disabled.NonstreamKeepalive { + t.Fatalf("未设置时应使用默认关闭: %+v %v", disabled.NonstreamKeepalive, err) + } +} + +func TestCredentialEncryptionRequiresExternalKey(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows 使用 DPAPI,不读取外部密钥") + } + plain := Default() + if err := plain.Validate(); err != nil { + t.Fatal(err) + } + + missing := Default() + missing.EncryptCredentials = true + if err := missing.Validate(); err == nil || strings.Contains(err.Error(), "secret-key-material") { + t.Fatalf("缺少密钥时应拒绝启动,且错误不含密钥内容: %v", err) + } + + inside := Default() + inside.EncryptCredentials = true + inside.CredentialsKeyFile = "credentials.key" + if err := inside.Validate(); err == nil || !strings.Contains(err.Error(), "工作目录") { + t.Fatalf("数据目录内的密钥文件应被拒绝: %v", err) + } + + authDir := t.TempDir() + authKey := filepath.Join(authDir, "credentials.key") + if err := os.WriteFile(authKey, bytes.Repeat([]byte{9}, 32), 0o600); err != nil { + t.Fatal(err) + } + placedWithAuth := Default() + placedWithAuth.EncryptCredentials = true + placedWithAuth.AuthStates = authDir + placedWithAuth.CredentialsKeyFile = authKey + if err := placedWithAuth.Validate(); err == nil || !strings.Contains(err.Error(), "账户目录") { + t.Fatalf("账户目录内的密钥文件应被拒绝: %v", err) + } + + loosePath := filepath.Join(t.TempDir(), "credentials.key") + if err := os.WriteFile(loosePath, bytes.Repeat([]byte{4}, 32), 0o644); err != nil { + t.Fatal(err) + } + loose := Default() + loose.EncryptCredentials = true + loose.CredentialsKeyFile = loosePath + if err := loose.Validate(); err == nil || !strings.Contains(err.Error(), "0600") { + t.Fatalf("过宽权限应被拒绝: %v", err) + } + + absent := Default() + absent.EncryptCredentials = true + absentPath := filepath.Join(t.TempDir(), "missing.key") + absent.CredentialsKeyFile = absentPath + if err := absent.Validate(); err == nil || !strings.Contains(err.Error(), "不会自动生成") { + t.Fatalf("缺失密钥文件应被拒绝且不创建: %v", err) + } + if _, err := os.Stat(absentPath); !os.IsNotExist(err) { + t.Fatalf("校验创建了密钥文件: %v", err) + } + + keyPath := filepath.Join(t.TempDir(), "credentials.key") + if err := os.WriteFile(keyPath, bytes.Repeat([]byte{3}, 32), 0o600); err != nil { + t.Fatal(err) + } + external := Default() + external.EncryptCredentials = true + external.CredentialsKeyFile = keyPath + if err := external.Validate(); err != nil { + t.Fatal(err) + } + + both := external + both.CredentialsKey = bytes.Repeat([]byte{8}, 32) + both.Environment = []string{"CREDENTIALS_KEY"} + if err := both.Validate(); err == nil { + t.Fatal("同时设置两种密钥来源时应拒绝") + } +} + +func TestUnboundAPIKeys(t *testing.T) { + if got := UnboundAPIKeys("key-a,key-b", ""); got != nil { + t.Fatalf("未启用账户范围时不应有提示: %v", got) + } + if got := UnboundAPIKeys("key-a,key-b", "key-a:a@example.com|b@example.com,key-b:b@example.com"); got != nil { + t.Fatalf("全部 key 已列入时不应有提示: %v", got) + } + got := UnboundAPIKeys("key-a,key-b,key-c", "key-a:a@example.com") + if len(got) != 2 || got[0] != "key-b" || got[1] != "key-c" { + t.Fatalf("未列入的 key 应按配置顺序返回: %v", got) + } + if got := UnboundAPIKeys("", "key-a:a@example.com"); got != nil { + t.Fatalf("没有配置 key 时不应有提示: %v", got) + } +} + +func TestIsFilesystemRoot(t *testing.T) { + if !isFilesystemRoot("/") { + t.Fatal("POSIX 根目录应被识别为文件系统根") + } + if isFilesystemRoot("/opt") || isFilesystemRoot("relative") { + t.Fatal("非根目录不应被识别为文件系统根") + } + if runtime.GOOS == "windows" { + if root := filepath.Clean(filepath.VolumeName(`C:\any\path`) + `\`); !isFilesystemRoot(root) { + t.Fatalf("Windows 卷根应被识别为文件系统根: %s", root) + } + } +} + +func TestCredentialsKeyFromEnvironmentOnly(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows 使用 DPAPI,不读取外部密钥") + } + secret := bytes.Repeat([]byte{0x2a}, 32) + encoded := base64.StdEncoding.EncodeToString(secret) + fileOnly := "WAA_BACKEND=spidermonkey\nENCRYPT_CREDENTIALS=true\nCREDENTIALS_KEY=" + encoded + "\n" + path := writeTestEnv(t, fileOnly) + if _, err := Load(path); err == nil || strings.Contains(err.Error(), encoded) { + t.Fatalf("写在 .env 中的密钥应被拒绝且不回显: %v", err) + } + if got := readTestEnv(t, path); got != fileOnly { + t.Fatalf("拒绝启动时改写了配置:\n%s", got) + } + + t.Setenv("ENCRYPT_CREDENTIALS", "true") + t.Setenv("CREDENTIALS_KEY", encoded) + envPath := writeTestEnv(t, "WAA_BACKEND=spidermonkey\nENCRYPT_CREDENTIALS=false\nCREDENTIALS_KEY=present-in-file\n") + loaded, err := Load(envPath) + if err != nil || !bytes.Equal(loaded.CredentialsKey, secret) { + t.Fatalf("环境变量密钥没有生效: %v len=%d", err, len(loaded.CredentialsKey)) + } + if err := loaded.Save(envPath); err != nil { + t.Fatal(err) + } + saved := readTestEnv(t, envPath) + if strings.Contains(saved, "CREDENTIALS_KEY") || strings.Contains(saved, encoded) || strings.Contains(saved, "present-in-file") { + t.Fatalf("密钥被写回 .env:\n%s", saved) + } +} diff --git a/internal/securefile/aes_other_test.go b/internal/securefile/aes_other_test.go index ca488302..2cd5d32e 100644 --- a/internal/securefile/aes_other_test.go +++ b/internal/securefile/aes_other_test.go @@ -9,11 +9,30 @@ import ( "testing" ) -// TestAESRoundTrip 验证非 Windows 平台用密钥文件加解密凭证 +func writeTestKey(t *testing.T, path string) { + t.Helper() + key := bytes.Repeat([]byte{0x42}, aesKeySize) + if err := os.WriteFile(path, key, 0o600); err != nil { + t.Fatal(err) + } +} + +func resetCredentialKey(t *testing.T) { + t.Helper() + t.Cleanup(func() { + Configure(false) + ConfigureKey(nil) + ConfigureKeyPath("") + }) +} + +// TestAESRoundTrip 验证非 Windows 平台用已准备好的密钥文件加解密凭证 func TestAESRoundTrip(t *testing.T) { - ConfigureKeyPath(filepath.Join(t.TempDir(), "credentials.key")) + resetCredentialKey(t) + path := filepath.Join(t.TempDir(), "credentials.key") + writeTestKey(t, path) + ConfigureKeyPath(path) Configure(true) - defer Configure(false) data := []byte(`{"cookies":[{"name":"SID","value":"secret"}]}`) encrypted, err := Encrypt(data) if err != nil { @@ -28,23 +47,118 @@ func TestAESRoundTrip(t *testing.T) { } } -// TestAESRequiresKeyPath 验证未配置密钥文件时拒绝加密 +// TestAESInlineKeyRoundTrip 验证进程内密钥可以加解密,并且不创建密钥文件 +func TestAESInlineKeyRoundTrip(t *testing.T) { + resetCredentialKey(t) + ConfigureKeyPath("") + ConfigureKey(bytes.Repeat([]byte{0x11}, aesKeySize)) + Configure(true) + encrypted, err := Encrypt([]byte("secret")) + if err != nil { + t.Fatal(err) + } + decrypted, err := Decrypt(encrypted) + if err != nil || string(decrypted) != "secret" { + t.Fatalf("解密结果 = %q, %v", decrypted, err) + } +} + +// TestAESMissingKeyIsNotCreated 验证缺少密钥文件时加密失败,且不会把新密钥写到调用方给出的路径 +func TestAESMissingKeyIsNotCreated(t *testing.T) { + resetCredentialKey(t) + path := filepath.Join(t.TempDir(), "credentials.key") + ConfigureKey(nil) + ConfigureKeyPath(path) + Configure(true) + if _, err := Encrypt([]byte("secret")); err == nil { + t.Fatal("缺少密钥文件时仍然加密成功") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("缺失的密钥文件被创建了: %v", err) + } +} + +// TestAESRejectsLoosePermissions 验证组或其他用户可读的密钥文件被拒绝 +func TestAESRejectsLoosePermissions(t *testing.T) { + resetCredentialKey(t) + path := filepath.Join(t.TempDir(), "credentials.key") + if err := os.WriteFile(path, bytes.Repeat([]byte{0x7}, aesKeySize), 0o644); err != nil { + t.Fatal(err) + } + ConfigureKey(nil) + ConfigureKeyPath(path) + Configure(true) + if _, err := Encrypt([]byte("secret")); err == nil { + t.Fatal("权限过宽的密钥文件仍然可以加密") + } +} + +// TestAESPlaintextRemainsReadable 验证开启加密后,已有明文仍可读取,解密不会改写调用方持有的原文 +func TestAESPlaintextRemainsReadable(t *testing.T) { + resetCredentialKey(t) + ConfigureKey(bytes.Repeat([]byte{0x3}, aesKeySize)) + Configure(true) + plaintext := []byte(`{"cookies":[{"name":"SID","value":"legacy"}]}`) + stored := append([]byte(nil), plaintext...) + decrypted, err := Decrypt(stored) + if err != nil || !bytes.Equal(decrypted, plaintext) || !bytes.Equal(stored, plaintext) { + t.Fatalf("明文迁移读取 = %q stored=%q err=%v", decrypted, stored, err) + } +} + +// TestAESLostKeyDoesNotRevealOrRewrite 验证密钥不符时解密失败,磁盘上的密文保持原样 +func TestAESLostKeyDoesNotRevealOrRewrite(t *testing.T) { + resetCredentialKey(t) + path := filepath.Join(t.TempDir(), "storage-state.json") + ConfigureKey(bytes.Repeat([]byte{0x5}, aesKeySize)) + Configure(true) + encrypted, err := Encrypt([]byte("secret-cookie")) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, encrypted, 0o600); err != nil { + t.Fatal(err) + } + ConfigureKey(bytes.Repeat([]byte{0x6}, aesKeySize)) + stored, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if _, err := Decrypt(stored); err == nil { + t.Fatal("错误密钥仍然解密成功") + } + again, err := os.ReadFile(path) + if err != nil || !bytes.Equal(again, encrypted) { + t.Fatalf("密文文件被改写: %v", err) + } +} + +// TestAESRequiresKeyPath 验证未配置密钥来源时拒绝加密 func TestAESRequiresKeyPath(t *testing.T) { + resetCredentialKey(t) + ConfigureKey(nil) ConfigureKeyPath("") Configure(true) - defer Configure(false) if _, err := Encrypt([]byte("secret")); err == nil { t.Fatal("缺少密钥文件时仍然加密成功") } } -// TestReadExistingKeyRejectsWrongLength 验证长度错误的已有密钥文件被拒绝 -func TestReadExistingKeyRejectsWrongLength(t *testing.T) { +// TestAESRejectsWrongKeyLength 验证长度错误的密钥文件被拒绝,文件内容保持不变 +func TestAESRejectsWrongKeyLength(t *testing.T) { + resetCredentialKey(t) path := filepath.Join(t.TempDir(), "credentials.key") if err := os.WriteFile(path, []byte("short"), 0o600); err != nil { t.Fatal(err) } - if _, err := readExistingKey(path); err == nil { + ConfigureKey(nil) + ConfigureKeyPath(path) + Configure(true) + if _, err := Encrypt([]byte("secret")); err == nil { t.Fatal("长度错误的密钥文件未被拒绝") } + stored, err := os.ReadFile(path) + if err != nil || string(stored) != "short" { + t.Fatalf("密钥文件被改写: %q %v", stored, err) + } } diff --git a/internal/securefile/dpapi_other.go b/internal/securefile/dpapi_other.go index ee145086..b3c9d0fa 100644 --- a/internal/securefile/dpapi_other.go +++ b/internal/securefile/dpapi_other.go @@ -10,13 +10,9 @@ import ( "fmt" "io" "os" - "path/filepath" "sync" ) -// aesKeySize 是 AES-256 密钥的字节数 -const aesKeySize = 32 - // errUnsupported 表示当前平台没有 DPAPI 或缺少密钥配置 var errUnsupported = errors.New("凭证加密需要配置密钥文件") @@ -58,10 +54,16 @@ func openAES(data []byte) ([]byte, error) { } func credentialCipher() (cipher.AEAD, error) { - key, err := loadKey() + key, err := configuredInlineKey() if err != nil { return nil, err } + if key == nil { + key, err = loadKey() + if err != nil { + return nil, err + } + } block, err := aes.NewCipher(key) if err != nil { return nil, fmt.Errorf("初始化凭证密钥: %w", err) @@ -69,7 +71,7 @@ func credentialCipher() (cipher.AEAD, error) { return cipher.NewGCM(block) } -// loadKey 读取或创建权限为 0600 的 AES 密钥文件 +// loadKey 读取已经存在的 AES 密钥文件。文件缺失时返回错误,不在数据目录旁边自动生成。 func loadKey() ([]byte, error) { path := configuredKeyPath() if path == "" { @@ -77,88 +79,25 @@ func loadKey() ([]byte, error) { } keyMu.Lock() defer keyMu.Unlock() - data, err := os.ReadFile(path) - switch { - case err == nil: - if len(data) != aesKeySize { - return nil, fmt.Errorf("凭证密钥文件长度应为 %d 字节", aesKeySize) - } - // 创建时设 0600,读取时也要校验:从备份还原等途径预置的宽权限文件不能静默沿用 - if info, statErr := os.Stat(path); statErr == nil && info.Mode().Perm()&0o077 != 0 { - return nil, fmt.Errorf("凭证密钥文件权限过宽 (%04o),应为 0600: %s", info.Mode().Perm(), path) + info, err := os.Lstat(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("凭证密钥文件不存在,不会自动生成: %s", path) } - return data, nil - case !errors.Is(err, os.ErrNotExist): return nil, fmt.Errorf("读取凭证密钥: %w", err) } - key := make([]byte, aesKeySize) - if _, err := io.ReadFull(rand.Reader, key); err != nil { - return nil, fmt.Errorf("生成凭证密钥: %w", err) - } - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - return nil, fmt.Errorf("创建凭证密钥目录: %w", err) - } - // 先写临时文件再原子发布,避免中断后留下长度错误的密钥;已有密钥时使用先创建的一方 - temp, err := os.CreateTemp(filepath.Dir(path), ".credentials-key-*") - if err != nil { - return nil, fmt.Errorf("创建凭证密钥临时文件: %w", err) - } - tempName := temp.Name() - defer os.Remove(tempName) - if err := temp.Chmod(0o600); err != nil { - temp.Close() - return nil, fmt.Errorf("设置凭证密钥权限: %w", err) - } - if _, err := temp.Write(key); err != nil { - temp.Close() - return nil, fmt.Errorf("写入凭证密钥: %w", err) + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return nil, fmt.Errorf("凭证密钥文件必须是普通文件: %s", path) } - if err := temp.Sync(); err != nil { - temp.Close() - return nil, fmt.Errorf("同步凭证密钥: %w", err) + if info.Mode().Perm()&0o077 != 0 { + return nil, fmt.Errorf("凭证密钥文件权限过宽 (%04o),应为 0600: %s", info.Mode().Perm(), path) } - if err := temp.Close(); err != nil { - return nil, fmt.Errorf("关闭凭证密钥临时文件: %w", err) - } - if err := os.Link(tempName, path); err != nil { - if errors.Is(err, os.ErrExist) { - return readExistingKey(path) - } - // 不支持硬链接的文件系统改用排他创建发布:Rename 在 POSIX 下会直接覆盖目标, - // 并发首次启动时可能把对方已发布的密钥换掉,使其已加密的凭证无法解密 - published, openErr := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) - if openErr != nil { - if errors.Is(openErr, os.ErrExist) { - return readExistingKey(path) - } - return nil, fmt.Errorf("发布凭证密钥: %w", openErr) - } - if _, writeErr := published.Write(key); writeErr != nil { - published.Close() - os.Remove(path) - return nil, fmt.Errorf("发布凭证密钥: %w", writeErr) - } - if syncErr := published.Sync(); syncErr != nil { - published.Close() - os.Remove(path) - return nil, fmt.Errorf("发布凭证密钥: %w", syncErr) - } - if closeErr := published.Close(); closeErr != nil { - os.Remove(path) - return nil, fmt.Errorf("发布凭证密钥: %w", closeErr) - } - } - return key, nil -} - -// readExistingKey 读取并发创建的一方写入的密钥 -func readExistingKey(path string) ([]byte, error) { - existing, err := os.ReadFile(path) + data, err := os.ReadFile(path) if err != nil { - return nil, fmt.Errorf("读取并发创建的凭证密钥: %w", err) + return nil, fmt.Errorf("读取凭证密钥: %w", err) } - if len(existing) != aesKeySize { + if len(data) != aesKeySize { return nil, fmt.Errorf("凭证密钥文件长度应为 %d 字节", aesKeySize) } - return existing, nil + return data, nil } diff --git a/internal/securefile/securefile.go b/internal/securefile/securefile.go index 0d0c1616..dd7914db 100644 --- a/internal/securefile/securefile.go +++ b/internal/securefile/securefile.go @@ -15,9 +15,13 @@ const envelopePrefix = "AISTUDIO2API-DPAPI1:" // aesEnvelopePrefix 标记 AES-256-GCM 加密的凭证内容 const aesEnvelopePrefix = "AISTUDIO2API-AESGCM1:" +// aesKeySize 是 AES-256 密钥的字节数 +const aesKeySize = 32 + var ( - enabled atomic.Bool - keyPath atomic.Value + enabled atomic.Bool + keyPath atomic.Value + inlineKey atomic.Value ) // Configure 设置写出凭证文件时是否加密,进程启动时调用一次 @@ -25,11 +29,34 @@ func Configure(value bool) { enabled.Store(value) } -// ConfigureKeyPath 设置非 Windows 平台 AES 密钥文件路径,进程启动时调用一次 +// ConfigureKeyPath 设置非 Windows 平台 AES 密钥文件路径,进程启动时调用一次。 +// 路径必须由调用方明确给出。读取时不会在缺失时自动生成密钥。 func ConfigureKeyPath(path string) { keyPath.Store(strings.TrimSpace(path)) } +// ConfigureKey 设置进程内的 AES-256 密钥。传 nil 或空切片表示不使用进程内密钥。 +// 密钥应由进程环境注入,不要把它写到账户数据旁边。 +func ConfigureKey(key []byte) { + if len(key) == 0 { + inlineKey.Store([]byte(nil)) + return + } + inlineKey.Store(append([]byte(nil), key...)) +} + +// configuredInlineKey 返回进程内密钥的副本。未配置时返回 nil。 +func configuredInlineKey() ([]byte, error) { + key, _ := inlineKey.Load().([]byte) + if len(key) == 0 { + return nil, nil + } + if len(key) != aesKeySize { + return nil, fmt.Errorf("凭证密钥长度应为 %d 字节", aesKeySize) + } + return append([]byte(nil), key...), nil +} + // configuredKeyPath 返回 AES 密钥文件路径,未配置时为空 func configuredKeyPath() string { path, _ := keyPath.Load().(string)