Compare commits
No commits in common. "cde9638227600a5af429ba86fa10e0a13953d626" and "24bc1f4232687adafe97e170b59f9c18b2f8bf14" have entirely different histories.
cde9638227
...
24bc1f4232
26
API.md
26
API.md
@ -50,32 +50,6 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管
|
|||||||
- 后端格式化时间字段统一使用 `KtDateTime extends Date`:Entity 通过 `@KtDateTimeColumn(format)`、`@KtCreateDateColumn(format)`、`@KtUpdateDateColumn(format)` 在 TypeORM hydrate 边界转换;DTO/外部数据源通过 `@KtDateTimeField(format)` + `transformKtDateTimeFields()` 转换。默认输出 `YYYY-MM-DD HH:mm:ss`,可在装饰器中传入格式字符串;响应包装不做递归遍历。
|
- 后端格式化时间字段统一使用 `KtDateTime extends Date`:Entity 通过 `@KtDateTimeColumn(format)`、`@KtCreateDateColumn(format)`、`@KtUpdateDateColumn(format)` 在 TypeORM hydrate 边界转换;DTO/外部数据源通过 `@KtDateTimeField(format)` + `transformKtDateTimeFields()` 转换。默认输出 `YYYY-MM-DD HH:mm:ss`,可在装饰器中传入格式字符串;响应包装不做递归遍历。
|
||||||
- `POST */save` 默认会删除请求体里的 `id`,防止新增接口误用前端主键。
|
- `POST */save` 默认会删除请求体里的 `id`,防止新增接口误用前端主键。
|
||||||
|
|
||||||
## Runtime Health
|
|
||||||
|
|
||||||
| 方法 | 路径 | 认证 | 说明 |
|
|
||||||
| ----- | ----------------- | ---- | ---------------------------------- |
|
|
||||||
| `GET` | `/health/runtime` | 否 | API 运行时健康和配置检查状态 |
|
|
||||||
|
|
||||||
该接口返回 plain JSON,不使用 Vben 响应包装,供本地 smoke、Jenkins/K8s 和 ktWorkflow 观测脚本直接读取。接口位于 Swagger 基础能力分组 `/api/basic`。
|
|
||||||
|
|
||||||
顶层字段:
|
|
||||||
|
|
||||||
| 字段 | 说明 |
|
|
||||||
| ----------- | ------------------------------------------------ |
|
|
||||||
| `service` | 固定为 `kt-template-online-api` |
|
|
||||||
| `checkedAt` | ISO 时间字符串 |
|
|
||||||
| `status` | `live`、`ready`、`degraded` 或 `blocked` |
|
|
||||||
| `checks` | 进程和配置检查列表 |
|
|
||||||
|
|
||||||
公开响应不返回数据库、WordPress、Loki、NapCat SSH 等运行拓扑配置快照;配置检查只暴露 key 级别、是否存在和缺失说明。
|
|
||||||
|
|
||||||
状态含义:
|
|
||||||
|
|
||||||
- `live`:NestJS 进程能响应健康请求。
|
|
||||||
- `ready`:关键配置存在,当前检查未发现缺失项。
|
|
||||||
- `degraded`:可选运行时配置缺失,核心 API 可继续工作。
|
|
||||||
- `blocked`:关键运行时配置缺失,不能声明部署或运行态成功。
|
|
||||||
|
|
||||||
## 环境变量分组
|
## 环境变量分组
|
||||||
|
|
||||||
| 分组 | 关键变量 |
|
| 分组 | 关键变量 |
|
||||||
|
|||||||
11
README.md
11
README.md
@ -121,17 +121,6 @@ pnpm exec jest --runInBand --runTestsByPath test/path/to/file.spec.ts
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 运行时健康检查
|
|
||||||
|
|
||||||
API 暴露 `GET /health/runtime` 作为本地 smoke、Jenkins/K8s 和 ktWorkflow 观测入口。该接口返回 plain JSON,不使用 Vben 响应包装,便于脚本直接读取。
|
|
||||||
|
|
||||||
返回内容包括:
|
|
||||||
|
|
||||||
- `status`:`live`、`ready`、`degraded` 或 `blocked`。
|
|
||||||
- `checks`:进程存活和运行时配置检查状态。
|
|
||||||
|
|
||||||
该公开入口不返回数据库、WordPress、Loki、NapCat SSH 等运行拓扑配置快照;配置检查只暴露 key 级别、是否存在和缺失说明。`blocked` 表示关键配置缺失;`degraded` 表示可选运行时配置缺失,核心 API 仍可继续工作。本地未配置 Loki、WordPress、NapCat 等可选依赖时,健康状态可能保持 `degraded`。
|
|
||||||
|
|
||||||
## 核心规则
|
## 核心规则
|
||||||
|
|
||||||
- 后台主键使用 Snowflake 数字 ID,数据库字段为 `BIGINT`,接口按字符串返回。
|
- 后台主键使用 Snowflake 数字 ID,数据库字段为 `BIGINT`,接口按字符串返回。
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,494 +0,0 @@
|
|||||||
# API Runtime Foundation Design
|
|
||||||
|
|
||||||
## Background
|
|
||||||
|
|
||||||
The API project has grown into several runtime-heavy domains: Admin, Blog,
|
|
||||||
WordPress, QQBot, MinIO, Loki logging, Jenkins/K8s deployment, and NAS-hosted
|
|
||||||
NapCat containers. The current module layout works, but reliability behavior is
|
|
||||||
spread across large services and scripts.
|
|
||||||
|
|
||||||
The most visible pressure points are:
|
|
||||||
|
|
||||||
- `src/qqbot/account/qqbot-napcat-login.service.ts` mixes session state, SSE
|
|
||||||
events, quick login, password login, captcha, QR code fallback, cleanup, and
|
|
||||||
NapCat WebUI calls.
|
|
||||||
- `src/qqbot/napcat/qqbot-napcat-container.service.ts` mixes database container
|
|
||||||
state, SSH process execution, Docker script generation, runtime status checks,
|
|
||||||
and NapCat WebUI requests.
|
|
||||||
- `src/wordpress/wordpress.service.ts` mixes WordPress auth, request transport,
|
|
||||||
data normalization, markdown conversion, public article reads, and theme
|
|
||||||
integration.
|
|
||||||
- Runtime configuration is read directly through `ConfigService.get(...)` in
|
|
||||||
many services, with required keys, defaults, timeouts, and secret masking
|
|
||||||
handled locally.
|
|
||||||
- Jenkins/K8s deployment already builds and rolls out the API, but post-rollout
|
|
||||||
evidence still depends on manual observation and task-specific smoke commands.
|
|
||||||
|
|
||||||
The user selected the API-wide refactor priority as:
|
|
||||||
|
|
||||||
1. Runtime reliability.
|
|
||||||
2. Module boundaries.
|
|
||||||
3. Contract stability.
|
|
||||||
4. Testability.
|
|
||||||
|
|
||||||
The user also selected a combined path: first implement the API runtime
|
|
||||||
foundation, then extend that line into ktWorkflow/Jenkins/K8s automated
|
|
||||||
observation.
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Build an API Runtime Foundation that centralizes configuration, external
|
|
||||||
runtime calls, health reporting, cleanup semantics, and verification evidence,
|
|
||||||
then use NapCat and Jenkins/K8s as the first end-to-end reliability samples.
|
|
||||||
|
|
||||||
## Non-Goals
|
|
||||||
|
|
||||||
- Do not rewrite every API business module in the first phase.
|
|
||||||
- Do not migrate all Admin, Blog, WordPress, MinIO, QQBot, and common contracts
|
|
||||||
at once.
|
|
||||||
- Do not replace existing Vben response wrappers or public API shapes during the
|
|
||||||
runtime foundation phase unless a sample integration requires a narrow
|
|
||||||
compatibility change.
|
|
||||||
- Do not bypass QQ or Tencent security checks. Captcha and new-device
|
|
||||||
verification stay user-driven.
|
|
||||||
- Do not commit real secrets, backend `.env.development`, backend
|
|
||||||
`.env.production`, database passwords, SSH keys, tokens, or production
|
|
||||||
Kubernetes Secrets.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
This spec covers the first API-wide subproject:
|
|
||||||
|
|
||||||
- Add a runtime foundation inside the API project.
|
|
||||||
- Connect that foundation to two sample runtime paths:
|
|
||||||
- API deployment observation through Jenkins/K8s and ktWorkflow.
|
|
||||||
- QQBot NapCat runtime and login reliability.
|
|
||||||
- Create guardrails so future refactors move from runtime reliability into
|
|
||||||
module boundaries, contract stability, and testability without large,
|
|
||||||
unrelated rewrites.
|
|
||||||
|
|
||||||
Future subprojects should receive their own Superpowers specs:
|
|
||||||
|
|
||||||
- API module boundary refactor.
|
|
||||||
- API contract and DTO stability.
|
|
||||||
- API testability and large-service decomposition.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Runtime Foundation Layer
|
|
||||||
|
|
||||||
Create a focused runtime layer rather than expanding `common` into a larger
|
|
||||||
utility bucket. The layer owns operational concerns, while business modules keep
|
|
||||||
domain state and user-facing behavior.
|
|
||||||
|
|
||||||
Proposed package boundary:
|
|
||||||
|
|
||||||
```text
|
|
||||||
src/runtime/
|
|
||||||
runtime.module.ts
|
|
||||||
config/
|
|
||||||
client/
|
|
||||||
health/
|
|
||||||
evidence/
|
|
||||||
cleanup/
|
|
||||||
errors/
|
|
||||||
```
|
|
||||||
|
|
||||||
The exact file split can change during implementation, but each unit must have
|
|
||||||
one responsibility and a clear consumer:
|
|
||||||
|
|
||||||
- Runtime config is consumed by API modules and adapters.
|
|
||||||
- Runtime clients are consumed by integration adapters, not controllers.
|
|
||||||
- Runtime health is consumed by health controllers and deployment smoke.
|
|
||||||
- Runtime evidence is consumed by scripts, ktWorkflow, and final task records.
|
|
||||||
- Runtime cleanup is consumed by integration adapters that create temporary
|
|
||||||
runtime state.
|
|
||||||
|
|
||||||
### RuntimeConfigModule
|
|
||||||
|
|
||||||
`RuntimeConfigModule` provides typed config profiles for the runtime paths that
|
|
||||||
matter first:
|
|
||||||
|
|
||||||
- app and HTTP server settings.
|
|
||||||
- database connection metadata needed for health checks.
|
|
||||||
- Loki logging and query settings.
|
|
||||||
- WordPress integration settings.
|
|
||||||
- MinIO integration settings.
|
|
||||||
- QQBot and NapCat runtime settings.
|
|
||||||
- Jenkins/K8s smoke observation settings where they are needed by scripts.
|
|
||||||
|
|
||||||
Each profile must provide:
|
|
||||||
|
|
||||||
- typed values with normalized number and boolean parsing.
|
|
||||||
- required key validation.
|
|
||||||
- safe defaults where the current project already has stable defaults.
|
|
||||||
- secret masking for logs, health output, evidence files, and review output.
|
|
||||||
- a safe snapshot method that never returns raw passwords, tokens, private keys,
|
|
||||||
or secret values.
|
|
||||||
|
|
||||||
`ConfigService.get(...)` may remain in untouched legacy code during migration,
|
|
||||||
but new runtime code should consume typed config profiles.
|
|
||||||
|
|
||||||
### RuntimeHttpClient and RuntimeProcessClient
|
|
||||||
|
|
||||||
External calls should flow through runtime clients instead of ad hoc
|
|
||||||
`fetch`, `http.request`, `https.request`, or `spawn` logic.
|
|
||||||
|
|
||||||
`RuntimeHttpClient` handles:
|
|
||||||
|
|
||||||
- request timeout.
|
|
||||||
- operation name.
|
|
||||||
- safe target summary.
|
|
||||||
- duration.
|
|
||||||
- status code.
|
|
||||||
- response parsing failures.
|
|
||||||
- classified errors.
|
|
||||||
|
|
||||||
`RuntimeProcessClient` handles:
|
|
||||||
|
|
||||||
- bounded process execution.
|
|
||||||
- command family and safe argument summaries.
|
|
||||||
- stdin script support for NAS SSH here-string workflows.
|
|
||||||
- duration and exit code.
|
|
||||||
- timeout classification.
|
|
||||||
- stdout/stderr truncation for safe evidence.
|
|
||||||
|
|
||||||
These clients do not know domain rules. They return transport-level results that
|
|
||||||
adapters translate into domain state.
|
|
||||||
|
|
||||||
### RuntimeHealthService
|
|
||||||
|
|
||||||
Add a machine-readable runtime health endpoint such as:
|
|
||||||
|
|
||||||
```text
|
|
||||||
GET /health/runtime
|
|
||||||
```
|
|
||||||
|
|
||||||
The endpoint should support at least these states:
|
|
||||||
|
|
||||||
- `live`: the process is running and can answer HTTP requests.
|
|
||||||
- `ready`: critical dependencies and required config are available.
|
|
||||||
- `degraded`: optional dependencies are unavailable, but core API behavior can
|
|
||||||
continue.
|
|
||||||
- `blocked`: required config is missing or a critical dependency is unavailable.
|
|
||||||
|
|
||||||
Initial probes should be lightweight. They should not perform destructive work
|
|
||||||
or expensive business operations. The first implementation can keep the existing
|
|
||||||
K8s TCP readiness probe and let Jenkins/ktWorkflow call `/health/runtime`.
|
|
||||||
Switching the Kubernetes readiness probe to HTTP should happen only after the
|
|
||||||
endpoint proves stable online.
|
|
||||||
|
|
||||||
### RuntimeEvidenceService
|
|
||||||
|
|
||||||
Runtime evidence should be emitted as structured JSON under
|
|
||||||
`.kt-workspace/test-artifacts/...` and summarized in final reports.
|
|
||||||
|
|
||||||
Evidence records should include:
|
|
||||||
|
|
||||||
- title and task type.
|
|
||||||
- target project and environment.
|
|
||||||
- command or endpoint name.
|
|
||||||
- safe target summary.
|
|
||||||
- start time, end time, and duration.
|
|
||||||
- result status.
|
|
||||||
- classified error when present.
|
|
||||||
- validation assertions.
|
|
||||||
- cleanup result.
|
|
||||||
|
|
||||||
Evidence must not include full logs, raw tokens, raw passwords, SSH keys, full
|
|
||||||
base64 image payloads, or production Secret values.
|
|
||||||
|
|
||||||
### RuntimeCleanup Semantics
|
|
||||||
|
|
||||||
Cleanup must be explicit and reportable. Runtime operations that create
|
|
||||||
temporary state must return cleanup results instead of burying cleanup failure in
|
|
||||||
logs.
|
|
||||||
|
|
||||||
For NapCat, cleanup failure includes:
|
|
||||||
|
|
||||||
- failing to remove runtime login password environment variables.
|
|
||||||
- failing to clean temporary SSH scripts or runtime artifacts started by this
|
|
||||||
task.
|
|
||||||
- failing to preserve or verify required persistent device state.
|
|
||||||
|
|
||||||
Cleanup failure must not be overwritten by ordinary offline or login-failed
|
|
||||||
messages.
|
|
||||||
|
|
||||||
### NapCatRuntimeAdapter
|
|
||||||
|
|
||||||
NapCat becomes the first domain adapter for the runtime foundation.
|
|
||||||
|
|
||||||
It should own runtime concerns that currently sit inside large QQBot services:
|
|
||||||
|
|
||||||
- SSH/Docker script execution through `RuntimeProcessClient`.
|
|
||||||
- Docker device identity persistence.
|
|
||||||
- NapCat WebUI calls through `RuntimeHttpClient`.
|
|
||||||
- timeout and retry classification.
|
|
||||||
- safe runtime evidence for container rebuilds, captcha, new-device verification,
|
|
||||||
and cleanup.
|
|
||||||
|
|
||||||
The QQBot account/login services should keep domain state:
|
|
||||||
|
|
||||||
- account binding.
|
|
||||||
- login session lifecycle.
|
|
||||||
- SSE progress.
|
|
||||||
- expected selfId checks.
|
|
||||||
- success and failure messages.
|
|
||||||
|
|
||||||
The adapter must support the confirmed NapCat upstream new-device flow:
|
|
||||||
|
|
||||||
1. `PasswordLogin` or `CaptchaLogin` returns `needNewDevice`, `jumpUrl`, and
|
|
||||||
`newDevicePullQrCodeSig`.
|
|
||||||
2. API calls `GetNewDeviceQRCode` with `uin` and `jumpUrl`.
|
|
||||||
3. API keeps the same scan session pending and exposes a user-facing new-device
|
|
||||||
QR state through SSE/Admin.
|
|
||||||
4. API polls `PollNewDeviceQR` using `bytesToken`.
|
|
||||||
5. When the user confirms the QR, API calls `NewDeviceLogin` with `uin`,
|
|
||||||
`passwordMd5`, and `newDevicePullQrCodeSig`.
|
|
||||||
6. API checks login state, cleans runtime password state, binds the account, and
|
|
||||||
records evidence.
|
|
||||||
|
|
||||||
### DeployObservationAdapter
|
|
||||||
|
|
||||||
Deployment observation starts from ktWorkflow and can reuse API evidence
|
|
||||||
formats. It should collect:
|
|
||||||
|
|
||||||
- Jenkins job and build number.
|
|
||||||
- commit hash.
|
|
||||||
- image tag.
|
|
||||||
- Kubernetes Deployment generation and observedGeneration.
|
|
||||||
- updated and ready replica counts.
|
|
||||||
- running Pod selected by image tag.
|
|
||||||
- restart count.
|
|
||||||
- recent Pod logs or events when failing.
|
|
||||||
- `/health/runtime` response.
|
|
||||||
- task-specific smoke result.
|
|
||||||
|
|
||||||
Rollout success alone is deployment evidence, not functional success.
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
The target flow is:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Controller or script
|
|
||||||
-> business service
|
|
||||||
-> domain adapter
|
|
||||||
-> runtime config/client/cleanup/health/evidence
|
|
||||||
-> external system
|
|
||||||
```
|
|
||||||
|
|
||||||
External results flow back as:
|
|
||||||
|
|
||||||
```text
|
|
||||||
external system result
|
|
||||||
-> runtime client classification
|
|
||||||
-> domain adapter translation
|
|
||||||
-> business service state update
|
|
||||||
-> controller/SSE/API response
|
|
||||||
-> runtime evidence summary
|
|
||||||
```
|
|
||||||
|
|
||||||
For NapCat login, that means:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Admin refresh login
|
|
||||||
-> QqbotNapcatLoginService session state
|
|
||||||
-> NapCatRuntimeAdapter
|
|
||||||
-> RuntimeProcessClient for Docker/SSH
|
|
||||||
-> RuntimeHttpClient for NapCat WebUI
|
|
||||||
-> classified status
|
|
||||||
-> SSE Chinese progress and scan/status response
|
|
||||||
-> runtime evidence
|
|
||||||
```
|
|
||||||
|
|
||||||
For deployment observation, that means:
|
|
||||||
|
|
||||||
```text
|
|
||||||
push/commit
|
|
||||||
-> Jenkins build and K8s rollout
|
|
||||||
-> ktWorkflow DeployObservationAdapter
|
|
||||||
-> Kubernetes and health checks
|
|
||||||
-> task smoke
|
|
||||||
-> runtime evidence
|
|
||||||
-> closeout decision
|
|
||||||
```
|
|
||||||
|
|
||||||
## Error Model
|
|
||||||
|
|
||||||
Runtime errors should be classified before business code decides the user-facing
|
|
||||||
message.
|
|
||||||
|
|
||||||
### `config_error`
|
|
||||||
|
|
||||||
Required configuration is missing, dangerous, or unreadable.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
- missing database host.
|
|
||||||
- missing explicit QQBot account secret.
|
|
||||||
- missing NapCat SSH target.
|
|
||||||
- unreadable SSH key path.
|
|
||||||
|
|
||||||
Result: blocked. Do not claim deploy or runtime success.
|
|
||||||
|
|
||||||
### `dependency_unavailable`
|
|
||||||
|
|
||||||
The external dependency is unreachable or not authorized.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
- WordPress timeout.
|
|
||||||
- Loki query timeout.
|
|
||||||
- NapCat WebUI unavailable.
|
|
||||||
- SSH command timeout.
|
|
||||||
|
|
||||||
Result: degraded or blocked, depending on dependency criticality.
|
|
||||||
|
|
||||||
### `operation_failed`
|
|
||||||
|
|
||||||
The dependency is reachable but the business operation did not complete.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
- NapCat requires captcha.
|
|
||||||
- NapCat requires new-device verification.
|
|
||||||
- QQ account mismatch.
|
|
||||||
- WordPress returns an upstream validation error.
|
|
||||||
|
|
||||||
Result: domain service keeps pending, returns a business error, or falls back
|
|
||||||
according to its state machine.
|
|
||||||
|
|
||||||
### `cleanup_failed`
|
|
||||||
|
|
||||||
Primary work may have completed or partially completed, but cleanup failed.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
- `NAPCAT_QUICK_PASSWORD` could not be removed.
|
|
||||||
- temporary runtime evidence could not be cleaned.
|
|
||||||
- a remote temporary file created by this run remains.
|
|
||||||
|
|
||||||
Result: block success reporting until the cleanup failure is handled or reported
|
|
||||||
as a stable blocker.
|
|
||||||
|
|
||||||
## Phased Delivery
|
|
||||||
|
|
||||||
### Phase 1: Runtime Foundation Skeleton
|
|
||||||
|
|
||||||
Create the runtime module, typed config profiles, health types, evidence types,
|
|
||||||
and lightweight `/health/runtime` endpoint.
|
|
||||||
|
|
||||||
Initial validation:
|
|
||||||
|
|
||||||
- targeted Jest for config parsing, masking, health state aggregation, and
|
|
||||||
evidence serialization.
|
|
||||||
- `pnpm run typecheck`.
|
|
||||||
- one real local or reused-service request to `/health/runtime` when the
|
|
||||||
endpoint exists.
|
|
||||||
|
|
||||||
### Phase 2: Deployment Observation Foundation
|
|
||||||
|
|
||||||
Add ktWorkflow/Jenkins/K8s observation support that can use the new health and
|
|
||||||
evidence format.
|
|
||||||
|
|
||||||
Initial validation:
|
|
||||||
|
|
||||||
- ktWorkflow typecheck and self-test.
|
|
||||||
- dry-run or read-only deploy observation against the current API deployment.
|
|
||||||
- evidence written under `.kt-workspace/test-artifacts`.
|
|
||||||
|
|
||||||
### Phase 3: NapCat Runtime Adapter Sample
|
|
||||||
|
|
||||||
Refactor the NapCat runtime boundary around device persistence, WebUI calls,
|
|
||||||
new-device QR flow, runtime password cleanup, and SSH/Docker evidence.
|
|
||||||
|
|
||||||
Required behavior:
|
|
||||||
|
|
||||||
- Docker container rebuild preserves device identity files:
|
|
||||||
- `$DATA_DIR/QQ` mounted to `/app/.config/QQ`.
|
|
||||||
- `$DATA_DIR/device.env` stores stable MAC and hostname.
|
|
||||||
- `$DATA_DIR/machine-id` mounts to `/etc/machine-id:ro`.
|
|
||||||
- Docker run uses `--mac-address` and `--hostname`.
|
|
||||||
- Reset login state must not delete `device.env` or `machine-id`.
|
|
||||||
- Captcha and new-device verification keep the same scan session pending.
|
|
||||||
- New-device verification follows `GetNewDeviceQRCode -> PollNewDeviceQR ->
|
|
||||||
NewDeviceLogin`.
|
|
||||||
- SSE/Admin progress remains Chinese and user-facing:
|
|
||||||
- quick login.
|
|
||||||
- password login.
|
|
||||||
- captcha required.
|
|
||||||
- new-device QR generated.
|
|
||||||
- scanned.
|
|
||||||
- confirming.
|
|
||||||
- success or failure.
|
|
||||||
- Runtime login password cleanup failure blocks success.
|
|
||||||
|
|
||||||
Initial validation:
|
|
||||||
|
|
||||||
- targeted Jest around NapCat device persistence and new-device login flow.
|
|
||||||
- API typecheck.
|
|
||||||
- local or reused service request for changed login endpoints.
|
|
||||||
- online account smoke after push and rollout.
|
|
||||||
|
|
||||||
### Phase 4: Boundary Ratchet
|
|
||||||
|
|
||||||
Add guardrails so future runtime work uses the foundation.
|
|
||||||
|
|
||||||
Guardrails:
|
|
||||||
|
|
||||||
- new external integration code should not directly scatter
|
|
||||||
`ConfigService.get(...)` without a typed runtime profile.
|
|
||||||
- new runtime calls should use runtime clients or justify why not.
|
|
||||||
- new deployment completion claims should include rollout, health, and smoke
|
|
||||||
evidence.
|
|
||||||
- new cleanup-sensitive flows should return cleanup evidence.
|
|
||||||
|
|
||||||
Validation:
|
|
||||||
|
|
||||||
- ktWorkflow global review rule updates.
|
|
||||||
- ktWorkflow self-test.
|
|
||||||
- documentation sync for README/API/docs/TASKS/Obsidian entries as required.
|
|
||||||
|
|
||||||
## Verification Strategy
|
|
||||||
|
|
||||||
Verification scales with risk:
|
|
||||||
|
|
||||||
- docs-only spec commit: self-review, `git diff --check`, and scoped review.
|
|
||||||
- runtime foundation code: targeted Jest and typecheck.
|
|
||||||
- interface changes: real local or reused-service request.
|
|
||||||
- deployment automation: read-only observation or dry run first, then online
|
|
||||||
observation after push.
|
|
||||||
- NapCat runtime changes: targeted Jest, real API request, Jenkins/K8s rollout,
|
|
||||||
and online account smoke.
|
|
||||||
|
|
||||||
Completion of the full workstream requires:
|
|
||||||
|
|
||||||
- KT documentation sync.
|
|
||||||
- cleanup-history final `deleted=[]` evidence when artifacts are produced.
|
|
||||||
- KT global review with no blocking findings.
|
|
||||||
- Superpowers code review evidence.
|
|
||||||
- ktWorkflow closeout evidence.
|
|
||||||
|
|
||||||
## Worktree Cleanliness
|
|
||||||
|
|
||||||
The earlier NapCat half-implementation draft was explicitly abandoned before
|
|
||||||
this spec was committed. Future implementation work should start from a clean
|
|
||||||
API worktree, apart from committed design and planning documents. If new local
|
|
||||||
changes appear before implementation starts, inspect and classify them before
|
|
||||||
editing.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
The first implementation plan is ready when it can produce these outcomes in
|
|
||||||
order:
|
|
||||||
|
|
||||||
1. API has a runtime foundation skeleton with typed config, health, evidence,
|
|
||||||
and classified error primitives.
|
|
||||||
2. Jenkins/K8s/ktWorkflow can observe a deployment using the shared evidence
|
|
||||||
shape.
|
|
||||||
3. NapCat login and container runtime become the first real adapter sample.
|
|
||||||
4. Online verification proves rollout, health, and at least one real runtime
|
|
||||||
smoke path.
|
|
||||||
5. The next API refactor phase can start from module boundaries rather than
|
|
||||||
re-solving runtime reliability.
|
|
||||||
@ -1,112 +0,0 @@
|
|||||||
# QQBot NapCat 登录与设备持久化设计
|
|
||||||
|
|
||||||
## 背景
|
|
||||||
|
|
||||||
当前 QQBot 的 NapCat 登录链路存在两个耦合问题:
|
|
||||||
|
|
||||||
- Docker 重建容器时只持久化 `/app/.config/QQ`,没有固定 MAC、hostname 和 `/etc/machine-id`,QQ 侧容易把同一账号识别成新设备。
|
|
||||||
- 密码登录或验证码登录返回 `needNewDevice` 后,后端只把 `jumpUrl` 透给 Admin,没有按 NapCat 上游流程继续执行新设备二维码获取、轮询和确认登录。
|
|
||||||
|
|
||||||
上游依据:
|
|
||||||
|
|
||||||
- NapCat Docker 官方 compose 固定 `mac_address`,并挂载 QQ 数据目录到 `/app/.config/QQ`。
|
|
||||||
- NapCat 文档说明 Docker 环境中的 QQ 数据目录位于 `/app/.config/QQ`。
|
|
||||||
- NapCat 源码中 Linux GUID 与 `/etc/machine-id` 和 MAC 相关。
|
|
||||||
- NapCat WebUI 的新设备流程为 `GetNewDeviceQRCode -> PollNewDeviceQR -> NewDeviceLogin`。
|
|
||||||
|
|
||||||
## 目标
|
|
||||||
|
|
||||||
本次改动以一次完整闭环为目标:
|
|
||||||
|
|
||||||
- 重建 NapCat 容器时保持稳定设备身份。
|
|
||||||
- 将快速登录、密码登录、验证码、新设备验证收敛为后端登录状态机。
|
|
||||||
- SSE 向 Admin 展示每一步中文进度和必要操作,不暴露内部 token。
|
|
||||||
- 线上完成一次当前账号的实际登录链路验证。
|
|
||||||
|
|
||||||
## 非目标
|
|
||||||
|
|
||||||
- 不绕过 QQ 安全验证,不做验证码自动求解。
|
|
||||||
- 不删除现有 QQ 登录数据,除非用户明确执行重置登录态。
|
|
||||||
- 不把账号在线状态和容器在线状态合并为同一个状态字段。
|
|
||||||
- 不新增独立 NapCat 部署方式。
|
|
||||||
|
|
||||||
## 运行时设备持久化
|
|
||||||
|
|
||||||
每个托管 NapCat 容器的数据目录维护以下持久文件:
|
|
||||||
|
|
||||||
- `$DATA_DIR/QQ`:继续挂载到 `/app/.config/QQ`。
|
|
||||||
- `$DATA_DIR/device.env`:保存 `NAPCAT_MAC_ADDRESS` 和 `NAPCAT_HOSTNAME`。
|
|
||||||
- `$DATA_DIR/machine-id`:挂载到 `/etc/machine-id:ro`。
|
|
||||||
|
|
||||||
容器创建或重建时:
|
|
||||||
|
|
||||||
1. 如果同名旧容器仍存在,优先读取旧容器的 MAC、hostname 和 `/etc/machine-id`。
|
|
||||||
2. 如果 `$DATA_DIR/device.env` 或 `$DATA_DIR/machine-id` 已存在,优先复用持久文件。
|
|
||||||
3. 如果没有可复用值,则基于容器名生成稳定的本地管理 MAC、hostname 和 32 位 machine-id。
|
|
||||||
4. `docker run` 固定 `--mac-address`、`--hostname`,并挂载 machine-id 文件。
|
|
||||||
|
|
||||||
重置登录态只清理 `$DATA_DIR/QQ` 下的登录数据,不删除 `device.env` 和 `machine-id`。
|
|
||||||
|
|
||||||
## 登录状态机
|
|
||||||
|
|
||||||
后端登录链路按固定顺序推进:
|
|
||||||
|
|
||||||
1. 快速登录:有历史会话时优先使用 `ACCOUNT/-q`。失败后进入密码登录,不清设备数据。
|
|
||||||
2. 密码登录:使用账号密码计算 MD5,调用 NapCat `PasswordLogin`。
|
|
||||||
3. 验证码阶段:`needCaptcha` 时 SSE 推送验证码 URL,Admin 提交用户完成验证后的 `ticket/randstr/sid`。
|
|
||||||
4. 新设备阶段:`needNewDevice` 时后端保存 `jumpUrl` 和 `newDevicePullQrCodeSig`,随后调用 `GetNewDeviceQRCode`。
|
|
||||||
5. 新设备轮询:后端使用 `PollNewDeviceQR` 轮询扫码状态。
|
|
||||||
6. 新设备确认:扫码确认后调用 `NewDeviceLogin`,成功后检查登录态、清理临时密码环境、绑定账号。
|
|
||||||
|
|
||||||
如果 `NewDeviceLogin` 再次返回 `needNewDevice`,状态机重新进入新设备阶段。
|
|
||||||
|
|
||||||
## SSE 与 Admin 表现
|
|
||||||
|
|
||||||
SSE 保留最近事件,刷新页面可以恢复当前阶段。Admin 只展示用户需要知道的状态:
|
|
||||||
|
|
||||||
- 正在尝试快速登录
|
|
||||||
- 快速登录失败,尝试密码登录
|
|
||||||
- 密码登录需要 QQ 安全验证
|
|
||||||
- QQ 需要新设备验证
|
|
||||||
- 新设备二维码已生成
|
|
||||||
- 已扫码,等待确认
|
|
||||||
- 新设备确认中
|
|
||||||
- 登录成功
|
|
||||||
- 登录失败及原因
|
|
||||||
|
|
||||||
Admin 不直接展示或保存 NapCat 内部轮询 token。二维码继续复用现有扫码区域展示。
|
|
||||||
|
|
||||||
## 错误处理
|
|
||||||
|
|
||||||
- 密码环境清理失败必须阻断成功,不允许把登录成功和清理失败混在一起。
|
|
||||||
- 普通 WebSocket close 不清空已有明确下线原因。
|
|
||||||
- `isOnline:false` 只能表示账号离线,不能表示容器离线。
|
|
||||||
- 新设备二维码过期时保持 session pending,并允许刷新当前阶段二维码。
|
|
||||||
- 重建容器失败时保留旧 QQ 数据目录和设备持久文件,错误写入账号或容器状态。
|
|
||||||
|
|
||||||
## 验证计划
|
|
||||||
|
|
||||||
后端单测:
|
|
||||||
|
|
||||||
- Docker 创建脚本包含稳定 `device.env`、`machine-id`、`--mac-address`、`--hostname` 和 QQ 数据挂载。
|
|
||||||
- 登录服务覆盖 `CaptchaLogin -> needNewDevice -> GetNewDeviceQRCode -> PollNewDeviceQR -> NewDeviceLogin`。
|
|
||||||
- 新设备阶段刷新状态不会误判登录成功,也不会提前清理密码环境。
|
|
||||||
|
|
||||||
本地验证:
|
|
||||||
|
|
||||||
- 运行相关 Jest 单测。
|
|
||||||
- 运行类型检查。
|
|
||||||
|
|
||||||
线上验证:
|
|
||||||
|
|
||||||
- 推送后观察 Jenkins、K8s rollout、Pod 日志。
|
|
||||||
- 当前账号执行更新登录,确认 SSE 进度包含验证码和新设备阶段。
|
|
||||||
- 重建同一 NapCat 容器后检查 MAC、hostname、machine-id、QQ 数据目录仍复用同一持久值。
|
|
||||||
- 登录成功后确认容器环境不再残留临时 `NAPCAT_QUICK_PASSWORD`。
|
|
||||||
|
|
||||||
## 完成标准
|
|
||||||
|
|
||||||
- 代码、测试、文档和线上验证证据完整。
|
|
||||||
- 线上账号完成一次可用登录闭环。
|
|
||||||
- 不再因普通容器重建导致同一账号反复被识别为新设备。
|
|
||||||
- KT workflow 记录本次稳定问题点和解决方案。
|
|
||||||
@ -18,7 +18,6 @@ import { AdminModule } from './admin/admin.module';
|
|||||||
import { BlogModule } from './blog/blog.module';
|
import { BlogModule } from './blog/blog.module';
|
||||||
import { WordpressModule } from './wordpress/wordpress.module';
|
import { WordpressModule } from './wordpress/wordpress.module';
|
||||||
import { QqbotModule } from './qqbot/qqbot.module';
|
import { QqbotModule } from './qqbot/qqbot.module';
|
||||||
import { RuntimeModule } from './runtime';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -65,7 +64,6 @@ import { RuntimeModule } from './runtime';
|
|||||||
}),
|
}),
|
||||||
MinioClientModule,
|
MinioClientModule,
|
||||||
CommonModule,
|
CommonModule,
|
||||||
RuntimeModule,
|
|
||||||
AdminModule,
|
AdminModule,
|
||||||
BlogModule,
|
BlogModule,
|
||||||
WordpressModule,
|
WordpressModule,
|
||||||
|
|||||||
@ -258,19 +258,6 @@ function applyOperationResponseExamples(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRuntimeHealthPath(path)) {
|
|
||||||
const plainResponse = buildPlainJsonResponse(
|
|
||||||
runtimeHealthExample(),
|
|
||||||
'API 运行时健康检查',
|
|
||||||
);
|
|
||||||
operation.responses['200'] = mergeJsonResponse(
|
|
||||||
operation.responses['200'],
|
|
||||||
plainResponse,
|
|
||||||
);
|
|
||||||
applyErrorResponses(operation);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataExample = getOperationDataExample(path, method, operation);
|
const dataExample = getOperationDataExample(path, method, operation);
|
||||||
const successSchema = createOperationSuccessSchema(
|
const successSchema = createOperationSuccessSchema(
|
||||||
document,
|
document,
|
||||||
@ -326,24 +313,6 @@ function buildSuccessResponse(dataExample: any, schema: SwaggerSchema) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPlainJsonResponse(example: any, description: string) {
|
|
||||||
return {
|
|
||||||
description,
|
|
||||||
content: {
|
|
||||||
'application/json': {
|
|
||||||
schema: schemaFromExample(example),
|
|
||||||
example,
|
|
||||||
examples: {
|
|
||||||
success: {
|
|
||||||
summary: '成功响应',
|
|
||||||
value: example,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildErrorResponse(status: number, summary: string, message: string) {
|
function buildErrorResponse(status: number, summary: string, message: string) {
|
||||||
return {
|
return {
|
||||||
description: message,
|
description: message,
|
||||||
@ -736,10 +705,6 @@ function isBinaryResponsePath(path: string) {
|
|||||||
return path.includes('/download') || path.includes('/resource-proxy');
|
return path.includes('/download') || path.includes('/resource-proxy');
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRuntimeHealthPath(path: string) {
|
|
||||||
return path.toLowerCase() === '/health/runtime';
|
|
||||||
}
|
|
||||||
|
|
||||||
function itemExampleByPath(path: string) {
|
function itemExampleByPath(path: string) {
|
||||||
if (path.includes('/qqbot/account')) return qqbotAccountExample();
|
if (path.includes('/qqbot/account')) return qqbotAccountExample();
|
||||||
if (path.includes('/qqbot/command')) return qqbotCommandExample();
|
if (path.includes('/qqbot/command')) return qqbotCommandExample();
|
||||||
@ -981,28 +946,6 @@ function pluginHealthExample() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function runtimeHealthExample() {
|
|
||||||
return {
|
|
||||||
service: 'kt-template-online-api',
|
|
||||||
checkedAt: '2026-06-13T00:00:00.000Z',
|
|
||||||
status: 'degraded',
|
|
||||||
checks: [
|
|
||||||
{
|
|
||||||
name: 'process',
|
|
||||||
status: 'live',
|
|
||||||
critical: true,
|
|
||||||
message: 'NestJS process is responding',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'config:QQBOT_NAPCAT_IMAGE',
|
|
||||||
status: 'degraded',
|
|
||||||
critical: false,
|
|
||||||
message: 'QQBOT_NAPCAT_IMAGE is not configured',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function wordpressArticleExample() {
|
function wordpressArticleExample() {
|
||||||
return {
|
return {
|
||||||
id: 1,
|
id: 1,
|
||||||
|
|||||||
@ -41,8 +41,7 @@ const swaggerGroups: SwaggerDocumentGroup[] = [
|
|||||||
path: 'api/wordpress',
|
path: 'api/wordpress',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
matcher: (path) =>
|
matcher: (path) => path === '/' || path.startsWith('/minio'),
|
||||||
path === '/' || path.startsWith('/minio') || path.startsWith('/health'),
|
|
||||||
name: '基础能力',
|
name: '基础能力',
|
||||||
path: 'api/basic',
|
path: 'api/basic',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,265 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { ToolsService } from '../../common';
|
|
||||||
import {
|
|
||||||
RuntimeAppConfig,
|
|
||||||
RuntimeConfigCheck,
|
|
||||||
RuntimeConfigCheckLevel,
|
|
||||||
RuntimeDatabaseConfig,
|
|
||||||
RuntimeLokiConfig,
|
|
||||||
RuntimeMinioConfig,
|
|
||||||
RuntimeQqbotConfig,
|
|
||||||
RuntimeSafeConfigSnapshot,
|
|
||||||
RuntimeWordpressConfig,
|
|
||||||
} from './runtime-config.types';
|
|
||||||
|
|
||||||
const REQUIRED_CONFIG_KEYS = [
|
|
||||||
'DB_HOST',
|
|
||||||
'DB_PORT',
|
|
||||||
'DB_USERNAME',
|
|
||||||
'DB_PASSWORD',
|
|
||||||
'DB_DATABASE',
|
|
||||||
'ADMIN_TOKEN_SECRET',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const OPTIONAL_CONFIG_CHECKS: ReadonlyArray<string | readonly string[]> = [
|
|
||||||
'MINIO_ENDPOINT',
|
|
||||||
'MINIO_PORT',
|
|
||||||
'MINIO_ACCESS_KEY',
|
|
||||||
'MINIO_SECRET_KEY',
|
|
||||||
'MINIO_BUCKET',
|
|
||||||
['LOKI_HOST', 'LOKI_URL'],
|
|
||||||
['LOKI_QUERY_HOST', 'LOKI_HOST', 'LOKI_URL'],
|
|
||||||
'LOKI_ENV',
|
|
||||||
'LOKI_HTTP_REQUEST_PUSH_ENABLED',
|
|
||||||
'LOKI_TENANT_ID',
|
|
||||||
'LOKI_USERNAME',
|
|
||||||
'LOKI_PASSWORD',
|
|
||||||
'LOKI_PUSH_ENDPOINT',
|
|
||||||
'LOKI_QUERY_ENDPOINT',
|
|
||||||
'LOKI_PUSH_TIMEOUT_MS',
|
|
||||||
'LOKI_QUERY_TIMEOUT_MS',
|
|
||||||
'LOKI_BATCH_INTERVAL_SECONDS',
|
|
||||||
'LOKI_BATCH_MAX_BUFFER_SIZE',
|
|
||||||
'WORDPRESS_BASE_URL',
|
|
||||||
'WORDPRESS_HOST_HEADER',
|
|
||||||
'WORDPRESS_ADMIN_USERNAME',
|
|
||||||
'WORDPRESS_ADMIN_PASSWORD',
|
|
||||||
'WORDPRESS_TIMEOUT_MS',
|
|
||||||
'WORDPRESS_LOGIN_TIMEOUT_MS',
|
|
||||||
'WORDPRESS_AVAILABILITY_TTL_MS',
|
|
||||||
'QQBOT_REVERSE_WS_PATH',
|
|
||||||
'QQBOT_REVERSE_WS_TOKEN',
|
|
||||||
'QQBOT_NAPCAT_ROOT',
|
|
||||||
'QQBOT_NAPCAT_IMAGE',
|
|
||||||
'QQBOT_NAPCAT_CONTAINER_MODE',
|
|
||||||
'QQBOT_NAPCAT_SSH_TARGET',
|
|
||||||
'QQBOT_NAPCAT_SSH_PORT',
|
|
||||||
'QQBOT_NAPCAT_SSH_KEY_PATH',
|
|
||||||
['QQBOT_NAPCAT_REVERSE_WS_URL', 'QQBOT_NAPCAT_REVERSE_WS_BASE'],
|
|
||||||
['NAPCAT_WEBUI_BASE_URL', 'QQBOT_NAPCAT_WEBUI_URL'],
|
|
||||||
['NAPCAT_WEBUI_TOKEN', 'QQBOT_NAPCAT_WEBUI_TOKEN'],
|
|
||||||
];
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class RuntimeConfigService {
|
|
||||||
constructor(
|
|
||||||
private readonly configService: ConfigService,
|
|
||||||
private readonly toolsService: ToolsService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
readAppProfile(): RuntimeAppConfig {
|
|
||||||
return {
|
|
||||||
nodeEnv: this.getString('NODE_ENV', 'development'),
|
|
||||||
port: 48085,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
readDatabaseProfile(): RuntimeDatabaseConfig {
|
|
||||||
return {
|
|
||||||
host: this.getString('DB_HOST'),
|
|
||||||
port: this.getPositiveNumber('DB_PORT', 3306),
|
|
||||||
database: this.getString('DB_DATABASE'),
|
|
||||||
username: this.getString('DB_USERNAME'),
|
|
||||||
synchronize: this.getBoolean('DB_SYNC', false),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
readLokiProfile(): RuntimeLokiConfig {
|
|
||||||
const host = this.getFirstString(['LOKI_HOST', 'LOKI_URL']);
|
|
||||||
const queryHost = this.getFirstString([
|
|
||||||
'LOKI_QUERY_HOST',
|
|
||||||
'LOKI_HOST',
|
|
||||||
'LOKI_URL',
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
transportEnabled: !!host,
|
|
||||||
httpRequestPushEnabled:
|
|
||||||
!!host && this.getBoolean('LOKI_HTTP_REQUEST_PUSH_ENABLED', true),
|
|
||||||
queryConfigured: !!queryHost,
|
|
||||||
host,
|
|
||||||
queryHost,
|
|
||||||
environment: this.getString(
|
|
||||||
'LOKI_ENV',
|
|
||||||
this.getString('NODE_ENV', 'development'),
|
|
||||||
),
|
|
||||||
tenantId: this.getString('LOKI_TENANT_ID'),
|
|
||||||
username: this.getString('LOKI_USERNAME'),
|
|
||||||
passwordConfigured: !!this.getString('LOKI_PASSWORD'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
readMinioProfile(): RuntimeMinioConfig {
|
|
||||||
return {
|
|
||||||
endpoint: this.getString('MINIO_ENDPOINT'),
|
|
||||||
port: this.getPositiveNumber('MINIO_PORT', 9000),
|
|
||||||
useSSL: false,
|
|
||||||
accessKey: this.maskSecret(this.configService.get('MINIO_ACCESS_KEY')),
|
|
||||||
bucket: this.getString('MINIO_BUCKET', 'kt-template-online'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
readWordpressProfile(): RuntimeWordpressConfig {
|
|
||||||
const timeoutMs = this.getPositiveNumber('WORDPRESS_TIMEOUT_MS', 15000);
|
|
||||||
|
|
||||||
return {
|
|
||||||
baseUrl: this.getString('WORDPRESS_BASE_URL'),
|
|
||||||
hostHeader: this.getString('WORDPRESS_HOST_HEADER'),
|
|
||||||
adminUsername: this.getString('WORDPRESS_ADMIN_USERNAME'),
|
|
||||||
passwordConfigured: !!this.getString('WORDPRESS_ADMIN_PASSWORD'),
|
|
||||||
timeoutMs,
|
|
||||||
loginTimeoutMs: this.getPositiveNumber(
|
|
||||||
'WORDPRESS_LOGIN_TIMEOUT_MS',
|
|
||||||
this.getPositiveNumber('WORDPRESS_TIMEOUT_MS', 3000),
|
|
||||||
),
|
|
||||||
availabilityTtlMs: this.getPositiveNumber(
|
|
||||||
'WORDPRESS_AVAILABILITY_TTL_MS',
|
|
||||||
60_000,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
readQqbotProfile(): RuntimeQqbotConfig {
|
|
||||||
return {
|
|
||||||
reverseWsPath: this.getString(
|
|
||||||
'QQBOT_REVERSE_WS_PATH',
|
|
||||||
'/qqbot/onebot/reverse',
|
|
||||||
),
|
|
||||||
reverseWsToken: this.maskSecret(
|
|
||||||
this.configService.get('QQBOT_REVERSE_WS_TOKEN'),
|
|
||||||
),
|
|
||||||
napcatRoot: this.getString(
|
|
||||||
'QQBOT_NAPCAT_ROOT',
|
|
||||||
'/vol1/docker/kt-qqbot/napcat-instances',
|
|
||||||
),
|
|
||||||
napcatImage: this.getString('QQBOT_NAPCAT_IMAGE'),
|
|
||||||
napcatContainerMode: this.getString('QQBOT_NAPCAT_CONTAINER_MODE'),
|
|
||||||
napcatSshTarget: this.getString('QQBOT_NAPCAT_SSH_TARGET', 'nas'),
|
|
||||||
napcatSshPort: this.getPositiveNumber('QQBOT_NAPCAT_SSH_PORT', 22),
|
|
||||||
napcatSshKeyPath: this.getString('QQBOT_NAPCAT_SSH_KEY_PATH'),
|
|
||||||
napcatReverseWsBase: this.getFirstString([
|
|
||||||
'QQBOT_NAPCAT_REVERSE_WS_URL',
|
|
||||||
'QQBOT_NAPCAT_REVERSE_WS_BASE',
|
|
||||||
]),
|
|
||||||
napcatWebuiBaseUrl: this.getFirstString([
|
|
||||||
'NAPCAT_WEBUI_BASE_URL',
|
|
||||||
'QQBOT_NAPCAT_WEBUI_URL',
|
|
||||||
]),
|
|
||||||
napcatWebuiToken: this.maskSecret(
|
|
||||||
this.getFirstString(['NAPCAT_WEBUI_TOKEN', 'QQBOT_NAPCAT_WEBUI_TOKEN']),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
getSafeSnapshot(): RuntimeSafeConfigSnapshot {
|
|
||||||
return {
|
|
||||||
app: this.readAppProfile(),
|
|
||||||
database: this.readDatabaseProfile(),
|
|
||||||
loki: this.readLokiProfile(),
|
|
||||||
minio: this.readMinioProfile(),
|
|
||||||
wordpress: this.readWordpressProfile(),
|
|
||||||
qqbot: this.readQqbotProfile(),
|
|
||||||
checks: this.getConfigChecks(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
getConfigChecks(): RuntimeConfigCheck[] {
|
|
||||||
return [
|
|
||||||
...REQUIRED_CONFIG_KEYS.map((key) => this.createCheck(key, 'required')),
|
|
||||||
...OPTIONAL_CONFIG_CHECKS.map((check) =>
|
|
||||||
typeof check === 'string'
|
|
||||||
? this.createCheck(check, 'optional')
|
|
||||||
: this.createAnyCheck([...check], 'optional'),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
maskSecret(value: unknown): string {
|
|
||||||
const text = this.toolsService.toSecretText(value);
|
|
||||||
if (!text) return '';
|
|
||||||
if (text.length <= 4) return '****';
|
|
||||||
return `${text.slice(0, 2)}***${text.slice(-2)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private createCheck(
|
|
||||||
key: string,
|
|
||||||
level: RuntimeConfigCheckLevel,
|
|
||||||
): RuntimeConfigCheck {
|
|
||||||
const value = this.configService.get(key);
|
|
||||||
const text = this.toolsService.toSecretText(value);
|
|
||||||
const present = !!text;
|
|
||||||
|
|
||||||
return {
|
|
||||||
key,
|
|
||||||
level,
|
|
||||||
present,
|
|
||||||
maskedValue: present ? this.maskSecret(value) : undefined,
|
|
||||||
message: present ? undefined : `${key} is not configured`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private createAnyCheck(
|
|
||||||
keys: string[],
|
|
||||||
level: RuntimeConfigCheckLevel,
|
|
||||||
): RuntimeConfigCheck {
|
|
||||||
const key = keys.join('|');
|
|
||||||
const value = this.getFirstString(keys);
|
|
||||||
const present = !!value;
|
|
||||||
|
|
||||||
return {
|
|
||||||
key,
|
|
||||||
level,
|
|
||||||
present,
|
|
||||||
maskedValue: present ? this.maskSecret(value) : undefined,
|
|
||||||
message: present ? undefined : `${key} is not configured`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private getString(key: string, fallback = '') {
|
|
||||||
const value = this.toolsService.toTrimmedString(this.configService.get(key));
|
|
||||||
return value || fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getFirstString(keys: string[], fallback = '') {
|
|
||||||
for (const key of keys) {
|
|
||||||
const value = this.getString(key);
|
|
||||||
if (value) return value;
|
|
||||||
}
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getPositiveNumber(key: string, fallback: number) {
|
|
||||||
return this.toolsService.toPositiveNumber(
|
|
||||||
this.configService.get<string | number>(key),
|
|
||||||
fallback,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getBoolean(key: string, fallback: boolean) {
|
|
||||||
return this.toolsService.normalizeBoolean(
|
|
||||||
this.configService.get<string | boolean | number>(key),
|
|
||||||
fallback,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,76 +0,0 @@
|
|||||||
export type RuntimeConfigCheckLevel = 'required' | 'optional';
|
|
||||||
|
|
||||||
export interface RuntimeConfigCheck {
|
|
||||||
key: string;
|
|
||||||
level: RuntimeConfigCheckLevel;
|
|
||||||
present: boolean;
|
|
||||||
maskedValue?: string;
|
|
||||||
message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RuntimeAppConfig {
|
|
||||||
nodeEnv: string;
|
|
||||||
port: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RuntimeDatabaseConfig {
|
|
||||||
host: string;
|
|
||||||
port: number;
|
|
||||||
database: string;
|
|
||||||
username: string;
|
|
||||||
synchronize: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RuntimeLokiConfig {
|
|
||||||
transportEnabled: boolean;
|
|
||||||
httpRequestPushEnabled: boolean;
|
|
||||||
queryConfigured: boolean;
|
|
||||||
host: string;
|
|
||||||
queryHost: string;
|
|
||||||
environment: string;
|
|
||||||
tenantId: string;
|
|
||||||
username: string;
|
|
||||||
passwordConfigured: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RuntimeMinioConfig {
|
|
||||||
endpoint: string;
|
|
||||||
port: number;
|
|
||||||
useSSL: boolean;
|
|
||||||
accessKey: string;
|
|
||||||
bucket: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RuntimeWordpressConfig {
|
|
||||||
baseUrl: string;
|
|
||||||
hostHeader: string;
|
|
||||||
adminUsername: string;
|
|
||||||
passwordConfigured: boolean;
|
|
||||||
timeoutMs: number;
|
|
||||||
loginTimeoutMs: number;
|
|
||||||
availabilityTtlMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RuntimeQqbotConfig {
|
|
||||||
reverseWsPath: string;
|
|
||||||
reverseWsToken: string;
|
|
||||||
napcatRoot: string;
|
|
||||||
napcatImage: string;
|
|
||||||
napcatContainerMode: string;
|
|
||||||
napcatSshTarget: string;
|
|
||||||
napcatSshPort: number;
|
|
||||||
napcatSshKeyPath: string;
|
|
||||||
napcatReverseWsBase: string;
|
|
||||||
napcatWebuiBaseUrl: string;
|
|
||||||
napcatWebuiToken: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RuntimeSafeConfigSnapshot {
|
|
||||||
app: RuntimeAppConfig;
|
|
||||||
database: RuntimeDatabaseConfig;
|
|
||||||
loki: RuntimeLokiConfig;
|
|
||||||
minio: RuntimeMinioConfig;
|
|
||||||
wordpress: RuntimeWordpressConfig;
|
|
||||||
qqbot: RuntimeQqbotConfig;
|
|
||||||
checks: RuntimeConfigCheck[];
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
export type RuntimeErrorCategory =
|
|
||||||
| 'config_error'
|
|
||||||
| 'dependency_unavailable'
|
|
||||||
| 'operation_failed'
|
|
||||||
| 'cleanup_failed';
|
|
||||||
|
|
||||||
export interface RuntimeClassifiedError {
|
|
||||||
category: RuntimeErrorCategory;
|
|
||||||
operation: string;
|
|
||||||
message: string;
|
|
||||||
cause?: string;
|
|
||||||
retryable: boolean;
|
|
||||||
}
|
|
||||||
@ -1,112 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import {
|
|
||||||
RuntimeEvidenceInput,
|
|
||||||
RuntimeEvidenceRecord,
|
|
||||||
} from './runtime-evidence.types';
|
|
||||||
|
|
||||||
const REDACTED_VALUE = '<redacted>';
|
|
||||||
const REDACTED_BASE64_VALUE = '<redacted-base64>';
|
|
||||||
const SENSITIVE_KEY_PATTERN =
|
|
||||||
/password|secret|token|authorization|cookie|privatekey|sshkey|accesskey|apikey|ticket|randstr|replytext|base64/i;
|
|
||||||
const SENSITIVE_TEXT_KEY_PATTERN =
|
|
||||||
'(?:[A-Za-z0-9_-]*(?:password|secret|token|authorization|cookie|private[_-]?key|ssh[_-]?key|access[_-]?key|api[_-]?key|ticket|randstr|replyText|base64)[A-Za-z0-9_-]*|sid)';
|
|
||||||
const SENSITIVE_TEXT_REPLACEMENTS: Array<[RegExp, string]> = [
|
|
||||||
[
|
|
||||||
/data:[a-z0-9.+-]+\/[a-z0-9.+-]+;base64,[a-z0-9+/=\r\n]+/gi,
|
|
||||||
REDACTED_BASE64_VALUE,
|
|
||||||
],
|
|
||||||
[/\b[A-Za-z0-9+/]{120,}={0,2}\b/g, REDACTED_BASE64_VALUE],
|
|
||||||
[/\b(Authorization)\s*[:=]\s*[^\r\n]+/gi, '$1=<redacted>'],
|
|
||||||
[/\b(Cookie)\s*[:=]\s*[^\r\n]+/gi, '$1=<redacted>'],
|
|
||||||
[
|
|
||||||
/\b((?:private|ssh)[_-]?key)(\s*[:=]\s*)-----BEGIN[\s\S]*?-----END [^-]+-----/gi,
|
|
||||||
'$1$2<redacted>',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/\b((?:private|ssh)[_-]?key)(\s*[:=]\s*)-----BEGIN[\s\S]*?(?=\s+[A-Za-z0-9_-]+\s*[:=]|\s*$)/gi,
|
|
||||||
'$1$2<redacted>',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
new RegExp(
|
|
||||||
`\\b(${SENSITIVE_TEXT_KEY_PATTERN})(\\s*[:=]\\s*)Bearer\\s+[^\\s,;&]+`,
|
|
||||||
'gi',
|
|
||||||
),
|
|
||||||
'$1$2<redacted>',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
new RegExp(
|
|
||||||
`(["'])(${SENSITIVE_TEXT_KEY_PATTERN})\\1\\s*:\\s*(?:(["'])[^"']*\\3|[-+]?\\d+(?:\\.\\d+)?|true|false|null)`,
|
|
||||||
'gi',
|
|
||||||
),
|
|
||||||
'$1$2$1:"<redacted>"',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
new RegExp(
|
|
||||||
`\\b(${SENSITIVE_TEXT_KEY_PATTERN})(\\s*[:=]\\s*)(["'])[^"']*\\3`,
|
|
||||||
'gi',
|
|
||||||
),
|
|
||||||
'$1$2$3<redacted>$3',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
new RegExp(
|
|
||||||
`\\b(${SENSITIVE_TEXT_KEY_PATTERN})(\\s*[:=]\\s*)[^\\s,;&]+`,
|
|
||||||
'gi',
|
|
||||||
),
|
|
||||||
'$1$2<redacted>',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class RuntimeEvidenceService {
|
|
||||||
createRecord(input: RuntimeEvidenceInput): RuntimeEvidenceRecord {
|
|
||||||
const startedAt = input.startedAt ?? new Date();
|
|
||||||
const endedAt = input.endedAt ?? new Date();
|
|
||||||
const record: RuntimeEvidenceRecord = {
|
|
||||||
...input,
|
|
||||||
startedAt,
|
|
||||||
endedAt,
|
|
||||||
durationMs: Math.max(0, endedAt.getTime() - startedAt.getTime()),
|
|
||||||
schemaVersion: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
return this.sanitizeValue(record) as RuntimeEvidenceRecord;
|
|
||||||
}
|
|
||||||
|
|
||||||
private sanitizeValue(value: unknown): unknown {
|
|
||||||
if (value instanceof Date) return value;
|
|
||||||
if (typeof value === 'string') return this.sanitizeText(value);
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return value.map((item) => this.sanitizeValue(item));
|
|
||||||
}
|
|
||||||
if (this.isPlainRecord(value)) {
|
|
||||||
return Object.fromEntries(
|
|
||||||
Object.entries(value).map(([key, entry]) => [
|
|
||||||
key,
|
|
||||||
this.isSensitiveKey(key) ? REDACTED_VALUE : this.sanitizeValue(entry),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private sanitizeText(value: string) {
|
|
||||||
return SENSITIVE_TEXT_REPLACEMENTS.reduce(
|
|
||||||
(text, [pattern, replacement]) => text.replace(pattern, replacement),
|
|
||||||
value,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return (
|
|
||||||
typeof value === 'object' &&
|
|
||||||
value !== null &&
|
|
||||||
!Array.isArray(value) &&
|
|
||||||
!(value instanceof Date)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private isSensitiveKey(key: string) {
|
|
||||||
const normalizedKey = key.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
|
|
||||||
return normalizedKey === 'sid' || SENSITIVE_KEY_PATTERN.test(normalizedKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
import type { RuntimeClassifiedError } from '../errors/runtime-error.types';
|
|
||||||
|
|
||||||
export type RuntimeEvidenceStatus =
|
|
||||||
| 'passed'
|
|
||||||
| 'failed'
|
|
||||||
| 'blocked'
|
|
||||||
| 'skipped';
|
|
||||||
|
|
||||||
export interface RuntimeEvidenceCleanupResult {
|
|
||||||
status: RuntimeEvidenceStatus;
|
|
||||||
message: string;
|
|
||||||
details?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RuntimeEvidenceAssertion {
|
|
||||||
name: string;
|
|
||||||
passed: boolean;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RuntimeEvidenceInput {
|
|
||||||
title: string;
|
|
||||||
taskType: string;
|
|
||||||
project: string;
|
|
||||||
environment: string;
|
|
||||||
operation: string;
|
|
||||||
target?: string;
|
|
||||||
status: RuntimeEvidenceStatus;
|
|
||||||
startedAt?: Date;
|
|
||||||
endedAt?: Date;
|
|
||||||
details?: Record<string, unknown>;
|
|
||||||
assertions?: RuntimeEvidenceAssertion[];
|
|
||||||
cleanup?: RuntimeEvidenceCleanupResult;
|
|
||||||
error?: RuntimeClassifiedError;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RuntimeEvidenceRecord extends RuntimeEvidenceInput {
|
|
||||||
startedAt: Date;
|
|
||||||
endedAt: Date;
|
|
||||||
durationMs: number;
|
|
||||||
schemaVersion: 1;
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
|
||||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
|
||||||
import { RuntimeHealthService } from './runtime-health.service';
|
|
||||||
import type { RuntimeHealthReport } from './runtime-health.types';
|
|
||||||
|
|
||||||
@ApiTags('Runtime Health')
|
|
||||||
@Controller('health')
|
|
||||||
export class RuntimeHealthController {
|
|
||||||
constructor(private readonly runtimeHealthService: RuntimeHealthService) {}
|
|
||||||
|
|
||||||
@Get('runtime')
|
|
||||||
@ApiOperation({ summary: 'Get machine-readable API runtime health' })
|
|
||||||
getRuntimeHealth(): RuntimeHealthReport {
|
|
||||||
return this.runtimeHealthService.getRuntimeHealth();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { RuntimeConfigService } from '../config/runtime-config.service';
|
|
||||||
import {
|
|
||||||
RuntimeHealthCheck,
|
|
||||||
RuntimeHealthReport,
|
|
||||||
RuntimeHealthStatus,
|
|
||||||
} from './runtime-health.types';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class RuntimeHealthService {
|
|
||||||
constructor(private readonly runtimeConfigService: RuntimeConfigService) {}
|
|
||||||
|
|
||||||
getRuntimeHealth(): RuntimeHealthReport {
|
|
||||||
const config = this.runtimeConfigService.getSafeSnapshot();
|
|
||||||
const checks: RuntimeHealthCheck[] = [
|
|
||||||
{
|
|
||||||
name: 'process',
|
|
||||||
status: 'live',
|
|
||||||
critical: true,
|
|
||||||
message: 'NestJS process answered runtime health request',
|
|
||||||
},
|
|
||||||
...config.checks.map((check) => ({
|
|
||||||
name: `config:${check.key}`,
|
|
||||||
status: this.getConfigCheckStatus(check.present, check.level),
|
|
||||||
critical: check.level === 'required',
|
|
||||||
message: check.present
|
|
||||||
? `${check.key} is configured`
|
|
||||||
: check.message ?? `${check.key} is not configured`,
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
service: 'kt-template-online-api',
|
|
||||||
checkedAt: new Date().toISOString(),
|
|
||||||
status: this.aggregateStatus(checks),
|
|
||||||
checks,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private getConfigCheckStatus(
|
|
||||||
present: boolean,
|
|
||||||
level: 'required' | 'optional',
|
|
||||||
): RuntimeHealthStatus {
|
|
||||||
if (present) return 'ready';
|
|
||||||
return level === 'required' ? 'blocked' : 'degraded';
|
|
||||||
}
|
|
||||||
|
|
||||||
private aggregateStatus(checks: RuntimeHealthCheck[]): RuntimeHealthStatus {
|
|
||||||
if (checks.some((check) => check.critical && check.status === 'blocked')) {
|
|
||||||
return 'blocked';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checks.some((check) => check.status === 'degraded')) {
|
|
||||||
return 'degraded';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checks.every((check) => check.status === 'live')) {
|
|
||||||
return 'live';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'ready';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
export type RuntimeHealthStatus = 'live' | 'ready' | 'degraded' | 'blocked';
|
|
||||||
|
|
||||||
export interface RuntimeHealthCheck {
|
|
||||||
name: string;
|
|
||||||
status: RuntimeHealthStatus;
|
|
||||||
critical: boolean;
|
|
||||||
message: string;
|
|
||||||
detail?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RuntimeHealthReport {
|
|
||||||
service: 'kt-template-online-api';
|
|
||||||
checkedAt: string;
|
|
||||||
status: RuntimeHealthStatus;
|
|
||||||
checks: RuntimeHealthCheck[];
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
export * from './config/runtime-config.service';
|
|
||||||
export * from './config/runtime-config.types';
|
|
||||||
export * from './errors/runtime-error.types';
|
|
||||||
export * from './evidence/runtime-evidence.service';
|
|
||||||
export * from './evidence/runtime-evidence.types';
|
|
||||||
export * from './health/runtime-health.controller';
|
|
||||||
export * from './health/runtime-health.service';
|
|
||||||
export * from './health/runtime-health.types';
|
|
||||||
export * from './runtime.module';
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { ConfigModule } from '@nestjs/config';
|
|
||||||
import { CommonModule } from '../common';
|
|
||||||
import { RuntimeConfigService } from './config/runtime-config.service';
|
|
||||||
import { RuntimeEvidenceService } from './evidence/runtime-evidence.service';
|
|
||||||
import { RuntimeHealthController } from './health/runtime-health.controller';
|
|
||||||
import { RuntimeHealthService } from './health/runtime-health.service';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [ConfigModule, CommonModule],
|
|
||||||
controllers: [RuntimeHealthController],
|
|
||||||
providers: [
|
|
||||||
RuntimeConfigService,
|
|
||||||
RuntimeEvidenceService,
|
|
||||||
RuntimeHealthService,
|
|
||||||
],
|
|
||||||
exports: [RuntimeConfigService, RuntimeEvidenceService, RuntimeHealthService],
|
|
||||||
})
|
|
||||||
export class RuntimeModule {}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
import type { OpenAPIObject } from '@nestjs/swagger';
|
|
||||||
import { applySwaggerResponseExamples } from '../../src/common/swagger/swagger-response';
|
|
||||||
|
|
||||||
describe('applySwaggerResponseExamples', () => {
|
|
||||||
it('keeps runtime health swagger response as plain JSON instead of Vben success wrapper', () => {
|
|
||||||
const document = {
|
|
||||||
openapi: '3.0.0',
|
|
||||||
info: {
|
|
||||||
title: 'KT API',
|
|
||||||
version: 'test',
|
|
||||||
},
|
|
||||||
paths: {
|
|
||||||
'/health/runtime': {
|
|
||||||
get: {
|
|
||||||
summary: 'Get machine-readable API runtime health',
|
|
||||||
responses: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as unknown as OpenAPIObject;
|
|
||||||
|
|
||||||
applySwaggerResponseExamples(document);
|
|
||||||
|
|
||||||
const operation = (document.paths['/health/runtime'] as any).get;
|
|
||||||
const jsonContent = operation.responses['200'].content['application/json'];
|
|
||||||
|
|
||||||
expect(jsonContent.example).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
service: 'kt-template-online-api',
|
|
||||||
status: 'degraded',
|
|
||||||
checks: expect.any(Array),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(jsonContent.example).not.toHaveProperty('code');
|
|
||||||
expect(jsonContent.example).not.toHaveProperty('msg');
|
|
||||||
expect(jsonContent.example).not.toHaveProperty('data');
|
|
||||||
expect(jsonContent.example).not.toHaveProperty('config');
|
|
||||||
expect(jsonContent.schema).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
type: 'object',
|
|
||||||
properties: expect.objectContaining({
|
|
||||||
service: expect.any(Object),
|
|
||||||
checks: expect.any(Object),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,201 +0,0 @@
|
|||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { ToolsService } from '../../src/common';
|
|
||||||
import { RuntimeConfigService } from '../../src/runtime/config/runtime-config.service';
|
|
||||||
|
|
||||||
function createService(values: Record<string, unknown>) {
|
|
||||||
const configService = {
|
|
||||||
get: jest.fn((key: string) => values[key]),
|
|
||||||
} as unknown as ConfigService;
|
|
||||||
|
|
||||||
return new RuntimeConfigService(configService, new ToolsService());
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('RuntimeConfigService', () => {
|
|
||||||
it('parses app and database profiles with stable defaults', () => {
|
|
||||||
const service = createService({
|
|
||||||
DB_HOST: '127.0.0.1',
|
|
||||||
DB_PORT: '3307',
|
|
||||||
DB_DATABASE: 'kt',
|
|
||||||
DB_USERNAME: 'admin',
|
|
||||||
DB_SYNC: 'true',
|
|
||||||
NODE_ENV: 'test',
|
|
||||||
PORT: '12345',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(service.readAppProfile()).toEqual({
|
|
||||||
nodeEnv: 'test',
|
|
||||||
port: 48085,
|
|
||||||
});
|
|
||||||
expect(service.readDatabaseProfile()).toEqual({
|
|
||||||
host: '127.0.0.1',
|
|
||||||
port: 3307,
|
|
||||||
database: 'kt',
|
|
||||||
username: 'admin',
|
|
||||||
synchronize: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('masks secrets in checks and snapshots', () => {
|
|
||||||
const service = createService({
|
|
||||||
ADMIN_TOKEN_SECRET: 'abcdef123456',
|
|
||||||
DB_HOST: 'mysql',
|
|
||||||
DB_PORT: '3306',
|
|
||||||
DB_USERNAME: 'root',
|
|
||||||
DB_PASSWORD: 'password-value',
|
|
||||||
DB_DATABASE: 'kt',
|
|
||||||
MINIO_ACCESS_KEY: 'minio-access-key',
|
|
||||||
MINIO_USE_SSL: 'true',
|
|
||||||
WORDPRESS_ADMIN_USERNAME: 'wordpress-user',
|
|
||||||
WORDPRESS_ADMIN_PASSWORD: 'wordpress-password',
|
|
||||||
LOKI_PASSWORD: 'loki-password',
|
|
||||||
QQBOT_REVERSE_WS_TOKEN: 'qq-reverse-token',
|
|
||||||
NAPCAT_WEBUI_TOKEN: 'napcat-webui-token',
|
|
||||||
});
|
|
||||||
|
|
||||||
const snapshot = service.getSafeSnapshot();
|
|
||||||
const snapshotJson = JSON.stringify(snapshot);
|
|
||||||
const adminSecretCheck = snapshot.checks.find(
|
|
||||||
(check) => check.key === 'ADMIN_TOKEN_SECRET',
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(service.maskSecret('abcdef123456')).toBe('ab***56');
|
|
||||||
expect(service.maskSecret('')).toBe('');
|
|
||||||
expect(service.maskSecret('abcd')).toBe('****');
|
|
||||||
expect(adminSecretCheck).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
present: true,
|
|
||||||
maskedValue: 'ab***56',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(snapshotJson).not.toContain('abcdef123456');
|
|
||||||
expect(snapshotJson).not.toContain('password-value');
|
|
||||||
expect(snapshotJson).not.toContain('minio-access-key');
|
|
||||||
expect(snapshotJson).not.toContain('wordpress-password');
|
|
||||||
expect(snapshotJson).not.toContain('loki-password');
|
|
||||||
expect(snapshotJson).not.toContain('qq-reverse-token');
|
|
||||||
expect(snapshotJson).not.toContain('napcat-webui-token');
|
|
||||||
expect(snapshot.minio.accessKey).toBe('mi***ey');
|
|
||||||
expect(snapshot.minio.useSSL).toBe(false);
|
|
||||||
expect(snapshot.wordpress.adminUsername).toBe('wordpress-user');
|
|
||||||
expect(snapshot.wordpress.passwordConfigured).toBe(true);
|
|
||||||
expect(snapshot.loki.passwordConfigured).toBe(true);
|
|
||||||
expect(snapshot.qqbot.reverseWsToken).toBe('qq***en');
|
|
||||||
expect(snapshot.qqbot.napcatWebuiToken).toBe('na***en');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reads current WordPress, Loki, and NapCat runtime keys without leaking secrets', () => {
|
|
||||||
const service = createService({
|
|
||||||
WORDPRESS_BASE_URL: 'https://blog.example.test',
|
|
||||||
WORDPRESS_HOST_HEADER: 'blog.example.test',
|
|
||||||
WORDPRESS_ADMIN_USERNAME: 'wordpress-admin',
|
|
||||||
WORDPRESS_ADMIN_PASSWORD: 'wordpress-password',
|
|
||||||
WORDPRESS_TIMEOUT_MS: '16000',
|
|
||||||
WORDPRESS_LOGIN_TIMEOUT_MS: '4000',
|
|
||||||
WORDPRESS_AVAILABILITY_TTL_MS: '70000',
|
|
||||||
LOKI_URL: 'https://loki-push.example.test',
|
|
||||||
LOKI_QUERY_HOST: 'https://loki-query.example.test',
|
|
||||||
LOKI_ENV: 'production',
|
|
||||||
LOKI_HTTP_REQUEST_PUSH_ENABLED: 'false',
|
|
||||||
LOKI_USERNAME: 'loki-user',
|
|
||||||
LOKI_PASSWORD: 'loki-password',
|
|
||||||
QQBOT_NAPCAT_ROOT: '/vol1/docker/napcat',
|
|
||||||
QQBOT_NAPCAT_IMAGE: 'mlikiowa/napcat-docker:latest',
|
|
||||||
QQBOT_NAPCAT_CONTAINER_MODE: 'ssh',
|
|
||||||
QQBOT_NAPCAT_SSH_TARGET: 'nas',
|
|
||||||
QQBOT_NAPCAT_SSH_PORT: '2202',
|
|
||||||
QQBOT_NAPCAT_SSH_KEY_PATH: '/home/kt/.ssh/napcat',
|
|
||||||
QQBOT_NAPCAT_REVERSE_WS_BASE: 'ws://api.example.test/onebot',
|
|
||||||
QQBOT_REVERSE_WS_PATH: '/qqbot/reverse',
|
|
||||||
QQBOT_REVERSE_WS_TOKEN: 'qq-reverse-token',
|
|
||||||
NAPCAT_WEBUI_BASE_URL: 'http://127.0.0.1:6099',
|
|
||||||
NAPCAT_WEBUI_TOKEN: 'napcat-webui-token',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(service.readWordpressProfile()).toEqual({
|
|
||||||
baseUrl: 'https://blog.example.test',
|
|
||||||
hostHeader: 'blog.example.test',
|
|
||||||
adminUsername: 'wordpress-admin',
|
|
||||||
passwordConfigured: true,
|
|
||||||
timeoutMs: 16000,
|
|
||||||
loginTimeoutMs: 4000,
|
|
||||||
availabilityTtlMs: 70000,
|
|
||||||
});
|
|
||||||
expect(service.readLokiProfile()).toEqual({
|
|
||||||
transportEnabled: true,
|
|
||||||
httpRequestPushEnabled: false,
|
|
||||||
queryConfigured: true,
|
|
||||||
host: 'https://loki-push.example.test',
|
|
||||||
queryHost: 'https://loki-query.example.test',
|
|
||||||
environment: 'production',
|
|
||||||
tenantId: '',
|
|
||||||
username: 'loki-user',
|
|
||||||
passwordConfigured: true,
|
|
||||||
});
|
|
||||||
expect(service.readQqbotProfile()).toEqual({
|
|
||||||
reverseWsPath: '/qqbot/reverse',
|
|
||||||
reverseWsToken: 'qq***en',
|
|
||||||
napcatRoot: '/vol1/docker/napcat',
|
|
||||||
napcatImage: 'mlikiowa/napcat-docker:latest',
|
|
||||||
napcatContainerMode: 'ssh',
|
|
||||||
napcatSshTarget: 'nas',
|
|
||||||
napcatSshPort: 2202,
|
|
||||||
napcatSshKeyPath: '/home/kt/.ssh/napcat',
|
|
||||||
napcatReverseWsBase: 'ws://api.example.test/onebot',
|
|
||||||
napcatWebuiBaseUrl: 'http://127.0.0.1:6099',
|
|
||||||
napcatWebuiToken: 'na***en',
|
|
||||||
});
|
|
||||||
|
|
||||||
const checks = service.getConfigChecks();
|
|
||||||
expect(checks).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
key: 'LOKI_HOST|LOKI_URL',
|
|
||||||
level: 'optional',
|
|
||||||
present: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(checks).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
key: 'QQBOT_NAPCAT_IMAGE',
|
|
||||||
level: 'optional',
|
|
||||||
present: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(checks).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
key: 'QQBOT_NAPCAT_REVERSE_WS_URL|QQBOT_NAPCAT_REVERSE_WS_BASE',
|
|
||||||
level: 'optional',
|
|
||||||
present: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(checks).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
key: 'NAPCAT_WEBUI_BASE_URL|QQBOT_NAPCAT_WEBUI_URL',
|
|
||||||
level: 'optional',
|
|
||||||
present: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const snapshotJson = JSON.stringify(service.getSafeSnapshot());
|
|
||||||
expect(snapshotJson).not.toContain('wordpress-password');
|
|
||||||
expect(snapshotJson).not.toContain('loki-password');
|
|
||||||
expect(snapshotJson).not.toContain('qq-reverse-token');
|
|
||||||
expect(snapshotJson).not.toContain('napcat-webui-token');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('marks missing required config as absent', () => {
|
|
||||||
const service = createService({
|
|
||||||
DB_HOST: 'mysql',
|
|
||||||
});
|
|
||||||
|
|
||||||
const checks = service.getConfigChecks();
|
|
||||||
|
|
||||||
expect(checks).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
key: 'DB_PASSWORD',
|
|
||||||
level: 'required',
|
|
||||||
present: false,
|
|
||||||
message: 'DB_PASSWORD is not configured',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,314 +0,0 @@
|
|||||||
import { RuntimeEvidenceService } from '../../src/runtime/evidence/runtime-evidence.service';
|
|
||||||
|
|
||||||
describe('RuntimeEvidenceService', () => {
|
|
||||||
it('creates records with explicit timing, duration, and schema version', () => {
|
|
||||||
const service = new RuntimeEvidenceService();
|
|
||||||
const startedAt = new Date('2026-06-13T00:00:00.000Z');
|
|
||||||
const endedAt = new Date('2026-06-13T00:00:01.250Z');
|
|
||||||
|
|
||||||
const record = service.createRecord({
|
|
||||||
title: 'runtime config smoke',
|
|
||||||
taskType: 'backend',
|
|
||||||
project: 'kt-template-online-api',
|
|
||||||
environment: 'local',
|
|
||||||
operation: 'runtime-config',
|
|
||||||
status: 'passed',
|
|
||||||
startedAt,
|
|
||||||
endedAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(record.startedAt).toBe(startedAt);
|
|
||||||
expect(record.endedAt).toBe(endedAt);
|
|
||||||
expect(record.durationMs).toBe(1250);
|
|
||||||
expect(record.schemaVersion).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps duration non-negative when evidence ends before it starts', () => {
|
|
||||||
const service = new RuntimeEvidenceService();
|
|
||||||
const startedAt = new Date('2026-06-13T00:00:01.250Z');
|
|
||||||
const endedAt = new Date('2026-06-13T00:00:00.000Z');
|
|
||||||
|
|
||||||
const record = service.createRecord({
|
|
||||||
title: 'runtime config smoke',
|
|
||||||
taskType: 'backend',
|
|
||||||
project: 'kt-template-online-api',
|
|
||||||
environment: 'local',
|
|
||||||
operation: 'runtime-config',
|
|
||||||
status: 'failed',
|
|
||||||
startedAt,
|
|
||||||
endedAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(record.durationMs).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('redacts nested sensitive fields in details and cleanup details', () => {
|
|
||||||
const service = new RuntimeEvidenceService();
|
|
||||||
|
|
||||||
const record = service.createRecord({
|
|
||||||
title: 'qqbot captcha smoke',
|
|
||||||
taskType: 'api',
|
|
||||||
project: 'kt-template-online-api',
|
|
||||||
environment: 'local',
|
|
||||||
operation: 'qqbot-login',
|
|
||||||
target: 'account:2637330537',
|
|
||||||
status: 'blocked',
|
|
||||||
details: {
|
|
||||||
username: 'kept-user',
|
|
||||||
password: 1,
|
|
||||||
nested: {
|
|
||||||
token: true,
|
|
||||||
message: 'kept-message',
|
|
||||||
replyText: 'raw-reply-text',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
cleanup: {
|
|
||||||
status: 'failed',
|
|
||||||
message: 'cleanup failed',
|
|
||||||
details: {
|
|
||||||
ticket: 'raw-ticket',
|
|
||||||
randstr: 'raw-randstr',
|
|
||||||
removed: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(record.details).toEqual({
|
|
||||||
username: 'kept-user',
|
|
||||||
password: '<redacted>',
|
|
||||||
nested: {
|
|
||||||
token: '<redacted>',
|
|
||||||
message: 'kept-message',
|
|
||||||
replyText: '<redacted>',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(record.cleanup?.details).toEqual({
|
|
||||||
ticket: '<redacted>',
|
|
||||||
randstr: '<redacted>',
|
|
||||||
removed: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sanitizes arrays and case-insensitive sensitive keys while preserving dates', () => {
|
|
||||||
const service = new RuntimeEvidenceService();
|
|
||||||
const createdAt = new Date('2026-06-13T00:00:00.000Z');
|
|
||||||
|
|
||||||
const record = service.createRecord({
|
|
||||||
title: 'array evidence',
|
|
||||||
taskType: 'backend',
|
|
||||||
project: 'kt-template-online-api',
|
|
||||||
environment: 'local',
|
|
||||||
operation: 'runtime-evidence',
|
|
||||||
status: 'failed',
|
|
||||||
details: {
|
|
||||||
events: [
|
|
||||||
{ Authorization: 'Bearer raw', name: 'kept' },
|
|
||||||
{ nested: { Cookie: 'session=raw', count: 2 } },
|
|
||||||
],
|
|
||||||
createdAt,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const details = record.details as {
|
|
||||||
events: Array<Record<string, unknown>>;
|
|
||||||
createdAt: Date;
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(details.createdAt).toBe(createdAt);
|
|
||||||
expect(details.createdAt).toBeInstanceOf(Date);
|
|
||||||
expect(details.events).toHaveLength(2);
|
|
||||||
expect(details.events[0]).toEqual({
|
|
||||||
Authorization: '<redacted>',
|
|
||||||
name: 'kept',
|
|
||||||
});
|
|
||||||
expect(details.events[1]).toEqual({
|
|
||||||
nested: {
|
|
||||||
Cookie: '<redacted>',
|
|
||||||
count: 2,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sanitizes sensitive key-value text across the whole evidence record without mutating input', () => {
|
|
||||||
const service = new RuntimeEvidenceService();
|
|
||||||
const input = {
|
|
||||||
title: 'safe title token=raw-token',
|
|
||||||
taskType: 'api',
|
|
||||||
project: 'kt-template-online-api',
|
|
||||||
environment: 'local',
|
|
||||||
operation: 'qqbot-login',
|
|
||||||
target: 'account safe text Authorization=Bearer raw-token',
|
|
||||||
status: 'failed' as const,
|
|
||||||
error: {
|
|
||||||
category: 'operation_failed' as const,
|
|
||||||
operation: 'captcha',
|
|
||||||
message: 'safe error message ticket=raw-ticket',
|
|
||||||
cause:
|
|
||||||
'safe cause randstr=raw-randstr Authorization: Bearer raw-token Cookie=session=raw-cookie',
|
|
||||||
retryable: false,
|
|
||||||
},
|
|
||||||
assertions: [
|
|
||||||
{
|
|
||||||
name: 'reply text check',
|
|
||||||
passed: false,
|
|
||||||
message: 'safe assertion replyText=raw-reply-text',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
cleanup: {
|
|
||||||
status: 'failed' as const,
|
|
||||||
message: 'safe cleanup Cookie=session=raw-cookie',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const record = service.createRecord(input);
|
|
||||||
const serialized = JSON.stringify(record);
|
|
||||||
|
|
||||||
expect(serialized).not.toContain('raw-ticket');
|
|
||||||
expect(serialized).not.toContain('raw-randstr');
|
|
||||||
expect(serialized).not.toContain('raw-token');
|
|
||||||
expect(serialized).not.toContain('raw-cookie');
|
|
||||||
expect(serialized).not.toContain('raw-reply-text');
|
|
||||||
expect(serialized).toContain('safe title');
|
|
||||||
expect(serialized).toContain('safe error message');
|
|
||||||
expect(serialized).toContain('safe cause');
|
|
||||||
expect(serialized).toContain('safe assertion');
|
|
||||||
expect(serialized).toContain('safe cleanup');
|
|
||||||
expect(input.title).toContain('raw-token');
|
|
||||||
expect(input.error.message).toContain('raw-ticket');
|
|
||||||
expect(input.error.cause).toContain('raw-randstr');
|
|
||||||
expect(input.assertions[0].message).toContain('raw-reply-text');
|
|
||||||
expect(input.cleanup.message).toContain('raw-cookie');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('redacts captcha sid, snake_case secret keys, JSON secret text, and base64 payloads', () => {
|
|
||||||
const service = new RuntimeEvidenceService();
|
|
||||||
const rawBase64Payload = `data:image/png;base64,${'A'.repeat(160)}`;
|
|
||||||
|
|
||||||
const record = service.createRecord({
|
|
||||||
title: 'captcha evidence',
|
|
||||||
taskType: 'api',
|
|
||||||
project: 'kt-template-online-api',
|
|
||||||
environment: 'local',
|
|
||||||
operation: 'qqbot-login',
|
|
||||||
status: 'blocked',
|
|
||||||
details: {
|
|
||||||
sid: 'raw-captcha-sid',
|
|
||||||
private_key: 'raw-private-key',
|
|
||||||
ssh_key: 'raw-ssh-key',
|
|
||||||
imageBase64: rawBase64Payload,
|
|
||||||
text: 'sid=raw-text-sid private_key=raw-text-private-key ssh_key=raw-text-ssh-key',
|
|
||||||
jsonText:
|
|
||||||
'{"sid":"raw-json-sid","token":"raw-json-token","safe":"kept"}',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const serialized = JSON.stringify(record);
|
|
||||||
|
|
||||||
expect(serialized).not.toContain('raw-captcha-sid');
|
|
||||||
expect(serialized).not.toContain('raw-private-key');
|
|
||||||
expect(serialized).not.toContain('raw-ssh-key');
|
|
||||||
expect(serialized).not.toContain('raw-text-sid');
|
|
||||||
expect(serialized).not.toContain('raw-text-private-key');
|
|
||||||
expect(serialized).not.toContain('raw-text-ssh-key');
|
|
||||||
expect(serialized).not.toContain('raw-json-sid');
|
|
||||||
expect(serialized).not.toContain('raw-json-token');
|
|
||||||
expect(serialized).not.toContain(rawBase64Payload);
|
|
||||||
expect(serialized).toContain('captcha evidence');
|
|
||||||
expect(serialized).toContain('kept');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('redacts compound token and secret text keys while preserving session identifiers', () => {
|
|
||||||
const service = new RuntimeEvidenceService();
|
|
||||||
|
|
||||||
const record = service.createRecord({
|
|
||||||
title: 'compound token evidence',
|
|
||||||
taskType: 'api',
|
|
||||||
project: 'kt-template-online-api',
|
|
||||||
environment: 'local',
|
|
||||||
operation: 'admin-login',
|
|
||||||
target: 'sessionId=KT_SCAN_SAFE',
|
|
||||||
status: 'failed',
|
|
||||||
details: {
|
|
||||||
sessionId: 'KT_SCAN_SAFE',
|
|
||||||
text:
|
|
||||||
'accessToken=raw-access-token refreshToken=raw-refresh-token access_token=raw-snake-token client_secret=raw-client-secret sessionId=KT_SCAN_SAFE',
|
|
||||||
jsonText:
|
|
||||||
'{"accessToken":"raw-json-access","refreshToken":"raw-json-refresh","access_token":"raw-json-snake","client_secret":"raw-json-client-secret","sessionId":"KT_SCAN_SAFE"}',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const serialized = JSON.stringify(record);
|
|
||||||
|
|
||||||
expect(serialized).not.toContain('raw-access-token');
|
|
||||||
expect(serialized).not.toContain('raw-refresh-token');
|
|
||||||
expect(serialized).not.toContain('raw-snake-token');
|
|
||||||
expect(serialized).not.toContain('raw-client-secret');
|
|
||||||
expect(serialized).not.toContain('raw-json-access');
|
|
||||||
expect(serialized).not.toContain('raw-json-refresh');
|
|
||||||
expect(serialized).not.toContain('raw-json-snake');
|
|
||||||
expect(serialized).not.toContain('raw-json-client-secret');
|
|
||||||
expect(serialized).toContain('KT_SCAN_SAFE');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('redacts cookie headers, quoted private key values, and non-string JSON secrets', () => {
|
|
||||||
const service = new RuntimeEvidenceService();
|
|
||||||
|
|
||||||
const record = service.createRecord({
|
|
||||||
title: 'realistic secret text evidence',
|
|
||||||
taskType: 'api',
|
|
||||||
project: 'kt-template-online-api',
|
|
||||||
environment: 'local',
|
|
||||||
operation: 'runtime-evidence',
|
|
||||||
status: 'failed',
|
|
||||||
details: {
|
|
||||||
cookieHeader:
|
|
||||||
'Cookie: admin_access_token=raw-cookie-token; wordpress_logged_in=raw-wordpress-cookie; theme=light',
|
|
||||||
quotedPrivateKey:
|
|
||||||
'private_key="-----BEGIN PRIVATE KEY----- raw pem body -----END PRIVATE KEY-----"',
|
|
||||||
jsonText:
|
|
||||||
'{"accessToken":12345,"sid":67890,"client_secret":true,"sessionId":"KT_SCAN_SAFE"}',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const serialized = JSON.stringify(record);
|
|
||||||
|
|
||||||
expect(serialized).not.toContain('raw-cookie-token');
|
|
||||||
expect(serialized).not.toContain('raw-wordpress-cookie');
|
|
||||||
expect(serialized).not.toContain('raw pem body');
|
|
||||||
expect(serialized).not.toContain('12345');
|
|
||||||
expect(serialized).not.toContain('67890');
|
|
||||||
expect(serialized).toContain('KT_SCAN_SAFE');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('redacts access key and api key text plus unquoted multi-word secret values', () => {
|
|
||||||
const service = new RuntimeEvidenceService();
|
|
||||||
|
|
||||||
const record = service.createRecord({
|
|
||||||
title: 'access key evidence',
|
|
||||||
taskType: 'backend',
|
|
||||||
project: 'kt-template-online-api',
|
|
||||||
environment: 'local',
|
|
||||||
operation: 'runtime-evidence',
|
|
||||||
status: 'failed',
|
|
||||||
details: {
|
|
||||||
text:
|
|
||||||
'accessKey=raw-access-key access_key=raw-snake-access-key apiKey=raw-api-key api_key=raw-snake-api-key private_key=-----BEGIN PRIVATE KEY----- raw unquoted pem token=Bearer raw-bearer-token safe=value',
|
|
||||||
jsonText:
|
|
||||||
'{"accessKey":"raw-json-access-key","api_key":"raw-json-api-key","safe":"kept"}',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const serialized = JSON.stringify(record);
|
|
||||||
|
|
||||||
expect(serialized).not.toContain('raw-access-key');
|
|
||||||
expect(serialized).not.toContain('raw-snake-access-key');
|
|
||||||
expect(serialized).not.toContain('raw-api-key');
|
|
||||||
expect(serialized).not.toContain('raw-snake-api-key');
|
|
||||||
expect(serialized).not.toContain('raw unquoted pem');
|
|
||||||
expect(serialized).not.toContain('raw-bearer-token');
|
|
||||||
expect(serialized).not.toContain('raw-json-access-key');
|
|
||||||
expect(serialized).not.toContain('raw-json-api-key');
|
|
||||||
expect(serialized).toContain('safe=value');
|
|
||||||
expect(serialized).toContain('kept');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
import { RuntimeHealthController } from '../../src/runtime/health/runtime-health.controller';
|
|
||||||
import type { RuntimeHealthReport } from '../../src/runtime/health/runtime-health.types';
|
|
||||||
|
|
||||||
describe('RuntimeHealthController', () => {
|
|
||||||
it('returns the service report and calls the service once', () => {
|
|
||||||
const report: RuntimeHealthReport = {
|
|
||||||
service: 'kt-template-online-api',
|
|
||||||
checkedAt: '2026-06-13T00:00:00.000Z',
|
|
||||||
status: 'ready',
|
|
||||||
checks: [],
|
|
||||||
};
|
|
||||||
const service = { getRuntimeHealth: jest.fn(() => report) };
|
|
||||||
const controller = new RuntimeHealthController(service as any);
|
|
||||||
|
|
||||||
expect(controller.getRuntimeHealth()).toBe(report);
|
|
||||||
expect(service.getRuntimeHealth).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,162 +0,0 @@
|
|||||||
import { RuntimeHealthService } from '../../src/runtime/health/runtime-health.service';
|
|
||||||
import type { RuntimeSafeConfigSnapshot } from '../../src/runtime/config/runtime-config.types';
|
|
||||||
|
|
||||||
function createSnapshot(
|
|
||||||
checks: RuntimeSafeConfigSnapshot['checks'],
|
|
||||||
): RuntimeSafeConfigSnapshot {
|
|
||||||
return {
|
|
||||||
app: { nodeEnv: 'test', port: 48085 },
|
|
||||||
database: {
|
|
||||||
host: 'mysql',
|
|
||||||
port: 3306,
|
|
||||||
database: 'kt',
|
|
||||||
username: 'root',
|
|
||||||
synchronize: false,
|
|
||||||
},
|
|
||||||
loki: {
|
|
||||||
transportEnabled: true,
|
|
||||||
httpRequestPushEnabled: true,
|
|
||||||
queryConfigured: true,
|
|
||||||
host: 'https://loki-push.example.test',
|
|
||||||
queryHost: 'https://loki-query.example.test',
|
|
||||||
environment: 'test',
|
|
||||||
tenantId: 'kt',
|
|
||||||
username: 'loki-user',
|
|
||||||
passwordConfigured: true,
|
|
||||||
},
|
|
||||||
minio: {
|
|
||||||
endpoint: 'minio',
|
|
||||||
port: 9000,
|
|
||||||
useSSL: false,
|
|
||||||
accessKey: 'mi***ey',
|
|
||||||
bucket: 'kt-template-online',
|
|
||||||
},
|
|
||||||
wordpress: {
|
|
||||||
baseUrl: 'https://blog.example.test',
|
|
||||||
hostHeader: 'blog.example.test',
|
|
||||||
adminUsername: 'wordpress-admin',
|
|
||||||
passwordConfigured: true,
|
|
||||||
timeoutMs: 15000,
|
|
||||||
loginTimeoutMs: 3000,
|
|
||||||
availabilityTtlMs: 60000,
|
|
||||||
},
|
|
||||||
qqbot: {
|
|
||||||
reverseWsPath: '/qqbot/onebot/reverse',
|
|
||||||
reverseWsToken: 'qq***en',
|
|
||||||
napcatRoot: '/vol1/docker/napcat',
|
|
||||||
napcatImage: 'mlikiowa/napcat-docker:latest',
|
|
||||||
napcatContainerMode: 'ssh',
|
|
||||||
napcatSshTarget: 'nas',
|
|
||||||
napcatSshPort: 2202,
|
|
||||||
napcatSshKeyPath: '/home/kt/.ssh/napcat',
|
|
||||||
napcatReverseWsBase: 'ws://api.example.test/onebot',
|
|
||||||
napcatWebuiBaseUrl: 'http://127.0.0.1:6099',
|
|
||||||
napcatWebuiToken: 'na***en',
|
|
||||||
},
|
|
||||||
checks,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createService(snapshot: RuntimeSafeConfigSnapshot) {
|
|
||||||
return new RuntimeHealthService({
|
|
||||||
getSafeSnapshot: jest.fn(() => snapshot),
|
|
||||||
} as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('RuntimeHealthService', () => {
|
|
||||||
it('returns ready when required and optional checks are present', () => {
|
|
||||||
const service = createService(
|
|
||||||
createSnapshot([
|
|
||||||
{ key: 'DB_HOST', level: 'required', present: true },
|
|
||||||
{ key: 'MINIO_ENDPOINT', level: 'optional', present: true },
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const report = service.getRuntimeHealth();
|
|
||||||
|
|
||||||
expect(report).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
service: 'kt-template-online-api',
|
|
||||||
status: 'ready',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(report).not.toHaveProperty('config');
|
|
||||||
expect(JSON.stringify(report)).not.toContain('mysql');
|
|
||||||
expect(JSON.stringify(report)).not.toContain('/vol1/docker/napcat');
|
|
||||||
expect(new Date(report.checkedAt).toISOString()).toBe(report.checkedAt);
|
|
||||||
expect(report.checks).toContainEqual({
|
|
||||||
name: 'process',
|
|
||||||
status: 'live',
|
|
||||||
critical: true,
|
|
||||||
message: 'NestJS process answered runtime health request',
|
|
||||||
});
|
|
||||||
expect(report.checks).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
name: 'config:DB_HOST',
|
|
||||||
status: 'ready',
|
|
||||||
critical: true,
|
|
||||||
message: 'DB_HOST is configured',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(report.checks).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
name: 'config:MINIO_ENDPOINT',
|
|
||||||
status: 'ready',
|
|
||||||
critical: false,
|
|
||||||
message: 'MINIO_ENDPOINT is configured',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns blocked when required config is missing', () => {
|
|
||||||
const service = createService(
|
|
||||||
createSnapshot([
|
|
||||||
{
|
|
||||||
key: 'DB_PASSWORD',
|
|
||||||
level: 'required',
|
|
||||||
present: false,
|
|
||||||
message: 'DB_PASSWORD is not configured',
|
|
||||||
},
|
|
||||||
{ key: 'MINIO_ENDPOINT', level: 'optional', present: true },
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const report = service.getRuntimeHealth();
|
|
||||||
|
|
||||||
expect(report.status).toBe('blocked');
|
|
||||||
expect(report.checks).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
name: 'config:DB_PASSWORD',
|
|
||||||
status: 'blocked',
|
|
||||||
critical: true,
|
|
||||||
message: 'DB_PASSWORD is not configured',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns degraded when only optional config is missing', () => {
|
|
||||||
const service = createService(
|
|
||||||
createSnapshot([
|
|
||||||
{ key: 'DB_HOST', level: 'required', present: true },
|
|
||||||
{
|
|
||||||
key: 'LOKI_PASSWORD',
|
|
||||||
level: 'optional',
|
|
||||||
present: false,
|
|
||||||
message: 'LOKI_PASSWORD is not configured',
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const report = service.getRuntimeHealth();
|
|
||||||
|
|
||||||
expect(report.status).toBe('degraded');
|
|
||||||
expect(report.checks).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
name: 'config:LOKI_PASSWORD',
|
|
||||||
status: 'degraded',
|
|
||||||
critical: false,
|
|
||||||
message: 'LOKI_PASSWORD is not configured',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Loading…
Reference in New Issue
Block a user