diff --git a/README.md b/README.md index e600343..9d624df 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,9 @@ > 把本机已经登录的消费级 AI 客户端,接成 OpenAI 兼容接口,给 Codex、OpenCode、Cherry Studio、NextChat 等用。默认打开 Work Buddy / CodeBuddy、QClaw、千问办公(QwenWork)、TraeWork 四个通道;管理页下拉选其中一个。一次请求只走一个通道。 -当前版本 **2.1.5**。这个项目只适合本机自用,不要公开部署,也不要把登录凭据、API Key、数据库文件发给别人。 +当前版本 **2.1.6**。这个项目只适合本机自用,不要公开部署,也不要把登录凭据、API Key、数据库文件发给别人。 + +默认本机启动会自动打开管理页,无需填写管理 Token,重启后已有页面仍可直接使用。重复启动会打开已运行的同一实例;同一数据库不能同时由多个实例使用。后台服务可加 `--no-browser`。显式设置 `--admin-token` 或 `CB_GATEWAY_ADMIN_TOKEN` 时启用凭证管理模式;非本机监听必须设置该凭证,并在管理页设置中填写。客户端 API Key 和上游账号授权不受影响。 ## 这是什么? @@ -97,7 +99,7 @@ python server.py ### 第一次打开网页之后 -本机浏览器一般会自动带上管理 Cookie,不用粘贴 Token。 +本机管理页自动授权,不用粘贴 Token,也不依赖管理 Cookie。 1. 打开「账号」。下拉里选 WorkBuddy / QClaw / 千问办公 / TraeWork,点「重新检测」,再点「一键导入本机登录」。 2. 点该账号的「测试」,能返回一句话就说明这条通道通了。 @@ -215,8 +217,9 @@ QwenWork、QClaw、TraeWork 各用自己那把 Key,不要混用。 |---|---|---| | `--host` | `127.0.0.1` | 监听地址,本机用保持这个值 | | `--port` | `8787` | 端口 | -| `--admin-token` | 自动生成 | 管理 Token;本机网页通常用 Cookie | -| `--no-admin-auth` | 关 | 关掉管理鉴权,只适合本机临时试 | +| `--admin-token` | 不设置 | 本机默认自动授权;远程监听必须配置管理凭证 | +| `--no-admin-auth` | 关 | 显式启用本机自动授权,仍校验请求来源;只允许回环监听 | +| `--no-browser` | 关 | 不自动打开浏览器,适合后台服务 | ## 环境变量 diff --git a/README_EN.md b/README_EN.md index 6fcc795..3af4292 100644 --- a/README_EN.md +++ b/README_EN.md @@ -4,7 +4,9 @@ > Local consumer AI clients → one OpenAI-compatible API for Codex, OpenCode, Cherry Studio, NextChat, and similar agents. Work Buddy / CodeBuddy, QClaw, QwenWork, and TraeWork are on by default; pick one in the UI dropdown. Each request stays on one channel. -Release **2.1.5**. Local use only. Do not expose this on the public internet, and do not share credentials, API keys, or the database. +Release **2.1.6**. Local use only. Do not expose this on the public internet, and do not share credentials, API keys, or the database. + +Default loopback startup opens the management page without an Admin Token. Restarting does not invalidate local access. Repeated startup opens the existing instance; a database can only be used by one process. Use `--no-browser` for background services. Setting `--admin-token` or `CB_GATEWAY_ADMIN_TOKEN` enables explicit token authentication and is required for non-loopback listeners. Enter that token in the management settings. Client API keys and upstream account credentials are unchanged. ## What is this? diff --git a/docs/releases/v2.1.6.md b/docs/releases/v2.1.6.md new file mode 100644 index 0000000..55e8090 --- /dev/null +++ b/docs/releases/v2.1.6.md @@ -0,0 +1,9 @@ +# v2.1.6 + +- 本机默认打开即用:管理页无需 Admin Token,不再因重启或其他实例覆盖 Cookie 而失效。 +- 重复启动会打开已运行的同一实例;启动前预占监听端口并锁定数据库,防止重复初始化和并发修改账号。 +- 本机模式校验连接来源、Host、Origin 和浏览器请求来源;忽略代理转发头。 +- 远程监听必须显式配置管理凭证,首页不再自动发放 Admin Token。 +- 管理页面禁止缓存,本机模式隐藏凭证输入;新增 `--no-browser` 适配后台启动。 + +客户端 API Key 和上游账号 Token 的使用方式保持不变。Docker 的管理凭证配置继续生效。 diff --git a/server.py b/server.py index 84a7857..d38afb4 100644 --- a/server.py +++ b/server.py @@ -12,18 +12,24 @@ import argparse import asyncio import contextvars +import hashlib +import ipaddress import json import os import secrets +import socket import sys import tempfile import time +import threading +import webbrowser +from urllib.parse import urlsplit from pathlib import Path import uvicorn from fastapi import FastAPI, Header, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse, StreamingResponse, FileResponse +from fastapi.responses import JSONResponse, StreamingResponse, HTMLResponse from starlette.concurrency import run_in_threadpool import database as db @@ -86,8 +92,8 @@ def _cors_origins() -> list[str]: ADMIN_TOKEN: str = "" ALLOW_NO_ADMIN_AUTH = False +LOCAL_MODE = False ALLOW_UNAUTHENTICATED_API = _env_flag("CB_GATEWAY_ALLOW_UNAUTHENTICATED_API", False) -ADMIN_COOKIE_NAME = "cb_gw_admin_token" MAX_BODY_BYTES = max(1024, _env_int("CB_GATEWAY_MAX_BODY_BYTES", 10 * 1024 * 1024)) _CURRENT_REQUEST: contextvars.ContextVar[Request | None] = contextvars.ContextVar("current_request", default=None) @@ -115,6 +121,9 @@ def _atomic_write(path: Path, content: str | bytes, mode: int = 0o600): @app.middleware("http") async def _request_context(request: Request, call_next): + management = request.url.path == "/" or request.url.path == "/admin" or request.url.path.startswith("/admin/") + if LOCAL_MODE and management and not _trusted_local_request(request): + return JSONResponse({"detail": "Local access requires a loopback host and same-origin request"}, status_code=403) token = _CURRENT_REQUEST.set(request) try: return await call_next(request) @@ -122,7 +131,30 @@ async def _request_context(request: Request, call_next): _CURRENT_REQUEST.reset(token) +def _trusted_local_request(request: Request) -> bool: + try: + if not request.client or not ipaddress.ip_address(request.client.host).is_loopback: + return False + if request.url.hostname not in {"127.0.0.1", "localhost", "::1"}: + return False + origin = request.headers.get("origin") + if origin: + parsed = urlsplit(origin) + if parsed.scheme != request.url.scheme or parsed.netloc != request.url.netloc: + return False + if request.headers.get("sec-fetch-site") not in {None, "none", "same-origin"}: + return False + return True + except ValueError: + return False + + def _check_admin(authorization: str | None): + if LOCAL_MODE: + request = _CURRENT_REQUEST.get() + if not request or not _trusted_local_request(request): + raise HTTPException(status_code=403, detail="Local management requires a trusted local request") + return if ALLOW_NO_ADMIN_AUTH: return candidates = [] @@ -130,10 +162,6 @@ def _check_admin(authorization: str | None): parts = authorization.split(" ", 1) candidates.append(parts[1] if len(parts) == 2 else parts[0]) - request = _CURRENT_REQUEST.get() - if request: - candidates.append(request.cookies.get(ADMIN_COOKIE_NAME, "")) - if not any(t and secrets.compare_digest(t, ADMIN_TOKEN) for t in candidates): raise HTTPException(status_code=401, detail="Invalid admin token") @@ -278,6 +306,7 @@ async def health(): } return { "status": "ok", + "instance": _instance_id(), "version": VERSION, "accounts": len(accounts), "active_accounts": sum(1 for account in accounts if account.get("status") == "active"), @@ -1214,16 +1243,9 @@ async def admin_update_aliases( @app.get("/") async def index(request: Request): - response = FileResponse(str(WEB_DIR / "index.html")) - if ADMIN_TOKEN and not ALLOW_NO_ADMIN_AUTH: - response.set_cookie( - ADMIN_COOKIE_NAME, - ADMIN_TOKEN, - httponly=True, - samesite="lax", - secure=request.url.scheme == "https" or _env_flag("CB_GATEWAY_SECURE_COOKIE"), - max_age=30 * 24 * 3600, - ) + html = (WEB_DIR / "index.html").read_text(encoding="utf-8") + html = html.replace("/* LOCAL_MODE */ false", "true" if LOCAL_MODE else "false") + response = HTMLResponse(html, headers={"Cache-Control": "no-store", "Content-Security-Policy": "frame-ancestors 'none'"}) return response @@ -1231,25 +1253,100 @@ async def index(request: Request): # 启动 # ============================================================ +def _instance_id(): + return hashlib.sha256(str(db.DB_PATH.resolve()).encode()).hexdigest() + + +def _lock_database(): + path = Path(str(db.DB_PATH.resolve()) + ".instance.lock.tmp") + path.parent.mkdir(parents=True, exist_ok=True) + handle = open(path, "a+b") + try: + if os.name == "nt": + import msvcrt + handle.seek(0) + if not handle.read(1): + handle.write(b"0") + handle.flush() + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) + else: + import fcntl + fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + handle.close() + raise RuntimeError("This database is already in use by another Buddy2api instance") + return handle + + +def _open_when_ready(server, url): + while not server.started and not server.should_exit: + time.sleep(0.1) + if server.started: + webbrowser.open(url) + + def main(): - global ADMIN_TOKEN, ALLOW_NO_ADMIN_AUTH + global ADMIN_TOKEN, ALLOW_NO_ADMIN_AUTH, LOCAL_MODE ap = argparse.ArgumentParser(description="Buddy 2 API") ap.add_argument("--host", default="127.0.0.1") ap.add_argument("--port", type=int, default=8787) ap.add_argument("--admin-token", default=os.environ.get("CB_GATEWAY_ADMIN_TOKEN", ""), - help="Admin API token. Defaults to CB_GATEWAY_ADMIN_TOKEN or a generated startup token.") + help="Explicit management token; required for non-loopback listeners.") + ap.add_argument("--no-browser", action="store_true", help="Do not open the local management page") ap.add_argument("--no-admin-auth", action="store_true", - help="Disable Admin API authentication. Only use on trusted local machines.") + help="Use automatic local management access with request-origin validation.") ap.add_argument("--log-level", default="warning", choices=["debug","info","warning","error"], help="Log level") args = ap.parse_args() + if not 1 <= args.port <= 65535: + ap.error("--port must be between 1 and 65535") if args.no_admin_auth and args.host not in {"127.0.0.1", "localhost", "::1"}: ap.error("--no-admin-auth can only be used with a loopback host") - ALLOW_NO_ADMIN_AUTH = args.no_admin_auth - ADMIN_TOKEN = "" if ALLOW_NO_ADMIN_AUTH else (args.admin_token or f"cb-admin-{secrets.token_urlsafe(24)}") + local_host = args.host in {"127.0.0.1", "localhost", "::1"} + if not local_host and not args.admin_token: + ap.error("Remote access requires --admin-token or CB_GATEWAY_ADMIN_TOKEN") + LOCAL_MODE = local_host and (args.no_admin_auth or not args.admin_token) + ALLOW_NO_ADMIN_AUTH = False + ADMIN_TOKEN = "" if LOCAL_MODE else args.admin_token + + host = "127.0.0.1" if args.host == "localhost" else args.host + url_host = f"[{host}]" if ":" in host else host + url = f"http://{url_host}:{args.port}" + listener = socket.socket(socket.AF_INET6 if ":" in host else socket.AF_INET, socket.SOCK_STREAM) + if os.name == "nt": + listener.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1) + else: + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + listener.bind((host, args.port)) + listener.listen(128) + except OSError as exc: + listener.close() + if local_host: + import httpx + for attempt in range(20): + try: + with httpx.Client(trust_env=False, timeout=0.5) as client: + reply = client.get(url + "/health") + info = reply.json() + if reply.status_code == 200 and isinstance(info, dict) and info.get("instance") == _instance_id(): + sys.stderr.write(f"Buddy2api is already running: {url}\n") + if not args.no_browser: + webbrowser.open(url) + return + break + except (httpx.HTTPError, ValueError): + time.sleep(0.25) + ap.error(f"Cannot listen on {url}: {exc}") + try: + instance_lock = _lock_database() + except RuntimeError as exc: + listener.close() + ap.error(str(exc)) db.init_db() @@ -1266,12 +1363,20 @@ def main(): sys.stderr.write( f" 启动导入: {'on' if control_plane.auto_import_enabled() else 'off (CB_GATEWAY_AUTO_IMPORT=1 可打开)'}\n" ) - sys.stderr.write(f" Admin: {'no auth' if ALLOW_NO_ADMIN_AUTH else 'enabled'}\n") + sys.stderr.write(f" Admin: {'local automatic access' if LOCAL_MODE else 'token required'}\n") if ADMIN_TOKEN: sys.stderr.write(" Admin Token: configured (hidden)\n") sys.stderr.write(f" ========================\n\n") - uvicorn.run(app, host=args.host, port=args.port, log_level=args.log_level) + config = uvicorn.Config(app, host=host, port=args.port, log_level=args.log_level, proxy_headers=False) + server = uvicorn.Server(config) + if local_host and not args.no_browser: + threading.Thread(target=_open_when_ready, args=(server, url), daemon=True).start() + try: + server.run(sockets=[listener]) + finally: + listener.close() + instance_lock.close() if __name__ == "__main__": diff --git a/start.bat b/start.bat index fd73a14..063306a 100644 --- a/start.bat +++ b/start.bat @@ -92,4 +92,5 @@ pause exit /b 1 :end -pause +if errorlevel 1 goto failed +exit /b 0 diff --git a/tests/test_local_access.py b/tests/test_local_access.py new file mode 100644 index 0000000..409305f --- /dev/null +++ b/tests/test_local_access.py @@ -0,0 +1,146 @@ +import asyncio +import os +import socket +import subprocess +import sys +import time +from pathlib import Path + +import httpx +import pytest + +import server + + +@pytest.fixture +def local_mode(monkeypatch): + monkeypatch.setattr(server, "LOCAL_MODE", True) + monkeypatch.setattr(server, "ALLOW_NO_ADMIN_AUTH", False) + monkeypatch.setattr(server, "ADMIN_TOKEN", "unused") + monkeypatch.setattr(server.db, "get_all_settings", lambda: {"timeout": 300}) + + +def request(path="/admin/settings", *, peer="127.0.0.1", base="http://127.0.0.1:8787", headers=None): + async def run(): + transport = httpx.ASGITransport(app=server.app, client=(peer, 12345)) + async with httpx.AsyncClient(transport=transport, base_url=base) as client: + return await client.get(path, headers=headers) + return asyncio.run(run()) + + +def test_local_management_works_without_credentials_and_with_stale_credentials(local_mode): + assert request().status_code == 200 + assert request(headers={"Cookie": "cb_gw_admin_token=stale", "Authorization": "Bearer stale"}).status_code == 200 + assert request(headers={"Origin": "http://127.0.0.1:8787", "Sec-Fetch-Site": "same-origin"}).status_code == 200 + + +@pytest.mark.parametrize("kwargs", [ + {"peer": "192.168.1.10"}, + {"peer": "192.168.1.10", "headers": {"X-Forwarded-For": "127.0.0.1"}}, + {"base": "http://attacker.example:8787"}, + {"headers": {"Origin": "https://attacker.example"}}, + {"headers": {"Origin": "http://127.0.0.1:9999"}}, + {"headers": {"Origin": "null"}}, + {"headers": {"Sec-Fetch-Site": "cross-site"}}, + {"headers": {"Sec-Fetch-Site": "same-site"}}, +]) +@pytest.mark.parametrize("path", ["/", "/admin/settings"]) +def test_local_mode_rejects_untrusted_requests(local_mode, kwargs, path): + assert request(path, **kwargs).status_code == 403 + + +@pytest.mark.parametrize("host,peer", [("localhost", "127.0.0.1"), ("[::1]", "::1")]) +def test_local_aliases(local_mode, host, peer): + assert request(base=f"http://{host}:8787", peer=peer).status_code == 200 + + +def test_home_does_not_cache_or_disclose_tokens(local_mode, monkeypatch): + response = request("/") + assert response.headers["cache-control"] == "no-store" + assert "set-cookie" not in response.headers + assert "const localMode=true;" in response.text + monkeypatch.setattr(server, "LOCAL_MODE", False) + monkeypatch.setattr(server, "ADMIN_TOKEN", "private-management-secret") + response = request("/") + assert "set-cookie" not in response.headers + assert "private-management-secret" not in response.text + assert "const localMode=false;" in response.text + assert request().status_code == 401 + assert request(headers={"Cookie": "cb_gw_admin_token=private-management-secret"}).status_code == 401 + assert request(headers={"Authorization": "Bearer private-management-secret"}).status_code == 200 + + +def test_client_api_key_is_still_required(local_mode, monkeypatch): + monkeypatch.setattr(server, "ALLOW_UNAUTHENTICATED_API", False) + monkeypatch.setattr(server.db, "list_api_keys", lambda: []) + assert request("/v1/models").status_code == 503 + monkeypatch.setattr(server.db, "list_api_keys", lambda: [{"id": 1}]) + assert request("/v1/models").status_code == 401 + # Browser-based API clients keep using API keys and the configured CORS policy. + assert request("/v1/models", headers={"Origin": "http://localhost:3000", "Sec-Fetch-Site": "cross-site"}).status_code == 401 + + +def test_database_lock_releases_after_close(tmp_path, monkeypatch): + monkeypatch.setattr(server.db, "DB_PATH", tmp_path / "test.db") + first = server._lock_database() + try: + with pytest.raises(RuntimeError, match="already in use"): + server._lock_database() + finally: + first.close() + server._lock_database().close() + + +def unused_port(): + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +def test_repeated_start_and_restart(tmp_path): + root = Path(server.__file__).parent + port = unused_port() + env = os.environ.copy() + env.pop("CB_GATEWAY_ADMIN_TOKEN", None) + env.update(CB_GATEWAY_DB_PATH=str(tmp_path / "gateway.db"), CB_GATEWAY_AUTO_IMPORT="0", CB_GATEWAY_PROVIDERS="workbuddy", CB_AUTH_DIR=str(tmp_path / "no-auth")) + command = [sys.executable, "server.py", "--no-browser", "--port", str(port)] + with httpx.Client(base_url=f"http://127.0.0.1:{port}", trust_env=False, timeout=1) as client: + # Keep the browser's old credentials across process restarts. + client.cookies.set("cb_gw_admin_token", "stale") + for cycle in range(2): + with open(tmp_path / f"server-{cycle}.log", "w") as log: + process = subprocess.Popen(command, cwd=root, env=env, stdout=log, stderr=log) + try: + deadline = time.monotonic() + 20 + while time.monotonic() < deadline: + assert process.poll() is None, (tmp_path / f"server-{cycle}.log").read_text() + try: + if client.get("/health").status_code == 200: + break + except httpx.HTTPError: + pass + time.sleep(0.1) + else: + pytest.fail("Server did not start") + assert client.get("/admin/settings").status_code == 200 + duplicate = subprocess.run(command, cwd=root, env=env, capture_output=True, text=True, timeout=20) + assert duplicate.returncode == 0, duplicate.stderr + assert "already running" in duplicate.stderr + assert process.poll() is None + assert client.get("/admin/settings").status_code == 200 + conflict = subprocess.run(command[:-1] + [str(unused_port())], cwd=root, env=env, capture_output=True, text=True, timeout=20) + assert conflict.returncode != 0 + assert "already in use" in conflict.stderr + finally: + process.terminate() + process.wait(timeout=10) + + +def test_remote_listener_requires_explicit_token(tmp_path): + env = os.environ.copy() + env.pop("CB_GATEWAY_ADMIN_TOKEN", None) + env["CB_GATEWAY_DB_PATH"] = str(tmp_path / "never-created.db") + result = subprocess.run([sys.executable, server.__file__, "--host", "0.0.0.0", "--no-browser"], env=env, capture_output=True, text=True, timeout=20) + assert result.returncode != 0 + assert "Remote access requires" in result.stderr + assert not (tmp_path / "never-created.db").exists() diff --git a/version.py b/version.py index b82fac5..73bee60 100644 --- a/version.py +++ b/version.py @@ -1 +1 @@ -VERSION = "2.1.5" +VERSION = "2.1.6" diff --git a/web/index.html b/web/index.html index aa10b65..4166606 100644 --- a/web/index.html +++ b/web/index.html @@ -366,13 +366,14 @@ plus:'', copy:'', }; +const localMode=/* LOCAL_MODE */ false; const api={ async get(p,t){const h={};if(t)h.Authorization='Bearer '+t;const r=await fetch(p,{headers:h,credentials:'same-origin'});if(!r.ok)throw new Error(r.status);return r.json()}, async post(p,b,t){const h={'Content-Type':'application/json'};if(t)h.Authorization='Bearer '+t;const r=await fetch(p,{method:'POST',headers:h,body:JSON.stringify(b),credentials:'same-origin'});if(!r.ok)throw new Error(r.status);return r.json()}, async put(p,b,t){const h={'Content-Type':'application/json'};if(t)h.Authorization='Bearer '+t;const r=await fetch(p,{method:'PUT',headers:h,body:JSON.stringify(b),credentials:'same-origin'});if(!r.ok)throw new Error(r.status);return r.json()}, async del(p,t){const h={};if(t)h.Authorization='Bearer '+t;const r=await fetch(p,{method:'DELETE',headers:h,credentials:'same-origin'});if(!r.ok)throw new Error(r.status);return r.json()}, }; -function apiErr(e,fallback='加载失败'){return e.message==='401'?'本机管理凭证无效,请刷新页面;远程访问时可在设置页填写备用 Token。':fallback} +function apiErr(e,fallback='加载失败'){return e.message==='401'?'管理凭证无效,请在设置页填写配置的 Admin Token。':e.message==='403'?'请求来源不被允许,请使用网关的本机地址打开。':fallback} createApp({ setup(){ const validPages=['dashboard','accounts','keys','models','logs','settings']; @@ -388,7 +389,7 @@ template:`
网关状态、额度和调用强度
{{err}}
本机访问通常刷新页面即可重新获取管理凭证。
{{err}}
暂无记录
运行配置、接入信息和本机维护