From 19ea4ceaaabb2a3eecc1f19a201937acd8142677 Mon Sep 17 00:00:00 2001 From: ULookup Date: Wed, 22 Jul 2026 05:43:00 +0000 Subject: [PATCH 1/2] fix(config): standardize runtime secret injection --- .../references/core-flows.md | 20 ++ .../references/repository-map.md | 24 +- .../references/technology-stack.md | 18 +- .../skills/chatnow-securing-changes/SKILL.md | 10 +- .../chatnow-testing/references/framework.md | 17 +- .env.example | 21 ++ .github/workflows/ci.yml | 32 ++ .gitignore | 2 + README.md | 3 +- common/auth/auth_config_loader.hpp | 52 ++- common/config/secret_resolver.hpp | 216 ++++++++++++ conf/auth.json | 11 +- conf/docker/conversation_server.conf | 1 - conf/docker/gateway_server.conf | 1 - conf/docker/identity_server.conf | 3 - conf/docker/media_server.conf | 1 - conf/docker/message_server.conf | 2 - conf/docker/push_server.conf | 3 - conf/docker/relationship_server.conf | 1 - conf/docker/transmite_server.conf | 8 - conf/local/conversation_server.conf | 1 - conf/local/gateway_server.conf | 1 - conf/local/identity_server.conf | 3 - conf/local/message_server.conf | 4 +- conf/local/push_server.conf | 4 - conf/local/relationship_server.conf | 1 - conf/local/transmite_server.conf | 1 - conf/media.json | 4 +- conf/transmite_server.conf.example | 1 - conversation/source/conversation_server.cc | 6 +- docker-compose.yml | 29 +- docker/docker-compose.yml | 8 +- docs/operations/jwt-key-rotation.md | 101 +++--- docs/operations/runtime-secrets.md | 109 ++++++ .../2026-05-14-p4-media-object-storage.md | 13 +- ...26-05-15-relationship-service-migration.md | 2 +- ...26-05-16-conversation-service-migration.md | 2 +- .../plans/2026-05-17-push-service-refactor.md | 4 +- ...026-05-20-containerize-local-deployment.md | 24 +- .../2026-07-09-phase1-bvt-core-and-infra.md | 4 +- ...07-09-phase2-media-presence-cpp-removal.md | 8 +- ...-07-09-phase3-reliability-perf-baseline.md | 14 +- ...20-containerize-local-deployment-design.md | 6 +- gateway/source/gateway_server.cc | 7 +- gateway/source/gateway_server.h | 4 +- identity/source/identity_server.cc | 19 +- identity/source/identity_server.h | 4 +- media/source/media_main.cc | 29 +- message/source/message_server.cc | 11 +- push/source/push_server.cc | 13 +- push/source/push_server.h | 4 +- relationship/source/relationship_server.cc | 6 +- tests/config.yaml | 3 +- tests/pkg/agentpolicy/runtime_secrets_test.go | 316 ++++++++++++++++++ tests/pkg/verify/minio.go | 8 +- tests/pkg/verify/minio_test.go | 12 + transmite/source/transmite_server.cc | 18 +- 57 files changed, 1016 insertions(+), 234 deletions(-) create mode 100644 .env.example create mode 100644 common/config/secret_resolver.hpp create mode 100644 docs/operations/runtime-secrets.md create mode 100644 tests/pkg/agentpolicy/runtime_secrets_test.go diff --git a/.agents/skills/chatnow-orienting/references/core-flows.md b/.agents/skills/chatnow-orienting/references/core-flows.md index 13a0e62..a25300b 100644 --- a/.agents/skills/chatnow-orienting/references/core-flows.md +++ b/.agents/skills/chatnow-orienting/references/core-flows.md @@ -1,5 +1,9 @@ # Core Flows +Target version: `3.0-dev` +Status: Current +Verified: 2026-07-22 + These are current-state flows for the `3.0-dev` line. Re-verify affected symbols at the target commit and keep proposals in a separate section. ## HTTP request flow @@ -41,12 +45,28 @@ These are current-state flows for the `3.0-dev` line. Re-verify affected symbols **Flow:** Identity registration/login/refresh -> JWT -> Gateway or Push verification -> server-derived context -> downstream brpc metadata. - Entry/contracts: `proto/identity/identity_service.proto`; `identity/source/identity_server.h`; `common/auth/jwt_codec.hpp`; `common/auth/jwt_store.hpp`; `gateway/source/gateway_auth.hpp`; `push/source/push_server.h`. +- Key loading: Identity, Gateway, and Push each resolve the complete JWT JSON document from exactly one of `CHATNOW_JWT_CONFIG` or `CHATNOW_JWT_CONFIG_FILE` at process startup. Identity signs and verifies; Gateway and Push verify. The codec is not hot-reloaded, so a key-set or `current_kid` change requires a controlled rollout of all affected processes. - Stores: Identity uses MySQL for users/devices and Redis for active refresh tokens, rotation/reuse detection, and revocation state. - Trust: Gateway validates Bearer access tokens and revocation before deriving metadata. Push verifies WS `CLIENT_AUTH` and binds claim identity to the connection. Downstream handlers use `common/auth/auth_context.hpp`; service-to-service forwarding uses `common/auth/forward_auth.hpp` where required. - Sync/retry: Login and refresh are synchronous; refresh rotation detects reuse. Cache/store failure behavior must be inspected before changing fail-open/fail-closed semantics. - Tests: `tests/bvt/auth_test.go`, `tests/func/identity_test.go`, `tests/func/auth_middleware_test.go`, `tests/func/security_test.go`, `tests/func/scenarios_test.go`. - Invariants: only Identity issues/refreshes tokens; access and refresh token purposes remain distinct; downstream identity comes from verified claims and forwarded metadata, not request bodies. +## Runtime secrets + +### Current + +**Flow:** deployment environment or mounted secret file -> common resolver -> service startup -> dependency/auth client construction. + +- `common/config/secret_resolver.hpp` owns an allowlist of logical credentials and their direct-environment/`_FILE` names. It rejects missing or conflicting sources, invalid values, symlinks, non-regular files, unexpected owners, and group/other-accessible modes. +- JWT (Identity, Gateway, Push), application MySQL (Conversation, Identity, Media, Message, Relationship), RabbitMQ (Transmite, Message, Push), SMTP (Identity), and S3 application credentials (Media) use the resolver. +- Resolution happens once at startup. Missing or unsafe input prevents the service from accepting traffic; there is no hot reload or tracked/default fallback. +- Real credential changes and production rotation require explicit human approval. Values and credential-derived fingerprints must never appear in logs or operational evidence. + +### Proposed + +Redis authentication, dynamic reload, automatic rotation, and additional credential classes are not implemented. They require their own scoped Issues and executable tests; do not infer them from the current resolver. See `docs/operations/runtime-secrets.md` for the exact current contract and rollback procedure. + ## Media upload and download **Flow:** Apply/init -> presigned MinIO upload -> complete -> MySQL metadata/quota -> authenticated download request -> presigned MinIO GET. diff --git a/.agents/skills/chatnow-orienting/references/repository-map.md b/.agents/skills/chatnow-orienting/references/repository-map.md index a501077..2697878 100644 --- a/.agents/skills/chatnow-orienting/references/repository-map.md +++ b/.agents/skills/chatnow-orienting/references/repository-map.md @@ -1,5 +1,9 @@ # Repository Map +Target version: `3.0-dev` +Status: Current +Verified: 2026-07-22 + ## Ownership and first reads | Path | Ownership | First-read files | @@ -16,13 +20,13 @@ | `presence/` | Presence aggregation, subscriptions, and typing coordination | `presence/source/presence_server.h`, `presence/source/presence_server.cc`, `proto/presence/presence_service.proto` | | `push/` | WebSocket connections, routes, cross-instance delivery, resend, client ACK ingestion | `push/source/push_server.h`, `push/source/connection.hpp`, `push/source/push_server.cc`, `proto/push/notify.proto` | | `odb/` | ODB entity definitions and durable relational fields | Affected entity, especially `message.hxx`, `user_timeline.hxx`, `conversation_member.hxx`, and `media_*.hxx` | -| `conf/` | Local/container flags and JSON configuration | `conf/local/`, `conf/docker/`, `conf/auth.json`, `conf/media.json` | +| `conf/` | Non-secret local/container flags and JSON configuration; tracked files are not a runtime secret source | `conf/local/`, `conf/docker/`, `conf/auth.json`, `conf/media.json` | | `sql/` | Versioned schema migrations | `sql/V4__media.sql` and any migration matching affected ODB entities | | `docker/` | Separate MinIO topology and initialization; not wired into the root application network | `docker/docker-compose.yml`, `docker/minio-init/entrypoint.sh` | | `docker-compose.yml` | Application stack declaration; Media object-storage wiring is incomplete | Root `docker-compose.yml`, then affected `Dockerfile` and `conf/docker` file | | `scripts/` | Operational support and monitoring configuration | `scripts/install_aws_sdk_linux.sh`, `scripts/prometheus/redis_alerts.yml` | -| `tests/` | Pure-Go L1-L4 framework, clients, fixtures, cleanup, and store verification | `tests/Makefile`, `tests/config.yaml`, affected `tests/bvt`, `tests/func`, `tests/perf`, `tests/pkg` | -| `docs/` | Secondary architecture/API/operations context | Affected `docs/api/*.yaml`, `docs/operations/`, then relevant architecture documents | +| `tests/` | Pure-Go L1-L4 plus Redis-focused Reliability framework, clients, fixtures, cleanup, and store verification | `tests/Makefile`, `tests/config.yaml`, affected `tests/bvt`, `tests/func`, `tests/perf`, `tests/reliability`, `tests/pkg` | +| `docs/` | Secondary architecture/API context and canonical operations guidance | `docs/operations/runtime-secrets.md`, affected `docs/api/*.yaml`, then relevant architecture documents | ## Verified ports and infrastructure endpoints @@ -52,6 +56,20 @@ MySQL service configs set `mysql_port=0`, while root Compose exposes MySQL on `3 Root Compose mounts `conf/media.json` into Media, but `s3.endpoint=http://127.0.0.1:9000` addresses the Media container itself. Root Compose has no MinIO service/dependency, while the supplemental MinIO Compose project has no declared shared external network with the root project. Do not present these declarations as a working integrated Media topology or recommend their current commands as a functional Media runtime. Any repair must explicitly reconcile the network, endpoint, dependency, and `9000`/`9001` host-port conflicts, then be verified from the affected containers. +## Runtime credential ownership + +Current consumers at the verified commit are: + +- Identity, Gateway, and Push resolve the complete JWT JSON document from `CHATNOW_JWT_CONFIG` or `CHATNOW_JWT_CONFIG_FILE` at startup. Identity signs and verifies tokens; Gateway and Push verify them. +- Conversation, Identity, Media, Message, and Relationship resolve service-specific MySQL password inputs through `common/config/secret_resolver.hpp`. +- Transmite, Message, and Push resolve service-specific RabbitMQ password inputs through the same resolver. +- Identity resolves its SMTP password; Media resolves separate S3 access-key and secret-key inputs. Non-secret S3 settings remain in `conf/media.json`. +- Root Compose requires MySQL, RabbitMQ, and supplemental MinIO bootstrap values through deployment environment references. These are separate from least-privileged application inputs. Redis has no configured password or ACL consumer. + +Tracked runtime credential literals have been removed from the scoped source, configuration, Compose, and test-runtime surfaces. Do not reintroduce values in documentation, logs, test output, Issues, or PRs. Synthetic test-only credentials and API examples require narrow scanner exemptions rather than broad path allowlists. + +The canonical current inventory and injection contract are in `docs/operations/runtime-secrets.md`. Reinspect the resolver and each consumer before extending the allowlist or claiming support for a credential not named there. + ## State ownership - MySQL/ODB: durable users, relationships, conversations/members, messages/timelines, ACK high-water marks, and media metadata/quota. diff --git a/.agents/skills/chatnow-orienting/references/technology-stack.md b/.agents/skills/chatnow-orienting/references/technology-stack.md index c81a5c3..4232c93 100644 --- a/.agents/skills/chatnow-orienting/references/technology-stack.md +++ b/.agents/skills/chatnow-orienting/references/technology-stack.md @@ -1,5 +1,9 @@ # Technology Stack and Entry Points +Target version: `3.0-dev` +Status: Current +Verified: 2026-07-22 + Use this reference for the `3.0-dev` architecture line, then verify task-sensitive details at the resolved commit. ## Stack @@ -37,10 +41,15 @@ Inspect root `CMakeLists.txt`, the affected service's `CMakeLists.txt`, and its - Local service flags: `conf/local/*_server.conf`. - Container service flags: `conf/docker/*_server.conf`. -- JWT keys and TTLs: `conf/auth.json`. -- Media S3, buckets, presign, and MIME policy: `conf/media.json` plus Media flags. +- JWT keys and TTLs: Identity, Gateway, and Push resolve `CHATNOW_JWT_CONFIG` or `CHATNOW_JWT_CONFIG_FILE` at process startup. The value is the complete JSON document. +- Media S3 application credentials are resolved through the common secret resolver. Buckets, endpoint, presign, and MIME policy remain in `conf/media.json` plus Media flags. - Example Transmite flags: `conf/transmite_server.conf.example`. - Service defaults and flag definitions: each `/source/_server.cc`. +- MySQL passwords for Conversation, Identity, Media, Message, and Relationship use service-specific direct-environment or `_FILE` inputs through `common/config/secret_resolver.hpp`. +- RabbitMQ passwords for Transmite, Message, and Push use the same resolver contract. Identity SMTP and Media S3 application credentials are also migrated. +- The resolver accepts exactly one allowlisted direct environment variable or `_FILE` locator, fails closed on missing/conflicting input, and validates secret-file type, owner, mode, size, and content. It reads once at startup; there is no hot reload. +- Bootstrap credentials in Compose remain deployment environment references rather than application resolver inputs. Redis has no configured password or ACL consumer. +- The canonical names, consumers, deployment rules, and limitations are maintained in `docs/operations/runtime-secrets.md`. - Root `docker-compose.yml` declares the application stack used by CI, but it is not a complete integrated Media/MinIO topology: it starts Media without a MinIO service or dependency. - `docker/docker-compose.yml` separately declares MinIO and its initialization sidecar on a different default Compose network. Media mounts `conf/media.json`, whose `http://127.0.0.1:9000` endpoint resolves to the Media container itself, not to that separate MinIO container. @@ -57,7 +66,10 @@ The current test framework is entirely Go. New or restored C++ test suites are p | L2 Functional | `tests/func`, `func` | `cd tests && make proto && make test-func` | | L3 Scenario | `tests/func`, `func` | `cd tests && make proto && make test-scenario` | | L4 Performance | `tests/perf`, `perf` | `cd tests && make proto && make test-perf` | +| Reliability | `tests/reliability`, `reliability` | `cd tests && make proto && make test-reliability` | + +Reliability is an executable, Redis-focused layer. Its current tests exercise Redis circuit recovery and Push unacked requeue behavior through `tests/pkg/chaos/redis.go`. The Make target runs the whole layer and does not consume `TEST_RUN`; use a direct tagged `go test ... -run` command when exact selection is required. No current controller covers RabbitMQ, MySQL, arbitrary services, or general network faults, so do not describe this as a broad chaos platform. -Reliability is a distinct framework layer with the reserved `reliability` build tag. There is currently no repository path or Make target for it, so do not claim a runnable Reliability command. Shared clients, fixtures, polling, cleanup, and direct store verification live under `tests/pkg`. +The CI definition has a dedicated `reliability` job that depends on `service-artifacts`, independently of BVT. At this verification date the job exists, but the inspected PR run was skipped after an upstream failure; that is not green runtime evidence. Shared clients, fixtures, polling, cleanup, and direct store verification live under `tests/pkg`. The CI definition is `.github/workflows/ci.yml`; verify its commands against files present at the target commit before copying them into local instructions. diff --git a/.agents/skills/chatnow-securing-changes/SKILL.md b/.agents/skills/chatnow-securing-changes/SKILL.md index c5d5dcf..1d108f4 100644 --- a/.agents/skills/chatnow-securing-changes/SKILL.md +++ b/.agents/skills/chatnow-securing-changes/SKILL.md @@ -5,6 +5,10 @@ description: Use when ChatNow work touches authentication, authorization, user i # Secure ChatNow Changes +Target version: `3.0-dev` +Status: Current +Verified: 2026-07-22 + ## Core principle Treat every external value as untrusted until a named server boundary validates it. Minimize authority and exposed data, and fail securely when a high-impact decision cannot be made safely. @@ -36,6 +40,9 @@ Stop when identity or ownership is ambiguous, authorization cannot be evaluated ### Identity, credentials, and logs - Preserve server-derived identity across trusted metadata and validate it again at the receiving boundary. Never forward a client identity as authenticated context. +- Treat `docs/operations/runtime-secrets.md` as the canonical credential inventory and runtime contract. Reinspect `common/config/secret_resolver.hpp` and the executable consumer before extending it; do not create a parallel loader. +- For a migrated credential, accept exactly one of its allowlisted direct environment variable or `_FILE` companion. Reject a direct/file conflict, missing required input, an empty value, an unreadable file, a symlink, non-regular input, unexpected ownership, or permissions that grant access beyond the intended runtime identity. Do not fall back to tracked configuration or a compiled default. +- Keep deployment bootstrap credentials separate from least-privileged application credentials. Local and CI values must be unique synthetic fixtures; never copy a real credential into a repository file, command line, workflow output, test failure, or artifact. - Never log or expose bearer tokens, authorization headers, passwords, signing keys, session secrets, cookies, presigned URLs, or real credentials. Do not create, log, or expose any credential-derived token fingerprint, including a hash, keyed HMAC, prefix, suffix, encoded value, or truncated derivative. Permit such a derivative only when an approved protocol explicitly requires it, constrain it to that protocol, and never repurpose it for diagnostics; prefer request or trace IDs. - Minimize personal data. Prefer a trace/request ID or purpose-specific opaque correlation ID. Redact or omit user identifiers, device identifiers, message content, contact data, object names, and search text unless the Issue documents necessity, access, retention, and a safe representation. - Write English structured logs with stable event and outcome fields. Avoid free-form concatenation of untrusted values and log injection; encode fields through the established logger. @@ -55,7 +62,7 @@ Stop when identity or ownership is ambiguous, authorization cannot be evaluated ## Human approval boundaries -Obtain explicit human approval before using or changing real credentials, operating in production, performing irreversible migration or deletion, or intentionally changing public compatibility or settled product semantics. Approval must name the exact operation and scope. A deadline, temporary diagnostic, rollback plan, or existing access does not substitute for approval. +Obtain explicit human approval before using, rotating, revoking, or changing real credentials; operating in production; performing irreversible migration or deletion; or intentionally changing public compatibility or settled product semantics. Approval must name the exact operation and scope. A deadline, temporary diagnostic, rollback plan, or existing access does not substitute for approval. ## Test contract @@ -66,6 +73,7 @@ Add pure-Go adversarial and regression cases for every changed boundary. Include - SQL metacharacters and Elasticsearch field/operator/script/query-string injection, authorization-filter bypass, excessive limits, and expensive queries; - `..`, absolute, mixed-separator, percent-encoded, NUL, symlink, bucket/prefix, and cross-user object-key traversal; - secret and personal-data absence from logs, responses, traces, fixtures, failure output, and generated artifacts; +- direct/file secret conflicts, missing/empty input, unsafe file ownership or permissions, symlinks, and proof that tracked/default values cannot silently take over; - dependency timeout/unavailability at a high-impact decision, proving a bounded secure failure with no partial privileged effect. Use unique synthetic identities and credentials only. Assign cleanup ownership for users, rows, indexes/documents, objects, keys, sockets, and temporary files. diff --git a/.agents/skills/chatnow-testing/references/framework.md b/.agents/skills/chatnow-testing/references/framework.md index 24583b9..c4cea8a 100644 --- a/.agents/skills/chatnow-testing/references/framework.md +++ b/.agents/skills/chatnow-testing/references/framework.md @@ -1,5 +1,9 @@ # ChatNow Test Framework +Target version: `3.0-dev` +Status: Current +Verified: 2026-07-22 + Read this reference before selecting, implementing, running, or reporting a test. Reinspect `tests/Makefile`, `.github/workflows/ci.yml`, and the current `tests/` tree before relying on a path, target, or command; executable files are authoritative. ## Layers and executable surface @@ -11,9 +15,11 @@ Read this reference before selecting, implementing, running, or reporting a test | L2 Functional | `tests/func`, `func` | Service APIs, errors, boundaries | `make -C tests test-func` | | L3 Scenario | `tests/func`, `func`; `TestScenario` filter | Cross-service workflows and store consistency | `make -C tests test-scenario` | | L4 Performance | `tests/perf`, `perf` | Throughput and latency baselines | `make -C tests test-perf` | -| Reliability | Reserved `reliability` tag and distinct layer | Failure injection, recovery, durability, and convergence | No current directory or Make target. Inspect the executable surface; do not invent a command or claim a run. | +| Reliability | `tests/reliability`, `reliability` | Redis failure injection, recovery, and Push unacked convergence | `make -C tests test-reliability` | + +The current `tests/Makefile` also provides `make -C tests proto`, `make -C tests deps`, `make -C tests test-agent-policy`, and `make -C tests clean`. Run `proto` only when generated Go protobuf is required; `clean` removes generated `tests/proto/chatnow` content. Repository policy checks are static evidence and never substitute for a behavior RED or runtime gate. -The current `tests/Makefile` also provides `make -C tests proto`, `make -C tests deps`, and `make -C tests clean`. Run `proto` only when generated Go protobuf is required; `clean` removes generated `tests/proto/chatnow` content. +The Reliability target runs the complete tagged package and does not consume `TEST_RUN`. For an exact test, invoke the same tagged Go package with an anchored `-run` expression, then run `make -C tests test-reliability` for the layer regression. The current fault controller is Redis-only; there is no RabbitMQ, MySQL, arbitrary-service, or general-network controller. The L0 commands currently encoded in `.github/workflows/ci.yml` are: @@ -32,7 +38,9 @@ These are Linux workflow commands. On another platform, report the workflow as n ## Gate order -CI orders `build` -> `bvt` -> `func`; a failed BVT prevents Functional and Scenario execution. Scheduled runs continue from `func` to `perf`. Preserve BVT short-circuit behavior when changing workflows or selecting local risk checks. +CI runs `build` independently and builds reusable service artifacts. BVT needs `service-artifacts`; Functional needs both `service-artifacts` and BVT; Reliability needs `service-artifacts` but does not wait for BVT. Scheduled Performance runs after Functional, while the cache-performance job depends directly on `service-artifacts`. Preserve each gate's actual dependency when changing workflows or selecting local risk checks. + +The dedicated Reliability job exists, but the inspected PR run was skipped after an upstream failure. Its existence is executable-surface evidence, not a successful runtime result. ## Shared framework @@ -58,7 +66,8 @@ Assign exactly one owner for each created resource. Prefer suite-level cleanup t | One service API, authorization rule, validation, boundary, or error path | L2 Functional | Add L3 when other services or stores participate. | | Cross-service flow, MQ/WebSocket delivery, idempotency, ordering, or MySQL/Elasticsearch/MinIO consistency | L3 Scenario | Also run affected L2 and L1 gates. | | Throughput, latency, allocation, or benchmark threshold | L4 Performance | Also run correctness layers for behavior used by the benchmark. | -| Failure injection, restart recovery, durability, or degraded convergence | Reliability | Treat as reserved until an executable surface exists; add the necessary architecture only when the scoped change authorizes it, and run lower correctness layers meanwhile. | +| Redis failure injection, restart recovery, or Push unacked convergence | Reliability | Run the exact tagged test, then `make -C tests test-reliability`; also run lower correctness layers selected by the affected behavior. | +| RabbitMQ/MySQL/service/network fault behavior | Reliability | No current controller exists for these faults. Do not expand the framework unless the scoped Issue authorizes it; use the nearest executable correctness layer and report the gap. | Choose the lowest layer that can fail for the required behavior, not the cheapest layer that happens to run. Run the target test first, its same-layer regressions second, and broader layers according to risk. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3d88bcc --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# Local and CI only. Use synthetic values; production should use the matching +# *_FILE variables and a deployment-managed read-only secret mount. +CHATNOW_MYSQL_ROOT_PASSWORD= +CHATNOW_RABBITMQ_BOOTSTRAP_PASSWORD= +CHATNOW_IDENTITY_MYSQL_PASSWORD= +CHATNOW_CONVERSATION_MYSQL_PASSWORD= +CHATNOW_RELATIONSHIP_MYSQL_PASSWORD= +CHATNOW_MESSAGE_MYSQL_PASSWORD= +CHATNOW_MEDIA_MYSQL_PASSWORD= +CHATNOW_TRANSMITE_MQ_PASSWORD= +CHATNOW_MESSAGE_MQ_PASSWORD= +CHATNOW_PUSH_MQ_PASSWORD= +CHATNOW_IDENTITY_SMTP_PASSWORD= +CHATNOW_MEDIA_S3_ACCESS_KEY= +CHATNOW_MEDIA_S3_SECRET_KEY= +CHATNOW_JWT_CONFIG= +CHATNOW_MINIO_ROOT_USER= +CHATNOW_MINIO_ROOT_PASSWORD= +MYSQL_DSN= +MINIO_ACCESS_KEY= +MINIO_SECRET_KEY= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1cfb6ab..24a4a3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,6 +112,34 @@ jobs: - &validate-compose-artifacts name: Validate downloaded Compose service artifacts run: docker run --rm -v "$PWD:/workspace" -w /workspace ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 ./scripts/validate_compose_artifacts.sh compose-artifacts + - &inject-runtime-secrets + name: Create synthetic runtime secrets + shell: bash + run: | + set -euo pipefail + umask 077 + shared_password="$(openssl rand -hex 24)" + jwt_key="$(openssl rand -hex 32)" + jwt_config="$(printf '{\"auth\":{\"jwt\":{\"current_kid\":\"ci-v1\",\"keys\":{\"ci-v1\":\"%s\"},\"access_ttl_sec\":7200,\"refresh_ttl_sec\":2592000}}}' "$jwt_key")" + { + printf 'CHATNOW_MYSQL_ROOT_PASSWORD=%s\n' "$shared_password" + printf 'CHATNOW_RABBITMQ_BOOTSTRAP_PASSWORD=%s\n' "$shared_password" + printf 'CHATNOW_IDENTITY_MYSQL_PASSWORD=%s\n' "$shared_password" + printf 'CHATNOW_CONVERSATION_MYSQL_PASSWORD=%s\n' "$shared_password" + printf 'CHATNOW_RELATIONSHIP_MYSQL_PASSWORD=%s\n' "$shared_password" + printf 'CHATNOW_MESSAGE_MYSQL_PASSWORD=%s\n' "$shared_password" + printf 'CHATNOW_MEDIA_MYSQL_PASSWORD=%s\n' "$shared_password" + printf 'CHATNOW_TRANSMITE_MQ_PASSWORD=%s\n' "$shared_password" + printf 'CHATNOW_MESSAGE_MQ_PASSWORD=%s\n' "$shared_password" + printf 'CHATNOW_PUSH_MQ_PASSWORD=%s\n' "$shared_password" + printf 'CHATNOW_IDENTITY_SMTP_PASSWORD=%s\n' "$shared_password" + printf 'CHATNOW_MEDIA_S3_ACCESS_KEY=%s\n' 'ci-synthetic-access' + printf 'CHATNOW_MEDIA_S3_SECRET_KEY=%s\n' "$shared_password" + printf 'CHATNOW_JWT_CONFIG=%s\n' "$jwt_config" + printf 'MYSQL_DSN=root:%s@tcp(localhost:3306)/chatnow?charset=utf8mb4&parseTime=true\n' "$shared_password" + printf 'MINIO_ACCESS_KEY=%s\n' 'ci-synthetic-access' + printf 'MINIO_SECRET_KEY=%s\n' "$shared_password" + } >> "$GITHUB_ENV" - name: Start full stack run: docker compose up -d --build - name: Wait for services @@ -145,6 +173,7 @@ jobs: - *restore-compose-artifacts - *chmod-compose-artifacts - *validate-compose-artifacts + - *inject-runtime-secrets - name: Start full stack run: docker compose up -d --build - name: Wait for services @@ -178,6 +207,7 @@ jobs: - *restore-compose-artifacts - *chmod-compose-artifacts - *validate-compose-artifacts + - *inject-runtime-secrets - name: Start full stack run: docker compose up -d --build - name: Wait for services @@ -211,6 +241,7 @@ jobs: - *restore-compose-artifacts - *chmod-compose-artifacts - *validate-compose-artifacts + - *inject-runtime-secrets - name: Start full stack with PF-09 rate limits env: # Transmite flags are int32; use the maximum valid value for gate-only headroom. @@ -244,6 +275,7 @@ jobs: sudo apt-get install -y protobuf-compiler netcat-openbsd - name: Install Go protobuf generator run: go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11 + - *inject-runtime-secrets - name: Start full stack run: docker compose up -d --build - name: Wait for services diff --git a/.gitignore b/.gitignore index ef5d314..db10c1f 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,8 @@ Thumbs.db # Environment secrets .env +.secrets/ + # Runtime data logs/ diff --git a/README.md b/README.md index 5c0972f..3c82b36 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,8 @@ ChatNow/ ## 安全 -⚠️ 仓库内样例配置含明文凭据,**切勿直接用于生产环境**。部署前请将密码 / 密钥迁移至安全的配置中心或环境变量。 +- [Runtime secret management](docs/operations/runtime-secrets.md) +- [JWT key rotation](docs/operations/jwt-key-rotation.md) ## License diff --git a/common/auth/auth_config_loader.hpp b/common/auth/auth_config_loader.hpp index 3e009f3..36af64e 100644 --- a/common/auth/auth_config_loader.hpp +++ b/common/auth/auth_config_loader.hpp @@ -1,8 +1,8 @@ #pragma once /** - * 从 JSON 文件加载 JwtConfig — 横切 spec §2.2 - * 启动时 fail-fast:文件不存在 / JSON 解析失败 / validate 失败 → throw std::runtime_error + * Load JwtConfig from the unified runtime Secret resolver. + * Startup fails closed when the source is missing, malformed, or invalid. * * JSON shape: * { @@ -23,39 +23,57 @@ #include -#include +#include #include #include namespace chatnow::auth { -inline JwtConfig load_jwt_config_from_file(const std::string& path) { - std::ifstream ifs(path); - if (!ifs) { - throw std::runtime_error("auth_config: cannot open " + path); - } +inline JwtConfig parse_jwt_config(const std::string& document) { + std::istringstream input(document); Json::Value root; Json::CharReaderBuilder b; - std::string err; - if (!Json::parseFromStream(b, ifs, &root, &err)) { - throw std::runtime_error("auth_config: parse failed: " + err); + std::string parse_errors; + if (!Json::parseFromStream(b, input, &root, &parse_errors)) { + throw std::runtime_error("auth_config: parse_failed"); } - if (!root.isMember("auth") || !root["auth"].isMember("jwt")) { - throw std::runtime_error("auth_config: missing auth.jwt section in " + path); + if (!root.isObject() || !root.isMember("auth") || !root["auth"].isObject() || + !root["auth"].isMember("jwt") || !root["auth"]["jwt"].isObject()) { + throw std::runtime_error("auth_config: schema_invalid"); } const auto& j = root["auth"]["jwt"]; JwtConfig cfg; + if (!j.isMember("current_kid") || !j["current_kid"].isString()) { + throw std::runtime_error("auth_config: schema_invalid"); + } cfg.current_kid = j.get("current_kid", "").asString(); - if (j.isMember("access_ttl_sec")) cfg.access_ttl_sec = j["access_ttl_sec"].asInt(); - if (j.isMember("refresh_ttl_sec")) cfg.refresh_ttl_sec = j["refresh_ttl_sec"].asInt(); + if (j.isMember("access_ttl_sec")) { + if (!j["access_ttl_sec"].isInt()) { + throw std::runtime_error("auth_config: schema_invalid"); + } + cfg.access_ttl_sec = j["access_ttl_sec"].asInt(); + } + if (j.isMember("refresh_ttl_sec")) { + if (!j["refresh_ttl_sec"].isInt()) { + throw std::runtime_error("auth_config: schema_invalid"); + } + cfg.refresh_ttl_sec = j["refresh_ttl_sec"].asInt(); + } if (!j.isMember("keys") || !j["keys"].isObject()) { - throw std::runtime_error("auth_config: missing auth.jwt.keys map"); + throw std::runtime_error("auth_config: schema_invalid"); } for (const auto& kid : j["keys"].getMemberNames()) { + if (!j["keys"][kid].isString()) { + throw std::runtime_error("auth_config: schema_invalid"); + } cfg.keys[kid] = j["keys"][kid].asString(); } - cfg.validate_or_throw(); + try { + cfg.validate_or_throw(); + } catch (const std::exception&) { + throw std::runtime_error("auth_config: validation_failed"); + } return cfg; } diff --git a/common/config/secret_resolver.hpp b/common/config/secret_resolver.hpp new file mode 100644 index 0000000..517d919 --- /dev/null +++ b/common/config/secret_resolver.hpp @@ -0,0 +1,216 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace chatnow::config { + +enum class SecretId { + JwtConfig, + IdentityMysqlPassword, + ConversationMysqlPassword, + RelationshipMysqlPassword, + MessageMysqlPassword, + MediaMysqlPassword, + TransmiteMqPassword, + MessageMqPassword, + PushMqPassword, + IdentitySmtpPassword, + MediaS3AccessKey, + MediaS3SecretKey, +}; + +struct SecretSpec { + const char* env_name; + const char* env_file; + std::size_t max_bytes; +}; + +inline SecretSpec secret_spec(SecretId id) { + constexpr std::size_t kPasswordMaxBytes = 4096; + constexpr std::size_t kJwtConfigMaxBytes = 64 * 1024; + switch (id) { + case SecretId::JwtConfig: + return {"CHATNOW_JWT_CONFIG", "CHATNOW_JWT_CONFIG_FILE", kJwtConfigMaxBytes}; + case SecretId::IdentityMysqlPassword: + return {"CHATNOW_IDENTITY_MYSQL_PASSWORD", "CHATNOW_IDENTITY_MYSQL_PASSWORD_FILE", kPasswordMaxBytes}; + case SecretId::ConversationMysqlPassword: + return {"CHATNOW_CONVERSATION_MYSQL_PASSWORD", "CHATNOW_CONVERSATION_MYSQL_PASSWORD_FILE", kPasswordMaxBytes}; + case SecretId::RelationshipMysqlPassword: + return {"CHATNOW_RELATIONSHIP_MYSQL_PASSWORD", "CHATNOW_RELATIONSHIP_MYSQL_PASSWORD_FILE", kPasswordMaxBytes}; + case SecretId::MessageMysqlPassword: + return {"CHATNOW_MESSAGE_MYSQL_PASSWORD", "CHATNOW_MESSAGE_MYSQL_PASSWORD_FILE", kPasswordMaxBytes}; + case SecretId::MediaMysqlPassword: + return {"CHATNOW_MEDIA_MYSQL_PASSWORD", "CHATNOW_MEDIA_MYSQL_PASSWORD_FILE", kPasswordMaxBytes}; + case SecretId::TransmiteMqPassword: + return {"CHATNOW_TRANSMITE_MQ_PASSWORD", "CHATNOW_TRANSMITE_MQ_PASSWORD_FILE", kPasswordMaxBytes}; + case SecretId::MessageMqPassword: + return {"CHATNOW_MESSAGE_MQ_PASSWORD", "CHATNOW_MESSAGE_MQ_PASSWORD_FILE", kPasswordMaxBytes}; + case SecretId::PushMqPassword: + return {"CHATNOW_PUSH_MQ_PASSWORD", "CHATNOW_PUSH_MQ_PASSWORD_FILE", kPasswordMaxBytes}; + case SecretId::IdentitySmtpPassword: + return {"CHATNOW_IDENTITY_SMTP_PASSWORD", "CHATNOW_IDENTITY_SMTP_PASSWORD_FILE", kPasswordMaxBytes}; + case SecretId::MediaS3AccessKey: + return {"CHATNOW_MEDIA_S3_ACCESS_KEY", "CHATNOW_MEDIA_S3_ACCESS_KEY_FILE", kPasswordMaxBytes}; + case SecretId::MediaS3SecretKey: + return {"CHATNOW_MEDIA_S3_SECRET_KEY", "CHATNOW_MEDIA_S3_SECRET_KEY_FILE", kPasswordMaxBytes}; + } + throw std::runtime_error("SecretId: invalid_secret_id"); +} + +namespace detail { + +[[noreturn]] inline void throw_secret_error(const char* locator, const char* reason) { + throw std::runtime_error(std::string(locator) + ": " + reason); +} + +inline void reject_nul(const std::string& value, const char* locator) { + if (value.find('\0') != std::string::npos) { + throw_secret_error(locator, "value_contains_nul"); + } +} + +inline void validate_secret_value(const std::string& value, + const SecretSpec& spec, + const char* locator) { + if (value.empty()) { + throw_secret_error(locator, "value_empty"); + } + reject_nul(value, locator); + if (value.size() > spec.max_bytes) { + throw_secret_error(locator, "value_too_large"); + } +} + +inline void trim_one_trailing_line_ending(std::string& value) { + if (!value.empty() && value.back() == '\n') { + value.pop_back(); + if (!value.empty() && value.back() == '\r') { + value.pop_back(); + } + } else if (!value.empty() && value.back() == '\r') { + value.pop_back(); + } +} + +class ScopedFd { +public: + explicit ScopedFd(int fd) noexcept : _fd(fd) {} + ScopedFd(const ScopedFd&) = delete; + ScopedFd& operator=(const ScopedFd&) = delete; + ~ScopedFd() { + if (_fd >= 0) { + ::close(_fd); + } + } + + int get() const noexcept { return _fd; } + +private: + int _fd; +}; + +inline std::string read_secret_file(const std::string& path, const SecretSpec& spec) { + constexpr std::size_t kMaxLocatorBytes = 4096; + if (path.empty()) { + throw_secret_error(spec.env_file, "locator_empty"); + } + if (path.front() != '/') { + throw_secret_error(spec.env_file, "locator_not_absolute"); + } + reject_nul(path, spec.env_file); + if (path.size() > kMaxLocatorBytes) { + throw_secret_error(spec.env_file, "locator_too_large"); + } + + int raw_fd; + do { + raw_fd = ::open(path.c_str(), O_RDONLY | O_NOFOLLOW | O_CLOEXEC); + } while (raw_fd < 0 && errno == EINTR); + if (raw_fd < 0) { + throw_secret_error(spec.env_file, "open_failed"); + } + ScopedFd fd(raw_fd); + + struct stat status {}; + if (::fstat(fd.get(), &status) != 0) { + throw_secret_error(spec.env_file, "stat_failed"); + } + if (!S_ISREG(status.st_mode)) { + throw_secret_error(spec.env_file, "not_regular_file"); + } + const uid_t effective_uid = ::geteuid(); + if (status.st_uid != effective_uid && status.st_uid != 0) { + throw_secret_error(spec.env_file, "owner_not_allowed"); + } + constexpr mode_t kForbiddenMode = S_IRWXG | S_IRWXO | S_ISUID | S_ISGID | S_ISVTX; + if ((status.st_mode & kForbiddenMode) != 0) { + throw_secret_error(spec.env_file, "permissions_too_open"); + } + + constexpr std::size_t kTrailingLineEndingMaxBytes = 2; + const std::size_t raw_limit = spec.max_bytes + kTrailingLineEndingMaxBytes; + if (status.st_size < 0 || static_cast(status.st_size) > raw_limit) { + throw_secret_error(spec.env_file, "value_too_large"); + } + + std::string value; + value.reserve(std::min(static_cast(status.st_size), raw_limit)); + std::array buffer {}; + while (true) { + const std::size_t remaining = raw_limit + 1 - value.size(); + const std::size_t request_size = std::min(buffer.size(), remaining); + ssize_t count; + do { + count = ::read(fd.get(), buffer.data(), request_size); + } while (count < 0 && errno == EINTR); + if (count < 0) { + throw_secret_error(spec.env_file, "read_failed"); + } + if (count == 0) { + break; + } + value.append(buffer.data(), static_cast(count)); + if (value.size() > raw_limit) { + throw_secret_error(spec.env_file, "value_too_large"); + } + } + + reject_nul(value, spec.env_file); + trim_one_trailing_line_ending(value); + validate_secret_value(value, spec, spec.env_file); + return value; +} + +} // namespace detail + +inline std::string resolve_secret(SecretId id) { + const SecretSpec spec = secret_spec(id); + const char* env_value = std::getenv(spec.env_name); + const char* env_file = std::getenv(spec.env_file); + + if (env_value != nullptr && env_file != nullptr) { + detail::throw_secret_error(spec.env_name, "source_conflict"); + } + if (env_value == nullptr && env_file == nullptr) { + detail::throw_secret_error(spec.env_name, "source_missing"); + } + if (env_value != nullptr) { + std::string value(env_value); + detail::validate_secret_value(value, spec, spec.env_name); + return value; + } + return detail::read_secret_file(std::string(env_file), spec); +} + +} // namespace chatnow::config diff --git a/conf/auth.json b/conf/auth.json index fad3fd3..4ee5f89 100644 --- a/conf/auth.json +++ b/conf/auth.json @@ -1,12 +1,3 @@ { - "auth": { - "jwt": { - "current_kid": "v1", - "keys": { - "v1": "0123456789abcdef0123456789abcdef" - }, - "access_ttl_sec": 7200, - "refresh_ttl_sec": 2592000 - } - } + "_comment": "JWT configuration is injected through CHATNOW_JWT_CONFIG or CHATNOW_JWT_CONFIG_FILE; this tracked file contains no runtime key material." } diff --git a/conf/docker/conversation_server.conf b/conf/docker/conversation_server.conf index f084d3b..6236537 100644 --- a/conf/docker/conversation_server.conf +++ b/conf/docker/conversation_server.conf @@ -20,7 +20,6 @@ -redis_pool_size=4 -mysql_host=mysql -mysql_user=root --mysql_pswd=YHY060403 -mysql_db=chatnow -mysql_cset=utf8mb4 -mysql_port=0 diff --git a/conf/docker/gateway_server.conf b/conf/docker/gateway_server.conf index 300248c..d5c1a66 100644 --- a/conf/docker/gateway_server.conf +++ b/conf/docker/gateway_server.conf @@ -19,4 +19,3 @@ -redis_db=0 -redis_seeds=redis-node1:6379,redis-node2:6380,redis-node3:6381,redis-node4:6382,redis-node5:6383,redis-node6:6384 -redis_keep_alive=true --auth_config=/im/conf/auth.json diff --git a/conf/docker/identity_server.conf b/conf/docker/identity_server.conf index a7e67df..ec408c2 100644 --- a/conf/docker/identity_server.conf +++ b/conf/docker/identity_server.conf @@ -12,7 +12,6 @@ -es_host=http://elasticsearch:9200/ -mysql_host=mysql -mysql_user=root --mysql_pswd=YHY060403 -mysql_db=chatnow -mysql_cset=utf8 -mysql_port=0 @@ -23,7 +22,5 @@ -redis_seeds=redis-node1:6379,redis-node2:6380,redis-node3:6381,redis-node4:6382,redis-node5:6383,redis-node6:6384 -redis_keep_alive=true -mail_user=yhaoyang666@163.com --mail_paswd=XKk5zvYwWKeB8xNk -mail_host=smtps://smtp.163.com:465 -mail_from=yhaoyang666@163.com --auth_config=/im/conf/auth.json diff --git a/conf/docker/media_server.conf b/conf/docker/media_server.conf index 9f5a834..0140d67 100644 --- a/conf/docker/media_server.conf +++ b/conf/docker/media_server.conf @@ -14,7 +14,6 @@ -redis_db=0 -mysql_host=mysql -mysql_user=root --mysql_pswd=YHY060403 -mysql_db=chatnow -mysql_cset=utf8 -mysql_port=0 diff --git a/conf/docker/message_server.conf b/conf/docker/message_server.conf index 5c5a488..6015034 100644 --- a/conf/docker/message_server.conf +++ b/conf/docker/message_server.conf @@ -12,13 +12,11 @@ -media_service=/service/media_service -mysql_host=mysql -mysql_user=root --mysql_pswd=YHY060403 -mysql_db=chatnow -mysql_cset=utf8mb4 -mysql_port=0 -mysql_pool_count=4 -mq_user=root --mq_pswd=YHY060403 -mq_host=rabbitmq:5672 -mq_msg_exchange=chat_msg_exchange -mq_msg_queue_db=msg_queue_db diff --git a/conf/docker/push_server.conf b/conf/docker/push_server.conf index f36ba8a..e8605e9 100644 --- a/conf/docker/push_server.conf +++ b/conf/docker/push_server.conf @@ -17,7 +17,6 @@ -redis_keep_alive=true -redis_pool_size=16 -mq_user=root --mq_pswd=YHY060403 -mq_host=rabbitmq:5672 -mq_push_exchange=chat_push_exchange -mq_push_queue=msg_push_queue @@ -27,5 +26,3 @@ -resend_max_age_sec=5 # Compose reliability tests pause this sole Push instance during Redis failover. -route_l1_ttl_sec=30 -# JWT — 统一从 auth.json 加载(与 identity/gateway 共享密钥源) --auth_config=/im/conf/auth.json diff --git a/conf/docker/relationship_server.conf b/conf/docker/relationship_server.conf index a21589f..d979970 100644 --- a/conf/docker/relationship_server.conf +++ b/conf/docker/relationship_server.conf @@ -13,7 +13,6 @@ -es_host=http://elasticsearch:9200/ -mysql_host=mysql -mysql_user=root --mysql_pswd=YHY060403 -mysql_db=chatnow -mysql_cset=utf8mb4 -mysql_port=0 diff --git a/conf/docker/transmite_server.conf b/conf/docker/transmite_server.conf index 351eb91..e1d17de 100644 --- a/conf/docker/transmite_server.conf +++ b/conf/docker/transmite_server.conf @@ -17,15 +17,7 @@ -redis_seeds=redis-node1:6379,redis-node2:6380,redis-node3:6381,redis-node4:6382,redis-node5:6383,redis-node6:6384 -redis_keep_alive=true -redis_pool_size=8 --mysql_host=mysql --mysql_user=root --mysql_pswd=YHY060403 --mysql_db=chatnow --mysql_cset=utf8 --mysql_port=0 --mysql_pool_count=4 -mq_user=root --mq_pswd=YHY060403 -mq_host=rabbitmq:5672 -mq_msg_exchange=chat_msg_exchange -mq_msg_queue= diff --git a/conf/local/conversation_server.conf b/conf/local/conversation_server.conf index f1abb6e..51f0b97 100644 --- a/conf/local/conversation_server.conf +++ b/conf/local/conversation_server.conf @@ -20,7 +20,6 @@ -redis_pool_size=4 -mysql_host=127.0.0.1 -mysql_user=root --mysql_pswd=YHY060403 -mysql_db=chatnow -mysql_cset=utf8mb4 -mysql_port=0 diff --git a/conf/local/gateway_server.conf b/conf/local/gateway_server.conf index 2e1abb0..556dd6a 100644 --- a/conf/local/gateway_server.conf +++ b/conf/local/gateway_server.conf @@ -19,4 +19,3 @@ -redis_db=0 -redis_seeds=127.0.0.1:6379,127.0.0.1:6380,127.0.0.1:6381,127.0.0.1:6382,127.0.0.1:6383,127.0.0.1:6384 -redis_keep_alive=true --auth_config=/home/icepop/ChatNow/conf/auth.json \ No newline at end of file diff --git a/conf/local/identity_server.conf b/conf/local/identity_server.conf index 4d8b4a9..8ee1331 100644 --- a/conf/local/identity_server.conf +++ b/conf/local/identity_server.conf @@ -12,7 +12,6 @@ -es_host=http://127.0.0.1:9200/ -mysql_host=127.0.0.1 -mysql_user=root --mysql_pswd=YHY060403 -mysql_db=chatnow -mysql_cset=utf8 -mysql_port=0 @@ -23,7 +22,5 @@ -redis_seeds=127.0.0.1:6379,127.0.0.1:6380,127.0.0.1:6381,127.0.0.1:6382,127.0.0.1:6383,127.0.0.1:6384 -redis_keep_alive=true -mail_user=yhaoyang666@163.com --mail_paswd=XKk5zvYwWKeB8xNk -mail_host=smtps://smtp.163.com:465 -mail_from=yhaoyang666@163.com --auth_config=/home/icepop/ChatNow/conf/auth.json \ No newline at end of file diff --git a/conf/local/message_server.conf b/conf/local/message_server.conf index bff14ff..f680702 100644 --- a/conf/local/message_server.conf +++ b/conf/local/message_server.conf @@ -12,13 +12,11 @@ -media_service=/service/media_service -mysql_host=127.0.0.1 -mysql_user=root --mysql_pswd=YHY060403 -mysql_db=chatnow -mysql_cset=utf8mb4 -mysql_port=0 -mysql_pool_count=4 -mq_user=root --mq_pswd=YHY060403 -mq_host=127.0.0.1:5672 -mq_msg_exchange=chat_msg_exchange -mq_msg_queue_db=msg_queue_db @@ -37,4 +35,4 @@ -redis_db=0 -redis_seeds=127.0.0.1:6379,127.0.0.1:6380,127.0.0.1:6381,127.0.0.1:6382,127.0.0.1:6383,127.0.0.1:6384 -redis_keep_alive=true --redis_pool_size=8 \ No newline at end of file +-redis_pool_size=8 diff --git a/conf/local/push_server.conf b/conf/local/push_server.conf index 83d73ca..10bef71 100644 --- a/conf/local/push_server.conf +++ b/conf/local/push_server.conf @@ -17,8 +17,6 @@ -redis_keep_alive=true -redis_pool_size=16 -mq_user=root -# Password must be provided via environment or deployment tooling — never hardcoded --mq_pswd= -mq_host=127.0.0.1:5672 -mq_push_exchange=chat_push_exchange -mq_push_queue=msg_push_queue @@ -26,5 +24,3 @@ # M5 心跳触发未 ack 重传 -resend_batch=50 -resend_max_age_sec=5 -# JWT — 统一从 auth.json 加载(与 identity/gateway 共享密钥源) --auth_config=/home/icepop/ChatNow/conf/auth.json diff --git a/conf/local/relationship_server.conf b/conf/local/relationship_server.conf index 09ee813..cb5d85a 100644 --- a/conf/local/relationship_server.conf +++ b/conf/local/relationship_server.conf @@ -13,7 +13,6 @@ -es_host=http://127.0.0.1:9200/ -mysql_host=127.0.0.1 -mysql_user=root --mysql_pswd=YHY060403 -mysql_db=chatnow -mysql_cset=utf8mb4 -mysql_port=0 diff --git a/conf/local/transmite_server.conf b/conf/local/transmite_server.conf index 43971f3..84b865c 100644 --- a/conf/local/transmite_server.conf +++ b/conf/local/transmite_server.conf @@ -18,7 +18,6 @@ -redis_keep_alive=true -redis_pool_size=8 -mq_user=root --mq_pswd= -mq_host=127.0.0.1:5672 -mq_msg_exchange=chat_msg_exchange -mq_msg_queue= diff --git a/conf/media.json b/conf/media.json index e91f5ef..515fa39 100644 --- a/conf/media.json +++ b/conf/media.json @@ -3,9 +3,7 @@ "s3": { "endpoint": "http://127.0.0.1:9000", - "region": "us-east-1", - "access_key": "minioadmin", - "secret_key": "minioadmin" + "region": "us-east-1" }, "media": { diff --git a/conf/transmite_server.conf.example b/conf/transmite_server.conf.example index 43971f3..84b865c 100644 --- a/conf/transmite_server.conf.example +++ b/conf/transmite_server.conf.example @@ -18,7 +18,6 @@ -redis_keep_alive=true -redis_pool_size=8 -mq_user=root --mq_pswd= -mq_host=127.0.0.1:5672 -mq_msg_exchange=chat_msg_exchange -mq_msg_queue= diff --git a/conversation/source/conversation_server.cc b/conversation/source/conversation_server.cc index a338ee7..7bc4150 100644 --- a/conversation/source/conversation_server.cc +++ b/conversation/source/conversation_server.cc @@ -1,4 +1,5 @@ #include "conversation_server.h" +#include "config/secret_resolver.hpp" DEFINE_bool(run_mode, false, "程序的运行模式 false-调试 ; true-发布"); DEFINE_string(log_file, "", "发布模式下,用于指定日志的输出文件"); @@ -28,7 +29,6 @@ DEFINE_int32(redis_pool_size, 4, "Redis 连接池大小"); DEFINE_string(mysql_host, "127.0.0.1", "MySQL服务器访问地址"); DEFINE_string(mysql_user, "root", "MySQL访问服务器用户名"); -DEFINE_string(mysql_pswd, "", "MySQL服务器访问密码"); DEFINE_string(mysql_db, "chatnow", "MySQL默认库名称"); DEFINE_string(mysql_cset, "utf8mb4", "MySQL客户端字符集"); DEFINE_int32(mysql_port, 0, "MySQL服务器访问端口"); @@ -40,6 +40,8 @@ DEFINE_string(public_url_prefix, "http://127.0.0.1:9000/chatnow-media-public", int main(int argc, char *argv[]) { google::ParseCommandLineFlags(&argc, &argv, true); + const auto mysql_password = chatnow::config::resolve_secret( + chatnow::config::SecretId::ConversationMysqlPassword); chatnow::init_logger(FLAGS_run_mode, FLAGS_log_file, FLAGS_log_level); chatnow::ConversationServerBuilder csb; @@ -47,7 +49,7 @@ int main(int argc, char *argv[]) csb.set_redis_seeds(FLAGS_redis_seeds); csb.make_redis_object(FLAGS_redis_host, FLAGS_redis_port, FLAGS_redis_db, FLAGS_redis_keep_alive, FLAGS_redis_pool_size); - csb.make_mysql_object(FLAGS_mysql_user, FLAGS_mysql_pswd, FLAGS_mysql_host, + csb.make_mysql_object(FLAGS_mysql_user, mysql_password, FLAGS_mysql_host, FLAGS_mysql_db, FLAGS_mysql_cset, FLAGS_mysql_port, FLAGS_mysql_pool_count); csb.make_discovery_object(FLAGS_registry_host, FLAGS_base_service, diff --git a/docker-compose.yml b/docker-compose.yml index e3b5231..a0d2293 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,7 +18,7 @@ services: image: mysql:8.0.44 container_name: mysql-service environment: - MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_ROOT_PASSWORD: ${CHATNOW_MYSQL_ROOT_PASSWORD:?CHATNOW_MYSQL_ROOT_PASSWORD is required} volumes: - ./sql:/docker-entrypoint-initdb.d/:rw - ./middle/data/mysql:/var/lib/mysql:rw @@ -126,7 +126,7 @@ services: container_name: rabbitmq-service environment: RABBITMQ_DEFAULT_USER: root - RABBITMQ_DEFAULT_PASS: ${RABBITMQ_DEFAULT_PASS} + RABBITMQ_DEFAULT_PASS: ${CHATNOW_RABBITMQ_BOOTSTRAP_PASSWORD:?CHATNOW_RABBITMQ_BOOTSTRAP_PASSWORD is required} volumes: - ./middle/data/rabbitmq:/var/lib/rabbitmq:rw ports: @@ -135,6 +135,10 @@ services: media_server: build: ./media container_name: media_server-service + environment: + CHATNOW_MEDIA_MYSQL_PASSWORD: ${CHATNOW_MEDIA_MYSQL_PASSWORD:?CHATNOW_MEDIA_MYSQL_PASSWORD is required} + CHATNOW_MEDIA_S3_ACCESS_KEY: ${CHATNOW_MEDIA_S3_ACCESS_KEY:?CHATNOW_MEDIA_S3_ACCESS_KEY is required} + CHATNOW_MEDIA_S3_SECRET_KEY: ${CHATNOW_MEDIA_S3_SECRET_KEY:?CHATNOW_MEDIA_S3_SECRET_KEY is required} volumes: - ./conf/docker/media_server.conf:/im/conf/media_server.conf - ./conf/media.json:/im/conf/media.json @@ -153,6 +157,8 @@ services: relationship_server: build: ./relationship container_name: relationship_server-service + environment: + CHATNOW_RELATIONSHIP_MYSQL_PASSWORD: ${CHATNOW_RELATIONSHIP_MYSQL_PASSWORD:?CHATNOW_RELATIONSHIP_MYSQL_PASSWORD is required} volumes: - ./conf/docker/relationship_server.conf:/im/conf/relationship_server.conf - ./middle/data/logs:/var/lib/logs:rw @@ -170,6 +176,8 @@ services: conversation_server: build: ./conversation container_name: conversation_server-service + environment: + CHATNOW_CONVERSATION_MYSQL_PASSWORD: ${CHATNOW_CONVERSATION_MYSQL_PASSWORD:?CHATNOW_CONVERSATION_MYSQL_PASSWORD is required} volumes: - ./conf/docker/conversation_server.conf:/im/conf/conversation_server.conf - ./middle/data/logs:/var/lib/logs:rw @@ -188,9 +196,10 @@ services: gateway_server: build: ./gateway container_name: gateway_server-service + environment: + CHATNOW_JWT_CONFIG: ${CHATNOW_JWT_CONFIG:?CHATNOW_JWT_CONFIG is required} volumes: - ./conf/docker/gateway_server.conf:/im/conf/gateway_server.conf - - ./conf/auth.json:/im/conf/auth.json - ./middle/data/logs:/var/lib/logs:rw - ./middle/data/data:/var/lib/data:rw - ./entrypoint.sh:/im/bin/entrypoint.sh @@ -205,9 +214,11 @@ services: push_server: build: ./push container_name: push_server-service + environment: + CHATNOW_JWT_CONFIG: ${CHATNOW_JWT_CONFIG:?CHATNOW_JWT_CONFIG is required} + CHATNOW_PUSH_MQ_PASSWORD: ${CHATNOW_PUSH_MQ_PASSWORD:?CHATNOW_PUSH_MQ_PASSWORD is required} volumes: - ./conf/docker/push_server.conf:/im/conf/push_server.conf - - ./conf/auth.json:/im/conf/auth.json - ./middle/data/logs:/var/lib/logs:rw - ./middle/data/data:/var/lib/data:rw - ./entrypoint.sh:/im/bin/entrypoint.sh @@ -224,6 +235,9 @@ services: message_server: build: ./message container_name: message_server-service + environment: + CHATNOW_MESSAGE_MYSQL_PASSWORD: ${CHATNOW_MESSAGE_MYSQL_PASSWORD:?CHATNOW_MESSAGE_MYSQL_PASSWORD is required} + CHATNOW_MESSAGE_MQ_PASSWORD: ${CHATNOW_MESSAGE_MQ_PASSWORD:?CHATNOW_MESSAGE_MQ_PASSWORD is required} volumes: - ./conf/docker/message_server.conf:/im/conf/message_server.conf - ./middle/data/logs:/var/lib/logs:rw @@ -243,6 +257,8 @@ services: transmite_server: build: ./transmite container_name: transmite_server-service + environment: + CHATNOW_TRANSMITE_MQ_PASSWORD: ${CHATNOW_TRANSMITE_MQ_PASSWORD:?CHATNOW_TRANSMITE_MQ_PASSWORD is required} volumes: - ./conf/docker/transmite_server.conf:/im/conf/transmite_server.conf - ./middle/data/logs:/var/lib/logs:rw @@ -261,9 +277,12 @@ services: identity_server: build: ./identity container_name: identity_server-service + environment: + CHATNOW_IDENTITY_MYSQL_PASSWORD: ${CHATNOW_IDENTITY_MYSQL_PASSWORD:?CHATNOW_IDENTITY_MYSQL_PASSWORD is required} + CHATNOW_IDENTITY_SMTP_PASSWORD: ${CHATNOW_IDENTITY_SMTP_PASSWORD:?CHATNOW_IDENTITY_SMTP_PASSWORD is required} + CHATNOW_JWT_CONFIG: ${CHATNOW_JWT_CONFIG:?CHATNOW_JWT_CONFIG is required} volumes: - ./conf/docker/identity_server.conf:/im/conf/identity_server.conf - - ./conf/auth.json:/im/conf/auth.json - ./middle/data/logs:/var/lib/logs:rw - ./middle/data/data:/var/lib/data:rw - ./entrypoint.sh:/im/bin/entrypoint.sh diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index f5068d9..77b5cf5 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -7,7 +7,7 @@ # cd docker && docker compose up -d minio minio-init # # 端点: -# - S3 API: http://localhost:9000 (用户名/密码:minioadmin/minioadmin) +# - S3 API: http://localhost:9000 # - Console: http://localhost:9001 # # bucket 由 minio-init sidecar 自动创建(详见 minio-init/entrypoint.sh)。 @@ -20,8 +20,8 @@ services: container_name: chatnow-minio command: server /data --console-address ":9001" environment: - MINIO_ROOT_USER: minioadmin - MINIO_ROOT_PASSWORD: minioadmin + MINIO_ROOT_USER: ${CHATNOW_MINIO_ROOT_USER:?CHATNOW_MINIO_ROOT_USER is required} + MINIO_ROOT_PASSWORD: ${CHATNOW_MINIO_ROOT_PASSWORD:?CHATNOW_MINIO_ROOT_PASSWORD is required} volumes: - minio-data:/data ports: @@ -42,7 +42,7 @@ services: entrypoint: ["/bin/sh", "/init/entrypoint.sh"] environment: # mc alias 名固定为 "local"(与 entrypoint.sh 中保持一致) - MC_HOST_local: http://minioadmin:minioadmin@minio:9000 + MC_HOST_local: http://${CHATNOW_MINIO_ROOT_USER:?CHATNOW_MINIO_ROOT_USER is required}:${CHATNOW_MINIO_ROOT_PASSWORD:?CHATNOW_MINIO_ROOT_PASSWORD is required}@minio:9000 volumes: - ./minio-init:/init:ro diff --git a/docs/operations/jwt-key-rotation.md b/docs/operations/jwt-key-rotation.md index 6603236..0765dae 100644 --- a/docs/operations/jwt-key-rotation.md +++ b/docs/operations/jwt-key-rotation.md @@ -1,59 +1,78 @@ -# JWT 密钥轮换 runbook +# JWT Key Rotation -> 目标:按 spec §2.2 规则在线轮换 HS256 签发密钥,不影响线上活跃 token。 +Target version: `3.0-dev` +Status: Current +Verified: 2026-07-22 -## 触发场景 +This runbook rotates the HS256 signing key without invalidating tokens that are still within their accepted lifetime. It never authorizes a production operation: using or changing real keys and operating on production require explicit human approval naming the environment, identities, time window, and rollback owner. -- 定期轮换(建议 90 天一次) -- 怀疑密钥泄漏 -- 配置文件历史泄漏审计 +## Current behavior -## 前置确认 +- Identity signs and verifies JWTs. Gateway and Push verify JWTs. +- Each process resolves the complete JSON document from exactly one of `CHATNOW_JWT_CONFIG` or `CHATNOW_JWT_CONFIG_FILE` at startup. There is no hot reload. +- A token carries a key ID. Verification succeeds only while that ID remains in the local key map and the token otherwise passes validation. +- The tracked `conf/auth.json` is repository configuration, not an operational secret store. Never edit or commit it to rotate a deployed key. +- The shared resolver enforces the source and mounted-file controls in [Runtime Secret Management](runtime-secrets.md). Never pass the document through a removed `auth_config` flag or restore a tracked-file fallback. -- access_ttl_sec 默认 7200(2h) -- refresh_ttl_sec 默认 2592000(30d) -- `current_kid` 当前指向的 key id 是 v\ +## Preconditions -## 步骤 +Before changing anything: -### 1. 生成新密钥(≥32 字节) +1. Obtain the required human approval and name the rollout and rollback owners. +2. Confirm the deployed commit, every Identity, Gateway, and Push instance, the configured token lifetimes, maximum clock skew, and maximum rollout delay. +3. Confirm an approved secret provider can publish the same JWT document to every consumer through the direct input or, preferably, an untracked read-only file referenced by `CHATNOW_JWT_CONFIG_FILE` with the required ownership and permissions. +4. Record only key IDs and deployment state. Never print, copy into a ticket, log, hash, fingerprint, prefix, suffix, or otherwise derive diagnostic material from a key value. +5. Verify that old and new configurations can be restored from the provider without using repository history. -```bash -openssl rand -base64 48 -``` +The overlap window must exceed the longest token lifetime that must remain valid, plus clock skew and rollout delay. Use the deployed values; do not rely on repository examples. -### 2. 编辑 `conf/auth.json`,加 v\,**保留 v\,current_kid 仍 v\** +## Rotation procedure -```json -{ - "auth": { - "jwt": { - "current_kid": "v1", - "keys": { - "v1": "OLD_KEY_AT_LEAST_32_BYTES", - "v2": "NEW_KEY_FROM_OPENSSL_RAND" - }, - "access_ttl_sec": 7200, - "refresh_ttl_sec": 2592000 - } - } -} -``` +### 1. Prepare the new key -### 3. 滚动重启 user_server 和 gateway_server,让两者都加载新 keys map +Generate a high-entropy key inside the approved secret system. Assign a new, non-reused key ID. Do not generate it with a command that writes the value to terminal output, shell history, CI output, or an artifact. -### 4. 等待 30 天 + 1 天(>refresh TTL),让所有 v1 签的 token 失效 +Publish an overlap key map containing both the old and new key IDs while leaving `current_kid` on the old key. Validate schema and minimum key length inside the protected secret workflow. -### 5. 编辑 `conf/auth.json`,把 current_kid 改为 v2 +### 2. Deploy the overlap verifier set -### 6. 滚动重启,新 token 用 v2 签 +Roll Gateway and Push instances first so every verification path accepts both key IDs. Then roll Identity while it still signs with the old key. A process must not receive traffic until startup has loaded the overlap set successfully. -### 7. 等待 30 天 + 1 天 +Verify, without exposing token or key material, that: -### 8. 编辑 `conf/auth.json`,删除 v1,滚动重启,归档老配置 +- pre-rollout access tokens remain accepted by Gateway and Push, and pre-rollout refresh tokens remain accepted by Identity; +- Identity still issues tokens with the old key ID; +- authentication error and startup-failure rates remain within the approved bounds. -## 故障排查 +Stop and roll back if any verifier does not accept the old key or if the fleet is not converged. -- 启动报 `auth.jwt.current_kid not in keys`:current_kid 写错或 keys 没改 -- 启动报 `auth.jwt.keys[xx] must be >=32 bytes`:补长密钥 -- 客户端大面积 401 (AUTH_TOKEN_INVALID=1003):可能误删了仍有 token 的旧 kid,回滚 conf 即可 +### 3. Switch the signer + +In the secret provider, change only `current_kid` to the new key ID while retaining both keys. Roll Identity so new tokens use the new key. Roll Gateway and Push as well if the deployment mechanism does not guarantee they already have the identical overlap file. + +Verify that newly issued access tokens are accepted by Gateway and Push and newly issued refresh tokens are accepted by Identity. Also verify an unexpired old-key access token through Gateway and Push and an unexpired old-key refresh token through Identity. Observe only key IDs, bounded outcome counters, and trace/request IDs. + +### 4. Hold the overlap + +Keep both keys until the last old-key token that the product promises to accept has expired. The hold starts after the last Identity instance stopped signing with the old key and lasts longer than the applicable maximum token lifetime plus clock skew and rollout delay. + +### 5. Retire the old key + +After approval to finish the rotation, remove the old key from the provider-managed key map and roll Gateway, Push, and Identity. Confirm all instances loaded the new-only set and that new-key tokens still pass both HTTP and WebSocket authentication. + +Retire provider versions according to the approved retention policy. Do not place an archived key in the repository or routine logs. + +## Rollback + +- Before old-key removal: set `current_kid` back to the old key in the provider, roll Identity, and verify old-key signing and all verification paths. Both keys remain present. +- After old-key removal: restore the previous overlap set from the provider, roll Gateway and Push first, then Identity, and verify old tokens before switching the signer. +- If the old key is suspected compromised, do not restore it merely to preserve availability. Escalate to the incident owner for an explicitly approved revocation plan and user/session impact decision. + +Rollback does not permit logging or exposing either key. Record key IDs, rollout versions, timestamps, instance health, and approval references only. + +## Failure handling + +- `current_kid` absent from the key map or a key rejected at startup: keep the instance out of service and restore the last approved provider version. +- Widespread invalid-token responses after a rollout: stop, compare non-secret deployment versions and key IDs across Identity, Gateway, and Push, then follow the applicable rollback stage. +- Mixed fleet state: stop the signer switch or retirement. Converge verifiers before allowing Identity to sign with a key they may not accept. +- Provider or mount unavailable: fail the rollout. Never fall back to a tracked file, compiled value, or command-line secret. diff --git a/docs/operations/runtime-secrets.md b/docs/operations/runtime-secrets.md new file mode 100644 index 0000000..49f69f6 --- /dev/null +++ b/docs/operations/runtime-secrets.md @@ -0,0 +1,109 @@ +# Runtime Secret Management + +Target version: `3.0-dev` +Status: Current +Verified: 2026-07-22 + +This is the sole canonical inventory and operational contract for ChatNow runtime secrets. It distinguishes implemented consumers from explicitly out-of-scope credential classes. + +## Current behavior + +The following behavior is implemented at the verified commit: + +| Credential | Current consumers | Current source | +|---|---|---| +| JWT HS256 JSON document | Identity signs and verifies; Gateway and Push verify | `CHATNOW_JWT_CONFIG` or `CHATNOW_JWT_CONFIG_FILE` through the common resolver | +| MySQL password | Conversation, Identity, Media, Message, and Relationship | Service-specific direct or `_FILE` input through the common resolver | +| RabbitMQ password | Transmite, Message, and Push | Service-specific direct or `_FILE` input through the common resolver | +| SMTP password | Identity | `CHATNOW_IDENTITY_SMTP_PASSWORD` or its `_FILE` companion | +| S3 application access and secret keys | Media | Separate service-specific direct or `_FILE` inputs; non-secret S3 settings remain in `conf/media.json` | +| MinIO bootstrap credential | Supplemental MinIO deployment | Required Compose deployment environment references | +| MySQL root and RabbitMQ bootstrap passwords | Root Compose infrastructure | Required Compose deployment environment references | + +Redis has no configured password or ACL consumer. The ASR helper can accept credentials, but Media startup does not currently wire them. The CI workflow does not pull real credentials; local and CI stack inputs must be synthetic. + +Tracked runtime literals have been removed from the scoped source, configuration, Compose, and test-runtime surfaces. Do not reintroduce, quote, log, hash, fingerprint, or copy credential values into Issues, PRs, tests, artifacts, or replacement documentation. + +### Implemented resolver inputs + +| Logical secret | Direct input | File input | +|---|---|---| +| JWT document | `CHATNOW_JWT_CONFIG` | `CHATNOW_JWT_CONFIG_FILE` | +| Identity MySQL password | `CHATNOW_IDENTITY_MYSQL_PASSWORD` | `CHATNOW_IDENTITY_MYSQL_PASSWORD_FILE` | +| Conversation MySQL password | `CHATNOW_CONVERSATION_MYSQL_PASSWORD` | `CHATNOW_CONVERSATION_MYSQL_PASSWORD_FILE` | +| Relationship MySQL password | `CHATNOW_RELATIONSHIP_MYSQL_PASSWORD` | `CHATNOW_RELATIONSHIP_MYSQL_PASSWORD_FILE` | +| Message MySQL password | `CHATNOW_MESSAGE_MYSQL_PASSWORD` | `CHATNOW_MESSAGE_MYSQL_PASSWORD_FILE` | +| Media MySQL password | `CHATNOW_MEDIA_MYSQL_PASSWORD` | `CHATNOW_MEDIA_MYSQL_PASSWORD_FILE` | +| Transmite RabbitMQ password | `CHATNOW_TRANSMITE_MQ_PASSWORD` | `CHATNOW_TRANSMITE_MQ_PASSWORD_FILE` | +| Message RabbitMQ password | `CHATNOW_MESSAGE_MQ_PASSWORD` | `CHATNOW_MESSAGE_MQ_PASSWORD_FILE` | +| Push RabbitMQ password | `CHATNOW_PUSH_MQ_PASSWORD` | `CHATNOW_PUSH_MQ_PASSWORD_FILE` | +| Identity SMTP password | `CHATNOW_IDENTITY_SMTP_PASSWORD` | `CHATNOW_IDENTITY_SMTP_PASSWORD_FILE` | +| Media S3 access key | `CHATNOW_MEDIA_S3_ACCESS_KEY` | `CHATNOW_MEDIA_S3_ACCESS_KEY_FILE` | +| Media S3 secret key | `CHATNOW_MEDIA_S3_SECRET_KEY` | `CHATNOW_MEDIA_S3_SECRET_KEY_FILE` | + +Compose bootstrap variables (`CHATNOW_MYSQL_ROOT_PASSWORD`, `CHATNOW_RABBITMQ_BOOTSTRAP_PASSWORD`, `CHATNOW_MINIO_ROOT_USER`, and `CHATNOW_MINIO_ROOT_PASSWORD`) are required deployment inputs, not common-resolver inputs. Do not append `_FILE` and assume Compose supports it. + +## Current injection contract + +For every migrated logical secret named `NAME`, the common resolver accepts exactly one source: + +- `NAME`: the secret value supplied directly by the process environment; or +- `NAME_FILE`: a locator for a runtime-mounted file. The resolver removes at most one trailing line ending and otherwise preserves the file content. + +The resolver: + +1. fail closed when both variables are set, when a required secret is missing, or when the resolved value is empty; +2. reject a symlink, non-regular file, unreadable file, file owned by neither the effective runtime user nor root, or any group/other access or special permission bits; +3. read once during startup into process memory, close the file, and avoid copying the value into gflags, process arguments, logs, errors, metrics, traces, crash annotations, or generated artifacts; +4. report only the logical secret name, selected source type, and a stable non-sensitive error category; +5. preserve existing service ownership and fail startup before accepting traffic when a required secret is invalid. + +The deployment must give the runtime identity read-only access to the mounted file. Prefer ownership by that identity (or an explicitly approved root-owned read-only mount) and mode `0400`; do not allow group/other permission bits. Mount each service only the secrets it consumes. + +Do not add a silent compatibility fallback to tracked configuration, compiled defaults, or command-line values. A staged migration may support legacy input only when the scoped Issue names the transition, makes precedence unambiguous, and proves eventual removal. + +## Credential classes and boundaries + +- Application credentials are the identities used by ChatNow services for MySQL, RabbitMQ, SMTP, and S3. Injection protects delivery but does not prove least privilege; deployment roles and configured usernames must be reviewed separately and narrowed before production. +- Bootstrap credentials create or rotate application identities and belong to the deployment system, not an application container. +- JWT signing keys belong only to the authentication deployment boundary. Gateway and Push require verifier access to the shared HS256 key set under the current algorithm, but they must not become rotation owners. +- Redis credentials remain out of scope until Redis ACL/password consumption is implemented and assigned to an Issue. + +Never reuse a bootstrap credential as an application credential. Never mount a full environment's secret bundle into every service. + +## Local development and CI + +- Use unique synthetic values generated for the disposable environment. They must not be copied from staging or production and must carry no external privilege. +- Inject them at runtime through ignored local environment files or ephemeral secret mounts. `.env` being ignored does not make it an approved production store. +- CI must source synthetic values from ephemeral job setup or the CI secret mechanism, mask values, avoid command tracing, and tear down volumes and temporary files on every exit path. +- Scanner exemptions must match exact synthetic fixtures or documented API examples. Do not exempt an entire `conf/`, `tests/`, `docs/`, Compose, or source subtree. +- Tests may assert source selection and error categories, but must not print the resolved value or any derivative. + +## Deployment procedure + +This procedure applies only to the implemented consumers listed above. A new credential class requires a scoped Issue, allowlist entry, consumer wiring, and pure-Go policy coverage before this procedure applies. + +1. Inventory the service and logical secret, its least-privileged owner, current source, approved provider, rollout owner, and rollback version. +2. Obtain human approval before any real credential or production operation. +3. Create or rotate the value in the approved provider without exposing it to a terminal, ticket, log, or artifact. +4. Publish either `NAME` or `NAME_FILE`, never both. For a file, set the required owner and restrictive mode before starting the process. +5. Roll one bounded unit, verify startup and dependency authentication from non-sensitive outcomes, then continue according to the service's availability policy. +6. After fleet convergence, remove the legacy source and prove that its absence cannot trigger a tracked/default fallback. +7. Revoke the superseded credential only after the rollback and overlap conditions for that credential class are satisfied. + +JWT rotation has verifier/signer ordering and overlap requirements; use [JWT Key Rotation](jwt-key-rotation.md). + +## Rollback + +Before rollout, preserve the last approved provider version and deployment manifest, never a plaintext copy in the repository. + +- If startup rejects the new source, keep the instance out of service and restore the prior provider version or mount metadata. +- If dependency authentication fails, stop the rollout, restore the previous secret reference, and restart only the affected bounded unit. +- If the old credential is still valid, restore it through the provider. If it has already been revoked, issue a new approved credential; never recover it from logs, shell history, or Git. +- A rollback must not re-enable a tracked literal, compiled default, command-line secret, or direct/file ambiguity. + +Record environment, service, logical secret name, source type, provider version, rollout timestamps, non-sensitive outcome, approval reference, and rollback decision. Never record a value or credential-derived fingerprint. + +## Incident and rotation boundary + +Real rotation, revocation, provider access, and production rollout are human-approval boundaries. Suspected disclosure is an incident: stop ordinary migration, preserve non-secret evidence, notify the designated owner, and follow the approved revocation and user-impact plan. Availability pressure does not authorize restoring a suspected-compromised credential. diff --git a/docs/superpowers/plans/2026-05-14-p4-media-object-storage.md b/docs/superpowers/plans/2026-05-14-p4-media-object-storage.md index 35801b7..afa6298 100644 --- a/docs/superpowers/plans/2026-05-14-p4-media-object-storage.md +++ b/docs/superpowers/plans/2026-05-14-p4-media-object-storage.md @@ -767,7 +767,7 @@ Client ─┤ ├────────────────── using namespace chatnow; static bool minio_enabled() { auto* e = std::getenv("MINIO_TEST"); return e && std::string(e)=="1"; } - static S3Options opts() { return { "http://127.0.0.1:9000", "us-east-1", "minioadmin", "minioadmin", true }; } + static S3Options opts() { return { "http://127.0.0.1:9000", "us-east-1", "", "", true }; } TEST(S3Integration, PutGetRoundtrip) { if (!minio_enabled()) GTEST_SKIP() << "MINIO_TEST!=1"; @@ -2377,8 +2377,8 @@ Client ─┤ ├────────────────── image: minio/minio:RELEASE.2024-10-13T13-34-11Z command: server /data --console-address ":9001" environment: - MINIO_ROOT_USER: minioadmin - MINIO_ROOT_PASSWORD: minioadmin + MINIO_ROOT_USER: + MINIO_ROOT_PASSWORD: volumes: - minio-data:/data ports: @@ -2396,7 +2396,7 @@ Client ─┤ ├────────────────── condition: service_healthy entrypoint: ["/bin/sh", "/init/entrypoint.sh"] environment: - MC_HOST_local: http://minioadmin:minioadmin@minio:9000 + MC_HOST_local: http://:@minio:9000 volumes: - ./minio-init:/init:ro ``` @@ -2489,8 +2489,8 @@ Client ─┤ ├────────────────── "s3": { "endpoint": "http://127.0.0.1:9000", "region": "us-east-1", - "access_key": "minioadmin", - "secret_key": "minioadmin" + "access_key": "", + "secret_key": "" }, "media": { "public_bucket": "chatnow-media-public", @@ -2733,4 +2733,3 @@ Client ─┤ ├────────────────── - 第 0 步:提交本节修订(plan 文件本身)。 - 第 0.5 步:提交 `common/error/error_codes.hpp` 增 5001–5007,独立 commit。 - §1–§31:按原顺序,commit 信息中如有偏离要在 body 里引用本节具体条款("see Adapter notes §C 第 N 行")。 - diff --git a/docs/superpowers/plans/2026-05-15-relationship-service-migration.md b/docs/superpowers/plans/2026-05-15-relationship-service-migration.md index 9b30566..156695c 100644 --- a/docs/superpowers/plans/2026-05-15-relationship-service-migration.md +++ b/docs/superpowers/plans/2026-05-15-relationship-service-migration.md @@ -815,7 +815,7 @@ Create `conf/relationship_server.conf` with: -es_host=http://10.0.4.10:9200/ -mysql_host=10.0.4.10 -mysql_user=root --mysql_pswd=YHY060403 +-mysql_pswd= -mysql_db=chatnow -mysql_cset=utf8mb4 -mysql_port=0 diff --git a/docs/superpowers/plans/2026-05-16-conversation-service-migration.md b/docs/superpowers/plans/2026-05-16-conversation-service-migration.md index 57c0fc0..23d1434 100644 --- a/docs/superpowers/plans/2026-05-16-conversation-service-migration.md +++ b/docs/superpowers/plans/2026-05-16-conversation-service-migration.md @@ -1350,7 +1350,7 @@ CMD /im/bin/conversation_server -flagfile=/im/conf/conversation_server.conf -redis_pool_size=4 -mysql_host=10.0.4.10 -mysql_user=root --mysql_pswd=YHY060403 +-mysql_pswd= -mysql_db=chatnow -mysql_cset=utf8mb4 -mysql_port=0 diff --git a/docs/superpowers/plans/2026-05-17-push-service-refactor.md b/docs/superpowers/plans/2026-05-17-push-service-refactor.md index 0ea4529..f987a9f 100644 --- a/docs/superpowers/plans/2026-05-17-push-service-refactor.md +++ b/docs/superpowers/plans/2026-05-17-push-service-refactor.md @@ -1493,7 +1493,7 @@ git commit -m "refactor(push): 完整重写 PushServiceImpl — namespace chatno // JWT config(从配置文件 / gflags 读取,或硬编码开发 key) chatnow::auth::JwtConfig jwt_cfg; jwt_cfg.current_kid = "v1"; -jwt_cfg.keys["v1"] = "0123456789abcdef0123456789abcdef"; // >=32 字节 +jwt_cfg.keys["v1"] = ""; // >=32 字节 jwt_cfg.access_ttl_sec = 7200; psb.make_jwt_object(jwt_cfg); ``` @@ -1538,7 +1538,7 @@ set(proto_files common/types.proto common/error.proto common/envelope.proto mess ``` # JWT(开发阶段临时键,后续配置化) -jwt_current_kid=v1 --jwt_key_v1=0123456789abcdef0123456789abcdef +-jwt_key_v1= ``` - [ ] **Step 3: Commit** diff --git a/docs/superpowers/plans/2026-05-20-containerize-local-deployment.md b/docs/superpowers/plans/2026-05-20-containerize-local-deployment.md index 5732232..7571f5a 100644 --- a/docs/superpowers/plans/2026-05-20-containerize-local-deployment.md +++ b/docs/superpowers/plans/2026-05-20-containerize-local-deployment.md @@ -124,7 +124,7 @@ cat > /home/icepop/ChatNow/conf/docker/identity_server.conf << 'EOF' -es_host=http://elasticsearch:9200/ -mysql_host=mysql -mysql_user=root --mysql_pswd=YHY060403 +-mysql_pswd= -mysql_db=chatnow -mysql_cset=utf8 -mysql_port=0 @@ -135,7 +135,7 @@ cat > /home/icepop/ChatNow/conf/docker/identity_server.conf << 'EOF' -redis_seeds=redis-node1:6379,redis-node2:6380,redis-node3:6381,redis-node4:6382,redis-node5:6383,redis-node6:6384 -redis_keep_alive=true -mail_user=yhaoyang666@163.com --mail_paswd=XKk5zvYwWKeB8xNk +-mail_paswd= -mail_host=smtps://smtp.163.com:465 -mail_from=yhaoyang666@163.com -auth_config=/im/conf/auth.json @@ -212,13 +212,13 @@ cat > /home/icepop/ChatNow/conf/docker/message_server.conf << 'EOF' -media_service=/service/media_service -mysql_host=mysql -mysql_user=root --mysql_pswd=YHY060403 +-mysql_pswd= -mysql_db=chatnow -mysql_cset=utf8 -mysql_port=0 -mysql_pool_count=4 -mq_user=root --mq_pswd=YHY060403 +-mq_pswd= -mq_host=rabbitmq:5672 -mq_msg_exchange=chat_msg_exchange -mq_msg_queue_db=msg_queue_db @@ -267,7 +267,7 @@ cat > /home/icepop/ChatNow/conf/docker/conversation_server.conf << 'EOF' -redis_pool_size=4 -mysql_host=mysql -mysql_user=root --mysql_pswd=YHY060403 +-mysql_pswd= -mysql_db=chatnow -mysql_cset=utf8mb4 -mysql_port=0 @@ -295,7 +295,7 @@ cat > /home/icepop/ChatNow/conf/docker/relationship_server.conf << 'EOF' -es_host=http://elasticsearch:9200/ -mysql_host=mysql -mysql_user=root --mysql_pswd=YHY060403 +-mysql_pswd= -mysql_db=chatnow -mysql_cset=utf8mb4 -mysql_port=0 @@ -326,7 +326,7 @@ cat > /home/icepop/ChatNow/conf/docker/push_server.conf << 'EOF' -redis_keep_alive=true -redis_pool_size=16 -mq_user=root --mq_pswd=YHY060403 +-mq_pswd= -mq_host=rabbitmq:5672 -mq_push_exchange=chat_push_exchange -mq_push_queue=msg_push_queue @@ -334,7 +334,7 @@ cat > /home/icepop/ChatNow/conf/docker/push_server.conf << 'EOF' -resend_batch=50 -resend_max_age_sec=5 -jwt_current_kid=v1 --jwt_key_v1=0123456789abcdef0123456789abcdef +-jwt_key_v1= EOF ``` @@ -363,13 +363,13 @@ cat > /home/icepop/ChatNow/conf/docker/transmite_server.conf << 'EOF' -redis_pool_size=8 -mysql_host=mysql -mysql_user=root --mysql_pswd=YHY060403 +-mysql_pswd= -mysql_db=chatnow -mysql_cset=utf8 -mysql_port=0 -mysql_pool_count=4 -mq_user=root --mq_pswd=YHY060403 +-mq_pswd= -mq_host=rabbitmq:5672 -mq_msg_exchange=chat_msg_exchange -mq_msg_queue= @@ -874,8 +874,8 @@ git commit -m "feat: update docker-compose for containerized deployment with ser ```bash cat > /home/icepop/ChatNow/.env << 'EOF' -MYSQL_ROOT_PASSWORD=YHY060403 -RABBITMQ_DEFAULT_PASS=YHY060403 +MYSQL_ROOT_PASSWORD= +RABBITMQ_DEFAULT_PASS= EOF ``` diff --git a/docs/superpowers/plans/2026-07-09-phase1-bvt-core-and-infra.md b/docs/superpowers/plans/2026-07-09-phase1-bvt-core-and-infra.md index 248df03..ea5ba0b 100644 --- a/docs/superpowers/plans/2026-07-09-phase1-bvt-core-and-infra.md +++ b/docs/superpowers/plans/2026-07-09-phase1-bvt-core-and-infra.md @@ -16,7 +16,7 @@ - 每 run 全量清理:TestMain 调 cleanup.CleanupAll,保证确定性状态(master spec §7) - tests/pkg/ 包无 build tag;tests/bvt/ 用 `//go:build bvt`;tests/func/ 用 `//go:build func` - 测试代码即权威:用例 ID 注释标注在测试函数顶部(master spec §6.3) -- MySQL 连接:root:YHY060403@tcp(localhost:3306)/chatnow(从 conf/docker/*.conf 获取) +- MySQL 连接:root:@tcp(localhost:3306)/chatnow(从 conf/docker/*.conf 获取) - ES 索引名:`message` 和 `chat_session`(从 common/dao/data_es.hpp 确认,非 chatnow_*) - Redis 集群:6 节点 localhost:6379-6384,FLUSHALL 需逐节点执行 - WS 协议:binary frame = 序列化的 push.NotifyMessage,首帧发 CLIENT_AUTH 鉴权 @@ -132,7 +132,7 @@ timeout: ws_read_sec: 30 database: - mysql_dsn: "root:YHY060403@tcp(localhost:3306)/chatnow?charset=utf8mb4&parseTime=true" + mysql_dsn: "root:@tcp(localhost:3306)/chatnow?charset=utf8mb4&parseTime=true" es_url: "http://localhost:9200" redis_nodes: - "localhost:6379" diff --git a/docs/superpowers/plans/2026-07-09-phase2-media-presence-cpp-removal.md b/docs/superpowers/plans/2026-07-09-phase2-media-presence-cpp-removal.md index ad93d82..02c69c2 100644 --- a/docs/superpowers/plans/2026-07-09-phase2-media-presence-cpp-removal.md +++ b/docs/superpowers/plans/2026-07-09-phase2-media-presence-cpp-removal.md @@ -17,7 +17,7 @@ - Build tag 规则:`tests/func/` 下文件首行 `//go:build func`;`tests/pkg/` 下无 tag。 - 用例 ID 注释:每个测试函数顶部加 `// FN-MD-01 | P0 | happy path | 说明` 注释块。 - Fixture 不做断言(除 `t.Fatal`),返回关键 ID 供测试代码断言。 -- MinIO 端点通过环境变量 `MINIO_ENDPOINT` / `MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY` 覆盖(默认 `http://127.0.0.1:9000` / `minioadmin` / `minioadmin`,与 `conf/media.json` 一致)。 +- MinIO 端点通过环境变量 `MINIO_ENDPOINT` / `MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY` 覆盖(默认 `http://127.0.0.1:9000` / `` / ``,与 `conf/media.json` 一致)。 - MinIO bucket 名称:`chatnow-media-private`(会话媒体)+ `chatnow-media-public`(avatar/sticker)。 - 假设 Phase 1 已完成:`cleanup` / `client/ws` / `verify/{db,es}` / `fixture/{group,message,ws}` 可直接引用。 @@ -164,16 +164,16 @@ type MinIOVerifier struct { } // NewMinIOVerifier 创建 MinIO 验证器。 -// endpoint 例 "127.0.0.1:9000"(不含 scheme),accessKey/secretKey 默认 minioadmin。 +// endpoint 例 "127.0.0.1:9000"(不含 scheme),accessKey/secretKey 默认 /。 func NewMinIOVerifier(endpoint, accessKey, secretKey string) *MinIOVerifier { if endpoint == "" { endpoint = "127.0.0.1:9000" } if accessKey == "" { - accessKey = "minioadmin" + accessKey = "" } if secretKey == "" { - secretKey = "minioadmin" + secretKey = "" } cli, err := minio.New(endpoint, &minio.Options{ Creds: credentials.NewStaticV4(accessKey, secretKey, ""), diff --git a/docs/superpowers/plans/2026-07-09-phase3-reliability-perf-baseline.md b/docs/superpowers/plans/2026-07-09-phase3-reliability-perf-baseline.md index 344a301..415dc34 100644 --- a/docs/superpowers/plans/2026-07-09-phase3-reliability-perf-baseline.md +++ b/docs/superpowers/plans/2026-07-09-phase3-reliability-perf-baseline.md @@ -17,7 +17,7 @@ - 用例 ID 遵循主 spec §6:RL-NN(可靠性)、FN-QT-NN(限流配额)、PF-NN(性能)、FN-XX-NN(P2 边角)。 - 每个测试函数顶部加 ID/优先级/验证点注释块(测试代码即权威)。 - DRY/YAGNI/TDD:先写失败测试,再写实现,频繁提交。 -- MySQL 密码 `YHY060403`,DSN `root:YHY060403@tcp(127.0.0.1:3306)/chatnow`(与 conf/docker/*.conf 一致)。 +- MySQL 密码 ``,DSN `root:@tcp(127.0.0.1:3306)/chatnow`(与 conf/docker/*.conf 一致)。 - docker-compose.yml 服务名:`rabbitmq`、`mysql`、`message_server`、`transmite_server`、`elasticsearch`;容器名带 `-service` 后缀(如 `rabbitmq-service`)。 --- @@ -365,7 +365,7 @@ import ( // RL-01 | P0 | 可靠性 | MQ 重启后消息最终落库,client_msg_id 幂等去重 func TestRL_MQRestart(t *testing.T) { alice, bob, convID := fixture.MakeFriends(t, HTTP) - dbVer := verify.NewDBVerifier("root:YHY060403@tcp(127.0.0.1:3306)/chatnow") + dbVer := verify.NewDBVerifier("root:@tcp(127.0.0.1:3306)/chatnow") // Step 1: 停止 rabbitmq require.NoError(t, chaos.StopService(t, "rabbitmq")) @@ -488,7 +488,7 @@ import ( // RL-02 | P1 | 可靠性 | message_server 重启后消费不丢 func TestRL_ServiceRestart(t *testing.T) { alice, bob, convID := fixture.MakeFriends(t, HTTP) - dbVer := verify.NewDBVerifier("root:YHY060403@tcp(127.0.0.1:3306)/chatnow") + dbVer := verify.NewDBVerifier("root:@tcp(127.0.0.1:3306)/chatnow") // Step 1: alice 发 3 条消息 var msgIDs []int64 @@ -613,7 +613,7 @@ import ( // RL-03 | P1 | 可靠性 | MySQL 短暂断连后重连写入正常 func TestRL_DBReconnect(t *testing.T) { alice, bob, convID := fixture.MakeFriends(t, HTTP) - dbVer := verify.NewDBVerifier("root:YHY060403@tcp(127.0.0.1:3306)/chatnow") + dbVer := verify.NewDBVerifier("root:@tcp(127.0.0.1:3306)/chatnow") // Step 1: 先发 1 条消息确认链路正常 preReq := &transmite.SendMessageReq{ @@ -890,7 +890,7 @@ import ( ) // dbDSN 与 conf/docker/*.conf 中 -mysql_pswd 一致 -const dbDSN = "root:YHY060403@tcp(127.0.0.1:3306)/chatnow" +const dbDSN = "root:@tcp(127.0.0.1:3306)/chatnow" // FN-QT-01 | P1 | 限流 | 短时间大量发消息触发限流 // @@ -2081,7 +2081,7 @@ func TestScenario_LargeGroupFanOut(t *testing.T) { } // 数据一致性 — 读扩散:message 表 1 条 - dbVer := verify.NewDBVerifier("root:YHY060403@tcp(127.0.0.1:3306)/chatnow") + dbVer := verify.NewDBVerifier("root:@tcp(127.0.0.1:3306)/chatnow") dbVer.MessageCount(t, convID, 1) } ``` @@ -2333,7 +2333,7 @@ Phase 3 完成后应满足: | RL-03 停止 MySQL 影响所有服务,可能导致服务崩溃不自动恢复 | 测试用宽松断言(GreaterOrEqual);如果服务不自动重连,增加 `chaos.RestartService` 重启受影响服务 | | RL-04 RabbitMQ 未配置 DLQ,测试无法验证死信 | t.Skip 并记录日志,标注"配置 DLQ 后可完整验证" | | FN-QT-01 限流默认关闭(rate_limit_user_max=600 但可能被覆盖) | 测试记录日志不强制 fail | -| FN-QT-02 需 DB 直改配额,依赖 MySQL 密码 | DSN 硬编码 `root:YHY060403@tcp(127.0.0.1:3306)/chatnow`,CI 中需一致 | +| FN-QT-02 需 DB 直改配额,依赖 MySQL 密码 | DSN 硬编码 `root:@tcp(127.0.0.1:3306)/chatnow`,CI 中需一致 | | PF-03 仅 1000 条消息,非 100 万 | 标注 future enhancement(DB 批量插入);P2 级别可接受 | | PF-01 200 成员注册耗时长(~60s) | benchmark setup 不计入计时(b.ResetTimer 之后才测) | | FN-WS-07 typing 通知可能未实现 WS 推送 | t.Skip 并记录 | diff --git a/docs/superpowers/specs/2026-05-20-containerize-local-deployment-design.md b/docs/superpowers/specs/2026-05-20-containerize-local-deployment-design.md index f57e10b..c294545 100644 --- a/docs/superpowers/specs/2026-05-20-containerize-local-deployment-design.md +++ b/docs/superpowers/specs/2026-05-20-containerize-local-deployment-design.md @@ -95,8 +95,8 @@ eval $command 创建 `.env` 文件存储敏感信息: ```ini -MYSQL_ROOT_PASSWORD=YHY060403 -RABBITMQ_DEFAULT_PASS=YHY060403 +MYSQL_ROOT_PASSWORD= +RABBITMQ_DEFAULT_PASS= ``` docker-compose.yml 引用变量: @@ -107,7 +107,7 @@ environment: RABBITMQ_DEFAULT_PASS: ${RABBITMQ_DEFAULT_PASS} ``` -`.env` 加入 `.gitignore`。配置文件中的 `-mysql_pswd=YHY060403` 等暂时保留(配置文件本身不提交到公开仓库)。 +`.env` 加入 `.gitignore`。配置文件中的 `-mysql_pswd=` 等暂时保留(配置文件本身不提交到公开仓库)。 ### 6. 验证步骤 diff --git a/gateway/source/gateway_server.cc b/gateway/source/gateway_server.cc index 388a3f1..64dba9e 100644 --- a/gateway/source/gateway_server.cc +++ b/gateway/source/gateway_server.cc @@ -1,4 +1,5 @@ #include "gateway_server.h" +#include "config/secret_resolver.hpp" DEFINE_bool(run_mode, false, "程序的运行模式 false-调试 ; true-发布"); DEFINE_string(log_file, "", "发布模式下,用于指定日志的输出文件"); @@ -24,17 +25,17 @@ DEFINE_int32(redis_db, 0, "Redis默认库号"); DEFINE_bool(redis_keep_alive, true, "Redis长连接保活"); DEFINE_int32(redis_pool_size, 16, "Redis 连接池大小"); -DEFINE_string(auth_config, "/im/conf/auth.json", "JWT 鉴权配置文件路径(JSON)"); - int main(int argc, char *argv[]) { google::ParseCommandLineFlags(&argc, &argv, true); + const auto jwt_config = chatnow::config::resolve_secret( + chatnow::config::SecretId::JwtConfig); chatnow::init_logger(FLAGS_run_mode, FLAGS_log_file, FLAGS_log_level); chatnow::GatewayServerBuilder gsb; gsb.set_redis_seeds(FLAGS_redis_seeds); gsb.make_redis_object(FLAGS_redis_host, FLAGS_redis_port, FLAGS_redis_db, FLAGS_redis_keep_alive, FLAGS_redis_pool_size); - gsb.make_jwt_object(FLAGS_auth_config); + gsb.make_jwt_object(jwt_config); gsb.make_discovery_object(FLAGS_registry_host, FLAGS_base_service, FLAGS_identity_service, FLAGS_relationship_service, FLAGS_conversation_service, FLAGS_message_service, diff --git a/gateway/source/gateway_server.h b/gateway/source/gateway_server.h index 785776b..077e01b 100644 --- a/gateway/source/gateway_server.h +++ b/gateway/source/gateway_server.h @@ -476,8 +476,8 @@ class GatewayServerBuilder { void make_redis_object(const std::string& host, int port, int db, bool keep_alive, int pool_size = 16) { _redis_host = host; _redis_port = port; _redis_db = db; _redis_keep_alive = keep_alive; _redis_pool_size = pool_size; } - void make_jwt_object(const std::string& auth_config_path) { - _jwt_config = ::chatnow::auth::load_jwt_config_from_file(auth_config_path); + void make_jwt_object(const std::string& auth_config_json) { + _jwt_config = ::chatnow::auth::parse_jwt_config(auth_config_json); } void make_discovery_object(const std::string& reg_host, const std::string& base, const std::string& identity, const std::string& relationship, diff --git a/identity/source/identity_server.cc b/identity/source/identity_server.cc index 284e978..1d564f2 100644 --- a/identity/source/identity_server.cc +++ b/identity/source/identity_server.cc @@ -1,4 +1,5 @@ #include "identity_server.h" +#include "config/secret_resolver.hpp" DEFINE_bool(run_mode, false, "程序的运行模式 false-调试 ; true-发布"); DEFINE_string(log_file, "", "发布模式下,用于指定日志的输出文件"); @@ -19,7 +20,6 @@ DEFINE_string(es_host, "http://127.0.0.1:9200/", "ES搜索引擎服务器URL"); DEFINE_string(mysql_host, "127.0.0.1", "MySQL服务器访问地址"); DEFINE_string(mysql_user, "root", "MySQL访问服务器用户名"); -DEFINE_string(mysql_pswd, "YHY060403", "MySQL服务器访问密码"); DEFINE_string(mysql_db, "chatnow", "MySQL默认库名称"); DEFINE_string(mysql_cset, "utf8mb4", "MySQL客户端字符集"); DEFINE_int32(mysql_port, 0, "MySQL服务器访问端口"); @@ -33,24 +33,27 @@ DEFINE_bool(redis_keep_alive, true, "Redis长连接保活"); DEFINE_int32(redis_pool_size, 16, "Redis 连接池大小"); DEFINE_string(mail_user, "yhaoyang666@163.com", "邮箱验证平台的用户名"); -DEFINE_string(mail_paswd, "XKk5zvYwWKeB8xNk", "邮箱验证平台的密码"); DEFINE_string(mail_host, "smtps://smtp.163.com:465", "邮箱验证平台的URL"); DEFINE_string(mail_from, "yhaoyang666@163.com", "邮箱验证平台的发送方"); -DEFINE_string(auth_config, "/im/conf/auth.json", "JWT 鉴权配置文件路径(JSON)"); - int main(int argc, char *argv[]) { google::ParseCommandLineFlags(&argc, &argv, true); + const auto mysql_password = chatnow::config::resolve_secret( + chatnow::config::SecretId::IdentityMysqlPassword); + const auto smtp_password = chatnow::config::resolve_secret( + chatnow::config::SecretId::IdentitySmtpPassword); + const auto jwt_config = chatnow::config::resolve_secret( + chatnow::config::SecretId::JwtConfig); chatnow::init_logger(FLAGS_run_mode, FLAGS_log_file, FLAGS_log_level); chatnow::IdentityServerBuilder isb; isb.make_es_object({FLAGS_es_host}); - isb.make_mysql_object(FLAGS_mysql_user, FLAGS_mysql_pswd, FLAGS_mysql_host, FLAGS_mysql_db, FLAGS_mysql_cset, FLAGS_mysql_port, FLAGS_mysql_pool_count); + isb.make_mysql_object(FLAGS_mysql_user, mysql_password, FLAGS_mysql_host, FLAGS_mysql_db, FLAGS_mysql_cset, FLAGS_mysql_port, FLAGS_mysql_pool_count); isb.set_redis_seeds(FLAGS_redis_seeds); isb.make_redis_object(FLAGS_redis_host, FLAGS_redis_port, FLAGS_redis_db, FLAGS_redis_keep_alive, FLAGS_redis_pool_size); - isb.make_jwt_object(FLAGS_auth_config); - isb.make_mail_object(FLAGS_mail_user, FLAGS_mail_paswd, FLAGS_mail_host, FLAGS_mail_from); + isb.make_jwt_object(jwt_config); + isb.make_mail_object(FLAGS_mail_user, smtp_password, FLAGS_mail_host, FLAGS_mail_from); isb.make_media_config(FLAGS_media_public_url_prefix); isb.make_discovery_object(FLAGS_registry_host, FLAGS_base_service); isb.make_rpc_object(FLAGS_listen_port, FLAGS_rpc_timeout, FLAGS_rpc_threads); @@ -60,4 +63,4 @@ int main(int argc, char *argv[]) server->start(); return 0; -} \ No newline at end of file +} diff --git a/identity/source/identity_server.h b/identity/source/identity_server.h index 43ccb85..862a4e7 100644 --- a/identity/source/identity_server.h +++ b/identity/source/identity_server.h @@ -714,12 +714,12 @@ class IdentityServerBuilder _user_info_cache = std::make_shared(_redis_client); } /* brief: 加载 JWT 配置并构造 codec / store(必须在 make_redis_object 之后) */ - void make_jwt_object(const std::string &auth_config_path) { + void make_jwt_object(const std::string &auth_config_json) { if (!_redis_client) { LOG_ERROR("make_jwt_object 必须在 make_redis_object 之后调用"); abort(); } - auto cfg = ::chatnow::auth::load_jwt_config_from_file(auth_config_path); + auto cfg = ::chatnow::auth::parse_jwt_config(auth_config_json); _jwt_codec = std::make_shared<::chatnow::auth::JwtCodec>(std::move(cfg)); _jwt_store = std::make_shared<::chatnow::auth::JwtStore>(_redis_client); } diff --git a/media/source/media_main.cc b/media/source/media_main.cc index 0c82a5d..dc9825d 100644 --- a/media/source/media_main.cc +++ b/media/source/media_main.cc @@ -1,11 +1,10 @@ // MediaServer 启动入口 // 1. 解析 gflags // 2. 初始化 logger -// 3. 加载 conf/media.json 拿 s3 / media 段 +// 3. 解析 runtime secrets 并加载 conf/media.json 的非敏感配置 // 4. Aws::InitAPI // 5. 用 Builder 组装 MediaServer 并 start -#include #include #include #include @@ -17,6 +16,7 @@ #include #include "media_server.h" +#include "config/secret_resolver.hpp" #include "infra/logger.hpp" #include "utils/mime_whitelist.hpp" @@ -35,7 +35,6 @@ DEFINE_int32(rpc_threads, 4, "RPC 的 IO 线程数"); DEFINE_string(mysql_host, "127.0.0.1", "MySQL 地址"); DEFINE_string(mysql_user, "root", "MySQL 用户名"); -DEFINE_string(mysql_pswd, "", "MySQL 密码 (通过 --mysql_pswd 或 MYSQL_PSWD 环境变量设置)"); DEFINE_string(mysql_db, "chatnow", "MySQL 库"); DEFINE_string(mysql_cset, "utf8mb4", "MySQL 字符集"); DEFINE_int32 (mysql_port, 0, "MySQL 端口"); @@ -56,8 +55,6 @@ struct LoadedMediaConf { std::shared_ptr mime; std::string s3_endpoint; std::string s3_region; - std::string s3_access_key; - std::string s3_secret_key; }; LoadedMediaConf load_media_conf(const std::string& path) { @@ -79,8 +76,6 @@ LoadedMediaConf load_media_conf(const std::string& path) { LoadedMediaConf out; out.s3_endpoint = s3.get("endpoint", "").asString(); out.s3_region = s3.get("region", "us-east-1").asString(); - out.s3_access_key = s3.get("access_key", "").asString(); - out.s3_secret_key = s3.get("secret_key", "").asString(); out.cfg.public_bucket = md.get("public_bucket", "").asString(); out.cfg.private_bucket = md.get("private_bucket", "").asString(); @@ -102,18 +97,14 @@ LoadedMediaConf load_media_conf(const std::string& path) { int main(int argc, char* argv[]) { google::ParseCommandLineFlags(&argc, &argv, true); + const auto mysql_password = chatnow::config::resolve_secret( + chatnow::config::SecretId::MediaMysqlPassword); + const auto s3_access_key = chatnow::config::resolve_secret( + chatnow::config::SecretId::MediaS3AccessKey); + const auto s3_secret_key = chatnow::config::resolve_secret( + chatnow::config::SecretId::MediaS3SecretKey); chatnow::init_logger(FLAGS_run_mode, FLAGS_log_file, FLAGS_log_level); - // 密码优先从环境变量读取,命令行参数次之 - if (FLAGS_mysql_pswd.empty()) { - const char* env = std::getenv("MYSQL_PSWD"); - if (env && *env) FLAGS_mysql_pswd = env; - } - if (FLAGS_mysql_pswd.empty()) { - std::cerr << "mysql_pswd 必须通过 --mysql_pswd 或环境变量 MYSQL_PSWD 设置" << std::endl; - return 1; - } - LoadedMediaConf conf; try { conf = load_media_conf(FLAGS_media_conf); @@ -127,14 +118,14 @@ int main(int argc, char* argv[]) { { chatnow::MediaServerBuilder b; - b.make_mysql_object(FLAGS_mysql_user, FLAGS_mysql_pswd, FLAGS_mysql_host, + b.make_mysql_object(FLAGS_mysql_user, mysql_password, FLAGS_mysql_host, FLAGS_mysql_db, FLAGS_mysql_cset, FLAGS_mysql_port, FLAGS_mysql_pool_count); b.set_redis_seeds(FLAGS_redis_seeds); b.make_redis_object(FLAGS_redis_host, FLAGS_redis_port, FLAGS_redis_db, FLAGS_redis_keep_alive); b.make_s3_object(conf.s3_endpoint, conf.s3_region, - conf.s3_access_key, conf.s3_secret_key); + s3_access_key, s3_secret_key); b.set_media_config(conf.cfg, conf.mime); b.make_registry_object(FLAGS_registry_host, FLAGS_base_service + FLAGS_instance_name, diff --git a/message/source/message_server.cc b/message/source/message_server.cc index 70a74bf..b4e4663 100644 --- a/message/source/message_server.cc +++ b/message/source/message_server.cc @@ -1,4 +1,5 @@ #include "message_server.h" +#include "config/secret_resolver.hpp" #include DEFINE_bool(run_mode, false, "程序的运行模式 false-调试 ; true-发布"); @@ -19,14 +20,12 @@ DEFINE_string(media_service, "/service/media_service", "Media 服务发现路径 DEFINE_string(mysql_host, "127.0.0.1", "MySQL服务器访问地址"); DEFINE_string(mysql_user, "root", "MySQL访问服务器用户名"); -DEFINE_string(mysql_pswd, "YHY060403", "MySQL服务器访问密码"); DEFINE_string(mysql_db, "chatnow", "MySQL默认库名称"); DEFINE_string(mysql_cset, "utf8mb4", "MySQL客户端字符集"); DEFINE_int32(mysql_port, 0, "MySQL服务器访问端口"); DEFINE_int32(mysql_pool_count, 4, "MySQL连接池最大连接数量"); DEFINE_string(mq_user, "root", "消息队列服务器访问用户名"); -DEFINE_string(mq_pswd, "YHY060403", "消息队列服务器访问密码"); DEFINE_string(mq_host, "127.0.0.1:5672", "消息队列服务器访问地址"); DEFINE_string(mq_msg_exchange, "chat_msg_exchange", "持久化消息的发布交换机名称"); DEFINE_string(mq_msg_queue_db, "msg_queue_db", "持久化DB消息的发布队列名称"); @@ -54,6 +53,10 @@ DEFINE_int32(redis_pool_size, 8, "Redis 连接池大小"); int main(int argc, char *argv[]) { google::ParseCommandLineFlags(&argc, &argv, true); + const auto mysql_password = chatnow::config::resolve_secret( + chatnow::config::SecretId::MessageMysqlPassword); + const auto mq_password = chatnow::config::resolve_secret( + chatnow::config::SecretId::MessageMqPassword); chatnow::init_logger(FLAGS_run_mode, FLAGS_log_file, FLAGS_log_level); chatnow::message::MessageServerBuilder msb; @@ -61,7 +64,7 @@ int main(int argc, char *argv[]) msb.make_redis_object(FLAGS_redis_host, FLAGS_redis_port, FLAGS_redis_db, FLAGS_redis_keep_alive, FLAGS_redis_pool_size); msb.set_reaper_owner(FLAGS_access_host + ":" + std::to_string(::getpid())); - msb.make_mq_object(FLAGS_mq_user, FLAGS_mq_pswd, FLAGS_mq_host, + msb.make_mq_object(FLAGS_mq_user, mq_password, FLAGS_mq_host, FLAGS_mq_msg_exchange, FLAGS_mq_msg_queue_db, FLAGS_mq_msg_queue_es, FLAGS_mq_db_binding_key, FLAGS_mq_es_binding_key); @@ -72,7 +75,7 @@ int main(int argc, char *argv[]) msb.make_es_index_subscriber(FLAGS_mq_es_exchange, FLAGS_mq_es_queue, FLAGS_mq_es_binding_key); msb.make_es_object({FLAGS_es_host}); - msb.make_mysql_object(FLAGS_mysql_user, FLAGS_mysql_pswd, FLAGS_mysql_host, + msb.make_mysql_object(FLAGS_mysql_user, mysql_password, FLAGS_mysql_host, FLAGS_mysql_db, FLAGS_mysql_cset, static_cast(FLAGS_mysql_port), FLAGS_mysql_pool_count); diff --git a/push/source/push_server.cc b/push/source/push_server.cc index 17247ff..09d3958 100644 --- a/push/source/push_server.cc +++ b/push/source/push_server.cc @@ -1,5 +1,6 @@ #include "push_server.h" #include "auth/jwt_codec.hpp" +#include "config/secret_resolver.hpp" DEFINE_bool(run_mode, false, "程序的运行模式 false-调试 ; true-发布"); DEFINE_string(log_file, "", "发布模式下,用于指定日志的输出文件"); @@ -26,7 +27,6 @@ DEFINE_bool(redis_keep_alive, true, "Redis 长连接"); DEFINE_int32(redis_pool_size, 16, "Redis 连接池大小"); DEFINE_string(mq_user, "root", "MQ 用户"); -DEFINE_string(mq_pswd, "", "MQ password"); DEFINE_string(mq_host, "127.0.0.1:5672", "MQ 地址"); DEFINE_string(mq_push_exchange, "chat_push_exchange", "推送交换机"); DEFINE_string(mq_push_queue, "msg_push_queue", "推送队列"); @@ -37,21 +37,22 @@ DEFINE_int32(resend_batch, 50, "心跳触发未 ack 重传的批量上限"); DEFINE_int32(resend_max_age_sec, 5, "未 ack 项入队后等待多少秒视为可重传"); DEFINE_int32(route_l1_ttl_sec, 2, "Push 在线路由 L1 TTL(1-300 秒)"); -// JWT — 统一从 auth.json 加载(与 identity/gateway 共享密钥源) -DEFINE_string(auth_config, "/im/conf/auth.json", "JWT 鉴权配置文件路径(JSON)"); - int main(int argc, char *argv[]) { google::ParseCommandLineFlags(&argc, &argv, true); + const auto jwt_config = chatnow::config::resolve_secret( + chatnow::config::SecretId::JwtConfig); + const auto mq_password = chatnow::config::resolve_secret( + chatnow::config::SecretId::PushMqPassword); chatnow::init_logger(FLAGS_run_mode, FLAGS_log_file, FLAGS_log_level); chatnow::push::PushServerBuilder psb; - psb.make_jwt_object(FLAGS_auth_config); + psb.make_jwt_object(jwt_config); psb.set_redis_seeds(FLAGS_redis_seeds); psb.make_redis_object(FLAGS_redis_host, FLAGS_redis_port, FLAGS_redis_db, FLAGS_redis_keep_alive, FLAGS_redis_pool_size); - psb.make_mq_object(FLAGS_mq_user, FLAGS_mq_pswd, FLAGS_mq_host, + psb.make_mq_object(FLAGS_mq_user, mq_password, FLAGS_mq_host, FLAGS_mq_push_exchange, FLAGS_mq_push_queue, FLAGS_mq_push_binding_key); psb.make_discovery_object(FLAGS_registry_host, FLAGS_base_service, FLAGS_message_service, FLAGS_push_service); psb.make_reg_object(FLAGS_registry_host, FLAGS_base_service + FLAGS_instance_name, FLAGS_access_host); diff --git a/push/source/push_server.h b/push/source/push_server.h index 7c46c12..b8ea74c 100644 --- a/push/source/push_server.h +++ b/push/source/push_server.h @@ -1062,8 +1062,8 @@ class PushServer class PushServerBuilder { public: - void make_jwt_object(const std::string &auth_config_path) { - auto cfg = ::chatnow::auth::load_jwt_config_from_file(auth_config_path); + void make_jwt_object(const std::string &auth_config_json) { + auto cfg = ::chatnow::auth::parse_jwt_config(auth_config_json); _jwt_codec = std::make_shared(cfg); } diff --git a/relationship/source/relationship_server.cc b/relationship/source/relationship_server.cc index 17bb77f..4313f94 100644 --- a/relationship/source/relationship_server.cc +++ b/relationship/source/relationship_server.cc @@ -1,4 +1,5 @@ #include "relationship_server.h" +#include "config/secret_resolver.hpp" DEFINE_bool(run_mode, false, "程序的运行模式 false-调试 ; true-发布"); DEFINE_string(log_file, "", "发布模式下,用于指定日志的输出文件"); @@ -20,7 +21,6 @@ DEFINE_string(es_host, "http://127.0.0.1:9200/", "ES搜索引擎服务器URL"); DEFINE_string(mysql_host, "127.0.0.1", "MySQL服务器访问地址"); DEFINE_string(mysql_user, "root", "MySQL访问服务器用户名"); -DEFINE_string(mysql_pswd, "", "MySQL服务器访问密码"); DEFINE_string(mysql_db, "chatnow", "MySQL默认库名称"); DEFINE_string(mysql_cset, "utf8mb4", "MySQL客户端字符集"); DEFINE_int32(mysql_port, 0, "MySQL服务器访问端口"); @@ -29,11 +29,13 @@ DEFINE_int32(mysql_pool_count, 4, "MySQL连接池最大连接数量"); int main(int argc, char *argv[]) { google::ParseCommandLineFlags(&argc, &argv, true); + const auto mysql_password = chatnow::config::resolve_secret( + chatnow::config::SecretId::RelationshipMysqlPassword); chatnow::init_logger(FLAGS_run_mode, FLAGS_log_file, FLAGS_log_level); chatnow::RelationshipServerBuilder rsb; rsb.make_es_object({FLAGS_es_host}); - rsb.make_mysql_object(FLAGS_mysql_user, FLAGS_mysql_pswd, FLAGS_mysql_host, + rsb.make_mysql_object(FLAGS_mysql_user, mysql_password, FLAGS_mysql_host, FLAGS_mysql_db, FLAGS_mysql_cset, FLAGS_mysql_port, FLAGS_mysql_pool_count); rsb.make_discovery_object(FLAGS_registry_host, FLAGS_base_service, diff --git a/tests/config.yaml b/tests/config.yaml index e65dad1..11e4a6a 100644 --- a/tests/config.yaml +++ b/tests/config.yaml @@ -7,7 +7,8 @@ timeout: ws_read_sec: 30 database: - mysql_dsn: "root:YHY060403@tcp(localhost:3306)/chatnow?charset=utf8mb4&parseTime=true" + # Inject MYSQL_DSN from an isolated local or CI environment. + mysql_dsn: "" es_url: "http://localhost:9200" redis_nodes: - "localhost:6379" diff --git a/tests/pkg/agentpolicy/runtime_secrets_test.go b/tests/pkg/agentpolicy/runtime_secrets_test.go new file mode 100644 index 0000000..a8fcbc6 --- /dev/null +++ b/tests/pkg/agentpolicy/runtime_secrets_test.go @@ -0,0 +1,316 @@ +package agentpolicy + +import ( + "bufio" + "bytes" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" +) + +type runtimeSecretFinding struct { + path string + line int + category string +} + +var ( + defineStringAssignment = regexp.MustCompile(`DEFINE_string\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*,\s*"((?:\\.|[^"\\])*)"`) + flagAssignment = regexp.MustCompile(`^\s*-?([A-Za-z_][A-Za-z0-9_.-]*)\s*=\s*(.*?)\s*$`) + yamlAssignment = regexp.MustCompile(`^\s*-?\s*([A-Za-z_][A-Za-z0-9_.-]*)\s*[:=]\s*(.*?)\s*$`) + jsonStringAssignment = regexp.MustCompile(`^\s*"([^"]+)"\s*:\s*"((?:\\.|[^"\\])*)"`) + environmentReference = regexp.MustCompile(`^\$\$?\{[A-Za-z_][A-Za-z0-9_]*(?::[-?][^}]*)?\}$`) + secretResolverCall = regexp.MustCompile(`resolve_secret\s*\(\s*(?:[A-Za-z_][A-Za-z0-9_:]*::)?SecretId::`) + directGetenvCall = regexp.MustCompile(`\b(?:std::)?getenv\s*\(`) +) + +const unifiedSecretResolverPath = "common/config/secret_resolver.hpp" + +var secretConsumerPaths = []string{ + "conversation/source/conversation_server.cc", + "gateway/source/gateway_server.cc", + "identity/source/identity_server.cc", + "media/source/media_main.cc", + "message/source/message_server.cc", + "push/source/push_server.cc", + "relationship/source/relationship_server.cc", + "transmite/source/transmite_server.cc", +} + +// Repository policy | P0 | Tracked runtime secrets must use an explicit placeholder or injection reference. +func TestRepositoryRejectsTrackedRuntimeSecrets(t *testing.T) { + root := repositoryRoot(t) + paths := trackedRepositoryFiles(t, root) + + var findings []runtimeSecretFinding + for _, path := range paths { + if !isRuntimeSecretPolicyFile(path) { + continue + } + findings = append(findings, scanTrackedRuntimeSecrets(t, root, path)...) + } + findings = append(findings, validateSecretInjectionContract(root)...) + + sort.Slice(findings, func(i, j int) bool { + if findings[i].path != findings[j].path { + return findings[i].path < findings[j].path + } + if findings[i].line != findings[j].line { + return findings[i].line < findings[j].line + } + return findings[i].category < findings[j].category + }) + for _, finding := range findings { + if finding.line > 0 { + t.Errorf("%s:%d: repository secret contract violation category=%s", + finding.path, finding.line, finding.category) + continue + } + t.Errorf("%s: repository secret contract violation category=%s", + finding.path, finding.category) + } +} + +func validateSecretInjectionContract(root string) []runtimeSecretFinding { + var findings []runtimeSecretFinding + resolver, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(unifiedSecretResolverPath))) + if err != nil { + category := "unified_secret_resolver_unreadable" + if os.IsNotExist(err) { + category = "unified_secret_resolver_missing" + } + findings = append(findings, runtimeSecretFinding{ + path: unifiedSecretResolverPath, + category: category, + }) + } else { + resolverText := string(resolver) + checks := []struct { + category string + pattern *regexp.Regexp + }{ + {category: "secret_id_allowlist_missing", pattern: regexp.MustCompile(`enum\s+class\s+SecretId\b`)}, + {category: "secret_spec_allowlist_missing", pattern: regexp.MustCompile(`struct\s+SecretSpec\b`)}, + {category: "secret_resolver_api_missing", pattern: regexp.MustCompile(`resolve_secret\s*\(\s*SecretId\b`)}, + {category: "secret_env_source_missing", pattern: regexp.MustCompile(`\b(?:std::)?getenv\s*\(`)}, + {category: "secret_env_file_locator_missing", pattern: regexp.MustCompile(`\benv_file\b`)}, + {category: "secret_file_source_missing", pattern: regexp.MustCompile(`::open\s*\(`)}, + {category: "secret_file_no_follow_missing", pattern: regexp.MustCompile(`\bO_NOFOLLOW\b`)}, + {category: "secret_file_close_on_exec_missing", pattern: regexp.MustCompile(`\bO_CLOEXEC\b`)}, + {category: "secret_file_stat_missing", pattern: regexp.MustCompile(`::fstat\s*\(`)}, + {category: "secret_file_regular_check_missing", pattern: regexp.MustCompile(`\bS_ISREG\s*\(`)}, + {category: "secret_file_owner_check_missing", pattern: regexp.MustCompile(`\bst_uid\b`)}, + {category: "secret_file_permission_check_missing", pattern: regexp.MustCompile(`\bS_IRWXG\b.*\bS_IRWXO\b`)}, + {category: "secret_value_bound_missing", pattern: regexp.MustCompile(`\bmax_bytes\b`)}, + {category: "secret_nul_rejection_missing", pattern: regexp.MustCompile(`reject_nul`)}, + {category: "secret_trailing_line_trim_missing", pattern: regexp.MustCompile(`trim_one_trailing_line_ending`)}, + {category: "secret_source_conflict_check_missing", pattern: regexp.MustCompile(`source_conflict`)}, + } + for _, check := range checks { + if !check.pattern.MatchString(resolverText) { + findings = append(findings, runtimeSecretFinding{ + path: unifiedSecretResolverPath, + category: check.category, + }) + } + } + } + + for _, path := range secretConsumerPaths { + content, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(path))) + if err != nil { + findings = append(findings, runtimeSecretFinding{ + path: path, + category: "secret_consumer_contract_unreadable", + }) + continue + } + if !secretResolverCall.Match(content) { + findings = append(findings, runtimeSecretFinding{ + path: path, + category: "unified_secret_resolver_call_missing", + }) + } + for index, line := range strings.Split(string(content), "\n") { + if directGetenvCall.MatchString(line) { + findings = append(findings, runtimeSecretFinding{ + path: path, + line: index + 1, + category: "direct_secret_environment_access", + }) + } + } + } + return findings +} + +func trackedRepositoryFiles(t *testing.T, root string) []string { + t.Helper() + cmd := exec.Command("git", "-C", root, "ls-files", "-z") + output, err := cmd.Output() + if err != nil { + t.Fatalf("list tracked repository files: %v", err) + } + + entries := bytes.Split(output, []byte{0}) + paths := make([]string, 0, len(entries)) + for _, entry := range entries { + if len(entry) != 0 { + paths = append(paths, filepath.ToSlash(string(entry))) + } + } + return paths +} + +func isRuntimeSecretPolicyFile(path string) bool { + if path == "tests/config.yaml" { + return true + } + if strings.HasPrefix(path, "tests/") { + return false + } + + ext := strings.ToLower(filepath.Ext(path)) + switch ext { + case ".cc", ".cpp", ".h", ".hpp": + return true + case ".conf", ".json": + return strings.HasPrefix(path, "conf/") + case ".yml", ".yaml": + name := strings.ToLower(filepath.Base(path)) + return strings.Contains(name, "compose") + default: + return false + } +} + +func scanTrackedRuntimeSecrets(t *testing.T, root, path string) []runtimeSecretFinding { + t.Helper() + file, err := os.Open(filepath.Join(root, filepath.FromSlash(path))) + if err != nil { + t.Fatalf("open tracked policy input %s: %v", path, err) + } + defer file.Close() + + var findings []runtimeSecretFinding + scanner := bufio.NewScanner(file) + lineNumber := 0 + jwtKeysDepth := -1 + jsonDepth := 0 + for scanner.Scan() { + lineNumber++ + line := scanner.Text() + field, value, ok := runtimeSecretAssignment(path, line) + category := runtimeSecretCategory(path, field) + if jwtKeysDepth >= 0 && jsonDepth > jwtKeysDepth && jsonStringAssignment.MatchString(line) { + category = "jwt_signing_key" + } + if ok && category != "" && !isAllowedSecretReference(value) { + findings = append(findings, runtimeSecretFinding{ + path: path, + line: lineNumber, + category: category, + }) + } + + if strings.HasSuffix(strings.ToLower(path), ".json") { + if jwtKeysDepth < 0 && strings.Contains(line, `"keys"`) && strings.Contains(line, "{") && + strings.Contains(strings.ToLower(path), "auth") { + jwtKeysDepth = jsonDepth + } + jsonDepth += strings.Count(line, "{") - strings.Count(line, "}") + if jwtKeysDepth >= 0 && jsonDepth <= jwtKeysDepth { + jwtKeysDepth = -1 + } + } + } + if err := scanner.Err(); err != nil { + t.Fatalf("scan tracked policy input %s: %v", path, err) + } + return findings +} + +func runtimeSecretAssignment(path, line string) (field, value string, ok bool) { + ext := strings.ToLower(filepath.Ext(path)) + var match []string + switch ext { + case ".cc", ".cpp", ".h", ".hpp": + match = defineStringAssignment.FindStringSubmatch(line) + case ".conf": + match = flagAssignment.FindStringSubmatch(line) + case ".json": + match = jsonStringAssignment.FindStringSubmatch(line) + case ".yml", ".yaml": + match = yamlAssignment.FindStringSubmatch(line) + } + if len(match) != 3 { + return "", "", false + } + return match[1], match[2], true +} + +func runtimeSecretCategory(path, field string) string { + name := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(field, "-", "_"), ".", "_")) + passwordField := strings.Contains(name, "password") || strings.Contains(name, "passwd") || + strings.Contains(name, "pswd") || strings.Contains(name, "paswd") || strings.HasSuffix(name, "_pass") + + switch { + case path == "tests/config.yaml" && name == "mysql_dsn": + return "database_password" + case passwordField && containsAny(name, "mysql", "database", "db_"): + return "database_password" + case passwordField && containsAny(name, "smtp", "mail"): + return "smtp_password" + case passwordField && containsAny(name, "rabbit", "amqp", "mq_"): + return "message_broker_password" + case passwordField && strings.Contains(name, "redis"): + return "redis_password" + case strings.Contains(name, "jwt") && containsAny(name, "secret", "signing", "private_key"): + return "jwt_signing_key" + case strings.Contains(name, "minio") && containsAny(name, "root_user", "access_key"): + return "object_storage_access_key" + case strings.Contains(name, "minio") && containsAny(name, "password", "secret_key"): + return "object_storage_secret_key" + case strings.HasPrefix(path, "conf/") && strings.Contains(strings.ToLower(path), "media") && name == "access_key": + return "object_storage_access_key" + case strings.HasPrefix(path, "conf/") && strings.Contains(strings.ToLower(path), "media") && name == "secret_key": + return "object_storage_secret_key" + case passwordField && (strings.HasPrefix(path, "conf/") || strings.Contains(strings.ToLower(filepath.Base(path)), "compose")): + return "runtime_password" + default: + return "" + } +} + +func isAllowedSecretReference(raw string) bool { + value := strings.TrimSpace(raw) + if comment := strings.Index(value, " #"); comment >= 0 { + value = strings.TrimSpace(value[:comment]) + } + value = strings.Trim(value, `"'`) + if value == "" || environmentReference.MatchString(value) { + return true + } + + lower := strings.ToLower(value) + if strings.HasPrefix(lower, "file:") || strings.HasPrefix(lower, "/run/secrets/") || + strings.HasPrefix(lower, "${") || strings.HasPrefix(lower, "$${") { + return true + } + return containsAny(lower, + "", "placeholder-only", "example-only", "dummy-only", + "test-only", "local-dev-only", "not-a-secret", "replace-me") +} + +func containsAny(value string, candidates ...string) bool { + for _, candidate := range candidates { + if strings.Contains(value, candidate) { + return true + } + } + return false +} diff --git a/tests/pkg/verify/minio.go b/tests/pkg/verify/minio.go index a50afd2..1ab86bc 100644 --- a/tests/pkg/verify/minio.go +++ b/tests/pkg/verify/minio.go @@ -16,16 +16,16 @@ type MinIOVerifier struct { } // NewMinIOVerifier 创建 MinIO 验证器。 -// endpoint 例 "127.0.0.1:9000"(不含 scheme),accessKey/secretKey 默认 minioadmin。 +// Credentials must be supplied by the isolated test environment. func NewMinIOVerifier(endpoint, accessKey, secretKey string) *MinIOVerifier { if endpoint == "" { - endpoint = "127.0.0.1:9000" + panic("NewMinIOVerifier: endpoint is required") } if accessKey == "" { - accessKey = "minioadmin" + panic("NewMinIOVerifier: access key is required") } if secretKey == "" { - secretKey = "minioadmin" + panic("NewMinIOVerifier: secret key is required") } cli, err := minio.New(endpoint, &minio.Options{ Creds: credentials.NewStaticV4(accessKey, secretKey, ""), diff --git a/tests/pkg/verify/minio_test.go b/tests/pkg/verify/minio_test.go index c798a5c..920b82d 100644 --- a/tests/pkg/verify/minio_test.go +++ b/tests/pkg/verify/minio_test.go @@ -10,6 +10,18 @@ import ( "github.com/stretchr/testify/require" ) +func TestNewMinIOVerifierRequiresInjectedCredentials(t *testing.T) { + require.PanicsWithValue(t, "NewMinIOVerifier: endpoint is required", func() { + NewMinIOVerifier("", "", "") + }) + require.PanicsWithValue(t, "NewMinIOVerifier: access key is required", func() { + NewMinIOVerifier("127.0.0.1:19000", "", "synthetic-secret") + }) + require.PanicsWithValue(t, "NewMinIOVerifier: secret key is required", func() { + NewMinIOVerifier("127.0.0.1:19000", "synthetic-access", "") + }) +} + func TestMinIOVerifier_ObjectExists(t *testing.T) { endpoint := os.Getenv("MINIO_ENDPOINT") if endpoint == "" { diff --git a/transmite/source/transmite_server.cc b/transmite/source/transmite_server.cc index ce023df..2cca554 100644 --- a/transmite/source/transmite_server.cc +++ b/transmite/source/transmite_server.cc @@ -1,4 +1,5 @@ #include "transmite_server.h" +#include "config/secret_resolver.hpp" DEFINE_bool(run_mode, false, "程序的运行模式 false-调试 ; true-发布"); DEFINE_string(log_file, "", "发布模式下,用于指定日志的输出文件"); @@ -29,7 +30,6 @@ DEFINE_bool(redis_keep_alive, true, "Redis 长连接"); DEFINE_int32(redis_pool_size, 8, "Redis 连接池大小"); DEFINE_string(mq_user, "root", "消息队列服务器访问用户名"); -DEFINE_string(mq_pswd, "", "消息队列服务器访问密码(可通过 CHATNOW_MQ_PSWD 环境变量设置)"); DEFINE_string(mq_host, "127.0.0.1:5672", "消息队列服务器访问地址"); // publisher-only:exchange 必须与 message 服务 mq_msg_exchange 一致 DEFINE_string(mq_msg_exchange, "chat_msg_exchange", "持久化消息的发布交换机名称(FANOUT,必须与 message.mq_msg_exchange 完全一致)"); @@ -45,18 +45,10 @@ DEFINE_int32(rate_limit_window_sec, 60, "限流窗口秒数"); int main(int argc, char *argv[]) { google::ParseCommandLineFlags(&argc, &argv, true); + const auto mq_password = chatnow::config::resolve_secret( + chatnow::config::SecretId::TransmiteMqPassword); chatnow::init_logger(FLAGS_run_mode, FLAGS_log_file, FLAGS_log_level); - // 环境变量兜底:配置文件中不应含密码 - if (FLAGS_mq_pswd.empty()) { - const char *env = std::getenv("CHATNOW_MQ_PSWD"); - if (env && env[0] != '\0') FLAGS_mq_pswd = env; - } - if (FLAGS_mq_pswd.empty()) { - LOG_ERROR("MQ 密码未设置(请通过 -mq_pswd 或 CHATNOW_MQ_PSWD 环境变量提供)"); - return 1; - } - chatnow::TransmiteServerBuilder tsb; // 注意:先初始化 Redis(worker_id 自动分配依赖 Redis),再初始化 ID 生成器 tsb.set_redis_seeds(FLAGS_redis_seeds); @@ -65,7 +57,7 @@ int main(int argc, char *argv[]) tsb.set_etcd_client(std::make_shared(FLAGS_registry_host)); tsb.make_local_cache(); tsb.make_id_generator_object(FLAGS_instance_num, FLAGS_epoch_ms, FLAGS_wait_on_clock_backwards); - tsb.make_mq_object(FLAGS_mq_user, FLAGS_mq_pswd, FLAGS_mq_host, FLAGS_mq_msg_exchange, FLAGS_mq_msg_queue, FLAGS_mq_msg_binding_key); + tsb.make_mq_object(FLAGS_mq_user, mq_password, FLAGS_mq_host, FLAGS_mq_msg_exchange, FLAGS_mq_msg_queue, FLAGS_mq_msg_binding_key); tsb.make_discovery_object(FLAGS_registry_host, FLAGS_base_service, FLAGS_identity_service, FLAGS_conversation_service, FLAGS_message_service); tsb.make_rpc_object(FLAGS_listen_port, FLAGS_rpc_timeout, FLAGS_rpc_threads); tsb.make_reg_object(FLAGS_registry_host, FLAGS_base_service + FLAGS_instance_name, FLAGS_access_host); @@ -74,4 +66,4 @@ int main(int argc, char *argv[]) server->start(); return 0; -} \ No newline at end of file +} From 80880e5b0c151d392299c4e6c2f10993219079fa Mon Sep 17 00:00:00 2001 From: ULookup Date: Wed, 22 Jul 2026 05:47:53 +0000 Subject: [PATCH 2/2] chore(ci): move runtime injection to dedicated PR --- .github/workflows/ci.yml | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24a4a3e..1cfb6ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,34 +112,6 @@ jobs: - &validate-compose-artifacts name: Validate downloaded Compose service artifacts run: docker run --rm -v "$PWD:/workspace" -w /workspace ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 ./scripts/validate_compose_artifacts.sh compose-artifacts - - &inject-runtime-secrets - name: Create synthetic runtime secrets - shell: bash - run: | - set -euo pipefail - umask 077 - shared_password="$(openssl rand -hex 24)" - jwt_key="$(openssl rand -hex 32)" - jwt_config="$(printf '{\"auth\":{\"jwt\":{\"current_kid\":\"ci-v1\",\"keys\":{\"ci-v1\":\"%s\"},\"access_ttl_sec\":7200,\"refresh_ttl_sec\":2592000}}}' "$jwt_key")" - { - printf 'CHATNOW_MYSQL_ROOT_PASSWORD=%s\n' "$shared_password" - printf 'CHATNOW_RABBITMQ_BOOTSTRAP_PASSWORD=%s\n' "$shared_password" - printf 'CHATNOW_IDENTITY_MYSQL_PASSWORD=%s\n' "$shared_password" - printf 'CHATNOW_CONVERSATION_MYSQL_PASSWORD=%s\n' "$shared_password" - printf 'CHATNOW_RELATIONSHIP_MYSQL_PASSWORD=%s\n' "$shared_password" - printf 'CHATNOW_MESSAGE_MYSQL_PASSWORD=%s\n' "$shared_password" - printf 'CHATNOW_MEDIA_MYSQL_PASSWORD=%s\n' "$shared_password" - printf 'CHATNOW_TRANSMITE_MQ_PASSWORD=%s\n' "$shared_password" - printf 'CHATNOW_MESSAGE_MQ_PASSWORD=%s\n' "$shared_password" - printf 'CHATNOW_PUSH_MQ_PASSWORD=%s\n' "$shared_password" - printf 'CHATNOW_IDENTITY_SMTP_PASSWORD=%s\n' "$shared_password" - printf 'CHATNOW_MEDIA_S3_ACCESS_KEY=%s\n' 'ci-synthetic-access' - printf 'CHATNOW_MEDIA_S3_SECRET_KEY=%s\n' "$shared_password" - printf 'CHATNOW_JWT_CONFIG=%s\n' "$jwt_config" - printf 'MYSQL_DSN=root:%s@tcp(localhost:3306)/chatnow?charset=utf8mb4&parseTime=true\n' "$shared_password" - printf 'MINIO_ACCESS_KEY=%s\n' 'ci-synthetic-access' - printf 'MINIO_SECRET_KEY=%s\n' "$shared_password" - } >> "$GITHUB_ENV" - name: Start full stack run: docker compose up -d --build - name: Wait for services @@ -173,7 +145,6 @@ jobs: - *restore-compose-artifacts - *chmod-compose-artifacts - *validate-compose-artifacts - - *inject-runtime-secrets - name: Start full stack run: docker compose up -d --build - name: Wait for services @@ -207,7 +178,6 @@ jobs: - *restore-compose-artifacts - *chmod-compose-artifacts - *validate-compose-artifacts - - *inject-runtime-secrets - name: Start full stack run: docker compose up -d --build - name: Wait for services @@ -241,7 +211,6 @@ jobs: - *restore-compose-artifacts - *chmod-compose-artifacts - *validate-compose-artifacts - - *inject-runtime-secrets - name: Start full stack with PF-09 rate limits env: # Transmite flags are int32; use the maximum valid value for gate-only headroom. @@ -275,7 +244,6 @@ jobs: sudo apt-get install -y protobuf-compiler netcat-openbsd - name: Install Go protobuf generator run: go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11 - - *inject-runtime-secrets - name: Start full stack run: docker compose up -d --build - name: Wait for services