From 54523175b2c66eccda6c719fc87bb6e97508c279 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E8=8F=8A?= <277378677+maiphucgiang@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:05:00 +0800 Subject: [PATCH 1/5] Record client IP and user agent in the audit log with trusted-proxy unwrap Request rows gain client_ip (real column, schema v3 additive migration, filterable in /admin/logs) and user_agent (payload). Headers are read only for client metadata; forwarded headers count solely when the direct peer is in CODEBUDDY2API_TRUSTED_PROXIES (hot setting, IP/CIDR list), otherwise the direct address wins so clients cannot spoof. The WebUI logs page shows the IP, an IP filter, Chinese labels for event kind/action/outcome and the new fields. --- .env.example | 2 ++ app/admin_api.py | 2 +- app/audit_store.py | 35 +++++++++++++++---- app/observability.py | 65 ++++++++++++++++++++++++++++++++++- app/settings.py | 20 +++++++++++ docs/deployment.md | 1 + docs/deployment.zh-CN.md | 1 + tests/test_audit_store.py | 57 ++++++++++++++++++++++++++++--- tests/test_observability.py | 68 ++++++++++++++++++++++++++++++++++--- web/src/logs.test.tsx | 39 ++++++++++++++++++++- web/src/pages/Logs.tsx | 46 +++++++++++++++++++++---- web/src/values.tsx | 46 +++++++++++++++++++++++++ 12 files changed, 357 insertions(+), 25 deletions(-) diff --git a/.env.example b/.env.example index 71a70d9..b292782 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,8 @@ CODEBUDDY2API_ADMIN_CSRF=true # Extra trusted management origins behind reverse proxies (comma separated; bare domains use HTTPS). # Unset leaves the list configurable in the WebUI; an explicit value locks it. # CODEBUDDY2API_ADMIN_ORIGINS=https://chat.example.com +# 可信代理 IP/CIDR(逗号分隔);命中时审计改记 X-Forwarded-For/X-Real-IP 的真实客户端地址 +# CODEBUDDY2API_TRUSTED_PROXIES=127.0.0.1,10.0.0.0/8 # Unset leaves tool metadata configurable in the WebUI; an explicit Boolean locks it. # CODEBUDDY2API_KEEP_TOOL_METADATA=true diff --git a/app/admin_api.py b/app/admin_api.py index a64d242..029ef37 100644 --- a/app/admin_api.py +++ b/app/admin_api.py @@ -486,7 +486,7 @@ async def logs_get(request): raise ValueError("limit 必须为整数") from None if not 1 <= limit <= 200: raise ValueError("limit 必须为 1 至 200") - filters = {key: params[key] for key in ("model", "credential", "profile", "status", "search") if key in params} + filters = {key: params[key] for key in ("model", "credential", "profile", "status", "search", "client_ip") if key in params} if any(len(value) > 256 for value in filters.values()) or len(params.get("cursor", "")) > 2048: raise ValueError("日志筛选参数过长") result = await run_in_threadpool(audit.list_records, kind, limit, params.get("cursor"), **filters) diff --git a/app/audit_store.py b/app/audit_store.py index 3ab4bdc..c9085c8 100644 --- a/app/audit_store.py +++ b/app/audit_store.py @@ -15,7 +15,7 @@ import uuid from typing import Any -SCHEMA_VERSION = 2 +SCHEMA_VERSION = 3 _CLEANUP_BATCH = 128 _CLEANUP_SECONDS = 0.05 METRICS = ("input_tokens", "output_tokens", "cache_read_tokens", "cache_creation_tokens", @@ -33,6 +33,16 @@ def safe_label(value: Any, limit: int = 160) -> str | None: return value + +def safe_agent(value: Any) -> str | None: + """Single-line printable User-Agent snippet; never header-shaped secrets.""" + if not isinstance(value, str): + return None + cleaned = " ".join(value.split())[:200] + if not cleaned or not all(0x20 <= ord(char) <= 0x7E for char in cleaned): + return None + return None if _SECRET.search(cleaned) else cleaned + def number(value: Any) -> int | float | None: if isinstance(value, bool) or not isinstance(value, (int, float)): return None @@ -119,7 +129,7 @@ def __init__(self, path, max_bytes=256 * 1024 * 1024, retention_days=30, self._db.execute("BEGIN IMMEDIATE") version = self._db.execute("PRAGMA user_version").fetchone()[0] tables = self._db.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall() - if version not in (0, 1, SCHEMA_VERSION) or (version == 0 and tables): + if version not in (0, 1, 2, SCHEMA_VERSION) or (version == 0 and tables): raise ValueError("unsupported audit schema") self._db.execute("CREATE TABLE IF NOT EXISTS state (id INTEGER PRIMARY KEY CHECK(id=1), epoch INTEGER NOT NULL, detail_generation INTEGER NOT NULL, cleared_at REAL NOT NULL)") self._db.execute("INSERT OR IGNORE INTO state VALUES(1,0,0,0)") @@ -133,6 +143,7 @@ def __init__(self, path, max_bytes=256 * 1024 * 1024, retention_days=30, for table in ("stats_hourly", "stats_daily", "stats_totals"): self._db.execute(f"CREATE TABLE IF NOT EXISTS {table} (bucket INTEGER NOT NULL, dimension TEXT NOT NULL, dimension_key TEXT NOT NULL, payload TEXT NOT NULL, PRIMARY KEY(bucket,dimension,dimension_key))") self._migrate_ingest_time() + self._migrate_client_ip() self._db.execute(f"PRAGMA user_version={SCHEMA_VERSION}") self._epoch = self._db.execute("SELECT epoch FROM state").fetchone()[0] self._db.execute("COMMIT") @@ -150,6 +161,12 @@ def _migrate_ingest_time(self): (SELECT started_at FROM events WHERE events.id=ingest.id), ?)""", (time.time(),)) self._db.execute("CREATE INDEX IF NOT EXISTS ingest_time ON ingest(created_at)") + def _migrate_client_ip(self): + # Additive v3 migration: record the audit-visible client address per request. + columns = [row[1] for row in self._db.execute("PRAGMA table_info(requests)").fetchall()] + if "client_ip" not in columns: + self._db.execute("ALTER TABLE requests ADD COLUMN client_ip TEXT") + def _init_accounting(self): # Additive v1 migration: scan legacy details only once, under the same # write transaction that installs triggers. Reopening never recounts. @@ -265,6 +282,8 @@ def _aggregate(self, record): def _sanitize_record(self, source): result = {key: safe_label(source.get(key)) for key in ("upstream_model", "profile", "credential", "protocol", "error_code", "usage_source")} + result["client_ip"] = safe_label(source.get("client_ip")) + result["user_agent"] = safe_agent(source.get("user_agent")) result["stream_mode"] = (source.get("stream_mode") if source.get("stream_mode") in ("compatible", "realtime") else None) projection_mode = source.get("responses_projection_mode") @@ -318,10 +337,12 @@ def commit(): (generation is None or generation == state["detail_generation"])) if keep: payload = json.dumps(data, separators=(",", ":")) - self._db.execute("INSERT INTO requests VALUES(?,?,?,?,?,?,?,?,?)", ( - data["id"], data["started_at"], data["model"], data["profile"], data["credential"], - data["outcome"], data["status_code"], payload, - len(payload.encode()) + sum(len(json.dumps(a).encode()) + 32 for a in data["attempts"]) + 256)) + self._db.execute( + "INSERT INTO requests(id,started_at,model,profile,credential,outcome,status_code,payload,logical_bytes,client_ip) VALUES(?,?,?,?,?,?,?,?,?,?)", + (data["id"], data["started_at"], data["model"], data["profile"], data["credential"], + data["outcome"], data["status_code"], payload, + len(payload.encode()) + sum(len(json.dumps(a).encode()) + 32 for a in data["attempts"]) + 256, + data["client_ip"])) for i, attempt in enumerate(data["attempts"]): self._db.execute("INSERT INTO attempts VALUES(?,?,?)", (data["id"], i, json.dumps(attempt))) self._prune() @@ -435,7 +456,7 @@ def list_records(self, kind="request", limit=50, cursor=None, **filters): clauses.append("(started_at OR (started_at=? AND id))") params.extend((stamp, stamp, record_id)) if kind == "request": - for key in ("model", "profile", "credential", "outcome", "status_code"): + for key in ("model", "profile", "credential", "outcome", "status_code", "client_ip"): value = filters.get(key) if value is not None and value != "": clauses.append(f"{key}=?") diff --git a/app/observability.py b/app/observability.py index 80ae552..e01da6d 100644 --- a/app/observability.py +++ b/app/observability.py @@ -1,4 +1,7 @@ """Observe inference metadata with bounded response parsing; never inspect request bodies or headers. + +The only headers read are audit client metadata (user-agent and forwarded-for +chains behind a configured trusted proxy); they are never forwarded upstream. Preserve the ASGI wire and exclude credential files or tokens from route metadata. """ from __future__ import annotations @@ -10,14 +13,71 @@ import time from typing import Any -from app.audit_store import AuditStore, METRICS, number, safe_attempt, safe_label +from app.audit_store import AuditStore, METRICS, number, safe_agent, safe_attempt, safe_label from app.request_context import current_context, ensure_context _PATHS = {"/v1/chat/completions": "chat", "/v1/responses": "responses", "/v1/messages": "messages"} _PARSE_LIMIT = 16384 +_META_HEADERS = ("user-agent", "x-forwarded-for", "x-real-ip") +_proxy_networks: dict[str, tuple] = {} _current: ContextVar[_Observation | None] = ContextVar("audit_observation", default=None) +def _trusted_networks(raw): + """Parse a normalized trusted_proxies setting; cached per setting value.""" + if not isinstance(raw, str) or not raw.strip(): + return () + cached = _proxy_networks.get(raw) + if cached is None: + import ipaddress + try: + cached = tuple(ipaddress.ip_network(entry.strip(), strict=False) + for entry in raw.split(",") if entry.strip()) + except ValueError: + return () + if len(_proxy_networks) > 8: + _proxy_networks.clear() + _proxy_networks[raw] = cached + return cached + + +def client_meta(scope, trusted_proxies): + """(client_ip, user_agent) for the audit record. + + Forwarded headers count only when the direct peer is a trusted proxy; + otherwise they are ignored so clients cannot spoof their address. + """ + headers = {} + for key, value in scope.get("headers") or []: + name = key.decode("latin-1").lower() if isinstance(key, (bytes, bytearray)) else str(key).lower() + if name in _META_HEADERS and name not in headers: + headers[name] = value.decode("latin-1", "replace").strip() if isinstance(value, (bytes, bytearray)) else str(value).strip() + client = scope.get("client") + remote = client[0] if client and isinstance(client[0], str) else None + networks = _trusted_networks(trusted_proxies) + try: + import ipaddress + remote_ip = ipaddress.ip_address(remote) if remote else None + except ValueError: + remote_ip = None + ip = remote + if remote_ip is not None and any(remote_ip in network for network in networks): + hops = [hop.strip() for hop in (headers.get("x-forwarded-for") or "").split(",") if hop.strip()][-32:] + chosen = None + for hop in reversed(hops): + try: + hop_ip = ipaddress.ip_address(hop) + except ValueError: + break + chosen = str(hop_ip) + if not any(hop_ip in network for network in networks): + break + if chosen is None: + chosen = headers.get("x-real-ip") or (hops[0] if hops else None) + ip = chosen or remote + return ip, safe_agent(headers.get("user-agent")) + + def _mapping(value): return value if isinstance(value, dict) else {} @@ -333,6 +393,9 @@ async def lifespan_receive(): "detail_generation": -1, "started_at": started, "protocol": _PATHS[scope["path"]], **{key: None for key in METRICS}}, monotonic_start) + observation.record.update(zip(("client_ip", "user_agent"), + client_meta(scope, self.config.get("trusted_proxies") + if isinstance(self.config, dict) else None))) token = _current.set(observation) parser = _Parser(observation) cancelled = False diff --git a/app/settings.py b/app/settings.py index de4f7a5..ca33ba2 100644 --- a/app/settings.py +++ b/app/settings.py @@ -33,6 +33,23 @@ def normalize_allowed_origins(value): return ",".join(normalized) +def normalize_trusted_proxies(value): + """Normalize a comma/space separated list of trusted proxy IPs or CIDRs.""" + import ipaddress + entries = [entry for entry in re.split(r"[\s,]+", value.strip()) if entry] + if len(entries) > 32: + raise ValueError("trusted_proxies: 代理数量超出上限") + normalized = [] + for entry in entries: + try: + network = str(ipaddress.ip_network(entry, strict=False)) + except ValueError: + raise ValueError(f"trusted_proxies: 地址无效 {entry!r}") from None + if network not in normalized: + normalized.append(network) + return ",".join(normalized) + + def validate_projection_max_bytes(value): """Allow zero or enough room for a bounded head/tail warning.""" if value != 0 and value < 256: @@ -105,6 +122,9 @@ def _item(default, type_, label, *, mode="hot", env=None, minimum=None, maximum= "audit_max_bytes": _item(256 * 1024 * 1024, "integer", "审计明细预算", minimum=1024**2, maximum=1024**4), "audit_retention_days": _item(30, "integer", "审计明细保留天数", minimum=1, maximum=36500), "audit_diagnostic_bytes": _item(8192, "integer", "失败诊断最大字节", minimum=0, maximum=8192), + "trusted_proxies": _item("", "string", "可信代理地址(IP/CIDR,逗号分隔)", + env="CODEBUDDY2API_TRUSTED_PROXIES", allow_empty=True, + max_length=2000, validator=normalize_trusted_proxies), } diff --git a/docs/deployment.md b/docs/deployment.md index 8a1e49a..ee8490a 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -13,6 +13,7 @@ On first setup, run `cp .env.example .env` and set `CODEBUDDY2API_KEY` to your o | `CODEBUDDY2API_IMAGE` | Compose image; the template uses `codebuddy2api:local` | | `CODEBUDDY2API_BIND` / `CODEBUDDY2API_PORT` | Native listener and Compose host mapping; default `127.0.0.1:8787` | | `CODEBUDDY2API_AUTH_PATH` | Compose host data directory; defaults to `./auth`, mounted at `/data/auth` | +| `CODEBUDDY2API_TRUSTED_PROXIES` | Trusted proxy IPs/CIDRs (comma separated); when the direct peer matches, the audit log records the client from `X-Forwarded-For`/`X-Real-IP` instead of the proxy address | | `CODEBUDDY_AUTH_DIR` | Local Python data directory; defaults to the repository's `auth/`. Compose sets it to `/data/auth` inside the container | | `CODEBUDDY_IMPORT_DIR` | Optional import directory; defaults to `imports/` under the data directory. Use container paths with Compose | The example lists all active runtime variables, including inbound/aggregate byte limits, concurrency, tool retries and failover. Compose forwards these limits; unset optional settings (tool metadata, Responses projection, origin allowlist, failover and similar) remain configurable in the WebUI. Zero disables aggregate/concurrency limits, per-item Responses trimming or extra retries, not the required positive inbound limit. diff --git a/docs/deployment.zh-CN.md b/docs/deployment.zh-CN.md index be1f8c9..1accfcf 100644 --- a/docs/deployment.zh-CN.md +++ b/docs/deployment.zh-CN.md @@ -13,6 +13,7 @@ | `CODEBUDDY2API_IMAGE` | Compose 镜像;模板默认 `codebuddy2api:local` | | `CODEBUDDY2API_BIND` / `CODEBUDDY2API_PORT` | 本地监听与 Compose 宿主机映射;默认 `127.0.0.1:8787` | | `CODEBUDDY2API_AUTH_PATH` | Compose 宿主机数据目录;默认 `./auth`,挂载到容器 `/data/auth` | +| `CODEBUDDY2API_TRUSTED_PROXIES` | 可信代理 IP/CIDR(逗号分隔);直连地址命中时审计改记 `X-Forwarded-For`/`X-Real-IP` 里的真实客户端地址,而不是代理地址 | | `CODEBUDDY_AUTH_DIR` | 本地 Python 数据目录;默认仓库内 `auth/`。Compose 在容器内固定为 `/data/auth` | | `CODEBUDDY_IMPORT_DIR` | 可选导入目录;默认数据目录下的 `imports/`。Compose 中请使用容器路径 | diff --git a/tests/test_audit_store.py b/tests/test_audit_store.py index 01d080e..c8d9fe7 100644 --- a/tests/test_audit_store.py +++ b/tests/test_audit_store.py @@ -12,7 +12,7 @@ from unittest.mock import patch sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -from app.audit_store import AuditStore, _CLEANUP_BATCH +from app.audit_store import SCHEMA_VERSION, AuditStore, _CLEANUP_BATCH class AuditStoreTests(unittest.TestCase): @@ -94,7 +94,7 @@ def test_all_dimensions_and_periods_persist_without_details(self): self.store.record_request(self.record(input_tokens=0)) self.store.clear("details") with closing(sqlite3.connect(self.path)) as db: - self.assertEqual(db.execute("PRAGMA user_version").fetchone()[0], 2) + self.assertEqual(db.execute("PRAGMA user_version").fetchone()[0], SCHEMA_VERSION) for table in ("stats_hourly", "stats_daily", "stats_totals"): rows = db.execute(f"SELECT dimension,payload FROM {table}").fetchall() self.assertEqual({r[0] for r in rows}, {"global", "model", "profile", "credential"}) @@ -148,7 +148,7 @@ def test_v1_ingest_table_migrates_with_backfilled_timestamps(self): store = AuditStore(path) try: with closing(sqlite3.connect(str(path))) as db: - self.assertEqual(db.execute("PRAGMA user_version").fetchone()[0], 2) + self.assertEqual(db.execute("PRAGMA user_version").fetchone()[0], SCHEMA_VERSION) columns = [row[1] for row in db.execute("PRAGMA table_info(ingest)").fetchall()] self.assertIn("created_at", columns) self.assertGreater(db.execute( @@ -520,8 +520,9 @@ def counted(): with closing(sqlite3.connect(self.path)) as fixture: first = samples[-1][0] if samples else 0 for index in range(first, population): - fixture.execute("INSERT INTO requests VALUES(?,?,?,?,?,?,?,?,?)", ( - f"seed{index}", stamp, None, None, None, "success", 200, "{}", 256)) + fixture.execute( + "INSERT INTO requests(id,started_at,model,profile,credential,outcome,status_code,payload,logical_bytes) VALUES(?,?,?,?,?,?,?,?,?)", + (f"seed{index}", stamp, None, None, None, "success", 200, "{}", 256)) fixture.execute("INSERT INTO attempts VALUES(?,?,?)", (f"seed{index}", 0, "{}")) fixture.execute("INSERT INTO events VALUES(?,?,?,?,?,?)", ( f"event{index}", stamp, "runtime", "test", "{}", 128)) @@ -586,6 +587,52 @@ def test_closed_store_degrades_instead_of_throwing(self): self.assertFalse(self.store.record_request(record)["ok"]) self.assertTrue(self.store.storage()["degraded"]) + def test_client_ip_and_agent_are_sanitized_persisted_and_filterable(self): + good = self.record(id="meta-1", client_ip="2001:db8::1", user_agent="synthetic-agent/1.2") + self.assertTrue(self.store.record_request(good)["ok"]) + dirty = self.record(id="meta-2", client_ip="fe80::1%eth0", + user_agent="evil\nBearer sk-secret\r\nX") + self.assertTrue(self.store.record_request(dirty)["ok"]) + items = {item["id"]: item for item in self.store.list_records()["items"]} + self.assertEqual(items["meta-1"]["client_ip"], "2001:db8::1") + self.assertEqual(items["meta-1"]["user_agent"], "synthetic-agent/1.2") + self.assertIsNone(items["meta-2"]["client_ip"]) + self.assertIsNone(items["meta-2"]["user_agent"]) + self.assertEqual(self.store.list_records(client_ip="2001:db8::1")["items"][0]["id"], "meta-1") + self.assertEqual(self.store.list_records(client_ip="198.51.100.1")["items"], []) + + def test_v2_database_gains_client_ip_without_losing_rows(self): + legacy_path = Path(self.temp.name) / "private" / "v2.sqlite3" + raw = sqlite3.connect(legacy_path) + with raw: + raw.execute("CREATE TABLE state (id INTEGER PRIMARY KEY CHECK(id=1), epoch INTEGER NOT NULL," + " detail_generation INTEGER NOT NULL, cleared_at REAL NOT NULL)") + raw.execute("INSERT INTO state VALUES(1,0,0,0)") + raw.execute("CREATE TABLE requests (id TEXT PRIMARY KEY, started_at REAL NOT NULL, model TEXT," + " profile TEXT, credential TEXT, outcome TEXT, status_code INTEGER," + " payload TEXT NOT NULL, logical_bytes INTEGER NOT NULL)") + raw.execute( + "INSERT INTO requests VALUES('legacy-1', ?, 'm', NULL, NULL, 'success', 200, ?, 128)", + (time.time(), json.dumps({"id": "legacy-1", "model": "m", "outcome": "success", + "status_code": 200, "attempts": []}))) + raw.execute("PRAGMA user_version=2") + raw.close() + reopened = AuditStore(legacy_path) + self.addCleanup(reopened.close) + self.assertIn("client_ip", + [c[1] for c in reopened._db.execute("PRAGMA table_info(requests)")]) + items = reopened.list_records()["items"] + self.assertEqual([item["id"] for item in items], ["legacy-1"]) + self.assertIsNone(items[0].get("client_ip")) + self.assertTrue(reopened.record_request( + {"id": "meta-3", **reopened.ticket(), "started_at": time.time(), + "public_model": "test-model", "client_ip": "203.0.113.9", + "protocol": "chat", "status_code": 200, "outcome": "success"})["ok"]) + self.assertEqual(reopened.list_records(client_ip="203.0.113.9")["items"][0]["id"], "meta-3") + accounting = reopened._db.execute("SELECT request_count FROM detail_accounting").fetchone()[0] + raw_count = reopened._db.execute("SELECT COUNT(*) FROM requests").fetchone()[0] + self.assertEqual(accounting, raw_count) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_observability.py b/tests/test_observability.py index 332528a..4bc520e 100644 --- a/tests/test_observability.py +++ b/tests/test_observability.py @@ -10,7 +10,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from app.audit_store import AuditStore -from app.observability import (AuditMiddleware, _Observation, _Parser, normalize_usage, +from app.observability import (AuditMiddleware, _Observation, _Parser, client_meta, normalize_usage, observe_attempt, observe_failure, observe_responses_projection, observe_route, observe_usage) @@ -23,14 +23,19 @@ async def asyncTearDown(self): self.store.close() self.temp.cleanup() - async def invoke(self, app, path="/v1/chat/completions", method="POST", receiver=None, sender=None): + async def invoke(self, app, path="/v1/chat/completions", method="POST", receiver=None, sender=None, + attrs=None, trusted_proxies=None): sent = [] async def receive(): self.fail("request body must not be inspected by middleware") async def send(message): sent.append(message) - middleware = AuditMiddleware(app, {"audit_store": self.store}) - await middleware({"type": "http", "method": method, "path": path}, receiver or receive, sender or send) + config = {"audit_store": self.store} + if trusted_proxies is not None: + config["trusted_proxies"] = trusted_proxies + middleware = AuditMiddleware(app, config) + scope = {"type": "http", "method": method, "path": path, **(attrs or {})} + await middleware(scope, receiver or receive, sender or send) return sent def only_record(self): @@ -339,6 +344,23 @@ async def app(scope, receive, send): await task self.assertEqual(self.only_record()["outcome"], "cancelled") + async def test_client_meta_recorded_and_proxy_unwrapped(self): + async def app(scope, receive, send): + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b"{}"}) + attrs = {"client": ("127.0.0.1", 4000), + "headers": [(b"x-forwarded-for", b"198.51.100.9"), (b"user-agent", b"ua-test/2.0")]} + await self.invoke(app, attrs=attrs, trusted_proxies="127.0.0.1") + record = self.only_record() + self.assertEqual(record["client_ip"], "198.51.100.9") + self.assertEqual(record["user_agent"], "ua-test/2.0") + self.store.clear("all") + # Without a trusted proxy the forwarded header must not be trusted. + await self.invoke(app, attrs=attrs) + record = self.only_record() + self.assertEqual(record["client_ip"], "127.0.0.1") + self.assertEqual(record["user_agent"], "ua-test/2.0") + async def test_failure_hook_preserves_specific_code_on_truncated_stream(self): async def app(scope, receive, send): await send({"type": "http.response.start", "status": 200, @@ -394,5 +416,43 @@ def test_usage_invalid_values_are_unknown(self): self.assertEqual(result, {"credit": 0, "cache_read_tokens": 0}) +class ClientMetaTests(unittest.TestCase): + def scope(self, client=("203.0.113.7", 443), headers=()): + return {"type": "http", "client": client, + "headers": [(key.encode(), value.encode()) for key, value in headers]} + + def test_untrusted_direct_peer_ignores_forwarded_headers(self): + scope = self.scope(headers=[("X-Forwarded-For", "9.9.9.9"), ("User-Agent", "synthetic/1.0")]) + self.assertEqual(client_meta(scope, "127.0.0.1"), ("203.0.113.7", "synthetic/1.0")) + self.assertEqual(client_meta(scope, ""), ("203.0.113.7", "synthetic/1.0")) + + def test_trusted_proxy_unwraps_rightmost_untrusted_hop(self): + scope = self.scope(client=("127.0.0.1", 8080), + headers=[("X-Forwarded-For", "198.51.100.9, 10.0.0.9")]) + self.assertEqual(client_meta(scope, "127.0.0.1,10.0.0.0/8")[0], "198.51.100.9") + + def test_forged_entries_beyond_the_trusted_chain_are_ignored(self): + scope = self.scope(client=("127.0.0.1", 8080), + headers=[("X-Forwarded-For", "6.6.6.6, 198.51.100.9, 10.0.0.9")]) + self.assertEqual(client_meta(scope, "127.0.0.1, 10.0.0.0/8")[0], "198.51.100.9") + + def test_all_trusted_chain_falls_back_to_leftmost_and_real_ip(self): + chain = self.scope(client=("127.0.0.1", 1), + headers=[("X-Forwarded-For", "10.0.0.9, 10.0.1.9")]) + self.assertEqual(client_meta(chain, "127.0.0.1, 10.0.0.0/8")[0], "10.0.0.9") + real = self.scope(client=("127.0.0.1", 1), headers=[("X-Real-IP", "198.51.100.9")]) + self.assertEqual(client_meta(real, "127.0.0.1")[0], "198.51.100.9") + + def test_user_agent_is_sanitized(self): + dirty = self.scope(headers=[("User-Agent", "a\nb Bearer x y")]) + self.assertIsNone(client_meta(dirty, "")[1]) + long = self.scope(headers=[("User-Agent", "x" * 500)]) + self.assertEqual(client_meta(long, "")[1], "x" * 200) + + def test_missing_client_scope_still_records_agent(self): + scope = {"type": "http", "headers": [(b"user-agent", b"ua/9")]} + self.assertEqual(client_meta(scope, ""), (None, "ua/9")) + + if __name__ == "__main__": unittest.main() diff --git a/web/src/logs.test.tsx b/web/src/logs.test.tsx index e8aa07a..7fcd7ad 100644 --- a/web/src/logs.test.tsx +++ b/web/src/logs.test.tsx @@ -23,7 +23,7 @@ beforeEach(() => { vi.mocked(useResource).mockReturnValue({ data: { items: [ - { id: "request-a", model: "model-a", outcome: "success" }, + { id: "request-a", model: "model-a", outcome: "success", client_ip: "198.51.100.9" }, { id: "request-b", model: "model-b", outcome: "success" }, ], has_more: false, @@ -43,6 +43,43 @@ function close() { fireEvent.click(screen.getByRole("button", { name: "关闭抽屉" })); } +describe("runtime event labels", () => { + it("renders kind, action and outcome in Chinese with raw values on hover", () => { + vi.mocked(useResource).mockReturnValue({ + data: { + items: [ + { id: "event-1", kind: "runtime", action: "credential.sync", started_at: 1 }, + { id: "event-2", kind: "admin", action: "mystery.action", details: { outcome: "success" } }, + ], + has_more: false, + next_cursor: null, + }, + loading: false, + error: null, + reload: vi.fn(), + }); + render(); + fireEvent.click(screen.getByRole("tab", { name: "运行事件" })); + expect(screen.getByTitle("credential.sync").textContent).toBe("凭证同步"); + expect(screen.getByTitle("runtime").textContent).toBe("运行事件"); + expect(screen.getByText("mystery.action")).toBeTruthy(); + expect(screen.getByText("成功")).toBeTruthy(); + }); +}); + +describe("request client metadata", () => { + it("shows the client IP and filters requests by it", () => { + render(); + expect(screen.getByText("198.51.100.9")).toBeTruthy(); + fireEvent.change(screen.getByLabelText("筛选客户端 IP"), { + target: { value: "198.51.100.9" }, + }); + fireEvent.click(screen.getByRole("button", { name: "应用筛选" })); + const call = vi.mocked(useResource).mock.calls.at(-1)![0] as string; + expect(call).toContain("client_ip=198.51.100.9"); + }); +}); + describe("log detail request lifetime", () => { it("does not reopen a closed drawer when its response arrives", async () => { const request = pending(); diff --git a/web/src/pages/Logs.tsx b/web/src/pages/Logs.tsx index 3f1a44e..7c42a2f 100644 --- a/web/src/pages/Logs.tsx +++ b/web/src/pages/Logs.tsx @@ -22,6 +22,7 @@ import { Panel, ResourceState, } from "../components"; +import { eventActionLabel, eventKindLabel } from "../values"; import { profiles } from "./Models"; import s from "../ui.module.scss"; function normalize(value: unknown) { @@ -35,7 +36,20 @@ function normalize(value: unknown) { degraded: data.degraded === true, }; } -const emptyFilters = { model: "", credential: "", profile: "", status: "", search: "" }; +const emptyFilters = { + model: "", + credential: "", + profile: "", + status: "", + client_ip: "", + search: "", +}; +const outcomeLabels: Record = { + success: "成功", + error: "失败", + cancelled: "取消", + warning: "警告", +}; export function Logs() { const [kind, setKind] = useState("request"); const [draft, setDraft] = useState(emptyFilters); @@ -153,6 +167,12 @@ export function Logs() { value={draft.credential} onChange={(e) => setDraft({ ...draft, credential: e.target.value })} /> + setDraft({ ...draft, client_ip: e.target.value })} + /> {text(item.id)} - {text(kind === "request" ? item.model : item.action)} - {text(kind === "request" ? item.profile : item.kind)} + + {text( + kind === "request" + ? item.model + : (eventActionLabel(item.action) ?? item.action), + )} + + + {text( + kind === "request" + ? item.profile + : (eventKindLabel(item.kind) ?? item.kind), + )} + + {kind === "request" && item.client_ip ? ( + {text(item.client_ip)} + ) : null} - {outcome === "warning" - ? "警告" - : text(outcome ?? item.level ?? item.status)} + {outcomeLabels[outcome] ?? + text(outcome ?? item.level ?? item.status)} {item.status_code !== undefined && ( HTTP {text(item.status_code)} diff --git a/web/src/values.tsx b/web/src/values.tsx index 4d36a34..1fbe6e1 100644 --- a/web/src/values.tsx +++ b/web/src/values.tsx @@ -143,6 +143,8 @@ const labels: Record = { schema_version: "存储版本", epoch: "统计代次", degraded: "存储降级", + client_ip: "客户端 IP", + user_agent: "客户端 UA", failure_count: "失败次数", dropped_records: "未写入记录数", closed: "存储已关闭", @@ -241,6 +243,50 @@ const ownLabel = (map: Record, key: string) => export function profileLabel(value: string) { return ownLabel(profiles, value) ?? value; } +const eventKinds: Record = { + runtime: "运行事件", + admin: "管理操作", +}; +const eventActions: Record = { + credits: "积分", + cred: "凭证", + models: "模型目录", + usage: "用量", + checkin: "签到", + dailychat: "每日对话", + buddy: "首领", + "buddy.attention_required": "首领需要关注", + "buddy.consent": "首领同意", + "buddy.agreement": "首领协议", + "buddy.departure": "首领出发", + "buddy.departure_requested": "首领请求出发", + "buddy.first": "首领领取", + "buddy.task_accept": "首领任务接取", + "buddy.task_chat": "首领任务对话", + "buddy.task_completed": "首领任务完成", + "buddy.authorization_used": "首领授权已使用", + "credential.sync": "凭证同步", + "credential.travel": "凭证旅行", + "credential.travel-status": "凭证旅行状态", + "credential.refresh": "凭证刷新", + "credential.checkin": "凭证签到", + "credential.daily-chat": "凭证每日对话", + "credential.auto_daily_chat": "凭证自动每日对话", + "credential.trial": "凭证试用", + "credential.enabled": "凭证启停", + "credential.reset-cooldown": "凭证清除冷却", + "oauth.saved": "OAuth 凭证已保存", + "settings.updated": "设置已更新", + "model.created": "模型已创建", + "model.updated": "模型已更新", + "model.deleted": "模型已删除", +}; +export function eventKindLabel(kind: unknown) { + return typeof kind === "string" ? ownLabel(eventKinds, kind) : undefined; +} +export function eventActionLabel(action: unknown) { + return typeof action === "string" ? ownLabel(eventActions, action) : undefined; +} export function bytes(value: number) { if (value < 1024) return `${metric(value)} B`; if (value < 1048576) return `${(value / 1024).toFixed(1)} KiB`; From 10ebad4a38b9dd920f289f3ebbf68f3e7484e149 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E8=8F=8A?= <277378677+maiphucgiang@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:09:59 +0800 Subject: [PATCH 2/5] Fix logs badge typing and formatting --- web/src/logs.test.tsx | 7 ++++++- web/src/pages/Logs.tsx | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/web/src/logs.test.tsx b/web/src/logs.test.tsx index 7fcd7ad..f006312 100644 --- a/web/src/logs.test.tsx +++ b/web/src/logs.test.tsx @@ -49,7 +49,12 @@ describe("runtime event labels", () => { data: { items: [ { id: "event-1", kind: "runtime", action: "credential.sync", started_at: 1 }, - { id: "event-2", kind: "admin", action: "mystery.action", details: { outcome: "success" } }, + { + id: "event-2", + kind: "admin", + action: "mystery.action", + details: { outcome: "success" }, + }, ], has_more: false, next_cursor: null, diff --git a/web/src/pages/Logs.tsx b/web/src/pages/Logs.tsx index 7c42a2f..9ce3456 100644 --- a/web/src/pages/Logs.tsx +++ b/web/src/pages/Logs.tsx @@ -281,7 +281,7 @@ export function Logs() { : "neutral" } > - {outcomeLabels[outcome] ?? + {(typeof outcome === "string" ? outcomeLabels[outcome] : undefined) ?? text(outcome ?? item.level ?? item.status)} {item.status_code !== undefined && ( From dfd0f15525042889e8fab8ee6ec0f95a86393678 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E8=8F=8A?= <277378677+maiphucgiang@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:30:05 +0800 Subject: [PATCH 3/5] Add bilingual WebUI with a language toggle and Chinese log labels Variable-key i18n (zh/en dictionaries both loaded, zh default, persisted) with a top-left toggle in the sidebar and login pages. The audit logs page is fully translated (tabs, filters, columns, drawer, clear dialog), event kind/action and outcome render from label keys, and empty values show a neutral dash instead of asserting unknown. Dashboard, Trend and the shared chrome are translated; Models/Credentials/Settings keep zh strings until their keys land. --- web/src/App.tsx | 94 ++++---- web/src/Trend.tsx | 12 +- web/src/api.test.tsx | 4 +- web/src/api.ts | 8 +- web/src/components.tsx | 45 ++-- web/src/copy.test.tsx | 2 +- web/src/i18n.test.tsx | 44 ++++ web/src/i18n.tsx | 453 +++++++++++++++++++++++++++++++++++ web/src/interaction.test.tsx | 2 +- web/src/pages/Dashboard.tsx | 121 ++++++---- web/src/pages/Logs.tsx | 119 +++++---- web/src/values.tsx | 78 +++--- 12 files changed, 767 insertions(+), 215 deletions(-) create mode 100644 web/src/i18n.test.tsx create mode 100644 web/src/i18n.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index d0fd1c3..3865bdd 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -9,20 +9,22 @@ import { Logs } from "./pages/Logs"; import { Settings } from "./pages/Settings"; import { Appearance } from "./appearance"; import s from "./ui.module.scss"; +import { LanguageProvider, LanguageToggle, useT } from "./i18n"; const navigation = [ - ["", "概览", "grid"], - ["/models", "模型路由", "model"], - ["/credentials", "凭证管理", "key"], - ["/logs", "日志审计", "logs"], - ["/settings", "系统设置", "settings"], + ["", "nav.overview", "grid"], + ["/models", "nav.models", "model"], + ["/credentials", "nav.credentials", "key"], + ["/logs", "nav.logs", "logs"], + ["/settings", "nav.settings", "settings"], ]; export function Guard() { + const t = useT(); const { status, error } = useSession(); if (status === "checking") return ( - 正在验证登录状态… + {t("app.verifying")} ); if (status === "error") @@ -34,12 +36,13 @@ export function Guard() { void establishSession(); }} /> - 返回登录 + {t("app.backToSignIn")} ); return status === "authenticated" ? : ; } function Shell() { + const t = useT(); const location = useLocation(); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); @@ -62,61 +65,66 @@ function Shell() { return ( - 跳转到内容 + {t("app.skip")} - 工作空间 /{" "} - {navigation.find(([path]) => `/dashboard${path}` === location.pathname)?.[1] ?? "概览"} + {t("app.workspace")} /{" "} + {navigation.find(([path]) => `/dashboard${path}` === location.pathname)?.[1] ?? + "nav.overview"} - 会话已认证 + {t("app.sessionActive")} setBusy(false)); }} > - 退出登录 + {t("app.signOut")} @@ -143,6 +151,7 @@ function Shell() { ); } function Login() { + const t = useT(); const status = useSession((state) => state.status); const [key, setKey] = useState(""); const [busy, setBusy] = useState(false); @@ -151,6 +160,7 @@ function Login() { return ( + @@ -160,14 +170,14 @@ function Login() { CodeBuddy - 网关管理 + {t("login.subtitle")} - 登录管理控制台 + {t("login.title")} { e.preventDefault(); @@ -189,12 +199,12 @@ function Login() { required value={key} onChange={(e) => setKey(e.target.value)} - placeholder="输入当前生效的 API key" + placeholder={t("login.keyPlaceholder")} /> - {busy ? "正在验证…" : "进入工作台"} + {busy ? t("login.verifying") : t("login.enter")} @@ -223,5 +233,9 @@ export default function App() { useEffect(() => { void establishSession(); }, []); - return ; + return ( + + + + ); } diff --git a/web/src/Trend.tsx b/web/src/Trend.tsx index 70a8388..a2976e6 100644 --- a/web/src/Trend.tsx +++ b/web/src/Trend.tsx @@ -1,6 +1,7 @@ import { useId, useState, type PointerEvent } from "react"; import { metric, number, text, type RecordValue } from "./api"; import { Empty } from "./components"; +import { useT } from "./i18n"; import s from "./ui.module.scss"; export function Trend({ @@ -12,11 +13,12 @@ export function Trend({ granularity?: string; partial?: boolean; }) { + const t = useT(); const id = useId(); const [selection, setSelection] = useState<{ rows: RecordValue[]; index: number } | null>(null); const active = selection?.rows === rows ? selection.index : null; if (!rows.length) - return ; + return ; const max = Math.max(1, ...rows.map((r) => number(r.requests) ?? 0)); const first = number(rows[0].bucket), last = number(rows.at(-1)?.bucket); @@ -53,7 +55,7 @@ export function Trend({ className={s.chart} role="img" tabIndex={0} - aria-label={granularity === "hour" ? "按小时请求量趋势" : "按日请求量趋势"} + aria-label={granularity === "hour" ? t("dash.trend.hourly") : t("dash.trend.daily")} aria-describedby={`${id}-hint${selected ? ` ${id}-tooltip` : ""}`} onPointerMove={pointer} onPointerDown={pointer} @@ -139,15 +141,15 @@ export function Trend({ {text(selected.date)} · UTC - 请求 + {t("dash.requests")} {metric(selected.requests)} - 成功 + {t("logs.outcome.success")} {metric(selected.success)} - 失败 + {t("logs.outcome.error")} {metric(selected.error)} diff --git a/web/src/api.test.tsx b/web/src/api.test.tsx index 0b8c6d4..9d0abe5 100644 --- a/web/src/api.test.tsx +++ b/web/src/api.test.tsx @@ -27,8 +27,8 @@ function response(data: unknown, config: InternalAxiosRequestConfig) { } describe("API and session state", () => { it("keeps unknown distinct from explicit zero", () => { - expect(metric(undefined)).toBe("未知"); - expect(metric(null)).toBe("未知"); + expect(metric(undefined)).toBe("—"); + expect(metric(null)).toBe("—"); expect(metric(0)).toBe("0"); }); it("rejects malformed DTO instead of fabricating empty success", () => { diff --git a/web/src/api.ts b/web/src/api.ts index 3b9ba95..848d6de 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -39,21 +39,19 @@ export function list(value: unknown, context = "列表"): RecordValue[] { return value.map((item) => object(item, context)); } export function text(value: unknown): string { - if (value === undefined || value === null) return "未知"; + if (value === undefined || value === null) return "—"; if (typeof value === "object") return JSON.stringify(value); if (typeof value === "string") return value; if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return `${value}`; - return "未知"; + return "—"; } export function number(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } export function metric(value: unknown): string { const n = number(value); - return n === null - ? "未知" - : new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 2 }).format(n); + return n === null ? "—" : new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 2 }).format(n); } export function errorMessage(error: unknown): string { if (axios.isAxiosError(error)) { diff --git a/web/src/components.tsx b/web/src/components.tsx index d318574..878ef9d 100644 --- a/web/src/components.tsx +++ b/web/src/components.tsx @@ -4,6 +4,7 @@ import { lockPage } from "./modal"; import { useExiting } from "./presence"; export { DrawerPresence } from "./presence"; import s from "./ui.module.scss"; +import { useT } from "./i18n"; export function Icon({ name = "grid" }: { name?: string }) { const paths: Record = { @@ -63,19 +64,22 @@ export function PageTitle({ ); } export function ErrorNotice({ message, retry }: { message: string | null; retry?: () => void }) { + const t = useT(); return message ? ( {message} - {retry && 重试} + {retry && {t("common.retry")}} ) : null; } -export function Empty({ title = "暂无数据", children }: { title?: string; children?: ReactNode }) { +export function Empty({ title, children }: { title?: string; children?: ReactNode }) { + const t = useT(); + const heading = title ?? t("common.empty"); return ( - {title} + {heading} {children && {children}} ); @@ -89,12 +93,13 @@ export function ResourceState({ error: string | null; reload: () => void; }) { + const t = useT(); return ( <> {loading && ( - 加载中… + {t("common.loading")} )} @@ -145,6 +150,7 @@ export function Drawer({ dismissDisabled?: boolean; className?: string; }) { + const t = useT(); const exiting = useExiting(); const ref = useRef(null); const outside = useRef(false); @@ -184,7 +190,12 @@ export function Drawer({ {title} - + @@ -195,13 +206,14 @@ export function Drawer({ } export const CLEAR_CONFIRMATION = "清空全部日志与统计"; export function ClearLogs({ onClose, onDone }: { onClose: () => void; onDone: () => void }) { + const t = useT(); const [scope, setScope] = useState<"details" | "all">("details"); const [confirmation, setConfirmation] = useState(""); const [key, setKey] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); return ( - + { e.preventDefault(); @@ -216,8 +228,7 @@ export function ClearLogs({ onClose, onDone }: { onClose: () => void; onDone: () ...(scope === "all" ? { api_key: currentKey } : {}), }) .then((response) => { - if (object(response.data).ok !== true) - throw new Error("后端未确认清理成功,请检查存储状态"); + if (object(response.data).ok !== true) throw new Error(t("logs.clear.unconfirmed")); onDone(); onClose(); }) @@ -226,7 +237,7 @@ export function ClearLogs({ onClose, onDone }: { onClose: () => void; onDone: () }} > - 清理范围 + {t("logs.clear.scope")} { @@ -235,19 +246,15 @@ export function ClearLogs({ onClose, onDone }: { onClose: () => void; onDone: () setKey(""); }} > - 仅清空明细 · 保留统计 - 全部日志与统计 + {t("logs.clear.detailsOnly")} + {t("logs.clear.everything")} - {scope === "all" ? CLEAR_CONFIRMATION : "仅清空日志明细,保留累计统计"} - - {scope === "all" - ? "将永久删除全部请求明细、事件日志和历史统计,不可撤销。建议先备份;凭证及网关配置不受影响。" - : "将删除请求明细和事件日志,保留历史统计。不可撤销,建议先备份。"} - + {scope === "all" ? CLEAR_CONFIRMATION : t("logs.clear.detailsStrong")} + {scope === "all" ? t("logs.clear.allWarning") : t("logs.clear.detailsWarning")} {scope === "all" && ( @@ -261,7 +268,7 @@ export function ClearLogs({ onClose, onDone }: { onClose: () => void; onDone: () /> - 当前 API key + {t("common.currentApiKey")} void; onDone: () className={s.danger} disabled={busy || (scope === "all" && (confirmation !== CLEAR_CONFIRMATION || !key))} > - {busy ? "正在清理…" : "确认不可撤销的清理"} + {busy ? t("logs.clear.busy") : t("logs.clear.confirm")} diff --git a/web/src/copy.test.tsx b/web/src/copy.test.tsx index 002d6f2..a42721a 100644 --- a/web/src/copy.test.tsx +++ b/web/src/copy.test.tsx @@ -154,7 +154,7 @@ describe("concise user-facing copy", () => { expect(screen.getByRole("alert").textContent).toBe("统计可能不完整,请检查日志存储状态。"); expect(screen.getByText("人工停用")).toBeTruthy(); expect(screen.getByText("1 个模型冷却")).toBeTruthy(); - expect(screen.getAllByText("未知").length).toBeGreaterThan(0); + expect(screen.getAllByText("—").length).toBeGreaterThan(0); expect(screen.getByText("网关已知 Credit").closest("section")?.textContent).toContain("0"); expect(container.textContent).not.toMatch( /一个客户端请求只统计一次|未知用量不计为零|人工状态与熔断独立|未导入的旧文本日志/, diff --git a/web/src/i18n.test.tsx b/web/src/i18n.test.tsx new file mode 100644 index 0000000..c789221 --- /dev/null +++ b/web/src/i18n.test.tsx @@ -0,0 +1,44 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vite-plus/test"; +import { LanguageProvider, LanguageToggle, translate, useT } from "./i18n"; + +beforeEach(() => { + try { + localStorage.clear(); + } catch { + /* ignore */ + } +}); + +function ProbeText() { + const t = useT(); + return ( + <> + {t("nav.overview")} + {t("logs.attemptN", { n: 3 })} + > + ); +} +function Probe() { + return ( + + + + + ); +} + +describe("i18n", () => { + it("defaults to Chinese and switches to English with persistence", () => { + render(); + expect(screen.getByTestId("probe").textContent).toBe("概览"); + fireEvent.click(screen.getByRole("button")); + // eslint-disable-next-line no-console + expect(screen.getByTestId("probe").textContent).toBe("Overview"); + expect(screen.getByTestId("interp").textContent).toBe("Attempt 3"); + expect(localStorage.getItem("codebuddy2api.lang")).toBe("en"); + }); + it("falls back to the key itself when an entry is missing", () => { + expect(translate("missing.key.entirely")).toBe("missing.key.entirely"); + }); +}); diff --git a/web/src/i18n.tsx b/web/src/i18n.tsx new file mode 100644 index 0000000..09c45cc --- /dev/null +++ b/web/src/i18n.tsx @@ -0,0 +1,453 @@ +import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; +import s from "./ui.module.scss"; + +export type Lang = "zh" | "en"; + +// Variable keys; both languages ship fully loaded. A missing entry falls back +// to zh and finally to the key itself so partial coverage stays readable. +const zh: Record = { + "app.skip": "跳转到内容", + "app.workspace": "工作空间", + "app.primaryNav": "主导航", + "app.brandSubtitle": "个人管理工作台", + "app.brandAria": "CodeBuddy 概览", + "app.expandSidebar": "展开侧栏", + "app.collapseSidebar": "收起侧栏", + "app.sessionActive": "会话已认证", + "app.signOut": "退出登录", + "app.verifying": "正在验证登录状态…", + "app.backToSignIn": "返回登录", + "app.language": "切换语言 / Switch language", + "nav.overview": "概览", + "nav.models": "模型路由", + "nav.credentials": "凭证管理", + "nav.logs": "日志审计", + "nav.settings": "系统设置", + "login.subtitle": "网关管理", + "login.title": "登录管理控制台", + "login.keyPlaceholder": "输入当前生效的 API key", + "login.verifying": "正在验证…", + "login.enter": "进入工作台", + "common.retry": "重试", + "common.empty": "暂无数据", + "common.loading": "加载中…", + "common.closeDrawer": "关闭抽屉", + "common.currentApiKey": "当前 API key", + "logs.title": "日志审计", + "logs.refresh": "刷新", + "logs.clear": "清理日志", + "logs.tab.request": "请求审计", + "logs.tab.runtime": "运行事件", + "logs.tab.admin": "管理操作记录", + "logs.panel.request": "请求记录", + "logs.panel.runtime": "运行事件", + "logs.panel.admin": "管理操作记录", + "logs.perPage": "每页 50 条", + "logs.filter.model": "筛选模型", + "logs.filter.credential": "筛选凭证", + "logs.filter.profile": "筛选产品", + "logs.filter.status": "筛选状态", + "logs.filter.clientIp": "筛选客户端 IP", + "logs.filter.search": "搜索日志", + "logs.placeholder.model": "模型 ID", + "logs.placeholder.credential": "凭证 account_key", + "logs.placeholder.clientIp": "客户端 IP", + "logs.placeholder.search": "搜索标识 / 操作…", + "logs.allProfiles": "全部产品", + "logs.allStatuses": "全部状态", + "logs.apply": "应用筛选", + "logs.reset": "重置", + "logs.col.time": "时间 / ID", + "logs.col.action": "操作 / 类型", + "logs.col.status": "状态", + "logs.col.duration": "耗时", + "logs.col.tokens": "Token", + "logs.viewDetail": "查看详情", + "logs.prevPage": "上一页", + "logs.nextPage": "下一页", + "logs.noMatch": "没有匹配的日志", + "logs.adjustFilters": "请尝试调整筛选。", + "logs.degraded": "日志读取已降级,列表可能不完整,请检查审计存储状态。", + "logs.detailTitle": "日志详情与实际尝试", + "logs.detailLoading": "正在加载详情…", + "logs.detailNeedsId": "请求记录缺少 ID,无法加载详情", + "logs.attempts": "实际尝试", + "logs.noAttempts": "暂无尝试记录。", + "logs.attemptN": "尝试 {n}", + "logs.outcome.success": "成功", + "logs.outcome.error": "失败", + "logs.outcome.cancelled": "取消", + "logs.outcome.warning": "警告", + "logs.kind.runtime": "运行事件", + "logs.kind.admin": "管理操作", + "logs.action.credits": "积分", + "logs.action.cred": "凭证", + "logs.action.models": "模型目录", + "logs.action.usage": "用量", + "logs.action.checkin": "签到", + "logs.action.dailychat": "每日对话", + "logs.action.buddy": "首领", + "logs.action.buddy_attention": "首领需要关注", + "logs.action.buddy_consent": "首领同意", + "logs.action.buddy_agreement": "首领协议", + "logs.action.buddy_departure": "首领出发", + "logs.action.buddy_departure_requested": "首领请求出发", + "logs.action.buddy_first": "首领领取", + "logs.action.buddy_task_accept": "首领任务接取", + "logs.action.buddy_task_chat": "首领任务对话", + "logs.action.buddy_task_completed": "首领任务完成", + "logs.action.buddy_authorization_used": "首领授权已使用", + "logs.action.credential_sync": "凭证同步", + "logs.action.credential_travel": "凭证旅行", + "logs.action.credential_travel_status": "凭证旅行状态", + "logs.action.credential_refresh": "凭证刷新", + "logs.action.credential_checkin": "凭证签到", + "logs.action.credential_daily_chat": "凭证每日对话", + "logs.action.credential_auto_daily_chat": "凭证自动每日对话", + "logs.action.credential_trial": "凭证试用", + "logs.action.credential_enabled": "凭证启停", + "logs.action.credential_reset_cooldown": "凭证清除冷却", + "logs.action.oauth_saved": "OAuth 凭证已保存", + "logs.action.settings_updated": "设置已更新", + "logs.action.model_created": "模型已创建", + "logs.action.model_updated": "模型已更新", + "logs.action.model_deleted": "模型已删除", + "dash.refresh": "刷新", + "dash.runtime": "运行概览", + "dash.trend": "请求趋势", + "dash.range": "统计时间范围", + "dash.granularity": "统计粒度", + "dash.granularityAuto": "自动粒度", + "dash.granularityDay": "按天", + "dash.granularityHour": "按小时", + "dash.granularityByRequests": "按请求量", + "dash.summary": "统计", + "dash.totalRequests": "请求总量", + "dash.successRate": "成功率", + "dash.totalTokens": "Token 用量", + "dash.requests": "请求", + "dash.models": "模型", + "dash.modelRank": "模型使用排行", + "dash.modelRankEmpty": "尚无模型请求记录", + "dash.profileUsage": "产品分布", + "dash.profileUsageEmpty": "暂无产品用量", + "dash.unknownModel": "未识别模型", + "dash.credentialHealth": "凭证健康", + "dash.credentialHealthEmpty": "尚未添加凭证", + "dash.health": "健康状态", + "dash.balance": "官方余额", + "dash.balanceNote": "账号余额,并非本网关消费统计。", + "dash.balanceEmpty": "暂无余额数据,可在凭证管理查看。", + "dash.balanceSnapshotEmpty": "暂无官方余额快照", + "dash.syncStatus": "同步状态", + "dash.syncTimeUnknown": "同步时间未知", + "dash.status.ready": "就绪", + "dash.status.done": "已完成", + "dash.status.normal": "正常", + "dash.status.manual": "人工停用", + "dash.status.syncError": "同步异常", + "dash.status.unknown": "状态未知", + "dash.status.batch": "分批清理中", + "dash.storage": "日志存储", + "dash.storageDegraded": "统计可能不完整,请检查日志存储状态。", + "dash.storagePartial": "小时记录不完整,仅展示已记录时段;可切换按天查看完整统计。", + "dash.storageDiagnose": "查看存储诊断", + "dash.storage.wal": "WAL 文件", + "dash.storage.shm": "SHM 文件", + "dash.storage.credits": "网关已知 Credit", + "dash.storage.creditsNote": "官方账号余额", + "dash.storage.unknown": "未知", + "dash.storage.collectDegraded": "采集降级", + "dash.credentials": "凭证", + "dash.window": "统计窗口", + "trend.partialRange": "该范围缺少小时记录", + "trend.emptyRange": "当前时间范围内暂无请求", + "dash.trend.daily": "按日请求量趋势", + "dash.trend.hourly": "按小时请求量趋势", + "dash.lastNDays": "最近 {n} 天", + "dash.rangeHint": "最近 {n} 天 · UTC 自然日 · {grain}", + "dash.updatedAt": "更新于", + "dash.shareOfRequests": "请求占比", + "dash.modelCooldowns": "{n} 个模型冷却", + "dash.storage.cleanupStatus": "清理状态:", + "dash.storage.detailsLine": "明细 {used} / {max}", + "dash.balanceTitle": "官方账号余额", + "logs.clear.scope": "清理范围", + "logs.clear.detailsOnly": "仅清空明细 · 保留统计", + "logs.clear.everything": "全部日志与统计", + "logs.clear.detailsStrong": "仅清空日志明细,保留累计统计", + "logs.clear.detailsWarning": "将删除请求明细和事件日志,保留历史统计。不可撤销,建议先备份。", + "logs.clear.allWarning": + "将永久删除全部请求明细、事件日志和历史统计,不可撤销。建议先备份;凭证及网关配置不受影响。", + "logs.clear.confirm": "确认不可撤销的清理", + "logs.clear.busy": "正在清理…", + "logs.clear.unconfirmed": "后端未确认清理成功,请检查存储状态", + "logs.http429": "HTTP 429", + "logs.col.actions": "操作", + "logs.col.modelProfile": "模型 / 产品", + "logs.logTypes": "日志类型", + "logs.pageIncomplete": "日志分页响应不完整", +}; + +const en: Record = { + "app.skip": "Skip to content", + "app.workspace": "Workspace", + "app.primaryNav": "Primary navigation", + "app.brandSubtitle": "Personal workspace", + "app.brandAria": "CodeBuddy overview", + "app.expandSidebar": "Expand sidebar", + "app.collapseSidebar": "Collapse sidebar", + "app.sessionActive": "Session active", + "app.signOut": "Sign out", + "app.verifying": "Verifying session…", + "app.backToSignIn": "Back to sign-in", + "app.language": "切换语言 / Switch language", + "nav.overview": "Overview", + "nav.models": "Model routing", + "nav.credentials": "Credentials", + "nav.logs": "Audit logs", + "nav.settings": "Settings", + "login.subtitle": "Gateway management", + "login.title": "Sign in to the console", + "login.keyPlaceholder": "Enter the active API key", + "login.verifying": "Verifying…", + "login.enter": "Enter workspace", + "common.retry": "Retry", + "common.empty": "Nothing here yet", + "common.loading": "Loading…", + "common.closeDrawer": "Close drawer", + "common.currentApiKey": "Current API key", + "logs.title": "Audit logs", + "logs.refresh": "Refresh", + "logs.clear": "Clear logs", + "logs.tab.request": "Requests", + "logs.tab.runtime": "Runtime events", + "logs.tab.admin": "Admin actions", + "logs.panel.request": "Request records", + "logs.panel.runtime": "Runtime events", + "logs.panel.admin": "Admin action records", + "logs.perPage": "50 per page", + "logs.filter.model": "Filter model", + "logs.filter.credential": "Filter credential", + "logs.filter.profile": "Filter product", + "logs.filter.status": "Filter status", + "logs.filter.clientIp": "Filter client IP", + "logs.filter.search": "Search logs", + "logs.placeholder.model": "Model ID", + "logs.placeholder.credential": "Credential account_key", + "logs.placeholder.clientIp": "Client IP", + "logs.placeholder.search": "Search id / action…", + "logs.allProfiles": "All products", + "logs.allStatuses": "All statuses", + "logs.apply": "Apply filters", + "logs.reset": "Reset", + "logs.col.time": "Time / ID", + "logs.col.action": "Action / type", + "logs.col.status": "Status", + "logs.col.duration": "Duration", + "logs.col.tokens": "Tokens", + "logs.viewDetail": "Details", + "logs.prevPage": "Previous page", + "logs.nextPage": "Next page", + "logs.noMatch": "No matching logs", + "logs.adjustFilters": "Try adjusting the filters.", + "logs.degraded": "Log reads are degraded; the list may be incomplete. Check audit storage.", + "logs.detailTitle": "Log details and attempts", + "logs.detailLoading": "Loading details…", + "logs.detailNeedsId": "Request record has no ID; cannot load details", + "logs.attempts": "Attempts", + "logs.noAttempts": "No attempts recorded.", + "logs.attemptN": "Attempt {n}", + "logs.outcome.success": "Success", + "logs.outcome.error": "Failed", + "logs.outcome.cancelled": "Cancelled", + "logs.outcome.warning": "Warning", + "logs.kind.runtime": "Runtime event", + "logs.kind.admin": "Admin action", + "logs.action.credits": "Credits", + "logs.action.cred": "Credential", + "logs.action.models": "Model catalog", + "logs.action.usage": "Usage", + "logs.action.checkin": "Check-in", + "logs.action.dailychat": "Daily chat", + "logs.action.buddy": "Buddy", + "logs.action.buddy_attention": "Buddy needs attention", + "logs.action.buddy_consent": "Buddy consent", + "logs.action.buddy_agreement": "Buddy agreement", + "logs.action.buddy_departure": "Buddy departure", + "logs.action.buddy_departure_requested": "Buddy departure requested", + "logs.action.buddy_first": "Buddy claimed", + "logs.action.buddy_task_accept": "Buddy task accepted", + "logs.action.buddy_task_chat": "Buddy task chat", + "logs.action.buddy_task_completed": "Buddy task completed", + "logs.action.buddy_authorization_used": "Buddy authorization used", + "logs.action.credential_sync": "Credential sync", + "logs.action.credential_travel": "Credential travel", + "logs.action.credential_travel_status": "Credential travel status", + "logs.action.credential_refresh": "Credential refresh", + "logs.action.credential_checkin": "Credential check-in", + "logs.action.credential_daily_chat": "Credential daily chat", + "logs.action.credential_auto_daily_chat": "Credential auto daily chat", + "logs.action.credential_trial": "Credential trial", + "logs.action.credential_enabled": "Credential enabled", + "logs.action.credential_reset_cooldown": "Credential cooldown reset", + "logs.action.oauth_saved": "OAuth credential saved", + "logs.action.settings_updated": "Settings updated", + "logs.action.model_created": "Model created", + "logs.action.model_updated": "Model updated", + "logs.action.model_deleted": "Model deleted", + "dash.refresh": "Refresh", + "dash.runtime": "Runtime overview", + "dash.trend": "Request trend", + "dash.range": "Statistics range", + "dash.granularity": "Granularity", + "dash.granularityAuto": "Auto", + "dash.granularityDay": "By day", + "dash.granularityHour": "By hour", + "dash.granularityByRequests": "By requests", + "dash.summary": "Summary", + "dash.totalRequests": "Total requests", + "dash.successRate": "Success rate", + "dash.totalTokens": "Token usage", + "dash.requests": "Requests", + "dash.models": "Models", + "dash.modelRank": "Model usage ranking", + "dash.modelRankEmpty": "No model requests yet", + "dash.profileUsage": "Product distribution", + "dash.profileUsageEmpty": "No product usage yet", + "dash.unknownModel": "Unidentified model", + "dash.credentialHealth": "Credential health", + "dash.credentialHealthEmpty": "No credentials yet", + "dash.health": "Health", + "dash.balance": "Official balance", + "dash.balanceNote": "Account balance, not this gateway's consumption.", + "dash.balanceEmpty": "No balance data yet; check credential management.", + "dash.balanceSnapshotEmpty": "No official balance snapshot", + "dash.syncStatus": "Sync status", + "dash.syncTimeUnknown": "Sync time unknown", + "dash.status.ready": "Ready", + "dash.status.done": "Completed", + "dash.status.normal": "Normal", + "dash.status.manual": "Manually disabled", + "dash.status.syncError": "Sync error", + "dash.status.unknown": "Unknown status", + "dash.status.batch": "Batch cleanup running", + "dash.storage": "Log storage", + "dash.storageDegraded": "Statistics may be incomplete; check log storage.", + "dash.storagePartial": + "Hourly records are incomplete; only recorded hours are shown. Switch to daily for full totals.", + "dash.storageDiagnose": "View storage diagnostics", + "dash.storage.wal": "WAL file", + "dash.storage.shm": "SHM file", + "dash.storage.credits": "Gateway-known credits", + "dash.storage.creditsNote": "Official account balance", + "dash.storage.unknown": "Unknown", + "dash.storage.collectDegraded": "Collection degraded", + "dash.credentials": "Credentials", + "dash.window": "Statistics window", + "trend.partialRange": "This range lacks hourly records", + "trend.emptyRange": "No requests in this range", + "dash.trend.daily": "Daily request trend", + "dash.trend.hourly": "Hourly request trend", + "dash.lastNDays": "Last {n} days", + "dash.rangeHint": "Last {n} days · UTC days · {grain}", + "dash.updatedAt": "Updated", + "dash.shareOfRequests": "share of requests", + "dash.modelCooldowns": "{n} model cooldowns", + "dash.storage.cleanupStatus": "Cleanup:", + "dash.storage.detailsLine": "Details {used} / {max}", + "dash.balanceTitle": "Official account balance", + "logs.clear.scope": "Scope", + "logs.clear.detailsOnly": "Details only · keep aggregates", + "logs.clear.everything": "All logs and aggregates", + "logs.clear.detailsStrong": "Clear log details only, keep aggregates", + "logs.clear.detailsWarning": + "Deletes request details and event logs, keeping aggregates. Irreversible; back up first.", + "logs.clear.allWarning": + "Permanently deletes all request details, event logs and historical aggregates. Irreversible. Back up first; credentials and gateway config are unaffected.", + "logs.clear.confirm": "Confirm irreversible cleanup", + "logs.clear.busy": "Clearing…", + "logs.clear.unconfirmed": "Backend did not confirm the cleanup; check storage state", + "logs.http429": "HTTP 429", + "logs.col.actions": "Actions", + "logs.col.modelProfile": "Model / product", + "logs.logTypes": "Log types", + "logs.pageIncomplete": "Log page response incomplete", +}; + +const messages: Record> = { zh, en }; +const STORAGE_KEY = "codebuddy2api.lang"; +const LANG_EVENT = "codebuddy.lang"; + +let currentLang: Lang = "zh"; +const LangContext = createContext("zh"); + +export function translate(key: string, vars?: Record): string { + const template = messages[currentLang][key] ?? messages.zh[key] ?? key; + return vars + ? template.replace(/\{(\w+)\}/g, (name) => String(vars[name.slice(1, -1)] ?? name)) + : template; +} + +// Global translator: the provider re-renders the whole tree on language +// change, so plain t() calls stay reactive without per-component hooks. +export const t = translate; +export function useLang(): Lang { + return useContext(LangContext); +} +export function useT() { + const lang = useLang(); + return useMemo( + () => (key: string, vars?: Record) => translate(key, vars), + [lang], + ); +} + +export function LanguageProvider({ children }: { children: ReactNode }) { + const [lang, setLang] = useState(() => { + try { + return localStorage.getItem(STORAGE_KEY) === "en" ? "en" : "zh"; + } catch { + return "zh"; + } + }); + currentLang = lang; + useEffect(() => { + try { + localStorage.setItem(STORAGE_KEY, lang); + } catch { + /* Session-only preference when storage is unavailable. */ + } + document.documentElement.lang = lang === "en" ? "en" : "zh-CN"; + }, [lang]); + useEffect(() => { + const apply = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (detail === "zh" || detail === "en") setLang(detail); + }; + window.addEventListener(LANG_EVENT, apply); + return () => window.removeEventListener(LANG_EVENT, apply); + }, []); + return {children}; +} + +export function LanguageToggle() { + return ( + { + const next = currentLang === "zh" ? "en" : "zh"; + try { + localStorage.setItem(STORAGE_KEY, next); + } catch { + /* ignore */ + } + window.dispatchEvent(new CustomEvent(LANG_EVENT, { detail: next })); + }} + aria-label={translate("app.language")} + title={translate("app.language")} + > + {currentLang === "zh" ? "EN" : "中"} + + ); +} diff --git a/web/src/interaction.test.tsx b/web/src/interaction.test.tsx index 3da7968..31363e8 100644 --- a/web/src/interaction.test.tsx +++ b/web/src/interaction.test.tsx @@ -101,7 +101,7 @@ describe("structured metadata", () => { expect(screen.getByText("已停用")).toBeTruthy(); expect(screen.getByText("1.0 KiB")).toBeTruthy(); expect(screen.getByText("Example")).toBeTruthy(); - expect(screen.getByText("未知")).toBeTruthy(); + expect(screen.getByText("—")).toBeTruthy(); expect(screen.getByText("0")).toBeTruthy(); expect(container.textContent).not.toContain('"name":'); }); diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 3d6dabb..d0d38dd 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -27,19 +27,20 @@ function normalize(value: unknown): RecordValue & { } return { ...d, - summary: object(d.summary, "统计"), + summary: object(d.summary, t("dash.summary")), series: list(d.series, "趋势"), models: list(d.models, "模型排行"), - profiles: list(d.profiles, "产品分布"), + profiles: list(d.profiles, t("dash.profileUsage")), health: object(d.health, "健康状态"), storage: object(d.storage, "存储状态"), - range: d.range === undefined ? {} : object(d.range, "统计窗口"), + range: d.range === undefined ? {} : object(d.range, t("dash.window")), }; } function Health({ data }: { data: RecordValue }) { + const t = useT(); if (!Array.isArray(data.credentials)) return ; const credentials = list(data.credentials); - if (!credentials.length) return ; + if (!credentials.length) return ; return ( {credentials.map((c, i) => ( @@ -50,10 +51,14 @@ function Health({ data }: { data: RecordValue }) { - {c.enabled === false ? "人工停用" : c.health === "ready" ? "就绪" : text(c.health)} + {c.enabled === false + ? t("dash.status.manual") + : c.health === "ready" + ? t("dash.status.ready") + : text(c.health)} {Array.isArray(c.cooldowns) && c.cooldowns.length > 0 && ( - {c.cooldowns.length} 个模型冷却 + {t("dash.modelCooldowns", { n: c.cooldowns.length })} )} @@ -62,16 +67,17 @@ function Health({ data }: { data: RecordValue }) { ); } function OfficialCredits({ value }: { value: unknown }) { + const t = useT(); const rows = Object.entries(object(value)); - if (!rows.length) return ; + if (!rows.length) return ; return ( - 凭证 - 官方余额 - 同步状态 + {t("dash.credentials")} + {t("dash.balance")} + {t("dash.syncStatus")} @@ -84,12 +90,12 @@ function OfficialCredits({ value }: { value: unknown }) { {metric(credits?.credits ?? credits?.remaining)} {row.error ? ( - 同步异常 + {t("dash.status.syncError")} ) : ( {typeof row.fetched_at === "number" ? new Date(row.fetched_at * 1000).toLocaleString("zh-CN") - : "同步时间未知"} + : t("dash.syncTimeUnknown")} )} @@ -103,14 +109,16 @@ function OfficialCredits({ value }: { value: unknown }) { } function bytes(value: unknown) { const n = number(value); - if (n === null) return "未知"; + if (n === null) return t("dash.storage.unknown"); if (n < 1024) return `${metric(n)} B`; if (n < 1048576) return `${(n / 1024).toFixed(1)} KiB`; return `${(n / 1048576).toFixed(1)} MiB`; } import { Trend } from "../Trend"; +import { t, useT } from "../i18n"; export { Trend } from "../Trend"; export function Dashboard() { + const t = useT(); const [days, setDays] = useState("7"); const [granularity, setGranularity] = useState("auto"); const resource = useResource(`/dashboard?days=${days}&granularity=${granularity}`, normalize); @@ -121,32 +129,32 @@ export function Dashboard() { return ( <> setDays(e.target.value)} > {["1", "7", "30", "90"].map((n) => ( - 最近 {n} 天 + {t("dash.lastNDays", { n })} ))} setGranularity(e.target.value)} > - 自动粒度 - 按小时 - 按天 + {t("dash.granularityAuto")} + {t("dash.granularityHour")} + {t("dash.granularityDay")} - 刷新 + {t("dash.refresh")} > } @@ -156,15 +164,19 @@ export function Dashboard() { <> {(d.degraded === true || d.storage.degraded === true) && ( - 统计可能不完整,请检查日志存储状态。 + {t("dash.storageDegraded")} )} {[ - ["请求总量", metric(d.summary.requests), "arrow"], - ["成功率", rate === null ? "未知" : `${(rate * 100).toFixed(1)}%`, "shield"], - ["Token 用量", metric(d.summary.total_tokens), "model"], - ["网关已知 Credit", metric(d.summary.credit), "leaf"], + [t("dash.totalRequests"), metric(d.summary.requests), "arrow"], + [ + t("dash.successRate"), + rate === null ? t("dash.storage.unknown") : `${(rate * 100).toFixed(1)}%`, + "shield", + ], + [t("dash.totalTokens"), metric(d.summary.total_tokens), "model"], + [t("dash.storage.credits"), metric(d.summary.credit), "leaf"], ].map(([label, value, icon]) => ( @@ -179,18 +191,22 @@ export function Dashboard() { {period.partial === true && ( - 小时记录不完整,仅展示已记录时段;可切换按天查看完整统计。 + {t("dash.storagePartial")} )} - + {d.profiles.length ? ( {d.profiles.map((p, i) => { @@ -205,24 +221,24 @@ export function Dashboard() { ); })} ) : ( - + )} - + {d.models.length ? ( - 模型 - 请求 + {t("dash.models")} + {t("dash.requests")} Token Credit @@ -235,7 +251,7 @@ export function Dashboard() { {String(i + 1).padStart(2, "0")} - {text(m.model) || "未识别模型"} + {text(m.model) || t("dash.unknownModel")} {metric(m.requests)} {metric(m.total_tokens)} @@ -246,13 +262,13 @@ export function Dashboard() { ) : ( - + )} - + - + {d.storage.degraded === true - ? "采集降级" + ? t("dash.storage.collectDegraded") : d.storage.degraded === false - ? "正常" - : "状态未知"} + ? t("dash.status.normal") + : t("dash.status.unknown")} - 明细 {bytes(d.storage.logical_bytes)} / {bytes(d.storage.max_bytes)} + {t("dash.storage.detailsLine", { + used: bytes(d.storage.logical_bytes), + max: bytes(d.storage.max_bytes), + })} - 查看存储诊断 + {t("dash.storageDiagnose")} - - 账号余额,并非本网关消费统计。 + + {t("dash.balanceNote")} {d.official_credits !== undefined ? ( ) : ( - 暂无余额数据,可在凭证管理查看。 + {t("dash.balanceEmpty")} )} - 更新于{" "} + {t("dash.updatedAt")}{" "} {typeof d.generated_at === "number" ? new Date(d.generated_at * 1000).toLocaleString("zh-CN") : text(d.generated_at)}{" "} diff --git a/web/src/pages/Logs.tsx b/web/src/pages/Logs.tsx index 9ce3456..2548a3c 100644 --- a/web/src/pages/Logs.tsx +++ b/web/src/pages/Logs.tsx @@ -25,10 +25,11 @@ import { import { eventActionLabel, eventKindLabel } from "../values"; import { profiles } from "./Models"; import s from "../ui.module.scss"; +import { t, useT } from "../i18n"; function normalize(value: unknown) { const data = object(value); if (typeof data.has_more !== "boolean" || (data.has_more && typeof data.next_cursor !== "string")) - throw new Error("日志分页响应不完整"); + throw new Error(t("logs.pageIncomplete")); return { items: list(data.items), next_cursor: typeof data.next_cursor === "string" ? data.next_cursor : null, @@ -45,12 +46,13 @@ const emptyFilters = { search: "", }; const outcomeLabels: Record = { - success: "成功", - error: "失败", - cancelled: "取消", - warning: "警告", + success: "logs.outcome.success", + error: "logs.outcome.error", + cancelled: "logs.outcome.cancelled", + warning: "logs.outcome.warning", }; export function Logs() { + const t = useT(); const [kind, setKind] = useState("request"); const [draft, setDraft] = useState(emptyFilters); const [filters, setFilters] = useState(emptyFilters); @@ -82,7 +84,7 @@ export function Logs() { setDetailLoading(false); if (kind !== "request") return; if (typeof item.id !== "string") { - setDetailError("请求记录缺少 ID,无法加载详情"); + setDetailError(t("logs.detailNeedsId")); return; } const controller = new AbortController(); @@ -107,22 +109,22 @@ export function Logs() { return ( <> - 刷新 + {t("logs.refresh")} - setClear(true)}>清理日志 + setClear(true)}>{t("logs.clear")} > } /> - + {[ - ["request", "请求审计"], - ["runtime", "运行事件"], - ["admin", "管理操作"], + ["request", t("logs.tab.request")], + ["runtime", t("logs.tab.runtime")], + ["admin", t("logs.kind.admin")], ].map(([value, label]) => ( setDraft({ ...draft, model: e.target.value })} /> setDraft({ ...draft, credential: e.target.value })} /> setDraft({ ...draft, client_ip: e.target.value })} /> setDraft({ ...draft, profile: e.target.value })} > - 全部产品 + {t("logs.allProfiles")} {profiles.map((p) => ( {p} ))} setDraft({ ...draft, status: e.target.value })} > - 全部状态 - 成功 - 失败 - 取消 - HTTP 429 + {t("logs.allStatuses")} + {t("logs.outcome.success")} + {t("logs.outcome.error")} + {t("logs.outcome.cancelled")} + {t("logs.http429")} > )} setDraft({ ...draft, search: e.target.value })} /> - 应用筛选 + {t("logs.apply")} { @@ -211,13 +219,13 @@ export function Logs() { setCursors([]); }} > - 重置 + {t("logs.reset")} {resource.data?.degraded && ( - 日志读取已降级,列表可能不完整,请检查审计存储状态。 + {t("logs.degraded")} )} {resource.data && @@ -226,16 +234,18 @@ export function Logs() { - 时间 / ID - {kind === "request" ? "模型 / 产品" : "操作 / 类型"} - 状态 + {t("logs.col.time")} + + {kind === "request" ? t("logs.col.modelProfile") : t("logs.col.action")} + + {t("logs.col.status")} {kind === "request" && ( <> - 耗时 + {t("logs.col.duration")} Token / Credit > )} - 操作 + {t("logs.col.actions")} @@ -255,14 +265,18 @@ export function Logs() { {text( kind === "request" ? item.model - : (eventActionLabel(item.action) ?? item.action), + : eventActionLabel(item.action) + ? t(eventActionLabel(item.action)!) + : item.action, )} {text( kind === "request" ? item.profile - : (eventKindLabel(item.kind) ?? item.kind), + : eventKindLabel(item.kind) + ? t(eventKindLabel(item.kind)!) + : item.kind, )} {kind === "request" && item.client_ip ? ( @@ -281,8 +295,9 @@ export function Logs() { : "neutral" } > - {(typeof outcome === "string" ? outcomeLabels[outcome] : undefined) ?? - text(outcome ?? item.level ?? item.status)} + {(typeof outcome === "string" + ? t(outcomeLabels[outcome]) + : undefined) ?? text(outcome ?? item.level ?? item.status)} {item.status_code !== undefined && ( HTTP {text(item.status_code)} @@ -299,7 +314,7 @@ export function Logs() { )} openDetail(item)}> - 查看详情 + {t("logs.viewDetail")} @@ -309,7 +324,7 @@ export function Logs() { ) : ( - 请尝试调整筛选。 + {t("logs.adjustFilters")} ))} 第 {cursors.length + 1} 页 @@ -318,7 +333,7 @@ export function Logs() { disabled={!cursors.length || resource.loading} onClick={() => setCursors((old) => old.slice(0, -1))} > - 上一页 + {t("logs.prevPage")} [...old, resource.data!.next_cursor!]); }} > - 下一页 + {t("logs.nextPage")} {detail && ( - + {detailLoading ? ( - 正在加载详情… + {t("logs.detailLoading")} ) : ( <> {Array.isArray(detail.attempts) && ( - + {detail.attempts.length ? ( list(detail.attempts).map((attempt, i) => ( - 尝试 {i + 1} + {t("logs.attemptN", { n: i + 1 })} )) ) : ( - 暂无尝试记录。 + {t("logs.noAttempts")} )} )} diff --git a/web/src/values.tsx b/web/src/values.tsx index 1fbe6e1..6981fb1 100644 --- a/web/src/values.tsx +++ b/web/src/values.tsx @@ -244,49 +244,49 @@ export function profileLabel(value: string) { return ownLabel(profiles, value) ?? value; } const eventKinds: Record = { - runtime: "运行事件", - admin: "管理操作", + runtime: "logs.kind.runtime", + admin: "logs.kind.admin", }; const eventActions: Record = { - credits: "积分", - cred: "凭证", - models: "模型目录", - usage: "用量", - checkin: "签到", - dailychat: "每日对话", - buddy: "首领", - "buddy.attention_required": "首领需要关注", - "buddy.consent": "首领同意", - "buddy.agreement": "首领协议", - "buddy.departure": "首领出发", - "buddy.departure_requested": "首领请求出发", - "buddy.first": "首领领取", - "buddy.task_accept": "首领任务接取", - "buddy.task_chat": "首领任务对话", - "buddy.task_completed": "首领任务完成", - "buddy.authorization_used": "首领授权已使用", - "credential.sync": "凭证同步", - "credential.travel": "凭证旅行", - "credential.travel-status": "凭证旅行状态", - "credential.refresh": "凭证刷新", - "credential.checkin": "凭证签到", - "credential.daily-chat": "凭证每日对话", - "credential.auto_daily_chat": "凭证自动每日对话", - "credential.trial": "凭证试用", - "credential.enabled": "凭证启停", - "credential.reset-cooldown": "凭证清除冷却", - "oauth.saved": "OAuth 凭证已保存", - "settings.updated": "设置已更新", - "model.created": "模型已创建", - "model.updated": "模型已更新", - "model.deleted": "模型已删除", + credits: "logs.action.credits", + cred: "logs.action.cred", + models: "logs.action.models", + usage: "logs.action.usage", + checkin: "logs.action.checkin", + dailychat: "logs.action.dailychat", + buddy: "logs.action.buddy", + "buddy.attention_required": "logs.action.buddy_attention", + "buddy.consent": "logs.action.buddy_consent", + "buddy.agreement": "logs.action.buddy_agreement", + "buddy.departure": "logs.action.buddy_departure", + "buddy.departure_requested": "logs.action.buddy_departure_requested", + "buddy.first": "logs.action.buddy_first", + "buddy.task_accept": "logs.action.buddy_task_accept", + "buddy.task_chat": "logs.action.buddy_task_chat", + "buddy.task_completed": "logs.action.buddy_task_completed", + "buddy.authorization_used": "logs.action.buddy_authorization_used", + "credential.sync": "logs.action.credential_sync", + "credential.travel": "logs.action.credential_travel", + "credential.travel-status": "logs.action.credential_travel_status", + "credential.refresh": "logs.action.credential_refresh", + "credential.checkin": "logs.action.credential_checkin", + "credential.daily-chat": "logs.action.credential_daily_chat", + "credential.auto_daily_chat": "logs.action.credential_auto_daily_chat", + "credential.trial": "logs.action.credential_trial", + "credential.enabled": "logs.action.credential_enabled", + "credential.reset-cooldown": "logs.action.credential_reset_cooldown", + "oauth.saved": "logs.action.oauth_saved", + "settings.updated": "logs.action.settings_updated", + "model.created": "logs.action.model_created", + "model.updated": "logs.action.model_updated", + "model.deleted": "logs.action.model_deleted", }; -export function eventKindLabel(kind: unknown) { - return typeof kind === "string" ? ownLabel(eventKinds, kind) : undefined; -} export function eventActionLabel(action: unknown) { return typeof action === "string" ? ownLabel(eventActions, action) : undefined; } +export function eventKindLabel(kind: unknown) { + return typeof kind === "string" ? ownLabel(eventKinds, kind) : undefined; +} export function bytes(value: number) { if (value < 1024) return `${metric(value)} B`; if (value < 1048576) return `${(value / 1024).toFixed(1)} KiB`; @@ -302,7 +302,7 @@ export function DataValue({ name?: string; depth?: number; }) { - if (value === null || value === undefined) return 未知; + if (value === null || value === undefined) return —; if (typeof value === "boolean") { const warning = ["partial", "degraded", "token_expired", "closed"].includes(name); return ( @@ -312,7 +312,7 @@ export function DataValue({ ); } if (typeof value === "number") { - if (!Number.isFinite(value)) return 未知; + if (!Number.isFinite(value)) return —; if (times.has(name)) { if (value === 0) return <>未设置>; const date = new Date(value > 1e11 ? value : value * 1000); From 64ea9a68a1ed340eb5733c2fc24bd35c29d9066d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E8=8F=8A?= <277378677+maiphucgiang@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:35:53 +0800 Subject: [PATCH 4/5] Persist language only on toggle and translate detail enum values The provider no longer writes localStorage on mount, restoring the anonymous-visit zero-storage guarantee the e2e admin test asserts. The audit detail drawer renders known enum values (outcomes, error codes, stream modes, usage sources, stages, event codes) in the UI language with the raw value on hover. --- web/src/i18n.tsx | 61 ++++++++++++++++++++++++++++++++++++++++++---- web/src/values.tsx | 38 +++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 5 deletions(-) diff --git a/web/src/i18n.tsx b/web/src/i18n.tsx index 09c45cc..ce26460 100644 --- a/web/src/i18n.tsx +++ b/web/src/i18n.tsx @@ -160,6 +160,33 @@ const zh: Record = { "dash.storage.collectDegraded": "采集降级", "dash.credentials": "凭证", "dash.window": "统计窗口", + "val.success": "成功", + "val.error": "失败", + "val.cancelled": "已取消", + "val.e.invalid_request": "无效请求", + "val.e.upstream": "上游错误", + "val.e.upstream_timeout": "上游超时", + "val.e.incomplete_stream": "流未完整结束", + "val.e.incomplete_response": "响应不完整", + "val.e.application": "应用异常", + "val.e.content_filter": "内容过滤", + "val.e.model_disabled": "模型已停用", + "val.e.sse": "SSE 错误", + "val.mode.compatible": "兼容模式", + "val.mode.realtime": "实时模式", + "val.usage.mixed": "混合来源", + "val.usage.wire_sse": "流式解析", + "val.usage.upstream_hook": "上游读取", + "val.catalog.shared": "共享目录", + "val.catalog.direct": "直接声明", + "val.code.cooldown": "熔断冷却", + "val.code.failure": "失败", + "val.code.updated": "更新", + "val.stage.route": "路由", + "val.stage.send": "发送", + "val.stage.collect": "聚合", + "val.stage.refresh": "刷新", + "val.level.info": "信息", "trend.partialRange": "该范围缺少小时记录", "trend.emptyRange": "当前时间范围内暂无请求", "dash.trend.daily": "按日请求量趋势", @@ -345,6 +372,33 @@ const en: Record = { "dash.storage.collectDegraded": "Collection degraded", "dash.credentials": "Credentials", "dash.window": "Statistics window", + "val.success": "Success", + "val.error": "Failed", + "val.cancelled": "Cancelled", + "val.e.invalid_request": "Invalid request", + "val.e.upstream": "Upstream error", + "val.e.upstream_timeout": "Upstream timeout", + "val.e.incomplete_stream": "Incomplete stream", + "val.e.incomplete_response": "Incomplete response", + "val.e.application": "Application error", + "val.e.content_filter": "Content filter", + "val.e.model_disabled": "Model disabled", + "val.e.sse": "SSE error", + "val.mode.compatible": "Compatible mode", + "val.mode.realtime": "Realtime mode", + "val.usage.mixed": "Mixed sources", + "val.usage.wire_sse": "Stream parsing", + "val.usage.upstream_hook": "Upstream read", + "val.catalog.shared": "Shared catalog", + "val.catalog.direct": "Direct declaration", + "val.code.cooldown": "Cooldown", + "val.code.failure": "Failure", + "val.code.updated": "Updated", + "val.stage.route": "Route", + "val.stage.send": "Send", + "val.stage.collect": "Collect", + "val.stage.refresh": "Refresh", + "val.level.info": "Info", "trend.partialRange": "This range lacks hourly records", "trend.emptyRange": "No requests in this range", "dash.trend.daily": "Daily request trend", @@ -413,11 +467,8 @@ export function LanguageProvider({ children }: { children: ReactNode }) { }); currentLang = lang; useEffect(() => { - try { - localStorage.setItem(STORAGE_KEY, lang); - } catch { - /* Session-only preference when storage is unavailable. */ - } + // 只更新文档语言;localStorage 仅在用户主动切换时写入, + // 保证未操作的访客在站点上不留下任何存储痕迹。 document.documentElement.lang = lang === "en" ? "en" : "zh-CN"; }, [lang]); useEffect(() => { diff --git a/web/src/values.tsx b/web/src/values.tsx index 6981fb1..8742e20 100644 --- a/web/src/values.tsx +++ b/web/src/values.tsx @@ -1,4 +1,5 @@ import { metric, type RecordValue } from "./api"; +import { t as translate } from "./i18n"; import s from "./ui.module.scss"; const labels: Record = { @@ -293,6 +294,36 @@ export function bytes(value: number) { if (value < 1073741824) return `${(value / 1048576).toFixed(1)} MiB`; return `${(value / 1073741824).toFixed(2)} GiB`; } +const valueKeys: Record = { + success: "val.success", + error: "val.error", + cancelled: "val.cancelled", + invalid_request_error: "val.e.invalid_request", + upstream_error: "val.e.upstream", + upstream_timeout: "val.e.upstream_timeout", + incomplete_stream: "val.e.incomplete_stream", + incomplete_response: "val.e.incomplete_response", + application_error: "val.e.application", + content_filter: "val.e.content_filter", + model_disabled: "val.e.model_disabled", + sse_error: "val.e.sse", + compatible: "val.mode.compatible", + realtime: "val.mode.realtime", + mixed: "val.usage.mixed", + wire_sse: "val.usage.wire_sse", + upstream_hook: "val.usage.upstream_hook", + shared: "val.catalog.shared", + direct: "val.catalog.direct", + cooldown: "val.code.cooldown", + failure: "val.code.failure", + updated: "val.code.updated", + route: "val.stage.route", + send: "val.stage.send", + collect: "val.stage.collect", + refresh: "val.stage.refresh", + info: "val.level.info", +}; + export function DataValue({ value, name = "", @@ -303,6 +334,13 @@ export function DataValue({ depth?: number; }) { if (value === null || value === undefined) return —; + if (typeof value === "string" && Object.hasOwn(valueKeys, value)) { + return ( + + {translate(valueKeys[value] as string)} + + ); + } if (typeof value === "boolean") { const warning = ["partial", "degraded", "token_expired", "closed"].includes(name); return ( From 49f6ddde15795541414af51b9f87d08af3777df3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E8=8F=8A?= <277378677+maiphucgiang@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:50:52 +0800 Subject: [PATCH 5/5] Harden client-ip derivation and ship the trusted-proxy plumbing Review fixes: combine repeated X-Forwarded-For fields in order before walking the chain (a proxy appending its hop as a separate field is no longer shadowed by a spoofed first field); validate every XFF hop and the X-Real-IP fallback with ipaddress and record the direct peer when a chain is broken or the fallback is not an address; create requests_client on migration so exact client-IP filters stay indexed; pass CODEBUDDY2API_TRUSTED_PROXIES through Compose; document the single-writer audit-schema rollout constraint. --- app/audit_store.py | 3 +++ app/observability.py | 47 ++++++++++++++++++++++++------------- docker-compose.yml | 2 ++ docs/deployment.md | 2 ++ docs/deployment.zh-CN.md | 2 ++ tests/test_audit_store.py | 2 ++ tests/test_observability.py | 29 +++++++++++++++++++++++ 7 files changed, 71 insertions(+), 16 deletions(-) diff --git a/app/audit_store.py b/app/audit_store.py index c9085c8..1b12322 100644 --- a/app/audit_store.py +++ b/app/audit_store.py @@ -166,6 +166,9 @@ def _migrate_client_ip(self): columns = [row[1] for row in self._db.execute("PRAGMA table_info(requests)").fetchall()] if "client_ip" not in columns: self._db.execute("ALTER TABLE requests ADD COLUMN client_ip TEXT") + # Exact-match client filters must not scan the retained time range. + self._db.execute( + "CREATE INDEX IF NOT EXISTS requests_client ON requests(client_ip, started_at)") def _init_accounting(self): # Additive v1 migration: scan legacy details only once, under the same diff --git a/app/observability.py b/app/observability.py index e01da6d..fa18a1e 100644 --- a/app/observability.py +++ b/app/observability.py @@ -7,6 +7,7 @@ from __future__ import annotations import asyncio +import ipaddress from contextvars import ContextVar from dataclasses import dataclass, field import json @@ -48,32 +49,46 @@ def client_meta(scope, trusted_proxies): otherwise they are ignored so clients cannot spoof their address. """ headers = {} + forwarded: list[str] = [] for key, value in scope.get("headers") or []: name = key.decode("latin-1").lower() if isinstance(key, (bytes, bytearray)) else str(key).lower() - if name in _META_HEADERS and name not in headers: - headers[name] = value.decode("latin-1", "replace").strip() if isinstance(value, (bytes, bytearray)) else str(value).strip() + clean = value.decode("latin-1", "replace").strip() if isinstance(value, (bytes, bytearray)) else str(value).strip() + if name == "x-forwarded-for": + # A trusted proxy may append its hop as a separate header field; + # combine the fields in order before walking the chain. + if clean: + forwarded.append(clean) + elif name in _META_HEADERS and name not in headers: + headers[name] = clean client = scope.get("client") remote = client[0] if client and isinstance(client[0], str) else None + + def valid_ip(candidate: str | None) -> str | None: + try: + return str(ipaddress.ip_address(candidate)) if candidate else None + except ValueError: + return None + + remote_ip = valid_ip(remote) + remote_obj = ipaddress.ip_address(remote_ip) if remote_ip else None networks = _trusted_networks(trusted_proxies) - try: - import ipaddress - remote_ip = ipaddress.ip_address(remote) if remote else None - except ValueError: - remote_ip = None ip = remote - if remote_ip is not None and any(remote_ip in network for network in networks): - hops = [hop.strip() for hop in (headers.get("x-forwarded-for") or "").split(",") if hop.strip()][-32:] + if remote_obj is not None and any(remote_obj in network for network in networks): + hops = [hop.strip() for hop in (",".join(forwarded)).split(",") if hop.strip()][-32:] chosen = None + broken = False for hop in reversed(hops): - try: - hop_ip = ipaddress.ip_address(hop) - except ValueError: + hop_ip = valid_ip(hop) + if hop_ip is None: + # A malformed hop breaks the chain; never fall back to the + # client-controlled leftmost entry in that case. + broken = True break - chosen = str(hop_ip) - if not any(hop_ip in network for network in networks): + chosen = hop_ip + if not any(ipaddress.ip_address(hop_ip) in network for network in networks): break - if chosen is None: - chosen = headers.get("x-real-ip") or (hops[0] if hops else None) + if chosen is None or broken: + chosen = valid_ip(headers.get("x-real-ip")) ip = chosen or remote return ip, safe_agent(headers.get("user-agent")) diff --git a/docker-compose.yml b/docker-compose.yml index 42ef8db..ee6628f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,8 @@ services: CODEBUDDY2API_AUTO_ACCEPT_BUDDY: ${CODEBUDDY2API_AUTO_ACCEPT_BUDDY:-false} # Unset variables remain configurable through the WebUI. CODEBUDDY2API_ADMIN_ORIGINS: + # Trusted proxy IPs/CIDRs; forwarded headers count only from these peers. + CODEBUDDY2API_TRUSTED_PROXIES: CODEBUDDY2API_KEEP_TOOL_METADATA: CODEBUDDY2API_MAX_IMAGES: ${CODEBUDDY2API_MAX_IMAGES:-16} CODEBUDDY2API_IMAGE_POLICY: ${CODEBUDDY2API_IMAGE_POLICY:-truncate} diff --git a/docs/deployment.md b/docs/deployment.md index ee8490a..27a3257 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -76,6 +76,8 @@ Without uv, run `python3 -m venv .venv`, activate it, install dependencies with Both commands optionally read `.env` in the current working directory, never parent directories. Precedence: explicit CLI > process environment > `.env` > saved SQLite values > defaults. Overrides do not replace the saved default key; an explicitly empty key still locks management. Listener changes require restart; `CODEBUDDY2API_IMAGE/AUTH_PATH` are Compose-only. +> The audit schema is single-writer: stop the old instance before starting a new version, and never run two gateway versions against one auth directory. + ### Upgrade and state migration Stop the gateway and back up the entire data directory first. Startup imports legacy JSON sessions, cooldowns, credit/trial history, catalogs and usage into `control.sqlite3` once. Old files remain as backups and are no longer read or updated. Invalid critical state or a failed migration stops startup; repair it or restore a backup rather than deleting ledgers to bypass validation. diff --git a/docs/deployment.zh-CN.md b/docs/deployment.zh-CN.md index 1accfcf..b867106 100644 --- a/docs/deployment.zh-CN.md +++ b/docs/deployment.zh-CN.md @@ -77,6 +77,8 @@ uv run converter.py 两种启动方式均可选读取当前工作目录的 `.env`,不搜索父目录。优先级:显式 CLI > 进程环境变量 > `.env` > SQLite 保存值 > 默认值。覆盖不改写已保存的默认 key;显式空 key 仍锁定管理。修改监听需重启,`CODEBUDDY2API_IMAGE/AUTH_PATH` 仅用于 Compose。 +> 审计库为单写者设计:升级新版本前先停止旧实例,不要让两个版本的网关共用同一 auth 目录。 + ### 升级与数据迁移 升级前停止网关并备份整个数据目录。首次启动将旧 JSON 会话、冷却、积分/领取、目录和用量状态一次性导入 `control.sqlite3`;原文件保留作备份,不再参与读写。关键数据损坏或迁移失败会阻止启动,请修复或恢复备份,不要删除账本绕过检查。 diff --git a/tests/test_audit_store.py b/tests/test_audit_store.py index c8d9fe7..1e73209 100644 --- a/tests/test_audit_store.py +++ b/tests/test_audit_store.py @@ -621,6 +621,8 @@ def test_v2_database_gains_client_ip_without_losing_rows(self): self.addCleanup(reopened.close) self.assertIn("client_ip", [c[1] for c in reopened._db.execute("PRAGMA table_info(requests)")]) + indexes = [row[1] for row in reopened._db.execute("PRAGMA index_list(requests)")] + self.assertIn("requests_client", indexes) items = reopened.list_records()["items"] self.assertEqual([item["id"] for item in items], ["legacy-1"]) self.assertIsNone(items[0].get("client_ip")) diff --git a/tests/test_observability.py b/tests/test_observability.py index 4bc520e..9e001a4 100644 --- a/tests/test_observability.py +++ b/tests/test_observability.py @@ -426,6 +426,35 @@ def test_untrusted_direct_peer_ignores_forwarded_headers(self): self.assertEqual(client_meta(scope, "127.0.0.1"), ("203.0.113.7", "synthetic/1.0")) self.assertEqual(client_meta(scope, ""), ("203.0.113.7", "synthetic/1.0")) + def test_repeated_forwarded_fields_are_combined_in_order(self): + # Client spoofs one XFF field; the trusted proxy appends the real + # chain as a second field. The combined chain must prefer the + # proxy-appended hop, not the client-supplied first field. + scope = self.scope( + client=("127.0.0.1", 8080), + headers=[ + ("X-Forwarded-For", "6.6.6.6"), + ("X-Forwarded-For", "198.51.100.9, 10.0.0.9"), + ], + ) + self.assertEqual(client_meta(scope, "127.0.0.1, 10.0.0.0/8")[0], "198.51.100.9") + + def test_malformed_fallback_values_are_rejected(self): + real = self.scope( + client=("127.0.0.1", 8080), + headers=[("X-Forwarded-For", "198.51.100.9, not-an-ip"), ("X-Real-IP", "garbage")], + ) + # The malformed hop stops the walk; the X-Real-IP fallback is not an + # address either, so the trusted proxy address itself is recorded. + self.assertEqual(client_meta(real, "127.0.0.1")[0], "127.0.0.1") + # Even with a trusted proxy ahead of the garbage, a broken chain never + # promotes the client-controlled leftmost entry. + first = self.scope( + client=("127.0.0.1", 8080), + headers=[("X-Forwarded-For", "198.51.100.9, not-an-ip")], + ) + self.assertEqual(client_meta(first, "127.0.0.1, 10.0.0.0/8")[0], "127.0.0.1") + def test_trusted_proxy_unwraps_rightmost_untrusted_hop(self): scope = self.scope(client=("127.0.0.1", 8080), headers=[("X-Forwarded-For", "198.51.100.9, 10.0.0.9")])
{children}
- {scope === "all" - ? "将永久删除全部请求明细、事件日志和历史统计,不可撤销。建议先备份;凭证及网关配置不受影响。" - : "将删除请求明细和事件日志,保留历史统计。不可撤销,建议先备份。"} -
{scope === "all" ? t("logs.clear.allWarning") : t("logs.clear.detailsWarning")}
- 小时记录不完整,仅展示已记录时段;可切换按天查看完整统计。 + {t("dash.storagePartial")}
账号余额,并非本网关消费统计。
{t("dash.balanceNote")}
暂无余额数据,可在凭证管理查看。
{t("dash.balanceEmpty")}
- 更新于{" "} + {t("dash.updatedAt")}{" "} {typeof d.generated_at === "number" ? new Date(d.generated_at * 1000).toLocaleString("zh-CN") : text(d.generated_at)}{" "} diff --git a/web/src/pages/Logs.tsx b/web/src/pages/Logs.tsx index 9ce3456..2548a3c 100644 --- a/web/src/pages/Logs.tsx +++ b/web/src/pages/Logs.tsx @@ -25,10 +25,11 @@ import { import { eventActionLabel, eventKindLabel } from "../values"; import { profiles } from "./Models"; import s from "../ui.module.scss"; +import { t, useT } from "../i18n"; function normalize(value: unknown) { const data = object(value); if (typeof data.has_more !== "boolean" || (data.has_more && typeof data.next_cursor !== "string")) - throw new Error("日志分页响应不完整"); + throw new Error(t("logs.pageIncomplete")); return { items: list(data.items), next_cursor: typeof data.next_cursor === "string" ? data.next_cursor : null, @@ -45,12 +46,13 @@ const emptyFilters = { search: "", }; const outcomeLabels: Record = { - success: "成功", - error: "失败", - cancelled: "取消", - warning: "警告", + success: "logs.outcome.success", + error: "logs.outcome.error", + cancelled: "logs.outcome.cancelled", + warning: "logs.outcome.warning", }; export function Logs() { + const t = useT(); const [kind, setKind] = useState("request"); const [draft, setDraft] = useState(emptyFilters); const [filters, setFilters] = useState(emptyFilters); @@ -82,7 +84,7 @@ export function Logs() { setDetailLoading(false); if (kind !== "request") return; if (typeof item.id !== "string") { - setDetailError("请求记录缺少 ID,无法加载详情"); + setDetailError(t("logs.detailNeedsId")); return; } const controller = new AbortController(); @@ -107,22 +109,22 @@ export function Logs() { return ( <> - 刷新 + {t("logs.refresh")} - setClear(true)}>清理日志 + setClear(true)}>{t("logs.clear")} > } /> - + {[ - ["request", "请求审计"], - ["runtime", "运行事件"], - ["admin", "管理操作"], + ["request", t("logs.tab.request")], + ["runtime", t("logs.tab.runtime")], + ["admin", t("logs.kind.admin")], ].map(([value, label]) => ( setDraft({ ...draft, model: e.target.value })} /> setDraft({ ...draft, credential: e.target.value })} /> setDraft({ ...draft, client_ip: e.target.value })} /> setDraft({ ...draft, profile: e.target.value })} > - 全部产品 + {t("logs.allProfiles")} {profiles.map((p) => ( {p} ))} setDraft({ ...draft, status: e.target.value })} > - 全部状态 - 成功 - 失败 - 取消 - HTTP 429 + {t("logs.allStatuses")} + {t("logs.outcome.success")} + {t("logs.outcome.error")} + {t("logs.outcome.cancelled")} + {t("logs.http429")} > )} setDraft({ ...draft, search: e.target.value })} /> - 应用筛选 + {t("logs.apply")} { @@ -211,13 +219,13 @@ export function Logs() { setCursors([]); }} > - 重置 + {t("logs.reset")} {resource.data?.degraded && ( - 日志读取已降级,列表可能不完整,请检查审计存储状态。 + {t("logs.degraded")} )} {resource.data && @@ -226,16 +234,18 @@ export function Logs() { - 时间 / ID - {kind === "request" ? "模型 / 产品" : "操作 / 类型"} - 状态 + {t("logs.col.time")} + + {kind === "request" ? t("logs.col.modelProfile") : t("logs.col.action")} + + {t("logs.col.status")} {kind === "request" && ( <> - 耗时 + {t("logs.col.duration")} Token / Credit > )} - 操作 + {t("logs.col.actions")} @@ -255,14 +265,18 @@ export function Logs() { {text( kind === "request" ? item.model - : (eventActionLabel(item.action) ?? item.action), + : eventActionLabel(item.action) + ? t(eventActionLabel(item.action)!) + : item.action, )} {text( kind === "request" ? item.profile - : (eventKindLabel(item.kind) ?? item.kind), + : eventKindLabel(item.kind) + ? t(eventKindLabel(item.kind)!) + : item.kind, )} {kind === "request" && item.client_ip ? ( @@ -281,8 +295,9 @@ export function Logs() { : "neutral" } > - {(typeof outcome === "string" ? outcomeLabels[outcome] : undefined) ?? - text(outcome ?? item.level ?? item.status)} + {(typeof outcome === "string" + ? t(outcomeLabels[outcome]) + : undefined) ?? text(outcome ?? item.level ?? item.status)} {item.status_code !== undefined && ( HTTP {text(item.status_code)} @@ -299,7 +314,7 @@ export function Logs() { )} openDetail(item)}> - 查看详情 + {t("logs.viewDetail")} @@ -309,7 +324,7 @@ export function Logs() { ) : ( - 请尝试调整筛选。 + {t("logs.adjustFilters")} ))} 第 {cursors.length + 1} 页 @@ -318,7 +333,7 @@ export function Logs() { disabled={!cursors.length || resource.loading} onClick={() => setCursors((old) => old.slice(0, -1))} > - 上一页 + {t("logs.prevPage")} [...old, resource.data!.next_cursor!]); }} > - 下一页 + {t("logs.nextPage")} {detail && ( - + {detailLoading ? ( - 正在加载详情… + {t("logs.detailLoading")} ) : ( <> {Array.isArray(detail.attempts) && ( - + {detail.attempts.length ? ( list(detail.attempts).map((attempt, i) => ( - 尝试 {i + 1} + {t("logs.attemptN", { n: i + 1 })} )) ) : ( - 暂无尝试记录。 + {t("logs.noAttempts")} )} )} diff --git a/web/src/values.tsx b/web/src/values.tsx index 1fbe6e1..6981fb1 100644 --- a/web/src/values.tsx +++ b/web/src/values.tsx @@ -244,49 +244,49 @@ export function profileLabel(value: string) { return ownLabel(profiles, value) ?? value; } const eventKinds: Record = { - runtime: "运行事件", - admin: "管理操作", + runtime: "logs.kind.runtime", + admin: "logs.kind.admin", }; const eventActions: Record = { - credits: "积分", - cred: "凭证", - models: "模型目录", - usage: "用量", - checkin: "签到", - dailychat: "每日对话", - buddy: "首领", - "buddy.attention_required": "首领需要关注", - "buddy.consent": "首领同意", - "buddy.agreement": "首领协议", - "buddy.departure": "首领出发", - "buddy.departure_requested": "首领请求出发", - "buddy.first": "首领领取", - "buddy.task_accept": "首领任务接取", - "buddy.task_chat": "首领任务对话", - "buddy.task_completed": "首领任务完成", - "buddy.authorization_used": "首领授权已使用", - "credential.sync": "凭证同步", - "credential.travel": "凭证旅行", - "credential.travel-status": "凭证旅行状态", - "credential.refresh": "凭证刷新", - "credential.checkin": "凭证签到", - "credential.daily-chat": "凭证每日对话", - "credential.auto_daily_chat": "凭证自动每日对话", - "credential.trial": "凭证试用", - "credential.enabled": "凭证启停", - "credential.reset-cooldown": "凭证清除冷却", - "oauth.saved": "OAuth 凭证已保存", - "settings.updated": "设置已更新", - "model.created": "模型已创建", - "model.updated": "模型已更新", - "model.deleted": "模型已删除", + credits: "logs.action.credits", + cred: "logs.action.cred", + models: "logs.action.models", + usage: "logs.action.usage", + checkin: "logs.action.checkin", + dailychat: "logs.action.dailychat", + buddy: "logs.action.buddy", + "buddy.attention_required": "logs.action.buddy_attention", + "buddy.consent": "logs.action.buddy_consent", + "buddy.agreement": "logs.action.buddy_agreement", + "buddy.departure": "logs.action.buddy_departure", + "buddy.departure_requested": "logs.action.buddy_departure_requested", + "buddy.first": "logs.action.buddy_first", + "buddy.task_accept": "logs.action.buddy_task_accept", + "buddy.task_chat": "logs.action.buddy_task_chat", + "buddy.task_completed": "logs.action.buddy_task_completed", + "buddy.authorization_used": "logs.action.buddy_authorization_used", + "credential.sync": "logs.action.credential_sync", + "credential.travel": "logs.action.credential_travel", + "credential.travel-status": "logs.action.credential_travel_status", + "credential.refresh": "logs.action.credential_refresh", + "credential.checkin": "logs.action.credential_checkin", + "credential.daily-chat": "logs.action.credential_daily_chat", + "credential.auto_daily_chat": "logs.action.credential_auto_daily_chat", + "credential.trial": "logs.action.credential_trial", + "credential.enabled": "logs.action.credential_enabled", + "credential.reset-cooldown": "logs.action.credential_reset_cooldown", + "oauth.saved": "logs.action.oauth_saved", + "settings.updated": "logs.action.settings_updated", + "model.created": "logs.action.model_created", + "model.updated": "logs.action.model_updated", + "model.deleted": "logs.action.model_deleted", }; -export function eventKindLabel(kind: unknown) { - return typeof kind === "string" ? ownLabel(eventKinds, kind) : undefined; -} export function eventActionLabel(action: unknown) { return typeof action === "string" ? ownLabel(eventActions, action) : undefined; } +export function eventKindLabel(kind: unknown) { + return typeof kind === "string" ? ownLabel(eventKinds, kind) : undefined; +} export function bytes(value: number) { if (value < 1024) return `${metric(value)} B`; if (value < 1048576) return `${(value / 1024).toFixed(1)} KiB`; @@ -302,7 +302,7 @@ export function DataValue({ name?: string; depth?: number; }) { - if (value === null || value === undefined) return 未知; + if (value === null || value === undefined) return —; if (typeof value === "boolean") { const warning = ["partial", "degraded", "token_expired", "closed"].includes(name); return ( @@ -312,7 +312,7 @@ export function DataValue({ ); } if (typeof value === "number") { - if (!Number.isFinite(value)) return 未知; + if (!Number.isFinite(value)) return —; if (times.has(name)) { if (value === 0) return <>未设置>; const date = new Date(value > 1e11 ? value : value * 1000); From 64ea9a68a1ed340eb5733c2fc24bd35c29d9066d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E8=8F=8A?= <277378677+maiphucgiang@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:35:53 +0800 Subject: [PATCH 4/5] Persist language only on toggle and translate detail enum values The provider no longer writes localStorage on mount, restoring the anonymous-visit zero-storage guarantee the e2e admin test asserts. The audit detail drawer renders known enum values (outcomes, error codes, stream modes, usage sources, stages, event codes) in the UI language with the raw value on hover. --- web/src/i18n.tsx | 61 ++++++++++++++++++++++++++++++++++++++++++---- web/src/values.tsx | 38 +++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 5 deletions(-) diff --git a/web/src/i18n.tsx b/web/src/i18n.tsx index 09c45cc..ce26460 100644 --- a/web/src/i18n.tsx +++ b/web/src/i18n.tsx @@ -160,6 +160,33 @@ const zh: Record = { "dash.storage.collectDegraded": "采集降级", "dash.credentials": "凭证", "dash.window": "统计窗口", + "val.success": "成功", + "val.error": "失败", + "val.cancelled": "已取消", + "val.e.invalid_request": "无效请求", + "val.e.upstream": "上游错误", + "val.e.upstream_timeout": "上游超时", + "val.e.incomplete_stream": "流未完整结束", + "val.e.incomplete_response": "响应不完整", + "val.e.application": "应用异常", + "val.e.content_filter": "内容过滤", + "val.e.model_disabled": "模型已停用", + "val.e.sse": "SSE 错误", + "val.mode.compatible": "兼容模式", + "val.mode.realtime": "实时模式", + "val.usage.mixed": "混合来源", + "val.usage.wire_sse": "流式解析", + "val.usage.upstream_hook": "上游读取", + "val.catalog.shared": "共享目录", + "val.catalog.direct": "直接声明", + "val.code.cooldown": "熔断冷却", + "val.code.failure": "失败", + "val.code.updated": "更新", + "val.stage.route": "路由", + "val.stage.send": "发送", + "val.stage.collect": "聚合", + "val.stage.refresh": "刷新", + "val.level.info": "信息", "trend.partialRange": "该范围缺少小时记录", "trend.emptyRange": "当前时间范围内暂无请求", "dash.trend.daily": "按日请求量趋势", @@ -345,6 +372,33 @@ const en: Record = { "dash.storage.collectDegraded": "Collection degraded", "dash.credentials": "Credentials", "dash.window": "Statistics window", + "val.success": "Success", + "val.error": "Failed", + "val.cancelled": "Cancelled", + "val.e.invalid_request": "Invalid request", + "val.e.upstream": "Upstream error", + "val.e.upstream_timeout": "Upstream timeout", + "val.e.incomplete_stream": "Incomplete stream", + "val.e.incomplete_response": "Incomplete response", + "val.e.application": "Application error", + "val.e.content_filter": "Content filter", + "val.e.model_disabled": "Model disabled", + "val.e.sse": "SSE error", + "val.mode.compatible": "Compatible mode", + "val.mode.realtime": "Realtime mode", + "val.usage.mixed": "Mixed sources", + "val.usage.wire_sse": "Stream parsing", + "val.usage.upstream_hook": "Upstream read", + "val.catalog.shared": "Shared catalog", + "val.catalog.direct": "Direct declaration", + "val.code.cooldown": "Cooldown", + "val.code.failure": "Failure", + "val.code.updated": "Updated", + "val.stage.route": "Route", + "val.stage.send": "Send", + "val.stage.collect": "Collect", + "val.stage.refresh": "Refresh", + "val.level.info": "Info", "trend.partialRange": "This range lacks hourly records", "trend.emptyRange": "No requests in this range", "dash.trend.daily": "Daily request trend", @@ -413,11 +467,8 @@ export function LanguageProvider({ children }: { children: ReactNode }) { }); currentLang = lang; useEffect(() => { - try { - localStorage.setItem(STORAGE_KEY, lang); - } catch { - /* Session-only preference when storage is unavailable. */ - } + // 只更新文档语言;localStorage 仅在用户主动切换时写入, + // 保证未操作的访客在站点上不留下任何存储痕迹。 document.documentElement.lang = lang === "en" ? "en" : "zh-CN"; }, [lang]); useEffect(() => { diff --git a/web/src/values.tsx b/web/src/values.tsx index 6981fb1..8742e20 100644 --- a/web/src/values.tsx +++ b/web/src/values.tsx @@ -1,4 +1,5 @@ import { metric, type RecordValue } from "./api"; +import { t as translate } from "./i18n"; import s from "./ui.module.scss"; const labels: Record = { @@ -293,6 +294,36 @@ export function bytes(value: number) { if (value < 1073741824) return `${(value / 1048576).toFixed(1)} MiB`; return `${(value / 1073741824).toFixed(2)} GiB`; } +const valueKeys: Record = { + success: "val.success", + error: "val.error", + cancelled: "val.cancelled", + invalid_request_error: "val.e.invalid_request", + upstream_error: "val.e.upstream", + upstream_timeout: "val.e.upstream_timeout", + incomplete_stream: "val.e.incomplete_stream", + incomplete_response: "val.e.incomplete_response", + application_error: "val.e.application", + content_filter: "val.e.content_filter", + model_disabled: "val.e.model_disabled", + sse_error: "val.e.sse", + compatible: "val.mode.compatible", + realtime: "val.mode.realtime", + mixed: "val.usage.mixed", + wire_sse: "val.usage.wire_sse", + upstream_hook: "val.usage.upstream_hook", + shared: "val.catalog.shared", + direct: "val.catalog.direct", + cooldown: "val.code.cooldown", + failure: "val.code.failure", + updated: "val.code.updated", + route: "val.stage.route", + send: "val.stage.send", + collect: "val.stage.collect", + refresh: "val.stage.refresh", + info: "val.level.info", +}; + export function DataValue({ value, name = "", @@ -303,6 +334,13 @@ export function DataValue({ depth?: number; }) { if (value === null || value === undefined) return —; + if (typeof value === "string" && Object.hasOwn(valueKeys, value)) { + return ( + + {translate(valueKeys[value] as string)} + + ); + } if (typeof value === "boolean") { const warning = ["partial", "degraded", "token_expired", "closed"].includes(name); return ( From 49f6ddde15795541414af51b9f87d08af3777df3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E8=8F=8A?= <277378677+maiphucgiang@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:50:52 +0800 Subject: [PATCH 5/5] Harden client-ip derivation and ship the trusted-proxy plumbing Review fixes: combine repeated X-Forwarded-For fields in order before walking the chain (a proxy appending its hop as a separate field is no longer shadowed by a spoofed first field); validate every XFF hop and the X-Real-IP fallback with ipaddress and record the direct peer when a chain is broken or the fallback is not an address; create requests_client on migration so exact client-IP filters stay indexed; pass CODEBUDDY2API_TRUSTED_PROXIES through Compose; document the single-writer audit-schema rollout constraint. --- app/audit_store.py | 3 +++ app/observability.py | 47 ++++++++++++++++++++++++------------- docker-compose.yml | 2 ++ docs/deployment.md | 2 ++ docs/deployment.zh-CN.md | 2 ++ tests/test_audit_store.py | 2 ++ tests/test_observability.py | 29 +++++++++++++++++++++++ 7 files changed, 71 insertions(+), 16 deletions(-) diff --git a/app/audit_store.py b/app/audit_store.py index c9085c8..1b12322 100644 --- a/app/audit_store.py +++ b/app/audit_store.py @@ -166,6 +166,9 @@ def _migrate_client_ip(self): columns = [row[1] for row in self._db.execute("PRAGMA table_info(requests)").fetchall()] if "client_ip" not in columns: self._db.execute("ALTER TABLE requests ADD COLUMN client_ip TEXT") + # Exact-match client filters must not scan the retained time range. + self._db.execute( + "CREATE INDEX IF NOT EXISTS requests_client ON requests(client_ip, started_at)") def _init_accounting(self): # Additive v1 migration: scan legacy details only once, under the same diff --git a/app/observability.py b/app/observability.py index e01da6d..fa18a1e 100644 --- a/app/observability.py +++ b/app/observability.py @@ -7,6 +7,7 @@ from __future__ import annotations import asyncio +import ipaddress from contextvars import ContextVar from dataclasses import dataclass, field import json @@ -48,32 +49,46 @@ def client_meta(scope, trusted_proxies): otherwise they are ignored so clients cannot spoof their address. """ headers = {} + forwarded: list[str] = [] for key, value in scope.get("headers") or []: name = key.decode("latin-1").lower() if isinstance(key, (bytes, bytearray)) else str(key).lower() - if name in _META_HEADERS and name not in headers: - headers[name] = value.decode("latin-1", "replace").strip() if isinstance(value, (bytes, bytearray)) else str(value).strip() + clean = value.decode("latin-1", "replace").strip() if isinstance(value, (bytes, bytearray)) else str(value).strip() + if name == "x-forwarded-for": + # A trusted proxy may append its hop as a separate header field; + # combine the fields in order before walking the chain. + if clean: + forwarded.append(clean) + elif name in _META_HEADERS and name not in headers: + headers[name] = clean client = scope.get("client") remote = client[0] if client and isinstance(client[0], str) else None + + def valid_ip(candidate: str | None) -> str | None: + try: + return str(ipaddress.ip_address(candidate)) if candidate else None + except ValueError: + return None + + remote_ip = valid_ip(remote) + remote_obj = ipaddress.ip_address(remote_ip) if remote_ip else None networks = _trusted_networks(trusted_proxies) - try: - import ipaddress - remote_ip = ipaddress.ip_address(remote) if remote else None - except ValueError: - remote_ip = None ip = remote - if remote_ip is not None and any(remote_ip in network for network in networks): - hops = [hop.strip() for hop in (headers.get("x-forwarded-for") or "").split(",") if hop.strip()][-32:] + if remote_obj is not None and any(remote_obj in network for network in networks): + hops = [hop.strip() for hop in (",".join(forwarded)).split(",") if hop.strip()][-32:] chosen = None + broken = False for hop in reversed(hops): - try: - hop_ip = ipaddress.ip_address(hop) - except ValueError: + hop_ip = valid_ip(hop) + if hop_ip is None: + # A malformed hop breaks the chain; never fall back to the + # client-controlled leftmost entry in that case. + broken = True break - chosen = str(hop_ip) - if not any(hop_ip in network for network in networks): + chosen = hop_ip + if not any(ipaddress.ip_address(hop_ip) in network for network in networks): break - if chosen is None: - chosen = headers.get("x-real-ip") or (hops[0] if hops else None) + if chosen is None or broken: + chosen = valid_ip(headers.get("x-real-ip")) ip = chosen or remote return ip, safe_agent(headers.get("user-agent")) diff --git a/docker-compose.yml b/docker-compose.yml index 42ef8db..ee6628f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,8 @@ services: CODEBUDDY2API_AUTO_ACCEPT_BUDDY: ${CODEBUDDY2API_AUTO_ACCEPT_BUDDY:-false} # Unset variables remain configurable through the WebUI. CODEBUDDY2API_ADMIN_ORIGINS: + # Trusted proxy IPs/CIDRs; forwarded headers count only from these peers. + CODEBUDDY2API_TRUSTED_PROXIES: CODEBUDDY2API_KEEP_TOOL_METADATA: CODEBUDDY2API_MAX_IMAGES: ${CODEBUDDY2API_MAX_IMAGES:-16} CODEBUDDY2API_IMAGE_POLICY: ${CODEBUDDY2API_IMAGE_POLICY:-truncate} diff --git a/docs/deployment.md b/docs/deployment.md index ee8490a..27a3257 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -76,6 +76,8 @@ Without uv, run `python3 -m venv .venv`, activate it, install dependencies with Both commands optionally read `.env` in the current working directory, never parent directories. Precedence: explicit CLI > process environment > `.env` > saved SQLite values > defaults. Overrides do not replace the saved default key; an explicitly empty key still locks management. Listener changes require restart; `CODEBUDDY2API_IMAGE/AUTH_PATH` are Compose-only. +> The audit schema is single-writer: stop the old instance before starting a new version, and never run two gateway versions against one auth directory. + ### Upgrade and state migration Stop the gateway and back up the entire data directory first. Startup imports legacy JSON sessions, cooldowns, credit/trial history, catalogs and usage into `control.sqlite3` once. Old files remain as backups and are no longer read or updated. Invalid critical state or a failed migration stops startup; repair it or restore a backup rather than deleting ledgers to bypass validation. diff --git a/docs/deployment.zh-CN.md b/docs/deployment.zh-CN.md index 1accfcf..b867106 100644 --- a/docs/deployment.zh-CN.md +++ b/docs/deployment.zh-CN.md @@ -77,6 +77,8 @@ uv run converter.py 两种启动方式均可选读取当前工作目录的 `.env`,不搜索父目录。优先级:显式 CLI > 进程环境变量 > `.env` > SQLite 保存值 > 默认值。覆盖不改写已保存的默认 key;显式空 key 仍锁定管理。修改监听需重启,`CODEBUDDY2API_IMAGE/AUTH_PATH` 仅用于 Compose。 +> 审计库为单写者设计:升级新版本前先停止旧实例,不要让两个版本的网关共用同一 auth 目录。 + ### 升级与数据迁移 升级前停止网关并备份整个数据目录。首次启动将旧 JSON 会话、冷却、积分/领取、目录和用量状态一次性导入 `control.sqlite3`;原文件保留作备份,不再参与读写。关键数据损坏或迁移失败会阻止启动,请修复或恢复备份,不要删除账本绕过检查。 diff --git a/tests/test_audit_store.py b/tests/test_audit_store.py index c8d9fe7..1e73209 100644 --- a/tests/test_audit_store.py +++ b/tests/test_audit_store.py @@ -621,6 +621,8 @@ def test_v2_database_gains_client_ip_without_losing_rows(self): self.addCleanup(reopened.close) self.assertIn("client_ip", [c[1] for c in reopened._db.execute("PRAGMA table_info(requests)")]) + indexes = [row[1] for row in reopened._db.execute("PRAGMA index_list(requests)")] + self.assertIn("requests_client", indexes) items = reopened.list_records()["items"] self.assertEqual([item["id"] for item in items], ["legacy-1"]) self.assertIsNone(items[0].get("client_ip")) diff --git a/tests/test_observability.py b/tests/test_observability.py index 4bc520e..9e001a4 100644 --- a/tests/test_observability.py +++ b/tests/test_observability.py @@ -426,6 +426,35 @@ def test_untrusted_direct_peer_ignores_forwarded_headers(self): self.assertEqual(client_meta(scope, "127.0.0.1"), ("203.0.113.7", "synthetic/1.0")) self.assertEqual(client_meta(scope, ""), ("203.0.113.7", "synthetic/1.0")) + def test_repeated_forwarded_fields_are_combined_in_order(self): + # Client spoofs one XFF field; the trusted proxy appends the real + # chain as a second field. The combined chain must prefer the + # proxy-appended hop, not the client-supplied first field. + scope = self.scope( + client=("127.0.0.1", 8080), + headers=[ + ("X-Forwarded-For", "6.6.6.6"), + ("X-Forwarded-For", "198.51.100.9, 10.0.0.9"), + ], + ) + self.assertEqual(client_meta(scope, "127.0.0.1, 10.0.0.0/8")[0], "198.51.100.9") + + def test_malformed_fallback_values_are_rejected(self): + real = self.scope( + client=("127.0.0.1", 8080), + headers=[("X-Forwarded-For", "198.51.100.9, not-an-ip"), ("X-Real-IP", "garbage")], + ) + # The malformed hop stops the walk; the X-Real-IP fallback is not an + # address either, so the trusted proxy address itself is recorded. + self.assertEqual(client_meta(real, "127.0.0.1")[0], "127.0.0.1") + # Even with a trusted proxy ahead of the garbage, a broken chain never + # promotes the client-controlled leftmost entry. + first = self.scope( + client=("127.0.0.1", 8080), + headers=[("X-Forwarded-For", "198.51.100.9, not-an-ip")], + ) + self.assertEqual(client_meta(first, "127.0.0.1, 10.0.0.0/8")[0], "127.0.0.1") + def test_trusted_proxy_unwraps_rightmost_untrusted_hop(self): scope = self.scope(client=("127.0.0.1", 8080), headers=[("X-Forwarded-For", "198.51.100.9, 10.0.0.9")])
正在加载详情…
{t("logs.detailLoading")}
暂无尝试记录。
{t("logs.noAttempts")}