Skip to content
Open
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
7 changes: 4 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Copy to .env for first use; preserve existing settings when updating.
# Compose loads .env automatically; native launches use uv run --env-file .env.
# Compose and native launches read .env; native zero-configuration startup can omit it.

# Native listener and Compose host mapping; explicit --host/--port override native values.
CODEBUDDY2API_BIND=127.0.0.1
Expand All @@ -15,6 +15,7 @@ CODEBUDDY2API_AUTH_PATH=./auth
# CODEBUDDY_IMPORT_DIR=./auth/imports

# Empty locks management; set a random key before exposing inference beyond loopback.
# Native: omit/comment the variable to use the saved default key (first local start displays it once).
CODEBUDDY2API_KEY=
# Native-only unsafe opt-in; Compose uses its host mapping to control exposure.
# CODEBUDDY2API_ALLOW_OPEN_NOAUTH=false
Expand Down Expand Up @@ -48,9 +49,9 @@ CODEBUDDY2API_MAX_CONCURRENT=64
# Disable only declared model-capability preflight; other safeguards and Intl image merging remain enabled.
# CODEBUDDY2API_MODEL_CAPABILITY_GUARD=true

# SQLite audit defaults to the data directory; this optional path enables a separate text log.
# Retired: this setting only warns; all persistent logs use logs.sqlite3.
CODEBUDDY2API_LOG=
# Redacted text preview bytes; zero logs metadata only.
# Legacy text-preview limit; text output is retired. SQLite diagnostics have a separate budget.
CODEBUDDY2API_LOG_BODY_LIMIT=65536

# International trial credits remain manual-only in the WebUI.
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}

# Add any setup steps before running the `github/codeql-action/init` action.
# This includes steps like installing compilers or runtimes (`actions/setup-node`
Expand Down Expand Up @@ -101,3 +103,5 @@ jobs:
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{matrix.language}}"
ref: ${{ github.event_name == 'pull_request' && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }}
sha: ${{ github.event.pull_request.head.sha || github.sha }}
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ The template binds to localhost only. Configure HTTPS and restrict network acces
## FAQ

