Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ PRICE_INPUT_PER_M=0
PRICE_OUTPUT_PER_M=0
# 设成 1 时会话 cookie 带 Secure,上了 HTTPS 再开。
COOKIE_SECURE=
# 控制面前面有几层自己的反向代理。登录失败和注册按来源 IP 限频,留空或 0 只认 socket 地址,不读 X-Forwarded-For,
# 因为客户端能随便填它。前面挂了 Caddy 或 nginx 就填 1,取 X-Forwarded-For 最右一项;没有代理却填了,任何人都能伪造来源。
TRUSTED_PROXY_HOPS=
# 工具凭据的加密钥匙,64 位十六进制,用 openssl rand -hex 32 生成,换了之前存的工具凭据就解不开。
# 不设时用代码里的开发密钥并告警;设了空值会启动失败,所以不用时整行保持注释。
# TOOL_SECRET_KEY=
Expand Down
1 change: 1 addition & 0 deletions deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ compose 读 `deploy/remote/.env`(`remote/README.md:41`)。控制面读哪些
| `PRICE_INPUT_PER_M`、`PRICE_OUTPUT_PER_M` | 否 | `0` | 每百万 token 价格,折算 run 的花费(`env.ts:96-97`)。不设用量金额恒为 0 |
| `GITHUB_API_BASE_URL`、`X_API_BASE_URL`、`GITHUB_OAUTH_BASE_URL`、`X_OAUTH_BASE_URL` | 否 | 各家官方地址,和 `env.ts:84-87` 一致 | 只在验证时指到假平台(`env.ts:98-99`、`:122-123`),生产不用设 |
| `COOKIE_SECURE` | 上了 HTTPS 就设 | `0` | 设成 `1` 会话 cookie 才带 Secure(`env.ts:12-14`) |
| `TRUSTED_PROXY_HOPS` | 否 | `1` | 控制面前面有几层反向代理,登录注册按来源 IP 限频时从 `X-Forwarded-For` 右数第几项取来源(`packages/control-plane/src/http/auth-limits.ts` 的 `clientIp`)。compose 默认 `1` 对应同机一层 Caddy;设成 `0` 所有请求都算成 docker 网关一个来源,两百次失败就锁住全站登录。控制面本身不设时是 `0` |
| `GITHUB_APP_CLIENT_ID`、`GITHUB_APP_CLIENT_SECRET` | 否 | 空 | 要一起设,只设一个启动时抛错(`env.ts:113-115`) |
| `X_CLIENT_ID`、`X_CLIENT_SECRET` | 否 | 空 | 设了 secret 就要设 id(`env.ts:118`) |

Expand Down
2 changes: 2 additions & 0 deletions deploy/remote/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ services:
# 工具认证信息的加密密钥,64 位十六进制。换了它,已存的凭据全部解不开。
TOOL_SECRET_KEY: ${TOOL_SECRET_KEY:?要设工具凭据的加密密钥}
COOKIE_SECURE: ${COOKIE_SECURE:-0}
# 控制面只绑 127.0.0.1,前面必有一层 Caddy 或 nginx。登录注册按来源 IP 限频,要从 X-Forwarded-For 取真实来源。
TRUSTED_PROXY_HOPS: ${TRUSTED_PROXY_HOPS:-1}
# 平台账号令牌的加密密钥,32 字节 base64。换了它,已存的令牌全部解不开。
CREDENTIALS_KEY: ${CREDENTIALS_KEY:?缺 CREDENTIALS_KEY}
# 浏览器打开 Web 的地址,OAuth 回调拼在它上面。控制面自己推不出浏览器到得了的地址,所以必填。
Expand Down
3 changes: 3 additions & 0 deletions docs/开发.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ Bot 走到要点头的工具时群里出一张审批卡,点按钮或在群里
| `DATABASE_URL` | `postgres://baize:baize@localhost:5434/baize` | Postgres 连接串 |
| `PORT` | `3001` | 监听端口 |
| `COOKIE_SECURE` | 空 | 设成 `1` 时会话 cookie 带 Secure |
| `TRUSTED_PROXY_HOPS` | `0` | 控制面前面有几层自己的反向代理,决定登录和注册限频认哪个来源 IP。`0` 只认 socket 地址,不读 `X-Forwarded-For`;前面挂了 Caddy 或 nginx 填 `1`,取 `X-Forwarded-For` 最右一项。没有代理却填了非 0,客户端就能伪造来源绕过 IP 限频 |
| `PUBLIC_BASE_URL` | 空,用实际监听的地址拼 | 只在没设 `WEB_BASE_URL` 时给 OAuth 回调当前缀。电脑拿到的数据面地址不看它,看代理自报的 `controlOrigin` |
| `GATEWAY_MODEL` | `baize-default` | 兼容旧配置;未设置 `GATEWAY_MODELS` 时同时作为 plan 和 fast 的上游模型名 |
| `GATEWAY_MODELS` | 空,回退 `GATEWAY_MODEL` | 按 `plan=模型,fast=模型` 配置两个用途;设置后必须同时包含 plan 和 fast |
Expand All @@ -70,6 +71,8 @@ Bot 走到要点头的工具时群里出一张审批卡,点按钮或在群里
| `GITHUB_OAUTH_BASE_URL` | `https://github.com` | GitHub 授权页和令牌端点的根地址,验证时指到假 GitHub |
| `X_OAUTH_BASE_URL` | `https://x.com` | X 授权页的根地址 |

