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 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 })} + /> { @@ -235,19 +246,15 @@ export function ClearLogs({ onClose, onDone }: { onClose: () => void; onDone: () setKey(""); }} > - - + +
- {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: () />