- **WebUI sign-in fails behind an HTTPS domain/reverse proxy?** Trust your public origin via `admin_allowed_origins` — see [Management Origin checks](docs/advanced.md#management-origin--csrf-switch).
- **No Docker on the server?** Run the terminal login and the gateway with Python directly — see [Local Python setup](docs/deployment.md#local-python-setup).
- **No Docker?** After installing dependencies and the WebUI, run `uv run converter.py` or `python3 converter.py` without `.env`. First local startup saves a default key and displays it once — see [Local Python setup](docs/deployment.md#local-python-setup).
- **Where is my data?** Everything lives in `auth/` (or `/data/auth` in Docker): credentials, settings and log databases — see [Data and backups](docs/webui.md#data-and-backups).
- **Which image tag should I use?** `latest` follows stable releases, `edge` follows main, version tags pin one release — see [Published images](docs/deployment.md#use-published-images).

Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ docker compose up -d --no-build
## 常见问题

- **绑定域名后经 HTTPS 反代无法登录 WebUI?** 将对外来源加入 `admin_allowed_origins` 信任列表——见[管理 Origin 校验](docs/advanced.zh-CN.md#管理-origin--csrf-开关)。
- **服务器没有 Docker?** 可用 Python 直接完成终端登录并运行网关——见[本地 Python 运行](docs/deployment.zh-CN.md#本地-python-运行)。
- **没有 Docker?** 准备依赖和 WebUI 后,直接 `uv run converter.py` 或 `python3 converter.py`,无需 `.env`;首次本地启动保存默认 key 并仅在终端显示一次——见[本地 Python 运行](docs/deployment.zh-CN.md#本地-python-运行)。
- **数据在哪里?** 全部位于 `auth/`(Docker 中为 `/data/auth`):凭证、设置与日志数据库——见[数据与备份](docs/webui.zh-CN.md#数据与备份)。
- **镜像标签怎么选?** `latest` 跟随稳定版,`edge` 跟随 main,版本标签固定某一发行版——见[使用已发布镜像](docs/deployment.zh-CN.md#使用已发布镜像)。

Expand Down
48 changes: 39 additions & 9 deletions app/admin_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from pathlib import Path
import re
import secrets
import sqlite3
import stat
import tempfile
import threading
Expand All @@ -24,11 +25,7 @@


class SessionStoreError(RuntimeError):
"""A superseded session snapshot could not be durably revoked.

Startup must abort on this: the surviving snapshot could otherwise be adopted by a
later start under the superseded key, resurrecting sessions that were meant to die.
"""
"""Authoritative session state could not be read or durably revoked."""


# Optional persisted session table so a restart does not force another login.
Expand Down Expand Up @@ -132,14 +129,15 @@ def __init__(self, config, *, clock=time.monotonic, wall_clock=time.time):
self.failures = OrderedDict()
self._configured_key = None
self._identity = None
self._path = _session_path(config.get("session_path"))
self._store = config.get("state_store")
self._path = self._store.path if self._store is not None else _session_path(config.get("session_path"))
self._storage_error = None # Set when the snapshot could not be written or cleared.

@staticmethod
def _fingerprint(key):
"""Keyed fingerprint naming the key epoch a snapshot belongs to.

It identifies the epoch; revocation itself is performed by clearing the file.
It identifies the epoch; revocation clears its persisted snapshot.
"""
return hmac.new(key.encode(), _SESSION_KEY_LABEL, hashlib.sha256).hexdigest()

Expand Down Expand Up @@ -179,6 +177,14 @@ def storage(self):

def _revoke(self):
"""Revoke the persisted snapshot; returns False when it could not be cleared."""
if self._store is not None:
try:
self._store.delete("sessions")
except (OSError, ValueError, sqlite3.Error) as error:
self._storage_failed(error)
return False
self._storage_ok()
return True
if self._path is None:
return True
try:
Expand Down Expand Up @@ -209,6 +215,14 @@ def _restore(self, key):
Returns False when a snapshot exists that cannot be trusted or validated,
so the caller revokes it instead of leaving it available to a later start.
"""
if self._store is not None:
try:
document = self._store.get("sessions")
self._storage_ok()
return True if document is None else self._adopt(document, key)
except (OSError, ValueError, sqlite3.Error) as error:
self._storage_failed(error)
raise SessionStoreError("无法读取 SQLite 会话状态;保留原记录,请修复控制库后重启") from None
if self._path is None:
return True
try:
Expand Down Expand Up @@ -237,6 +251,9 @@ def _restore(self, key):
except (ValueError, UnicodeDecodeError, RecursionError):
# RecursionError: size-bounded but deeply nested JSON still exhausts the parser.
return False
return self._adopt(document, key)

def _adopt(self, document, key):
if (not isinstance(document, dict) or set(document) != {"version", "fingerprint", "sessions"}
or type(document["version"]) is not int or document["version"] != SESSION_FILE_VERSION):
return False
Expand Down Expand Up @@ -280,6 +297,14 @@ def _persist(self):
document = {"version": SESSION_FILE_VERSION, "fingerprint": self._fingerprint(self._configured_key),
"sessions": {sid: {"csrf_token": item["csrf_token"], "expires": item["expires"]}
for sid, item in self.sessions.items()}}
if self._store is not None:
try:
self._store.put("sessions", document)
self._storage_ok()
return True
except (OSError, ValueError, sqlite3.Error) as error:
self._storage_failed(error)
return self._revoke()
try:
content = json.dumps(document, ensure_ascii=False, separators=(",", ":"),
allow_nan=False).encode("utf-8")
Expand Down Expand Up @@ -322,7 +347,12 @@ def _key(self):
if restoring:
# Adopt only a snapshot that matches the current epoch; anything else is
# revoked, so switching back to an old key cannot resurrect its sessions.
durable = self._restore(key) or self._revoke()
try:
durable = self._restore(key) or self._revoke()
except SessionStoreError:
self._configured_key = previous
self.sessions.clear()
raise
else:
# A rotated or cleared key revokes every session, on disk as well.
durable = self._persist()
Expand Down Expand Up @@ -452,7 +482,7 @@ async def no_cache(message):
# The key epoch changed but its superseded snapshot survives. Deny rather than
# continue: startup refuses this case, so it is only reachable mid-process.
return await error_response(
503, "会话快照无法持久撤销,管理接口已锁定;请检查管理目录权限后重启")(scope, receive, no_cache)
503, "会话快照无法安全读取或持久化,管理接口已锁定;请检查控制库后重启")(scope, receive, no_cache)
if not enabled:
return await error_response(503, "未配置 API key,管理接口已锁定")(scope, receive, no_cache)
path, method = scope["path"], scope["method"]
Expand Down
2 changes: 1 addition & 1 deletion app/audit_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
METRICS = ("input_tokens", "output_tokens", "cache_read_tokens", "cache_creation_tokens",
"reasoning_tokens", "total_tokens", "credit")
_IDENTIFIER = re.compile(r"^[A-Za-z0-9_.:/@-]{1,160}$")
_SECRET = re.compile(r"(?i)(bearer|sk-|access[_-]?token|refresh[_-]?token|api[_-]?key|eyJ|://)")
_SECRET = re.compile(r"(?i)(bearer|sk-|cb-|access[_-]?token|refresh[_-]?token|api[_-]?key|eyJ|://)")


def safe_label(value: Any, limit: int = 160) -> str | None:
Expand Down
11 changes: 9 additions & 2 deletions app/control_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,13 @@ def validate_model(source, rule, models=None, known_models=(), *, legacy_scopes=


class ControlStore:
SCHEMA_VERSION = 1
SCHEMA_VERSION = 2

def __init__(self, path):
path = Path(path)
existed = path.exists()
path = _secure_path(path)
self.path = path
self._lock = threading.RLock()
self._db = sqlite3.connect(path, check_same_thread=False, isolation_level=None, timeout=1)
try:
Expand All @@ -88,7 +89,7 @@ def __init__(self, path):
self._db.execute("CREATE TABLE control (id INTEGER PRIMARY KEY CHECK(id=1), revision INTEGER NOT NULL, payload TEXT NOT NULL)")
self._db.execute("INSERT INTO control VALUES (1,0,?)", (json.dumps({"settings": {}, "models": {}, "credentials": {}}),))
self._db.execute("PRAGMA user_version=1")
elif version != self.SCHEMA_VERSION:
elif version not in (1, self.SCHEMA_VERSION):
raise ValueError("管理数据库 schema 不受支持或已有库为空,未执行初始化")
self._snapshot = self._load()
self._db.execute("CREATE TABLE IF NOT EXISTS buddy_bootstrap ("
Expand All @@ -106,12 +107,18 @@ def __init__(self, path):
"accept_started INTEGER NOT NULL DEFAULT 0, chat_started INTEGER NOT NULL DEFAULT 0, "
"completed INTEGER NOT NULL DEFAULT 0, model TEXT, chat_state TEXT NOT NULL DEFAULT 'pending', "
"total_tokens INTEGER, updated_at REAL NOT NULL)")
self._db.execute("CREATE TABLE IF NOT EXISTS runtime_state (name TEXT PRIMARY KEY, payload TEXT NOT NULL)")
self._db.execute("CREATE TABLE IF NOT EXISTS state_imports (name TEXT PRIMARY KEY, imported INTEGER NOT NULL, migrated_at REAL NOT NULL)")
self._db.execute("CREATE TABLE IF NOT EXISTS gateway_secrets (name TEXT PRIMARY KEY, value TEXT NOT NULL, announced INTEGER NOT NULL DEFAULT 0 CHECK(announced IN (0,1)))")
self._db.execute("PRAGMA user_version=2")
self._db.execute("COMMIT")
except Exception:
if self._db.in_transaction:
self._db.execute("ROLLBACK")
self._db.close()
raise
from .state_store import StateStore
self.state = StateStore(self)

def _load(self):
row = self._db.execute("SELECT revision,payload FROM control WHERE id=1").fetchone()
Expand Down
41 changes: 16 additions & 25 deletions app/credential_cooldowns.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import json
import os
import re
import stat
import sqlite3
import tempfile
import threading
import time
Expand Down Expand Up @@ -95,8 +95,9 @@ class CredentialCooldowns:
"""Thread-safe, optionally persisted cooldown table keyed by validated account identity."""

def __init__(self, path=None, *, auth_ceiling_s: float = AUTH_CEILING_S,
model_ceiling_s: float = MODEL_CEILING_S):
self.path = str(path) if path else None
model_ceiling_s: float = MODEL_CEILING_S, store=None):
self._store = store
self.path = str(store.path) if store is not None else (str(path) if path else None)
self.auth_ceiling_s = _bounded_ceiling(auth_ceiling_s, AUTH_CEILING_S)
self.model_ceiling_s = _bounded_ceiling(model_ceiling_s, MODEL_CEILING_S)
self._lock = threading.RLock()
Expand All @@ -113,28 +114,8 @@ def __init__(self, path=None, *, auth_ceiling_s: float = AUTH_CEILING_S,

def _load(self):
"""Adopt only a fully valid snapshot; anything else leaves the table empty."""
try:
# Reject a symlink or FIFO *before* opening, so a device cannot block the read.
# This is best effort on Windows and is backed up by the fstat check below.
if not stat.S_ISREG(os.lstat(self.path).st_mode):
return
fd = os.open(self.path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
| getattr(os, "O_NONBLOCK", 0))
except OSError:
return
try:
with os.fdopen(fd, "rb") as stream:
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
return
raw = stream.read(MAX_BYTES + 1)
except OSError:
return
if len(raw) > MAX_BYTES:
return
try:
document = _strict_json(raw)
except (ValueError, UnicodeDecodeError, RecursionError):
return
from .state_store import load_document
document = load_document(self.path, self._store, "cooldowns", MAX_BYTES)
if (not isinstance(document, dict) or set(document) != {"version", "accounts"}
or type(document["version"]) is not int or document["version"] != VERSION):
return
Expand Down Expand Up @@ -204,6 +185,16 @@ def _save_locked(self) -> bool:
self.last_error = "serialize"
self._dirty = True
return False
if self._store is not None:
try:
self._store.put("cooldowns", json.loads(content))
except (OSError, ValueError, sqlite3.Error) as error:
self.last_error = type(error).__name__
self._dirty = True
return False
self.last_error = "capacity" if shed else None
self._dirty = shed
return not shed
temporary = None
try:
directory = os.path.dirname(self.path) or "."
Expand Down
23 changes: 18 additions & 5 deletions app/credits.py
Original file line number Diff line number Diff line change
Expand Up @@ -630,13 +630,17 @@ def fetch_request_usage(access_token: str, days: int = USAGE_MAX_DAYS,
class CreditLedger:
"""Persist per-credential check-in and balance snapshots for expiry-aware scheduling."""

def __init__(self, path: Path):
self.path = Path(path)
def __init__(self, path: Path | None = None, *, store=None):
self._store = store
self.path = Path(store.path if store is not None else path)
self._lock = threading.Lock()
self._data: dict = {"version": 1, "creds": {}}
self._load()

def _load(self):
if self._store is not None:
self._data = self._store.get("credits") or {"version": 1, "creds": {}}
return
try:
self._data = json.loads(self.path.read_text(encoding="utf-8"))
if "creds" not in self._data:
Expand All @@ -645,6 +649,9 @@ def _load(self):
self._data = {"version": 1, "creds": {}}

def _save(self):
if self._store is not None:
self._store.put("credits", self._data)
return
try:
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.path.with_suffix(self.path.suffix + ".tmp")
Expand Down Expand Up @@ -737,17 +744,20 @@ class ModelCatalogCache:

SCHEMA_VERSION = 2

def __init__(self, path: Path, ttl: float = 6 * 3600):
self.path = Path(path)
def __init__(self, path: Path | None = None, ttl: float = 6 * 3600, *, store=None):
self._store = store
self.path = Path(store.path if store is not None else path)
self.ttl = max(60.0, float(ttl or 0))
self._lock = threading.Lock()
self._data: dict = {"version": self.SCHEMA_VERSION, "groups": {}}
self._load()

def _load(self):
with self._lock:
d = (self._store.get("catalog") or self._data) if self._store is not None else None
try:
d = json.loads(self.path.read_text(encoding="utf-8"))
if self._store is None:
d = json.loads(self.path.read_text(encoding="utf-8"))
if (not isinstance(d, dict) or d.get("version") not in (1, self.SCHEMA_VERSION)
or not isinstance(d.get("groups"), dict)):
return
Expand All @@ -773,6 +783,9 @@ def _load(self):
pass # Keep the loaded catalog when disk reads fail.

def _save(self):
if self._store is not None:
self._store.put_cache("catalog", self._data)
return
try:
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.path.with_suffix(self.path.suffix + ".tmp")
Expand Down
Loading
Loading