登录和注册有进程内的限频,阈值是 `packages/control-plane/src/http/auth-limits.ts` 里的常量:同一邮箱 15 分钟内失败 5 次、同一来源 IP 15 分钟内失败 200 次,再登录就回 429 带 `Retry-After`,锁定期内密码对了也一样,登录成功清掉该邮箱的计数;同一来源 IP 一小时内最多注册 20 次。计数在内存里,重启清零,每个计数器最多记一万个键。本地开发经 Vite 代理进来的请求来源都是 127.0.0.1,自己连错几次密码就被锁了的话,换个邮箱或重启控制面。

GitHub、X 账号在账号页走 OAuth 连。开发时手里有令牌想跳过授权页,也可以用脚本挂上,令牌加密存进库里(没有刷新令牌,过期了只能再跑一次):`pnpm --silent --filter @baize/control-plane account-add -- --owner <邮箱> --platform github --handle <名> --token <PAT>`,可选 `--tier`。不带 `--silent` 时 pnpm 会把整条命令连令牌一起回显到终端。

## 跑起来
Expand Down
9 changes: 9 additions & 0 deletions packages/control-plane/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ export function secureCookie(): boolean {
return (process.env["COOKIE_SECURE"] ?? "") === "1";
}

/** 控制面前面有几层自己的反向代理。0 只认 socket 地址,见 http/auth-limits.ts 的 clientIp。 */
export function trustedProxyHops(): number {
const raw = nonEmpty("TRUSTED_PROXY_HOPS");
if (raw === null) return 0;
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed < 0) throw new Error(`TRUSTED_PROXY_HOPS 不是非负整数: ${raw}`);
return parsed;
}

