feat: 测试交付 Q1 混沌压测骨架与 Q4 镜像骨架

This commit is contained in:
Nixevol
2026-09-30 06:59:44 +08:00
parent 0e068c6ade
commit 8dde564517
21 changed files with 1268 additions and 16 deletions
+19
View File
@@ -0,0 +1,19 @@
# 加快构建,并避免把本机 node_modules(Windows)盖掉镜像内安装结果
.git
.cursor
aidocs
bin
**/node_modules
web/dist
**/*.exe
**/*.test
**/*.db
**/*.db-shm
**/*.db-wal
data
config.yaml
*.pem
*.key
*.crt
.env
.env.*
+51 -13
View File
@@ -3,36 +3,74 @@ package main
import (
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"git.asio.asia/nixevol/NixMsg/internal/config"
)
// healthcheck 请求本机 /healthz,成功退出 0。给 Docker HEALTHCHECK 用。
func cmdHealthcheck(_ []string) error {
cfg, err := loadAndValidateConfig()
cfgPath := config.PathFromEnv()
cfg, err := config.Load(cfgPath)
if err != nil {
// 无配置时仍尝试 data_dir 默认值下的 listen.addr
cfg = config.Default()
}
addr, err := resolveHealthAddr(cfg.DataDir, cfg.Listen)
if err != nil {
return err
}
addrBytes, err := os.ReadFile(filepath.Join(cfg.DataDir, "listen.addr"))
if err != nil {
return fmt.Errorf("read listen.addr: %w", err)
}
addr := strings.TrimSpace(string(addrBytes))
if addr == "" {
return fmt.Errorf("listen.addr is empty")
}
url := "http://" + addr + "/healthz"
client := &http.Client{Timeout: 5 * time.Second}
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Get(url)
if err != nil {
return err
return fmt.Errorf("healthcheck %s: %w", url, err)
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
_, _ = io.Copy(io.Discard, resp.Body)
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("healthz status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
return fmt.Errorf("healthcheck %s: status %d", url, resp.StatusCode)
}
return nil
}
func resolveHealthAddr(dataDir, listen string) (string, error) {
path := filepath.Join(dataDir, "listen.addr")
if b, err := os.ReadFile(path); err == nil {
addr := strings.TrimSpace(string(b))
if addr != "" {
return rewriteLoopback(addr), nil
}
}
if listen == "" {
return "", fmt.Errorf("no listen.addr and empty listen")
}
host, port, err := net.SplitHostPort(listen)
if err != nil {
// listen 可能是 ":7443"
if strings.HasPrefix(listen, ":") {
return "127.0.0.1" + listen, nil
}
return "", fmt.Errorf("parse listen %q: %w", listen, err)
}
if host == "" || host == "0.0.0.0" || host == "::" {
host = "127.0.0.1"
}
return net.JoinHostPort(host, port), nil
}
func rewriteLoopback(addr string) string {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return addr
}
if host == "" || host == "0.0.0.0" || host == "::" {
host = "127.0.0.1"
}
return net.JoinHostPort(host, port)
}
+56
View File
@@ -0,0 +1,56 @@
package main
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
)
func TestResolveHealthAddrFromListenAddrFile(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "listen.addr"), []byte("0.0.0.0:19100\n"), 0o644); err != nil {
t.Fatal(err)
}
got, err := resolveHealthAddr(dir, ":7443")
if err != nil {
t.Fatal(err)
}
if got != "127.0.0.1:19100" {
t.Fatalf("got %q", got)
}
}
func TestResolveHealthAddrFromListen(t *testing.T) {
got, err := resolveHealthAddr(t.TempDir(), ":9555")
if err != nil {
t.Fatal(err)
}
if got != "127.0.0.1:9555" {
t.Fatalf("got %q", got)
}
}
func TestCmdHealthcheckOK(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}))
t.Cleanup(srv.Close)
dir := t.TempDir()
// httptest URL is like http://127.0.0.1:port — write host:port into listen.addr
hostPort := srv.Listener.Addr().String()
if err := os.WriteFile(filepath.Join(dir, "listen.addr"), []byte(hostPort+"\n"), 0o644); err != nil {
t.Fatal(err)
}
cfgPath := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(cfgPath, []byte("listen: \":0\"\ndata_dir: \""+filepath.ToSlash(dir)+"\"\n"), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("NIXMSG_CONFIG", cfgPath)
if err := cmdHealthcheck(nil); err != nil {
t.Fatal(err)
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
# 多阶段骨架:完整多架构发布见 Q4。
# 语法阶段:Node 构建前端 → Go 编译(嵌入前端)→ distroless 运行。
# 多阶段构建:Node 构建前端 → Go 编译(嵌入前端)→ distroless static nonroot。
# 镜像名 git.asio.asia/nixevol/nixmsg;多架构 buildx 推送留给 Q4 定稿,本骨架先单架构可构建。
FROM node:24-bookworm AS web
WORKDIR /src/web
+10
View File
@@ -0,0 +1,10 @@
# Compose / 容器内使用的配置示例。业务端口可按需改;测试请用随机端口,不要多实例抢 7443。
listen: ":7443"
admin_listen: ""
tls:
cert_file: ""
key_file: ""
allow_plaintext: true
data_dir: /data
log:
level: info
+31
View File
@@ -0,0 +1,31 @@
# Docker Compose 示例(DEVELOPMENT 第 11.4 节骨架)
# 本地冒烟:先 docker build -t git.asio.asia/nixevol/nixmsg:0.1.0 -f deploy/Dockerfile .
# 容器名带 q 前缀便于多 Agent 隔离;正式部署可去掉 container_name。
services:
nixmsg:
image: git.asio.asia/nixevol/nixmsg:0.1.0
container_name: q-nixmsg
restart: unless-stopped
command: ["serve"]
environment:
NIXMSG_CONFIG: /etc/nixmsg/config.yaml
ports:
- "7443:7443"
volumes:
- ./config.docker.yaml:/etc/nixmsg/config.yaml:ro
- q-nixmsg-data:/data
# 生产环境挂载证书目录,例如:
# - /opt/nixmsg/certs:/certs:ro
healthcheck:
test: ["CMD", "/nixmsg", "healthcheck"]
interval: 30s
timeout: 5s
retries: 3
volumes:
q-nixmsg-data:
name: q-nixmsg-data
# 首次使用命名卷时,需保证 /data 对 uid 65532 可写,例如:
# docker run --rm -v q-nixmsg-data:/data busybox chown -R 65532:65532 /data
+57 -1
View File
@@ -401,4 +401,60 @@
## 测试交付 Q
暂无。
### Q1 / Q4 骨架 2026-09-30
1. **压测客户端未做到 1000 连接 / 10 分钟**
- 原条款:TASKS Q1「1000 连接的压测客户端」;验证「能保持 1000 个连接」。
- 实际做法:`test/load` 提供 MQTT 3.1.1 CONNECT/CONNACK 骨架与 `mqttbench` 命令,单元测试用进程内假 broker 验证多连接与连接数打印;未对真实 broker 压到 1000,也未跑 10 分钟吞吐。
- 原因:本波只做 Q1 基础设施骨架;完整弱网/压测属 Q3,且当前 main 尚无完整 MQTT broker 业务。
- 备选方案:立刻接 mochi 假 broker 或外部 mosquitto 压到 1000。
- 影响:Q3 需补真实压测与报告数字。
2. **未做弱网压测与验收用例**
- 原条款:TASKS Q2/Q3;本波总控指示「不要做弱网压测和验收用例」。
- 实际做法:只交付 toxiproxy/netem 辅助、报告生成器与空结果样例;F01–F23 全部为未测。
- 原因:波次范围。
- 备选方案:无。
- 影响:交付标准第 2、4 条待后续波次。
3. **为 Docker 健康检查在 `cmd/nixmsg` 增加 `healthcheck`**
- 原条款:DEVELOPMENT 11.2/11.4;TASKS 第 5 节 `cmd/nixmsg` 属平台 P。
- 实际做法:Q 线最小实现 `nixmsg healthcheck`(读 `listen.addr` 或配置 `listen`,请求本机 `/healthz`),否则 compose 健康检查无法按文档工作。
- 原因:Q4 镜像骨架硬依赖该子命令;改动面小。
- 备选方案:等 P 实现后再写 compose;或健康检查改用外部 curl(与 distroless 无 shell/curl 冲突)。
- 影响:P 线后续可替换或扩展实现;注意勿重复注册同名命令。
4. **Docker 镜像仅当前架构骨架,不推送**
- 原条款:DEVELOPMENT 11.4 多架构 `buildx` 推送;TASKS Q4 验证两个架构。
- 实际做法:完善多阶段 Dockerfile + `deploy/docker-compose.yml`(镜像名 `git.asio.asia/nixevol/nixmsg`,健康检查 `/nixmsg healthcheck`);本机只 `docker build` 当前架构并冒烟 `/healthz`;不做 `docker push`、不做 arm64。
- 原因:总控本波只要骨架;推送在 Z3。
- 备选方案:本波强制 buildx 双架构(耗时长、非阻塞目标)。
- 影响:多架构与仓库推送留到 Q4 定稿 / 阶段 3。
5. **compose 示例端口仍写 7443**
- 原条款:测试隔离「不要写死 7443」;DEVELOPMENT 11.4 示例为 `7443:7443`。
- 实际做法:`deploy/docker-compose.yml` 与文档示例一致使用 7443;混沌辅助与压测工具通过参数传入上游地址,不写死;本地冒烟可用其它宿主机端口映射。
- 原因:部署示例需与 DEVELOPMENT 对齐;隔离约束针对并行测试而非产品默认端口。
- 备选方案:compose 用变量 `${NIXMSG_HOST_PORT:-7443}`。
- 影响:多 Agent 同时起官方 compose 会端口冲突,应改映射或错开项目名。
6. **toxiproxy 镜像与 netem 旁路镜像选型**
- 原条款:用 toxiproxy 官方镜像;Linux 丢包用 netem。
- 实际做法:`ghcr.io/shopify/toxiproxy:2.12.0`;netem 说明用 `nicolaka/netshoot` 挂脚本(需 `NET_ADMIN`)。
- 原因:官方镜像无 tc;本机 Windows 不能本机 netem。
- 备选方案:自建含 iproute2 的旁路镜像。
- 影响:首次拉取 netshoot 需网络;脚本不进业务镜像。
7. **增加根目录 `.dockerignore`**
- 原条款:未要求;Dockerfile 原为 `COPY web/` 在 `pnpm install` 之后。
- 实际做法:忽略 `**/node_modules`、`web/dist`、`bin` 等,避免本机 Windows 的 `node_modules` 覆盖 Linux 安装结果导致 `vue-tsc` 找不到。
- 原因:本机 `task check` 会生成 `web/node_modules`,直接进构建上下文会破坏前端阶段。
- 备选方案:Dockerfile 在 `COPY web/` 后再跑一次 `pnpm install`。
- 影响:镜像构建依赖 dockerignore;与任务目录无关的本地产物不再进上下文。
8. **命名卷首次需 chown 给 nonroot**
- 原条款:DEVELOPMENT 11.4「挂载的数据目录要可写」,uid 65532。
- 实际做法:冒烟前对命名卷执行 `chown -R 65532:65532 /data`;compose 未内置 init 容器。
- 原因:空命名卷属主为 root 时,distroless nonroot 无法建库(`unable to open database file`)。
- 备选方案:compose 增加一次性 init 服务;或文档要求宿主机目录预授权。
- 影响:按示例首次 `up` 前需处理权限,否则 serve 立即退出。
+27
View File
@@ -0,0 +1,27 @@
version: "3"
tasks:
q:chaos-test:
desc: 运行混沌辅助单元测试
cmds:
- go test ./test/chaos/ -count=1 -v
q:report:
desc: 从结果 JSON 生成 F01–F23 验收对照表(默认空样例)
cmds:
- go run ./test/report/cmd/genreport {{.CLI_ARGS}}
q:report-sample:
desc: 用空结果样例生成对照表到 stdout
cmds:
- go run ./test/report/cmd/genreport ./test/report/testdata/sample_results.json
q:load-test:
desc: 压测客户端骨架单元测试
cmds:
- go test ./test/load/ -count=1 -v
q:docker-build:
desc: 构建当前架构镜像(不推送)
cmds:
- docker build -t git.asio.asia/nixevol/nixmsg:0.1.0 -f deploy/Dockerfile .
+61
View File
@@ -0,0 +1,61 @@
# 混沌 / 弱网测试辅助(Q 线)
用 toxiproxy 官方镜像做延迟和断开;Linux 丢包用容器内 netem。所有 Docker 资源名带 `q` 前缀,用完删除。
## 启动 toxiproxy
在仓库根目录(或本目录)执行:
```powershell
docker compose -p q-chaos -f test/chaos/compose.yml up -d
```
API 默认映射到本机随机/固定端口见 compose 注释。查看实际端口:
```powershell
docker compose -p q-chaos -f test/chaos/compose.yml port toxiproxy 8474
```
清理:
```powershell
docker compose -p q-chaos -f test/chaos/compose.yml down -v
```
## 对运行中的服务注入故障
不要写死 `7443`。上游地址取自当前实例的实际监听端口(例如测试 harness 写入的 `listen.addr`,或你启动时打印的地址)。
1. 本机起好 NixMsg(或任意 TCP 服务),记下 `HOST:PORT`。
2. 在 Windows 上,容器访问本机服务用 `host.docker.internal:PORT`。
3. 用本包客户端或 curl 建代理(listen 用 `0.0.0.0:0` 让 toxiproxy 分配端口):
```go
c := chaos.NewClient("http://127.0.0.1:<api端口>")
p, err := c.CreateProxy("q-nixmsg", "0.0.0.0:0", "host.docker.internal:"+strconv.Itoa(port))
// 客户端改连 p.Listen(把 0.0.0.0 换成 127.0.0.1)
_, _ = c.AddLatency("q-nixmsg", "lag", 200, 50, "")
_, _ = c.AddResetPeer("q-nixmsg", "rst", 0, "")
```
等价 curl:
```bash
curl -s -X POST http://127.0.0.1:<api>/proxies \
-H 'Content-Type: application/json' \
-d '{"name":"q-nixmsg","listen":"0.0.0.0:0","upstream":"host.docker.internal:<PORT>","enabled":true}'
```
延迟:`POST .../proxies/q-nixmsg/toxics`,`type=latency`,`attributes.latency` / `jitter`(毫秒)。
断开:`type=reset_peer`,`attributes.timeout`(毫秒,0 表示尽快 RST)。
## 单元测试
```powershell
go test ./test/chaos/ -count=1
```
## netem 丢包
见 [netem/README.md](./netem/README.md)。脚本必须在 Linux 容器内执行(本机是 Windows)。
+15
View File
@@ -0,0 +1,15 @@
# toxiproxy:延迟与断开。项目名用 -p q-chaos,容器名带 q 前缀。
# API 与代理端口映射到本机,具体宿主机端口用 `docker compose port` 查询,不要写死业务端口。
services:
toxiproxy:
image: ghcr.io/shopify/toxiproxy:2.12.0
container_name: q-toxiproxy
# 8474=API;8475 起可手工映射代理端口,或在 CreateProxy 时用 0.0.0.0:0 再 docker port 查询
ports:
- "8474"
- "8475"
- "8476"
# 允许代理到本机服务(Docker Desktop / Windows)
extra_hosts:
- "host.docker.internal:host-gateway"
+192
View File
@@ -0,0 +1,192 @@
// Package chaos 提供基于 toxiproxy 的弱网故障注入辅助。
// 上游地址由调用方传入,不写死端口。
package chaos
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// Client 调用 toxiproxy HTTP API。
type Client struct {
BaseURL string
HTTPClient *http.Client
}
// NewClient 创建客户端。baseURL 形如 http://127.0.0.1:8474。
func NewClient(baseURL string) *Client {
return &Client{
BaseURL: strings.TrimRight(baseURL, "/"),
HTTPClient: &http.Client{
Timeout: 10 * time.Second,
},
}
}
// Proxy 描述一个 toxiproxy 代理。
type Proxy struct {
Name string `json:"name"`
Listen string `json:"listen"`
Upstream string `json:"upstream"`
Enabled bool `json:"enabled"`
}
// Toxic 描述一条故障规则。
type Toxic struct {
Name string `json:"name"`
Type string `json:"type"`
Stream string `json:"stream,omitempty"`
Toxicity float32 `json:"toxicity,omitempty"`
Attributes map[string]any `json:"attributes,omitempty"`
}
// CreateProxy 创建或覆盖同名代理。listen 可用 host:0 让 toxiproxy 分配端口。
func (c *Client) CreateProxy(name, listen, upstream string) (*Proxy, error) {
if name == "" {
return nil, fmt.Errorf("proxy name required")
}
if listen == "" {
return nil, fmt.Errorf("listen required")
}
if upstream == "" {
return nil, fmt.Errorf("upstream required")
}
body := Proxy{
Name: name,
Listen: listen,
Upstream: upstream,
Enabled: true,
}
var out Proxy
if err := c.doJSON(http.MethodPost, "/proxies", body, &out); err != nil {
return nil, err
}
return &out, nil
}
// DeleteProxy 删除代理;不存在时忽略。
func (c *Client) DeleteProxy(name string) error {
req, err := http.NewRequest(http.MethodDelete, c.BaseURL+"/proxies/"+name, nil)
if err != nil {
return err
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK {
return nil
}
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("delete proxy: %s: %s", resp.Status, strings.TrimSpace(string(b)))
}
// GetProxy 读取代理(含实际 listen 地址)。
func (c *Client) GetProxy(name string) (*Proxy, error) {
var out Proxy
if err := c.doJSON(http.MethodGet, "/proxies/"+name, nil, &out); err != nil {
return nil, err
}
return &out, nil
}
// AddLatency 注入下行/上行延迟(毫秒)。stream 为空时默认 downstream。
func (c *Client) AddLatency(proxyName, toxicName string, latencyMs, jitterMs int, stream string) (*Toxic, error) {
if stream == "" {
stream = "downstream"
}
t := Toxic{
Name: toxicName,
Type: "latency",
Stream: stream,
Toxicity: 1,
Attributes: map[string]any{
"latency": latencyMs,
"jitter": jitterMs,
},
}
return c.addToxic(proxyName, t)
}
// AddResetPeer 在连接上注入 TCP RST(断开)。timeoutMs 为触发前等待。
func (c *Client) AddResetPeer(proxyName, toxicName string, timeoutMs int, stream string) (*Toxic, error) {
if stream == "" {
stream = "downstream"
}
t := Toxic{
Name: toxicName,
Type: "reset_peer",
Stream: stream,
Toxicity: 1,
Attributes: map[string]any{
"timeout": timeoutMs,
},
}
return c.addToxic(proxyName, t)
}
// RemoveToxic 删除一条 toxic。
func (c *Client) RemoveToxic(proxyName, toxicName string) error {
req, err := http.NewRequest(http.MethodDelete, c.BaseURL+"/proxies/"+proxyName+"/toxics/"+toxicName, nil)
if err != nil {
return err
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK {
return nil
}
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("remove toxic: %s: %s", resp.Status, strings.TrimSpace(string(b)))
}
func (c *Client) addToxic(proxyName string, t Toxic) (*Toxic, error) {
var out Toxic
if err := c.doJSON(http.MethodPost, "/proxies/"+proxyName+"/toxics", t, &out); err != nil {
return nil, err
}
return &out, nil
}
func (c *Client) doJSON(method, path string, in any, out any) error {
var body io.Reader
if in != nil {
b, err := json.Marshal(in)
if err != nil {
return err
}
body = bytes.NewReader(b)
}
req, err := http.NewRequest(method, c.BaseURL+path, body)
if err != nil {
return err
}
if in != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("%s %s: %s: %s", method, path, resp.Status, strings.TrimSpace(string(respBody)))
}
if out == nil || len(respBody) == 0 {
return nil
}
return json.Unmarshal(respBody, out)
}
+113
View File
@@ -0,0 +1,113 @@
package chaos
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestClientCreateLatencyAndReset(t *testing.T) {
t.Parallel()
toxics := map[string][]Toxic{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/proxies":
var p Proxy
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
t.Errorf("decode proxy: %v", err)
http.Error(w, err.Error(), 400)
return
}
if p.Listen == "0.0.0.0:0" {
p.Listen = "0.0.0.0:18080"
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(p)
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/toxics"):
var toxic Toxic
if err := json.NewDecoder(r.Body).Decode(&toxic); err != nil {
http.Error(w, err.Error(), 400)
return
}
name := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/proxies/"), "/toxics")
toxics[name] = append(toxics[name], toxic)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(toxic)
case r.Method == http.MethodDelete:
w.WriteHeader(http.StatusNoContent)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(srv.Close)
c := NewClient(srv.URL)
p, err := c.CreateProxy("q-demo", "0.0.0.0:0", "127.0.0.1:19000")
if err != nil {
t.Fatalf("CreateProxy: %v", err)
}
if p.Upstream != "127.0.0.1:19000" {
t.Fatalf("upstream = %q", p.Upstream)
}
if p.Listen != "0.0.0.0:18080" {
t.Fatalf("listen = %q", p.Listen)
}
lat, err := c.AddLatency("q-demo", "lag", 200, 50, "")
if err != nil {
t.Fatalf("AddLatency: %v", err)
}
if lat.Type != "latency" {
t.Fatalf("type = %q", lat.Type)
}
if _, err := c.AddResetPeer("q-demo", "drop", 0, ""); err != nil {
t.Fatalf("AddResetPeer: %v", err)
}
if got := toxics["q-demo"]; len(got) != 2 {
t.Fatalf("toxics len = %d", len(got))
}
if got := toxics["q-demo"][0]; got.Type != "latency" {
t.Fatalf("first toxic type = %q", got.Type)
}
if err := c.RemoveToxic("q-demo", "lag"); err != nil {
t.Fatalf("RemoveToxic: %v", err)
}
if err := c.DeleteProxy("q-demo"); err != nil {
t.Fatalf("DeleteProxy: %v", err)
}
}
func TestClientRejectsEmptyFields(t *testing.T) {
t.Parallel()
c := NewClient("http://127.0.0.1:1")
if _, err := c.CreateProxy("", "0.0.0.0:0", "127.0.0.1:1"); err == nil {
t.Fatal("expected error for empty name")
}
if _, err := c.CreateProxy("x", "", "127.0.0.1:1"); err == nil {
t.Fatal("expected error for empty listen")
}
if _, err := c.CreateProxy("x", "0.0.0.0:0", ""); err == nil {
t.Fatal("expected error for empty upstream")
}
}
func TestCreateProxyErrorStatus(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusConflict)
_, _ = io.WriteString(w, "already exists")
}))
t.Cleanup(srv.Close)
c := NewClient(srv.URL)
if _, err := c.CreateProxy("q", "0.0.0.0:0", "127.0.0.1:9"); err == nil {
t.Fatal("expected error")
}
}
+31
View File
@@ -0,0 +1,31 @@
# Linux 容器内用 netem 做丢包(本机 Windows 不能直接跑 tc)
## 用法
对已经在同一网络里的目标容器网卡注入 20% 丢包(需要 `NET_ADMIN`):
```powershell
# 示例:起一个带 net-tools/iproute2 的旁路容器,对网卡 eth0 丢包
docker run --rm --name q-netem --cap-add=NET_ADMIN --network container:q-toxiproxy `
nicolaka/netshoot `
bash -lc "tc qdisc replace dev eth0 root netem loss 20%"
```
或把本目录脚本挂进去:
```powershell
docker run --rm --name q-netem --cap-add=NET_ADMIN --network container:<目标容器名> `
-v ${PWD}/test/chaos/netem:/scripts:ro `
nicolaka/netshoot `
bash /scripts/apply-loss.sh eth0 20
```
清除:
```powershell
docker run --rm --name q-netem-clear --cap-add=NET_ADMIN --network container:<目标容器名> `
nicolaka/netshoot `
bash -lc "tc qdisc del dev eth0 root 2>/dev/null || true"
```
用完删除容器(上面 `--rm` 已自动删)。不要把 NixMsg 业务端口写死进脚本。
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# 在 Linux 容器内执行:对指定网卡注入丢包。用法:apply-loss.sh <iface> <loss_percent>
set -euo pipefail
IFACE="${1:-eth0}"
LOSS="${2:-20}"
if ! command -v tc >/dev/null 2>&1; then
echo "tc not found; use an image with iproute2 (e.g. nicolaka/netshoot)" >&2
exit 1
fi
tc qdisc replace dev "$IFACE" root netem loss "${LOSS}%"
echo "netem: iface=${IFACE} loss=${LOSS}%"
tc qdisc show dev "$IFACE"
+40
View File
@@ -0,0 +1,40 @@
package main
import (
"flag"
"fmt"
"os"
"os/signal"
"syscall"
"time"
"git.asio.asia/nixevol/NixMsg/test/load"
)
// 压测客户端骨架:连上 N 个 MQTT 连接并打印连接数。完整 1000 连接 / 10 分钟压测留给 Q3。
func main() {
addr := flag.String("addr", "", "MQTT broker host:port(必填,不要写死业务端口)")
n := flag.Int("n", 10, "连接数")
prefix := flag.String("prefix", "q-bench-", "client id 前缀")
hold := flag.Duration("hold", 3*time.Second, "保持连接时长")
flag.Parse()
if *addr == "" {
fmt.Fprintln(os.Stderr, "usage: mqttbench -addr host:port [-n 10]")
os.Exit(2)
}
b := &load.MQTTBench{Addr: *addr, ClientIDPrefix: *prefix, Timeout: 5 * time.Second}
defer b.Close()
if err := b.ConnectN(*n, os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Printf("held %d connections for %s\n", b.Alive(), hold.String())
ch := make(chan os.Signal, 1)
signal.Notify(ch, os.Interrupt, syscall.SIGTERM)
select {
case <-time.After(*hold):
case <-ch:
}
}
+145
View File
@@ -0,0 +1,145 @@
// Package load 提供 MQTT 压测客户端骨架:先连上并统计连接数。
package load
import (
"encoding/binary"
"fmt"
"io"
"net"
"sync"
"sync/atomic"
"time"
)
// MQTTBench 保持多个 MQTT 3.1.1 TCP 连接(仅 CONNECT/CONNACK)。
type MQTTBench struct {
Addr string
ClientIDPrefix string
Timeout time.Duration
mu sync.Mutex
conns []net.Conn
alive atomic.Int64
}
// ConnectN 建立 n 条连接;成功一条 alive+1,并打印当前连接数到 w(可为 nil)。
func (b *MQTTBench) ConnectN(n int, w io.Writer) error {
if b.Addr == "" {
return fmt.Errorf("addr required")
}
if n <= 0 {
return fmt.Errorf("n must be > 0")
}
timeout := b.Timeout
if timeout <= 0 {
timeout = 5 * time.Second
}
prefix := b.ClientIDPrefix
if prefix == "" {
prefix = "q-bench-"
}
for i := 0; i < n; i++ {
conn, err := net.DialTimeout("tcp", b.Addr, timeout)
if err != nil {
return fmt.Errorf("dial %d: %w", i, err)
}
_ = conn.SetDeadline(time.Now().Add(timeout))
cid := fmt.Sprintf("%s%d", prefix, i)
if err := mqttConnect(conn, cid); err != nil {
_ = conn.Close()
return fmt.Errorf("connect %d: %w", i, err)
}
_ = conn.SetDeadline(time.Time{})
b.mu.Lock()
b.conns = append(b.conns, conn)
b.mu.Unlock()
cur := b.alive.Add(1)
if w != nil {
_, _ = fmt.Fprintf(w, "mqtt connections: %d\n", cur)
}
}
return nil
}
// Alive 当前仍打开的连接数。
func (b *MQTTBench) Alive() int64 {
return b.alive.Load()
}
// Close 关闭全部连接。
func (b *MQTTBench) Close() {
b.mu.Lock()
defer b.mu.Unlock()
for _, c := range b.conns {
_ = c.Close()
}
b.conns = nil
b.alive.Store(0)
}
func mqttConnect(conn net.Conn, clientID string) error {
pkt := buildConnect(clientID)
if _, err := conn.Write(pkt); err != nil {
return err
}
header := make([]byte, 4)
if _, err := io.ReadFull(conn, header[:2]); err != nil {
return err
}
if header[0] != 0x20 {
return fmt.Errorf("unexpected packet type 0x%02x", header[0])
}
// remaining length 对 CONNACK 固定为 2
if header[1] != 2 {
return fmt.Errorf("unexpected remaining length %d", header[1])
}
if _, err := io.ReadFull(conn, header[2:4]); err != nil {
return err
}
if header[3] != 0 {
return fmt.Errorf("CONNACK rc=%d", header[3])
}
return nil
}
func buildConnect(clientID string) []byte {
// Variable header: protocol name MQTT, level 4, flags 0, keepalive 60
vh := []byte{
0x00, 0x04, 'M', 'Q', 'T', 'T',
0x04,
0x00, // clean session=0 flags for skeleton; brokers may still accept
0x00, 0x3c,
}
// Actually clean session bit should be set for simple benches
vh[7] = 0x02 // Clean Session
id := []byte(clientID)
payload := make([]byte, 2+len(id))
binary.BigEndian.PutUint16(payload[0:2], uint16(len(id)))
copy(payload[2:], id)
remaining := len(vh) + len(payload)
pkt := make([]byte, 0, 2+remaining)
pkt = append(pkt, 0x10)
pkt = append(pkt, encodeRemainingLength(remaining)...)
pkt = append(pkt, vh...)
pkt = append(pkt, payload...)
return pkt
}
func encodeRemainingLength(n int) []byte {
var out []byte
for {
encoded := byte(n % 128)
n /= 128
if n > 0 {
encoded |= 0x80
}
out = append(out, encoded)
if n == 0 {
break
}
}
return out
}
+75
View File
@@ -0,0 +1,75 @@
package load
import (
"bytes"
"io"
"net"
"testing"
"time"
)
// 极简 MQTT broker:读 CONNECT,回 CONNACK accepted,保持连接。
func startFakeMQTTBroker(t *testing.T) (addr string, closeFn func()) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
done := make(chan struct{})
go func() {
for {
conn, err := ln.Accept()
if err != nil {
select {
case <-done:
return
default:
return
}
}
go func(c net.Conn) {
defer func() { _ = c.Close() }()
_ = c.SetDeadline(time.Now().Add(5 * time.Second))
buf := make([]byte, 256)
n, err := c.Read(buf)
if err != nil || n < 2 || buf[0] != 0x10 {
return
}
// CONNACK: type 0x20, remaining 2, flags 0, rc 0
_, _ = c.Write([]byte{0x20, 0x02, 0x00, 0x00})
_ = c.SetDeadline(time.Time{})
_, _ = io.Copy(io.Discard, c)
}(conn)
}
}()
return ln.Addr().String(), func() {
close(done)
_ = ln.Close()
}
}
func TestMQTTBenchConnectN(t *testing.T) {
addr, stop := startFakeMQTTBroker(t)
defer stop()
var out bytes.Buffer
b := &MQTTBench{Addr: addr, ClientIDPrefix: "q-t-", Timeout: 2 * time.Second}
defer b.Close()
if err := b.ConnectN(3, &out); err != nil {
t.Fatalf("ConnectN: %v", err)
}
if b.Alive() != 3 {
t.Fatalf("alive=%d", b.Alive())
}
got := out.String()
if !bytes.Contains(out.Bytes(), []byte("mqtt connections: 3")) {
t.Fatalf("output missing count: %q", got)
}
}
func TestMQTTBenchRequiresAddr(t *testing.T) {
b := &MQTTBench{}
if err := b.ConnectN(1, nil); err == nil {
t.Fatal("expected error")
}
}
+39
View File
@@ -0,0 +1,39 @@
package main
import (
"fmt"
"os"
"git.asio.asia/nixevol/NixMsg/test/report"
)
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: genreport <results.json> [out.md]")
os.Exit(2)
}
r, err := report.LoadResults(os.Args[1])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
items, err := report.Normalize(r)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
out := os.Stdout
if len(os.Args) >= 3 {
f, err := os.Create(os.Args[2])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
defer func() { _ = f.Close() }()
out = f
}
if err := report.WriteMarkdown(out, items); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+172
View File
@@ -0,0 +1,172 @@
// Package report 按 PRD 第 10 节 F01–F23 生成验收对照表。
package report
import (
"encoding/json"
"fmt"
"io"
"os"
"strings"
"time"
)
// Status 验收结果。
type Status string
const (
StatusPass Status = "pass"
StatusFail Status = "fail"
StatusUntested Status = "untested"
)
// Item 单条验收结果。
type Item struct {
ID string `json:"id"`
Status Status `json:"status"`
Note string `json:"note,omitempty"`
}
// Results 结果文件格式。
type Results struct {
GeneratedAt string `json:"generated_at,omitempty"`
Items []Item `json:"items"`
}
// Feature 验收总表一行。
type Feature struct {
ID string
Summary string
}
// Features 是 PRD 第 10 节 F01–F23。
var Features = []Feature{
{ID: "F01", Summary: "批量开通整批校验、停用、删除群主转让、删除后同编号重开不串数据"},
{ID: "F02", Summary: "新设备登录后旧设备自动退出、换 IP 用令牌重连、两种密码锁定、重置密码后被踢、服务器故障不误报密码错误"},
{ID: "F03", Summary: "断开后状态及时变离线,全表可列出"},
{ID: "F04", Summary: "只通知订阅了的端"},
{ID: "F05", Summary: "崩溃不丢已提交消息,消息号去重和冲突,密码门生效,配额生效"},
{ID: "F06", Summary: "群成员收到同一份,入群前不补,发送者不收到自己的"},
{ID: "F07", Summary: "256 KiB 通过,超出拒绝,接收上限生效"},
{ID: "F08", Summary: "弱网最终送达且应用层不重复,重启后续传"},
{ID: "F09", Summary: "保留时间从发送时刻起算,超时过期"},
{ID: "F10", Summary: "短断线送到,长断线丢弃,服务器重启后宽限内重连送到"},
{ID: "F11", Summary: "发送方离线后到点仍发送"},
{ID: "F12", Summary: "延迟窗口内撤回对方收不到"},
{ID: "F13", Summary: "未推送必撤成功;群部分确认得到部分撤回"},
{ID: "F14", Summary: "回执能补送给当时离线的发送方"},
{ID: "F15", Summary: "输一次记住、改密失效、回复免密、进群仍要密码、防多账号轮流猜"},
{ID: "F16", Summary: "群主权限、退出后不再收到、解散后同编号新群不收旧消息"},
{ID: "F17", Summary: "后台管端、管注册、管群、查记录,响应里没有正文;API 令牌可用且不能越权"},
{ID: "F18", Summary: "送达后正文消失;记录天数 0 时连记录消失;防重仍在"},
{ID: "F19", Summary: "四种 SDK 通过同一清单"},
{ID: "F20", Summary: "裸 MQTT 能登录、收、确认、发"},
{ID: "F21", Summary: "默认一个端口提供后台、WebSocket、TCP、注册;后台可分到单独端口"},
{ID: "F22", Summary: "初始化后单文件或 Docker 启动、备份恢复、升级迁移、证书自动重载、指标可抓取"},
{ID: "F23", Summary: "注册开关、安全码校验、换码不影响已注册、输错锁定"},
}
// LoadResults 读取结果 JSON;缺项视为未测。
func LoadResults(path string) (Results, error) {
data, err := os.ReadFile(path)
if err != nil {
return Results{}, err
}
var r Results
if err := json.Unmarshal(data, &r); err != nil {
return Results{}, fmt.Errorf("parse results: %w", err)
}
return r, nil
}
// Normalize 把结果补全为 F01–F23;非法 status 报错。
func Normalize(r Results) ([]Item, error) {
byID := make(map[string]Item, len(r.Items))
for _, it := range r.Items {
id := strings.ToUpper(strings.TrimSpace(it.ID))
st, err := parseStatus(it.Status)
if err != nil {
return nil, fmt.Errorf("%s: %w", id, err)
}
it.ID = id
it.Status = st
byID[id] = it
}
out := make([]Item, 0, len(Features))
for _, f := range Features {
if it, ok := byID[f.ID]; ok {
out = append(out, it)
continue
}
out = append(out, Item{ID: f.ID, Status: StatusUntested, Note: "结果文件未提供"})
}
return out, nil
}
func parseStatus(s Status) (Status, error) {
switch Status(strings.ToLower(string(s))) {
case StatusPass, "通过", "ok", "passed":
return StatusPass, nil
case StatusFail, "失败", "failed":
return StatusFail, nil
case StatusUntested, "未测", "skip", "skipped", "":
return StatusUntested, nil
default:
return "", fmt.Errorf("unknown status %q", s)
}
}
func statusCN(s Status) string {
switch s {
case StatusPass:
return "通过"
case StatusFail:
return "失败"
default:
return "未测"
}
}
// WriteMarkdown 写出 F01–F23 对照表。
func WriteMarkdown(w io.Writer, items []Item) error {
summary := map[Status]int{}
byID := make(map[string]Item, len(items))
for _, it := range items {
byID[it.ID] = it
summary[it.Status]++
}
if _, err := fmt.Fprintf(w, "# NixMsg 验收对照表(PRD 第 10 节)\n\n"); err != nil {
return err
}
if _, err := fmt.Fprintf(w, "生成时间:%s\n\n", time.Now().UTC().Format(time.RFC3339)); err != nil {
return err
}
if _, err := fmt.Fprintf(w, "汇总:通过 %d,失败 %d,未测 %d\n\n", summary[StatusPass], summary[StatusFail], summary[StatusUntested]); err != nil {
return err
}
if _, err := fmt.Fprintf(w, "| 编号 | 一句话 | 结果 | 备注 |\n|---|---|---|---|\n"); err != nil {
return err
}
for _, f := range Features {
it := byID[f.ID]
note := it.Note
if note == "" {
note = "—"
}
if _, err := fmt.Fprintf(w, "| %s | %s | %s | %s |\n", f.ID, f.Summary, statusCN(it.Status), note); err != nil {
return err
}
}
return nil
}
// EmptyResults 返回全部未测的样例。
func EmptyResults() Results {
items := make([]Item, 0, len(Features))
for _, f := range Features {
items = append(items, Item{ID: f.ID, Status: StatusUntested, Note: ""})
}
return Results{
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
Items: items,
}
}
+89
View File
@@ -0,0 +1,89 @@
package report
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestNormalizeFillsMissingAsUntested(t *testing.T) {
t.Parallel()
items, err := Normalize(Results{Items: []Item{
{ID: "F01", Status: StatusPass, Note: "ok"},
{ID: "f08", Status: "失败", Note: "flaky"},
}})
if err != nil {
t.Fatal(err)
}
if len(items) != 23 {
t.Fatalf("len=%d", len(items))
}
if items[0].Status != StatusPass || items[0].Note != "ok" {
t.Fatalf("F01=%+v", items[0])
}
if items[7].Status != StatusFail {
t.Fatalf("F08=%+v", items[7])
}
if items[22].Status != StatusUntested {
t.Fatalf("F23=%+v", items[22])
}
}
func TestWriteMarkdownSample(t *testing.T) {
t.Parallel()
path := filepath.Join("testdata", "sample_results.json")
r, err := LoadResults(path)
if err != nil {
t.Fatal(err)
}
items, err := Normalize(r)
if err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
if err := WriteMarkdown(&buf, items); err != nil {
t.Fatal(err)
}
out := buf.String()
for _, id := range []string{"F01", "F12", "F23"} {
if !strings.Contains(out, id) {
t.Fatalf("missing %s in:\n%s", id, out)
}
}
if !strings.Contains(out, "未测") {
t.Fatalf("expected 未测:\n%s", out)
}
if !strings.Contains(out, "通过 0,失败 0,未测 23") {
t.Fatalf("summary mismatch:\n%s", out)
}
}
func TestEmptyResultsRoundTrip(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "empty.json")
raw, err := json.MarshalIndent(EmptyResults(), "", " ")
if err != nil {
t.Fatal(err)
}
if writeErr := os.WriteFile(path, raw, 0o644); writeErr != nil {
t.Fatal(writeErr)
}
r, err := LoadResults(path)
if err != nil {
t.Fatal(err)
}
if len(r.Items) != 23 {
t.Fatalf("items=%d", len(r.Items))
}
}
func TestParseStatusRejectsUnknown(t *testing.T) {
t.Parallel()
if _, err := Normalize(Results{Items: []Item{{ID: "F01", Status: "maybe"}}}); err == nil {
t.Fatal("expected error")
}
}
+28
View File
@@ -0,0 +1,28 @@
{
"generated_at": "2026-09-30T00:00:00Z",
"items": [
{"id": "F01", "status": "untested", "note": ""},
{"id": "F02", "status": "untested", "note": ""},
{"id": "F03", "status": "untested", "note": ""},
{"id": "F04", "status": "untested", "note": ""},
{"id": "F05", "status": "untested", "note": ""},
{"id": "F06", "status": "untested", "note": ""},
{"id": "F07", "status": "untested", "note": ""},
{"id": "F08", "status": "untested", "note": ""},
{"id": "F09", "status": "untested", "note": ""},
{"id": "F10", "status": "untested", "note": ""},
{"id": "F11", "status": "untested", "note": ""},
{"id": "F12", "status": "untested", "note": ""},
{"id": "F13", "status": "untested", "note": ""},
{"id": "F14", "status": "untested", "note": ""},
{"id": "F15", "status": "untested", "note": ""},
{"id": "F16", "status": "untested", "note": ""},
{"id": "F17", "status": "untested", "note": ""},
{"id": "F18", "status": "untested", "note": ""},
{"id": "F19", "status": "untested", "note": ""},
{"id": "F20", "status": "untested", "note": ""},
{"id": "F21", "status": "untested", "note": ""},
{"id": "F22", "status": "untested", "note": ""},
{"id": "F23", "status": "untested", "note": ""}
]
}