export function toolSecretKey(): string {
const key = nonEmpty("TOOL_SECRET_KEY");
if (key !== null) return key;
Expand Down
36 changes: 35 additions & 1 deletion packages/control-plane/src/http/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,16 @@ import type { PushHub } from "../ws/hub.js";
import type { AccountTokens, CredentialCrypto } from "../credentials.js";
import type { OAuthEndpoints } from "../oauth-providers.js";
import { ACCOUNTS_PROXY_PATH, createAccountsProxy } from "./accounts-proxy.js";
import {
AttemptCounter,
LOGIN_FAILURES_PER_EMAIL,
LOGIN_FAILURES_PER_IP,
LOGIN_WINDOW_MS,
REGISTER_WINDOW_MS,
REGISTRATIONS_PER_IP,
clientIp,
retryAfterSeconds,
} from "./auth-limits.js";
import { GATEWAY_PATH, createGateway, type GatewayDeps } from "./gateway.js";
import { HUB_PATH, createHub } from "./hub.js";
import { MCP_PROXY_PATH, createMcpProxy } from "./mcp-proxy.js";
Expand Down Expand Up @@ -191,6 +201,7 @@ export type AppDeps = {
readonly groupTurns: GroupTurns;
readonly notifications: NoticePublisher;
readonly secureCookie: boolean;
readonly trustedProxyHops: number;
readonly pokerSeed?: () => string;
readonly gateway: Omit<GatewayDeps, "db">;
readonly platformAccounts: {
Expand Down Expand Up @@ -287,9 +298,16 @@ export function createApp(deps: AppDeps): Hono<Env> & { closeMcpClients(): Promi
}),
);

const loginFailuresByEmail = new AttemptCounter(LOGIN_FAILURES_PER_EMAIL, LOGIN_WINDOW_MS);
const loginFailuresByIp = new AttemptCounter(LOGIN_FAILURES_PER_IP, LOGIN_WINDOW_MS);
const registrationsByIp = new AttemptCounter(REGISTRATIONS_PER_IP, REGISTER_WINDOW_MS);
app.post("/api/register", async (c) => {
const body = await readJson(c.req.raw, RegisterRequest);
if (!body.ok) return c.json({ error: body.error }, 400);
const ip = clientIp(c, deps.trustedProxyHops);
const wait = registrationsByIp.blockedFor(ip);
if (wait !== null) return tooManyAttempts(c, wait);
registrationsByIp.hit(ip);
let user: User;
try {
user = await createUser(db, body.value);
Expand All @@ -312,8 +330,18 @@ export function createApp(deps: AppDeps): Hono<Env> & { closeMcpClients(): Promi
app.post("/api/auth/login", async (c) => {
const body = await readJson(c.req.raw, LoginRequest);
if (!body.ok) return c.json({ error: body.error }, 400);
const user = await authenticate(db, body.value.email, body.value.password);
const { email, password } = body.value;
const ip = clientIp(c, deps.trustedProxyHops);
const byEmail = loginFailuresByEmail.blockedFor(email);
const byIp = loginFailuresByIp.blockedFor(ip);
if (byEmail !== null || byIp !== null) return tooManyAttempts(c, Math.max(byEmail ?? 0, byIp ?? 0));
// 验密码要等 scrypt,先按失败记上,并发涌进来的请求在这期间就能看到计数。
loginFailuresByEmail.hit(email);
loginFailuresByIp.hit(ip);
const user = await authenticate(db, email, password);
if (user === null) return c.json({ error: "邮箱或密码不对" }, 401);
loginFailuresByEmail.clear(email);
loginFailuresByIp.unhit(ip);
const token = await createSession(db, user.id);
// 首次登录时给他登记一台电脑,状态先是创建中,代理第一次握手才转在线。
await ensureComputer(db, user.id);
Expand Down Expand Up @@ -1122,6 +1150,12 @@ const MessageQuery = z.object({

type ReadResult<T> = { ok: true; value: T } | { ok: false; error: string };

function tooManyAttempts(c: Context, waitMs: number) {
const seconds = retryAfterSeconds(waitMs);
c.header("Retry-After", String(seconds));
return c.json({ error: `尝试太多次了,请 ${Math.ceil(seconds / 60)} 分钟后再试` }, 429);
}

// 泛型收在 schema 上而不是 z.ZodType<T>:后者的输入类型会把 BotId 这类品牌化 ID 磨回 string。
async function readJson<S extends z.ZodTypeAny>(request: Request, schema: S): Promise<ReadResult<z.infer<S>>> {
let raw: unknown;
Expand Down
91 changes: 91 additions & 0 deletions packages/control-plane/src/http/auth-limits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { getConnInfo } from "@hono/node-server/conninfo";
import { createHash } from "node:crypto";
import type { Context } from "hono";

export const LOGIN_WINDOW_MS = 15 * 60 * 1000;
export const LOGIN_FAILURES_PER_EMAIL = 5;
/** 一个办公室常常共用一个出口 IP,这一维放得比邮箱宽得多,只挡换着邮箱撞库。 */
export const LOGIN_FAILURES_PER_IP = 200;
export const REGISTER_WINDOW_MS = 60 * 60 * 1000;
export const REGISTRATIONS_PER_IP = 20;
/** 每个计数器最多记这么多个键,随机邮箱刷不爆内存。 */
export const MAX_TRACKED_KEYS = 10_000;

type Window = { readonly resetAt: number; count: number };

/**
* 进程内的定窗计数,窗口从第一次计数起算。控制面是单实例(#25),重启清零可以接受。
* 所有窗口一样长,Map 的插入顺序就是到期顺序,过期的总在最前面。
*/
export class AttemptCounter {
readonly #windows = new Map<string, Window>();

constructor(
private readonly limit: number,
private readonly windowMs: number,
private readonly maxKeys: number = MAX_TRACKED_KEYS,
private readonly now: () => number = Date.now,
) {}

/** 到了上限回还要等多少毫秒,没到回 null。 */
blockedFor(key: string): number | null {
const window = this.#windows.get(digest(key));
const now = this.now();
if (window === undefined || window.resetAt <= now || window.count < this.limit) return null;
return window.resetAt - now;
}

hit(key: string): void {
const now = this.now();
this.#dropExpired(now);
const k = digest(key);
const window = this.#windows.get(k);
if (window !== undefined) {
window.count += 1;
return;
}
if (this.#windows.size >= this.maxKeys) {
const oldest = this.#windows.keys().next();
if (oldest.done !== true) this.#windows.delete(oldest.value);
}
this.#windows.set(k, { resetAt: now + this.windowMs, count: 1 });
}

/** 退回一次 hit。登录先记上再验密码,验过了就退掉。 */
unhit(key: string): void {
const window = this.#windows.get(digest(key));
if (window !== undefined && window.count > 0) window.count -= 1;
}

clear(key: string): void {
this.#windows.delete(digest(key));
}

#dropExpired(now: number): void {
for (const [k, window] of this.#windows) {
if (window.resetAt > now) return;
this.#windows.delete(k);
}
}
}

// 邮箱是请求体里的任意长字符串,摘要后每个键定长。
const digest = (key: string): string => createHash("sha256").update(key).digest("base64url");

/**
* 请求的来源 IP。trustedProxyHops 是控制面前面有几层自己的反向代理:0 只认 socket 地址,
* 不看 X-Forwarded-For,因为客户端能随便填它;n 取 X-Forwarded-For 从右数第 n 项,那是最外层代理看到的来源。
*/
export function clientIp(c: Context, trustedProxyHops: number): string {
const socket = getConnInfo(c).remote.address ?? "unknown";
if (trustedProxyHops === 0) return socket;
const forwarded = (c.req.header("x-forwarded-for") ?? "")
.split(",")
.map((part) => part.trim())
.filter((part) => part !== "");
return forwarded[Math.max(0, forwarded.length - trustedProxyHops)] ?? socket;
}

export function retryAfterSeconds(waitMs: number): number {
return Math.max(1, Math.ceil(waitMs / 1000));
}
3 changes: 2 additions & 1 deletion packages/control-plane/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { connect } from "./db/client.js";
import { databaseUrl, port, runtimeConfig, secureCookie } from "./env.js";
import { databaseUrl, port, runtimeConfig, secureCookie, trustedProxyHops } from "./env.js";
import { acquireInstanceLock } from "./instance-lock.js";
import { startServer, type RunningServer } from "./server.js";

Expand Down Expand Up @@ -56,6 +56,7 @@ try {
db,
port: port(),
secureCookie: secureCookie(),
trustedProxyHops: trustedProxyHops(),
config: runtimeConfig(),
});
} catch (error) {
Expand Down
3 changes: 3 additions & 0 deletions packages/control-plane/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ export type StartOptions = {
/** 不给就监听通配地址。测试给 127.0.0.1:macOS 分临时端口时不避开别的进程占在 127.0.0.1 上的同号端口,客户端连 127.0.0.1 会连到别人那里。 */
readonly hostname?: string;
readonly secureCookie?: boolean;
/** 不给就是 0:只认 socket 地址,不读 X-Forwarded-For。 */
readonly trustedProxyHops?: number;
readonly config?: RuntimeConfig;
readonly scheduler?: Pick<SchedulerOptions, "now" | "intervalMs">;
readonly notificationScheduler?: NotificationOptions;
Expand Down Expand Up @@ -157,6 +159,7 @@ export async function startServer(options: StartOptions): Promise<RunningServer>
groupTurns,
notifications,
secureCookie: options.secureCookie ?? false,
trustedProxyHops: options.trustedProxyHops ?? 0,
...(options.poker?.seed === undefined ? {} : { pokerSeed: options.poker.seed }),
gateway: {
models: config.gatewayModels,
Expand Down
12 changes: 4 additions & 8 deletions packages/control-plane/test/attachments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ import {
ComputerTokenResponse,
CreateBotResponse,
CreateGroupResponse,
RegisterResponse,
type BotId,
type GroupId,
} from "@baize/shared";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { helloAgent, type AgentClient } from "./helpers/agent.js";
import { startTestServer, type TestServer } from "./helpers/client.js";
import { createTestDb, type TestDb } from "./helpers/db.js";
import { PASSWORD, makeUser } from "./helpers/fixtures.js";

let database: TestDb;
let server: TestServer;
Expand Down Expand Up @@ -48,13 +48,9 @@ afterAll(async () => {

async function owner() {
const client = server.client();
const registered = await client.post("/api/register", {
name: "附件测试",
email: `${randomUUID()}@baize.dev`,
password: "baize-dev",
});
expect(registered.status).toBe(201);
const { user } = RegisterResponse.parse(await registered.json());
// 一个文件建二十几个人,走注册会撞上按 IP 的注册限频。
const user = await makeUser(database.db, "附件测试");
expect((await client.login(user.email, PASSWORD)).status).toBe(200);
const paired = await client.post("/api/computers/local");
expect(paired.status).toBe(200);
const computer = ComputerTokenResponse.parse(await paired.json());
Expand Down
50 changes: 50 additions & 0 deletions packages/control-plane/test/auth-limits.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { expect, it } from "vitest";
import { AttemptCounter } from "../src/http/auth-limits.js";

function clock(start = 0) {
let now = start;
return { now: () => now, advance: (ms: number) => (now += ms) };
}

it("计满才挡,回剩下的毫秒数,窗口过了自动放开", () => {
const time = clock();
const counter = new AttemptCounter(2, 1000, 100, time.now);

counter.hit("a@baize.dev");
expect(counter.blockedFor("a@baize.dev")).toBeNull();
counter.hit("a@baize.dev");
time.advance(300);
expect(counter.blockedFor("a@baize.dev")).toBe(700);

time.advance(700);
expect(counter.blockedFor("a@baize.dev"), "窗口到期就放开").toBeNull();
counter.hit("a@baize.dev");
expect(counter.blockedFor("a@baize.dev"), "到期后重新起一个窗口").toBeNull();
});

it("退回一次和清零都能把挡住的键放开", () => {
const counter = new AttemptCounter(1, 1000);
counter.hit("a");
counter.unhit("a");
expect(counter.blockedFor("a")).toBeNull();
counter.hit("b");
counter.clear("b");
expect(counter.blockedFor("b")).toBeNull();
});

it("键数到上限时先丢过期的,都没过期就丢最早的", () => {
const time = clock();
const counter = new AttemptCounter(1, 1000, 2, time.now);

counter.hit("old");
time.advance(1000);
counter.hit("a");
counter.hit("b");
expect(counter.blockedFor("a"), "丢的是过期的 old,a 还在").not.toBeNull();
expect(counter.blockedFor("b")).not.toBeNull();

counter.hit("c");
expect(counter.blockedFor("a"), "都没过期时丢最早建的 a").toBeNull();
expect(counter.blockedFor("b")).not.toBeNull();
expect(counter.blockedFor("c")).not.toBeNull();
});
Loading
Loading